// "Pull": the live store texts back into the local sources, as pure merges. The commands (apple listing pull, play // listing pull, catalog pull) read the stores, call these, print the diff and write only with --write. // - App Store: appInfoLocalizations (name, subtitle) + appStoreVersionLocalizations (description, keywords, // promotionalText, whatsNew) -> store/appstore.md, one "## " section per locale, "### " blocks. // - Google Play: edits.listings (title, shortDescription, fullDescription) -> store/listing.md sections with // "". A section serving several Play codes is only changed when they agree on the store. // - In-app purchases and subscriptions: the store texts as a proposal for storebound.config.ts (a TypeScript file is // never rewritten by a tool): the diff per product and language, and a JSON proposal file with --write. // Only fields the store has a value for are written; a local field the store leaves empty is kept. import type { AppleCopy, AppleField } from './copy'; import { APPLE_LIMITS, len, PLAY_LIMITS } from './copy'; import type { Language, ShipConfig } from './config'; import { playCodes } from './config'; export interface FieldChange { section: string; field: string; from: string | null; to: string } export interface MergeResult { md: string; changes: FieldChange[]; added: string[]; conflicts: string[] } const fence = (t: string): string => '```\n' + t + '\n```'; const BLOCK = (heading: RegExp) => new RegExp(`(^### (?:${heading.source})[^\\n]*\\n)\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n?\`\`\``, 'm' + (heading.flags.includes('i') ? 'i' : '')); /** Replace (or append) the fenced block under the first "### " heading matching `heading` in one section. */ function setBlock(sec: string, heading: RegExp, newHeading: string, text: string): { sec: string; from: string | null } { const re = BLOCK(heading); const m = re.exec(sec); if (m) return { sec: sec.replace(re, (_a, h: string) => h + fence(text)), from: m[2]!.trim() }; const body = sec.replace(/\s*$/, ''); return { sec: `${body}\n\n### ${newHeading}\n${fence(text)}\n\n`, from: null }; } const APPLE_ORDER: AppleField[] = ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew']; /** Live App Store texts per locale merged into the App Store listing file. A name equal to `defaultName` (apple.name, * else app.name) is only written where the section already has a name block. Pure. */ export function mergeAppleListing(md: string, remote: Record, defaultName?: string): MergeResult { const parts = md.split(/^(?=## )/m); const res: MergeResult = { md: '', changes: [], added: [], conflicts: [] }; const seen = new Set(); const out = parts.map(sec => { if (!sec.startsWith('## ')) return sec; const loc = sec.slice(3).split(/[\s·]/)[0]!.trim(); const r = remote[loc]; if (!r) return sec; seen.add(loc); for (const f of APPLE_ORDER) { const v = r[f]; if (v === undefined || v === null || v === '') continue; if (f === 'name' && v === defaultName && !BLOCK(/name\b/).test(sec)) continue; const { sec: next, from } = setBlock(sec, new RegExp(`${f}\\b`), f, v); if (from !== v.trim()) res.changes.push({ section: loc, field: f, from, to: v }); sec = next; } return sec; }); let text = out.join(''); for (const [loc, r] of Object.entries(remote).sort(([a], [b]) => a.localeCompare(b))) { if (seen.has(loc)) continue; const fields = APPLE_ORDER.filter(f => r[f] && !(f === 'name' && r[f] === defaultName)); if (!fields.length) continue; res.added.push(loc); text = text.replace(/\s*$/, '') + `\n\n## ${loc}\n\n` + fields.map(f => `### ${f}\n${fence(r[f]!)}`).join('\n\n') + '\n'; for (const f of fields) res.changes.push({ section: loc, field: f, from: null, to: r[f]! }); } res.md = text.endsWith('\n') ? text : text + '\n'; return res; } export interface PlayRemote { language: string; title?: string; shortDescription?: string; fullDescription?: string } const PLAY_FIELDS: { key: keyof PlayRemote; re: RegExp; heading: string; limit: number }[] = [ { key: 'title', re: /[^\n]*\btitle\b/i, heading: `Title (max ${PLAY_LIMITS.title})`, limit: PLAY_LIMITS.title }, { key: 'shortDescription', re: /[^\n]*\bshort\b/i, heading: `Short description (max ${PLAY_LIMITS.short})`, limit: PLAY_LIMITS.short }, { key: 'fullDescription', re: /[^\n]*\bfull\b/i, heading: `Full description (max ${PLAY_LIMITS.full})`, limit: PLAY_LIMITS.full }, ]; /** Live Play listings merged into the Play listing file. A title equal to `defaultTitle` (app.name) is only written * where the section already has a title block. Pure. */ export function mergePlayListing(md: string, remote: PlayRemote[], defaultTitle: string): MergeResult { const byLang = new Map(remote.map(l => [l.language, l] as const)); const res: MergeResult = { md: '', changes: [], added: [], conflicts: [] }; const seen = new Set(); const parts = md.split(/^(?=## )/m).map(sec => { const m = /^$/m.exec(sec); if (!m) return sec; const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean); const hits = codes.map(c => byLang.get(c)).filter((x): x is PlayRemote => !!x); for (const c of codes) if (byLang.has(c)) seen.add(c); if (!hits.length) return sec; for (const f of PLAY_FIELDS) { const values = [...new Set(hits.map(h => (h[f.key] ?? '').trim()).filter(Boolean))]; if (!values.length) continue; if (values.length > 1) { res.conflicts.push(`${codes.join(', ')}: ${f.key} differs between these Play languages on the store; left as it is (give each its own section)`); continue; } const v = values[0]!; const has = BLOCK(f.re).test(sec); if (f.key === 'title' && !has && v === defaultTitle) continue; const { sec: next, from } = setBlock(sec, f.re, f.heading, v); if (from !== v) res.changes.push({ section: codes.join(', '), field: f.key, from, to: v }); sec = next; } return sec; }); let text = parts.join(''); for (const l of [...remote].sort((a, b) => a.language.localeCompare(b.language))) { if (seen.has(l.language)) continue; res.added.push(l.language); const blocks = PLAY_FIELDS.filter(f => l[f.key] && !(f.key === 'title' && l.title === defaultTitle)).map(f => `### ${f.heading}\n${fence(String(l[f.key]))}`); text = text.replace(/\s*$/, '') + `\n\n## ${l.language}\n\n\n${blocks.join('\n\n')}\n`; for (const f of PLAY_FIELDS) if (l[f.key]) res.changes.push({ section: l.language, field: f.key, from: null, to: String(l[f.key]) }); } res.md = text.endsWith('\n') ? text : text + '\n'; return res; } /** Store values over a store limit (the pull still writes them; `check` will flag them). */ export function overLimits(changes: FieldChange[], store: 'apple' | 'play'): string[] { const lim: Record = store === 'apple' ? APPLE_LIMITS : { title: PLAY_LIMITS.title, shortDescription: PLAY_LIMITS.short, fullDescription: PLAY_LIMITS.full }; return changes.filter(c => lim[c.field] !== undefined && len(c.to) > lim[c.field]!).map(c => `${c.section} ${c.field}: ${len(c.to)} > ${lim[c.field]}`); } // ---- in-app purchases and subscriptions -> config proposal ---- export interface RemoteText { title: string; description: string; benefits?: string[] } /** Store texts per product id and store language code (App Store locale or Play language code). */ export type RemoteCatalog = Record>; export interface CatalogProposal { /** product id -> language id -> the store's texts (only where they differ from the config or are missing there). */ listings: Record>; changes: { id: string; lang: string; field: string; from: string | null; to: string; store: 'apple' | 'play' }[]; /** Store languages the config has no language entry for (not mapped). */ unmapped: string[]; /** Products on the store that are not in the config. */ unknown: string[]; } /** Store language code -> config language id (App Store locale or any Play code of the entry). */ export function langIdOf(langs: Language[], store: 'apple' | 'play', code: string): string | undefined { return langs.find(l => (store === 'apple' ? l.apple === code : playCodes(l).includes(code)))?.id; } /** The store texts of in-app purchases and subscriptions against the config's listings. Apple wins over Play when * both differ for the same language (shown as two changes). Pure. */ export function catalogProposal(config: Pick, remote: { apple?: RemoteCatalog; play?: RemoteCatalog }): CatalogProposal { const local = new Map>(); for (const p of config.products ?? []) local.set(p.id, p.listings); for (const g of config.subscriptions ?? []) for (const s of g.subscriptions) local.set(s.id, s.listings); const out: CatalogProposal = { listings: {}, changes: [], unmapped: [], unknown: [] }; for (const store of ['play', 'apple'] as const) { for (const [id, byCode] of Object.entries(remote[store] ?? {})) { const mine = local.get(id); if (!mine) { if (!out.unknown.includes(id)) out.unknown.push(id); continue; } for (const [code, t] of Object.entries(byCode)) { const lang = langIdOf(config.languages, store, code); if (!lang) { const u = `${store}:${code}`; if (!out.unmapped.includes(u)) out.unmapped.push(u); continue; } const cur = mine[lang]; const fields: [string, string | null, string][] = [['title', cur?.title ?? null, t.title], ['description', cur?.description ?? null, t.description]]; if (store === 'play' && t.benefits?.length) fields.push(['benefits', cur?.benefits?.join(' | ') ?? null, t.benefits.join(' | ')]); const diff = fields.filter(([, from, to]) => to && from !== to); if (!diff.length) continue; for (const [field, from, to] of diff) out.changes.push({ id, lang, field, from, to, store }); const prev = out.listings[id]?.[lang] ?? { title: cur?.title ?? t.title, description: cur?.description ?? t.description, ...(cur?.benefits ? { benefits: cur.benefits } : {}) }; out.listings[id] = { ...out.listings[id], [lang]: { ...prev, title: t.title || prev.title, description: t.description || prev.description, ...(store === 'play' && t.benefits?.length ? { benefits: t.benefits } : {}) } }; } } } return out; }