113 lines
10 KiB
TypeScript
113 lines
10 KiB
TypeScript
// Exports the window offers (top right of a screen and the command palette): CSV and Markdown built from what the
|
|
// screen already read. Pure, so tests can pin the formats. The prices CSV has the exact columns of `storebound prices
|
|
// import`, so an exported sheet can be edited and compared again.
|
|
import type { AnalyticsCompare, AppStatus, DeclarationsResult, ListingText, PricesCurrent } from '../api';
|
|
import type { CatalogDiff, HealthSnapshot, Review, ValidateResult } from './model';
|
|
import { isError } from './model';
|
|
import { buildReport, renderMarkdown } from '../../../../cli/lib/report';
|
|
|
|
type Cell = string | number | boolean | null | undefined;
|
|
|
|
const cell = (v: Cell): string => {
|
|
const s = v === null || v === undefined ? '' : String(v);
|
|
return /[",\n\r]/.test(s) || /^\s|\s$/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
};
|
|
/** RFC 4180 CSV with a header row; spreadsheet formula prefixes (= + - @) in text cells are quoted with a ' first. */
|
|
export function csv(head: string[], rows: Cell[][]): string {
|
|
const safe = (v: Cell) => (typeof v === 'string' && /^[=+\-@\t]/.test(v) && !/^-?\d+(\.\d+)?$/.test(v) ? `'${v}` : v);
|
|
return [head, ...rows.map(r => r.map(safe))].map(r => r.map(cell).join(',')).join('\n') + '\n';
|
|
}
|
|
|
|
const pipe = (s: Cell) => String(s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
export function mdTable(head: string[], rows: Cell[][]): string {
|
|
if (!rows.length) return '_none_\n';
|
|
return [`| ${head.map(pipe).join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`, ...rows.map(r => `| ${r.map(pipe).join(' | ')} |`)].join('\n') + '\n';
|
|
}
|
|
|
|
/** "Prism Puddle prices 2026-10-11" (the dialog adds the extension). */
|
|
export const exportName = (app: string, what: string, now = new Date()): string => `${app} ${what} ${now.toISOString().slice(0, 10)}`;
|
|
|
|
/** The price sheet: product, store, region, currency, price, start_date, kind (the `prices import` format). */
|
|
export function pricesCsv(p: PricesCurrent): string {
|
|
// one row per product x store x region x start date (the import refuses doubles): a manual price wins over Apple's equalized one
|
|
const key = (r: PricesCurrent['rows'][number]) => `${r.product}|${r.store}|${r.region}|${r.startDate ?? ''}`;
|
|
const manual = new Set(p.rows.filter(r => r.kind !== 'auto').map(key));
|
|
const rows = p.rows.filter(r => r.kind !== 'auto' || !manual.has(key(r))).sort((a, b) => a.product.localeCompare(b.product) || a.store.localeCompare(b.store) || a.region.localeCompare(b.region));
|
|
return csv(['product', 'store', 'region', 'currency', 'price', 'start_date', 'kind'], rows.map(r => [r.product, r.store, r.region, r.currency, r.price, r.startDate ?? '', r.kind ?? 'manual']));
|
|
}
|
|
|
|
/** Reviews as a CSV (personal data: the window warns before saving). */
|
|
export function reviewsCsv(rs: Review[]): string {
|
|
return csv(['store', 'date', 'rating', 'territory', 'version', 'title', 'review', 'replied', 'reply'],
|
|
rs.map(r => [r.store === 'apple' ? 'App Store' : 'Google Play', r.date.slice(0, 10), r.rating, r.where ?? '', r.appVersion ?? '', r.title ?? '', r.body, r.reply ? 'yes' : 'no', r.reply?.text ?? '']));
|
|
}
|
|
|
|
const pctOf = (v: number | null | undefined, d = 2): string => (v === null || v === undefined ? '–' : `${(v * 100).toFixed(d)} %`);
|
|
|
|
export function healthMd(app: string, h: HealthSnapshot): string {
|
|
const out = [`# ${app}: health ${h.date}`, ''];
|
|
if (h.play && !isError(h.play)) {
|
|
const last = <T>(xs: T[]): T | undefined => xs[xs.length - 1];
|
|
const c = last(h.play.crash)?.values, a = last(h.play.anr)?.values;
|
|
out.push('## Google Play', '', `Window: ${h.play.window.start} to ${h.play.window.end}`, '',
|
|
mdTable(['metric', 'last day', '28 days (user-weighted)', 'bad behavior threshold'], [
|
|
['User-perceived crash rate', pctOf(c?.userPerceivedCrashRate), pctOf(c?.userPerceivedCrashRate28dUserWeighted), '1.09 %'],
|
|
['User-perceived ANR rate', pctOf(a?.userPerceivedAnrRate), pctOf(a?.userPerceivedAnrRate28dUserWeighted), '0.47 %'],
|
|
]), '### Worst devices', '', mdTable(['device', 'crash rate', 'users'], (h.play.devices ?? []).map(d => [d.model, pctOf(d.crash), d.users ?? ''])),
|
|
'### Top issues', '', mdTable(['type', 'cause', 'where', 'reports', 'users', 'last version'], h.play.issues.map(i => [i.type, i.cause, i.location, i.reports, i.users, i.lastVersion])));
|
|
} else if (h.play) out.push('## Google Play', '', `Not read: ${(h.play as { error: string }).error}`, '');
|
|
if (h.apple && !isError(h.apple)) {
|
|
out.push('## App Store', '', mdTable(['category', 'metric', 'version', 'value', 'goal'], h.apple.metrics.map(m => [m.category, m.metric, m.version, `${m.value} ${m.unit}`, m.goal])),
|
|
'### Diagnostic signatures', '', mdTable(['build', 'type', 'signature', 'weight'], h.apple.signatures.map(s => [s.build, s.type, s.signature, pctOf(s.weight, 0)])));
|
|
} else if (h.apple) out.push('## App Store', '', `Not read: ${(h.apple as { error: string }).error}`, '');
|
|
return out.join('\n');
|
|
}
|
|
|
|
/** The store readiness export: the same report and Markdown renderer as `storebound report --md` (cli/lib/report.ts),
|
|
* from what the Checks tab read (validate, the offline check, the scan when it ran). */
|
|
export function validateMd(app: string, v: ValidateResult, at: string | null, more: { check?: { problems: string[]; warnings: string[] } | null; scan?: unknown; version?: string; stores?: ('apple' | 'play')[] } = {}): string {
|
|
const r = buildReport({ app: { name: app }, ...(more.stores ? { stores: more.stores } : {}), generatedAt: at ?? new Date().toISOString(), storebound: more.version ?? 'desktop',
|
|
validate: v as never, check: more.check ?? null, scan: (more.scan ?? null) as never, storeRead: { apple: true, play: true } });
|
|
return renderMarkdown(r);
|
|
}
|
|
|
|
export function catalogMd(app: string, c: CatalogDiff): string {
|
|
return [`# ${app}: catalog parity`, '', `${c.errors} error(s)`, '', '## Items', '',
|
|
mdTable(['item', 'store', 'level', 'code', 'note'], c.rows.map(r => [r.id, r.store === 'apple' ? 'App Store' : 'Google Play', r.level, r.code, r.msg])),
|
|
'## Price gaps between the stores', '', mdTable(['item', 'region', 'currency', 'App Store', 'Google Play', 'difference'], c.parity.map(g => [g.id, g.region, g.currency, g.apple, g.play, `${g.diffPct.toFixed(1)} %`])),
|
|
].join('\n');
|
|
}
|
|
|
|
export function listingsMd(app: string, l: ListingText): string {
|
|
const part = (title: string, f: { path: string; text: string } | null) => (f ? [`<!-- ${title}: ${f.path.split(/[\\/]/).slice(-2).join('/')} -->`, f.text.trim(), ''] : []);
|
|
return [`# ${app}: store listings`, '', ...part('Google Play', l.play), ...part('App Store', l.apple)].join('\n');
|
|
}
|
|
|
|
export function portfolioCsv(rows: { name: string; studio: string; s?: AppStatus }[]): string {
|
|
return csv(['app', 'studio', 'ios bundle id', 'android package', 'ios version', 'ios state', 'android version', 'android state', 'next version', 'config problems', 'config warnings'],
|
|
rows.map(({ name, studio, s }) => {
|
|
const cur = s?.release.current;
|
|
return [name, studio, s?.app?.platforms.ios?.bundleId ?? '', s?.app?.platforms.android?.packageName ?? '', cur?.ios?.version ?? '', cur?.ios?.phase ?? '', cur?.android?.version ?? '',
|
|
cur?.android?.phase ?? '', s?.release.next?.version ?? '', s?.check?.problems.length ?? '', s?.check?.warnings.length ?? ''];
|
|
}));
|
|
}
|
|
|
|
export function declarationsMd(app: string, d: DeclarationsResult): string {
|
|
const yes = (b: boolean | null | undefined) => (b === null || b === undefined ? 'not answered' : b ? 'yes' : 'no');
|
|
const out = [`# ${app}: store declarations`, '', 'From storebound.config.ts and .storebound/ (contact and demo account masked).', ''];
|
|
if (d.ask.length) out.push('## Ask the owner', '', mdTable(['area', 'question', 'how to answer'], d.ask.map(a => [a.area, a.text, a.fix])));
|
|
if (d.dataSafety) out.push('## Google Play Data safety', '', `Collects data: ${yes(d.dataSafety.collectsData)} · encrypted in transit: ${yes(d.dataSafety.encryptedInTransit)}${d.dataSafety.accountCreation.includes('PSL_ACM_NONE') ? '' : ` · data deletion without deleting the account: ${d.dataSafety.partialDeletion === 'auto90' ? 'no, deleted automatically within 90 days' : yes(d.dataSafety.partialDeletion)}`}`, '',
|
|
mdTable(['data type', 'category', 'collected for', 'shared for', 'optional', 'ephemeral'], d.dataSafety.types.map(x => [x.id, x.category, x.collect.join(', '), x.shared ? x.share.join(', ') : 'not shared', yes(x.optional), yes(x.ephemeral)])));
|
|
if (d.appPrivacy) out.push('## App Store App Privacy (enter in App Store Connect)', '', mdTable(['data type', 'category', 'purposes', 'third party', 'from'], d.appPrivacy.items.map(x => [x.apple, x.category, x.purposes.join(', '), yes(x.thirdParty), x.from.join(', ')])));
|
|
if (d.appInfo) out.push('## App Store app info', '', `Category: ${d.appInfo.categories ? [d.appInfo.categories.primary, d.appInfo.categories.primarySubOne, d.appInfo.categories.primarySubTwo].filter(Boolean).join(' / ') : 'not set'}`,
|
|
`Age rating overrides: ${d.appInfo.ageRating.map(a => `${a.question}=${a.value}`).join(', ') || 'none (every question none / false)'}`, `Content rights: ${d.appInfo.contentRights ?? 'not set'}`, `Export compliance (non-exempt encryption): ${yes(d.encryption)}`, '');
|
|
out.push('## App Review', '', `Sign-in required: ${yes(d.review.signInRequired)} · contact: ${d.review.contact ?? 'missing'} · demo account: ${d.review.demoAccount ?? 'none'} · notes: ${d.review.notesBytes} / ${d.review.notesMax} bytes`, '');
|
|
return out.join('\n');
|
|
}
|
|
|
|
export function analyticsCsv(a: AnalyticsCompare): string {
|
|
const dates = [...new Set([...a.series.apple.map(d => d.date), ...a.series.play.map(d => d.date)])].sort();
|
|
const ap = new Map(a.series.apple.map(d => [d.date, d] as const)), pl = new Map(a.series.play.map(d => [d.date, d] as const));
|
|
return csv(['date', 'app store impressions', 'app store page views', 'app store downloads', 'play visitors', 'play acquisitions', 'play installs'],
|
|
dates.map(d => [d, ap.get(d)?.impressions ?? '', ap.get(d)?.pageViews ?? '', ap.get(d)?.downloads ?? '', pl.get(d)?.visitors ?? '', pl.get(d)?.acquisitions ?? '', pl.get(d)?.installs ?? '']));
|
|
}
|