// 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 = { 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 => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };