locale-sync-cli 1.0.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.
@@ -0,0 +1,160 @@
1
+ const { normalizeForAnchor } = require('../utils/stringNormalizer');
2
+ const { checkPlaceholderConsistency } = require('../utils/placeholderChecker');
3
+
4
+ /**
5
+ * 优先级链(按列颜色独立判定):
6
+ * 项目(⬜/🟢非空) > Common(⬜/🟢非空) > 项目(🟡) > Common(🟡) > 主动清空(空+有色) > 保留本地
7
+ */
8
+ async function generatePullPlan(projectRows, commonRows, localMap, config) {
9
+ const plan = [];
10
+ const normH = h => h.toLowerCase().replace(/[-_]/g, '');
11
+ const getLocaleBase = loc => loc.length <= 3 ? loc : loc.slice(0, 2);
12
+ const isEnLoc = loc => getLocaleBase(loc) === 'en';
13
+ // 查找行对象中 en 系列列(en/enus/en-US)的英文值(兼容新旧表头格式)
14
+ const getEnValue = r => {
15
+ if (!r) return '';
16
+ for (const [k, v] of Object.entries(r)) {
17
+ if (k.startsWith('_')) continue;
18
+ if (typeof v === 'string' && v.trim() && isEnLoc(k)) return v;
19
+ }
20
+ return '';
21
+ };
22
+
23
+ // 构建 Common 索引(en 锚点 → 行)
24
+ const commonByEn = new Map();
25
+ for (const r of commonRows) {
26
+ const anchor = normalizeForAnchor(getEnValue(r));
27
+ if (anchor) commonByEn.set(anchor, r);
28
+ }
29
+
30
+ const processedAnchors = new Set();
31
+
32
+ // ── 第一轮:处理项目表行 ──────────────────────────────────────
33
+ for (const projRow of projectRows) {
34
+ // 防环路:忽略 < antiLoopWindow 秒内来自 Push 的行
35
+ if (projRow.lastsyncby === 'push' && projRow.lastsyncat) {
36
+ const diff = (Date.now() - new Date(projRow.lastsyncat).getTime()) / 1000;
37
+ if (diff < config.sync.antiLoopWindow) continue;
38
+ }
39
+ // 灰色行整行跳过
40
+ if (projRow._color === 'gray') continue;
41
+
42
+ const enAnchor = normalizeForAnchor(getEnValue(projRow));
43
+ if (!enAnchor) continue;
44
+ processedAnchors.add(enAnchor);
45
+
46
+ const commonRow = commonByEn.get(enAnchor);
47
+ const localKey = projRow.key || null;
48
+
49
+ for (const [localeKey, localEntry] of Object.entries(localMap)) {
50
+ if (isEnLoc(localeKey)) continue;
51
+ if (!localKey || !(localKey in localEntry.values)) continue;
52
+
53
+ const localValue = localEntry.values[localKey];
54
+ const base = getLocaleBase(localeKey);
55
+
56
+ // 兼容新格式列名(arsa←ar-SA)和旧格式(ar)
57
+ const projColor = projRow._colors?.[localeKey] ?? projRow._colors?.[base] ?? 'none';
58
+ const projVal = projRow[localeKey] ?? projRow[base] ?? '';
59
+ const commColor = commonRow ? (commonRow._colors?.[localeKey] ?? commonRow._colors?.[base] ?? 'none') : null;
60
+ const commVal = commonRow ? (commonRow[localeKey] ?? commonRow[base] ?? '') : null;
61
+
62
+ let finalValue;
63
+ let warnYellow = false;
64
+
65
+ if (projVal !== '') {
66
+ // 项目表有值 → 永远最高优先级(不论颜色)
67
+ finalValue = projVal;
68
+ warnYellow = projColor === 'yellow';
69
+ } else if (projVal === '' && projColor !== 'none') {
70
+ // 项目表主动清空(空+有色)
71
+ finalValue = '';
72
+ } else if (commonRow && commVal !== '') {
73
+ // Common 有值 → 次优先级
74
+ finalValue = commVal;
75
+ if (commColor === 'yellow') warnYellow = true;
76
+ } else if (commonRow && commVal === '' && commColor !== null && commColor !== 'none') {
77
+ // Common 主动清空
78
+ finalValue = '';
79
+ }
80
+ // else: 保留本地
81
+
82
+ if (finalValue !== undefined && finalValue !== localValue) {
83
+ plan.push({
84
+ filePath: localEntry.filePaths?.[localKey] || localEntry.filePath,
85
+ key: localEntry.keyMap?.[localKey] || localKey,
86
+ oldValue: localValue,
87
+ newValue: finalValue,
88
+ locale: localeKey,
89
+ warnings: [
90
+ ...checkPlaceholderConsistency(getEnValue(projRow), { [localeKey]: finalValue }),
91
+ ...(warnYellow ? [`[${localeKey}] 来源为黄色单元格(待人工确认)`] : [])
92
+ ]
93
+ });
94
+ }
95
+ }
96
+ }
97
+
98
+ // ── 第二轮:处理 Common 表中未被项目表覆盖的行 ───────────────
99
+ const enEntry = Object.entries(localMap).find(([k]) => isEnLoc(k))?.[1];
100
+ for (const commonRow of commonRows) {
101
+ const enAnchor = normalizeForAnchor(getEnValue(commonRow));
102
+ if (!enAnchor || processedAnchors.has(enAnchor)) continue;
103
+
104
+ if (commonRow.lastsyncby === 'push' && commonRow.lastsyncat) {
105
+ const diff = (Date.now() - new Date(commonRow.lastsyncat).getTime()) / 1000;
106
+ if (diff < config.sync.antiLoopWindow) continue;
107
+ }
108
+ if (commonRow._color === 'gray') continue;
109
+
110
+ for (const [localeKey, localEntry] of Object.entries(localMap)) {
111
+ if (isEnLoc(localeKey)) continue;
112
+
113
+ let localKey;
114
+ if (enEntry) {
115
+ localKey = Object.keys(enEntry.values).find(
116
+ k => normalizeForAnchor(enEntry.values[k]) === enAnchor
117
+ );
118
+ } else {
119
+ localKey = Object.keys(localEntry.values).find(
120
+ k => normalizeForAnchor(localEntry.values[k]) === enAnchor
121
+ );
122
+ }
123
+ if (!localKey || !(localKey in localEntry.values)) continue;
124
+
125
+ const localValue = localEntry.values[localKey];
126
+ const base = getLocaleBase(localeKey);
127
+ const cellColor = commonRow._colors?.[localeKey] ?? commonRow._colors?.[base] ?? 'none';
128
+ const cellVal = commonRow[localeKey] ?? commonRow[base] ?? '';
129
+ let finalValue;
130
+ let warnYellow = false;
131
+
132
+ if (['none', 'green'].includes(cellColor) && cellVal !== '') {
133
+ finalValue = cellVal;
134
+ } else if (cellColor === 'yellow') {
135
+ finalValue = cellVal;
136
+ warnYellow = true;
137
+ } else if (cellVal === '' && cellColor !== 'none') {
138
+ finalValue = '';
139
+ }
140
+
141
+ if (finalValue !== undefined && finalValue !== localValue) {
142
+ plan.push({
143
+ filePath: localEntry.filePaths?.[localKey] || localEntry.filePath,
144
+ key: localKey,
145
+ oldValue: localValue,
146
+ newValue: finalValue,
147
+ locale: localeKey,
148
+ warnings: [
149
+ ...checkPlaceholderConsistency(getEnValue(commonRow), { [localeKey]: finalValue }),
150
+ ...(warnYellow ? [`[${localeKey}] 来源为黄色单元格(待人工确认)`] : [])
151
+ ]
152
+ });
153
+ }
154
+ }
155
+ }
156
+
157
+ return plan;
158
+ }
159
+
160
+ module.exports = { generatePullPlan };
@@ -0,0 +1,367 @@
1
+ const path = require('path');
2
+ const { normalizeForAnchor } = require('../utils/stringNormalizer');
3
+ const { colorToRGB } = require('../../io/googleSheets');
4
+
5
+ // 前置校验:拦截公式注入与非法控制字符
6
+ function validateValue(val, key, locale) {
7
+ if (typeof val !== 'string') return;
8
+ if (val.startsWith('=')) {
9
+ throw new Error(`[Formula Injection] Key "${key}" [${locale}] 值以 "=" 开头,已拒绝写入`);
10
+ }
11
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(val)) {
12
+ throw new Error(`[Control Char] Key "${key}" [${locale}] 值包含非法控制字符,已拒绝写入`);
13
+ }
14
+ }
15
+
16
+ async function generatePushPlan(localItems, sheetData, config) {
17
+ const requests = [];
18
+ const {
19
+ projectHeaders = [],
20
+ projectSheetId = 0,
21
+ commonHeaders = [],
22
+ commonSheetId = 0
23
+ } = sheetData;
24
+
25
+ const normH = h => h.toLowerCase().replace(/[-_]/g, '');
26
+ const getLocaleBase = loc => loc.length <= 3 ? loc : loc.slice(0, 2);
27
+ const isEnCol = hn => getLocaleBase(hn) === 'en'; // en / enus / en-US 归一化后均视为 en 列
28
+ const GREEN = colorToRGB('green');
29
+ const YELLOW = colorToRGB('yellow');
30
+
31
+ // 构建新增行的单元格数组(appendCells 复用),bgColor 可传绿色或黄色
32
+ const buildRow = (hdrs, isProjectTable, rowItem, now, bgColor = GREEN) =>
33
+ hdrs.map(h => {
34
+ const hn = normH(h);
35
+ let val = '';
36
+ if (hn === 'key') val = isProjectTable ? rowItem.key : '';
37
+ else if (hn === 'filepath') val = isProjectTable ? path.relative(process.cwd(), rowItem.filePath) : '';
38
+ else if (isEnCol(hn)) val = rowItem.en;
39
+ else if (hn === 'lastsyncby') val = 'push';
40
+ else if (hn === 'lastsyncat') val = now;
41
+ else {
42
+ val = rowItem.translations[hn] || '';
43
+ validateValue(val, rowItem.key, hn);
44
+ }
45
+ const isTransCol = !['key', 'filepath', 'lastsyncby', 'lastsyncat'].includes(hn);
46
+ const cell = { userEnteredValue: { stringValue: (val == null ? '' : String(val)) } };
47
+ cell.userEnteredFormat = { wrapStrategy: 'WRAP', horizontalAlignment: 'CENTER', verticalAlignment: 'MIDDLE' };
48
+ if (isTransCol && val) cell.userEnteredFormat.backgroundColor = bgColor;
49
+ return cell;
50
+ });
51
+
52
+ // 构建冲突行:仅含冲突 locale(黄色) + en/key/filepath 元信息,其余 locale 留空
53
+ const buildConflictRow = (hdrs, rowItem, now, conflictMap) =>
54
+ hdrs.map(h => {
55
+ const hn = normH(h);
56
+ let val = '';
57
+ if (hn === 'key') val = rowItem.key;
58
+ else if (hn === 'filepath') val = path.relative(process.cwd(), rowItem.filePath);
59
+ else if (isEnCol(hn)) val = rowItem.en;
60
+ else if (hn === 'lastsyncby') val = 'push';
61
+ else if (hn === 'lastsyncat') val = now;
62
+ else if (conflictMap.has(hn)) val = conflictMap.get(hn);
63
+ // 其他 locale → 留空(已在 common 行填充)
64
+
65
+ const cell = { userEnteredValue: { stringValue: (val == null ? '' : String(val)) } };
66
+ cell.userEnteredFormat = { wrapStrategy: 'WRAP', horizontalAlignment: 'CENTER', verticalAlignment: 'MIDDLE' };
67
+ if (val) {
68
+ if (conflictMap.has(hn)) {
69
+ cell.userEnteredFormat.backgroundColor = YELLOW; // 冲突 locale → 黄色
70
+ } else if (!['key', 'filepath', 'lastsyncby', 'lastsyncat'].includes(hn)) {
71
+ cell.userEnteredFormat.backgroundColor = GREEN; // en 等内容列 → 绿色
72
+ }
73
+ }
74
+ return cell;
75
+ });
76
+
77
+ // locale 列按表头顺序排列,用于 en 为空时的稳定备用锚点
78
+ // locale 列按表头顺序排列(排除 en 系列和元数据列),用于无 en 时的稳定备用锚点
79
+ const localeColOrder = commonHeaders
80
+ .map(h => h.toLowerCase().replace(/[-_]/g, ''))
81
+ .filter(h => !['key', 'filepath', 'lastsyncby', 'lastsyncat'].includes(h) && getLocaleBase(h) !== 'en');
82
+
83
+ // 在 readSheet 行对象中查找 en 系列列(en / en-US / enus 等)的英文文本
84
+ const getEnValue = r => {
85
+ for (const [k, v] of Object.entries(r)) {
86
+ if (k.startsWith('_')) continue;
87
+ if (typeof v === 'string' && v.trim() && isEnCol(k)) return v;
88
+ }
89
+ return '';
90
+ };
91
+
92
+ // en 为空时:按表头 locale 列顺序找第一个非空翻译值作为锚点
93
+ const noEnAnchor = valMap => {
94
+ for (const loc of localeColOrder) {
95
+ const v = valMap[loc];
96
+ if (typeof v === 'string' && v.trim()) return `__noen__${normalizeForAnchor(v)}`;
97
+ }
98
+ return null;
99
+ };
100
+
101
+ // 构建索引
102
+ const commonIndex = new Map();
103
+ sheetData.common.forEach(r => {
104
+ const enVal = getEnValue(r);
105
+ if (enVal) {
106
+ const anchor = normalizeForAnchor(enVal);
107
+ if (!commonIndex.has(anchor)) commonIndex.set(anchor, []);
108
+ commonIndex.get(anchor).push(r);
109
+ } else {
110
+ // en 为空:按 locale 列顺序取第一个非空翻译值作锚点
111
+ const anchor = noEnAnchor(r);
112
+ if (anchor) {
113
+ if (!commonIndex.has(anchor)) commonIndex.set(anchor, []);
114
+ commonIndex.get(anchor).push(r);
115
+ }
116
+ // key|filepath 兜底(项目表写入的行)
117
+ if (r.key || r.filepath) {
118
+ const keyAnchor = `__key__${r.key || ''}|${r.filepath || ''}`;
119
+ if (!commonIndex.has(keyAnchor)) commonIndex.set(keyAnchor, []);
120
+ commonIndex.get(keyAnchor).push(r);
121
+ }
122
+ }
123
+ });
124
+ const projectIndex = new Map();
125
+ // 兼容旧格式(绝对路径)和新格式(相对路径),统一转为相对路径作为索引 key
126
+ const toRelPath = p => (!p ? '' : path.isAbsolute(p) ? path.relative(process.cwd(), p) : p);
127
+ sheetData.project.forEach(r => projectIndex.set(`${r.key}|${toRelPath(r.filepath)}`, r));
128
+
129
+ // 新增行缓冲区:最终合并成单个 appendCells,避免 Google API 多请求上限
130
+ const newCommonRows = [];
131
+ const newProjectRows = [];
132
+ // 第一次 push 批内冲突检测:enAnchor → { item, rowIndex }
133
+ const pendingCommonMap = new Map();
134
+
135
+ const now = new Date().toISOString();
136
+
137
+ for (const item of localItems) {
138
+ // en 为空时:用同一套 locale 列顺序逻辑求备用锚点
139
+ let enAnchor;
140
+ if (item.en && item.en.trim()) {
141
+ enAnchor = normalizeForAnchor(item.en);
142
+ } else {
143
+ const normTrans = {};
144
+ for (const [k, v] of Object.entries(item.translations)) normTrans[normH(k)] = v;
145
+ enAnchor = noEnAnchor(normTrans) ?? `__key__${item.key}|${path.relative(process.cwd(), item.filePath)}`;
146
+ }
147
+ const pk = `${item.key}|${path.relative(process.cwd(), item.filePath)}`;
148
+ const projRow = projectIndex.get(pk);
149
+ const commonRows = commonIndex.get(enAnchor) || [];
150
+ const commonRow = commonRows[0];
151
+
152
+ let rowIdx, sheetId, headers, isProject = true;
153
+
154
+ if (projRow) {
155
+ // 防环路:忽略 < antiLoopWindow 秒内来自 Pull 的行
156
+ if (projRow.lastsyncby === 'pull' && projRow.lastsyncat) {
157
+ const diff = (Date.now() - new Date(projRow.lastsyncat).getTime()) / 1000;
158
+ if (diff < config.sync.antiLoopWindow) continue;
159
+ }
160
+ rowIdx = projRow._rowIndex;
161
+ sheetId = projRow._sheetId ?? 0;
162
+ headers = projRow._headers;
163
+ } else if (commonRow) {
164
+ if (commonRow.lastsyncby === 'pull' && commonRow.lastsyncat) {
165
+ const diff = (Date.now() - new Date(commonRow.lastsyncat).getTime()) / 1000;
166
+ if (diff < config.sync.antiLoopWindow) continue;
167
+ }
168
+ rowIdx = commonRow._rowIndex;
169
+ sheetId = commonRow._sheetId ?? 0;
170
+ headers = commonRow._headers;
171
+ isProject = false;
172
+ } else {
173
+ // ── 新增行:common / project 都不存在 ──
174
+ const hasContent = (item.en && item.en.trim() !== '') ||
175
+ Object.values(item.translations).some(v => typeof v === 'string' && v.trim() !== '');
176
+ if (!hasContent) continue;
177
+
178
+ if (commonHeaders.length > 0) {
179
+ const pending = pendingCommonMap.get(enAnchor);
180
+
181
+ if (pending) {
182
+ // 同英文锚点已在待写队列:冲突检测 + 非冲突 locale 合并入已排队行
183
+ const conflictMap = new Map();
184
+ const fillUpdates = {};
185
+
186
+ for (const [locale, val] of Object.entries(item.translations)) {
187
+ if (isEnCol(normH(locale))) continue;
188
+ const localeNorm = normH(locale);
189
+ if (!val || !val.trim()) continue;
190
+ const pendingVal = (pending.item.translations[localeNorm] ?? '').trim();
191
+ if (pendingVal && pendingVal !== val) {
192
+ conflictMap.set(localeNorm, val);
193
+ } else if (!pendingVal) {
194
+ fillUpdates[localeNorm] = val;
195
+ }
196
+ }
197
+
198
+ // 将新 locale 值合并入已排队的 common 行
199
+ if (Object.keys(fillUpdates).length > 0) {
200
+ const mergedItem = { ...pending.item, translations: { ...pending.item.translations, ...fillUpdates } };
201
+ newCommonRows[pending.rowIndex] = { values: buildRow(commonHeaders, false, mergedItem, now) };
202
+ pending.item = mergedItem;
203
+ }
204
+
205
+ if (conflictMap.size > 0 && projectHeaders.length > 0) {
206
+ newProjectRows.push({ values: buildConflictRow(projectHeaders, item, now, conflictMap) });
207
+ }
208
+ } else {
209
+ // 首次出现:写入 common,并记录到待写索引
210
+ pendingCommonMap.set(enAnchor, { item, rowIndex: newCommonRows.length });
211
+ newCommonRows.push({ values: buildRow(commonHeaders, false, item, now) });
212
+ }
213
+ }
214
+ continue;
215
+ }
216
+
217
+ // ── 更新已有行 ──────────────────────────────────────────────
218
+ // 检查 common 行是否为重复标记(同一翻译内容已在其他行)
219
+ if (!isProject && commonRow && commonRow._isDuplicateMarked) {
220
+ // 重复行:生成警告信息,但继续处理(可选择跳过或合并)
221
+ console.warn(`[WARN] Common表中发现重复翻译: key="${item.key}" en="${getEnValue(commonRow)}",该行可能需要人工合并`);
222
+ }
223
+
224
+ // 检查是否与 common 行完全相同(所有 locale 都匹配)→ 完全重复则直接跳过
225
+ if (!isProject && commonRow) {
226
+ let isFullyMatched = true;
227
+ for (const [locale, val] of Object.entries(item.translations)) {
228
+ if (isEnCol(normH(locale))) continue;
229
+ const localeNorm = normH(locale);
230
+ const itemVal = (val || '').trim();
231
+ const commonVal = (commonRow[localeNorm] || '').trim();
232
+ if (itemVal && itemVal !== commonVal) {
233
+ isFullyMatched = false;
234
+ break;
235
+ }
236
+ }
237
+ if (isFullyMatched && Object.values(item.translations).some(v => (v || '').trim())) {
238
+ // 所有翻译都相同,完全重复 → 跳过此项
239
+ continue;
240
+ }
241
+ }
242
+
243
+ const syncByIdx = headers.findIndex(h => normH(h) === 'lastsyncby');
244
+ const syncAtIdx = headers.findIndex(h => normH(h) === 'lastsyncat');
245
+ let rowHasUpdate = false;
246
+
247
+ if (!isProject) {
248
+ // ── Common 行:填充空格 + 冲突检测 ──
249
+ // fill : common 为空,本地有值 → 更新 common(绿色)
250
+ // conflict: common 非空 且本地值不同(非空)→ 不改 common,在项目表追加冲突行
251
+ const conflictMap = new Map(); // localeNorm → local val
252
+
253
+ for (const [locale, val] of Object.entries(item.translations)) {
254
+ if (isEnCol(normH(locale))) continue;
255
+ const localeNorm = normH(locale);
256
+ const colIdx = headers.findIndex(h => normH(h) === localeNorm);
257
+ if (colIdx === -1) continue;
258
+ validateValue(val, item.key, locale);
259
+
260
+ const cellColor = commonRow._colors?.[localeNorm] ?? 'none';
261
+ const existingVal = commonRow[localeNorm] ?? '';
262
+ // 译者手动编辑(无背景色 + 非空)→ 锁定,完全跳过
263
+ if (cellColor === 'none' && existingVal !== '') continue;
264
+
265
+ if (existingVal === '' && val && val.trim() !== '') {
266
+ // 空格填充:common 为空,本地有值
267
+ requests.push({
268
+ updateCells: {
269
+ range: { sheetId, startRowIndex: rowIdx - 1, endRowIndex: rowIdx, startColumnIndex: colIdx, endColumnIndex: colIdx + 1 },
270
+ rows: [{ values: [{
271
+ userEnteredValue: { stringValue: val },
272
+ userEnteredFormat: { backgroundColor: GREEN, wrapStrategy: 'WRAP' }
273
+ }] }],
274
+ fields: 'userEnteredValue,userEnteredFormat.backgroundColor,userEnteredFormat.wrapStrategy'
275
+ }
276
+ });
277
+ rowHasUpdate = true;
278
+ } else if (existingVal !== '' && val && val.trim() !== '' && val !== existingVal) {
279
+ // 冲突:common 已有不同值 → 记录,不改 common
280
+ conflictMap.set(localeNorm, val);
281
+ }
282
+ // match 或 local 为空 → 无需操作
283
+ }
284
+
285
+ // 冲突 → 项目表追加冲突行(仅含冲突 locale 黄色 + en 绿色,其余 locale 留空)
286
+ // 若该 key 的项目行已存在则不重复追加
287
+ if (conflictMap.size > 0 && projectHeaders.length > 0 && !projectIndex.has(pk)) {
288
+ newProjectRows.push({ values: buildConflictRow(projectHeaders, item, now, conflictMap) });
289
+ }
290
+ } else {
291
+ // ── Project 行:只更新绿色(push 写入)单元格 ──
292
+ // 黄色(冲突待审)、空(非冲突 locale 留空)、白/无色(译者编辑)均跳过
293
+ for (const [locale, val] of Object.entries(item.translations)) {
294
+ if (isEnCol(normH(locale))) continue;
295
+ const localeNorm = normH(locale);
296
+ const colIdx = headers.findIndex(h => normH(h) === localeNorm);
297
+ if (colIdx === -1) continue;
298
+ if (!val || !val.trim()) continue; // 本地值为空,跳过
299
+ validateValue(val, item.key, locale);
300
+
301
+ const cellColor = projRow._colors?.[localeNorm] ?? 'none';
302
+ const existingVal = projRow[localeNorm] ?? '';
303
+ // 仅更新 push 写入的绿色单元格(值有变化时)
304
+ if (cellColor !== 'green') continue;
305
+ if (existingVal === val) continue; // 无变化跳过
306
+
307
+ requests.push({
308
+ updateCells: {
309
+ range: { sheetId, startRowIndex: rowIdx - 1, endRowIndex: rowIdx, startColumnIndex: colIdx, endColumnIndex: colIdx + 1 },
310
+ rows: [{ values: [{
311
+ userEnteredValue: { stringValue: val },
312
+ userEnteredFormat: { backgroundColor: GREEN, wrapStrategy: 'WRAP' }
313
+ }] }],
314
+ fields: 'userEnteredValue,userEnteredFormat.backgroundColor,userEnteredFormat.wrapStrategy'
315
+ }
316
+ });
317
+ rowHasUpdate = true;
318
+ }
319
+ }
320
+
321
+ // 元数据只写一次
322
+ if (rowHasUpdate) {
323
+ if (syncByIdx !== -1) {
324
+ requests.push({
325
+ updateCells: {
326
+ range: { sheetId, startRowIndex: rowIdx - 1, endRowIndex: rowIdx, startColumnIndex: syncByIdx, endColumnIndex: syncByIdx + 1 },
327
+ rows: [{ values: [{ userEnteredValue: { stringValue: 'push' } }] }],
328
+ fields: 'userEnteredValue'
329
+ }
330
+ });
331
+ }
332
+ if (syncAtIdx !== -1) {
333
+ requests.push({
334
+ updateCells: {
335
+ range: { sheetId, startRowIndex: rowIdx - 1, endRowIndex: rowIdx, startColumnIndex: syncAtIdx, endColumnIndex: syncAtIdx + 1 },
336
+ rows: [{ values: [{ userEnteredValue: { stringValue: now } }] }],
337
+ fields: 'userEnteredValue'
338
+ }
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ // ── 新增行:合并为单个 appendCells 请求(common / project 各一个)──
345
+ if (newCommonRows.length > 0) {
346
+ requests.push({
347
+ appendCells: {
348
+ sheetId: commonSheetId,
349
+ rows: newCommonRows,
350
+ fields: 'userEnteredValue,userEnteredFormat.backgroundColor,userEnteredFormat.wrapStrategy,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment'
351
+ }
352
+ });
353
+ }
354
+ if (newProjectRows.length > 0) {
355
+ requests.push({
356
+ appendCells: {
357
+ sheetId: projectSheetId,
358
+ rows: newProjectRows,
359
+ fields: 'userEnteredValue,userEnteredFormat.backgroundColor,userEnteredFormat.wrapStrategy,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment'
360
+ }
361
+ });
362
+ }
363
+
364
+ return requests;
365
+ }
366
+
367
+ module.exports = { generatePushPlan };
@@ -0,0 +1,26 @@
1
+ const PLACEHOLDER_REGEX = /\{[^{}]+\}|\$\{[^{}]+\}|%[sd]|%\{[^{}]+\}|<[^\/]+\/>/g;
2
+
3
+ function extractPlaceholders(str) {
4
+ if (!str) return new Set();
5
+ const matches = str.match(PLACEHOLDER_REGEX) || [];
6
+ return new Set(matches.map(p => p.trim()));
7
+ }
8
+
9
+ function checkPlaceholderConsistency(enText, translations) {
10
+ const enPlaceholders = extractPlaceholders(enText);
11
+ const warnings = [];
12
+
13
+ for (const [locale, text] of Object.entries(translations)) {
14
+ if (!text) continue;
15
+ const localePlaceholders = extractPlaceholders(text);
16
+
17
+ const missing = [...enPlaceholders].filter(p => !localePlaceholders.has(p));
18
+ if (missing.length) warnings.push(`[${locale}] 缺失占位符: ${missing.join(', ')}`);
19
+
20
+ const extra = [...localePlaceholders].filter(p => !enPlaceholders.has(p));
21
+ if (extra.length) warnings.push(`[${locale}] 多余占位符: ${extra.join(', ')}`);
22
+ }
23
+ return warnings;
24
+ }
25
+
26
+ module.exports = { extractPlaceholders, checkPlaceholderConsistency };
@@ -0,0 +1,22 @@
1
+ function normalizeForAnchor(str) {
2
+ if (typeof str !== 'string') return '';
3
+ return str
4
+ .trim()
5
+ .replace(/[\u200B-\u200D\uFEFF\u00A0]/g, '') // 移除零宽字符与不间断空格
6
+ .replace(/\s+/g, ' '); // 连续空白合并为单空格(保留大小写)
7
+ }
8
+
9
+ /**
10
+ * 将归一化 locale 码还原为 BCP-47 显示格式
11
+ * enus → en-US arsa → ar-SA ptbr → pt-BR arb → arb en → en
12
+ */
13
+ function formatLocaleHeader(code) {
14
+ const n = code.length;
15
+ if (n <= 3) return code;
16
+ if (n === 4) return `${code.slice(0, 2)}-${code.slice(2).toUpperCase()}`;
17
+ if (n === 5) return `${code.slice(0, 3)}-${code.slice(3).toUpperCase()}`;
18
+ if (n === 6) return `${code.slice(0, 3)}-${code.slice(3).toUpperCase()}`;
19
+ return code;
20
+ }
21
+
22
+ module.exports = { normalizeForAnchor, formatLocaleHeader };
package/lib/index.js ADDED
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ config: require('./config/manager'),
3
+ io: require('./io/googleSheets'),
4
+ engines: {
5
+ pull: require('./core/engines/pullEngine'),
6
+ push: require('./core/engines/pushEngine')
7
+ }
8
+ };