// Write plans for the listing commands (apple listing, play listing, apple appinfo): what the store has now, what a // run would send, field by field (old -> new). The dry run prints the plan; the real run applies exactly that plan. // Pure: the commands read the store, this file decides and formats. import { EDITABLE } from '../apple/asc'; import { appleLocaleOf, markerOf, playCodesOf, sections, type TStatus } from './listing-translate'; export interface FieldDiff { field: string; from: string | null; to: string | null } /** One localization (or one settings object): create, update the changed fields, or leave it. */ export interface ItemPlan { key: string; action: 'create' | 'update' | 'same'; id?: string; changes: FieldDiff[]; unchanged: string[]; attributes: Record } export interface Skipped { key: string; reason: string } const norm = (v: unknown): string | null => (v === undefined || v === null || v === '' ? null : String(v)); /** Create / update / same for one wanted attribute set against the current one (null when it does not exist). Only * defined wanted values count; an empty and a missing value are the same. Pure. */ export function planItem(key: string, current: { id?: string; attributes: Record } | null, want: Record): ItemPlan { const attributes = Object.fromEntries(Object.entries(want).filter((e): e is [string, string] => e[1] !== undefined)); if (!current) return { key, action: 'create', changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), unchanged: [], attributes }; const changes: FieldDiff[] = [], unchanged: string[] = []; for (const [field, to] of Object.entries(attributes)) { const from = norm(current.attributes[field]); if (from === norm(to)) unchanged.push(field); else changes.push({ field, from, to }); } return { key, action: changes.length ? 'update' : 'same', ...(current.id ? { id: current.id } : {}), changes, unchanged, attributes }; } /** Localizations: every wanted locale against the ones the store has (by `locale`). Pure. */ export function planLocales(have: { id: string; attributes: Record }[], want: Record>): ItemPlan[] { const byLocale = new Map(have.map(h => [String(h.attributes.locale), h] as const)); return Object.entries(want).map(([locale, w]) => planItem(locale, byLocale.get(locale) ?? null, w)); } // ---- App Store version ---- export interface VersionInfo { id: string; versionString: string; state: string; platform: string } export interface VersionPlan { action: 'create' | 'update' | 'keep' | 'refuse'; /** The editable version that is kept or updated. */ id?: string; reason: string; existing: VersionInfo[]; changes: FieldDiff[]; attributes: Record; /** No version of the app went through review before: what's new cannot be set on the first version. */ first: boolean; } /** Versions App Store Connect still lets you edit (texts, build, version string). */ export const EDITABLE_STATES = EDITABLE; /** A version in these states blocks a new one (App Store Connect allows one version in progress). */ const IN_PROGRESS = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW', 'READY_FOR_REVIEW', 'WAITING_FOR_EXPORT_COMPLIANCE', 'PENDING_APPLE_RELEASE', 'PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_APP_STORE', 'PROCESSING_FOR_DISTRIBUTION', 'ACCEPTED']); /** -1 / 0 / 1 by numeric segments ("1.0" < "1.0.1", "1.10" > "1.9"). */ export function compareVersions(a: string, b: string): number { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] ?? 0) - (y[i] ?? 0); if (d) return d < 0 ? -1 : 1; } return 0; } /** Version rows of GET /v1/apps/{id}/appStoreVersions. */ export const versionInfos = (rows: { id: string; attributes: Record }[]): VersionInfo[] => rows.map(r => ({ id: r.id, versionString: String(r.attributes.versionString ?? ''), state: String(r.attributes.appVersionState ?? r.attributes.appStoreState ?? ''), platform: String(r.attributes.platform ?? 'IOS') })); /** * What `apple listing --version ` does with the app's iOS versions: keep / update the editable one (its version * string changes only while PREPARE_FOR_SUBMISSION), create a new one, or refuse (the number exists, is not higher than * a version that went through review, or another version is in review / pending release). Pure. */ export function planVersion(rows: { id: string; attributes: Record }[], want: string, attrs: { copyright: string; releaseType: string }): VersionPlan { const existing = versionInfos(rows); const editable = existing.find(v => EDITABLE_STATES.has(v.state)); const others = existing.filter(v => v !== editable); const first = !others.length; const base = { existing, first, changes: [] as FieldDiff[], attributes: {} as Record }; const refuse = (reason: string): VersionPlan => ({ ...base, action: 'refuse', reason }); const taken = others.find(v => v.versionString === want); if (taken) return refuse(`version ${want} already exists (${taken.state}); a new version needs a higher number`); const higher = others.filter(v => compareVersions(v.versionString, want) >= 0).sort((a, b) => compareVersions(b.versionString, a.versionString))[0]; if (higher && (!editable || editable.versionString !== want)) return refuse(`version ${want} is not higher than ${higher.versionString} (${higher.state}); App Store versions only go up`); if (editable) { const cur = rows.find(r => r.id === editable.id)!.attributes; const wantAttrs = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType }; const p = planItem(editable.id, { id: editable.id, attributes: cur }, wantAttrs); if (editable.versionString !== want && editable.state !== 'PREPARE_FOR_SUBMISSION') return refuse(`the editable version ${editable.versionString} is ${editable.state}; its version string changes only while PREPARE_FOR_SUBMISSION (use --version ${editable.versionString}, or change it in App Store Connect)`); if (editable.versionString !== want) return { ...base, action: 'update', id: editable.id, changes: p.changes, attributes: p.attributes, reason: `update the editable version ${editable.versionString} (${editable.state}) to ${want}` }; return { ...base, action: p.changes.length ? 'update' : 'keep', id: editable.id, changes: p.changes, attributes: p.attributes, reason: p.changes.length ? `update version ${want} (${editable.state}): ${p.changes.map(c => c.field).join(', ')}` : `version ${want} (${editable.state}) is up to date` }; } const busy = others.find(v => IN_PROGRESS.has(v.state)); if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (wait for its release or remove it from review)`); const attributes = { platform: 'IOS', versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType }; return { ...base, action: 'create', reason: `create version ${want} (no editable iOS version)`, changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes }; } // ---- translation drafts ---- /** Locales (App Store) or Play language codes whose section is a translation draft or still untranslated, with why. */ export function draftSections(md: string, store: 'apple' | 'play'): Map { const out = new Map(); for (const sec of sections(md)) { const m = markerOf(sec); if (!m) continue; const keys = store === 'apple' ? [appleLocaleOf(sec)].filter((x): x is string => !!x) : playCodesOf(sec); for (const k of keys) out.set(k, m.status); } return out; } export const draftReason = (s: TStatus): string => s === 'draft' ? 'machine-translated draft, not reviewed (storebound listing check, then listing reviewed --lang ; or --include-drafts)' : 'not translated yet (status=translate: still the source text; translate it, or --include-drafts)'; // ---- formatting ---- /** One value for a plan line: quoted, long text cut with its length. */ export function short(v: string | null, max = 60): string { if (v === null) return '(none)'; const flat = v.replace(/\s*\n\s*/g, ' ⏎ '); const n = [...v].length; return [...flat].length > max ? `"${[...flat].slice(0, max).join('')}…" (${n} chars)` : `"${flat}"`; } /** Plan lines of one item: a header and one line per changed field; unchanged fields summarized. */ export function itemLines(label: string, p: ItemPlan, indent = ' '): string[] { if (p.action === 'same') return [`${indent}${label}: unchanged (${p.unchanged.length} field${p.unchanged.length === 1 ? '' : 's'})`]; const head = `${indent}${label}: ${p.action === 'create' ? 'create' : 'update'}${p.unchanged.length ? ` (${p.unchanged.length} unchanged: ${p.unchanged.join(', ')})` : ''}`; return [head, ...p.changes.map(c => `${indent} ${c.field}: ${p.action === 'create' ? short(c.to) : `${short(c.from)} -> ${short(c.to)}`}`)]; } export const counts = (items: ItemPlan[]): string => { const n = (a: ItemPlan['action']) => items.filter(i => i.action === a).length; return `${n('create')} create, ${n('update')} update, ${n('same')} unchanged`; };