166 lines
10 KiB
TypeScript
166 lines
10 KiB
TypeScript
// "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 "## <locale>" section per locale, "### <field>" blocks.
|
|
// - Google Play: edits.listings (title, shortDescription, fullDescription) -> store/listing.md sections with
|
|
// "<!-- play: <codes> -->". 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<string, AppleCopy>, defaultName?: string): MergeResult {
|
|
const parts = md.split(/^(?=## )/m);
|
|
const res: MergeResult = { md: '', changes: [], added: [], conflicts: [] };
|
|
const seen = new Set<string>();
|
|
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<string>();
|
|
const parts = md.split(/^(?=## )/m).map(sec => {
|
|
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/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<!-- play: ${l.language} -->\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<string, number> = 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<string, Record<string, RemoteText>>;
|
|
|
|
export interface CatalogProposal {
|
|
/** product id -> language id -> the store's texts (only where they differ from the config or are missing there). */
|
|
listings: Record<string, Record<string, { title: string; description: string; benefits?: string[] }>>;
|
|
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<ShipConfig, 'languages' | 'products' | 'subscriptions'>, remote: { apple?: RemoteCatalog; play?: RemoteCatalog }): CatalogProposal {
|
|
const local = new Map<string, Record<string, { title: string; description: string; benefits?: string[] }>>();
|
|
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;
|
|
}
|