storebound/cli/play/listing.ts

209 lines
14 KiB
TypeScript

// Play store listing text + app details in one edit.
// storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts] [--not-sent-for-review]
// Every run first reads Play in a throwaway edit (deleted again) and prints the plan: app details and per language
// create / update with old -> new per field. --dry stops there (nothing is written, not even into that edit); the real
// run asks in a terminal and PUTs only what the plan changes, in one committed edit. Languages whose section is
// status=draft or status=translate (listing translate) are skipped unless --include-drafts.
// App details: default language, contact email, website (must be the domain that serves app-ads.txt).
// Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo
// video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with
// `storebound shots video --target youtube`, upload it to YouTube (public or unlisted, ads off, embeddable), paste the URL.
// And the other way round (read-only on Play, one throwaway edit that is deleted):
// storebound play listing pull [--write] [--json] live listings -> diff -> (with --write) the Play listing file
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { parsePlayListing } from '../lib/copy';
import { fieldsMentions, refuseMentions } from '../lib/cross-store';
import { compactDiff } from '../lib/diff';
import { mergePlayListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, type Skipped } from '../lib/listing-plan';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, step, warn } from '../lib/log';
import { playClient } from './client';
import { newLanguageImages, type NewLangImages } from './images';
import { stdinIsTTY } from '../lib/tty';
/** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */
export function refusePlayNotes(ctx: Ctx, notes: { language: string; text: string }[], what: string): void {
const allow = ctx.loaded.config.listing?.allowPlatformMentions;
refuseMentions(notes.flatMap(n => fieldsMentions('play', n.language, { whatsNew: n.text }, allow)), what);
}
export function readPlayCopy(ctx: Ctx) {
const { config, resolve } = ctx.loaded;
const file = resolve(config.play?.listing ?? 'store/listing.md');
const only = ctx.args.list('only');
return parsePlayListing(readFileSync(file, 'utf8'), config.app.name).filter(c => !only.length || only.includes(c.lang));
}
/** Body of one Play listing PUT. The PUT replaces the whole listing, so a promo video set in Play Console is cleared
* unless its URL is in play.video. Pure. */
export function listingBody(c: { lang: string; title: string; short: string; full: string }, video?: string): Record<string, string> {
return { language: c.lang, title: c.title, shortDescription: c.short, fullDescription: c.full, ...(video ? { video } : {}) };
}
export interface PlayListing { language: string; title?: string; shortDescription?: string; fullDescription?: string; video?: string }
export interface ListingChange { lang: string; change: 'new' | 'update' | 'same'; fields: string[] }
const LISTING_FIELDS = ['title', 'shortDescription', 'fullDescription', 'video'] as const;
/** What a listing run would change, from the listings Play has now (GET edits/{id}/listings). An empty and a missing
* value are the same; a promo video on Play that play.video leaves out is cleared by the PUT. Pure. */
export function listingChanges(current: PlayListing[], wanted: Record<string, string>[]): ListingChange[] {
const now = new Map(current.map(l => [l.language, l]));
return wanted.map(w => {
const cur = now.get(w.language!);
if (!cur) return { lang: w.language!, change: 'new', fields: [] };
const fields = LISTING_FIELDS.filter(f => (cur[f] ?? '') !== (w[f] ?? '')).map(f => (f === 'video' && !w.video ? 'video (cleared)' : f));
return { lang: w.language!, change: fields.length ? 'update' : 'same', fields };
});
}
/** App details fields a run would change (defaultLanguage, contactEmail, contactWebsite). Pure. */
export function detailsChanges(current: Record<string, string | undefined>, wanted: Record<string, string | undefined>): string[] {
return Object.keys(wanted).filter(k => (current[k] ?? '') !== (wanted[k] ?? ''));
}
async function pull(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
const now = await play.read(async id => (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []);
const file = resolve(config.play?.listing ?? 'store/listing.md');
const before = existsSync(file) ? readFileSync(file, 'utf8') : '# Google Play listing copy\n\nPulled from Google Play by `storebound play listing pull`.\n';
const r = mergePlayListing(before, now, config.app.name);
const over = overLimits(r.changes, 'play');
const diff = compactDiff(before, r.md);
if (a.has('json')) printJson({ file, write: a.has('write') && !a.has('dry'), languages: now.map(l => l.language), changes: r.changes, added: r.added, conflicts: r.conflicts, overLimits: over, diff });
else {
log('play', `pull: ${now.length} listing(s) on Play`);
for (const ch of r.changes) log('play', `${ch.section} ${ch.field}: ${ch.from === null ? 'new' : 'changed'}`);
if (diff.length) console.log(diff.join('\n'));
}
for (const c of r.conflicts) warn('play', c);
for (const o of over) warn('play', `over the limit on the store: ${o}`);
if (!r.changes.length) { done('play', `${file} already matches the store`); return; }
if (!a.has('write') || a.has('dry')) { done('play', `${r.changes.length} change(s); nothing written (add --write to update ${file})`); return; }
writeFileSync(file, r.md);
done('play', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new languages ${r.added.join(', ')}` : ''}`);
}
/** The plan of one `play listing` run, from a read-only edit. */
export interface PlayListingPlan { details: ItemPlan; listings: ItemPlan[]; other: string[]; skipped: Skipped[]; notes: string[]; changes: number }
/** Plan from what Play has now. The listing PUT replaces the whole listing: a promo video on Play that play.video leaves
* out is shown as cleared. Pure. */
export function playPlan(curDetails: Record<string, unknown>, details: Record<string, string | undefined>, now: PlayListing[], bodies: Record<string, string>[],
o: { skipped?: Skipped[]; notes?: string[]; only?: boolean } = {}): PlayListingPlan {
const d = planItem('details', { attributes: curDetails }, details);
const byLang = new Map(now.map(l => [l.language, l]));
const listings = bodies.map(b => {
const cur = byLang.get(b.language!);
const { language, ...want } = b;
const w: Record<string, string | undefined> = { ...want, video: want.video ?? (cur?.video ? '' : undefined) };
return planItem(language!, cur ? { attributes: cur as unknown as Record<string, unknown> } : null, w);
});
const other = o.only ? [] : now.filter(l => !bodies.some(b => b.language === l.language)).map(l => l.language);
const changes = (d.action === 'same' ? 0 : 1) + listings.filter(l => l.action !== 'same').length;
return { details: d, listings, other, skipped: o.skipped ?? [], notes: o.notes ?? [], changes };
}
/** The plan as text lines. Pure. */
export function playPlanLines(p: PlayListingPlan): string[] {
const out = [...itemLines('App details (default language, contact email, website)', p.details, '')];
out.push(`Listings: title, short and full description, promo video · ${counts(p.listings)}`);
for (const l of p.listings) out.push(...itemLines(l.key, l));
if (p.other.length) out.push(`Left as they are (on Play, not in the listing file): ${p.other.join(', ')}`);
for (const s of p.skipped) out.push(`Skipped ${s.key}: ${s.reason}`);
for (const n of p.notes) out.push(`Note: ${n}`);
return out;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
if (a._[0] === 'pull') return pull(ctx);
const pkg = need(config.play?.packageName, 'play.packageName');
const all = readPlayCopy(ctx);
const drafts = draftSections(readFileSync(resolve(config.play?.listing ?? 'store/listing.md'), 'utf8'), 'play');
const skipped: Skipped[] = [], notes: string[] = [];
const copy = all.filter(c => {
const st = drafts.get(c.lang);
if (!st) return true;
if (a.has('include-drafts')) { notes.push(`${c.lang}: ${st === 'draft' ? 'machine-translated draft' : 'untranslated (status=translate)'} included (--include-drafts)`); return true; }
skipped.push({ key: c.lang, reason: draftReason(st) });
return false;
});
// Play Metadata policy: iOS / App Store / Game Center in Play text stop the run, dry runs included (pitfall 77)
refuseMentions(copy.flatMap(c => fieldsMentions('play', c.lang, { title: c.title, short: c.short, full: c.full }, config.listing?.allowPlatformMentions)), `play listing${a.has('dry') ? ' (dry run)' : ''}`);
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
if (!all.some(c => c.lang === details.defaultLanguage) && !a.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`);
if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`);
const play = await playClient(root, pkg);
const bodies = copy.map(c => listingBody(c, config.play?.video));
// A language Play does not have yet only commits with its icon, feature graphic, 2+ phone screenshots (and the
// tablet sets when the other languages have them) in the same edit: planned here so the dry run refuses early.
const fresh = new Map<string, NewLangImages>();
const planFresh = async (id: string, now: PlayListing[]) => {
const missing = bodies.filter(b => !now.some(n => n.language === b.language)).map(b => b.language!);
if (!missing.length) return;
const ref = now.find(n => n.language === details.defaultLanguage) ?? now[0];
let tabletsOnPlay = false;
if (ref) for (const t of ['sevenInchScreenshots', 'tenInchScreenshots']) {
try { tabletsOnPlay ||= !!((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${id}/listings/${ref.language}/${t}`)).images?.length); } catch { /* no tablet set */ }
}
for (const code of missing) {
const f = newLanguageImages(ctx, code, details.defaultLanguage, tabletsOnPlay);
// the very first listing of an app: Play checks the images at release, not at this commit, so only a warning
if (!now.length && f.errors.length) { f.notes.push(...f.errors.map(e => `warning: ${e}`)); f.errors = []; }
fresh.set(code, f);
}
};
const p = await play.read(async id => {
const cur = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
await planFresh(id, now);
return playPlan(cur, details, now, bodies, { skipped, notes, only: a.has('only') });
});
for (const f of fresh.values()) {
p.notes.push(...f.notes);
if (!f.errors.length) p.notes.push(`${f.code} is new on Play: uploads ${f.uploads.map(([t, fl]) => `${t} (${fl.length})`).join(', ')} in the same edit`);
}
const dry = a.has('dry');
const refuse = [...fresh.values()].flatMap(f => f.errors);
if (refuse.length) {
if (a.has('json')) printJson({ dry, ...p, errors: refuse }); else for (const line of playPlanLines(p)) console.log(line);
throw new Error(['a new Play language needs its images in the same edit:', ...refuse].join('\n'));
}
if (a.has('json')) printJson({ dry, ...p });
else for (const line of playPlanLines(p)) console.log(line);
if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
if (!p.changes) { done('play', 'listing already up to date'); return; }
if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; }
// The edit compares again with what it holds (a Console save or another run since the plan): only differing details
// and languages are PUT; nothing left to change = the edit is deleted, not committed. Commit only after every PUT worked.
let sent = 0;
await play.edit(async (id, ctl) => {
const cur = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
const q = playPlan(cur, details, now, bodies, { only: a.has('only') });
const todo = q.listings.filter(l => l.action !== 'same').map(l => bodies.find(b => b.language === l.key)!);
sent = q.changes;
if (!q.changes) { ctl.unchanged(); return; }
if (q.details.action !== 'same') {
await play.call('PUT', `${play.API}/edits/${id}/details`, details);
log('play', `details: ${q.details.changes.map(ch => ch.field).join(', ')}`);
}
for (const [i, b] of todo.entries()) {
await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b);
log('play', step(i + 1, todo.length, b.language!));
// a language Play does not have yet: its edit only commits with its images in it (pitfall 102)
if (!now.some(n => n.language === b.language)) {
for (const [type, files] of fresh.get(b.language!)?.uploads ?? []) {
for (const f of files) await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
log('play', `${b.language}: ${type} (${files.length}) uploaded with the new listing`);
}
}
}
}, { notSentForReview: ctx.args.has('not-sent-for-review'), summary: 'listing text' });
done('play', sent ? 'listing done' : 'listing already up to date (the edit was deleted, nothing committed)');
}