149 lines
11 KiB
TypeScript
149 lines
11 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;
|
|
}
|
|
|
|
const PLATFORM_LABEL: Record<string, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
|
|
|
|
/** 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; earliestReleaseDate?: string }, platform = 'IOS'): 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);
|
|
// the version asked for is in review / pending release: it exists and is locked; "needs a higher number" would send
|
|
// the owner off to make a second version while the first is still in review
|
|
if (taken && IN_PROGRESS.has(taken.state))
|
|
return refuse(`version ${want} is ${taken.state}: App Store Connect locks its texts and build while it is in review or pending release (remove it from review in App Store Connect to change it, or wait for the release and then make the next version)`);
|
|
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;
|
|
// releaseType only when asked for (apple.releaseType): an existing version keeps the one it has
|
|
const wantAttrs: Record<string, string | undefined> = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType, earliestReleaseDate: attrs.releaseType === 'SCHEDULED' ? attrs.earliestReleaseDate : undefined };
|
|
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));
|
|
// the way out, named (Acme 2026-10-22: an approved manual-release version blocked the next one and only the console
|
|
// was suggested): an approved version is released with apple release --manual-release
|
|
if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (${busy.state === 'PENDING_DEVELOPER_RELEASE'
|
|
? `release it first: storebound apple release --manual-release --version ${busy.versionString} --dry, then without --dry`
|
|
: busy.state === 'WAITING_FOR_REVIEW' || busy.state === 'IN_REVIEW' ? 'wait for the review, or remove it from review in App Store Connect' : 'wait for its release or remove it from review'})`);
|
|
const attributes: Record<string, string> = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType ?? 'MANUAL' };
|
|
if (attributes.releaseType === 'SCHEDULED' && attrs.earliestReleaseDate) attributes.earliestReleaseDate = attrs.earliestReleaseDate;
|
|
return { ...base, action: 'create', reason: `create version ${want} (no editable ${PLATFORM_LABEL[platform] ?? platform} 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`;
|
|
};
|