194 lines
14 KiB
TypeScript
194 lines
14 KiB
TypeScript
// Listing text straight on the stores, without listing files: for store-only apps in the desktop (no app folder, so
|
|
// nothing to save into) and for a quick fix of one language.
|
|
// storebound listing live [--json]
|
|
// the live texts per language: Google Play title / short / full (one throwaway edit, deleted), App Store name /
|
|
// subtitle (the app info being prepared, else the live one) and description / keywords / promotional text / what's
|
|
// new (the editable version, else the newest)
|
|
// storebound listing set --store play|apple --lang <code> --fields '{"short":"..."}' [--dry] [--json]
|
|
// changes only the given fields of one language. Every run reads the store first and prints old -> new per field;
|
|
// --dry stops there; the real run asks in a terminal. Google Play: one edit, PATCH of that listing (other fields,
|
|
// the promo video and the other languages stay), committed. App Store: PATCH of the localization (name, subtitle:
|
|
// the app info being prepared; promotional text: the editable version, else the live one, which App Store
|
|
// Connect allows; description, keywords, what's new: the editable version only). A missing language is created.
|
|
// Checks before anything is sent: the store limits and the rules of `check` / `scan` (lib/listing-rules.ts); an error
|
|
// stops the run. The URLs, contact and app details are not touched here (`play listing` / `apple listing` send them
|
|
// from the config of an app folder).
|
|
import { appleLocaleIssues, type FieldIssue, playLangIssues } from './lib/listing-rules';
|
|
import type { AppleCopy } from './lib/copy';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { confirm, need } from './lib/ctx';
|
|
import { done, log, printJson, warn } from './lib/log';
|
|
import { ascClient, EDITABLE, type J } from './apple/asc';
|
|
import { playClient } from './play/client';
|
|
|
|
const TAG = 'listing';
|
|
|
|
export const PLAY_API_FIELD = { title: 'title', short: 'shortDescription', full: 'fullDescription' } as const;
|
|
export type PlayTextField = keyof typeof PLAY_API_FIELD;
|
|
export const APPLE_INFO_FIELDS = ['name', 'subtitle'] as const;
|
|
export const APPLE_VERSION_FIELDS = ['description', 'keywords', 'promotionalText', 'whatsNew'] as const;
|
|
|
|
export interface LiveListings {
|
|
play: { language: string; title: string; short: string; full: string }[] | null;
|
|
apple: { version: string | null; state: string | null; editable: boolean; locales: { locale: string; values: AppleCopy }[] } | null;
|
|
errors: { play?: string; apple?: string };
|
|
}
|
|
|
|
export interface FieldPlan { field: string; from: string | null; to: string }
|
|
export interface SetPlan { store: 'play' | 'apple'; lang: string; create: boolean; changes: FieldPlan[]; same: string[]; issues: FieldIssue[]; refused: string | null; target?: string }
|
|
|
|
/** Old -> new for the given fields of one language (an empty and a missing value are the same). Pure. */
|
|
export function setPlan(store: 'play' | 'apple', lang: string, current: Record<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean }): SetPlan {
|
|
const changes: FieldPlan[] = [];
|
|
const same: string[] = [];
|
|
for (const [f, v] of Object.entries(want)) {
|
|
const from = current?.[f] ?? null;
|
|
if ((from ?? '') === v.trim()) same.push(f); else changes.push({ field: f, from, to: v.trim() });
|
|
}
|
|
const merged = { ...(current ?? {}), ...Object.fromEntries(Object.entries(want).map(([k, v]) => [k, v.trim()])) };
|
|
// only the fields this run sends are judged (a store value it leaves alone is not this run's problem)
|
|
const sent = new Set(Object.keys(want));
|
|
const all = store === 'play'
|
|
? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName)
|
|
: appleLocaleIssues(lang, merged as AppleCopy, o.appName);
|
|
const issues = all.filter(i => sent.has(i.field) && !(i.level === 'error' && /is required$/.test(i.msg) && !(want[i.field] !== undefined && !want[i.field]!.trim())));
|
|
let refused: string | null = null;
|
|
if (store === 'apple' && o.editable === false && changes.some(c => c.field !== 'promotionalText' && (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)))
|
|
refused = 'no editable App Store version: description, keywords and what\'s new change with a new version (Releases, or apple listing --version)';
|
|
return { store, lang, create: !current, changes, same, issues, refused };
|
|
}
|
|
|
|
async function playLive(ctx: Ctx): Promise<LiveListings['play']> {
|
|
const { config, root } = ctx.loaded;
|
|
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
|
const now = await play.read(async id => (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []);
|
|
return now.map(l => ({ language: l.language, title: l.title ?? '', short: l.shortDescription ?? '', full: l.fullDescription ?? '' })).sort((a, b) => a.language.localeCompare(b.language));
|
|
}
|
|
|
|
interface AppleRefs { c: Awaited<ReturnType<typeof ascClient>>; version: J | null; editable: boolean; info: J }
|
|
|
|
async function appleRefs(ctx: Ctx): Promise<AppleRefs> {
|
|
const { config, root } = ctx.loaded;
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
const ev = await c.editableVersion();
|
|
const versions = ev ? [ev] : await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
|
const version = ev ?? [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')))[0] ?? null;
|
|
const info = await c.editableAppInfo();
|
|
return { c, version, editable: !!ev && EDITABLE.has(ev.attributes.appVersionState ?? ev.attributes.appStoreState), info };
|
|
}
|
|
|
|
async function appleLive(ctx: Ctx): Promise<NonNullable<LiveListings['apple']>> {
|
|
const r = await appleRefs(ctx);
|
|
const by: Record<string, AppleCopy> = {};
|
|
for (const l of await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`)) by[l.attributes.locale] = { name: l.attributes.name ?? '', subtitle: l.attributes.subtitle ?? '' };
|
|
if (r.version) for (const l of await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`)) {
|
|
const at = l.attributes;
|
|
by[at.locale] = { ...by[at.locale], description: at.description ?? '', keywords: at.keywords ?? '', promotionalText: at.promotionalText ?? '', whatsNew: at.whatsNew ?? '' };
|
|
}
|
|
return {
|
|
version: r.version?.attributes.versionString ?? null, state: r.version ? (r.version.attributes.appVersionState ?? r.version.attributes.appStoreState) : null, editable: r.editable,
|
|
locales: Object.entries(by).sort(([a], [b]) => a.localeCompare(b)).map(([locale, values]) => ({ locale, values })),
|
|
};
|
|
}
|
|
|
|
async function live(ctx: Ctx): Promise<void> {
|
|
const { config } = ctx.loaded;
|
|
const out: LiveListings = { play: null, apple: null, errors: {} };
|
|
await Promise.all([
|
|
config.play ? playLive(ctx).then(v => { out.play = v; }, e => { out.errors.play = (e as Error).message.split('\n')[0]!; }) : null,
|
|
config.apple ? appleLive(ctx).then(v => { out.apple = v; }, e => { out.errors.apple = (e as Error).message.split('\n')[0]!; }) : null,
|
|
]);
|
|
if (ctx.args.has('json')) { printJson(out); return; }
|
|
for (const l of out.play ?? []) log(TAG, `Google Play ${l.language}: title ${l.title.length}, short ${l.short.length}, full ${l.full.length} characters`);
|
|
for (const l of out.apple?.locales ?? []) log(TAG, `App Store ${l.locale}: ${Object.entries(l.values).filter(([, v]) => v).map(([k]) => k).join(', ')}`);
|
|
for (const [s, e] of Object.entries(out.errors)) warn(TAG, `${s}: ${e}`);
|
|
}
|
|
|
|
const cut = (s: string | null): string => (s === null ? '(none)' : `"${s.length > 60 ? `${s.slice(0, 57)}...` : s}"`.replace(/\n/g, ' '));
|
|
|
|
function show(ctx: Ctx, p: SetPlan): void {
|
|
const where = `${p.store === 'play' ? 'Google Play' : 'App Store'} ${p.lang}${p.target ? ` (${p.target})` : ''}`;
|
|
if (ctx.args.has('json') && process.env.STOREBOUND_MACHINE !== '1') return;
|
|
log(TAG, `${where}: ${p.create ? 'new language' : 'update'}`);
|
|
for (const c of p.changes) console.log(` ${c.field}: ${cut(c.from)} -> ${cut(c.to)}`);
|
|
for (const s of p.same) console.log(` ${s}: same`);
|
|
for (const i of p.issues) console.log(` ${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} ${i.field}: ${i.msg}`);
|
|
if (p.refused) console.log(` refused: ${p.refused}`);
|
|
}
|
|
|
|
async function set(ctx: Ctx): Promise<void> {
|
|
const a = ctx.args;
|
|
const { config } = ctx.loaded;
|
|
const store = a.get('store');
|
|
if (store !== 'play' && store !== 'apple') throw new Error('--store play or apple');
|
|
const lang = a.get('lang') ?? '';
|
|
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error('--lang: a store language code (en-US, tr-TR, zh-Hans)');
|
|
let want: Record<string, string>;
|
|
try { want = JSON.parse(a.get('fields') ?? '') as Record<string, string>; } catch { throw new Error('--fields: JSON, e.g. {"short":"..."}'); }
|
|
const allowed: readonly string[] = store === 'play' ? Object.keys(PLAY_API_FIELD) : [...APPLE_INFO_FIELDS, ...APPLE_VERSION_FIELDS];
|
|
if (!want || typeof want !== 'object' || Array.isArray(want) || !Object.keys(want).length) throw new Error('--fields: at least one field');
|
|
for (const [k, v] of Object.entries(want)) {
|
|
if (!allowed.includes(k)) throw new Error(`--fields: ${store} has no text field ${k} here (${allowed.join(', ')})`);
|
|
if (typeof v !== 'string' || v.length > 20_000) throw new Error(`--fields.${k}: text`);
|
|
}
|
|
const dry = a.has('dry');
|
|
if (store === 'play') {
|
|
const playName = config.play?.name ?? config.app.name;
|
|
const now = await playLive(ctx);
|
|
const cur = now?.find(l => l.language === lang) ?? null;
|
|
const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName });
|
|
show(ctx, p);
|
|
const errors = p.issues.filter(i => i.level === 'error').length;
|
|
if (a.has('json')) printJson({ ...p, dry, written: false });
|
|
if (errors) throw new Error(`nothing sent: ${errors} error(s)`);
|
|
if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; }
|
|
if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; }
|
|
const { root } = ctx.loaded;
|
|
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
|
const body: Record<string, string> = { language: lang };
|
|
for (const c of p.changes) body[PLAY_API_FIELD[c.field as PlayTextField]] = c.to;
|
|
if (p.create && !body.title) body.title = playName;
|
|
await play.edit(async id => {
|
|
if (p.create) await play.call('PUT', `${play.API}/edits/${id}/listings/${lang}`, { title: playName, shortDescription: '', fullDescription: '', ...body });
|
|
else await play.call('PATCH', `${play.API}/edits/${id}/listings/${lang}`, body);
|
|
});
|
|
done(TAG, `Google Play ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (not public until Google reviews the change)`);
|
|
return;
|
|
}
|
|
const r = await appleRefs(ctx);
|
|
const infoLocs = await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`);
|
|
const verLocs = r.version ? await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`) : [];
|
|
const il = infoLocs.find((x: J) => x.attributes.locale === lang) ?? null;
|
|
const vl = verLocs.find((x: J) => x.attributes.locale === lang) ?? null;
|
|
const cur: Record<string, string> | null = il || vl ? {
|
|
name: il?.attributes.name ?? '', subtitle: il?.attributes.subtitle ?? '', description: vl?.attributes.description ?? '', keywords: vl?.attributes.keywords ?? '',
|
|
promotionalText: vl?.attributes.promotionalText ?? '', whatsNew: vl?.attributes.whatsNew ?? '',
|
|
} : null;
|
|
const appName = config.apple?.name ?? config.app.name;
|
|
const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' };
|
|
show(ctx, p);
|
|
const errors = p.issues.filter(i => i.level === 'error').length;
|
|
if (a.has('json')) printJson({ ...p, dry, written: false });
|
|
if (p.refused) throw new Error(p.refused);
|
|
if (errors) throw new Error(`nothing sent: ${errors} error(s)`);
|
|
if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; }
|
|
if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; }
|
|
const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to]));
|
|
const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to]));
|
|
if (Object.keys(info).length) {
|
|
if (il) await r.c.asc('PATCH', `/v1/appInfoLocalizations/${il.id}`, { data: { type: 'appInfoLocalizations', id: il.id, attributes: info } });
|
|
else await r.c.asc('POST', '/v1/appInfoLocalizations', { data: { type: 'appInfoLocalizations', attributes: { locale: lang, name: info.name ?? appName, ...info }, relationships: { appInfo: { data: { type: 'appInfos', id: r.info.id } } } } });
|
|
}
|
|
if (Object.keys(ver).length) {
|
|
if (!r.version) throw new Error('no App Store version to put the texts on');
|
|
if (vl) await r.c.asc('PATCH', `/v1/appStoreVersionLocalizations/${vl.id}`, { data: { type: 'appStoreVersionLocalizations', id: vl.id, attributes: ver } });
|
|
else await r.c.asc('POST', '/v1/appStoreVersionLocalizations', { data: { type: 'appStoreVersionLocalizations', attributes: { locale: lang, ...ver }, relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: r.version.id } } } } });
|
|
}
|
|
done(TAG, `App Store ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (${p.target}; customers see it with the next approved version, promotional text at once)`);
|
|
}
|
|
|
|
export async function run(ctx: Ctx, sub: string): Promise<void> {
|
|
if (sub === 'live') return live(ctx);
|
|
if (sub === 'set') return set(ctx);
|
|
}
|