@hkonda/loco-translate 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.
- package/README.md +284 -0
- package/bin/loco.js +5 -0
- package/dist/assets/index-CGo6e-bA.js +59 -0
- package/dist/assets/index-DBcQDZ75.css +1 -0
- package/dist/index.html +13 -0
- package/dist-server/app.d.ts +3 -0
- package/dist-server/app.d.ts.map +1 -0
- package/dist-server/app.js +154 -0
- package/dist-server/app.js.map +1 -0
- package/dist-server/config.d.ts +16 -0
- package/dist-server/config.d.ts.map +1 -0
- package/dist-server/config.js +41 -0
- package/dist-server/config.js.map +1 -0
- package/dist-server/db/index.d.ts +8 -0
- package/dist-server/db/index.d.ts.map +1 -0
- package/dist-server/db/index.js +28 -0
- package/dist-server/db/index.js.map +1 -0
- package/dist-server/db/schema.d.ts +851 -0
- package/dist-server/db/schema.d.ts.map +1 -0
- package/dist-server/db/schema.js +65 -0
- package/dist-server/db/schema.js.map +1 -0
- package/dist-server/db/seed.d.ts +14 -0
- package/dist-server/db/seed.d.ts.map +1 -0
- package/dist-server/db/seed.js +229 -0
- package/dist-server/db/seed.js.map +1 -0
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.d.ts.map +1 -0
- package/dist-server/index.js +31 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/routes/ai-jobs.d.ts +5 -0
- package/dist-server/routes/ai-jobs.d.ts.map +1 -0
- package/dist-server/routes/ai-jobs.js +141 -0
- package/dist-server/routes/ai-jobs.js.map +1 -0
- package/dist-server/routes/backup.d.ts +5 -0
- package/dist-server/routes/backup.d.ts.map +1 -0
- package/dist-server/routes/backup.js +125 -0
- package/dist-server/routes/backup.js.map +1 -0
- package/dist-server/routes/chrome-extension.d.ts +5 -0
- package/dist-server/routes/chrome-extension.d.ts.map +1 -0
- package/dist-server/routes/chrome-extension.js +140 -0
- package/dist-server/routes/chrome-extension.js.map +1 -0
- package/dist-server/routes/export.d.ts +5 -0
- package/dist-server/routes/export.d.ts.map +1 -0
- package/dist-server/routes/export.js +95 -0
- package/dist-server/routes/export.js.map +1 -0
- package/dist-server/routes/languages.d.ts +5 -0
- package/dist-server/routes/languages.d.ts.map +1 -0
- package/dist-server/routes/languages.js +36 -0
- package/dist-server/routes/languages.js.map +1 -0
- package/dist-server/routes/project.d.ts +5 -0
- package/dist-server/routes/project.d.ts.map +1 -0
- package/dist-server/routes/project.js +151 -0
- package/dist-server/routes/project.js.map +1 -0
- package/dist-server/routes/prompts.d.ts +5 -0
- package/dist-server/routes/prompts.d.ts.map +1 -0
- package/dist-server/routes/prompts.js +90 -0
- package/dist-server/routes/prompts.js.map +1 -0
- package/dist-server/routes/screenshots.d.ts +5 -0
- package/dist-server/routes/screenshots.d.ts.map +1 -0
- package/dist-server/routes/screenshots.js +71 -0
- package/dist-server/routes/screenshots.js.map +1 -0
- package/dist-server/routes/textnodes.d.ts +5 -0
- package/dist-server/routes/textnodes.d.ts.map +1 -0
- package/dist-server/routes/textnodes.js +318 -0
- package/dist-server/routes/textnodes.js.map +1 -0
- package/dist-server/routes/translations.d.ts +5 -0
- package/dist-server/routes/translations.d.ts.map +1 -0
- package/dist-server/routes/translations.js +224 -0
- package/dist-server/routes/translations.js.map +1 -0
- package/dist-server/scripts/backup.d.ts +2 -0
- package/dist-server/scripts/backup.d.ts.map +1 -0
- package/dist-server/scripts/backup.js +26 -0
- package/dist-server/scripts/backup.js.map +1 -0
- package/dist-server/services/ai-translation.d.ts +21 -0
- package/dist-server/services/ai-translation.d.ts.map +1 -0
- package/dist-server/services/ai-translation.js +137 -0
- package/dist-server/services/ai-translation.js.map +1 -0
- package/dist-server/services/job-manager.d.ts +67 -0
- package/dist-server/services/job-manager.d.ts.map +1 -0
- package/dist-server/services/job-manager.js +159 -0
- package/dist-server/services/job-manager.js.map +1 -0
- package/dist-server/services/slot-pattern.d.ts +16 -0
- package/dist-server/services/slot-pattern.d.ts.map +1 -0
- package/dist-server/services/slot-pattern.js +68 -0
- package/dist-server/services/slot-pattern.js.map +1 -0
- package/dist-server/utils/network.d.ts +2 -0
- package/dist-server/utils/network.d.ts.map +1 -0
- package/dist-server/utils/network.js +16 -0
- package/dist-server/utils/network.js.map +1 -0
- package/package.json +47 -0
- package/public/loco.js +2386 -0
- package/public/loco.min.js +4 -0
package/public/loco.js
ADDED
|
@@ -0,0 +1,2386 @@
|
|
|
1
|
+
var Loco = function() {
|
|
2
|
+
"use strict";
|
|
3
|
+
var state = {
|
|
4
|
+
apiKey: null,
|
|
5
|
+
apiBase: null,
|
|
6
|
+
phrases: [],
|
|
7
|
+
translations: {},
|
|
8
|
+
observer: null,
|
|
9
|
+
fileMode: false,
|
|
10
|
+
fileData: null,
|
|
11
|
+
fileReadyResolve: null,
|
|
12
|
+
fileReady: null,
|
|
13
|
+
fileUrl: null,
|
|
14
|
+
fileUrls: [],
|
|
15
|
+
scanStopped: false,
|
|
16
|
+
loadedFromCache: false,
|
|
17
|
+
screenshotsEnabled: true,
|
|
18
|
+
widgetPosition: null
|
|
19
|
+
};
|
|
20
|
+
var DEFAULT_BLOCKED_TAGS = [
|
|
21
|
+
"SCRIPT",
|
|
22
|
+
"STYLE",
|
|
23
|
+
"NOSCRIPT",
|
|
24
|
+
"IFRAME",
|
|
25
|
+
"CODE",
|
|
26
|
+
"SVG",
|
|
27
|
+
"AUDIO",
|
|
28
|
+
"VIDEO",
|
|
29
|
+
"LINK"
|
|
30
|
+
];
|
|
31
|
+
var DEFAULT_INLINE_TAGS = [
|
|
32
|
+
"VAR",
|
|
33
|
+
"STRONG",
|
|
34
|
+
"EM",
|
|
35
|
+
"B",
|
|
36
|
+
"I",
|
|
37
|
+
"SPAN",
|
|
38
|
+
"A",
|
|
39
|
+
"ABBR",
|
|
40
|
+
"MARK",
|
|
41
|
+
"SMALL",
|
|
42
|
+
"SUB",
|
|
43
|
+
"SUP",
|
|
44
|
+
"U",
|
|
45
|
+
"S",
|
|
46
|
+
"TIME"
|
|
47
|
+
];
|
|
48
|
+
var DEFAULT_DOM_CONTEXT_SELECTORS = [
|
|
49
|
+
"h1",
|
|
50
|
+
"h2",
|
|
51
|
+
"h3",
|
|
52
|
+
"h4",
|
|
53
|
+
"h5",
|
|
54
|
+
"h6",
|
|
55
|
+
"label",
|
|
56
|
+
"legend",
|
|
57
|
+
"caption",
|
|
58
|
+
"figcaption",
|
|
59
|
+
"nav",
|
|
60
|
+
"header",
|
|
61
|
+
"footer",
|
|
62
|
+
"main",
|
|
63
|
+
"section",
|
|
64
|
+
"article",
|
|
65
|
+
"form"
|
|
66
|
+
];
|
|
67
|
+
var BLOCKED_TAGS = new Set(DEFAULT_BLOCKED_TAGS);
|
|
68
|
+
var INLINE_TAGS = new Set(DEFAULT_INLINE_TAGS);
|
|
69
|
+
var DOM_CONTEXT_SELECTORS = [].concat(DEFAULT_DOM_CONTEXT_SELECTORS);
|
|
70
|
+
var CONTEXT_DEPTH = 0;
|
|
71
|
+
var DOM_CONTEXT_ENABLED = true;
|
|
72
|
+
var MAX_TEXT_LENGTH = 1e3;
|
|
73
|
+
var MAX_STRING_LENGTH = 1e4;
|
|
74
|
+
var STORAGE_KEY = "loco-lang";
|
|
75
|
+
function getSavedLang() {
|
|
76
|
+
try {
|
|
77
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function saveLang(code) {
|
|
83
|
+
try {
|
|
84
|
+
if (code) localStorage.setItem(STORAGE_KEY, code);
|
|
85
|
+
else localStorage.removeItem(STORAGE_KEY);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function applyConfig(cfg) {
|
|
90
|
+
if (cfg.blockedTags) {
|
|
91
|
+
var disabled = cfg.blockedTags.disabled || [];
|
|
92
|
+
var custom = cfg.blockedTags.custom || [];
|
|
93
|
+
BLOCKED_TAGS = new Set(DEFAULT_BLOCKED_TAGS.filter(function(t) {
|
|
94
|
+
return disabled.indexOf(t) < 0;
|
|
95
|
+
}));
|
|
96
|
+
custom.forEach(function(t) {
|
|
97
|
+
BLOCKED_TAGS.add(t.toUpperCase());
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (cfg.inlineTags) {
|
|
101
|
+
var disabled = cfg.inlineTags.disabled || [];
|
|
102
|
+
var custom = cfg.inlineTags.custom || [];
|
|
103
|
+
INLINE_TAGS = new Set(DEFAULT_INLINE_TAGS.filter(function(t) {
|
|
104
|
+
return disabled.indexOf(t) < 0;
|
|
105
|
+
}));
|
|
106
|
+
custom.forEach(function(t) {
|
|
107
|
+
INLINE_TAGS.add(t.toUpperCase());
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (cfg.domContextSelectors) {
|
|
111
|
+
var disabled = cfg.domContextSelectors.disabled || [];
|
|
112
|
+
var custom = cfg.domContextSelectors.custom || [];
|
|
113
|
+
DOM_CONTEXT_SELECTORS = DEFAULT_DOM_CONTEXT_SELECTORS.filter(function(s) {
|
|
114
|
+
return disabled.indexOf(s) < 0;
|
|
115
|
+
}).concat(custom);
|
|
116
|
+
}
|
|
117
|
+
if (cfg.screenshotsEnabled === false) {
|
|
118
|
+
state.screenshotsEnabled = false;
|
|
119
|
+
}
|
|
120
|
+
if (typeof cfg.contextDepth === "number") {
|
|
121
|
+
CONTEXT_DEPTH = cfg.contextDepth;
|
|
122
|
+
}
|
|
123
|
+
if (cfg.domContextEnabled === false) {
|
|
124
|
+
DOM_CONTEXT_ENABLED = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function normalize(text) {
|
|
128
|
+
return text.trim().replace(/\s+/g, " ");
|
|
129
|
+
}
|
|
130
|
+
function isOnlySymbolsOrNumbers(text) {
|
|
131
|
+
return !/[a-zA-Z\u00C0-\u024F\u0900-\u097F\u0600-\u06FF]/.test(text);
|
|
132
|
+
}
|
|
133
|
+
function isMixedKeyOnlySeparators(key) {
|
|
134
|
+
var literal = key.replace(/\{\{(?:text|number|decimal|date):\d+\}\}/g, "");
|
|
135
|
+
return !literal.trim() || isOnlySymbolsOrNumbers(literal);
|
|
136
|
+
}
|
|
137
|
+
function escapeHTML(str) {
|
|
138
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
139
|
+
}
|
|
140
|
+
function shouldSkipElement(el) {
|
|
141
|
+
var _a;
|
|
142
|
+
if (!el || el.nodeType !== 1) return false;
|
|
143
|
+
const tag = (_a = el.tagName) == null ? void 0 : _a.toUpperCase();
|
|
144
|
+
return BLOCKED_TAGS.has(tag) || el.hasAttribute("notranslate") || el.hasAttribute("data-notranslate") || el.getAttribute("translate") === "no" || el.isContentEditable;
|
|
145
|
+
}
|
|
146
|
+
function isAncestorBlocked(node) {
|
|
147
|
+
let el = node.parentElement;
|
|
148
|
+
while (el && el !== document.body) {
|
|
149
|
+
if (shouldSkipElement(el)) return true;
|
|
150
|
+
el = el.parentElement;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const HEADING_TAGS = /* @__PURE__ */ new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
155
|
+
const LABEL_SKIP_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "CODE"]);
|
|
156
|
+
let _traceCache = /* @__PURE__ */ new WeakMap();
|
|
157
|
+
let _labelCache = /* @__PURE__ */ new WeakMap();
|
|
158
|
+
let _allContextElements = null;
|
|
159
|
+
function prepareContextScan() {
|
|
160
|
+
if (!DOM_CONTEXT_ENABLED) return;
|
|
161
|
+
_allContextElements = /* @__PURE__ */ new Set();
|
|
162
|
+
var selectors = DOM_CONTEXT_SELECTORS.slice();
|
|
163
|
+
try {
|
|
164
|
+
var combined = selectors.join(",");
|
|
165
|
+
document.querySelectorAll(combined).forEach(function(el) {
|
|
166
|
+
_allContextElements.add(el);
|
|
167
|
+
});
|
|
168
|
+
} catch (e) {
|
|
169
|
+
selectors.forEach(function(sel) {
|
|
170
|
+
try {
|
|
171
|
+
document.querySelectorAll(sel).forEach(function(el) {
|
|
172
|
+
_allContextElements.add(el);
|
|
173
|
+
});
|
|
174
|
+
} catch (e2) {
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
_allContextElements.forEach(function(el) {
|
|
179
|
+
if (!_labelCache.has(el)) getLabel(el);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function isContextElement(el) {
|
|
183
|
+
if (_allContextElements) return _allContextElements.has(el);
|
|
184
|
+
if (HEADING_TAGS.has(el.tagName)) return true;
|
|
185
|
+
return DOM_CONTEXT_SELECTORS.some(function(sel) {
|
|
186
|
+
try {
|
|
187
|
+
return el.matches(sel);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function getDirectText(el) {
|
|
194
|
+
let text = "";
|
|
195
|
+
for (const child of el.childNodes) {
|
|
196
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
197
|
+
const t = child.textContent.trim();
|
|
198
|
+
if (t) text += (text ? " " : "") + t;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
function getVisibleText(el, maxLen) {
|
|
204
|
+
var text = "";
|
|
205
|
+
var walker = document.createTreeWalker(el, NodeFilter.SHOW_ALL, {
|
|
206
|
+
acceptNode: function(node2) {
|
|
207
|
+
if (node2.nodeType === Node.ELEMENT_NODE) {
|
|
208
|
+
if (LABEL_SKIP_TAGS.has(node2.tagName)) return NodeFilter.FILTER_REJECT;
|
|
209
|
+
return NodeFilter.FILTER_SKIP;
|
|
210
|
+
}
|
|
211
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
var node;
|
|
215
|
+
while (node = walker.nextNode()) {
|
|
216
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
217
|
+
text += node.textContent;
|
|
218
|
+
if (text.length >= maxLen) break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return text.trim();
|
|
222
|
+
}
|
|
223
|
+
function getLabel(el) {
|
|
224
|
+
if (_labelCache.has(el)) return _labelCache.get(el);
|
|
225
|
+
let result = null;
|
|
226
|
+
const direct = getDirectText(el);
|
|
227
|
+
if (direct) {
|
|
228
|
+
result = direct.substring(0, 60);
|
|
229
|
+
_labelCache.set(el, result);
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
if (HEADING_TAGS.has(el.tagName)) {
|
|
233
|
+
const full = (el.textContent || "").trim().replace(/\s+/g, " ");
|
|
234
|
+
if (full) {
|
|
235
|
+
result = full.substring(0, 60);
|
|
236
|
+
_labelCache.set(el, result);
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
var visible = getVisibleText(el, 200);
|
|
241
|
+
if (visible) {
|
|
242
|
+
const firstLine = visible.split(/\n/)[0].trim().replace(/\s+/g, " ");
|
|
243
|
+
if (firstLine.length >= 2 && !isOnlySymbolsOrNumbers(firstLine)) {
|
|
244
|
+
result = firstLine.substring(0, 60);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
_labelCache.set(el, result);
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
function buildContextTrace(el) {
|
|
251
|
+
if (!el || !DOM_CONTEXT_ENABLED) return "";
|
|
252
|
+
if (_traceCache.has(el)) return _traceCache.get(el);
|
|
253
|
+
const trail = [];
|
|
254
|
+
const seen = /* @__PURE__ */ new Set();
|
|
255
|
+
let current = el;
|
|
256
|
+
let steps = 0;
|
|
257
|
+
if (el && el.tagName === "TD") {
|
|
258
|
+
var table = el.closest("table");
|
|
259
|
+
if (table) {
|
|
260
|
+
var th = table.querySelector(
|
|
261
|
+
"thead tr th:nth-child(" + (el.cellIndex + 1) + ")"
|
|
262
|
+
);
|
|
263
|
+
if (th) {
|
|
264
|
+
var lbl = getLabel(th);
|
|
265
|
+
if (lbl) {
|
|
266
|
+
seen.add(lbl);
|
|
267
|
+
trail.push(lbl);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
while (current && current !== document.body) {
|
|
273
|
+
if (CONTEXT_DEPTH > 0 && steps >= CONTEXT_DEPTH) break;
|
|
274
|
+
steps++;
|
|
275
|
+
let sib = current.previousElementSibling;
|
|
276
|
+
while (sib) {
|
|
277
|
+
var sibTag = sib.tagName.toUpperCase();
|
|
278
|
+
if (BLOCKED_TAGS.has(sibTag)) {
|
|
279
|
+
sib = sib.previousElementSibling;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (sibTag === "ARTICLE" || sibTag === "MAIN") {
|
|
283
|
+
sib = sib.previousElementSibling;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (isContextElement(sib)) {
|
|
287
|
+
const lbl2 = getLabel(sib);
|
|
288
|
+
if (lbl2 && !seen.has(lbl2)) {
|
|
289
|
+
seen.add(lbl2);
|
|
290
|
+
trail.unshift(lbl2);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
let nested = null;
|
|
295
|
+
for (const child of sib.children) {
|
|
296
|
+
if (isContextElement(child)) {
|
|
297
|
+
nested = child;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!nested) {
|
|
302
|
+
for (const child of sib.children) {
|
|
303
|
+
for (const gc of child.children) {
|
|
304
|
+
if (isContextElement(gc)) {
|
|
305
|
+
nested = gc;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (nested) break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (nested) {
|
|
313
|
+
const lbl2 = getLabel(nested);
|
|
314
|
+
if (lbl2 && !seen.has(lbl2)) {
|
|
315
|
+
seen.add(lbl2);
|
|
316
|
+
trail.unshift(lbl2);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
sib = sib.previousElementSibling;
|
|
321
|
+
}
|
|
322
|
+
if (isContextElement(current)) {
|
|
323
|
+
const lbl2 = getLabel(current);
|
|
324
|
+
if (lbl2 && !seen.has(lbl2)) {
|
|
325
|
+
seen.add(lbl2);
|
|
326
|
+
trail.unshift(lbl2);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
var tag = current.tagName;
|
|
330
|
+
if (tag === "ARTICLE" || tag === "MAIN") break;
|
|
331
|
+
current = current.parentElement;
|
|
332
|
+
}
|
|
333
|
+
const result = trail.join(" > ");
|
|
334
|
+
_traceCache.set(el, result);
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
function detectSlotType(value) {
|
|
338
|
+
if (/^\d+$/.test(value)) return "number";
|
|
339
|
+
if (/^\d+\.\d+$/.test(value)) return "decimal";
|
|
340
|
+
return "text";
|
|
341
|
+
}
|
|
342
|
+
function extractVarSlots(rawKey, patternKey) {
|
|
343
|
+
var tokens = patternKey.split(/(\{\{(?:text|number|decimal|date):\d+\}\})/);
|
|
344
|
+
var slots = [];
|
|
345
|
+
var remaining = rawKey;
|
|
346
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
347
|
+
var token = tokens[i];
|
|
348
|
+
var m = token.match(/^\{\{(text|number|decimal|date):(\d+)\}\}$/);
|
|
349
|
+
if (m) {
|
|
350
|
+
if (remaining.indexOf(token) === 0) {
|
|
351
|
+
slots.push({ type: m[1], index: parseInt(m[2]), originalText: token });
|
|
352
|
+
remaining = remaining.substring(token.length);
|
|
353
|
+
} else {
|
|
354
|
+
var suffix = "";
|
|
355
|
+
for (var j = i + 1; j < tokens.length; j++) {
|
|
356
|
+
var nextTok = tokens[j];
|
|
357
|
+
if (!/^\{\{(?:text|number|decimal|date):\d+\}\}$/.test(nextTok)) {
|
|
358
|
+
if (nextTok !== "") {
|
|
359
|
+
suffix = nextTok;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
} else if (remaining.indexOf(nextTok) >= 0) {
|
|
363
|
+
suffix = nextTok;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
var value;
|
|
368
|
+
if (suffix === "") {
|
|
369
|
+
value = remaining;
|
|
370
|
+
remaining = "";
|
|
371
|
+
} else {
|
|
372
|
+
var sepIdx = remaining.indexOf(suffix);
|
|
373
|
+
if (sepIdx >= 0) {
|
|
374
|
+
value = remaining.substring(0, sepIdx);
|
|
375
|
+
remaining = remaining.substring(sepIdx);
|
|
376
|
+
} else {
|
|
377
|
+
value = remaining;
|
|
378
|
+
remaining = "";
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
slots.push({ type: m[1], index: parseInt(m[2]), originalText: value });
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
if (remaining.indexOf(token) === 0) remaining = remaining.substring(token.length);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return slots;
|
|
388
|
+
}
|
|
389
|
+
function applyKeyMap(phraseList, keyMap) {
|
|
390
|
+
phraseList.forEach(function(p) {
|
|
391
|
+
if (p.varSlots && p.varSlots.length > 0) return;
|
|
392
|
+
var mapped = keyMap[p.key];
|
|
393
|
+
if (!mapped) return;
|
|
394
|
+
p.varSlots = extractVarSlots(p.key, mapped);
|
|
395
|
+
p.key = mapped;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function isStaticVarMatch(key, pattern) {
|
|
399
|
+
var parts = pattern.split(/\{\{(?:text|number|decimal|date):\d+\}\}/g);
|
|
400
|
+
var remaining = key;
|
|
401
|
+
var values = [];
|
|
402
|
+
for (var i = 0; i < parts.length; i++) {
|
|
403
|
+
if (i === 0) {
|
|
404
|
+
if (remaining.indexOf(parts[i]) === 0) remaining = remaining.substring(parts[i].length);
|
|
405
|
+
} else {
|
|
406
|
+
if (parts[i] === "" && i === parts.length - 1) {
|
|
407
|
+
values.push(remaining);
|
|
408
|
+
remaining = "";
|
|
409
|
+
} else {
|
|
410
|
+
var idx = remaining.indexOf(parts[i]);
|
|
411
|
+
if (idx >= 0) {
|
|
412
|
+
values.push(remaining.substring(0, idx));
|
|
413
|
+
remaining = remaining.substring(idx + parts[i].length);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return values.length > 0 && values.every(function(v) {
|
|
419
|
+
return /^[a-zA-Z\s]+$/.test(v.trim());
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function patternSpecificity(patternKey) {
|
|
423
|
+
var score = 0;
|
|
424
|
+
var re = /\{\{(text|number|decimal|date):\d+\}\}/g;
|
|
425
|
+
var m;
|
|
426
|
+
while ((m = re.exec(patternKey)) !== null) {
|
|
427
|
+
switch (m[1]) {
|
|
428
|
+
case "number":
|
|
429
|
+
score += 3;
|
|
430
|
+
break;
|
|
431
|
+
case "decimal":
|
|
432
|
+
score += 3;
|
|
433
|
+
break;
|
|
434
|
+
case "date":
|
|
435
|
+
score += 2;
|
|
436
|
+
break;
|
|
437
|
+
default:
|
|
438
|
+
score += 1;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return score;
|
|
443
|
+
}
|
|
444
|
+
function buildFileVarKeyMap(phraseList, translationKeys) {
|
|
445
|
+
var slotPatterns = [];
|
|
446
|
+
for (var i = 0; i < translationKeys.length; i++) {
|
|
447
|
+
var k = translationKeys[i];
|
|
448
|
+
if (k.indexOf("{{text:") < 0 && k.indexOf("{{number:") < 0 && k.indexOf("{{decimal:") < 0 && k.indexOf("{{date:") < 0) continue;
|
|
449
|
+
var literals = [];
|
|
450
|
+
var slotTypes = [];
|
|
451
|
+
var lastIdx = 0;
|
|
452
|
+
var re = /\{\{(text|number|decimal|date):\d+\}\}/g;
|
|
453
|
+
var m;
|
|
454
|
+
while ((m = re.exec(k)) !== null) {
|
|
455
|
+
literals.push(k.substring(lastIdx, m.index));
|
|
456
|
+
slotTypes.push(m[1]);
|
|
457
|
+
lastIdx = re.lastIndex;
|
|
458
|
+
}
|
|
459
|
+
literals.push(k.substring(lastIdx));
|
|
460
|
+
var regexStr = "";
|
|
461
|
+
for (var j = 0; j < literals.length; j++) {
|
|
462
|
+
regexStr += literals[j].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
463
|
+
if (j < slotTypes.length) {
|
|
464
|
+
switch (slotTypes[j]) {
|
|
465
|
+
case "number":
|
|
466
|
+
regexStr += "\\d+";
|
|
467
|
+
break;
|
|
468
|
+
case "decimal":
|
|
469
|
+
regexStr += "\\d+\\.\\d+";
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
regexStr += ".+";
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
slotPatterns.push({ pattern: k, regex: new RegExp("^" + regexStr + "$"), specificity: patternSpecificity(k) });
|
|
478
|
+
}
|
|
479
|
+
if (slotPatterns.length === 0) return;
|
|
480
|
+
slotPatterns.sort(function(a, b) {
|
|
481
|
+
return b.specificity - a.specificity;
|
|
482
|
+
});
|
|
483
|
+
var keyMap = {};
|
|
484
|
+
var translationKeySet = {};
|
|
485
|
+
for (var i = 0; i < translationKeys.length; i++) translationKeySet[translationKeys[i]] = true;
|
|
486
|
+
phraseList.forEach(function(p) {
|
|
487
|
+
if (translationKeySet[p.key]) return;
|
|
488
|
+
if (p.key.length > MAX_TEXT_LENGTH) return;
|
|
489
|
+
for (var j2 = 0; j2 < slotPatterns.length; j2++) {
|
|
490
|
+
if (slotPatterns[j2].regex.test(p.key) && !isStaticVarMatch(p.key, slotPatterns[j2].pattern)) {
|
|
491
|
+
keyMap[p.key] = slotPatterns[j2].pattern;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
applyKeyMap(phraseList, keyMap);
|
|
497
|
+
return keyMap;
|
|
498
|
+
}
|
|
499
|
+
function hasMixedContent(el) {
|
|
500
|
+
let hasText = false;
|
|
501
|
+
let hasSpecial = false;
|
|
502
|
+
for (const child of el.childNodes) {
|
|
503
|
+
if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
|
|
504
|
+
hasText = true;
|
|
505
|
+
}
|
|
506
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
507
|
+
const tag = child.tagName.toUpperCase();
|
|
508
|
+
if (tag === "BR") return false;
|
|
509
|
+
if (tag === "VAR" || tag === "TIME" && child.hasAttribute("datetime") || INLINE_TAGS.has(tag)) {
|
|
510
|
+
hasSpecial = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return hasText && hasSpecial;
|
|
515
|
+
}
|
|
516
|
+
function extractMixedContent(parentEl) {
|
|
517
|
+
let key = "";
|
|
518
|
+
const slots = [];
|
|
519
|
+
for (const child of parentEl.childNodes) {
|
|
520
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
521
|
+
const text = normalize(child.textContent);
|
|
522
|
+
if (text) key += text;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
526
|
+
const tag = child.tagName.toUpperCase();
|
|
527
|
+
if (tag === "TIME" && child.hasAttribute("datetime")) {
|
|
528
|
+
const index = slots.length;
|
|
529
|
+
slots.push({ type: "date", index, raw: child.getAttribute("datetime"), display: child.textContent, node: child });
|
|
530
|
+
key += `{{date:${index}}}`;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (tag === "VAR" || INLINE_TAGS.has(tag)) {
|
|
534
|
+
const text = normalize(child.textContent);
|
|
535
|
+
if (text) {
|
|
536
|
+
const index = slots.length;
|
|
537
|
+
const slotType = detectSlotType(text);
|
|
538
|
+
slots.push({ type: slotType, index, tag, value: text, node: child });
|
|
539
|
+
key += `{{${slotType}:${index}}}`;
|
|
540
|
+
}
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { key: normalize(key), slots };
|
|
546
|
+
}
|
|
547
|
+
var INPUT_TRANSLATABLE_ATTRS = ["title", "placeholder", "aria-label"];
|
|
548
|
+
function collectPhrases(root) {
|
|
549
|
+
const phrases = [];
|
|
550
|
+
const seen = /* @__PURE__ */ new Set();
|
|
551
|
+
if (root.nodeType === Node.ELEMENT_NODE && !shouldSkipElement(root) && hasMixedContent(root)) {
|
|
552
|
+
const { key, slots } = extractMixedContent(root);
|
|
553
|
+
if (key && key.length <= MAX_TEXT_LENGTH) {
|
|
554
|
+
if (isMixedKeyOnlySeparators(key)) {
|
|
555
|
+
slots.forEach(function(slot) {
|
|
556
|
+
if (slot.type === "text" && slot.value && slot.value.length >= 2 && !isOnlySymbolsOrNumbers(slot.value)) {
|
|
557
|
+
var slotCtx = buildContextTrace(slot.node || root);
|
|
558
|
+
var sc = slot.value + "\0" + slotCtx;
|
|
559
|
+
if (!seen.has(sc)) {
|
|
560
|
+
seen.add(sc);
|
|
561
|
+
phrases.push({ type: "text", key: slot.value, slots: [], textNode: slot.node ? slot.node.firstChild : null, element: slot.node || root, context: slotCtx, original: slot.value });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
} else {
|
|
566
|
+
var ctx = buildContextTrace(root);
|
|
567
|
+
var compositeKey = key + "\0" + ctx;
|
|
568
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
569
|
+
const type = slots.some((s) => s.type === "date") ? "mixed-date" : slots.some((s) => s.type === "text") ? "mixed-text" : "mixed";
|
|
570
|
+
phrases.push({
|
|
571
|
+
type,
|
|
572
|
+
key,
|
|
573
|
+
slots,
|
|
574
|
+
element: root,
|
|
575
|
+
context: ctx,
|
|
576
|
+
original: key,
|
|
577
|
+
originalHTML: root.innerHTML
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const walker = document.createTreeWalker(
|
|
583
|
+
root,
|
|
584
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
|
585
|
+
{
|
|
586
|
+
acceptNode(node2) {
|
|
587
|
+
var _a, _b, _c;
|
|
588
|
+
if (node2.nodeType === Node.TEXT_NODE) {
|
|
589
|
+
const text = normalize(node2.textContent);
|
|
590
|
+
if (!text || text.length < 2) return NodeFilter.FILTER_SKIP;
|
|
591
|
+
if (text.length > MAX_TEXT_LENGTH) return NodeFilter.FILTER_SKIP;
|
|
592
|
+
if (isOnlySymbolsOrNumbers(text)) return NodeFilter.FILTER_SKIP;
|
|
593
|
+
if (/\{\{(?:text|number|decimal|date):\d+\}\}/.test(text)) return NodeFilter.FILTER_SKIP;
|
|
594
|
+
if (isAncestorBlocked(node2)) return NodeFilter.FILTER_SKIP;
|
|
595
|
+
const parentTag = (_b = (_a = node2.parentElement) == null ? void 0 : _a.tagName) == null ? void 0 : _b.toUpperCase();
|
|
596
|
+
if (BLOCKED_TAGS.has(parentTag)) return NodeFilter.FILTER_SKIP;
|
|
597
|
+
if (hasMixedContent(node2.parentElement)) return NodeFilter.FILTER_SKIP;
|
|
598
|
+
var ancestor = node2.parentElement;
|
|
599
|
+
while (ancestor) {
|
|
600
|
+
var aTag = (_c = ancestor.tagName) == null ? void 0 : _c.toUpperCase();
|
|
601
|
+
if (aTag === "VAR" || aTag === "TIME" || INLINE_TAGS.has(aTag)) {
|
|
602
|
+
if (ancestor.parentElement && hasMixedContent(ancestor.parentElement)) return NodeFilter.FILTER_SKIP;
|
|
603
|
+
}
|
|
604
|
+
ancestor = ancestor.parentElement;
|
|
605
|
+
}
|
|
606
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
607
|
+
}
|
|
608
|
+
if (node2.nodeType === Node.ELEMENT_NODE) {
|
|
609
|
+
const tag = node2.tagName.toUpperCase();
|
|
610
|
+
if (BLOCKED_TAGS.has(tag)) return NodeFilter.FILTER_REJECT;
|
|
611
|
+
if (shouldSkipElement(node2)) return NodeFilter.FILTER_REJECT;
|
|
612
|
+
if (tag === "INPUT") {
|
|
613
|
+
var itype2 = (node2.getAttribute("type") || "").toLowerCase();
|
|
614
|
+
if (itype2 === "button" || itype2 === "submit" || itype2 === "reset") {
|
|
615
|
+
var val2 = normalize(node2.value);
|
|
616
|
+
if (val2 && val2.length >= 2 && !isOnlySymbolsOrNumbers(val2)) return NodeFilter.FILTER_ACCEPT;
|
|
617
|
+
}
|
|
618
|
+
var hasTranslatableAttr = INPUT_TRANSLATABLE_ATTRS.some(function(a) {
|
|
619
|
+
var v = normalize(node2.getAttribute(a) || "");
|
|
620
|
+
return v && v.length >= 2 && !isOnlySymbolsOrNumbers(v);
|
|
621
|
+
});
|
|
622
|
+
if (hasTranslatableAttr) return NodeFilter.FILTER_ACCEPT;
|
|
623
|
+
return NodeFilter.FILTER_SKIP;
|
|
624
|
+
}
|
|
625
|
+
if (hasMixedContent(node2)) return NodeFilter.FILTER_ACCEPT;
|
|
626
|
+
return NodeFilter.FILTER_SKIP;
|
|
627
|
+
}
|
|
628
|
+
return NodeFilter.FILTER_SKIP;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
let node;
|
|
633
|
+
while (node = walker.nextNode()) {
|
|
634
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
635
|
+
const text = normalize(node.textContent);
|
|
636
|
+
if (!text) continue;
|
|
637
|
+
var ctx = buildContextTrace(node.parentElement);
|
|
638
|
+
var compositeKey = text + "\0" + ctx;
|
|
639
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
640
|
+
phrases.push({
|
|
641
|
+
type: "text",
|
|
642
|
+
key: text,
|
|
643
|
+
slots: [],
|
|
644
|
+
textNode: node,
|
|
645
|
+
element: node.parentElement,
|
|
646
|
+
context: ctx,
|
|
647
|
+
original: text
|
|
648
|
+
});
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toUpperCase() === "INPUT") {
|
|
652
|
+
var itype = (node.getAttribute("type") || "").toLowerCase();
|
|
653
|
+
var ctx = buildContextTrace(node);
|
|
654
|
+
if (itype === "button" || itype === "submit" || itype === "reset") {
|
|
655
|
+
var val = normalize(node.value);
|
|
656
|
+
if (val && val.length >= 2 && !isOnlySymbolsOrNumbers(val)) {
|
|
657
|
+
var compositeKey = val + "\0" + ctx;
|
|
658
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
659
|
+
phrases.push({
|
|
660
|
+
type: "input-value",
|
|
661
|
+
key: val,
|
|
662
|
+
slots: [],
|
|
663
|
+
element: node,
|
|
664
|
+
context: ctx,
|
|
665
|
+
original: val
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
INPUT_TRANSLATABLE_ATTRS.forEach(function(attr) {
|
|
670
|
+
var attrVal = normalize(node.getAttribute(attr) || "");
|
|
671
|
+
if (!attrVal || attrVal.length < 2 || isOnlySymbolsOrNumbers(attrVal)) return;
|
|
672
|
+
var attrKey = attrVal + "\0" + ctx + "\0" + attr;
|
|
673
|
+
if (seen.has(attrKey)) return;
|
|
674
|
+
seen.add(attrKey);
|
|
675
|
+
phrases.push({
|
|
676
|
+
type: "input-attr",
|
|
677
|
+
key: attrVal,
|
|
678
|
+
attr,
|
|
679
|
+
slots: [],
|
|
680
|
+
element: node,
|
|
681
|
+
context: ctx,
|
|
682
|
+
original: attrVal
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
688
|
+
const { key, slots } = extractMixedContent(node);
|
|
689
|
+
if (!key || key.length > MAX_TEXT_LENGTH) continue;
|
|
690
|
+
if (isMixedKeyOnlySeparators(key)) {
|
|
691
|
+
slots.forEach(function(slot) {
|
|
692
|
+
if (slot.type === "text" && slot.value && slot.value.length >= 2 && !isOnlySymbolsOrNumbers(slot.value)) {
|
|
693
|
+
var slotCtx = buildContextTrace(slot.node || node);
|
|
694
|
+
var sc = slot.value + "\0" + slotCtx;
|
|
695
|
+
if (!seen.has(sc)) {
|
|
696
|
+
seen.add(sc);
|
|
697
|
+
phrases.push({ type: "text", key: slot.value, slots: [], textNode: slot.node ? slot.node.firstChild : null, element: slot.node || node, context: slotCtx, original: slot.value });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
var ctx = buildContextTrace(node);
|
|
704
|
+
var compositeKey = key + "\0" + ctx;
|
|
705
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
706
|
+
const type = slots.some((s) => s.type === "date") ? "mixed-date" : slots.some((s) => s.type === "text") ? "mixed-text" : "mixed";
|
|
707
|
+
phrases.push({
|
|
708
|
+
type,
|
|
709
|
+
key,
|
|
710
|
+
slots,
|
|
711
|
+
element: node,
|
|
712
|
+
context: ctx,
|
|
713
|
+
original: key,
|
|
714
|
+
originalHTML: node.innerHTML
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return phrases;
|
|
719
|
+
}
|
|
720
|
+
async function collectPhrasesAsync(root, yieldEvery) {
|
|
721
|
+
if (!yieldEvery) yieldEvery = 100;
|
|
722
|
+
const phrases = [];
|
|
723
|
+
const seen = /* @__PURE__ */ new Set();
|
|
724
|
+
if (root.nodeType === Node.ELEMENT_NODE && !shouldSkipElement(root) && hasMixedContent(root)) {
|
|
725
|
+
const { key, slots } = extractMixedContent(root);
|
|
726
|
+
if (key && key.length <= MAX_TEXT_LENGTH) {
|
|
727
|
+
if (isMixedKeyOnlySeparators(key)) {
|
|
728
|
+
slots.forEach(function(slot) {
|
|
729
|
+
if (slot.type === "text" && slot.value && slot.value.length >= 2 && !isOnlySymbolsOrNumbers(slot.value)) {
|
|
730
|
+
var slotCtx = buildContextTrace(slot.node || root);
|
|
731
|
+
var sc = slot.value + "\0" + slotCtx;
|
|
732
|
+
if (!seen.has(sc)) {
|
|
733
|
+
seen.add(sc);
|
|
734
|
+
phrases.push({ type: "text", key: slot.value, slots: [], textNode: slot.node ? slot.node.firstChild : null, element: slot.node || root, context: slotCtx, original: slot.value });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
} else {
|
|
739
|
+
var ctx = buildContextTrace(root);
|
|
740
|
+
var compositeKey = key + "\0" + ctx;
|
|
741
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
742
|
+
const type = slots.some((s) => s.type === "date") ? "mixed-date" : slots.some((s) => s.type === "text") ? "mixed-text" : "mixed";
|
|
743
|
+
phrases.push({
|
|
744
|
+
type,
|
|
745
|
+
key,
|
|
746
|
+
slots,
|
|
747
|
+
element: root,
|
|
748
|
+
context: ctx,
|
|
749
|
+
original: key,
|
|
750
|
+
originalHTML: root.innerHTML
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const nodes = [];
|
|
756
|
+
const walker = document.createTreeWalker(
|
|
757
|
+
root,
|
|
758
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
|
759
|
+
{
|
|
760
|
+
acceptNode(node2) {
|
|
761
|
+
var _a, _b, _c;
|
|
762
|
+
if (node2.nodeType === Node.TEXT_NODE) {
|
|
763
|
+
const text = normalize(node2.textContent);
|
|
764
|
+
if (!text || text.length < 2) return NodeFilter.FILTER_SKIP;
|
|
765
|
+
if (text.length > MAX_TEXT_LENGTH) return NodeFilter.FILTER_SKIP;
|
|
766
|
+
if (isOnlySymbolsOrNumbers(text)) return NodeFilter.FILTER_SKIP;
|
|
767
|
+
if (/\{\{(?:text|number|decimal|date):\d+\}\}/.test(text)) return NodeFilter.FILTER_SKIP;
|
|
768
|
+
if (isAncestorBlocked(node2)) return NodeFilter.FILTER_SKIP;
|
|
769
|
+
const parentTag = (_b = (_a = node2.parentElement) == null ? void 0 : _a.tagName) == null ? void 0 : _b.toUpperCase();
|
|
770
|
+
if (BLOCKED_TAGS.has(parentTag)) return NodeFilter.FILTER_SKIP;
|
|
771
|
+
if (hasMixedContent(node2.parentElement)) return NodeFilter.FILTER_SKIP;
|
|
772
|
+
var ancestor = node2.parentElement;
|
|
773
|
+
while (ancestor) {
|
|
774
|
+
var aTag = (_c = ancestor.tagName) == null ? void 0 : _c.toUpperCase();
|
|
775
|
+
if (aTag === "VAR" || aTag === "TIME" || INLINE_TAGS.has(aTag)) {
|
|
776
|
+
if (ancestor.parentElement && hasMixedContent(ancestor.parentElement)) return NodeFilter.FILTER_SKIP;
|
|
777
|
+
}
|
|
778
|
+
ancestor = ancestor.parentElement;
|
|
779
|
+
}
|
|
780
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
781
|
+
}
|
|
782
|
+
if (node2.nodeType === Node.ELEMENT_NODE) {
|
|
783
|
+
const tag = node2.tagName.toUpperCase();
|
|
784
|
+
if (BLOCKED_TAGS.has(tag)) return NodeFilter.FILTER_REJECT;
|
|
785
|
+
if (shouldSkipElement(node2)) return NodeFilter.FILTER_REJECT;
|
|
786
|
+
if (tag === "INPUT") {
|
|
787
|
+
var itype2 = (node2.getAttribute("type") || "").toLowerCase();
|
|
788
|
+
if (itype2 === "button" || itype2 === "submit" || itype2 === "reset") {
|
|
789
|
+
var val2 = normalize(node2.value);
|
|
790
|
+
if (val2 && val2.length >= 2 && !isOnlySymbolsOrNumbers(val2)) return NodeFilter.FILTER_ACCEPT;
|
|
791
|
+
}
|
|
792
|
+
var hasTranslatableAttr = INPUT_TRANSLATABLE_ATTRS.some(function(a) {
|
|
793
|
+
var v = normalize(node2.getAttribute(a) || "");
|
|
794
|
+
return v && v.length >= 2 && !isOnlySymbolsOrNumbers(v);
|
|
795
|
+
});
|
|
796
|
+
if (hasTranslatableAttr) return NodeFilter.FILTER_ACCEPT;
|
|
797
|
+
return NodeFilter.FILTER_SKIP;
|
|
798
|
+
}
|
|
799
|
+
if (hasMixedContent(node2)) return NodeFilter.FILTER_ACCEPT;
|
|
800
|
+
return NodeFilter.FILTER_SKIP;
|
|
801
|
+
}
|
|
802
|
+
return NodeFilter.FILTER_SKIP;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
var n;
|
|
807
|
+
while (n = walker.nextNode()) nodes.push(n);
|
|
808
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
809
|
+
if (i > 0 && i % yieldEvery === 0) {
|
|
810
|
+
await new Promise(function(resolve) {
|
|
811
|
+
setTimeout(resolve, 0);
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
var node = nodes[i];
|
|
815
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
816
|
+
const text = normalize(node.textContent);
|
|
817
|
+
if (!text) continue;
|
|
818
|
+
var ctx = buildContextTrace(node.parentElement);
|
|
819
|
+
var compositeKey = text + "\0" + ctx;
|
|
820
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
821
|
+
phrases.push({
|
|
822
|
+
type: "text",
|
|
823
|
+
key: text,
|
|
824
|
+
slots: [],
|
|
825
|
+
textNode: node,
|
|
826
|
+
element: node.parentElement,
|
|
827
|
+
context: ctx,
|
|
828
|
+
original: text
|
|
829
|
+
});
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toUpperCase() === "INPUT") {
|
|
833
|
+
var itype = (node.getAttribute("type") || "").toLowerCase();
|
|
834
|
+
var ctx = buildContextTrace(node);
|
|
835
|
+
if (itype === "button" || itype === "submit" || itype === "reset") {
|
|
836
|
+
var val = normalize(node.value);
|
|
837
|
+
if (val && val.length >= 2 && !isOnlySymbolsOrNumbers(val)) {
|
|
838
|
+
var compositeKey = val + "\0" + ctx;
|
|
839
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
840
|
+
phrases.push({
|
|
841
|
+
type: "input-value",
|
|
842
|
+
key: val,
|
|
843
|
+
slots: [],
|
|
844
|
+
element: node,
|
|
845
|
+
context: ctx,
|
|
846
|
+
original: val
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
INPUT_TRANSLATABLE_ATTRS.forEach(function(attr) {
|
|
851
|
+
var attrVal = normalize(node.getAttribute(attr) || "");
|
|
852
|
+
if (!attrVal || attrVal.length < 2 || isOnlySymbolsOrNumbers(attrVal)) return;
|
|
853
|
+
var attrKey = attrVal + "\0" + ctx + "\0" + attr;
|
|
854
|
+
if (seen.has(attrKey)) return;
|
|
855
|
+
seen.add(attrKey);
|
|
856
|
+
phrases.push({
|
|
857
|
+
type: "input-attr",
|
|
858
|
+
key: attrVal,
|
|
859
|
+
attr,
|
|
860
|
+
slots: [],
|
|
861
|
+
element: node,
|
|
862
|
+
context: ctx,
|
|
863
|
+
original: attrVal
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
869
|
+
const { key, slots } = extractMixedContent(node);
|
|
870
|
+
if (!key || key.length > MAX_TEXT_LENGTH) continue;
|
|
871
|
+
if (isMixedKeyOnlySeparators(key)) {
|
|
872
|
+
slots.forEach(function(slot) {
|
|
873
|
+
if (slot.type === "text" && slot.value && slot.value.length >= 2 && !isOnlySymbolsOrNumbers(slot.value)) {
|
|
874
|
+
var slotCtx = buildContextTrace(slot.node || node);
|
|
875
|
+
var sc = slot.value + "\0" + slotCtx;
|
|
876
|
+
if (!seen.has(sc)) {
|
|
877
|
+
seen.add(sc);
|
|
878
|
+
phrases.push({ type: "text", key: slot.value, slots: [], textNode: slot.node ? slot.node.firstChild : null, element: slot.node || node, context: slotCtx, original: slot.value });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
var ctx = buildContextTrace(node);
|
|
885
|
+
var compositeKey = key + "\0" + ctx;
|
|
886
|
+
if (!seen.has(compositeKey)) seen.add(compositeKey);
|
|
887
|
+
const type = slots.some((s) => s.type === "date") ? "mixed-date" : slots.some((s) => s.type === "text") ? "mixed-text" : "mixed";
|
|
888
|
+
phrases.push({
|
|
889
|
+
type,
|
|
890
|
+
key,
|
|
891
|
+
slots,
|
|
892
|
+
element: node,
|
|
893
|
+
context: ctx,
|
|
894
|
+
original: key,
|
|
895
|
+
originalHTML: node.innerHTML
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return phrases;
|
|
900
|
+
}
|
|
901
|
+
function postKeys(phrases) {
|
|
902
|
+
if (state.scanStopped) return Promise.resolve({ ok: true, registered: 0, keyMap: {} });
|
|
903
|
+
var sentKeys = /* @__PURE__ */ new Set();
|
|
904
|
+
var keys = [];
|
|
905
|
+
phrases.forEach(function(p) {
|
|
906
|
+
var composite = p.key + "\0" + (p.context || "");
|
|
907
|
+
if (sentKeys.has(composite)) return;
|
|
908
|
+
if (p.varSlots && p.varSlots.length > 0 && /\{\{(?:text|number|decimal|date):\d+\}\}/.test(p.key)) return;
|
|
909
|
+
sentKeys.add(composite);
|
|
910
|
+
keys.push({ key: p.key, context: p.context || "" });
|
|
911
|
+
if (p.slots && p.slots.length > 0) {
|
|
912
|
+
p.slots.forEach(function(slot) {
|
|
913
|
+
if (slot.type === "text" && slot.value && !isOnlySymbolsOrNumbers(slot.value)) {
|
|
914
|
+
var slotComposite = slot.value + "\0";
|
|
915
|
+
if (!sentKeys.has(slotComposite)) {
|
|
916
|
+
sentKeys.add(slotComposite);
|
|
917
|
+
keys.push({ key: slot.value, context: "" });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
return fetch(state.apiBase + "/api/textnodes", {
|
|
924
|
+
method: "POST",
|
|
925
|
+
headers: { "Content-Type": "application/json", "X-API-Key": state.apiKey },
|
|
926
|
+
body: JSON.stringify({ keys, url: window.location.href })
|
|
927
|
+
}).then(function(r) {
|
|
928
|
+
return r.json();
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
function fetchTranslations(langCode) {
|
|
932
|
+
var url = state.apiBase + "/api/translations?lang=" + encodeURIComponent(langCode);
|
|
933
|
+
return fetch(url, {
|
|
934
|
+
headers: { "X-API-Key": state.apiKey }
|
|
935
|
+
}).then(function(r) {
|
|
936
|
+
return r.json();
|
|
937
|
+
}).then(function(data) {
|
|
938
|
+
var map = {};
|
|
939
|
+
if (Array.isArray(data)) {
|
|
940
|
+
data.forEach(function(r) {
|
|
941
|
+
map[r.key + "\0" + (r.context || "")] = r.value;
|
|
942
|
+
if (!map.hasOwnProperty(r.key)) map[r.key] = r.value;
|
|
943
|
+
});
|
|
944
|
+
} else {
|
|
945
|
+
map = data;
|
|
946
|
+
}
|
|
947
|
+
return map;
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
function captureScreenshot() {
|
|
951
|
+
if (state.fileMode || !state.screenshotsEnabled) return;
|
|
952
|
+
var script = document.createElement("script");
|
|
953
|
+
script.src = state.apiBase + "/cdn/html2canvas.min.js";
|
|
954
|
+
script.onload = function() {
|
|
955
|
+
if (typeof html2canvas !== "function") return;
|
|
956
|
+
html2canvas(document.body, {
|
|
957
|
+
scale: 0.35,
|
|
958
|
+
logging: false,
|
|
959
|
+
useCORS: true,
|
|
960
|
+
allowTaint: true,
|
|
961
|
+
width: window.innerWidth,
|
|
962
|
+
height: window.innerHeight,
|
|
963
|
+
windowWidth: window.innerWidth,
|
|
964
|
+
windowHeight: window.innerHeight
|
|
965
|
+
}).then(function(canvas) {
|
|
966
|
+
var data = canvas.toDataURL("image/jpeg", 0.5);
|
|
967
|
+
var base64 = data.split(",")[1];
|
|
968
|
+
if (!base64 || base64.length > 5e5) return;
|
|
969
|
+
fetch(state.apiBase + "/api/screenshots", {
|
|
970
|
+
method: "POST",
|
|
971
|
+
headers: { "Content-Type": "application/json", "X-API-Key": state.apiKey },
|
|
972
|
+
body: JSON.stringify({ url: window.location.href, screenshot: base64 })
|
|
973
|
+
}).catch(function() {
|
|
974
|
+
});
|
|
975
|
+
}).catch(function() {
|
|
976
|
+
});
|
|
977
|
+
};
|
|
978
|
+
script.onerror = function() {
|
|
979
|
+
};
|
|
980
|
+
document.head.appendChild(script);
|
|
981
|
+
}
|
|
982
|
+
function rehydrate(translation, slots) {
|
|
983
|
+
return translation.replace(
|
|
984
|
+
/\{\{(text|number|decimal|date):(\d+)\}\}/g,
|
|
985
|
+
(match, type, i) => {
|
|
986
|
+
const slot = slots[parseInt(i)];
|
|
987
|
+
if (!slot) return match;
|
|
988
|
+
if (type === "date") return slot.display;
|
|
989
|
+
return slot.value;
|
|
990
|
+
}
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
function normalizeTranslationMap(raw) {
|
|
994
|
+
if (!Array.isArray(raw)) return raw;
|
|
995
|
+
var map = {};
|
|
996
|
+
raw.forEach(function(r) {
|
|
997
|
+
map[r.key + "\0" + (r.context || "")] = r.value;
|
|
998
|
+
if (!map.hasOwnProperty(r.key)) map[r.key] = r.value;
|
|
999
|
+
});
|
|
1000
|
+
return map;
|
|
1001
|
+
}
|
|
1002
|
+
function applyTranslations(phrases, translations) {
|
|
1003
|
+
let applied = 0;
|
|
1004
|
+
let skipped = 0;
|
|
1005
|
+
phrases.forEach((p) => {
|
|
1006
|
+
const ctxKey = p.key + "\0" + (p.context || "");
|
|
1007
|
+
var translation = translations[ctxKey] || translations[p.key];
|
|
1008
|
+
if (!translation && p.slots && p.slots.length > 0) {
|
|
1009
|
+
var hasSlotTranslation = p.slots.some(function(slot) {
|
|
1010
|
+
if (slot.type !== "text" || !slot.value) return false;
|
|
1011
|
+
var slotCtxKey = slot.value + "\0" + (p.context || "");
|
|
1012
|
+
var slotEmptyCtx = slot.value + "\0";
|
|
1013
|
+
return translations.hasOwnProperty(slotCtxKey) || translations.hasOwnProperty(slotEmptyCtx) || translations.hasOwnProperty(slot.value);
|
|
1014
|
+
});
|
|
1015
|
+
if (hasSlotTranslation) translation = p.key;
|
|
1016
|
+
}
|
|
1017
|
+
if (!translation) {
|
|
1018
|
+
skipped++;
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (p.type === "input-value" && p.element) {
|
|
1022
|
+
try {
|
|
1023
|
+
p.element.value = translation;
|
|
1024
|
+
applied++;
|
|
1025
|
+
return;
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
console.warn("[translate] Could not write to input value", e);
|
|
1028
|
+
skipped++;
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (p.type === "input-attr" && p.element && p.attr) {
|
|
1033
|
+
try {
|
|
1034
|
+
p.element.setAttribute(p.attr, translation);
|
|
1035
|
+
applied++;
|
|
1036
|
+
return;
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
console.warn("[translate] Could not write to input attr", e);
|
|
1039
|
+
skipped++;
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (p.type === "text" && p.textNode) {
|
|
1044
|
+
try {
|
|
1045
|
+
var translated = translation;
|
|
1046
|
+
if (p.varSlots && p.varSlots.length > 0) {
|
|
1047
|
+
translated = translated.replace(/\{\{(text|number|decimal|date):(\d+)\}\}/g, function(match, type, i) {
|
|
1048
|
+
var slot = p.varSlots.find(function(s) {
|
|
1049
|
+
return (s.type || "text") === type && s.index === +i;
|
|
1050
|
+
});
|
|
1051
|
+
if (!slot) {
|
|
1052
|
+
slot = p.varSlots[+i];
|
|
1053
|
+
}
|
|
1054
|
+
return slot ? slot.originalText : match;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
translated = translated.replace(/\{\{(?:text|number|decimal|date):\d+\}\}/g, "");
|
|
1058
|
+
p.textNode.nodeValue = translated;
|
|
1059
|
+
applied++;
|
|
1060
|
+
return;
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
console.warn("[translate] Could not write to text node", e);
|
|
1063
|
+
skipped++;
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (p.element) {
|
|
1068
|
+
try {
|
|
1069
|
+
const rehydrated = rehydrate(translation, p.slots);
|
|
1070
|
+
let html = translation;
|
|
1071
|
+
var slotReplacements = {};
|
|
1072
|
+
p.slots.forEach((slot) => {
|
|
1073
|
+
const placeholder = `{{${slot.type}:${slot.index}}}`;
|
|
1074
|
+
if (slot.type === "date" && slot.node) {
|
|
1075
|
+
slot.node.textContent = slot.display;
|
|
1076
|
+
slotReplacements[placeholder] = slot.node.outerHTML;
|
|
1077
|
+
} else if (slot.node) {
|
|
1078
|
+
if (slot.type === "text" && slot.value) {
|
|
1079
|
+
var slotCtxKey = slot.value + "\0" + (p.context || "");
|
|
1080
|
+
var slotEmptyCtx = slot.value + "\0";
|
|
1081
|
+
var slotTranslation = translations[slotCtxKey] || translations[slotEmptyCtx] || translations[slot.value];
|
|
1082
|
+
if (slotTranslation) {
|
|
1083
|
+
slot.node.textContent = slotTranslation;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
slotReplacements[placeholder] = slot.node.outerHTML;
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
if (p.varSlots && p.varSlots.length > 0) {
|
|
1090
|
+
p.varSlots.forEach(function(vs) {
|
|
1091
|
+
var ph = "{{" + vs.type + ":" + vs.index + "}}";
|
|
1092
|
+
if (!slotReplacements.hasOwnProperty(ph)) {
|
|
1093
|
+
slotReplacements[ph] = escapeHTML(vs.originalText);
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
var parts = html.split(/(\{\{(?:text|number|decimal|date):\d+\}\})/g);
|
|
1098
|
+
html = parts.map(function(part) {
|
|
1099
|
+
if (slotReplacements.hasOwnProperty(part)) return slotReplacements[part];
|
|
1100
|
+
if (/^\{\{(?:text|number|decimal|date):\d+\}\}$/.test(part)) return "";
|
|
1101
|
+
return escapeHTML(part);
|
|
1102
|
+
}).join("");
|
|
1103
|
+
p.element.innerHTML = html;
|
|
1104
|
+
applied++;
|
|
1105
|
+
} catch (e) {
|
|
1106
|
+
console.warn("[translate] Could not apply mixed translation", e);
|
|
1107
|
+
skipped++;
|
|
1108
|
+
}
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
skipped++;
|
|
1112
|
+
});
|
|
1113
|
+
return { applied, skipped };
|
|
1114
|
+
}
|
|
1115
|
+
async function applyTranslationsAsync(phrases, translations, yieldEvery) {
|
|
1116
|
+
if (!yieldEvery) yieldEvery = 100;
|
|
1117
|
+
var applied = 0;
|
|
1118
|
+
var skipped = 0;
|
|
1119
|
+
for (var i = 0; i < phrases.length; i += yieldEvery) {
|
|
1120
|
+
if (i > 0) {
|
|
1121
|
+
await new Promise(function(resolve) {
|
|
1122
|
+
setTimeout(resolve, 0);
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
var batch = phrases.slice(i, i + yieldEvery);
|
|
1126
|
+
var result = applyTranslations(batch, translations);
|
|
1127
|
+
applied += result.applied;
|
|
1128
|
+
skipped += result.skipped;
|
|
1129
|
+
}
|
|
1130
|
+
return { applied, skipped };
|
|
1131
|
+
}
|
|
1132
|
+
function untranslate(phrases) {
|
|
1133
|
+
let restored = 0;
|
|
1134
|
+
phrases.forEach((p) => {
|
|
1135
|
+
try {
|
|
1136
|
+
if (p.type === "input-value" && p.element) {
|
|
1137
|
+
p.element.value = p.original;
|
|
1138
|
+
restored++;
|
|
1139
|
+
} else if (p.type === "input-attr" && p.element && p.attr) {
|
|
1140
|
+
p.element.setAttribute(p.attr, p.original);
|
|
1141
|
+
restored++;
|
|
1142
|
+
} else if (p.type === "text" && p.textNode) {
|
|
1143
|
+
p.textNode.nodeValue = p.original;
|
|
1144
|
+
restored++;
|
|
1145
|
+
}
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
console.warn("[translate] Could not restore text node", e);
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
phrases.forEach((p) => {
|
|
1151
|
+
if (p.type === "text") return;
|
|
1152
|
+
try {
|
|
1153
|
+
if (p.element && p.originalHTML !== void 0) {
|
|
1154
|
+
p.element.innerHTML = p.originalHTML;
|
|
1155
|
+
restored++;
|
|
1156
|
+
}
|
|
1157
|
+
} catch (e) {
|
|
1158
|
+
console.warn("[translate] Could not restore node", e);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
console.log("[loco] untranslated " + restored + " phrase(s)");
|
|
1162
|
+
}
|
|
1163
|
+
function watchAndTranslate(existingKeys, translations) {
|
|
1164
|
+
const seenKeys = new Set(existingKeys);
|
|
1165
|
+
const translatedPatterns = [];
|
|
1166
|
+
Object.values(translations).forEach(function(v) {
|
|
1167
|
+
if (!v) return;
|
|
1168
|
+
seenKeys.add(v);
|
|
1169
|
+
if (/\{\{(text|number|decimal|date):\d+\}\}/.test(v)) {
|
|
1170
|
+
var parts = v.split(/\{\{(?:text|number|decimal|date):\d+\}\}/g);
|
|
1171
|
+
var escaped = parts.map(function(p) {
|
|
1172
|
+
return p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1173
|
+
});
|
|
1174
|
+
translatedPatterns.push(new RegExp("^" + escaped.join(".+") + "$"));
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
function isTranslatedPhrase(key) {
|
|
1178
|
+
if (seenKeys.has(key)) return true;
|
|
1179
|
+
for (var i = 0; i < translatedPatterns.length; i++) {
|
|
1180
|
+
if (translatedPatterns[i].test(key)) return true;
|
|
1181
|
+
}
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
let pending = [];
|
|
1185
|
+
let flushTimer = null;
|
|
1186
|
+
let rafId = null;
|
|
1187
|
+
let rescanTargets = /* @__PURE__ */ new Set();
|
|
1188
|
+
let observer = null;
|
|
1189
|
+
let applying = false;
|
|
1190
|
+
function flushPending() {
|
|
1191
|
+
if (pending.length === 0) return;
|
|
1192
|
+
var batch = pending.splice(0);
|
|
1193
|
+
if (!state.fileMode) {
|
|
1194
|
+
postKeys(batch).then(function(result) {
|
|
1195
|
+
if (result && result.keyMap) {
|
|
1196
|
+
applyKeyMap(batch, result.keyMap);
|
|
1197
|
+
applying = true;
|
|
1198
|
+
applyTranslations(batch, translations);
|
|
1199
|
+
if (observer) observer.takeRecords();
|
|
1200
|
+
applying = false;
|
|
1201
|
+
}
|
|
1202
|
+
}).catch(function() {
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function processNode(node) {
|
|
1207
|
+
const newPhrases = collectPhrases(node);
|
|
1208
|
+
newPhrases.forEach((p) => {
|
|
1209
|
+
var compositeKey = p.key + "\0" + (p.context || "");
|
|
1210
|
+
if (isTranslatedPhrase(p.key) || seenKeys.has(compositeKey)) return;
|
|
1211
|
+
seenKeys.add(compositeKey);
|
|
1212
|
+
seenKeys.add(p.key);
|
|
1213
|
+
pending.push(p);
|
|
1214
|
+
state.phrases.push(p);
|
|
1215
|
+
});
|
|
1216
|
+
buildFileVarKeyMap(newPhrases, Object.keys(translations));
|
|
1217
|
+
applying = true;
|
|
1218
|
+
applyTranslations(newPhrases, translations);
|
|
1219
|
+
if (observer) observer.takeRecords();
|
|
1220
|
+
applying = false;
|
|
1221
|
+
if (!state.fileMode) {
|
|
1222
|
+
clearTimeout(flushTimer);
|
|
1223
|
+
flushTimer = setTimeout(flushPending, 500);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
function scheduleRescan(node) {
|
|
1227
|
+
rescanTargets.add(node);
|
|
1228
|
+
if (!rafId) {
|
|
1229
|
+
rafId = requestAnimationFrame(function() {
|
|
1230
|
+
rafId = null;
|
|
1231
|
+
var targets = Array.from(rescanTargets);
|
|
1232
|
+
rescanTargets.clear();
|
|
1233
|
+
targets.forEach(function(t) {
|
|
1234
|
+
if (t.isConnected) processNode(t);
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
observer = new MutationObserver((mutations) => {
|
|
1240
|
+
if (applying) return;
|
|
1241
|
+
var elementNodes = [];
|
|
1242
|
+
var textParents = /* @__PURE__ */ new Set();
|
|
1243
|
+
for (const mutation of mutations) {
|
|
1244
|
+
for (const node of mutation.addedNodes) {
|
|
1245
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1246
|
+
elementNodes.push(node);
|
|
1247
|
+
} else if (node.nodeType === Node.TEXT_NODE) {
|
|
1248
|
+
var parent = node.parentElement;
|
|
1249
|
+
if (parent && parent.isConnected) textParents.add(parent);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
elementNodes.forEach(function(n) {
|
|
1254
|
+
processNode(n);
|
|
1255
|
+
});
|
|
1256
|
+
textParents.forEach(function(p) {
|
|
1257
|
+
scheduleRescan(p);
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
observer.observe(document.body, {
|
|
1261
|
+
childList: true,
|
|
1262
|
+
subtree: true
|
|
1263
|
+
});
|
|
1264
|
+
return observer;
|
|
1265
|
+
}
|
|
1266
|
+
var LOCO_DB_NAME = "loco";
|
|
1267
|
+
var LOCO_DB_VERSION = 2;
|
|
1268
|
+
function openLocoDB() {
|
|
1269
|
+
return new Promise(function(resolve, reject) {
|
|
1270
|
+
try {
|
|
1271
|
+
var req = indexedDB.open(LOCO_DB_NAME, LOCO_DB_VERSION);
|
|
1272
|
+
req.onupgradeneeded = function(e) {
|
|
1273
|
+
var db = e.target.result;
|
|
1274
|
+
if (db.objectStoreNames.contains("meta") && e.oldVersion < 2) {
|
|
1275
|
+
db.deleteObjectStore("meta");
|
|
1276
|
+
}
|
|
1277
|
+
if (!db.objectStoreNames.contains("meta")) {
|
|
1278
|
+
db.createObjectStore("meta", { keyPath: "key" });
|
|
1279
|
+
}
|
|
1280
|
+
if (!db.objectStoreNames.contains("translations")) {
|
|
1281
|
+
db.createObjectStore("translations", { keyPath: "lang" });
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
req.onsuccess = function(e) {
|
|
1285
|
+
resolve(e.target.result);
|
|
1286
|
+
};
|
|
1287
|
+
req.onerror = function() {
|
|
1288
|
+
reject(req.error);
|
|
1289
|
+
};
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
reject(e);
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
function getLanguageRegistry() {
|
|
1296
|
+
return openLocoDB().then(function(db) {
|
|
1297
|
+
return new Promise(function(resolve, reject) {
|
|
1298
|
+
var tx = db.transaction("meta", "readonly");
|
|
1299
|
+
var req = tx.objectStore("meta").get("registry");
|
|
1300
|
+
req.onsuccess = function() {
|
|
1301
|
+
db.close();
|
|
1302
|
+
var row = req.result;
|
|
1303
|
+
resolve(row ? { languages: row.languages || [], languageNames: row.languageNames || {} } : null);
|
|
1304
|
+
};
|
|
1305
|
+
req.onerror = function() {
|
|
1306
|
+
db.close();
|
|
1307
|
+
reject(req.error);
|
|
1308
|
+
};
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
function getSourceMeta(url) {
|
|
1313
|
+
var metaKey = "source:" + url;
|
|
1314
|
+
return openLocoDB().then(function(db) {
|
|
1315
|
+
return new Promise(function(resolve, reject) {
|
|
1316
|
+
var tx = db.transaction("meta", "readonly");
|
|
1317
|
+
var req = tx.objectStore("meta").get(metaKey);
|
|
1318
|
+
req.onsuccess = function() {
|
|
1319
|
+
db.close();
|
|
1320
|
+
var row = req.result;
|
|
1321
|
+
resolve(row ? { url: row.url, timestamp: row.timestamp, storedAt: row.storedAt } : null);
|
|
1322
|
+
};
|
|
1323
|
+
req.onerror = function() {
|
|
1324
|
+
db.close();
|
|
1325
|
+
reject(req.error);
|
|
1326
|
+
};
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
function loadLangFromDb(lang) {
|
|
1331
|
+
return openLocoDB().then(function(db) {
|
|
1332
|
+
return new Promise(function(resolve, reject) {
|
|
1333
|
+
var tx = db.transaction("translations", "readonly");
|
|
1334
|
+
var req = tx.objectStore("translations").get(lang);
|
|
1335
|
+
req.onsuccess = function() {
|
|
1336
|
+
db.close();
|
|
1337
|
+
var row = req.result;
|
|
1338
|
+
resolve(row ? row.data : null);
|
|
1339
|
+
};
|
|
1340
|
+
req.onerror = function() {
|
|
1341
|
+
db.close();
|
|
1342
|
+
reject(req.error);
|
|
1343
|
+
};
|
|
1344
|
+
});
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
function getCachedData() {
|
|
1348
|
+
return openLocoDB().then(function(db) {
|
|
1349
|
+
return new Promise(function(resolve, reject) {
|
|
1350
|
+
var tx = db.transaction(["meta", "translations"], "readonly");
|
|
1351
|
+
var metaStore = tx.objectStore("meta");
|
|
1352
|
+
var regReq = metaStore.get("registry");
|
|
1353
|
+
regReq.onsuccess = function() {
|
|
1354
|
+
var registry = regReq.result;
|
|
1355
|
+
if (!registry || !registry.languages || registry.languages.length === 0) {
|
|
1356
|
+
db.close();
|
|
1357
|
+
resolve(null);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
var activeLang = getSavedLang();
|
|
1361
|
+
var lang = activeLang || registry.languages[0];
|
|
1362
|
+
var langReq = tx.objectStore("translations").get(lang);
|
|
1363
|
+
langReq.onsuccess = function() {
|
|
1364
|
+
db.close();
|
|
1365
|
+
var row = langReq.result;
|
|
1366
|
+
if (!row || !row.data) {
|
|
1367
|
+
resolve(null);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
var translations = {};
|
|
1371
|
+
translations[lang] = row.data;
|
|
1372
|
+
resolve({
|
|
1373
|
+
languages: registry.languages,
|
|
1374
|
+
languageNames: registry.languageNames || {},
|
|
1375
|
+
translations
|
|
1376
|
+
});
|
|
1377
|
+
};
|
|
1378
|
+
langReq.onerror = function() {
|
|
1379
|
+
db.close();
|
|
1380
|
+
reject(langReq.error);
|
|
1381
|
+
};
|
|
1382
|
+
};
|
|
1383
|
+
regReq.onerror = function() {
|
|
1384
|
+
db.close();
|
|
1385
|
+
reject(regReq.error);
|
|
1386
|
+
};
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
function setCachedData(url, data, merge) {
|
|
1391
|
+
var languages = data.languages || [];
|
|
1392
|
+
var languageNames = data.languageNames || {};
|
|
1393
|
+
var tMap = data.translations || {};
|
|
1394
|
+
var timestamp = data.timestamp || 0;
|
|
1395
|
+
return openLocoDB().then(function(db) {
|
|
1396
|
+
return new Promise(function(resolve, reject) {
|
|
1397
|
+
var tx = db.transaction(["meta", "translations"], "readwrite");
|
|
1398
|
+
var metaStore = tx.objectStore("meta");
|
|
1399
|
+
var transStore = tx.objectStore("translations");
|
|
1400
|
+
var regReq = metaStore.get("registry");
|
|
1401
|
+
regReq.onsuccess = function() {
|
|
1402
|
+
var existing = regReq.result;
|
|
1403
|
+
var newLangs, newNames;
|
|
1404
|
+
if (merge && existing) {
|
|
1405
|
+
var existingLangs = existing.languages || [];
|
|
1406
|
+
newLangs = existingLangs.slice();
|
|
1407
|
+
for (var i = 0; i < languages.length; i++) {
|
|
1408
|
+
if (newLangs.indexOf(languages[i]) === -1) {
|
|
1409
|
+
newLangs.push(languages[i]);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
newNames = {};
|
|
1413
|
+
var oldNames = existing.languageNames || {};
|
|
1414
|
+
var k;
|
|
1415
|
+
for (k in oldNames) {
|
|
1416
|
+
newNames[k] = oldNames[k];
|
|
1417
|
+
}
|
|
1418
|
+
for (k in languageNames) {
|
|
1419
|
+
newNames[k] = languageNames[k];
|
|
1420
|
+
}
|
|
1421
|
+
} else {
|
|
1422
|
+
newLangs = languages;
|
|
1423
|
+
newNames = languageNames;
|
|
1424
|
+
}
|
|
1425
|
+
metaStore.put({ key: "registry", languages: newLangs, languageNames: newNames });
|
|
1426
|
+
metaStore.put({ key: "source:" + url, url, timestamp, storedAt: Date.now() });
|
|
1427
|
+
for (var j = 0; j < languages.length; j++) {
|
|
1428
|
+
if (!tMap[languages[j]]) continue;
|
|
1429
|
+
if (merge && Array.isArray(tMap[languages[j]])) {
|
|
1430
|
+
(function(lang) {
|
|
1431
|
+
var getReq = transStore.get(lang);
|
|
1432
|
+
getReq.onsuccess = function() {
|
|
1433
|
+
var row = getReq.result;
|
|
1434
|
+
var existingArr = row && Array.isArray(row.data) ? row.data : null;
|
|
1435
|
+
var incomingArr = tMap[lang];
|
|
1436
|
+
if (existingArr) {
|
|
1437
|
+
var SEP = "\0";
|
|
1438
|
+
var map = {};
|
|
1439
|
+
var order = [];
|
|
1440
|
+
for (var a = 0; a < existingArr.length; a++) {
|
|
1441
|
+
var ek = existingArr[a].key + SEP + (existingArr[a].context || "");
|
|
1442
|
+
if (!map.hasOwnProperty(ek)) order.push(ek);
|
|
1443
|
+
map[ek] = existingArr[a];
|
|
1444
|
+
}
|
|
1445
|
+
for (var b = 0; b < incomingArr.length; b++) {
|
|
1446
|
+
var nk = incomingArr[b].key + SEP + (incomingArr[b].context || "");
|
|
1447
|
+
if (!map.hasOwnProperty(nk)) order.push(nk);
|
|
1448
|
+
map[nk] = incomingArr[b];
|
|
1449
|
+
}
|
|
1450
|
+
var merged = [];
|
|
1451
|
+
for (var c = 0; c < order.length; c++) {
|
|
1452
|
+
merged.push(map[order[c]]);
|
|
1453
|
+
}
|
|
1454
|
+
transStore.put({ lang, data: merged });
|
|
1455
|
+
} else {
|
|
1456
|
+
transStore.put({ lang, data: incomingArr });
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
})(languages[j]);
|
|
1460
|
+
} else {
|
|
1461
|
+
transStore.put({ lang: languages[j], data: tMap[languages[j]] });
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
tx.oncomplete = function() {
|
|
1466
|
+
db.close();
|
|
1467
|
+
resolve();
|
|
1468
|
+
};
|
|
1469
|
+
tx.onerror = function() {
|
|
1470
|
+
db.close();
|
|
1471
|
+
reject(tx.error);
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
function clearLocoCache() {
|
|
1477
|
+
return new Promise(function(resolve) {
|
|
1478
|
+
var req = indexedDB.deleteDatabase(LOCO_DB_NAME);
|
|
1479
|
+
req.onsuccess = function() {
|
|
1480
|
+
resolve();
|
|
1481
|
+
};
|
|
1482
|
+
req.onerror = function() {
|
|
1483
|
+
resolve();
|
|
1484
|
+
};
|
|
1485
|
+
req.onblocked = function() {
|
|
1486
|
+
resolve();
|
|
1487
|
+
};
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
var PARROT_ICON = '<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" width="32" height="32"><defs><clipPath id="loco-wc"><circle cx="100" cy="100" r="90"/></clipPath></defs><circle cx="100" cy="100" r="90" fill="#1a1f2e"/><g clip-path="url(#loco-wc)"><g transform="translate(100,108) scale(0.72) translate(-334,-198)"><ellipse cx="310" cy="318" rx="10" ry="38" fill="#2ECC88" transform="rotate(-18 310 318)"/><ellipse cx="326" cy="326" rx="9" ry="42" fill="#34D99A" transform="rotate(-6 326 326)"/><ellipse cx="342" cy="328" rx="9" ry="42" fill="#7BC74A" transform="rotate(6 342 328)"/><ellipse cx="357" cy="320" rx="9" ry="36" fill="#F07040" transform="rotate(18 357 320)"/><ellipse cx="334" cy="240" rx="62" ry="80" fill="#2ECC88"/><ellipse cx="334" cy="252" rx="36" ry="52" fill="#D4F4B0"/><path d="M275 220 Q228 190 232 265 Q248 295 280 285 Q268 255 275 220Z" fill="#3A8FE0"/><path d="M277 225 Q238 208 240 262 Q252 285 278 277 Q268 252 277 225Z" fill="#6BB3FF" opacity="0.7"/><path d="M393 220 Q440 190 436 265 Q420 295 388 285 Q400 255 393 220Z" fill="#3A8FE0"/><path d="M391 225 Q430 208 428 262 Q416 285 390 277 Q400 252 391 225Z" fill="#6BB3FF" opacity="0.7"/><ellipse cx="334" cy="172" rx="38" ry="30" fill="#2ECC88"/><circle cx="334" cy="148" r="52" fill="#2ECC88"/><ellipse cx="334" cy="118" rx="30" ry="18" fill="#FFB833"/><circle cx="312" cy="138" r="14" fill="white"/><circle cx="315" cy="140" r="9" fill="#2C2C2A"/><circle cx="315" cy="140" r="4" fill="#04342C"/><circle cx="319" cy="136" r="3.5" fill="white"/><circle cx="356" cy="138" r="14" fill="white"/><circle cx="353" cy="140" r="9" fill="#2C2C2A"/><circle cx="353" cy="140" r="4" fill="#04342C"/><circle cx="357" cy="136" r="3.5" fill="white"/><path d="M326 155 Q334 144 342 155 Q342 170 334 173 Q326 170 326 155Z" fill="#E8A020"/><path d="M328 165 Q334 158 340 165 Q340 174 334 176 Q328 174 328 165Z" fill="#A06010"/><ellipse cx="302" cy="152" rx="12" ry="8" fill="#F07040" opacity="0.8"/><ellipse cx="366" cy="152" rx="12" ry="8" fill="#F07040" opacity="0.8"/><path d="M320 100 Q316 68 308 52" stroke="#FFB833" stroke-width="6" fill="none" stroke-linecap="round"/><path d="M334 97 Q334 64 334 46" stroke="#7BC74A" stroke-width="6" fill="none" stroke-linecap="round"/><path d="M348 100 Q352 68 360 52" stroke="#F07040" stroke-width="6" fill="none" stroke-linecap="round"/><circle cx="308" cy="50" r="8" fill="#FFB833"/><circle cx="334" cy="44" r="8" fill="#7BC74A"/><circle cx="360" cy="50" r="8" fill="#F07040"/><rect x="260" y="330" width="168" height="10" rx="5" fill="#8B5E20"/><path d="M310 330 L300 350 M310 330 L315 352 M310 330 L325 348" stroke="#A07030" stroke-width="4" fill="none" stroke-linecap="round"/><path d="M360 330 L350 350 M360 330 L365 352 M360 330 L375 348" stroke="#A07030" stroke-width="4" fill="none" stroke-linecap="round"/></g></g></svg>';
|
|
1491
|
+
var _widgetPosition = null;
|
|
1492
|
+
function renderWidget(langs, position) {
|
|
1493
|
+
_widgetPosition = position;
|
|
1494
|
+
var existing = document.getElementById("loco-lang-widget");
|
|
1495
|
+
if (existing) existing.remove();
|
|
1496
|
+
var customLabels = {};
|
|
1497
|
+
var langCodes = langs.map(function(l) {
|
|
1498
|
+
if (typeof l === "object" && l.code) {
|
|
1499
|
+
if (l.name) customLabels[l.code] = l.name;
|
|
1500
|
+
return l.code;
|
|
1501
|
+
}
|
|
1502
|
+
return l;
|
|
1503
|
+
});
|
|
1504
|
+
function getLabel2(code) {
|
|
1505
|
+
return customLabels[code] || code;
|
|
1506
|
+
}
|
|
1507
|
+
var currentLang = getSavedLang() || null;
|
|
1508
|
+
var container = document.createElement("div");
|
|
1509
|
+
container.id = "loco-lang-widget";
|
|
1510
|
+
container.setAttribute("data-notranslate", "");
|
|
1511
|
+
var posStyles = {
|
|
1512
|
+
"bottom-right": "bottom:20px;right:20px;",
|
|
1513
|
+
"bottom-left": "bottom:20px;left:20px;",
|
|
1514
|
+
"top-right": "top:20px;right:20px;",
|
|
1515
|
+
"top-left": "top:20px;left:20px;"
|
|
1516
|
+
};
|
|
1517
|
+
container.style.cssText = 'position:fixed;z-index:2147483647;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:14px;' + (posStyles[position] || posStyles["bottom-right"]);
|
|
1518
|
+
var btn = document.createElement("button");
|
|
1519
|
+
btn.innerHTML = PARROT_ICON;
|
|
1520
|
+
btn.style.cssText = "width:52px;height:52px;border-radius:50%;border:none;background:#1a1f2e;color:#fff;font-size:22px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,0.25);display:flex;align-items:center;justify-content:center;transition:transform 0.2s,box-shadow 0.2s;overflow:hidden;padding:0;";
|
|
1521
|
+
btn.onmouseenter = function() {
|
|
1522
|
+
btn.style.transform = "scale(1.1)";
|
|
1523
|
+
btn.style.boxShadow = "0 6px 20px rgba(0,0,0,0.35)";
|
|
1524
|
+
};
|
|
1525
|
+
btn.onmouseleave = function() {
|
|
1526
|
+
btn.style.transform = "scale(1)";
|
|
1527
|
+
btn.style.boxShadow = "0 4px 14px rgba(0,0,0,0.25)";
|
|
1528
|
+
};
|
|
1529
|
+
var panel = document.createElement("div");
|
|
1530
|
+
panel.style.cssText = "display:none;position:absolute;" + (position.indexOf("bottom") === 0 ? "bottom:56px;" : "top:56px;") + (position.indexOf("right") >= 0 ? "right:0;" : "left:0;") + "background:#fff;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.18);min-width:200px;max-height:320px;overflow-y:auto;padding:6px 0;";
|
|
1531
|
+
var origItem = document.createElement("div");
|
|
1532
|
+
origItem.textContent = "Original";
|
|
1533
|
+
origItem.style.cssText = "padding:10px 16px;cursor:pointer;color:#333;transition:background 0.15s;font-weight:600;border-bottom:1px solid #eee;";
|
|
1534
|
+
origItem.onmouseenter = function() {
|
|
1535
|
+
origItem.style.background = "#f5f5f5";
|
|
1536
|
+
};
|
|
1537
|
+
origItem.onmouseleave = function() {
|
|
1538
|
+
origItem.style.background = "transparent";
|
|
1539
|
+
};
|
|
1540
|
+
origItem.onclick = function() {
|
|
1541
|
+
currentLang = null;
|
|
1542
|
+
window.Loco.restore();
|
|
1543
|
+
updateActive();
|
|
1544
|
+
panel.style.display = "none";
|
|
1545
|
+
};
|
|
1546
|
+
panel.appendChild(origItem);
|
|
1547
|
+
var items = [];
|
|
1548
|
+
langCodes.forEach(function(code) {
|
|
1549
|
+
var item = document.createElement("div");
|
|
1550
|
+
item.textContent = getLabel2(code);
|
|
1551
|
+
item.setAttribute("data-lang", code);
|
|
1552
|
+
item.style.cssText = "padding:10px 16px;cursor:pointer;color:#333;transition:background 0.15s;";
|
|
1553
|
+
item.onmouseenter = function() {
|
|
1554
|
+
item.style.background = "#f5f5f5";
|
|
1555
|
+
};
|
|
1556
|
+
item.onmouseleave = function() {
|
|
1557
|
+
item.style.background = currentLang === code ? "#e8f0fe" : "transparent";
|
|
1558
|
+
};
|
|
1559
|
+
item.onclick = function() {
|
|
1560
|
+
currentLang = code;
|
|
1561
|
+
window.Loco.apply(code);
|
|
1562
|
+
updateActive();
|
|
1563
|
+
panel.style.display = "none";
|
|
1564
|
+
};
|
|
1565
|
+
panel.appendChild(item);
|
|
1566
|
+
items.push({ el: item, code });
|
|
1567
|
+
});
|
|
1568
|
+
function updateActive() {
|
|
1569
|
+
origItem.style.background = currentLang === null ? "#e8f0fe" : "transparent";
|
|
1570
|
+
origItem.style.fontWeight = currentLang === null ? "600" : "400";
|
|
1571
|
+
items.forEach(function(it) {
|
|
1572
|
+
it.el.style.background = currentLang === it.code ? "#e8f0fe" : "transparent";
|
|
1573
|
+
it.el.style.fontWeight = currentLang === it.code ? "600" : "400";
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
btn.onclick = function(e) {
|
|
1577
|
+
e.stopPropagation();
|
|
1578
|
+
panel.style.display = panel.style.display === "none" ? "block" : "none";
|
|
1579
|
+
};
|
|
1580
|
+
document.addEventListener("click", function(e) {
|
|
1581
|
+
if (!container.contains(e.target)) {
|
|
1582
|
+
panel.style.display = "none";
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
updateActive();
|
|
1586
|
+
container.appendChild(panel);
|
|
1587
|
+
container.appendChild(btn);
|
|
1588
|
+
document.body.appendChild(container);
|
|
1589
|
+
}
|
|
1590
|
+
function refreshWidget(langs) {
|
|
1591
|
+
if (!_widgetPosition) return;
|
|
1592
|
+
if (!langs || langs.length === 0) return;
|
|
1593
|
+
renderWidget(langs, _widgetPosition);
|
|
1594
|
+
}
|
|
1595
|
+
var DANGEROUS_KEYS = { "__proto__": 1, "constructor": 1, "prototype": 1 };
|
|
1596
|
+
function isAllowedUrl(url) {
|
|
1597
|
+
if (!url || typeof url !== "string") return false;
|
|
1598
|
+
if (url.charAt(0) === "/" || url.charAt(0) === ".") return true;
|
|
1599
|
+
try {
|
|
1600
|
+
var parsed = new URL(url, window.location.origin);
|
|
1601
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
1602
|
+
} catch (e) {
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function validateFileData(data) {
|
|
1607
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
if (Array.isArray(data._raw)) {
|
|
1611
|
+
var langSet = {};
|
|
1612
|
+
var translations = {};
|
|
1613
|
+
for (var ri = 0; ri < data._raw.length; ri++) {
|
|
1614
|
+
var row = data._raw[ri];
|
|
1615
|
+
if (!row || typeof row.l !== "string" || typeof row.k !== "string" || typeof row.v !== "string") continue;
|
|
1616
|
+
if (!langSet[row.l]) {
|
|
1617
|
+
langSet[row.l] = true;
|
|
1618
|
+
translations[row.l] = [];
|
|
1619
|
+
}
|
|
1620
|
+
translations[row.l].push({ key: row.k, context: typeof row.c === "string" ? row.c : "", value: row.v });
|
|
1621
|
+
}
|
|
1622
|
+
data = {
|
|
1623
|
+
languages: Object.keys(langSet).sort(),
|
|
1624
|
+
languageNames: {},
|
|
1625
|
+
translations,
|
|
1626
|
+
timestamp: 0
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
var result = {};
|
|
1630
|
+
if (!Array.isArray(data.languages)) return null;
|
|
1631
|
+
result.languages = [];
|
|
1632
|
+
for (var i = 0; i < data.languages.length; i++) {
|
|
1633
|
+
if (typeof data.languages[i] !== "string" || data.languages[i].length > 20) return null;
|
|
1634
|
+
result.languages.push(data.languages[i]);
|
|
1635
|
+
}
|
|
1636
|
+
result.timestamp = typeof data.timestamp === "number" && isFinite(data.timestamp) ? data.timestamp : 0;
|
|
1637
|
+
result.languageNames = {};
|
|
1638
|
+
if (data.languageNames && typeof data.languageNames === "object" && !Array.isArray(data.languageNames)) {
|
|
1639
|
+
for (var code in data.languageNames) {
|
|
1640
|
+
if (!data.languageNames.hasOwnProperty(code) || DANGEROUS_KEYS[code]) continue;
|
|
1641
|
+
if (typeof data.languageNames[code] === "string" && data.languageNames[code].length <= 100) {
|
|
1642
|
+
result.languageNames[code] = data.languageNames[code];
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
result.translations = {};
|
|
1647
|
+
if (data.translations && typeof data.translations === "object" && !Array.isArray(data.translations)) {
|
|
1648
|
+
for (var lang in data.translations) {
|
|
1649
|
+
if (!data.translations.hasOwnProperty(lang) || DANGEROUS_KEYS[lang]) continue;
|
|
1650
|
+
if (result.languages.indexOf(lang) === -1) continue;
|
|
1651
|
+
var langData = data.translations[lang];
|
|
1652
|
+
if (Array.isArray(langData)) {
|
|
1653
|
+
var sanitized = [];
|
|
1654
|
+
for (var j = 0; j < langData.length; j++) {
|
|
1655
|
+
var entry = langData[j];
|
|
1656
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1657
|
+
if (typeof entry.key !== "string" || typeof entry.value !== "string") continue;
|
|
1658
|
+
if (entry.key.length > MAX_STRING_LENGTH || entry.value.length > MAX_STRING_LENGTH) continue;
|
|
1659
|
+
if (DANGEROUS_KEYS[entry.key]) continue;
|
|
1660
|
+
sanitized.push({ key: entry.key, value: entry.value, context: typeof entry.context === "string" ? entry.context : "" });
|
|
1661
|
+
}
|
|
1662
|
+
result.translations[lang] = sanitized;
|
|
1663
|
+
} else if (typeof langData === "object") {
|
|
1664
|
+
var sanitizedMap = {};
|
|
1665
|
+
for (var key in langData) {
|
|
1666
|
+
if (!langData.hasOwnProperty(key) || DANGEROUS_KEYS[key]) continue;
|
|
1667
|
+
if (typeof langData[key] !== "string") continue;
|
|
1668
|
+
if (key.length > MAX_STRING_LENGTH || langData[key].length > MAX_STRING_LENGTH) continue;
|
|
1669
|
+
sanitizedMap[key] = langData[key];
|
|
1670
|
+
}
|
|
1671
|
+
result.translations[lang] = sanitizedMap;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return result;
|
|
1676
|
+
}
|
|
1677
|
+
async function init() {
|
|
1678
|
+
try {
|
|
1679
|
+
var res = await fetch(state.apiBase + "/api/project/crawler-config", {
|
|
1680
|
+
headers: { "X-API-Key": state.apiKey }
|
|
1681
|
+
});
|
|
1682
|
+
if (res.ok) applyConfig(await res.json());
|
|
1683
|
+
} catch (e) {
|
|
1684
|
+
}
|
|
1685
|
+
prepareContextScan();
|
|
1686
|
+
state.phrases = await collectPhrasesAsync(document.body);
|
|
1687
|
+
postKeys(state.phrases).then(function(result) {
|
|
1688
|
+
if (result && result.keyMap) applyKeyMap(state.phrases, result.keyMap);
|
|
1689
|
+
}).catch(function(e) {
|
|
1690
|
+
console.warn("[loco] Failed to register keys:", e);
|
|
1691
|
+
});
|
|
1692
|
+
if (!state.scanStopped && state.screenshotsEnabled) setTimeout(captureScreenshot, 3e3);
|
|
1693
|
+
console.log("[loco] " + state.phrases.length + " text nodes discovered");
|
|
1694
|
+
console.log(
|
|
1695
|
+
' Loco.textnodes() — list all discovered text nodes\n Loco.apply("zh-Hans") — apply translations for a language\n Loco.restore() — revert to original text\n Loco.rescan() — re-scan DOM for new nodes'
|
|
1696
|
+
);
|
|
1697
|
+
var saved = getSavedLang();
|
|
1698
|
+
if (saved) {
|
|
1699
|
+
Loco2.apply(saved);
|
|
1700
|
+
} else {
|
|
1701
|
+
state.observer = watchAndTranslate(state.phrases.map(function(p) {
|
|
1702
|
+
return p.key + "\0" + (p.context || "");
|
|
1703
|
+
}), {});
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function mergeTranslationArrays(existing, incoming) {
|
|
1707
|
+
var SEP = "\0";
|
|
1708
|
+
var map = {};
|
|
1709
|
+
var order = [];
|
|
1710
|
+
for (var i = 0; i < existing.length; i++) {
|
|
1711
|
+
var e = existing[i];
|
|
1712
|
+
var k = e.key + SEP + (e.context || "");
|
|
1713
|
+
if (!map.hasOwnProperty(k)) order.push(k);
|
|
1714
|
+
map[k] = e;
|
|
1715
|
+
}
|
|
1716
|
+
for (var j = 0; j < incoming.length; j++) {
|
|
1717
|
+
var n = incoming[j];
|
|
1718
|
+
var nk = n.key + SEP + (n.context || "");
|
|
1719
|
+
if (!map.hasOwnProperty(nk)) order.push(nk);
|
|
1720
|
+
map[nk] = n;
|
|
1721
|
+
}
|
|
1722
|
+
var merged = [];
|
|
1723
|
+
for (var m = 0; m < order.length; m++) {
|
|
1724
|
+
merged.push(map[order[m]]);
|
|
1725
|
+
}
|
|
1726
|
+
return merged;
|
|
1727
|
+
}
|
|
1728
|
+
async function applyFileData(source) {
|
|
1729
|
+
state.screenshotsEnabled = false;
|
|
1730
|
+
prepareContextScan();
|
|
1731
|
+
state.phrases = await collectPhrasesAsync(document.body);
|
|
1732
|
+
var allTransKeys = [];
|
|
1733
|
+
var tMap = state.fileData.translations || {};
|
|
1734
|
+
var firstLang = (state.fileData.languages || [])[0];
|
|
1735
|
+
if (firstLang && tMap[firstLang]) {
|
|
1736
|
+
tMap[firstLang] = normalizeTranslationMap(tMap[firstLang]);
|
|
1737
|
+
allTransKeys = Object.keys(tMap[firstLang]);
|
|
1738
|
+
}
|
|
1739
|
+
var fileKeyMap = buildFileVarKeyMap(state.phrases, allTransKeys);
|
|
1740
|
+
var remapped = fileKeyMap ? Object.keys(fileKeyMap).length : 0;
|
|
1741
|
+
var langCount = (state.fileData.languages || []).length;
|
|
1742
|
+
console.log("[loco] (file mode) " + state.phrases.length + " text nodes discovered, " + langCount + " language(s) available" + (remapped ? ", " + remapped + " var-remapped" : "") + " [" + source + "]");
|
|
1743
|
+
console.log(" Languages: " + (state.fileData.languages || []).join(", "));
|
|
1744
|
+
var saved = getSavedLang();
|
|
1745
|
+
if (saved) {
|
|
1746
|
+
Loco2.apply(saved);
|
|
1747
|
+
} else {
|
|
1748
|
+
state.observer = watchAndTranslate(state.phrases.map(function(p) {
|
|
1749
|
+
return p.key + "\0" + (p.context || "");
|
|
1750
|
+
}), {});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
async function initFileMode(urls) {
|
|
1754
|
+
try {
|
|
1755
|
+
indexedDB.deleteDatabase("loco-translations");
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
indexedDB.deleteDatabase("loco-meta");
|
|
1760
|
+
} catch (e) {
|
|
1761
|
+
}
|
|
1762
|
+
try {
|
|
1763
|
+
if (indexedDB.databases) {
|
|
1764
|
+
indexedDB.databases().then(function(dbs) {
|
|
1765
|
+
dbs.forEach(function(d) {
|
|
1766
|
+
if (d.name && d.name.indexOf("loco-tr-") === 0) {
|
|
1767
|
+
try {
|
|
1768
|
+
indexedDB.deleteDatabase(d.name);
|
|
1769
|
+
} catch (e) {
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
}).catch(function() {
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
} catch (e) {
|
|
1777
|
+
}
|
|
1778
|
+
state.fileUrls = urls;
|
|
1779
|
+
var cached = null;
|
|
1780
|
+
try {
|
|
1781
|
+
cached = await getCachedData();
|
|
1782
|
+
} catch (e) {
|
|
1783
|
+
}
|
|
1784
|
+
if (cached && cached.translations) {
|
|
1785
|
+
state.fileData = { languages: cached.languages, languageNames: cached.languageNames || {}, translations: cached.translations, timestamp: 0 };
|
|
1786
|
+
state.loadedFromCache = true;
|
|
1787
|
+
await applyFileData("cache");
|
|
1788
|
+
if (state.fileReadyResolve) {
|
|
1789
|
+
state.fileReadyResolve();
|
|
1790
|
+
state.fileReadyResolve = null;
|
|
1791
|
+
}
|
|
1792
|
+
backgroundRefreshFiles(urls);
|
|
1793
|
+
} else {
|
|
1794
|
+
await fetchAndCacheFiles(urls);
|
|
1795
|
+
await applyFileData("network");
|
|
1796
|
+
if (state.fileReadyResolve) {
|
|
1797
|
+
state.fileReadyResolve();
|
|
1798
|
+
state.fileReadyResolve = null;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
async function fetchAndCacheFiles(urls) {
|
|
1803
|
+
var fetches = urls.map(function(url2) {
|
|
1804
|
+
return fetch(url2).then(function(res) {
|
|
1805
|
+
if (!res.ok) throw new Error("HTTP " + res.status + " for " + url2);
|
|
1806
|
+
return res.text();
|
|
1807
|
+
}).then(function(text) {
|
|
1808
|
+
if (!text || !text.trim()) return null;
|
|
1809
|
+
var data2;
|
|
1810
|
+
try {
|
|
1811
|
+
data2 = JSON.parse(text);
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
var validated = validateFileData(data2);
|
|
1816
|
+
if (!validated) {
|
|
1817
|
+
console.warn("[loco] File rejected (invalid schema): " + url2);
|
|
1818
|
+
return null;
|
|
1819
|
+
}
|
|
1820
|
+
return { url: url2, data: validated };
|
|
1821
|
+
}).catch(function(e) {
|
|
1822
|
+
console.warn("[loco] Failed to load " + url2 + ":", e);
|
|
1823
|
+
return null;
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
var results = await Promise.all(fetches);
|
|
1827
|
+
var isFirst = true;
|
|
1828
|
+
for (var i = 0; i < results.length; i++) {
|
|
1829
|
+
if (!results[i]) continue;
|
|
1830
|
+
var url = results[i].url;
|
|
1831
|
+
var data = results[i].data;
|
|
1832
|
+
var shouldMerge = !isFirst || (data.languages || []).length === 1;
|
|
1833
|
+
mergeIntoFileData(data, isFirst);
|
|
1834
|
+
isFirst = false;
|
|
1835
|
+
try {
|
|
1836
|
+
await setCachedData(url, data, shouldMerge);
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
state.loadedFromCache = false;
|
|
1841
|
+
}
|
|
1842
|
+
function mergeIntoFileData(data, isFirst) {
|
|
1843
|
+
if (!state.fileData || isFirst) {
|
|
1844
|
+
state.fileData = {
|
|
1845
|
+
languages: (data.languages || []).slice(),
|
|
1846
|
+
languageNames: Object.assign({}, data.languageNames || {}),
|
|
1847
|
+
translations: Object.assign({}, data.translations || {}),
|
|
1848
|
+
timestamp: data.timestamp || 0
|
|
1849
|
+
};
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
var langs = data.languages || [];
|
|
1853
|
+
for (var i = 0; i < langs.length; i++) {
|
|
1854
|
+
if (state.fileData.languages.indexOf(langs[i]) === -1) {
|
|
1855
|
+
state.fileData.languages.push(langs[i]);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
var names = data.languageNames || {};
|
|
1859
|
+
if (!state.fileData.languageNames) state.fileData.languageNames = {};
|
|
1860
|
+
for (var k in names) {
|
|
1861
|
+
if (!names.hasOwnProperty(k) || DANGEROUS_KEYS[k]) continue;
|
|
1862
|
+
state.fileData.languageNames[k] = names[k];
|
|
1863
|
+
}
|
|
1864
|
+
var tMap = data.translations || {};
|
|
1865
|
+
if (!state.fileData.translations) state.fileData.translations = {};
|
|
1866
|
+
for (var lang in tMap) {
|
|
1867
|
+
if (!tMap.hasOwnProperty(lang) || DANGEROUS_KEYS[lang]) continue;
|
|
1868
|
+
var existing = state.fileData.translations[lang];
|
|
1869
|
+
var incoming = tMap[lang];
|
|
1870
|
+
if (!existing || !Array.isArray(existing) || !Array.isArray(incoming)) {
|
|
1871
|
+
state.fileData.translations[lang] = incoming;
|
|
1872
|
+
} else {
|
|
1873
|
+
state.fileData.translations[lang] = mergeTranslationArrays(existing, incoming);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (data.timestamp && data.timestamp > state.fileData.timestamp) {
|
|
1877
|
+
state.fileData.timestamp = data.timestamp;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
function backgroundRefreshFiles(urls) {
|
|
1881
|
+
var refreshPromises = urls.map(function(url) {
|
|
1882
|
+
return Promise.all([
|
|
1883
|
+
getSourceMeta(url).catch(function() {
|
|
1884
|
+
return null;
|
|
1885
|
+
}),
|
|
1886
|
+
fetch(url).then(function(res) {
|
|
1887
|
+
if (!res.ok) return null;
|
|
1888
|
+
return res.text();
|
|
1889
|
+
}).catch(function() {
|
|
1890
|
+
return null;
|
|
1891
|
+
})
|
|
1892
|
+
]).then(function(results) {
|
|
1893
|
+
var sourceMeta = results[0];
|
|
1894
|
+
var text = results[1];
|
|
1895
|
+
if (!text || !text.trim()) return null;
|
|
1896
|
+
var freshData;
|
|
1897
|
+
try {
|
|
1898
|
+
freshData = JSON.parse(text);
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
return null;
|
|
1901
|
+
}
|
|
1902
|
+
freshData = validateFileData(freshData);
|
|
1903
|
+
if (!freshData) return null;
|
|
1904
|
+
var freshTs = freshData.timestamp || 0;
|
|
1905
|
+
var cachedTs = sourceMeta && sourceMeta.timestamp || 0;
|
|
1906
|
+
if (freshTs !== cachedTs) {
|
|
1907
|
+
var shouldMerge = urls.length > 1 || (freshData.languages || []).length === 1;
|
|
1908
|
+
setCachedData(url, freshData, shouldMerge).catch(function() {
|
|
1909
|
+
});
|
|
1910
|
+
return freshData;
|
|
1911
|
+
}
|
|
1912
|
+
return null;
|
|
1913
|
+
});
|
|
1914
|
+
});
|
|
1915
|
+
Promise.all(refreshPromises).then(function(results) {
|
|
1916
|
+
var anyUpdated = false;
|
|
1917
|
+
for (var i = 0; i < results.length; i++) {
|
|
1918
|
+
if (results[i]) {
|
|
1919
|
+
mergeIntoFileData(results[i], false);
|
|
1920
|
+
anyUpdated = true;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (anyUpdated) {
|
|
1924
|
+
state.loadedFromCache = false;
|
|
1925
|
+
if (state.observer) {
|
|
1926
|
+
state.observer.disconnect();
|
|
1927
|
+
state.observer = null;
|
|
1928
|
+
}
|
|
1929
|
+
untranslate(state.phrases);
|
|
1930
|
+
applyFileData("network — updated").then(function() {
|
|
1931
|
+
refreshWidgetFromDb();
|
|
1932
|
+
});
|
|
1933
|
+
console.log("[loco] translations refreshed from file(s) (timestamp changed)");
|
|
1934
|
+
}
|
|
1935
|
+
}).catch(function() {
|
|
1936
|
+
console.log("[loco] using cached translations (network unavailable)");
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
function refreshWidgetFromDb() {
|
|
1940
|
+
if (!state.widgetPosition) return;
|
|
1941
|
+
getLanguageRegistry().then(function(reg) {
|
|
1942
|
+
if (!reg) return;
|
|
1943
|
+
var langs = buildWidgetLangs(reg.languages, reg.languageNames);
|
|
1944
|
+
refreshWidget(langs);
|
|
1945
|
+
}).catch(function() {
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
function buildWidgetLangs(languages, languageNames) {
|
|
1949
|
+
languages = languages || [];
|
|
1950
|
+
languageNames = languageNames || {};
|
|
1951
|
+
return languages.map(function(c) {
|
|
1952
|
+
return languageNames[c] ? { code: c, name: languageNames[c] } : c;
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
var Loco2 = {
|
|
1956
|
+
init: function(config) {
|
|
1957
|
+
if (!config) {
|
|
1958
|
+
console.warn("[loco] Loco.init() requires a config object");
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (config.file || config.files) {
|
|
1962
|
+
state.fileMode = true;
|
|
1963
|
+
state.fileReady = new Promise(function(resolve) {
|
|
1964
|
+
state.fileReadyResolve = resolve;
|
|
1965
|
+
});
|
|
1966
|
+
var urls = config.files ? config.files.slice() : [config.file];
|
|
1967
|
+
for (var i = 0; i < urls.length; i++) {
|
|
1968
|
+
if (!isAllowedUrl(urls[i])) {
|
|
1969
|
+
console.warn("[loco] Blocked unsafe URL: " + urls[i]);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
state.fileUrl = urls[0];
|
|
1974
|
+
if (document.readyState === "loading") {
|
|
1975
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
1976
|
+
initFileMode(urls);
|
|
1977
|
+
});
|
|
1978
|
+
} else {
|
|
1979
|
+
initFileMode(urls);
|
|
1980
|
+
}
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (!config.apiKey) {
|
|
1984
|
+
console.warn("[loco] Loco.init() requires { apiKey } or { file }");
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (!config.apiUrl) {
|
|
1988
|
+
console.warn("[loco] Loco.init() requires { apiUrl }");
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
state.apiKey = config.apiKey;
|
|
1992
|
+
state.apiBase = config.apiUrl.replace(/\/+$/, "");
|
|
1993
|
+
if (document.readyState === "loading") {
|
|
1994
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
1995
|
+
} else {
|
|
1996
|
+
init();
|
|
1997
|
+
}
|
|
1998
|
+
},
|
|
1999
|
+
apply: async function(langCode) {
|
|
2000
|
+
if (!langCode) {
|
|
2001
|
+
console.warn('[loco] Loco.apply() requires a language code, e.g. Loco.apply("zh-Hans")');
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (typeof langCode !== "string" || langCode.length > 20 || !/^[a-zA-Z0-9\-_]+$/.test(langCode)) {
|
|
2005
|
+
console.warn("[loco] Invalid language code: " + langCode);
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (state.fileMode) {
|
|
2009
|
+
if (!state.fileData) {
|
|
2010
|
+
console.warn("[loco] File not loaded yet. Call Loco.init({ file }) first.");
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
if (!state.fileData.translations || !state.fileData.translations[langCode]) {
|
|
2014
|
+
try {
|
|
2015
|
+
var transMap = await loadLangFromDb(langCode);
|
|
2016
|
+
if (!transMap || Object.keys(transMap).length === 0) {
|
|
2017
|
+
console.warn('[loco] No translations for "' + langCode + '" in file data or cache');
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (!state.fileData.translations) state.fileData.translations = {};
|
|
2021
|
+
state.fileData.translations[langCode] = transMap;
|
|
2022
|
+
if (state.fileData.languages.indexOf(langCode) === -1) {
|
|
2023
|
+
state.fileData.languages.push(langCode);
|
|
2024
|
+
}
|
|
2025
|
+
return Loco2.apply(langCode);
|
|
2026
|
+
} catch (e) {
|
|
2027
|
+
console.warn('[loco] Failed to load translations for "' + langCode + '" from cache:', e);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (state.observer) {
|
|
2032
|
+
state.observer.disconnect();
|
|
2033
|
+
state.observer = null;
|
|
2034
|
+
}
|
|
2035
|
+
untranslate(state.phrases);
|
|
2036
|
+
state.translations = normalizeTranslationMap(state.fileData.translations[langCode]);
|
|
2037
|
+
state.fileData.translations[langCode] = state.translations;
|
|
2038
|
+
state.phrases = await collectPhrasesAsync(document.body);
|
|
2039
|
+
var allTransKeys = Object.keys(state.translations);
|
|
2040
|
+
buildFileVarKeyMap(state.phrases, allTransKeys);
|
|
2041
|
+
var result = await applyTranslationsAsync(state.phrases, state.translations);
|
|
2042
|
+
state.observer = watchAndTranslate(state.phrases.map(function(p) {
|
|
2043
|
+
return p.key + "\0" + (p.context || "");
|
|
2044
|
+
}), state.translations);
|
|
2045
|
+
saveLang(langCode);
|
|
2046
|
+
console.log("[loco] (file) applied " + result.applied + " translation(s), " + result.skipped + " pending");
|
|
2047
|
+
return result;
|
|
2048
|
+
}
|
|
2049
|
+
if (!state.apiKey || !state.apiBase) {
|
|
2050
|
+
console.warn("[loco] Call Loco.init() first");
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (state.observer) {
|
|
2054
|
+
state.observer.disconnect();
|
|
2055
|
+
state.observer = null;
|
|
2056
|
+
}
|
|
2057
|
+
untranslate(state.phrases);
|
|
2058
|
+
try {
|
|
2059
|
+
var map = await fetchTranslations(langCode);
|
|
2060
|
+
state.translations = map || {};
|
|
2061
|
+
state.phrases = await collectPhrasesAsync(document.body);
|
|
2062
|
+
buildFileVarKeyMap(state.phrases, Object.keys(state.translations));
|
|
2063
|
+
postKeys(state.phrases).then(function(result2) {
|
|
2064
|
+
if (result2 && result2.keyMap) {
|
|
2065
|
+
applyKeyMap(state.phrases, result2.keyMap);
|
|
2066
|
+
applyTranslationsAsync(state.phrases, state.translations);
|
|
2067
|
+
}
|
|
2068
|
+
}).catch(function() {
|
|
2069
|
+
});
|
|
2070
|
+
var result = await applyTranslationsAsync(state.phrases, state.translations);
|
|
2071
|
+
state.observer = watchAndTranslate(state.phrases.map(function(p) {
|
|
2072
|
+
return p.key + "\0" + (p.context || "");
|
|
2073
|
+
}), state.translations);
|
|
2074
|
+
saveLang(langCode);
|
|
2075
|
+
console.log("[loco] applied " + result.applied + " translation(s), " + result.skipped + " pending");
|
|
2076
|
+
return result;
|
|
2077
|
+
} catch (e) {
|
|
2078
|
+
console.warn("[loco] Failed to fetch translations:", e);
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2081
|
+
restore: function() {
|
|
2082
|
+
untranslate(state.phrases);
|
|
2083
|
+
if (state.observer) state.observer.disconnect();
|
|
2084
|
+
saveLang(null);
|
|
2085
|
+
},
|
|
2086
|
+
rescan: async function() {
|
|
2087
|
+
var hadTranslations = Object.keys(state.translations).length > 0;
|
|
2088
|
+
if (hadTranslations) untranslate(state.phrases);
|
|
2089
|
+
state.phrases = await collectPhrasesAsync(document.body);
|
|
2090
|
+
if (!state.fileMode) {
|
|
2091
|
+
postKeys(state.phrases).catch(function() {
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
if (hadTranslations) {
|
|
2095
|
+
buildFileVarKeyMap(state.phrases, Object.keys(state.translations));
|
|
2096
|
+
await applyTranslationsAsync(state.phrases, state.translations);
|
|
2097
|
+
}
|
|
2098
|
+
},
|
|
2099
|
+
textnodes: function() {
|
|
2100
|
+
return state.phrases.map(function(p) {
|
|
2101
|
+
return { key: p.key, context: p.context || "", element: p.element };
|
|
2102
|
+
});
|
|
2103
|
+
},
|
|
2104
|
+
stopScan: function() {
|
|
2105
|
+
state.scanStopped = true;
|
|
2106
|
+
if (state.observer) {
|
|
2107
|
+
state.observer.disconnect();
|
|
2108
|
+
state.observer = null;
|
|
2109
|
+
}
|
|
2110
|
+
console.log("[loco] scanning stopped, no further nodes will be sent to dashboard");
|
|
2111
|
+
},
|
|
2112
|
+
startScan: function() {
|
|
2113
|
+
state.scanStopped = false;
|
|
2114
|
+
console.log("[loco] scanning resumed");
|
|
2115
|
+
},
|
|
2116
|
+
isFileMode: function() {
|
|
2117
|
+
return state.fileMode;
|
|
2118
|
+
},
|
|
2119
|
+
languages: function() {
|
|
2120
|
+
if (state.fileMode) {
|
|
2121
|
+
var fromMemory = function() {
|
|
2122
|
+
var codes = state.fileData ? state.fileData.languages || [] : [];
|
|
2123
|
+
var names = state.fileData && state.fileData.languageNames || {};
|
|
2124
|
+
return codes.map(function(c) {
|
|
2125
|
+
return { code: c, name: names[c] || c };
|
|
2126
|
+
});
|
|
2127
|
+
};
|
|
2128
|
+
if (!state.fileData && state.fileReady) {
|
|
2129
|
+
return state.fileReady.then(function() {
|
|
2130
|
+
return getLanguageRegistry().then(function(reg) {
|
|
2131
|
+
if (reg && reg.languages && reg.languages.length > 0) {
|
|
2132
|
+
return buildWidgetLangs(reg.languages, reg.languageNames);
|
|
2133
|
+
}
|
|
2134
|
+
return fromMemory();
|
|
2135
|
+
}).catch(function() {
|
|
2136
|
+
return fromMemory();
|
|
2137
|
+
});
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
return getLanguageRegistry().then(function(reg) {
|
|
2141
|
+
if (reg && reg.languages && reg.languages.length > 0) {
|
|
2142
|
+
return buildWidgetLangs(reg.languages, reg.languageNames);
|
|
2143
|
+
}
|
|
2144
|
+
return fromMemory();
|
|
2145
|
+
}).catch(function() {
|
|
2146
|
+
return fromMemory();
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
if (!state.apiKey || !state.apiBase) {
|
|
2150
|
+
return Promise.resolve([]);
|
|
2151
|
+
}
|
|
2152
|
+
return fetch(state.apiBase + "/api/languages", {
|
|
2153
|
+
headers: { "X-API-Key": state.apiKey }
|
|
2154
|
+
}).then(function(r) {
|
|
2155
|
+
return r.json();
|
|
2156
|
+
}).then(function(langs) {
|
|
2157
|
+
return Array.isArray(langs) ? langs : [];
|
|
2158
|
+
}).catch(function() {
|
|
2159
|
+
return [];
|
|
2160
|
+
});
|
|
2161
|
+
},
|
|
2162
|
+
widget: function(opts) {
|
|
2163
|
+
opts = opts || {};
|
|
2164
|
+
var position = opts.position || "bottom-right";
|
|
2165
|
+
state.widgetPosition = position;
|
|
2166
|
+
if (state.fileMode) {
|
|
2167
|
+
let doRenderWidget = function() {
|
|
2168
|
+
getLanguageRegistry().then(function(reg) {
|
|
2169
|
+
var langs;
|
|
2170
|
+
if (reg && reg.languages && reg.languages.length > 0) {
|
|
2171
|
+
langs = buildWidgetLangs(reg.languages, reg.languageNames);
|
|
2172
|
+
} else {
|
|
2173
|
+
langs = fileModeLangs();
|
|
2174
|
+
}
|
|
2175
|
+
if (langs.length === 0) {
|
|
2176
|
+
console.warn("[loco] No languages found in translation file or cache");
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
renderWidget(langs, position);
|
|
2180
|
+
}).catch(function() {
|
|
2181
|
+
var langs = fileModeLangs();
|
|
2182
|
+
if (langs.length === 0) {
|
|
2183
|
+
console.warn("[loco] No languages found in translation file");
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
renderWidget(langs, position);
|
|
2187
|
+
});
|
|
2188
|
+
}, fileModeLangs = function() {
|
|
2189
|
+
var codes = state.fileData ? state.fileData.languages || [] : [];
|
|
2190
|
+
var names = state.fileData && state.fileData.languageNames || {};
|
|
2191
|
+
return codes.map(function(c) {
|
|
2192
|
+
return names[c] ? { code: c, name: names[c] } : c;
|
|
2193
|
+
});
|
|
2194
|
+
};
|
|
2195
|
+
if (!state.fileData && state.fileReady) {
|
|
2196
|
+
state.fileReady.then(doRenderWidget);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
doRenderWidget();
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (!state.apiKey || !state.apiBase) {
|
|
2203
|
+
console.warn("[loco] Call Loco.init() first");
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
fetch(state.apiBase + "/api/languages", {
|
|
2207
|
+
headers: { "X-API-Key": state.apiKey }
|
|
2208
|
+
}).then(function(r) {
|
|
2209
|
+
return r.json();
|
|
2210
|
+
}).then(function(langs) {
|
|
2211
|
+
if (!Array.isArray(langs) || langs.length === 0) {
|
|
2212
|
+
console.warn("[loco] No languages found — add translations in the dashboard first");
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
renderWidget(langs, position);
|
|
2216
|
+
}).catch(function(e) {
|
|
2217
|
+
console.warn("[loco] Failed to fetch languages:", e);
|
|
2218
|
+
});
|
|
2219
|
+
},
|
|
2220
|
+
clearCache: function() {
|
|
2221
|
+
if (!state.fileMode) {
|
|
2222
|
+
console.warn("[loco] clearCache() is only available in file mode");
|
|
2223
|
+
return Promise.resolve();
|
|
2224
|
+
}
|
|
2225
|
+
return clearLocoCache().then(function() {
|
|
2226
|
+
if (state.fileData) {
|
|
2227
|
+
state.fileData.translations = {};
|
|
2228
|
+
state.fileData.timestamp = 0;
|
|
2229
|
+
}
|
|
2230
|
+
state.loadedFromCache = false;
|
|
2231
|
+
saveLang(null);
|
|
2232
|
+
untranslate(state.phrases);
|
|
2233
|
+
if (state.observer) {
|
|
2234
|
+
state.observer.disconnect();
|
|
2235
|
+
state.observer = null;
|
|
2236
|
+
}
|
|
2237
|
+
console.log("[loco] IndexedDB cache cleared — call pullLatest() to re-fetch");
|
|
2238
|
+
}).catch(function(e) {
|
|
2239
|
+
console.warn("[loco] Failed to clear cache:", e);
|
|
2240
|
+
});
|
|
2241
|
+
},
|
|
2242
|
+
addFile: function(url) {
|
|
2243
|
+
if (!state.fileMode) {
|
|
2244
|
+
console.warn("[loco] addFile() is only available in file mode");
|
|
2245
|
+
return Promise.resolve({ status: "error", reason: "not in file mode" });
|
|
2246
|
+
}
|
|
2247
|
+
if (!url) {
|
|
2248
|
+
return Promise.resolve({ status: "error", reason: "no URL provided" });
|
|
2249
|
+
}
|
|
2250
|
+
if (!isAllowedUrl(url)) {
|
|
2251
|
+
console.warn("[loco] Blocked unsafe URL: " + url);
|
|
2252
|
+
return Promise.resolve({ status: "error", reason: "unsafe URL" });
|
|
2253
|
+
}
|
|
2254
|
+
if (state.fileUrls.indexOf(url) === -1) {
|
|
2255
|
+
state.fileUrls.push(url);
|
|
2256
|
+
}
|
|
2257
|
+
return fetch(url).then(function(res) {
|
|
2258
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
2259
|
+
return res.text();
|
|
2260
|
+
}).then(function(text) {
|
|
2261
|
+
if (!text || !text.trim()) throw new Error("Empty file");
|
|
2262
|
+
var data;
|
|
2263
|
+
try {
|
|
2264
|
+
data = JSON.parse(text);
|
|
2265
|
+
} catch (e) {
|
|
2266
|
+
throw new Error("Invalid JSON");
|
|
2267
|
+
}
|
|
2268
|
+
data = validateFileData(data);
|
|
2269
|
+
if (!data) throw new Error("Invalid file schema");
|
|
2270
|
+
var isFirst = !state.fileData || !state.fileData.languages || state.fileData.languages.length === 0;
|
|
2271
|
+
var shouldMerge = !isFirst || (data.languages || []).length === 1;
|
|
2272
|
+
mergeIntoFileData(data, isFirst);
|
|
2273
|
+
setCachedData(url, data, shouldMerge).catch(function() {
|
|
2274
|
+
});
|
|
2275
|
+
refreshWidgetFromDb();
|
|
2276
|
+
console.log("[loco] added file: " + url + " (" + (data.languages || []).join(", ") + ")");
|
|
2277
|
+
return { status: "added", languages: data.languages || [] };
|
|
2278
|
+
}).catch(function(e) {
|
|
2279
|
+
console.warn("[loco] addFile() failed:", e);
|
|
2280
|
+
return { status: "error", reason: e.message };
|
|
2281
|
+
});
|
|
2282
|
+
},
|
|
2283
|
+
pullLatest: function() {
|
|
2284
|
+
if (!state.fileMode) {
|
|
2285
|
+
console.warn("[loco] pullLatest() is only available in file mode");
|
|
2286
|
+
return Promise.resolve({ status: "skipped", reason: "not in file mode" });
|
|
2287
|
+
}
|
|
2288
|
+
var urls = state.fileUrls.length > 0 ? state.fileUrls : state.fileUrl ? [state.fileUrl] : [];
|
|
2289
|
+
if (urls.length === 0) {
|
|
2290
|
+
console.warn("[loco] No file URL(s) configured. Call Loco.init({ file }) first.");
|
|
2291
|
+
return Promise.resolve({ status: "error", reason: "no file URLs" });
|
|
2292
|
+
}
|
|
2293
|
+
var pullPromises = urls.map(function(url) {
|
|
2294
|
+
return Promise.all([
|
|
2295
|
+
getSourceMeta(url).catch(function() {
|
|
2296
|
+
return null;
|
|
2297
|
+
}),
|
|
2298
|
+
fetch(url).then(function(res) {
|
|
2299
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
2300
|
+
return res.text();
|
|
2301
|
+
})
|
|
2302
|
+
]).then(function(results) {
|
|
2303
|
+
var sourceMeta = results[0];
|
|
2304
|
+
var text = results[1];
|
|
2305
|
+
if (!text || !text.trim()) return { url, status: "current", reason: "empty" };
|
|
2306
|
+
var freshData;
|
|
2307
|
+
try {
|
|
2308
|
+
freshData = JSON.parse(text);
|
|
2309
|
+
} catch (e) {
|
|
2310
|
+
throw new Error("Invalid JSON in " + url);
|
|
2311
|
+
}
|
|
2312
|
+
freshData = validateFileData(freshData);
|
|
2313
|
+
if (!freshData) throw new Error("Invalid file schema in " + url);
|
|
2314
|
+
var freshTs = freshData.timestamp || 0;
|
|
2315
|
+
var cachedTs = sourceMeta && sourceMeta.timestamp || 0;
|
|
2316
|
+
if (freshTs === cachedTs && cachedTs !== 0) {
|
|
2317
|
+
var hasData = state.fileData && state.fileData.translations;
|
|
2318
|
+
var fileLangs = freshData.languages || [];
|
|
2319
|
+
var needsData = !hasData || fileLangs.some(function(l) {
|
|
2320
|
+
return !state.fileData.translations[l];
|
|
2321
|
+
});
|
|
2322
|
+
if (!needsData) {
|
|
2323
|
+
return { url, status: "current", timestamp: cachedTs };
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
var isFirst = !state.fileData || !state.fileData.languages || state.fileData.languages.length === 0;
|
|
2327
|
+
var shouldMerge = !isFirst || (freshData.languages || []).length === 1;
|
|
2328
|
+
mergeIntoFileData(freshData, isFirst);
|
|
2329
|
+
setCachedData(url, freshData, shouldMerge).catch(function() {
|
|
2330
|
+
});
|
|
2331
|
+
return { url, status: "updated", timestamp: freshTs, previousTimestamp: cachedTs };
|
|
2332
|
+
}).catch(function(e) {
|
|
2333
|
+
return { url, status: "error", reason: e.message };
|
|
2334
|
+
});
|
|
2335
|
+
});
|
|
2336
|
+
return Promise.all(pullPromises).then(async function(results) {
|
|
2337
|
+
var anyUpdated = results.some(function(r2) {
|
|
2338
|
+
return r2.status === "updated";
|
|
2339
|
+
});
|
|
2340
|
+
if (anyUpdated) {
|
|
2341
|
+
state.loadedFromCache = false;
|
|
2342
|
+
var currentLang = getSavedLang();
|
|
2343
|
+
if (currentLang) {
|
|
2344
|
+
if (state.observer) {
|
|
2345
|
+
state.observer.disconnect();
|
|
2346
|
+
state.observer = null;
|
|
2347
|
+
}
|
|
2348
|
+
untranslate(state.phrases);
|
|
2349
|
+
await applyFileData("pullLatest");
|
|
2350
|
+
} else {
|
|
2351
|
+
state.phrases = collectPhrases(document.body);
|
|
2352
|
+
var tMap = state.fileData.translations || {};
|
|
2353
|
+
var firstLang = (state.fileData.languages || [])[0];
|
|
2354
|
+
if (firstLang && tMap[firstLang]) {
|
|
2355
|
+
buildFileVarKeyMap(state.phrases, Object.keys(tMap[firstLang]));
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
refreshWidgetFromDb();
|
|
2359
|
+
}
|
|
2360
|
+
if (results.length === 1) {
|
|
2361
|
+
var r = results[0];
|
|
2362
|
+
console.log("[loco] pullLatest: " + r.status + (r.timestamp ? " (timestamp: " + r.timestamp + ")" : ""));
|
|
2363
|
+
return r;
|
|
2364
|
+
}
|
|
2365
|
+
var updated = results.filter(function(r2) {
|
|
2366
|
+
return r2.status === "updated";
|
|
2367
|
+
}).map(function(r2) {
|
|
2368
|
+
return r2.url;
|
|
2369
|
+
});
|
|
2370
|
+
var current = results.filter(function(r2) {
|
|
2371
|
+
return r2.status === "current";
|
|
2372
|
+
}).map(function(r2) {
|
|
2373
|
+
return r2.url;
|
|
2374
|
+
});
|
|
2375
|
+
console.log("[loco] pullLatest: " + updated.length + " updated, " + current.length + " current");
|
|
2376
|
+
return { status: anyUpdated ? "updated" : "current", results };
|
|
2377
|
+
}).catch(function(e) {
|
|
2378
|
+
console.warn("[loco] pullLatest() failed:", e);
|
|
2379
|
+
return { status: "error", reason: e.message };
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
window.Loco = Loco2;
|
|
2384
|
+
Object.defineProperty(Loco2, "_state", { value: state, writable: false, enumerable: false, configurable: false });
|
|
2385
|
+
return Loco2;
|
|
2386
|
+
}();
|