// Offline reads of a project's own files for the desktop app (serve.ts): the app icon, listing completeness per // language on both stores, and the local review reply drafts. Tolerant on purpose: a broken file becomes a problem // line, never an exception, so one bad section does not hide the rest ("unknown, never false"). import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { extname, isAbsolute, join, resolve } from 'node:path'; import { appleName, type ShipConfig, playCodes } from './config'; import { APPLE_LIMITS, len, PLAY_LIMITS } from './copy'; import type { DraftsResult, IconResult, ListingField, ListingLocale, ListingsResult, ListingText, RenderImage, ReplyDraft } from './desktop-protocol'; import { parseFrontMatter, stringifyFrontMatter } from './frontmatter'; import { checkReply, parseDraft } from './reviews'; const IMAGE = /\.(png|jpe?g|webp|svg)$/i; export const ICON_MAX_BYTES = 1_500_000; /** Icon candidates, most specific first: icons.composite, the rendered store icons, play.icon, the usual places. */ export function iconCandidates(config: Pick, root: string): string[] { const r = (p: string) => (isAbsolute(p) ? p : resolve(root, p)); const out: string[] = []; if (config.icons?.composite) out.push(r(config.icons.composite)); if (config.icons?.out) out.push(r(join(config.icons.out, 'app-store-1024.png')), r(join(config.icons.out, 'play-512.png'))); if (config.play?.icon) out.push(r(config.play.icon)); for (const p of ['store/icons/app-store-1024.png', 'store/icons/play-512.png', 'store/icon.png', 'store/icon-1024.png', 'icon.png', 'assets/icon.png', 'resources/icon.png', 'assets/icon-only.png', 'public/icon-512.png', 'web/icon-512.png']) out.push(r(p)); return [...new Set(out)]; } /** First candidate that exists and is an image file, or null. */ export function findIcon(config: Pick, root: string): string | null { for (const p of iconCandidates(config, root)) { try { if (IMAGE.test(p) && statSync(p).isFile()) return p; } catch { /* next */ } } return null; } const MIME: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml' }; /** The icon as a data: URL for the desktop window (its CSP allows data: images only). Too big or unreadable -> null. */ export function iconDataUrl(path: string | null): IconResult { if (!path) return { path: null, dataUrl: null }; try { const st = statSync(path); if (!st.isFile() || st.size > ICON_MAX_BYTES) return { path, dataUrl: null }; const mime = MIME[extname(path).toLowerCase()]; if (!mime) return { path, dataUrl: null }; return { path, dataUrl: `data:${mime};base64,${readFileSync(path).toString('base64')}` }; } catch { return { path, dataUrl: null }; } } /** "### heading" sub-sections of a "## " section with their first fenced block (like lib/copy.ts, never throws). */ function blocks(section: string): { heading: string; text: string }[] { const out: { heading: string; text: string }[] = []; for (const part of section.split(/^### /m).slice(1)) { const heading = part.split('\n')[0]!.trim(); const m = /^```[^\n]*\n([\s\S]*?)\n```/m.exec(part); out.push({ heading, text: m ? m[1]!.trim() : '' }); } return out; } function field(name: string, text: string | undefined, limit: number, required: boolean): ListingField { const n = text ? len(text) : 0; const state: ListingField['state'] = !n ? (required ? 'missing' : 'empty-optional') : n > limit ? 'over' : 'ok'; return { field: name, length: n, limit, required, state }; } /** Play sections by Play language code: { title, short, full, whatsNew } texts. */ export function playSections(md: string): Map> { const out = new Map>(); for (const sec of md.split(/^## /m).slice(1)) { const m = /^$/m.exec(sec); if (!m) continue; const bs = blocks(sec); const find = (re: RegExp): string | undefined => bs.find(b => re.test(b.heading))?.text; const f = { title: find(/\btitle\b/i), short: find(/\bshort\b/i), full: find(/\bfull\b/i), whatsNew: find(/what'?s new|release notes/i) }; for (const lang of m[1]!.split(',').map(s => s.trim()).filter(Boolean)) out.set(lang, f); } return out; } /** App Store sections by App Store Connect locale: field -> text. */ export function appleSections(md: string): Map> { const out = new Map>(); for (const sec of md.split(/^## /m).slice(1)) { const locale = sec.split(/[\s·]/)[0]!.trim(); if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(locale)) continue; const f: Record = {}; for (const b of blocks(sec)) if (b.heading in APPLE_LIMITS) f[b.heading] = b.text; out.set(locale, f); } return out; } /** Store images of one language: the images in its size folders (//...). Files right in the language * folder (a contact sheet, a preview collage) are not store images and are not counted, like the gallery. */ export function countImages(dir: string, depth = 0): number { let n = 0; try { for (const e of readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory()) n += countImages(join(dir, e.name), depth + 1); else if (depth > 0 && /\.(png|jpe?g)$/i.test(e.name)) n++; } } catch { /* unreadable: 0 */ } return n; } /** Listing completeness per config language, both stores, plus rendered screenshots per language. */ export function listingCompleteness(config: ShipConfig, root: string): ListingsResult { const problems: string[] = []; const read = (rel: string | undefined, fallback: string): { path: string | null; text: string | null } => { const p = resolve(root, rel ?? fallback); if (!existsSync(p)) { problems.push(`${rel ?? fallback}: file not found`); return { path: null, text: null }; } try { return { path: p, text: readFileSync(p, 'utf8') }; } catch (e) { problems.push(`${rel ?? fallback}: ${(e as Error).message}`); return { path: p, text: null }; } }; const playFile = config.play ? read(config.play.listing, 'store/listing.md') : { path: null, text: null }; const appleFile = config.apple ? read(config.apple.listing, 'store/appstore.md') : { path: null, text: null }; const play = playFile.text !== null ? playSections(playFile.text) : null; const apple = appleFile.text !== null ? appleSections(appleFile.text) : null; const rendersDir = resolve(root, config.renders ?? config.shots?.out ?? 'store/renders'); const hasRenders = existsSync(rendersDir); const locales: ListingLocale[] = config.languages.map(l => { let req = 0, good = 0; const tally = (fs: ListingField[]) => { for (const f of fs) if (f.required) { req++; if (f.state === 'ok') good++; } return fs; }; const pc = config.play ? playCodes(l)[0] ?? null : null; const ps = pc && play ? play.get(pc) : undefined; const playPart = config.play ? { code: pc, fields: tally([ field('title', ps?.title ?? (ps ? config.app.name : undefined), PLAY_LIMITS.title, true), field('short', ps?.short, PLAY_LIMITS.short, true), field('full', ps?.full, PLAY_LIMITS.full, true), field('whatsNew', ps?.whatsNew, PLAY_LIMITS.whatsNew, false), ]), } : null; const ac = config.apple ? l.apple ?? null : null; const as = ac && apple ? apple.get(ac) : undefined; const applePart = config.apple ? { code: ac, fields: tally([ field('name', as?.name ?? (as ? appleName(config) : undefined), APPLE_LIMITS.name, true), field('subtitle', as?.subtitle, APPLE_LIMITS.subtitle, true), field('promotionalText', as?.promotionalText, APPLE_LIMITS.promotionalText, true), field('keywords', as?.keywords, APPLE_LIMITS.keywords, true), field('description', as?.description, APPLE_LIMITS.description, true), field('whatsNew', as?.whatsNew, APPLE_LIMITS.whatsNew, false), ]), } : null; const screenshots = hasRenders ? countImages(join(rendersDir, l.id)) : null; return { lang: l.id, rtl: !!l.rtl, play: playPart, apple: applePart, screenshots, complete: req ? good / req : 0 }; }); return { files: { play: playFile.path, apple: appleFile.path }, problems, locales }; } /** The listing files as they are (the desktop export), or null where the config has no store / the file is missing. */ export function listingText(config: ShipConfig, root: string): ListingText { const one = (rel: string): { path: string; text: string } | null => { const p = resolve(root, rel); try { return { path: p, text: readFileSync(p, 'utf8') }; } catch { return null; } }; return { play: config.play ? one(config.play.listing ?? 'store/listing.md') : null, apple: config.apple ? one(config.apple.listing ?? 'store/appstore.md') : null }; } export const RENDER_MAX_BYTES = 12_000_000; /** A render path as the gallery data names it: relative, no "..", an image. */ export const RENDER_FILE_RE = /^(?!.*(^|\/)\.\.?(\/|$))(?!\/)[A-Za-z0-9._ @+()/-]{1,300}\.(png|jpe?g|webp)$/i; /** One render (a path relative to the renders folder) as a data: URL for the desktop gallery; null when it is not * an image inside the folder or is bigger than RENDER_MAX_BYTES. */ export function renderImage(rendersDir: string, file: string): RenderImage { if (!RENDER_FILE_RE.test(file)) return { file, dataUrl: null, bytes: 0 }; const base = resolve(rendersDir), p = resolve(base, file); if (!p.startsWith(base + '/') && !p.startsWith(base + '\\')) return { file, dataUrl: null, bytes: 0 }; try { const st = statSync(p); const mime = MIME[extname(p).toLowerCase()]; if (!st.isFile() || !mime || st.size > RENDER_MAX_BYTES) return { file, dataUrl: null, bytes: st.size }; return { file, dataUrl: `data:${mime};base64,${readFileSync(p).toString('base64')}`, bytes: st.size }; } catch { return { file, dataUrl: null, bytes: 0 }; } } /** Local reply drafts with their send checks. Unparsable files become problems. */ export function readReplyDrafts(dir: string): DraftsResult { const out: DraftsResult = { dir, drafts: [], problems: [] }; if (!existsSync(dir)) return out; for (const name of readdirSync(dir).filter(n => n.endsWith('.md')).sort()) { const file = join(dir, name); try { const text = readFileSync(file, 'utf8'); const d = parseDraft(file, text); const { data, body } = parseFrontMatter(text); const quote = body.split(/^## Reply\s*$/m)[0]!.split('\n').filter(l => l.startsWith('>')).map(l => l.replace(/^>\s?/, '')).join('\n').trim(); const num = (v: unknown): number | null => (typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : null); const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null); const draft: ReplyDraft = { file, store: d.store, reviewId: d.reviewId, approved: d.approved, sent: d.sent, reply: d.reply, rating: num(data.rating), title: str(data.title) ?? (/^# [★☆]*\s*(.+)$/m.exec(body)?.[1]?.trim() || null), body: quote || null, date: str(data.date), issues: d.reply ? checkReply(d.reply, d.store) : [], }; out.drafts.push(draft); } catch (e) { out.problems.push(`${name}: ${(e as Error).message}`); } } return out; } /** The "## Reply" section of a draft set to `reply` (everything after the heading is replaced); `approved` goes back * to false, since an edited reply needs a fresh approval. Sent drafts are not edited. */ export function setReplyText(text: string, reply: string): string { const { data, body } = parseFrontMatter(text); if (data.store !== 'apple' && data.store !== 'play') throw new Error('not a review reply draft (front matter "store" missing)'); if (typeof data.sent === 'string' && data.sent) throw new Error('this reply was sent already: edit it in the store console'); const clean = reply.replace(/\r\n?/g, '\n').replace(//g, '').trim(); const at = body.search(/^## Reply\s*$/m); const head = at < 0 ? `${body.trimEnd()}\n\n` : body.slice(0, at); return stringifyFrontMatter({ ...data, approved: false }, `${head}## Reply\n\n${clean}\n`); } /** Front matter `approved: ` set in a draft file text; the body unchanged. */ export function setApproved(text: string, approved: boolean): string { const { data, body } = parseFrontMatter(text); if (data.store !== 'apple' && data.store !== 'play') throw new Error('not a review reply draft (front matter "store" missing)'); return stringifyFrontMatter({ ...data, approved }, body); }