desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case
This commit is contained in:
parent
714352ec18
commit
3cea2af4e1
37 changed files with 3020 additions and 54 deletions
83
cli/lib/edit-values.ts
Normal file
83
cli/lib/edit-values.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// What the desktop's editors show (`storebound edit --show --json`, serve method project.editable): the listing
|
||||
// fields per store and language as the files hold them, the config sections the editors change, the App Review
|
||||
// contact (masked: personal data), the store limits, and whether the files are uncommitted in git. Offline.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { APPLE_IAP_LIMITS, APPLE_LIMITS, PLAY_LIMITS } from './copy';
|
||||
import { appleName, type Loaded, playCodes } from './config';
|
||||
import { maskUser, reviewDemoAccount } from './credentials';
|
||||
import type { EditableResult } from './desktop-protocol';
|
||||
import { appleSection, playSection } from './listing-edit';
|
||||
import { NOTES_MAX_BYTES } from './review-details';
|
||||
import * as rt from './rt';
|
||||
import { APPLE_SUB_LIMITS, PLAY_SUB_LIMITS } from './subscriptions';
|
||||
|
||||
/** Is `root` in a git work tree, and which of `files` have uncommitted changes (or are untracked)? */
|
||||
export function gitState(root: string, files: string[]): { repo: boolean; uncommitted: string[] } {
|
||||
try {
|
||||
const inside = rt.spawnSync(['git', '-C', root, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
|
||||
if (!inside.success || inside.stdout.toString().trim() !== 'true') return { repo: false, uncommitted: [] };
|
||||
if (!files.length) return { repo: true, uncommitted: [] };
|
||||
const st = rt.spawnSync(['git', '-C', root, 'status', '--porcelain', '--untracked-files=all', '--', ...files.map(f => relative(root, f) || f)], { timeout: 5000 });
|
||||
if (!st.success) return { repo: true, uncommitted: [] };
|
||||
const out = st.stdout.toString().split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"|"$/g, ''));
|
||||
// porcelain paths are relative to the repository top: keep the file names the window knows
|
||||
const rels = files.map(f => relative(root, f) || f);
|
||||
return { repo: true, uncommitted: rels.filter(r => out.some(o => o === r || o.endsWith(`/${r}`))) };
|
||||
} catch {
|
||||
return { repo: false, uncommitted: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const mask = (field: string, v: string): string => (!v ? '' : field === 'email' ? maskUser(v) : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
|
||||
|
||||
export function editableValues(l: Loaded): EditableResult {
|
||||
const { config, root, path, resolve } = l;
|
||||
const rel = (f: string): string => relative(root, f) || f;
|
||||
const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : null;
|
||||
const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null;
|
||||
const playMd = playFile && existsSync(playFile) ? readFileSync(playFile, 'utf8') : null;
|
||||
const appleMd = appleFile && existsSync(appleFile) ? readFileSync(appleFile, 'utf8') : null;
|
||||
const contactFile = join(root, '.storebound', 'credentials.json');
|
||||
let contact: EditableResult['contact'] = null;
|
||||
try {
|
||||
const c = existsSync(contactFile) ? (JSON.parse(readFileSync(contactFile, 'utf8')) as { reviewContact?: Record<string, string> }).reviewContact : undefined;
|
||||
if (c) contact = { firstName: mask('firstName', c.firstName ?? ''), lastName: mask('lastName', c.lastName ?? ''), phone: mask('phone', c.phone ?? ''), email: mask('email', c.email ?? '') };
|
||||
} catch { /* unreadable: the declarations card names it */ }
|
||||
let demo: string | null = null;
|
||||
try { const d = reviewDemoAccount(root); demo = d ? maskUser(d.username) : null; } catch { /* unreadable */ }
|
||||
const play: EditableResult['play'] = [];
|
||||
const apple: EditableResult['apple'] = [];
|
||||
for (const lang of config.languages) {
|
||||
if (config.play) for (const code of playCodes(lang)) {
|
||||
const s = playMd ? playSection(playMd, code) : null;
|
||||
play.push({ lang: code, language: lang.id, codes: s?.codes ?? [code], status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
|
||||
}
|
||||
if (config.apple && lang.apple) {
|
||||
const s = appleMd ? appleSection(appleMd, lang.apple) : null;
|
||||
apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
|
||||
}
|
||||
}
|
||||
const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
|
||||
const { signing: _s, build: _b, adhoc: _a, ...appleRest } = config.apple ?? ({} as NonNullable<typeof config.apple>);
|
||||
const { build: _pb, ...playRest } = config.play ?? ({} as NonNullable<typeof config.play>);
|
||||
return {
|
||||
root,
|
||||
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile) },
|
||||
format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts',
|
||||
git: gitState(root, files),
|
||||
appName: config.app.name,
|
||||
appleName: appleName(config),
|
||||
languages: config.languages.map(x => ({ id: x.id, play: playCodes(x), apple: x.apple ?? null, rtl: !!x.rtl })),
|
||||
play: config.play ? play : null,
|
||||
apple: config.apple ? apple : null,
|
||||
config: JSON.parse(JSON.stringify({
|
||||
app: config.app, apple: config.apple ? appleRest : null, play: config.play ? playRest : null, products: config.products ?? [], subscriptions: config.subscriptions ?? [],
|
||||
dataSafety: config.dataSafety ?? null, appPrivacy: config.appPrivacy ?? null, reviewAccess: config.reviewAccess ?? null, accounts: config.accounts ?? null, social: config.social ?? null,
|
||||
shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null,
|
||||
})) as EditableResult['config'],
|
||||
contact,
|
||||
demoAccount: demo,
|
||||
limits: { play: PLAY_LIMITS, apple: APPLE_LIMITS, iap: APPLE_IAP_LIMITS, appleSub: APPLE_SUB_LIMITS, playSub: PLAY_SUB_LIMITS, reviewNotesBytes: NOTES_MAX_BYTES },
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue