storebound/cli/lib/identity.ts

247 lines
14 KiB
TypeScript

// One app card per product, across studios and store accounts: the pairing rules the desktop app (apps/desktop) and
// the CLI workspace (`storebound ws`, cli/lib/workspace.ts) share. Pure, so tests pin every rule
// (apps/desktop/tests/identity.test.ts, tests/workspace.test.ts).
//
// Inputs: app folders (with the store ids of their config), the apps each store account can see (`apps list`), and
// the owner's overrides (manual pairs / unpairs, links of a store app to a folder, studio moves, the ignore list).
// Order of matching:
// 1. a folder takes the store apps its config names (explicit link > App Store app id > bundle id / package name),
// preferring the accounts mapped to the folder;
// 2. the remaining store apps pair iOS + Android: manual pairs, then the same bundle id and package name, then a
// close id (".ios" / ".android" suffixes, case, punctuation) or the same name; unpairs block an automatic pair;
// 3. what is left is a single-platform store-only app.
// A store-only app is read through a "store target" (store:ios:<account>:<app id>:<bundle>;android:<account>:<package>).
import type { ProjectSummary, StoreApp } from './desktop-protocol';
/** An app folder as both front ends know it (the desktop's Project carries more fields). */
export interface IdProject { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null }
/** A store account: an id (16 hex) and its studio. */
export interface IdAccount { id: string; studio?: string | null }
export interface Pair { ios: string; android: string }
export interface Overrides {
pairs: Pair[];
unpairs: Pair[];
links: { key: string; project: string }[];
appStudio: { key: string; studio: string }[];
ignored: string[];
}
export const NO_OVERRIDES: Overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] };
/** One app record of one store account. */
export interface StoreRef { key: string; account: string; store: 'ios' | 'android'; app: StoreApp }
export type Pairing = 'folder' | 'manual' | 'id' | 'close' | 'name' | 'single';
export interface UnifiedApp<P extends IdProject = IdProject> {
/** Route and list key: the project id, else the store target. */
key: string;
/** What reads use: the project id, else the store target ("store:..."). */
target: string;
name: string;
project: P | null;
ios: StoreRef | null;
android: StoreRef | null;
studio: string | null;
pairing: Pairing;
/** The keys an "ignore" puts on the list ("project:<id>" or "app:<store key>"). */
ignoreKeys: string[];
/** Ignored entirely (every store side it has). */
ignored: boolean;
/** Store sides left out while the other side stays (a half-ignored app): their ref is dropped from ios / android. */
ignoredStores: ('ios' | 'android')[];
/** The ignore key of each side (null: no such side), for the per-store toggles. */
sideKeys: { ios: string | null; android: string | null };
/** Store icon (data URL) when the folder has none. */
storeIcon: string | null;
}
export const refKey = (account: string, a: StoreApp): string => `${a.store}:${account}:${a.id}`;
/** Store target of a store-only app. */
export function storeTarget(ios: StoreRef | null, android: StoreRef | null): string {
const parts: string[] = [];
if (ios) parts.push(`ios:${ios.account}:${ios.app.id}:${ios.app.bundleId}`);
if (android) parts.push(`android:${android.account}:${android.app.id}`);
return `store:${parts.join(';')}`;
}
/** "com.example.Puzzle.ios" -> "comexamplepuzzle": ids that differ only by a platform suffix, case or punctuation. */
export function normId(id: string): string {
return id.toLowerCase().replace(/[._-](ios|iphone|android|droid|app)$/g, '').replace(/[^a-z0-9]/g, '');
}
export const normName = (n: string): string => n.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]/g, '');
const samePair = (p: Pair, ios: string, android: string) => p.ios === ios && p.android === android;
export interface BuildInput<P extends IdProject = IdProject> {
projects: P[];
/** Store ids of each folder's config (from the offline portfolio read). */
summaryOf: (p: P) => ProjectSummary | null | undefined;
accounts: IdAccount[];
/** The last app list of each account. */
storeApps: { account: string; apps: StoreApp[] }[];
overrides: Overrides;
}
export function buildApps<P extends IdProject>(i: BuildInput<P>): UnifiedApp<P>[] {
const o = i.overrides;
const ignoredAccounts = new Set(o.ignored.filter(x => x.startsWith('account:')).map(x => x.slice(8)));
const accountStudio = new Map(i.accounts.map(a => [a.id, a.studio ?? null] as const));
const known = new Set(i.accounts.map(a => a.id));
// every store app of every (not ignored) account
const refs: StoreRef[] = [];
for (const s of i.storeApps) {
if (!known.has(s.account) || ignoredAccounts.has(s.account)) continue;
for (const app of s.apps) refs.push({ key: refKey(s.account, app), account: s.account, store: app.store, app });
}
const used = new Set<string>();
const take = (r: StoreRef | null | undefined): StoreRef | null => { if (!r || used.has(r.key)) return null; used.add(r.key); return r; };
const studioOverride = new Map(o.appStudio.map(x => [x.key, x.studio] as const));
const ignored = new Set(o.ignored);
const out: UnifiedApp<P>[] = [];
// 1. folders
for (const p of i.projects) {
const s = i.summaryOf(p);
const mine = (r: StoreRef) => r.account === (r.store === 'ios' ? p.appleAccount : p.playAccount);
const rank = (rs: StoreRef[]) => [...rs].sort((a, b) => Number(mine(b)) - Number(mine(a)));
const linked = o.links.filter(l => l.project === p.id).map(l => refs.find(r => r.key === l.key)).filter((r): r is StoreRef => !!r && !used.has(r.key));
let ios = take(linked.find(r => r.store === 'ios'));
let android = take(linked.find(r => r.store === 'android'));
if (!ios && s?.apple) {
const free = refs.filter(r => r.store === 'ios' && !used.has(r.key) && !o.links.some(l => l.key === r.key));
ios = take(rank(free.filter(r => s.apple!.appId && r.app.id === s.apple!.appId))[0] ?? rank(free.filter(r => r.app.bundleId === s.apple!.bundleId))[0]);
}
if (!android && s?.play) {
const free = refs.filter(r => r.store === 'android' && !used.has(r.key) && !o.links.some(l => l.key === r.key));
android = take(rank(free.filter(r => r.app.id === s.play!.packageName))[0]);
}
const studio = studioOverride.get(`project:${p.id}`)
?? (p.appleAccount && accountStudio.get(p.appleAccount)) ?? (p.playAccount && accountStudio.get(p.playAccount))
?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null;
// ignored: the whole folder, or every store side it has; one side alone hides just that store (project:<id>:ios | :android)
const whole = ignored.has(`project:${p.id}`);
const sideOff = { ios: whole || ignored.has(`project:${p.id}:ios`), android: whole || ignored.has(`project:${p.id}:android`) };
const has = { ios: !!ios || !!s?.apple || !!p.appleAccount, android: !!android || !!s?.play || !!p.playAccount };
const present = (['ios', 'android'] as const).filter(k => has[k]);
const allOff = whole || (present.length > 0 && present.every(k => sideOff[k]));
const hidden = allOff ? [] : present.filter(k => sideOff[k]);
out.push({
key: p.id, target: p.id, name: p.name, project: p, ios: hidden.includes('ios') ? null : ios, android: hidden.includes('android') ? null : android, studio, pairing: 'folder',
ignoreKeys: [`project:${p.id}`, `project:${p.id}:ios`, `project:${p.id}:android`], ignored: allOff, ignoredStores: hidden, sideKeys: { ios: `project:${p.id}:ios`, android: `project:${p.id}:android` },
storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
});
}
// guard: a store app with the same App Store id / bundle id / package name as a folder is that folder's record, never a
// second entry (the folder could not take it above, e.g. it is linked elsewhere); it is dropped, not shown separately
const folderIds = new Set<string>();
for (const p of i.projects) {
const s = i.summaryOf(p);
if (s?.apple?.appId) folderIds.add(`ios:id:${s.apple.appId}`);
if (s?.apple?.bundleId) folderIds.add(`ios:bundle:${s.apple.bundleId}`);
if (s?.play?.packageName) folderIds.add(`android:${s.play.packageName}`);
}
for (const r of refs) {
if (used.has(r.key)) continue;
if (r.store === 'ios' ? folderIds.has(`ios:id:${r.app.id}`) || folderIds.has(`ios:bundle:${r.app.bundleId}`) : folderIds.has(`android:${r.app.id}`)) used.add(r.key);
}
// 2. pairs among the remaining store apps
const freeIos = () => refs.filter(r => r.store === 'ios' && !used.has(r.key));
const freeAndroid = () => refs.filter(r => r.store === 'android' && !used.has(r.key));
const blocked = (a: StoreRef, b: StoreRef) => o.unpairs.some(p => samePair(p, a.key, b.key));
const sameStudio = (a: StoreRef, b: StoreRef) => (accountStudio.get(a.account) ?? null) === (accountStudio.get(b.account) ?? null);
const pairs: [StoreRef, StoreRef, Pairing][] = [];
for (const m of o.pairs) {
const a = freeIos().find(r => r.key === m.ios), b = freeAndroid().find(r => r.key === m.android);
if (a && b) { take(a); take(b); pairs.push([a, b, 'manual']); }
}
const auto = (how: Pairing, match: (a: StoreRef, b: StoreRef) => boolean) => {
for (const a of freeIos()) {
const cands = freeAndroid().filter(b => !blocked(a, b) && match(a, b)).sort((x, y) => Number(sameStudio(a, y)) - Number(sameStudio(a, x)));
const b = cands[0];
if (b) { take(a); take(b); pairs.push([a, b, how]); }
}
};
auto('id', (a, b) => a.app.bundleId === b.app.id);
auto('close', (a, b) => normId(a.app.bundleId) !== '' && normId(a.app.bundleId) === normId(b.app.id));
auto('name', (a, b) => normName(a.app.name) !== '' && normName(a.app.name) === normName(b.app.name));
const storeOnly = (ios: StoreRef | null, android: StoreRef | null, pairing: Pairing) => {
const target = storeTarget(ios, android);
const keys = [ios, android].filter((r): r is StoreRef => !!r).map(r => r.key);
const studio = keys.map(k => studioOverride.get(k)).find(Boolean)
?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null;
// a paired app is ignored when every side is; one ignored side alone hides just that store
const off = (r: StoreRef | null) => !!r && ignored.has(`app:${r.key}`);
const allOff = [ios, android].filter(Boolean).length > 0 && [ios, android].every(r => !r || off(r));
const hidden = allOff ? [] : (['ios', 'android'] as const).filter(k => off(k === 'ios' ? ios : android));
out.push({
key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios: hidden.includes('ios') ? null : ios, android: hidden.includes('android') ? null : android, studio, pairing,
ignoreKeys: keys.map(k => `app:${k}`), ignored: allOff, ignoredStores: hidden, sideKeys: { ios: ios ? `app:${ios.key}` : null, android: android ? `app:${android.key}` : null },
storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
});
};
for (const [a, b, how] of pairs) storeOnly(a, b, how);
// 3. singles
for (const r of refs) if (!used.has(r.key)) { used.add(r.key); storeOnly(r.store === 'ios' ? r : null, r.store === 'android' ? r : null, 'single'); }
return out;
}
/** Candidates to pair a single-platform app with (the other store's single apps), closest first. */
export function pairCandidates<P extends IdProject>(app: UnifiedApp<P>, all: UnifiedApp<P>[]): UnifiedApp<P>[] {
if (app.project || (app.ios && app.android)) return [];
const want = app.ios ? 'android' : 'ios';
const me = (app.ios ?? app.android)!;
const score = (x: UnifiedApp<P>) => {
const r = (want === 'ios' ? x.ios : x.android)!;
return (normId(r.app.bundleId) === normId(me.app.bundleId) ? 4 : 0) + (normName(r.app.name) === normName(me.app.name) ? 2 : 0) + (x.studio === app.studio ? 1 : 0);
};
return all.filter(x => !x.project && !x.ignored && (want === 'ios' ? x.ios && !x.android : x.android && !x.ios)).sort((a, b) => score(b) - score(a) || a.name.localeCompare(b.name));
}
/** Overrides after pairing two single store apps by hand (drops an unpair of the same two). */
export function withPair(o: Overrides, ios: string, android: string): Overrides {
return { ...o, pairs: [...o.pairs.filter(p => p.ios !== ios && p.android !== android), { ios, android }], unpairs: o.unpairs.filter(p => !samePair(p, ios, android)) };
}
/** Overrides after splitting a paired app (keeps it apart from now on). */
export function withUnpair(o: Overrides, ios: string, android: string): Overrides {
return { ...o, pairs: o.pairs.filter(p => !samePair(p, ios, android)), unpairs: [...o.unpairs.filter(p => !samePair(p, ios, android)), { ios, android }] };
}
/** Overrides after ignoring (or restoring) ONE store side of an app. A whole-folder key is split into its sides first. */
export function withSideIgnored(o: Overrides, app: UnifiedApp, side: 'ios' | 'android', on: boolean): Overrides {
const key = app.sideKeys[side];
if (!key) return o;
const set = new Set(o.ignored);
if (app.project) {
const whole = `project:${app.project.id}`;
if (set.delete(whole)) { set.add(app.sideKeys.ios!); set.add(app.sideKeys.android!); }
// both sides ignored is the same as the whole folder
if (on) set.add(key); else set.delete(key);
if (set.has(app.sideKeys.ios!) && set.has(app.sideKeys.android!)) { set.delete(app.sideKeys.ios!); set.delete(app.sideKeys.android!); set.add(whole); }
} else if (on) set.add(key); else set.delete(key);
return { ...o, ignored: [...set] };
}
export function withIgnored(o: Overrides, keys: string[], on: boolean): Overrides {
const set = new Set(o.ignored);
for (const k of keys) { if (on) set.add(k); else set.delete(k); }
return { ...o, ignored: [...set] };
}
/** Moves an app to a studio (null: back to its accounts' studio). */
export function withStudio(o: Overrides, app: UnifiedApp, studio: string | null): Overrides {
const keys = app.project ? [`project:${app.project.id}`] : [app.ios?.key, app.android?.key].filter((k): k is string => !!k);
const rest = o.appStudio.filter(x => !keys.includes(x.key));
return { ...o, appStudio: studio ? [...rest, ...keys.map(key => ({ key, studio }))] : rest };
}
/** Links the store apps of a store-only app to a folder. */
export function withLink(o: Overrides, app: UnifiedApp, project: string): Overrides {
const keys = [app.ios?.key, app.android?.key].filter((k): k is string => !!k);
return { ...o, links: [...o.links.filter(l => !keys.includes(l.key)), ...keys.map(key => ({ key, project }))] };
}