@verbatra/sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ import { GoogleGenAI } from '@google/genai';
10
10
  import OpenAI from 'openai';
11
11
  import { access, writeFile, rename, rm, open } from 'fs/promises';
12
12
  import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
13
+ import ExcelJS from 'exceljs';
14
+ import JSZip from 'jszip';
13
15
  import { watch as watch$1 } from 'chokidar';
14
16
 
15
17
  // src/config/define-config.ts
@@ -880,9 +882,41 @@ async function readBounded(path, maxBytes) {
880
882
  await handle.close();
881
883
  }
882
884
  }
885
+ async function readBoundedBytesInto(handle, size) {
886
+ const buffer = Buffer.allocUnsafeSlow(size);
887
+ let offset = 0;
888
+ while (offset < size) {
889
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
890
+ if (bytesRead === 0) {
891
+ break;
892
+ }
893
+ offset += bytesRead;
894
+ }
895
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
896
+ }
897
+ async function readBoundedBytes(path, maxBytes) {
898
+ let handle;
899
+ try {
900
+ handle = await open(path, "r");
901
+ } catch {
902
+ return { kind: "missing" };
903
+ }
904
+ try {
905
+ const info = await handle.stat();
906
+ if (!info.isFile()) {
907
+ return { kind: "missing" };
908
+ }
909
+ if (info.size > maxBytes) {
910
+ return { kind: "too-large" };
911
+ }
912
+ return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
913
+ } finally {
914
+ await handle.close();
915
+ }
916
+ }
883
917
  async function atomicWrite(path, data) {
884
918
  const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}`);
885
- await writeFile(tmp, data, "utf8");
919
+ await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
886
920
  try {
887
921
  await rename(tmp, path);
888
922
  } catch (error) {
@@ -900,7 +934,9 @@ var defaultFs = {
900
934
  }
901
935
  },
902
936
  readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
903
- writeFile: (path, data) => atomicWrite(path, data)
937
+ readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
938
+ writeFile: (path, data) => atomicWrite(path, data),
939
+ writeBytes: (path, data) => atomicWrite(path, data)
904
940
  };
905
941
  var LOCK_FILE_NAME = "verbatra.lock.json";
906
942
  var CURRENT_VERSION = 1;
@@ -1147,6 +1183,7 @@ function createJsonFileAdapter(options) {
1147
1183
  deriveEntry,
1148
1184
  extractPlaceholders: extractPlaceholders2,
1149
1185
  computeInvalidIcuKeys: computeInvalidIcuKeys2,
1186
+ validateMessage: validateMessage2,
1150
1187
  validateTree,
1151
1188
  buildWriteTree
1152
1189
  } = options;
@@ -1154,6 +1191,8 @@ function createJsonFileAdapter(options) {
1154
1191
  format,
1155
1192
  canHandle,
1156
1193
  extractPlaceholders: extractPlaceholders2,
1194
+ // Non-ICU formats supply no validator: every value is valid for their syntax.
1195
+ validateMessage: validateMessage2 ?? (() => true),
1157
1196
  async read(filePath, locale) {
1158
1197
  const outcome = await readBounded2(filePath);
1159
1198
  if (outcome.kind === "not-a-file") {
@@ -1269,10 +1308,13 @@ function analyzeIcuValue(value) {
1269
1308
  function extractPlaceholders(value) {
1270
1309
  return analyzeIcuValue(value).placeholders;
1271
1310
  }
1311
+ function validateMessage(value) {
1312
+ return analyzeIcuValue(value).valid;
1313
+ }
1272
1314
  function computeInvalidIcuKeys(entries) {
1273
1315
  const invalid = [];
1274
1316
  for (const [key, entry] of entries) {
1275
- if (!analyzeIcuValue(entry.value).valid) {
1317
+ if (!validateMessage(entry.value)) {
1276
1318
  invalid.push(key);
1277
1319
  }
1278
1320
  }
@@ -1286,7 +1328,8 @@ function createNextIntlJsonAdapter() {
1286
1328
  const analysis = analyzeIcuValue(value);
1287
1329
  return { placeholders: analysis.placeholders, isPlural: analysis.isPlural };
1288
1330
  },
1289
- computeInvalidIcuKeys
1331
+ computeInvalidIcuKeys,
1332
+ validateMessage
1290
1333
  });
1291
1334
  }
1292
1335
  function assertNotMixed(tree) {
@@ -1453,6 +1496,33 @@ function selectProvider(config, createProvider = buildProvider) {
1453
1496
  }
1454
1497
  }
1455
1498
 
1499
+ // src/flow/locale-failure.ts
1500
+ function describeError(error) {
1501
+ if (error instanceof Error) {
1502
+ const code = error.code;
1503
+ return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
1504
+ }
1505
+ return { code: "LOCALE_FAILED", message: String(error) };
1506
+ }
1507
+ function failureSummary(locale, error) {
1508
+ return {
1509
+ locale,
1510
+ status: "failed",
1511
+ translated: [],
1512
+ unchanged: [],
1513
+ orphaned: [],
1514
+ invalidIcuSource: [],
1515
+ integrityMismatches: [],
1516
+ notices: [],
1517
+ error: describeError(error)
1518
+ };
1519
+ }
1520
+ function partition(locales) {
1521
+ const succeeded = locales.filter((s) => s.status === "succeeded").map((s) => s.locale);
1522
+ const failed = locales.filter((s) => s.status === "failed").map((s) => s.locale);
1523
+ return { succeeded, failed };
1524
+ }
1525
+
1456
1526
  // src/flow/notices.ts
1457
1527
  function isNotice(value) {
1458
1528
  return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
@@ -1578,27 +1648,7 @@ function computeLockEntries(params, merged, withheld) {
1578
1648
  return lockEntries;
1579
1649
  }
1580
1650
 
1581
- // src/flow/translate-project.ts
1582
- function describeError(error) {
1583
- if (error instanceof Error) {
1584
- const code = error.code;
1585
- return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
1586
- }
1587
- return { code: "LOCALE_FAILED", message: String(error) };
1588
- }
1589
- function failureSummary(locale, error) {
1590
- return {
1591
- locale,
1592
- status: "failed",
1593
- translated: [],
1594
- unchanged: [],
1595
- orphaned: [],
1596
- invalidIcuSource: [],
1597
- integrityMismatches: [],
1598
- notices: [],
1599
- error: describeError(error)
1600
- };
1601
- }
1651
+ // src/flow/source.ts
1602
1652
  async function readSource(config, cwd, fs, adapter) {
1603
1653
  const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
1604
1654
  if (!await fs.fileExists(sourcePath)) {
@@ -1617,6 +1667,8 @@ async function readSource(config, cwd, fs, adapter) {
1617
1667
  );
1618
1668
  }
1619
1669
  }
1670
+
1671
+ // src/flow/translate-project.ts
1620
1672
  async function translate2(input, deps = {}) {
1621
1673
  const config = input.config;
1622
1674
  const cwd = input.cwd ?? process.cwd();
@@ -1655,12 +1707,594 @@ async function translate2(input, deps = {}) {
1655
1707
  summaries.push(failureSummary(targetLocale, error));
1656
1708
  }
1657
1709
  }
1658
- return aggregate(dryRun, summaries);
1710
+ const { succeeded, failed } = partition(summaries);
1711
+ return { dryRun, locales: summaries, succeeded, failed };
1659
1712
  }
1660
- function aggregate(dryRun, locales) {
1661
- const succeeded = locales.filter((s) => s.status === "succeeded").map((s) => s.locale);
1662
- const failed = locales.filter((s) => s.status === "failed").map((s) => s.locale);
1663
- return { dryRun, locales, succeeded, failed };
1713
+ var ExchangeError = class extends Error {
1714
+ code;
1715
+ constructor(code, message) {
1716
+ super(message);
1717
+ this.name = "ExchangeError";
1718
+ this.code = code;
1719
+ }
1720
+ };
1721
+ var INSTRUCTIONS_LINES = [
1722
+ "How to use this workbook",
1723
+ "",
1724
+ "1. There is one sheet per language. Open the sheet named for the language you are translating.",
1725
+ "2. Fill ONLY the 'Translation' column. Leave every other column unchanged.",
1726
+ "3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty string.",
1727
+ "4. Keep placeholders exactly as they appear. A token such as {name} or {count} must stay verbatim,",
1728
+ " in your translation, with the same spelling. Do not translate or remove it.",
1729
+ "5. For ICU messages (for example {count, plural, one {# item} other {# items}}), keep the ICU",
1730
+ " structure and the argument names exactly. Translate only the human-readable text.",
1731
+ "6. Do not edit the 'Key' column or the hidden 'Source hash' column. They map your translation",
1732
+ " back to the right string. You may sort or filter rows freely; mapping does not depend on row order.",
1733
+ "",
1734
+ "Status values:",
1735
+ " new - this string has no translation yet.",
1736
+ " changed - the source string changed and the translation needs updating.",
1737
+ "",
1738
+ "When you are done, save the file and send it back. verbatra checks every value on import:",
1739
+ "a value with a broken placeholder, invalid ICU, or a source that changed since export is",
1740
+ "withheld (not written) and reported so you can fix and resubmit it."
1741
+ ];
1742
+ var COLUMN = {
1743
+ key: 1,
1744
+ source: 2,
1745
+ current: 3,
1746
+ status: 4,
1747
+ translation: 5,
1748
+ sourceHash: 6
1749
+ };
1750
+ var HEADERS = [
1751
+ "Key",
1752
+ "Source",
1753
+ "Current translation",
1754
+ "Status",
1755
+ "Translation",
1756
+ "Source hash"
1757
+ ];
1758
+ var HEADER_ROW = 1;
1759
+ var INSTRUCTIONS_SHEET_NAME = "Instructions";
1760
+ var READ_ONLY_FILL = {
1761
+ type: "pattern",
1762
+ pattern: "solid",
1763
+ fgColor: { argb: "FFF1F3F5" }
1764
+ };
1765
+ var HEADER_FILL = {
1766
+ type: "pattern",
1767
+ pattern: "solid",
1768
+ fgColor: { argb: "FFDDE3EA" }
1769
+ };
1770
+ var COLUMN_WIDTHS = {
1771
+ [COLUMN.key]: 36,
1772
+ [COLUMN.source]: 50,
1773
+ [COLUMN.current]: 50,
1774
+ [COLUMN.status]: 12,
1775
+ [COLUMN.translation]: 50
1776
+ };
1777
+ function styleHeader(sheet) {
1778
+ const header = sheet.getRow(HEADER_ROW);
1779
+ HEADERS.forEach((label, index) => {
1780
+ const cell = header.getCell(index + 1);
1781
+ cell.value = label;
1782
+ cell.font = { bold: true };
1783
+ cell.fill = HEADER_FILL;
1784
+ });
1785
+ header.commit();
1786
+ }
1787
+ function applyColumnGeometry(sheet) {
1788
+ for (const [column, width] of Object.entries(COLUMN_WIDTHS)) {
1789
+ sheet.getColumn(Number(column)).width = width;
1790
+ }
1791
+ sheet.getColumn(COLUMN.sourceHash).hidden = true;
1792
+ sheet.views = [{ state: "frozen", ySplit: HEADER_ROW }];
1793
+ }
1794
+ function writeRow(sheet, sheetRow) {
1795
+ const row = sheet.addRow([]);
1796
+ row.getCell(COLUMN.key).value = sheetRow.key;
1797
+ row.getCell(COLUMN.source).value = sheetRow.source;
1798
+ row.getCell(COLUMN.current).value = sheetRow.currentTarget;
1799
+ row.getCell(COLUMN.status).value = sheetRow.status;
1800
+ row.getCell(COLUMN.translation).value = sheetRow.translation === "" ? null : sheetRow.translation;
1801
+ row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
1802
+ for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
1803
+ const cell = row.getCell(column);
1804
+ cell.protection = { locked: column !== COLUMN.translation };
1805
+ if (column !== COLUMN.translation) {
1806
+ cell.fill = READ_ONLY_FILL;
1807
+ }
1808
+ }
1809
+ row.commit();
1810
+ }
1811
+ var MAX_WORKSHEET_NAME_LENGTH = 31;
1812
+ var FORBIDDEN_WORKSHEET_NAME_CHARS = /[:\\/?*[\]]/;
1813
+ function assertValidWorksheetName(locale) {
1814
+ if (locale.length === 0 || locale.length > MAX_WORKSHEET_NAME_LENGTH) {
1815
+ throw new ExchangeError(
1816
+ "WORKBOOK_INVALID",
1817
+ `The locale "${locale}" cannot be an Excel worksheet name: it must be 1 to ${MAX_WORKSHEET_NAME_LENGTH} characters.`
1818
+ );
1819
+ }
1820
+ if (FORBIDDEN_WORKSHEET_NAME_CHARS.test(locale)) {
1821
+ throw new ExchangeError(
1822
+ "WORKBOOK_INVALID",
1823
+ `The locale "${locale}" cannot be an Excel worksheet name: it must not contain any of : \\ / ? * [ ].`
1824
+ );
1825
+ }
1826
+ }
1827
+ async function buildDataSheet(workbook, sheet) {
1828
+ assertValidWorksheetName(sheet.locale);
1829
+ const worksheet = workbook.addWorksheet(sheet.locale);
1830
+ styleHeader(worksheet);
1831
+ for (const row of sheet.rows) {
1832
+ writeRow(worksheet, row);
1833
+ }
1834
+ applyColumnGeometry(worksheet);
1835
+ await worksheet.protect("", {
1836
+ spinCount: 0,
1837
+ selectLockedCells: true,
1838
+ selectUnlockedCells: true,
1839
+ formatColumns: true,
1840
+ sort: true,
1841
+ autoFilter: true
1842
+ });
1843
+ }
1844
+ function buildInstructionsSheet(workbook) {
1845
+ const sheet = workbook.addWorksheet(INSTRUCTIONS_SHEET_NAME);
1846
+ sheet.getColumn(1).width = 110;
1847
+ for (const line of INSTRUCTIONS_LINES) {
1848
+ sheet.addRow([line]);
1849
+ }
1850
+ sheet.getRow(1).font = { bold: true };
1851
+ }
1852
+ async function buildWorkbook(model) {
1853
+ const workbook = new ExcelJS.Workbook();
1854
+ buildInstructionsSheet(workbook);
1855
+ for (const sheet of model.sheets) {
1856
+ await buildDataSheet(workbook, sheet);
1857
+ }
1858
+ try {
1859
+ const buffer = await workbook.xlsx.writeBuffer();
1860
+ const view = buffer;
1861
+ return Uint8Array.prototype.slice.call(view);
1862
+ } catch {
1863
+ throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
1864
+ }
1865
+ }
1866
+ var DEFAULT_WORKBOOK_LIMITS = {
1867
+ maxDecompressedBytes: 64 * 1024 * 1024,
1868
+ maxEntryCount: 1024,
1869
+ maxSheetCount: 256,
1870
+ maxRowsPerSheet: 1e5,
1871
+ maxCellsPerRow: 64
1872
+ };
1873
+ function assertNoDoctype(name, xml) {
1874
+ if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
1875
+ throw new ExchangeError(
1876
+ "WORKBOOK_INVALID",
1877
+ `A workbook XML part (${name}) declares a DTD or entity, which is not permitted.`
1878
+ );
1879
+ }
1880
+ }
1881
+ function declaredSize(file) {
1882
+ const data = file._data;
1883
+ const size = data?.uncompressedSize;
1884
+ return typeof size === "number" && Number.isFinite(size) ? size : void 0;
1885
+ }
1886
+ async function guardWorkbookBytes(bytes, limits) {
1887
+ let zip;
1888
+ try {
1889
+ zip = await JSZip.loadAsync(bytes);
1890
+ } catch {
1891
+ throw new ExchangeError("WORKBOOK_INVALID", "The workbook is not a readable xlsx container.");
1892
+ }
1893
+ const files = Object.values(zip.files).filter((file) => !file.dir);
1894
+ if (files.length > limits.maxEntryCount) {
1895
+ throw new ExchangeError(
1896
+ "WORKBOOK_INVALID",
1897
+ `The workbook has more than the maximum of ${limits.maxEntryCount} entries.`
1898
+ );
1899
+ }
1900
+ let declaredTotal = 0;
1901
+ for (const file of files) {
1902
+ const size = declaredSize(file);
1903
+ if (size !== void 0) {
1904
+ declaredTotal += size;
1905
+ if (declaredTotal > limits.maxDecompressedBytes) {
1906
+ throw new ExchangeError(
1907
+ "WORKBOOK_INVALID",
1908
+ `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
1909
+ );
1910
+ }
1911
+ }
1912
+ }
1913
+ let actualTotal = 0;
1914
+ for (const file of files) {
1915
+ let content;
1916
+ try {
1917
+ content = await file.async("string");
1918
+ } catch {
1919
+ throw new ExchangeError("WORKBOOK_INVALID", "A workbook entry could not be decompressed.");
1920
+ }
1921
+ actualTotal += Buffer.byteLength(content, "utf8");
1922
+ if (actualTotal > limits.maxDecompressedBytes) {
1923
+ throw new ExchangeError(
1924
+ "WORKBOOK_INVALID",
1925
+ `The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
1926
+ );
1927
+ }
1928
+ assertNoDoctype(file.name, content);
1929
+ }
1930
+ }
1931
+ var rowSchema = z.object({
1932
+ key: z.string().min(1),
1933
+ source: z.string(),
1934
+ currentTarget: z.string(),
1935
+ status: z.enum(["new", "changed"]),
1936
+ sourceHash: z.string(),
1937
+ translation: z.string()
1938
+ });
1939
+ function cellString(cell) {
1940
+ const value = cell.value;
1941
+ if (value === null || value === void 0) {
1942
+ return "";
1943
+ }
1944
+ if (typeof value === "string") {
1945
+ return value;
1946
+ }
1947
+ if (typeof value === "number" || typeof value === "boolean") {
1948
+ return String(value);
1949
+ }
1950
+ return typeof cell.text === "string" ? cell.text : "";
1951
+ }
1952
+ function assertHeader(sheet) {
1953
+ const header = sheet.getRow(HEADER_ROW);
1954
+ const key = cellString(header.getCell(COLUMN.key));
1955
+ const sourceHash = cellString(header.getCell(COLUMN.sourceHash));
1956
+ if (key !== HEADERS[COLUMN.key - 1] || sourceHash !== HEADERS[COLUMN.sourceHash - 1]) {
1957
+ throw new ExchangeError(
1958
+ "WORKBOOK_INVALID",
1959
+ `The sheet "${sheet.name}" is missing the expected Key and Source hash columns.`
1960
+ );
1961
+ }
1962
+ }
1963
+ function parseRow(sheet, row) {
1964
+ const candidate = {
1965
+ key: cellString(row.getCell(COLUMN.key)),
1966
+ source: cellString(row.getCell(COLUMN.source)),
1967
+ currentTarget: cellString(row.getCell(COLUMN.current)),
1968
+ status: cellString(row.getCell(COLUMN.status)),
1969
+ sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
1970
+ translation: cellString(row.getCell(COLUMN.translation))
1971
+ };
1972
+ const result = rowSchema.safeParse(candidate);
1973
+ if (!result.success) {
1974
+ throw new ExchangeError(
1975
+ "WORKBOOK_INVALID",
1976
+ `The sheet "${sheet.name}" has a row with a missing key or an unrecognized status.`
1977
+ );
1978
+ }
1979
+ return result.data;
1980
+ }
1981
+ function readDataSheet(sheet, limits) {
1982
+ assertHeader(sheet);
1983
+ if (sheet.rowCount - HEADER_ROW > limits.maxRowsPerSheet) {
1984
+ throw new ExchangeError(
1985
+ "WORKBOOK_INVALID",
1986
+ `The sheet "${sheet.name}" has more than the maximum of ${limits.maxRowsPerSheet} rows.`
1987
+ );
1988
+ }
1989
+ const rows = [];
1990
+ for (let rowNumber = HEADER_ROW + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
1991
+ const row = sheet.getRow(rowNumber);
1992
+ if (row.cellCount > limits.maxCellsPerRow) {
1993
+ throw new ExchangeError(
1994
+ "WORKBOOK_INVALID",
1995
+ `The sheet "${sheet.name}" has a row with more than the maximum of ${limits.maxCellsPerRow} cells.`
1996
+ );
1997
+ }
1998
+ if (cellString(row.getCell(COLUMN.key)) === "") {
1999
+ continue;
2000
+ }
2001
+ rows.push(parseRow(sheet, row));
2002
+ }
2003
+ return { locale: sheet.name, rows };
2004
+ }
2005
+ async function loadWorkbook(bytes) {
2006
+ const workbook = new ExcelJS.Workbook();
2007
+ try {
2008
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2009
+ await workbook.xlsx.load(buffer);
2010
+ } catch {
2011
+ throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be parsed as xlsx.");
2012
+ }
2013
+ return workbook;
2014
+ }
2015
+ async function readWorkbook(bytes, options = {}) {
2016
+ const limits = options.limits ?? DEFAULT_WORKBOOK_LIMITS;
2017
+ await guardWorkbookBytes(bytes, limits);
2018
+ const workbook = await loadWorkbook(bytes);
2019
+ if (workbook.worksheets.length > limits.maxSheetCount) {
2020
+ throw new ExchangeError(
2021
+ "WORKBOOK_INVALID",
2022
+ `The workbook has more than the maximum of ${limits.maxSheetCount} sheets.`
2023
+ );
2024
+ }
2025
+ const sheets = [];
2026
+ for (const sheet of workbook.worksheets) {
2027
+ if (sheet.name === INSTRUCTIONS_SHEET_NAME) {
2028
+ continue;
2029
+ }
2030
+ sheets.push(readDataSheet(sheet, limits));
2031
+ }
2032
+ return { sheets };
2033
+ }
2034
+
2035
+ // src/flow/workbook/export-workbook.ts
2036
+ var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
2037
+ async function readTarget2(cwd, config, adapter, fs, locale) {
2038
+ const path = localeFilePath(cwd, config.files.pattern, locale);
2039
+ if (!await fs.fileExists(path)) {
2040
+ return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
2041
+ }
2042
+ return (await adapter.read(path, locale)).resource;
2043
+ }
2044
+ function buildRows(source, target, baseline, includeUnchanged) {
2045
+ const diff = diffResources(source, target, { baseline });
2046
+ const rows = [];
2047
+ const add = (keys, status) => {
2048
+ for (const key of keys) {
2049
+ const sourceEntry = source.entries.get(key);
2050
+ if (sourceEntry === void 0) {
2051
+ continue;
2052
+ }
2053
+ rows.push({
2054
+ key,
2055
+ source: sourceEntry.value,
2056
+ currentTarget: target.entries.get(key)?.value ?? "",
2057
+ status,
2058
+ sourceHash: contentHash(sourceEntry),
2059
+ translation: ""
2060
+ });
2061
+ }
2062
+ };
2063
+ add(diff.missing, "new");
2064
+ add(diff.changed, "changed");
2065
+ if (includeUnchanged) {
2066
+ add(diff.unchanged, "changed");
2067
+ }
2068
+ return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
2069
+ }
2070
+ function selectedLocales(config, requested) {
2071
+ if (requested === void 0) {
2072
+ return config.targetLocales;
2073
+ }
2074
+ const wanted = new Set(requested);
2075
+ return config.targetLocales.filter((locale) => wanted.has(locale));
2076
+ }
2077
+ async function exportWorkbook(input, deps = {}) {
2078
+ const config = input.config;
2079
+ const cwd = input.cwd ?? process.cwd();
2080
+ const fs = deps.fs ?? defaultFs;
2081
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2082
+ const source = await readSource(config, cwd, fs, adapter);
2083
+ const lock = await readLockFile(lockFilePath(cwd), fs);
2084
+ const locales = selectedLocales(config, input.locales);
2085
+ const sheets = await Promise.all(
2086
+ locales.map(async (locale) => {
2087
+ const target = await readTarget2(cwd, config, adapter, fs, locale);
2088
+ const rows = buildRows(
2089
+ source.resource,
2090
+ target,
2091
+ baselineFor(lock, locale),
2092
+ input.includeUnchanged ?? false
2093
+ );
2094
+ return { locale, rows };
2095
+ })
2096
+ );
2097
+ const model = { sheets };
2098
+ const bytes = await buildWorkbook(model);
2099
+ const path = resolve(cwd, input.out ?? DEFAULT_WORKBOOK_PATH);
2100
+ await fs.writeBytes(path, bytes);
2101
+ return {
2102
+ path,
2103
+ locales: sheets.map((sheet) => ({ locale: sheet.locale, rows: sheet.rows.length }))
2104
+ };
2105
+ }
2106
+
2107
+ // src/flow/workbook/import-locale.ts
2108
+ var UnknownKeyError = class extends Error {
2109
+ key;
2110
+ constructor(key) {
2111
+ super(`The workbook has a row with key "${key}" that maps to no known source or target key.`);
2112
+ this.name = "UnknownKeyError";
2113
+ this.key = key;
2114
+ }
2115
+ };
2116
+ function isUnknownKey(row, source, target) {
2117
+ return !source.entries.has(row.key) && !target.entries.has(row.key);
2118
+ }
2119
+ function judge(row, sourceEntry, adapter) {
2120
+ if (contentHash(sourceEntry) !== row.sourceHash) {
2121
+ return "drift";
2122
+ }
2123
+ const integrity = checkPlaceholders(
2124
+ sourceEntry.placeholders,
2125
+ adapter.extractPlaceholders(row.translation)
2126
+ );
2127
+ if (!integrity.matches) {
2128
+ return "placeholder";
2129
+ }
2130
+ if (!adapter.validateMessage(row.translation)) {
2131
+ return "icu";
2132
+ }
2133
+ return void 0;
2134
+ }
2135
+ function classifyRows(params, buckets) {
2136
+ for (const row of params.sheet.rows) {
2137
+ if (row.translation === "") {
2138
+ continue;
2139
+ }
2140
+ if (isUnknownKey(row, params.source, params.target)) {
2141
+ throw new UnknownKeyError(row.key);
2142
+ }
2143
+ const sourceEntry = params.source.entries.get(row.key);
2144
+ if (sourceEntry === void 0) {
2145
+ continue;
2146
+ }
2147
+ const reason = judge(row, sourceEntry, params.adapter);
2148
+ if (reason === void 0) {
2149
+ buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
2150
+ } else {
2151
+ buckets.mismatches.push(row.key);
2152
+ buckets.withheld.add(row.key);
2153
+ }
2154
+ }
2155
+ }
2156
+ function importLocale(params) {
2157
+ const diff = diffResources(params.source, params.target, { baseline: params.baseline });
2158
+ const buckets = { accepted: /* @__PURE__ */ new Map(), mismatches: [], withheld: /* @__PURE__ */ new Set() };
2159
+ classifyRows(params, buckets);
2160
+ const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
2161
+ const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
2162
+ const summary = {
2163
+ locale: params.sheet.locale,
2164
+ status: "succeeded",
2165
+ translated: [...buckets.accepted.keys()].sort(),
2166
+ unchanged: diff.unchanged,
2167
+ orphaned: diff.orphaned,
2168
+ invalidIcuSource,
2169
+ integrityMismatches: [...buckets.mismatches].sort(),
2170
+ notices: []
2171
+ };
2172
+ return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
2173
+ }
2174
+
2175
+ // src/flow/workbook/import-workbook.ts
2176
+ var MAX_WORKBOOK_FILE_BYTES = 64 * 1024 * 1024;
2177
+ async function readWorkbookBytes(path, fs) {
2178
+ const read = await fs.readBytesBounded(path, MAX_WORKBOOK_FILE_BYTES);
2179
+ if (read.kind === "missing") {
2180
+ throw new SdkError("SOURCE_UNREADABLE", `The workbook was not found at ${path}.`);
2181
+ }
2182
+ if (read.kind === "too-large") {
2183
+ throw new SdkError(
2184
+ "SOURCE_INVALID",
2185
+ `The workbook at ${path} exceeds the maximum allowed size of ${MAX_WORKBOOK_FILE_BYTES} bytes.`
2186
+ );
2187
+ }
2188
+ return read.bytes;
2189
+ }
2190
+ async function readTarget3(cwd, config, adapter, fs, locale) {
2191
+ const path = localeFilePath(cwd, config.files.pattern, locale);
2192
+ if (!await fs.fileExists(path)) {
2193
+ return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
2194
+ }
2195
+ return (await adapter.read(path, locale)).resource;
2196
+ }
2197
+ function mergeAccepted(target, accepted) {
2198
+ const merged = new Map(target.entries);
2199
+ for (const [key, { value, source }] of accepted) {
2200
+ merged.set(key, { ...source, value, namespace: target.namespace });
2201
+ }
2202
+ return merged;
2203
+ }
2204
+ function computeLockEntries2(source, merged, baseline, withheld) {
2205
+ const entries = {};
2206
+ for (const key of merged.keys()) {
2207
+ const sourceEntry = source.entries.get(key);
2208
+ if (sourceEntry === void 0) {
2209
+ continue;
2210
+ }
2211
+ if (withheld.has(key)) {
2212
+ const prior = baseline.get(key);
2213
+ if (prior !== void 0) {
2214
+ entries[key] = prior;
2215
+ }
2216
+ continue;
2217
+ }
2218
+ entries[key] = contentHash(sourceEntry);
2219
+ }
2220
+ return entries;
2221
+ }
2222
+ async function runSheet(ctx, sheet, lock) {
2223
+ if (!ctx.config.targetLocales.includes(sheet.locale)) {
2224
+ throw new SdkError(
2225
+ "CONFIG_INVALID",
2226
+ `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
2227
+ );
2228
+ }
2229
+ const target = await readTarget3(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
2230
+ const baseline = baselineFor(lock, sheet.locale);
2231
+ const { summary, accepted, withheld } = importLocale({
2232
+ sheet,
2233
+ source: ctx.source,
2234
+ target,
2235
+ baseline,
2236
+ adapter: ctx.adapter,
2237
+ sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys
2238
+ });
2239
+ if (ctx.dryRun) {
2240
+ return { summary, lockEntries: {} };
2241
+ }
2242
+ const merged = mergeAccepted(target, accepted);
2243
+ if (accepted.size > 0) {
2244
+ const path = localeFilePath(ctx.cwd, ctx.config.files.pattern, sheet.locale);
2245
+ await ctx.adapter.write(
2246
+ {
2247
+ locale: sheet.locale,
2248
+ namespace: target.namespace,
2249
+ format: ctx.config.format,
2250
+ entries: merged
2251
+ },
2252
+ path
2253
+ );
2254
+ }
2255
+ return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, withheld) };
2256
+ }
2257
+ async function importWorkbook(input, deps = {}) {
2258
+ const config = input.config;
2259
+ const cwd = input.cwd ?? process.cwd();
2260
+ const dryRun = input.dryRun ?? false;
2261
+ const fs = deps.fs ?? defaultFs;
2262
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2263
+ const source = await readSource(config, cwd, fs, adapter);
2264
+ const workbookPath = resolve(cwd, input.workbook);
2265
+ const bytes = await readWorkbookBytes(workbookPath, fs);
2266
+ let data;
2267
+ try {
2268
+ data = await readWorkbook(bytes);
2269
+ } catch (error) {
2270
+ throw new SdkError("SOURCE_INVALID", error.message);
2271
+ }
2272
+ const lockPath = lockFilePath(cwd);
2273
+ let lock = await readLockFile(lockPath, fs);
2274
+ const ctx = {
2275
+ config,
2276
+ cwd,
2277
+ adapter,
2278
+ fs,
2279
+ source: source.resource,
2280
+ sourceInvalidIcuKeys: source.invalidIcuKeys,
2281
+ dryRun
2282
+ };
2283
+ const summaries = [];
2284
+ for (const sheet of data.sheets) {
2285
+ try {
2286
+ const { summary, lockEntries } = await runSheet(ctx, sheet, lock);
2287
+ if (!dryRun) {
2288
+ lock = updateLockLocale(lock, sheet.locale, lockEntries);
2289
+ await writeLockFile(lockPath, lock, fs);
2290
+ }
2291
+ summaries.push(summary);
2292
+ } catch (error) {
2293
+ summaries.push(failureSummary(sheet.locale, error));
2294
+ }
2295
+ }
2296
+ const { succeeded, failed } = partition(summaries);
2297
+ return { dryRun, locales: summaries, succeeded, failed };
1664
2298
  }
1665
2299
  var defaultCreateWatcher = (paths) => {
1666
2300
  const fsWatcher = watch$1([...paths], { persistent: true, ignoreInitial: true });
@@ -1768,6 +2402,6 @@ async function watch(input, deps = {}) {
1768
2402
  return { stop };
1769
2403
  }
1770
2404
 
1771
- export { SdkError, defineConfig, loadConfig, translate2 as translate, verbatraConfigSchema, watch };
2405
+ export { DEFAULT_WORKBOOK_PATH, SdkError, defineConfig, exportWorkbook, importWorkbook, loadConfig, translate2 as translate, verbatraConfigSchema, watch };
1772
2406
  //# sourceMappingURL=index.js.map
1773
2407
  //# sourceMappingURL=index.js.map