@lingfan-engine/editor 0.1.0 → 0.1.3
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/LICENSE +201 -201
- package/package.json +2 -2
- package/src/contracts/diagnostics.ts +62 -62
- package/src/contracts/index.ts +22 -22
- package/src/diagnostics/analyze.ts +161 -161
- package/src/i18n/reconcile.ts +71 -71
- package/src/i18n/skeleton.ts +112 -112
- package/src/i18n/table.ts +235 -235
- package/src/schema/opSchemas/commands.ts +374 -374
- package/src/schema/opSchemas/map.ts +134 -134
- package/src/schema/opSchemas/validate.ts +184 -184
- package/src/script/expr/audit/parser.ts +370 -370
- package/src/script/expr/build.ts +203 -203
- package/src/script/expr/registry.ts +130 -130
package/src/i18n/table.ts
CHANGED
|
@@ -1,235 +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
|
-
}
|
|
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
|
+
}
|