57 lines
3.6 KiB
TypeScript
57 lines
3.6 KiB
TypeScript
// App Asset Library states and upsert rules shared by creative assets and iPhone Duo screenshots. No command flags here.
|
|
import * as rt from '../lib/rt';
|
|
import type { J } from './asc';
|
|
|
|
export const REF = (sum: string): string => `storebound ${sum}`;
|
|
export const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
|
|
|
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
|
|
export const ASSET_STATES = {
|
|
/** still uploading / processing: wait (bounded), then select */
|
|
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
|
|
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
|
|
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
|
|
/** in a submission or reviewed: usable in a slot, nothing to add */
|
|
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
|
|
} as const;
|
|
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
|
|
|
|
/** What a library asset's state means for storebound. Pure. */
|
|
export function classify(state: string | undefined): AssetClass {
|
|
const s = String(state ?? '');
|
|
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
|
|
if (s === 'FAILED') return 'failed';
|
|
if (s === 'REJECTED') return 'rejected';
|
|
if (s === 'ARCHIVED') return 'archived';
|
|
return s ? 'unknown' : 'processing';
|
|
}
|
|
|
|
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
|
|
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
|
|
|
|
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
|
|
export const MAX_FAILED = 2;
|
|
|
|
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
|
|
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
|
|
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
|
|
const mine = assets.filter(x => sumOf(x) === sum);
|
|
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
|
|
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
|
|
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
|
|
if (live[0]) return { asset: live[0] };
|
|
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
|
|
const rej = by('rejected')[0];
|
|
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
|
|
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
|
|
return {};
|
|
}
|
|
|
|
export const reason = (a: J): string => {
|
|
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
|
|
return d.length ? ` (${d.join(', ')})` : '';
|
|
};
|
|
|
|
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
|
|
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
|
|
|