287 lines
20 KiB
TypeScript
287 lines
20 KiB
TypeScript
// Google Play Data safety form as Play's CSV import format (the same layout Play Console exports from the Data safety
|
|
// page: "Export to CSV"). One row per question / choice. Importing replaces every earlier answer. Pure.
|
|
// Play names a missing answer "Response missing for <ID>", a wrong choice "Invalid response ID <ID>" and a row whose
|
|
// question has choices but no Response ID "Line N: Response ID missing".
|
|
// Some questions are required only after another answer (DATA_SAFETY_RULES below); dataSafetyIssues finds a missing
|
|
// one offline, so a 400 like "Response missing for PSL_ACCOUNT_DELETION_URL" shows up in --dry and `check`.
|
|
// Every (Question ID, Response ID) pair and its Answer requirement come from Google's own empty form,
|
|
// cli/data/play-data-safety-form.csv (exported PLAY_FORM_EXPORTED). Refresh it when Play changes the form: Play Console >
|
|
// App content > Data safety > Export to CSV with nothing answered, saved over that file unchanged (docs/pitfalls.md 21).
|
|
import type { AccountsConfig, DataSafety, PlayPurpose } from './config';
|
|
import { csvCell, parseCsv } from './csv';
|
|
import { PLAY_DATA_SAFETY_FORM as FORM_TEXT } from './embedded';
|
|
|
|
export { csvCell };
|
|
|
|
/** Date of the Play Console export in cli/data/play-data-safety-form.csv. */
|
|
export const PLAY_FORM_EXPORTED = '2026-10-12';
|
|
export const PLAY_FORM_FILE = 'cli/data/play-data-safety-form.csv';
|
|
|
|
export interface PlayFormRow { question: string; response: string; requirement: string; label: string }
|
|
let formCache: Map<string, PlayFormRow> | null = null;
|
|
const pairKey = (q: string, r: string) => `${q}\u0000${r}`;
|
|
|
|
/** Google's empty Data safety form: every valid (Question ID, Response ID) pair with its Answer requirement and label. */
|
|
export function playForm(): Map<string, PlayFormRow> {
|
|
if (formCache) return formCache;
|
|
const m = new Map<string, PlayFormRow>();
|
|
for (const r of parseCsv(FORM_TEXT).slice(1)) {
|
|
const [question = '', response = '', , requirement = '', label = ''] = r;
|
|
m.set(pairKey(question, response), { question, response, requirement, label: label.trim() });
|
|
}
|
|
return (formCache = m);
|
|
}
|
|
export const formRow = (q: string, r = ''): PlayFormRow | undefined => playForm().get(pairKey(q, r));
|
|
|
|
export const PURPOSE_IDS: Record<PlayPurpose, string> = {
|
|
functionality: 'PSL_APP_FUNCTIONALITY',
|
|
analytics: 'PSL_ANALYTICS',
|
|
communications: 'PSL_DEVELOPER_COMMUNICATIONS',
|
|
fraud: 'PSL_FRAUD_PREVENTION_SECURITY',
|
|
advertising: 'PSL_ADVERTISING',
|
|
personalization: 'PSL_PERSONALIZATION',
|
|
account: 'PSL_ACCOUNT_MANAGEMENT',
|
|
};
|
|
const PURPOSES = Object.values(PURPOSE_IDS);
|
|
|
|
/** Every category and data type of the form, in form order. */
|
|
export const CATEGORIES: [string, string[]][] = [
|
|
['PSL_DATA_TYPES_PERSONAL', ['PSL_NAME', 'PSL_EMAIL', 'PSL_USER_ACCOUNT', 'PSL_ADDRESS', 'PSL_PHONE', 'PSL_RACE_ETHNICITY', 'PSL_POLITICAL_RELIGIOUS', 'PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY', 'PSL_OTHER_PERSONAL']],
|
|
['PSL_DATA_TYPES_FINANCIAL', ['PSL_PURCHASE_HISTORY', 'PSL_CREDIT_SCORE', 'PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER', 'PSL_OTHER']],
|
|
['PSL_DATA_TYPES_LOCATION', ['PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION']],
|
|
['PSL_DATA_TYPES_SEARCH_AND_BROWSING', ['PSL_WEB_BROWSING_HISTORY']],
|
|
['PSL_DATA_TYPES_EMAIL_AND_TEXT', ['PSL_EMAILS', 'PSL_SMS_CALL_LOG', 'PSL_OTHER_MESSAGES']],
|
|
['PSL_DATA_TYPES_PHOTOS_AND_VIDEOS', ['PSL_PHOTOS', 'PSL_VIDEOS']],
|
|
['PSL_DATA_TYPES_AUDIO', ['PSL_AUDIO', 'PSL_MUSIC', 'PSL_OTHER_AUDIO']],
|
|
['PSL_DATA_TYPES_HEALTH_AND_FITNESS', ['PSL_HEALTH', 'PSL_FITNESS']],
|
|
['PSL_DATA_TYPES_CONTACTS', ['PSL_CONTACTS']],
|
|
['PSL_DATA_TYPES_CALENDAR', ['PSL_CALENDAR']],
|
|
['PSL_DATA_TYPES_APP_PERFORMANCE', ['PSL_CRASH_LOGS', 'PSL_PERFORMANCE_DIAGNOSTICS', 'PSL_OTHER_PERFORMANCE']],
|
|
['PSL_DATA_TYPES_FILES_AND_DOCS', ['PSL_FILES_AND_DOCS']],
|
|
['PSL_DATA_TYPES_APP_ACTIVITY', ['PSL_USER_INTERACTION', 'PSL_IN_APP_SEARCH_HISTORY', 'PSL_APPS_ON_DEVICE', 'PSL_USER_GENERATED_CONTENT', 'PSL_OTHER_APP_ACTIVITY']],
|
|
['PSL_DATA_TYPES_IDENTIFIERS', ['PSL_DEVICE_ID']],
|
|
];
|
|
export const ALL_TYPES = new Set(CATEGORIES.flatMap(([, t]) => t));
|
|
|
|
const HEADER = ['Question ID (machine readable)', 'Response ID (machine readable)', 'Response value', 'Answer requirement', 'Human-friendly question label'];
|
|
|
|
/** The accounts part of the config the form needs (the delete-account URL). */
|
|
export type DataSafetyAccounts = Pick<AccountsConfig, 'deletionUrl'> | undefined;
|
|
|
|
/** PSL_SUPPORT_DATA_DELETION_BY_USER responses ("Do you provide a way for users to request that their data is deleted?"). */
|
|
export const DATA_DELETION_RESPONSE = { yes: 'DATA_DELETION_YES', no: 'DATA_DELETION_NO', auto90: 'DATA_DELETION_NO_AUTO_DELETED' } as const;
|
|
/** @deprecated name kept for imports */
|
|
export const PARTIAL_DELETION_RESPONSE = DATA_DELETION_RESPONSE;
|
|
|
|
/** The answer to the general data deletion question. It is the general question, not "without deleting the account":
|
|
* a delete-account page (accounts.deletionUrl) is a way to request deletion, so it means yes. Order: dataSafety.dataDeletion,
|
|
* then a delete-account URL -> yes, then deletionRequest (the same question under the form's old id), then the older partialDeletion values (true -> yes, 'auto90'). Pure. */
|
|
export function dataDeletionResponse(ds: DataSafety, accounts?: DataSafetyAccounts): string | undefined {
|
|
if (ds.dataDeletion) return DATA_DELETION_RESPONSE[ds.dataDeletion];
|
|
if (accounts?.deletionUrl) return DATA_DELETION_RESPONSE.yes;
|
|
if (ds.deletionRequest !== undefined) return ds.deletionRequest ? DATA_DELETION_RESPONSE.yes : DATA_DELETION_RESPONSE.no;
|
|
if (ds.partialDeletion === true) return DATA_DELETION_RESPONSE.yes;
|
|
if (ds.partialDeletion === 'auto90') return DATA_DELETION_RESPONSE.auto90;
|
|
return undefined;
|
|
}
|
|
|
|
export function dataSafetyRows(ds: DataSafety, accounts?: DataSafetyAccounts): string[][] {
|
|
for (const t of Object.keys(ds.data)) if (!ALL_TYPES.has(t)) throw new Error(`dataSafety: unknown data type ${t}`);
|
|
const b = (v: boolean) => (v ? 'TRUE' : 'FALSE');
|
|
const pick = (v: boolean) => (v ? 'TRUE' : '');
|
|
const rows: string[][] = [HEADER];
|
|
// Answer requirement and label come from Play's exported form; a pair it does not have keeps `fallback` and
|
|
// formPairIssues reports it (Play would answer 400).
|
|
const row = (q: string, r: string, v: string, fallback: string, label: string): string[] => {
|
|
const f = formRow(q, r);
|
|
return [q, r, v, f?.requirement ?? fallback, f?.label || label];
|
|
};
|
|
const add = (q: string, r: string, v: string, fallback: string, label: string) => { rows.push(row(q, r, v, fallback, label)); };
|
|
add('PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', '', b(ds.collectsData), 'REQUIRED', 'Does your app collect or share any of the required user data types?');
|
|
add('PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', '', b(ds.encryptedInTransit), 'MAYBE_REQUIRED', 'Is all of the user data collected by your app encrypted in transit?');
|
|
// dataSafety.deletionRequest: the form's old id PSL_DATA_COLLECTION_USER_REQUEST_DELETE is gone; the same question is
|
|
// PSL_SUPPORT_DATA_DELETION_BY_USER below (dataDeletionResponse).
|
|
const acm = ds.accountCreation ?? ['PSL_ACM_NONE'];
|
|
for (const id of acm) add('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`);
|
|
// Data deletion questions Play asks once the app lets people create an account (Play Console Help 13327111).
|
|
// An empty value is a missing answer: dataSafetyIssues names it with the config field to set.
|
|
if (!acm.includes('PSL_ACM_NONE')) {
|
|
add('PSL_ACCOUNT_DELETION_URL', '', accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete account URL');
|
|
// a SINGLE_CHOICE question: one row per option, TRUE on the picked one (a value without a Response ID is a 400)
|
|
const picked = dataDeletionResponse(ds, accounts);
|
|
for (const r of Object.values(DATA_DELETION_RESPONSE))
|
|
add('PSL_SUPPORT_DATA_DELETION_BY_USER', r, pick(picked === r), 'SINGLE_CHOICE', `Data deletion / ${r}`);
|
|
// "Delete data URL": the data-only page when there is one (partialDeletionUrl), else the delete-account page, which
|
|
// also deletes the data (Play answered 400 "Response missing for PSL_DATA_DELETION_URL" to a yes without it)
|
|
if (picked === DATA_DELETION_RESPONSE.yes) add('PSL_DATA_DELETION_URL', '', ds.partialDeletionUrl ?? accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete data URL');
|
|
}
|
|
for (const [cat, types] of CATEGORIES) for (const t of types) add(cat, t, pick(!!ds.data[t]), 'MULTIPLE_CHOICE', `${cat} / ${t}`);
|
|
for (const [, types] of CATEGORIES) for (const t of types) {
|
|
const u = ds.data[t];
|
|
const q = (s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
|
|
const label = `Data usage and handling (${t})`;
|
|
const collect = new Set((u?.collect ?? []).map(p => PURPOSE_IDS[p]));
|
|
const share = new Set((u?.shared ? u.share ?? [] : []).map(p => PURPOSE_IDS[p]));
|
|
add(q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_COLLECTED', pick(!!u), 'MULTIPLE_CHOICE', label + ' / Collected');
|
|
add(q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED', pick(!!u?.shared), 'MULTIPLE_CHOICE', label + ' / Shared');
|
|
add(q('PSL_DATA_USAGE_EPHEMERAL'), '', u ? b(!!u.ephemeral) : '', 'MAYBE_REQUIRED', label + ' / Processed ephemerally');
|
|
add(q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL', pick(!!u?.optional), 'SINGLE_CHOICE', label + ' / Optional');
|
|
add(q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_REQUIRED', pick(!!u && !u.optional), 'SINGLE_CHOICE', label + ' / Required');
|
|
for (const p of PURPOSES) add(q('DATA_USAGE_COLLECTION_PURPOSE'), p, pick(collect.has(p)), 'MULTIPLE_CHOICE', label + ' / Collection purpose');
|
|
for (const p of PURPOSES) add(q('DATA_USAGE_SHARING_PURPOSE'), p, pick(share.has(p)), 'MULTIPLE_CHOICE', label + ' / Sharing purpose');
|
|
}
|
|
// an extra row for a question storebound generates replaces that question (all its response rows)
|
|
const replaced = new Set((ds.extra ?? []).map(x => x.question));
|
|
const out = rows.filter((r, i) => i === 0 || !replaced.has(r[0]!));
|
|
for (const x of ds.extra ?? []) {
|
|
const r = row(x.question, x.response ?? '', x.value, x.response ? 'MULTIPLE_CHOICE' : 'MAYBE_REQUIRED', x.question);
|
|
if (x.label) r[4] = x.label;
|
|
out.push(r);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* (Question ID, Response ID) pairs of a CSV that are not in Play's current form (cli/data/play-data-safety-form.csv).
|
|
* Play refuses the whole import for one of them ("Response ID missing", "Invalid response ID"). Pure.
|
|
*/
|
|
export function formPairIssues(rows: string[][]): DataSafetyIssue[] {
|
|
const out: DataSafetyIssue[] = [];
|
|
for (const r of rows) {
|
|
const q = r[0] ?? '', resp = r[1] ?? '';
|
|
if (!q || q === HEADER[0] || formRow(q, resp)) continue;
|
|
out.push({ question: q, msg: `${q},${resp} is not in Play's current Data safety form (${PLAY_FORM_FILE}, exported ${PLAY_FORM_EXPORTED}); Play answers 400` });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const dataSafetyCsv = (ds: DataSafety, accounts?: DataSafetyAccounts): string => dataSafetyRows(ds, accounts).map(r => r.map(csvCell).join(',')).join('\n') + '\n';
|
|
|
|
// ---- required-row rules ----
|
|
|
|
/** Answers of a CSV, read back from its rows (the header row is skipped). */
|
|
export class DataSafetyAnswers {
|
|
private readonly values = new Map<string, string>();
|
|
private readonly picks = new Map<string, string[]>();
|
|
readonly questions = new Set<string>();
|
|
constructor(rows: string[][]) {
|
|
for (const r of rows) {
|
|
const q = r[0] ?? '', resp = r[1] ?? '', v = (r[2] ?? '').trim();
|
|
if (!q || q === HEADER[0]) continue;
|
|
this.questions.add(q);
|
|
if (!resp) this.values.set(q, v);
|
|
else if (v.toUpperCase() === 'TRUE') this.picks.set(q, [...(this.picks.get(q) ?? []), resp]);
|
|
}
|
|
}
|
|
/** The value of a free-text / TRUE-FALSE question ('' when missing or empty). */
|
|
value(q: string): string { return this.values.get(q) ?? ''; }
|
|
/** Response ids of a choice question marked TRUE. */
|
|
chosen(q: string): string[] { return this.picks.get(q) ?? []; }
|
|
answered(q: string): boolean { return this.questions.has(q); }
|
|
/** Account creation answered with a method other than "none". */
|
|
get accounts(): boolean { const c = this.chosen('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS'); return c.length > 0 && !c.includes('PSL_ACM_NONE'); }
|
|
get collects(): boolean { return this.value('PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA').toUpperCase() === 'TRUE'; }
|
|
}
|
|
|
|
export interface DataSafetyRule {
|
|
question: string;
|
|
/** When Play requires the answer (for need 'absent': when Play refuses the question). */
|
|
when: (a: DataSafetyAnswers) => boolean;
|
|
/** The condition in words, for messages and docs. */
|
|
condition: string;
|
|
need: 'bool' | 'url' | 'choice' | 'one-choice' | 'absent' | 'yes';
|
|
/** What to set in storebound.config.ts. */
|
|
fix: string;
|
|
}
|
|
|
|
/**
|
|
* Form-level rules: question ids Play requires (or refuses) depending on other answers. Sources: Play Console Help
|
|
* 10787469 (Data safety form, CSV import / export) and 13327111 (account deletion: apps with account creation give a
|
|
* delete-account web link; data-only deletion is a separate question), plus Play's own 400 answers to imports
|
|
* ("Response missing for PSL_ACCOUNT_DELETION_URL", "Response missing for PSL_DATA_DELETION_URL",
|
|
* "You cannot answer PSL_HAS_OUTSIDE_APP_ACCOUNTS").
|
|
*/
|
|
export const DATA_SAFETY_RULES: DataSafetyRule[] = [
|
|
{ question: 'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', when: () => true, condition: 'always', need: 'bool', fix: 'dataSafety.collectsData' },
|
|
{ question: 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', when: a => a.collects, condition: 'the app collects data', need: 'bool', fix: 'dataSafety.encryptedInTransit' },
|
|
{ question: 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', when: () => true, condition: 'always', need: 'choice', fix: "dataSafety.accountCreation (['PSL_ACM_NONE'] when there are no accounts)" },
|
|
{ question: 'PSL_ACCOUNT_DELETION_URL', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'url', fix: 'accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill)' },
|
|
{ question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'one-choice', fix: "accounts.deletionUrl (a delete-account page answers yes), or dataSafety.dataDeletion: 'yes' | 'no' | 'auto90'" },
|
|
{ question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', when: a => !!a.value('PSL_ACCOUNT_DELETION_URL') && a.chosen('PSL_SUPPORT_DATA_DELETION_BY_USER').length > 0 && !a.chosen('PSL_SUPPORT_DATA_DELETION_BY_USER').includes('DATA_DELETION_YES'), condition: 'PSL_ACCOUNT_DELETION_URL is set: a delete-account page is a way to request deletion, so the answer must be DATA_DELETION_YES', need: 'yes', fix: "dataSafety.dataDeletion: 'yes', or leave it out" },
|
|
{ question: 'PSL_DATA_DELETION_URL', when: a => a.chosen('PSL_SUPPORT_DATA_DELETION_BY_USER').includes('DATA_DELETION_YES'), condition: 'PSL_SUPPORT_DATA_DELETION_BY_USER is DATA_DELETION_YES', need: 'url', fix: 'dataSafety.partialDeletionUrl (default accounts.deletionUrl)' },
|
|
{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE; Play accepts it only with PSL_ACM_NONE', need: 'absent', fix: 'remove it from dataSafety.extra' },
|
|
];
|
|
|
|
const usage = (t: string, s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
|
|
|
|
/** Rules for one data type, derived from the form's structure (each type of CATEGORIES has the same usage block). */
|
|
export function typeRules(t: string): DataSafetyRule[] {
|
|
const how = (a: DataSafetyAnswers) => a.chosen(usage(t, 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'));
|
|
const collected = (a: DataSafetyAnswers) => how(a).includes('PSL_DATA_USAGE_ONLY_COLLECTED');
|
|
const shared = (a: DataSafetyAnswers) => how(a).includes('PSL_DATA_USAGE_ONLY_SHARED');
|
|
const selected = (a: DataSafetyAnswers) => CATEGORIES.some(([c]) => a.chosen(c).includes(t));
|
|
const fix = `dataSafety.data.${t}`;
|
|
return [
|
|
{ question: usage(t, 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'), when: selected, condition: `${t} is selected`, need: 'choice', fix: `${fix} (collected and / or shared)` },
|
|
{ question: usage(t, 'PSL_DATA_USAGE_EPHEMERAL'), when: collected, condition: `${t} is collected`, need: 'bool', fix: `${fix}.ephemeral` },
|
|
{ question: usage(t, 'DATA_USAGE_USER_CONTROL'), when: collected, condition: `${t} is collected`, need: 'one-choice', fix: `${fix}.optional` },
|
|
{ question: usage(t, 'DATA_USAGE_COLLECTION_PURPOSE'), when: collected, condition: `${t} is collected`, need: 'choice', fix: `${fix}.collect` },
|
|
{ question: usage(t, 'DATA_USAGE_SHARING_PURPOSE'), when: shared, condition: `${t} is shared`, need: 'choice', fix: `${fix}.share` },
|
|
];
|
|
}
|
|
|
|
export interface DataSafetyIssue { question: string; msg: string }
|
|
|
|
/**
|
|
* Check of a Data safety CSV (rows as dataSafetyRows returns them, or a parsed Play export): every pair is in Play's
|
|
* current form (formPairIssues) and every conditionally required row is there. Pure.
|
|
*/
|
|
export function dataSafetyIssues(rows: string[][]): DataSafetyIssue[] {
|
|
const a = new DataSafetyAnswers(rows);
|
|
const out: DataSafetyIssue[] = formPairIssues(rows);
|
|
for (const r of [...DATA_SAFETY_RULES, ...[...ALL_TYPES].flatMap(typeRules)]) {
|
|
if (!r.when(a)) continue;
|
|
const v = a.value(r.question), c = a.chosen(r.question);
|
|
const say = (what: string) => out.push({ question: r.question, msg: `${what}: ${r.question} (${r.condition}; set ${r.fix})` });
|
|
if (r.need === 'yes') { say(`must be DATA_DELETION_YES (is ${c.join(', ')})`); continue; }
|
|
if (r.need === 'absent') { if (a.answered(r.question)) say('not allowed'); }
|
|
else if (r.need === 'bool') { if (!/^(TRUE|FALSE)$/i.test(v)) say(v ? `not TRUE / FALSE ("${v}")` : 'missing'); }
|
|
else if (r.need === 'url') { if (!/^https:\/\/\S+$/.test(v)) say(v ? `not an https URL ("${v}")` : 'missing'); }
|
|
else if (r.need === 'choice') { if (!c.length) say('missing'); }
|
|
else if (c.length !== 1) say(c.length ? `${c.length} answers to a single choice` : 'missing');
|
|
}
|
|
const acm = a.chosen('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS');
|
|
if (acm.includes('PSL_ACM_NONE') && acm.length > 1)
|
|
out.push({ question: 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', msg: 'PSL_ACM_NONE together with sign-up methods: PSL_SUPPORTED_ACCOUNT_CREATION_METHODS (set dataSafety.accountCreation to PSL_ACM_NONE or to the methods)' });
|
|
if (a.collects && !CATEGORIES.some(([cat]) => a.chosen(cat).length))
|
|
out.push({ question: 'PSL_DATA_TYPES_*', msg: 'missing: a data type (collectsData is true; list the types in dataSafety.data or set collectsData: false)' });
|
|
return out;
|
|
}
|
|
|
|
/** The issues as one error message, or null. */
|
|
export function dataSafetyProblem(rows: string[][]): string | null {
|
|
const i = dataSafetyIssues(rows);
|
|
return i.length ? `Data safety CSV is not complete (Play would answer 400):\n${i.map(x => ` - ${x.msg}`).join('\n')}` : null;
|
|
}
|
|
|
|
export interface DataSafetyChange { question: string; response: string; label: string; from: string; to: string }
|
|
|
|
/**
|
|
* The answers that change between two Data safety CSVs (Play cannot read the form back, so `before` is the CSV this
|
|
* project uploaded last; null = nothing recorded, every answer is new). One entry per (question, response) whose value
|
|
* differs; a removed answer shows as old -> (empty). Pure.
|
|
*/
|
|
export function dataSafetyDiff(before: string | null, rows: string[][]): DataSafetyChange[] {
|
|
type V = { q: string; r: string; v: string; label: string };
|
|
const vals = (rs: string[][]) => new Map<string, V>(rs.slice(1).filter(r => r[0]).map(r => [pairKey(r[0]!, r[1] ?? ''), { q: r[0]!, r: r[1] ?? '', v: r[2] ?? '', label: (r[4] ?? '').trim() }]));
|
|
const old = before ? vals(parseCsv(before)) : new Map<string, V>();
|
|
const cur = vals(rows);
|
|
const out: DataSafetyChange[] = [];
|
|
for (const k of new Set([...old.keys(), ...cur.keys()])) {
|
|
const o = old.get(k), n = cur.get(k);
|
|
if ((o?.v ?? '') === (n?.v ?? '')) continue;
|
|
const x = (n ?? o)!;
|
|
out.push({ question: x.q, response: x.r, label: x.label || (formRow(x.q, x.r)?.label ?? ''), from: o?.v ?? '', to: n?.v ?? '' });
|
|
}
|
|
return out;
|
|
}
|