137 lines
9.2 KiB
TypeScript
137 lines
9.2 KiB
TypeScript
// 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<string, string> }
|
|
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<string, unknown> } | null, want: Record<string, string | undefined>): 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<string, unknown> }[], want: Record<string, Record<string, string | undefined>>): 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<string, string>;
|
|
/** 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<string, unknown> }[]): 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 <want>` 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<string, unknown> }[], 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<string, string> };
|
|
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<string, TStatus> {
|
|
const out = new Map<string, TStatus>();
|
|
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 <id>; 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`;
|
|
};
|