/** * Translator handoff for content/strings.json. * * node scripts/strings-csv.mjs export [--all] * Writes content/translations-ja.csv. By default only rows that still * need work: no Japanese yet, or Japanese recovered from the Builder * archive that a human has not confirmed. --all exports everything. * * node scripts/strings-csv.mjs import [--english] * Reads the file back and updates the "ja" values in * content/strings.json. Only the Japanese column is read; English and * every other field are ignored, so a translator cannot alter the source * text or the field wiring by editing their copy. * --english also applies edits to the English column - an owner copy * pass. Any row whose English moved and already had Japanese is flagged * needs_review, because Shopify ties a translation to a digest of its * source and the old Japanese is now stale. * * The CSV is written with a UTF-8 BOM so Excel opens Japanese correctly instead * of turning it into mojibake. */ import fs from 'node:fs'; import path from 'node:path'; const ROOT = process.cwd(); const SRC = path.join(ROOT, 'content/strings.json'); const OUT = path.join(ROOT, 'content/translations-ja.csv'); const COLUMNS = ['id', 'where', 'english', 'japanese', 'status', 'note_for_translator']; function toCsv(rows) { const esc = (v) => { const s = String(v ?? ''); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; return '' + [COLUMNS.join(','), ...rows.map((r) => r.map(esc).join(','))].join('\r\n') + '\r\n'; } /* RFC4180 parser: handles quoted fields, embedded commas, newlines, "" escapes */ function fromCsv(text) { const s = text.replace(/^/, ''); const rows = []; let row = [], field = '', quoted = false, i = 0; while (i < s.length) { const c = s[i]; if (quoted) { if (c === '"') { if (s[i + 1] === '"') { field += '"'; i += 2; continue; } quoted = false; i++; continue; } field += c; i++; continue; } if (c === '"') { quoted = true; i++; continue; } if (c === ',') { row.push(field); field = ''; i++; continue; } if (c === '\r') { i++; continue; } if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; } field += c; i++; } if (field.length || row.length) { row.push(field); rows.push(row); } return rows.filter((r) => r.some((x) => x !== '')); } function statusOf(row) { if (row.ja_intentionally_blank) return 'done - intentionally blank in Japanese'; if (!row.ja) return 'NEEDS TRANSLATION'; if (row.needs_review) return 'CHECK - recovered from old site'; return 'done'; } function noteOf(row) { if (row.owned_elsewhere) return 'Translate normally. This copy belongs to another workstream, so its Japanese is applied by them, not by this pipeline.'; if (row.target === 'locale') return 'Interface label. Keep {{ placeholders }} exactly as written.'; if (/_value$/.test(row.field || '')) return 'A measurement. Normally leave the numerals unchanged.'; if (row.needs_review) return 'Japanese came from the previous site; the English has since been reworded. Please check it still matches.'; return ''; } const [, , cmd, arg] = process.argv; const doc = JSON.parse(fs.readFileSync(SRC, 'utf8')); if (cmd === 'export') { const all = process.argv.includes('--all'); const entries = Object.entries(doc.strings); /* A row with no English is one the theme reads without a fallback, so the source text only exists in Shopify. Those must be pulled down first - handing a translator a blank English cell asks her to invent the copy. */ const noSource = entries.filter(([, r]) => !r.en); const rows = entries .filter(([, r]) => r.en) .filter(([, r]) => all || (!r.ja && !r.ja_intentionally_blank) || r.needs_review) .map(([id, r]) => [id, r.where || r.key || '', r.en, r.ja || '', statusOf(r), noteOf(r)]); try { fs.writeFileSync(OUT, toCsv(rows), 'utf8'); } catch (e) { /* Excel holds an exclusive lock on an open workbook, so a re-export while someone is looking at the sheet dies with a raw EBUSY stack. */ if (e.code === 'EBUSY' || e.code === 'EPERM') { console.error('cannot write content/translations-ja.csv - it is open in another program.'); console.error('Close it in Excel (or wherever) and run this again.'); process.exit(1); } throw e; } const need = rows.filter((r) => r[4] !== 'done').length; console.log('wrote content/translations-ja.csv'); console.log(' ' + rows.length + ' rows, ' + need + ' needing the translator'); console.log(' send this file as-is; she fills the "japanese" column only'); if (noSource.length) { console.log(''); console.log(' held back: ' + noSource.length + ' fields whose English lives only in Shopify.'); console.log(' Run the pull step first, then export again to include them.'); } } else if (cmd === 'import') { if (!arg) { console.error('usage: node scripts/strings-csv.mjs import '); process.exit(1); } const rows = fromCsv(fs.readFileSync(arg, 'utf8')); const head = rows.shift().map((h) => h.trim().toLowerCase()); const iId = head.indexOf('id'), iJa = head.indexOf('japanese'); if (iId < 0 || iJa < 0) { console.error('CSV must keep its "id" and "japanese" column headers'); process.exit(1); } /* The english column is ignored unless --english is passed. A translator editing her copy must not be able to reword the source or repoint a field; the owner doing a copy pass legitimately can. */ const takeEnglish = process.argv.includes('--english'); const iEn = head.indexOf('english'); let changed = 0, unknown = [], blank = 0, enChanged = [], unreviewed = []; for (const r of rows) { let id = (r[iId] || '').trim(); const ja = (r[iJa] || '').trim(); if (!id) continue; /* Ids gained the entry handle (type.handle.field) once multi-entry types joined the sheet. A CSV exported before that still says type.field, so accept it rather than throwing away a translator's work over a key format she never saw. */ if (!doc.strings[id] && id.split('.').length === 2) { const [t, f] = id.split('.'); if (doc.strings[t + '.live.' + f]) id = t + '.live.' + f; } if (!doc.strings[id]) { unknown.push(id); continue; } if (takeEnglish && iEn >= 0) { const en = (r[iEn] || '').trim(); if (en && en !== doc.strings[id].en) { doc.strings[id].en = en; /* Shopify ties a translation to a digest of its source text, so the moment the English moves the stored Japanese is stale - even though it still looks fine in the admin. Flag it rather than let it rot. */ if (doc.strings[id].ja) doc.strings[id].needs_review = true; doc.strings[id].en_source = 'owner'; enChanged.push(id); } } if (!ja) { blank++; continue; } /* Unchanged Japanese is not a review. A flagged row that comes back byte for byte identical has been looked at by nobody - the file simply made a round trip - so clearing needs_review here would launder unverified copy as verified. Only an actual edit counts. */ if (doc.strings[id].ja === ja) { if (doc.strings[id].needs_review) unreviewed.push(id); continue; } doc.strings[id].ja = ja; doc.strings[id].ja_source = 'translator'; if (!enChanged.includes(id)) delete doc.strings[id].needs_review; changed++; } fs.writeFileSync(SRC, JSON.stringify(doc, null, 2) + '\n', 'utf8'); console.log('updated content/strings.json'); console.log(' ' + changed + ' translations applied, ' + blank + ' left blank'); if (unreviewed.length) { console.log(' ' + unreviewed.length + ' rows came back unchanged and stay flagged for review'); } if (takeEnglish) { console.log(' ' + enChanged.length + ' English values changed'); if (enChanged.length) { console.log(' these need pushing to Shopify: node scripts/sync-strings.mjs --plan'); enChanged.slice(0, 8).forEach((i) => console.log(' ' + i)); const stale = enChanged.filter((i) => doc.strings[i].needs_review); if (stale.length) console.log(' ' + stale.length + ' had Japanese, now flagged for re-check'); } } else if (head.indexOf('english') >= 0) { console.log(' (English column ignored - pass --english to apply owner edits)'); } if (unknown.length) { console.log(' ' + unknown.length + ' unknown ids ignored (renamed or deleted since export):'); unknown.slice(0, 10).forEach((u) => console.log(' ' + u)); } const rest = Object.values(doc.strings).filter((x) => !x.ja).length; console.log(' still missing japanese: ' + rest); } else { console.error('usage: node scripts/strings-csv.mjs export [--all] | import '); process.exit(1); }