@lingfan-engine/editor 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/package.json +39 -3
  3. package/src/chapters/build.ts +180 -0
  4. package/src/chapters/index.ts +34 -0
  5. package/src/chapters/types.ts +71 -0
  6. package/src/contracts/diagnostics.ts +62 -0
  7. package/src/contracts/editing.ts +26 -0
  8. package/src/contracts/forms.ts +69 -0
  9. package/src/contracts/index.ts +22 -0
  10. package/src/contracts/texts.ts +204 -0
  11. package/src/diagnostics/analyze.ts +161 -0
  12. package/src/diagnostics/grouping.ts +164 -0
  13. package/src/diagnostics/index.ts +35 -0
  14. package/src/diagnostics/message.ts +60 -0
  15. package/src/diagnostics/refs.ts +123 -0
  16. package/src/diagnostics/symbols.ts +221 -0
  17. package/src/editing/columnGrouping.ts +331 -0
  18. package/src/editing/graphConnect.ts +88 -0
  19. package/src/editing/index.ts +72 -0
  20. package/src/editing/pointers.ts +291 -0
  21. package/src/editing/saveNormalization.ts +58 -0
  22. package/src/editing/session.ts +134 -0
  23. package/src/editing/storyOps.ts +268 -0
  24. package/src/editing/tsSourceGuard.ts +40 -0
  25. package/src/element/drag.ts +22 -0
  26. package/src/element/dragCreate.ts +60 -0
  27. package/src/element/index.ts +11 -0
  28. package/src/i18n/coverage.ts +191 -0
  29. package/src/i18n/extract.ts +38 -0
  30. package/src/i18n/index.ts +47 -0
  31. package/src/i18n/reconcile.ts +71 -0
  32. package/src/i18n/skeleton.ts +112 -0
  33. package/src/i18n/storyKeys.ts +27 -0
  34. package/src/i18n/surfaces.ts +54 -0
  35. package/src/i18n/table.ts +235 -0
  36. package/src/index.ts +342 -0
  37. package/src/layout/index.ts +14 -0
  38. package/src/layout/steps.ts +378 -0
  39. package/src/schema/catalog.ts +300 -0
  40. package/src/schema/elementForms.ts +307 -0
  41. package/src/schema/elementPalette.ts +100 -0
  42. package/src/schema/forms.ts +196 -0
  43. package/src/schema/index.ts +55 -0
  44. package/src/schema/opSchemas/commands.ts +374 -0
  45. package/src/schema/opSchemas/fragments.ts +24 -0
  46. package/src/schema/opSchemas/index.ts +16 -0
  47. package/src/schema/opSchemas/map.ts +134 -0
  48. package/src/schema/opSchemas/types.ts +42 -0
  49. package/src/schema/opSchemas/validate.ts +184 -0
  50. package/src/schema/surface.ts +101 -0
  51. package/src/schema/validation.ts +92 -0
  52. package/src/schema/walk.ts +167 -0
  53. package/src/script/expr/audit/index.ts +8 -0
  54. package/src/script/expr/audit/parser.ts +370 -0
  55. package/src/script/expr/audit/token.ts +23 -0
  56. package/src/script/expr/build.ts +203 -0
  57. package/src/script/expr/index.ts +17 -0
  58. package/src/script/expr/registry.ts +130 -0
  59. package/src/script/index.ts +168 -0
  60. package/src/script/vocabulary-gen.ts +202 -0
  61. package/src/script/vocabulary.g.ts +175 -0
  62. package/src/script/words/audio.ts +114 -0
  63. package/src/script/words/cells.ts +78 -0
  64. package/src/script/words/compile-time.ts +27 -0
  65. package/src/script/words/composite.ts +125 -0
  66. package/src/script/words/elements.ts +202 -0
  67. package/src/script/words/extension.ts +25 -0
  68. package/src/script/words/flow.ts +391 -0
  69. package/src/script/words/index.ts +23 -0
  70. package/src/script/words/interaction.ts +44 -0
  71. package/src/script/words/minigame.ts +66 -0
  72. package/src/script/words/narrative.ts +261 -0
  73. package/src/script/words/save.ts +54 -0
  74. package/src/script/words/vars.ts +155 -0
  75. package/src/script/words/video.ts +80 -0
  76. package/src/shared/guards.ts +19 -0
  77. package/src/shared/index.ts +6 -0
  78. package/src/shared/pointer.ts +23 -0
  79. package/README.md +0 -3
@@ -0,0 +1,71 @@
1
+ /**
2
+ * i18n 工具链 · 缺译 / 多译对账。
3
+ *
4
+ * 对账 = 原文键集合与各语言 overlay 键集合的双向差:
5
+ * - **缺译**(正向)= 原文 − 该语言 overlay(按语言分组;运行期「缺译文回退原文」的预警)
6
+ * - **多译**(反向)= overlay 键 − 原文(与诊断 `unused-translation` 同口径:未使用 = 冗余)
7
+ *
8
+ * 纯函数 + 可读报告文本(编辑器面板一节 / CLI stdout 共用)。加密 overlay(`.json.enc`)
9
+ * 走同一解密供给后与明文同口径参与(供给侧职责见 adapters `loadDiagnosticSupply`)。
10
+ */
11
+
12
+ import type { EditorTexts } from "../contracts/index.ts";
13
+ import { resolveEditorTexts } from "../contracts/index.ts";
14
+
15
+ export interface TranslationReconcileReport {
16
+ /** 每语言缺译键(稳定排序;语言无缺译时为空数组——「齐」也是显式状态) */
17
+ missingByLang: ReadonlyMap<string, readonly string[]>;
18
+ /** 多译键(跨语言并集口径,稳定排序;= unused-translation 诊断同源) */
19
+ unused: readonly string[];
20
+ }
21
+
22
+ /**
23
+ * 三态对账:某语言缺译 / 多译 / 齐。`overlayKeysByLang` 的键 = 语言码
24
+ * (单语言工具传单键对象即可)。
25
+ */
26
+ export function reconcileTranslations(
27
+ sources: readonly string[],
28
+ overlayKeysByLang: Readonly<Record<string, readonly string[]>>,
29
+ ): TranslationReconcileReport {
30
+ const sourceSet = new Set(sources);
31
+ const missingByLang = new Map<string, readonly string[]>();
32
+ const unused = new Set<string>();
33
+ for (const [lang, keys] of Object.entries(overlayKeysByLang)) {
34
+ const keySet = new Set(keys);
35
+ for (const key of keySet) {
36
+ if (!sourceSet.has(key)) unused.add(key);
37
+ }
38
+ const missing: string[] = [];
39
+ for (const source of sourceSet) {
40
+ if (!keySet.has(source)) missing.push(source);
41
+ }
42
+ missing.sort();
43
+ missingByLang.set(lang, missing);
44
+ }
45
+ return { missingByLang, unused: [...unused].sort() };
46
+ }
47
+
48
+ /** 可读报告(编辑器面板一节 / CLI stdout 共用);逐语言缺译 + 多译两节。
49
+ * `texts`(可选)= 集中文案表注入(缺省 defaultEditorTexts)。 */
50
+ export function formatTranslationReport(
51
+ report: TranslationReconcileReport,
52
+ texts: Partial<EditorTexts> = {},
53
+ ): string {
54
+ const t = resolveEditorTexts(texts);
55
+ const lines: string[] = [];
56
+ for (const [lang, missing] of [...report.missingByLang.entries()].sort()) {
57
+ if (missing.length === 0) {
58
+ lines.push(t.missingZero(lang));
59
+ continue;
60
+ }
61
+ lines.push(t.missingCount(lang, missing.length));
62
+ for (const key of missing) lines.push(` - ${key}`);
63
+ }
64
+ if (report.unused.length === 0) {
65
+ lines.push(t.unusedZero());
66
+ } else {
67
+ lines.push(t.unusedCount(report.unused.length));
68
+ for (const key of report.unused) lines.push(` - ${key}`);
69
+ }
70
+ return lines.join("\n");
71
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * i18n 工具链 · overlay 骨架生成。
3
+ *
4
+ * 给定语言与分组策略,产出**文件内容**(原文 → 既有译文或占位);落盘归调用方
5
+ * (编辑器保存 / CLI),本模块保持纯函数(不碰平台 API)。合并语义与运行期
6
+ * `mergeOverlayFiles` 对齐(main.json 兜底 + 其余按供给序覆盖)⇒ 骨架喂回合并后
7
+ * 键集合与 `extractStoryKeys` 输出**双向一致**(互锁测试锁定)。
8
+ *
9
+ * 布局覆盖 overlay 形态:「平铺」= 条目即键值平面(三形态共用);
10
+ * 单大文件 `Lang/{lang}.json`、目录 `Lang/{lang}/main.json`、按故事分类
11
+ * `Lang/{lang}/{章节子目录}/{故事}.json`——**per-story 镜像 `Stories/` 的递归目录**
12
+ * (章节子目录更好区分归类;`.json` / `.json.enc`
13
+ * 双识别归运行期供给,骨架产明文)。**增量模式**:命中的既有译文原样保留(含空串
14
+ * 译文——运行期「命中即用」语义),绝不覆盖。
15
+ */
16
+
17
+ import type { EditorTexts } from "../contracts/index.ts";
18
+ import { resolveEditorTexts } from "../contracts/index.ts";
19
+ import { isSafeFileNameSegment } from "@lingfan-engine/core";
20
+
21
+ /** 占位策略:original = 原文占位(未翻译时画面可读,翻译工作表语义);empty = 空串(运行期命中即隐句) */
22
+ export type SkeletonPlaceholder = "original" | "empty";
23
+
24
+ /** 骨架布局:single = `Lang/{lang}.json`;main = `Lang/{lang}/main.json`;per-story = 镜像 Stories/ 递归目录 */
25
+ export type SkeletonLayout = "single" | "main" | "per-story";
26
+
27
+ export interface OverlaySkeletonFile {
28
+ /** 相对资源根的 overlay 路径 */
29
+ path: string;
30
+ /** 键值平面(键 = 原文,稳定排序;值 = 既有译文(增量保留)或占位) */
31
+ entries: Record<string, string>;
32
+ /** 序列化好的文件内容(2 空格缩进 + 结尾换行;落盘即用,同输入逐字节确定) */
33
+ content: string;
34
+ }
35
+
36
+ /** overlay 根目录名(与运行期 Lang/ 供给面同源;单一出处供路径模板与测试共用) */
37
+ export const LANG_DIR = "Lang";
38
+
39
+ export interface SkeletonOptions {
40
+ lang: string;
41
+ layout: SkeletonLayout;
42
+ /**
43
+ * 故事 → 该故事的原文键(`extractStoryKeys` 输出;键序不要求,产出内会排序)。
44
+ * **键 = 故事文件相对 `Stories/` 的路径(不含扩展名)**,如 `chapter1/tavern`——
45
+ * per-story 布局按它镜像 `Stories/` 递归目录(平铺工程的键 = 故事 id,语义一致)。
46
+ */
47
+ storyKeys: ReadonlyMap<string, readonly string[]>;
48
+ /** 增量模式:该语言已存在的 overlay(路径 → 条目);命中的键保留原值 */
49
+ existing?: ReadonlyMap<string, Record<string, string>>;
50
+ placeholder?: SkeletonPlaceholder;
51
+ /** 集中文案表注入(缺省 defaultEditorTexts) */
52
+ texts?: Partial<EditorTexts>;
53
+ }
54
+
55
+ /**
56
+ * 产出骨架文件列表。fail-closed:`lang` / 故事 id 不合法即抛——骨架路径要能落进
57
+ * `Lang/` 而不越界(与故事文件名同一安全口径 `isSafeFileNameSegment`)。
58
+ */
59
+ export function planOverlaySkeleton(
60
+ options: SkeletonOptions,
61
+ ): OverlaySkeletonFile[] {
62
+ const {
63
+ lang,
64
+ layout,
65
+ storyKeys,
66
+ existing,
67
+ placeholder = "original",
68
+ } = options;
69
+ const t: EditorTexts = resolveEditorTexts(options.texts);
70
+ if (typeof lang !== "string" || !isSafeFileNameSegment(lang)) {
71
+ throw new Error(t.invalidLangCode(String(lang)));
72
+ }
73
+ const build = (
74
+ path: string,
75
+ keys: readonly string[],
76
+ ): OverlaySkeletonFile => {
77
+ const prior = existing?.get(path) ?? {};
78
+ const entries: Record<string, string> = {};
79
+ for (const key of [...keys].sort()) {
80
+ // 既有条目原样保留(含空串译文);`undefined` = 未命中 → 占位
81
+ const kept = prior[key];
82
+ entries[key] =
83
+ typeof kept === "string" ? kept : placeholder === "empty" ? "" : key;
84
+ }
85
+ return { path, entries, content: `${JSON.stringify(entries, null, 2)}\n` };
86
+ };
87
+ if (layout === "single" || layout === "main") {
88
+ const union = new Set<string>();
89
+ for (const keys of storyKeys.values()) {
90
+ for (const key of keys) union.add(key);
91
+ }
92
+ const path =
93
+ layout === "single"
94
+ ? `${LANG_DIR}/${lang}.json`
95
+ : `${LANG_DIR}/${lang}/main.json`;
96
+ return [build(path, [...union])];
97
+ }
98
+ // per-story:镜像 `Stories/` 的递归目录——
99
+ // 键 = 故事相对 `Stories/` 的路径(不含扩展名);**段级**安全校验防路径越界。
100
+ const files: OverlaySkeletonFile[] = [];
101
+ for (const [storyPath, keys] of storyKeys) {
102
+ const segments = storyPath.split("/");
103
+ if (
104
+ storyPath === "" ||
105
+ segments.some((segment) => !isSafeFileNameSegment(segment))
106
+ ) {
107
+ throw new Error(t.invalidStoryPath(storyPath));
108
+ }
109
+ files.push(build(`${LANG_DIR}/${lang}/${storyPath}.json`, keys));
110
+ }
111
+ return files;
112
+ }
@@ -0,0 +1,27 @@
1
+ import { STORIES_DIR } from "@lingfan-engine/core";
2
+
3
+ /**
4
+ * 列 → 产物故事键(i18n per-story 骨架的权威口径):列落盘文件相对 `Stories/` 的
5
+ * 路径、不含扩展名——与 `planOverlaySkeleton` 的 per-story 键、构建的写盘路径
6
+ * (`Stories/<相对源路径>.json`,文件名与列 id 解耦)同一规则,保证骨架
7
+ * `Lang/{lang}/{故事键}.json` 与 `Resources/Stories/` 递归目录严格镜像。
8
+ *
9
+ * 多目录形态下列 id ≠ 文件名,键必须从 `column.sourcePath`(构建回填的产物路径)
10
+ * 推导;缺省/非法(非 `Stories/` 前缀、含 `..`)回退引擎默认布局 `Stories/{id}.json`。
11
+ */
12
+ export function columnStoryKey(column: {
13
+ readonly id: string;
14
+ readonly sourcePath?: string;
15
+ }): string {
16
+ const fallback = `${STORIES_DIR}/${column.id}.json`;
17
+ const sourcePath = column.sourcePath;
18
+ const resolved =
19
+ typeof sourcePath === "string" &&
20
+ sourcePath.startsWith(`${STORIES_DIR}/`) &&
21
+ !sourcePath.includes("..")
22
+ ? sourcePath
23
+ : fallback;
24
+ const relative = resolved.slice(STORIES_DIR.length + 1);
25
+ const dot = relative.lastIndexOf(".");
26
+ return dot > 0 ? relative.slice(0, dot) : relative;
27
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * i18n 工具链 · 翻译面单一事实源。
3
+ *
4
+ * `TRANSLATE_SURFACES` 声明「命令流上哪些 op 的哪些字段会被运行期 Translate」——
5
+ * 诊断的原文收集(`indexStory.originals`)与键抽取器(`extractStoryKeys`)**共同消费本表**,
6
+ * 新增翻译面只改这一处(两侧自动同口径)。元素展示文字(`text`)不在命令流上,
7
+ * 不走此表——由两侧各自的元素遍历单独入集(与运行期 `translateElements` 对齐)。
8
+ *
9
+ * 路径语法:`a.b` 逐段取值;`a[].b` 的 `[]` = 对数组逐项迭代(`options[].text`)。
10
+ */
11
+
12
+ /** 可翻译原文面(运行期 Translate 挂接:say 文本+说话人 / menu prompt+选项 / input prompt / notify 文本。character.screen 见下——仅入编辑器比对面,运行期不翻) */
13
+ export const TRANSLATE_SURFACES: Readonly<Record<string, readonly string[]>> = {
14
+ say: ["text", "speaker"],
15
+ menu: ["prompt", "options[].text"],
16
+ input: ["prompt"],
17
+ notify: ["text"],
18
+ // character.screen 仅入编辑器比对面(原文收集/键抽取),运行期**不翻**:引擎恰好 7 个
19
+ // translate 调用点(runtime/i18n/translate.ts:31、ops/dialog.ts:92/107/171/175、
20
+ // ops/interaction.ts:42、ops/media.ts:262),均不含 screen(说话人显示名解析产物
21
+ // 走 say 的 Translate 挂接,查表见 runtime/ops/dialog.ts execSay;docs concepts/i18n.md 同口径)。
22
+ character: ["screen"],
23
+ };
24
+
25
+ /** 字段路径取值(`a[].b` = 逐项迭代;返回值与其相对指针) */
26
+ export function valuesAtPath(
27
+ cmd: Record<string, unknown>,
28
+ path: string,
29
+ ): { value: unknown; pointer: string }[] {
30
+ let current: { value: unknown; pointer: string }[] = [
31
+ { value: cmd, pointer: "" },
32
+ ];
33
+ for (const segment of path.split(".")) {
34
+ const iterate = segment.endsWith("[]");
35
+ const key = iterate ? segment.slice(0, -2) : segment;
36
+ const next: { value: unknown; pointer: string }[] = [];
37
+ for (const entry of current) {
38
+ if (entry.value === null || typeof entry.value !== "object") continue;
39
+ const child = (entry.value as Record<string, unknown>)[key];
40
+ const childPointer = `${entry.pointer}/${key}`;
41
+ if (iterate) {
42
+ if (Array.isArray(child)) {
43
+ child.forEach((item, index) => {
44
+ next.push({ value: item, pointer: `${childPointer}/${index}` });
45
+ });
46
+ }
47
+ } else {
48
+ next.push({ value: child, pointer: childPointer });
49
+ }
50
+ }
51
+ current = next;
52
+ }
53
+ return current;
54
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * i18n 工具链 · **译文表行编辑判据**(纯函数,可测)。
3
+ *
4
+ * 存在理由:译者需要「这一行不要了」「补一个键」能在**视图里就地做**,而视图不该持有判据。
5
+ * 本模块是那层判据的**唯一出处**,`LangView` 只负责渲染与收集意图。
6
+ *
7
+ * 三条约束:
8
+ *
9
+ * 1. **写回按语义,不按字节**:产出**规范形**(键按码元序排序 + 2 空格缩进 + 结尾换行),
10
+ * 与 `planOverlaySkeleton` 的 `content` 同形 ⇒ 同一输入逐字节确定,不因行序抖动而全文件重写。
11
+ * 2. **不丢作者意图**:译文值按 `unknown` **原样往返**(不把非字符串 coerce 成 `""`)。
12
+ * coerce 看似无害,实则**保存一次就把数字/嵌套值抹平**——静默改数据。
13
+ * 3. **fail-closed**:空键 / 重复键 / 改键到已存在 / 删不存在的键 ⇒ 显式报错,
14
+ * 绝不「静默忽略」或「静默覆盖」(覆盖 = 丢译文)。
15
+ */
16
+
17
+ import type { EditorTexts } from "../contracts/index.ts";
18
+ import { resolveEditorTexts } from "../contracts/index.ts";
19
+
20
+ /** 译文表的一行(`value` 原样保留,仅 `text` 供输入框显示) */
21
+ export interface TranslationRow {
22
+ readonly key: string;
23
+ /** **原样往返**的值(不 coerce;写回时按此序列化) */
24
+ readonly value: unknown;
25
+ /** 给输入框的字符串形态(非字符串值的直读形态;`undefined` → 空串) */
26
+ readonly text: string;
27
+ }
28
+
29
+ /** 解析结果(坏 JSON / 非对象根 ⇒ `fail-closed`,不静默当空表) */
30
+ export type TableParse =
31
+ | { readonly ok: true; readonly rows: readonly TranslationRow[] }
32
+ | { readonly ok: false; readonly error: string };
33
+
34
+ /** 键按码元序比较(与既有 `LangView` 排序口径一致,不引入 locale 依赖) */
35
+ function byKey(a: TranslationRow, b: TranslationRow): number {
36
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
37
+ }
38
+
39
+ /** 把 `unknown` 值转成输入框可显示的文本(`null`/对象/数组给 JSON 形态,不给 `[object Object]`) */
40
+ function displayTextOf(value: unknown): string {
41
+ if (typeof value === "string") return value;
42
+ if (value === undefined) return "";
43
+ if (
44
+ value === null ||
45
+ typeof value === "number" ||
46
+ typeof value === "boolean"
47
+ ) {
48
+ return String(value);
49
+ }
50
+ try {
51
+ return JSON.stringify(value) ?? "";
52
+ } catch {
53
+ // 循环引用等不可序列化形态:如实显示占位,不提供编辑
54
+ return "";
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 解析译文表 JSON 文本。
60
+ *
61
+ * **不接受数组根**(译文表契约是「键 → 译文」的平面对象);数组根会被
62
+ * `Array.isArray` 拦住并报错。
63
+ * `texts`(可选)= 集中文案表注入(缺省 defaultEditorTexts)。
64
+ */
65
+ export function parseTranslationTable(
66
+ source: string,
67
+ texts: Partial<EditorTexts> = {},
68
+ ): TableParse {
69
+ const t = resolveEditorTexts(texts);
70
+ let value: unknown;
71
+ try {
72
+ value = JSON.parse(source);
73
+ } catch (e) {
74
+ return { ok: false, error: t.invalidJson(String(e)) };
75
+ }
76
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
77
+ return { ok: false, error: t.tableRootMustBeObject() };
78
+ }
79
+ const rows = Object.entries(value as Record<string, unknown>).map(
80
+ ([key, v]) => ({
81
+ key,
82
+ value: v,
83
+ text: displayTextOf(v),
84
+ }),
85
+ );
86
+ rows.sort(byKey);
87
+ return { ok: true, rows };
88
+ }
89
+
90
+ /** 序列化回规范形(**键按码元序** + 2 空格缩进 + 结尾换行;值原样往返) */
91
+ export function serializeTranslationTable(
92
+ rows: readonly TranslationRow[],
93
+ ): string {
94
+ const out: Record<string, unknown> = {};
95
+ for (const row of [...rows].sort(byKey)) out[row.key] = row.value;
96
+ return `${JSON.stringify(out, null, 2)}\n`;
97
+ }
98
+
99
+ /** 变更结果(失败带**可展示给用户**的原因,成功带新行集) */
100
+ export type TableEdit =
101
+ | { readonly ok: true; readonly rows: readonly TranslationRow[] }
102
+ | { readonly ok: false; readonly error: string };
103
+
104
+ /** 空键判据(空白串、首尾空白、含控制字符一律拒绝——键要能当 JSON 键与运行期查表) */
105
+ function keyProblem(key: string, t: EditorTexts): string | undefined {
106
+ if (key === "") return t.keyEmpty();
107
+ if (key !== key.trim()) return t.keySurroundingWhitespace();
108
+ if (/[\u0000-\u001f]/.test(key)) return t.keyControlChars();
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * 增行。
114
+ *
115
+ /** **重复键显式拒绝**(不覆盖):译文表里一个键只能有一条,静默覆盖 = 丢译文。
116
+ * 新行的值恒为 `""`(未翻译)——`text` 与 `value` 同步,避免「显示空、写出 undefined」。
117
+ * `texts`(可选)= 集中文案表注入(缺省 defaultEditorTexts)。
118
+ */
119
+ export function addTranslationRow(
120
+ rows: readonly TranslationRow[],
121
+ key: string,
122
+ texts: Partial<EditorTexts> = {},
123
+ ): TableEdit {
124
+ const t = resolveEditorTexts(texts);
125
+ const problem = keyProblem(key, t);
126
+ if (problem !== undefined) return { ok: false, error: problem };
127
+ if (rows.some((r) => r.key === key)) {
128
+ return { ok: false, error: t.keyAlreadyExists(key) };
129
+ }
130
+ return {
131
+ ok: true,
132
+ rows: [...rows, { key, value: "", text: "" }].sort(byKey),
133
+ };
134
+ }
135
+
136
+ /** 删行(键不存在 ⇒ 显式失败,不静默noop——上层据此提示「状态已变」) */
137
+ export function removeTranslationRow(
138
+ rows: readonly TranslationRow[],
139
+ key: string,
140
+ texts: Partial<EditorTexts> = {},
141
+ ): TableEdit {
142
+ const t = resolveEditorTexts(texts);
143
+ if (!rows.some((r) => r.key === key)) {
144
+ return { ok: false, error: t.keyNotInTable(key) };
145
+ }
146
+ return { ok: true, rows: rows.filter((r) => r.key !== key) };
147
+ }
148
+
149
+ /** 改键(重命名);目标已存在 ⇒ 拒绝(合并两行译文 = 静默丢数据) */
150
+ export function renameTranslationRow(
151
+ rows: readonly TranslationRow[],
152
+ from: string,
153
+ to: string,
154
+ texts: Partial<EditorTexts> = {},
155
+ ): TableEdit {
156
+ const t = resolveEditorTexts(texts);
157
+ if (!rows.some((r) => r.key === from)) {
158
+ return { ok: false, error: t.keyNotInTable(from) };
159
+ }
160
+ const problem = keyProblem(to, t);
161
+ if (problem !== undefined) return { ok: false, error: problem };
162
+ if (from === to) return { ok: true, rows };
163
+ if (rows.some((r) => r.key === to)) {
164
+ return { ok: false, error: t.renameConflict(to) };
165
+ }
166
+ return {
167
+ ok: true,
168
+ rows: rows.map((r) => (r.key === from ? { ...r, key: to } : r)).sort(byKey),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * 改值。
174
+ *
175
+ * **保持值的原始类型**:文本非空且原值是合法 JSON 字面量(数字/布尔/null/数组/对象)
176
+ * 时按该类型写回——否则「译文其实想写数字」会被引号包成字符串。但**普通文本一律按字符串**
177
+ *(译者打的 `123` 若原值是字符串,语义是文本 `123`;类型跟随**原值**,不跟随输入形态)。
178
+ * 原值非字符串时按 JSON 解析输入,失败则回落字符串(不吞错:解析失败仍写字符串原文)。
179
+ * `texts`(可选)= 集中文案表注入(缺省 defaultEditorTexts)。
180
+ */
181
+ export function setTranslationValue(
182
+ rows: readonly TranslationRow[],
183
+ key: string,
184
+ text: string,
185
+ texts: Partial<EditorTexts> = {},
186
+ ): TableEdit {
187
+ const t = resolveEditorTexts(texts);
188
+ if (!rows.some((r) => r.key === key)) {
189
+ return { ok: false, error: t.keyNotInTable(key) };
190
+ }
191
+ return {
192
+ ok: true,
193
+ rows: rows.map((r) =>
194
+ r.key === key ? { ...r, value: coerceLike(r.value, text), text } : r,
195
+ ),
196
+ };
197
+ }
198
+
199
+ /** 值类型跟随**原值**:原值非字符串时,把输入按 JSON 解析(失败回落字符串原文) */
200
+ function coerceLike(original: unknown, text: string): unknown {
201
+ if (typeof original === "string") return text;
202
+ if (text === "") return "";
203
+ try {
204
+ return JSON.parse(text) as unknown;
205
+ } catch {
206
+ return text;
207
+ }
208
+ }
209
+
210
+ /** 脏判定(**逐行比键与值**;行数不同即脏。新增/删除/改键都必须能判出脏) */
211
+ export function isTableDirty(
212
+ rows: readonly TranslationRow[],
213
+ base: readonly TranslationRow[],
214
+ ): boolean {
215
+ if (rows.length !== base.length) return true;
216
+ const baseByKey = new Map(base.map((r) => [r.key, r.value]));
217
+ for (const row of rows) {
218
+ if (!baseByKey.has(row.key)) return true;
219
+ if (!sameValue(baseByKey.get(row.key), row.value)) return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /** 值相等判定(**结构相等**,不用 `===`——否则对象/数组永远判脏) */
225
+ function sameValue(a: unknown, b: unknown): boolean {
226
+ if (a === b) return true;
227
+ if (typeof a !== typeof b) return false;
228
+ if (a === null || b === null) return false;
229
+ if (typeof a !== "object") return false;
230
+ try {
231
+ return JSON.stringify(a) === JSON.stringify(b);
232
+ } catch {
233
+ return false; // 不可序列化 ⇒ 保守判脏
234
+ }
235
+ }