@lionrapid/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base/event-emitter.d.ts.map +1 -1
- package/dist/base/event-emitter.js +2 -1
- package/dist/base/event-emitter.js.map +1 -1
- package/dist/browser/attribute-extractor.d.ts.map +1 -1
- package/dist/browser/attribute-extractor.js +2 -1
- package/dist/browser/attribute-extractor.js.map +1 -1
- package/dist/browser/auto-init.d.ts +64 -0
- package/dist/browser/auto-init.d.ts.map +1 -1
- package/dist/browser/auto-init.js +171 -59
- package/dist/browser/auto-init.js.map +1 -1
- package/dist/browser/block-extractor.d.ts.map +1 -1
- package/dist/browser/block-extractor.js +3 -1
- package/dist/browser/block-extractor.js.map +1 -1
- package/dist/browser/editor/editor-api.d.ts +50 -6
- package/dist/browser/editor/editor-api.d.ts.map +1 -1
- package/dist/browser/editor/editor-api.js +121 -19
- package/dist/browser/editor/editor-api.js.map +1 -1
- package/dist/browser/editor/editor-init.js +4 -1
- package/dist/browser/editor/editor-init.js.map +1 -1
- package/dist/browser/editor/sidebar.d.ts +4 -3
- package/dist/browser/editor/sidebar.d.ts.map +1 -1
- package/dist/browser/editor/sidebar.js +93 -27
- package/dist/browser/editor/sidebar.js.map +1 -1
- package/dist/browser/editor/snapshot-viewer.d.ts.map +1 -1
- package/dist/browser/editor/snapshot-viewer.js +32 -3
- package/dist/browser/editor/snapshot-viewer.js.map +1 -1
- package/dist/browser/lionrapid.auto.dev.js +424 -147
- package/dist/browser/lionrapid.auto.dev.js.map +4 -4
- package/dist/browser/lionrapid.auto.js +424 -147
- package/dist/browser/lionrapid.auto.js.map +4 -4
- package/dist/browser/lionrapid.auto.min.js +4 -3
- package/dist/browser/lionrapid.auto.min.js.map +4 -4
- package/dist/browser/lionrapid.editor.js +1467 -1115
- package/dist/browser/lionrapid.editor.js.map +4 -4
- package/dist/browser/lionrapid.editor.min.js +4 -3
- package/dist/browser/lionrapid.editor.min.js.map +4 -4
- package/dist/browser/lionrapid.esm.js +584 -101
- package/dist/browser/lionrapid.esm.js.map +4 -4
- package/dist/browser/lionrapid.esm.min.js +3 -2
- package/dist/browser/lionrapid.esm.min.js.map +4 -4
- package/dist/browser/lionrapid.iife.dev.js +584 -101
- package/dist/browser/lionrapid.iife.dev.js.map +4 -4
- package/dist/browser/lionrapid.iife.js +584 -101
- package/dist/browser/lionrapid.iife.js.map +4 -4
- package/dist/browser/lionrapid.iife.min.js +3 -2
- package/dist/browser/lionrapid.iife.min.js.map +4 -4
- package/dist/browser/lionrapid.umd.js +584 -101
- package/dist/browser/lionrapid.umd.js.map +4 -4
- package/dist/browser/lionrapid.umd.min.js +3 -2
- package/dist/browser/lionrapid.umd.min.js.map +4 -4
- package/dist/browser/sanitizer.d.ts +37 -0
- package/dist/browser/sanitizer.d.ts.map +1 -0
- package/dist/browser/sanitizer.js +93 -0
- package/dist/browser/sanitizer.js.map +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +33 -5
- package/dist/builder.js.map +1 -1
- package/dist/config/lionrapid.config.d.ts +0 -1
- package/dist/config/lionrapid.config.d.ts.map +1 -1
- package/dist/config/repository.config.d.ts +2 -2
- package/dist/config/repository.config.d.ts.map +1 -1
- package/dist/content/index.d.ts +3 -0
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -0
- package/dist/content/index.js.map +1 -1
- package/dist/content/sanitizer/HTMLSanitizer.d.ts +105 -0
- package/dist/content/sanitizer/HTMLSanitizer.d.ts.map +1 -0
- package/dist/content/sanitizer/HTMLSanitizer.js +257 -0
- package/dist/content/sanitizer/HTMLSanitizer.js.map +1 -0
- package/dist/content/serializer/HTMLSerializer.d.ts +6 -2
- package/dist/content/serializer/HTMLSerializer.d.ts.map +1 -1
- package/dist/content/serializer/HTMLSerializer.js +16 -8
- package/dist/content/serializer/HTMLSerializer.js.map +1 -1
- package/dist/content/splitter/BlockSplitter.d.ts +66 -0
- package/dist/content/splitter/BlockSplitter.d.ts.map +1 -0
- package/dist/content/splitter/BlockSplitter.js +219 -0
- package/dist/content/splitter/BlockSplitter.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/lionRapid.d.ts +1 -2
- package/dist/lionRapid.d.ts.map +1 -1
- package/dist/lionRapid.js +5 -7
- package/dist/lionRapid.js.map +1 -1
- package/dist/repositories/network-repository.d.ts +30 -8
- package/dist/repositories/network-repository.d.ts.map +1 -1
- package/dist/repositories/network-repository.js +88 -74
- package/dist/repositories/network-repository.js.map +1 -1
- package/dist/types/lionRapid.types.d.ts +2 -0
- package/dist/types/lionRapid.types.d.ts.map +1 -1
- package/dist/types/translation.types.d.ts +0 -3
- package/dist/types/translation.types.d.ts.map +1 -1
- package/dist/utils/context-utils.d.ts +1 -2
- package/dist/utils/context-utils.d.ts.map +1 -1
- package/dist/utils/context-utils.js +3 -6
- package/dist/utils/context-utils.js.map +1 -1
- package/dist/utils/locale.d.ts +57 -0
- package/dist/utils/locale.d.ts.map +1 -0
- package/dist/utils/locale.js +116 -0
- package/dist/utils/locale.js.map +1 -0
- package/dist/utils/translation-flow.d.ts +1 -1
- package/dist/utils/translation-flow.d.ts.map +1 -1
- package/dist/utils/translation-flow.js +2 -3
- package/dist/utils/translation-flow.js.map +1 -1
- package/package.json +2 -1
|
@@ -1160,6 +1160,160 @@ var LionRapidEditor = (() => {
|
|
|
1160
1160
|
}
|
|
1161
1161
|
};
|
|
1162
1162
|
|
|
1163
|
+
// src/content/sanitizer/HTMLSanitizer.ts
|
|
1164
|
+
var SAFE_TAGS = /* @__PURE__ */ new Set([
|
|
1165
|
+
"a",
|
|
1166
|
+
"abbr",
|
|
1167
|
+
"b",
|
|
1168
|
+
"bdi",
|
|
1169
|
+
"bdo",
|
|
1170
|
+
"br",
|
|
1171
|
+
"cite",
|
|
1172
|
+
"code",
|
|
1173
|
+
"del",
|
|
1174
|
+
"dfn",
|
|
1175
|
+
"em",
|
|
1176
|
+
"i",
|
|
1177
|
+
"img",
|
|
1178
|
+
"ins",
|
|
1179
|
+
"kbd",
|
|
1180
|
+
"mark",
|
|
1181
|
+
"q",
|
|
1182
|
+
"s",
|
|
1183
|
+
"samp",
|
|
1184
|
+
"small",
|
|
1185
|
+
"span",
|
|
1186
|
+
"strong",
|
|
1187
|
+
"sub",
|
|
1188
|
+
"sup",
|
|
1189
|
+
"time",
|
|
1190
|
+
"u",
|
|
1191
|
+
"var",
|
|
1192
|
+
"wbr"
|
|
1193
|
+
]);
|
|
1194
|
+
var SAFE_ATTRS = /* @__PURE__ */ new Set([
|
|
1195
|
+
"alt",
|
|
1196
|
+
"cite",
|
|
1197
|
+
"class",
|
|
1198
|
+
"datetime",
|
|
1199
|
+
"dir",
|
|
1200
|
+
"height",
|
|
1201
|
+
"href",
|
|
1202
|
+
"id",
|
|
1203
|
+
"lang",
|
|
1204
|
+
"loading",
|
|
1205
|
+
"rel",
|
|
1206
|
+
"role",
|
|
1207
|
+
"src",
|
|
1208
|
+
"target",
|
|
1209
|
+
"title",
|
|
1210
|
+
"width"
|
|
1211
|
+
]);
|
|
1212
|
+
var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "cite"]);
|
|
1213
|
+
var SAFE_STYLE_PROPS = /* @__PURE__ */ new Set([
|
|
1214
|
+
"color",
|
|
1215
|
+
"background-color",
|
|
1216
|
+
"font-family",
|
|
1217
|
+
"font-size",
|
|
1218
|
+
"font-style",
|
|
1219
|
+
"font-weight",
|
|
1220
|
+
"letter-spacing",
|
|
1221
|
+
"line-height",
|
|
1222
|
+
"text-decoration",
|
|
1223
|
+
"text-decoration-color",
|
|
1224
|
+
"text-decoration-line",
|
|
1225
|
+
"text-decoration-style",
|
|
1226
|
+
"text-transform",
|
|
1227
|
+
"vertical-align",
|
|
1228
|
+
"white-space",
|
|
1229
|
+
"word-spacing"
|
|
1230
|
+
]);
|
|
1231
|
+
var DANGEROUS_CSS_VALUE = /url\s*\(|expression\s*\(|image-set\s*\(|element\s*\(|paint\s*\(|javascript:|\\|[<>{}@]/i;
|
|
1232
|
+
var SAFE_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
|
|
1233
|
+
var NAMED_ENTITIES = {
|
|
1234
|
+
amp: "&",
|
|
1235
|
+
lt: "<",
|
|
1236
|
+
gt: ">",
|
|
1237
|
+
quot: '"',
|
|
1238
|
+
apos: "'",
|
|
1239
|
+
nbsp: "\xA0",
|
|
1240
|
+
colon: ":",
|
|
1241
|
+
semi: ";",
|
|
1242
|
+
sol: "/",
|
|
1243
|
+
bsol: "\\",
|
|
1244
|
+
Tab: " ",
|
|
1245
|
+
NewLine: "\n"
|
|
1246
|
+
};
|
|
1247
|
+
function decodeEntities(value) {
|
|
1248
|
+
return value.replace(
|
|
1249
|
+
/&(?:#[xX]([0-9a-fA-F]+);?|#(\d+);?|([a-zA-Z]+);)/g,
|
|
1250
|
+
(full, hex, dec, name) => {
|
|
1251
|
+
if (hex) return String.fromCodePoint(parseInt(hex, 16));
|
|
1252
|
+
if (dec) return String.fromCodePoint(parseInt(dec, 10));
|
|
1253
|
+
return NAMED_ENTITIES[name] ?? full;
|
|
1254
|
+
}
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
function escapeHtml(text) {
|
|
1258
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1259
|
+
}
|
|
1260
|
+
function sanitizeUrl(value) {
|
|
1261
|
+
const decoded = decodeEntities(value);
|
|
1262
|
+
const probe = decoded.replace(/[\u0000-\u0020\u007f]/g, "").toLowerCase();
|
|
1263
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe);
|
|
1264
|
+
if (!scheme) return value;
|
|
1265
|
+
return SAFE_SCHEMES.has(scheme[1]) ? value : null;
|
|
1266
|
+
}
|
|
1267
|
+
function sanitizeStyle(value) {
|
|
1268
|
+
return filterCssDeclarations(value, true);
|
|
1269
|
+
}
|
|
1270
|
+
function sanitizeCssDeclarations(value) {
|
|
1271
|
+
return filterCssDeclarations(value, false);
|
|
1272
|
+
}
|
|
1273
|
+
function filterCssDeclarations(value, requirePropAllowlist) {
|
|
1274
|
+
const decoded = decodeEntities(value);
|
|
1275
|
+
const kept = [];
|
|
1276
|
+
for (const declaration of decoded.split(";")) {
|
|
1277
|
+
const colon = declaration.indexOf(":");
|
|
1278
|
+
if (colon === -1) continue;
|
|
1279
|
+
const prop = declaration.slice(0, colon).trim().toLowerCase();
|
|
1280
|
+
const val = declaration.slice(colon + 1).trim();
|
|
1281
|
+
if (!/^[a-z-]+$/.test(prop)) continue;
|
|
1282
|
+
if (requirePropAllowlist && !SAFE_STYLE_PROPS.has(prop)) continue;
|
|
1283
|
+
if (val === "" || DANGEROUS_CSS_VALUE.test(val)) continue;
|
|
1284
|
+
kept.push(`${prop}: ${val}`);
|
|
1285
|
+
}
|
|
1286
|
+
return kept.length > 0 ? kept.join("; ") : null;
|
|
1287
|
+
}
|
|
1288
|
+
var ATTR_PATTERN = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g;
|
|
1289
|
+
function sanitizeOpeningTag(rawOpen) {
|
|
1290
|
+
if (!rawOpen.startsWith("<")) return null;
|
|
1291
|
+
const tagName = extractTagName(rawOpen);
|
|
1292
|
+
if (!/^[a-z][a-z0-9-]*$/.test(tagName) || !SAFE_TAGS.has(tagName)) {
|
|
1293
|
+
return null;
|
|
1294
|
+
}
|
|
1295
|
+
const end = rawOpen.endsWith("/>") ? rawOpen.length - 2 : rawOpen.endsWith(">") ? rawOpen.length - 1 : rawOpen.length;
|
|
1296
|
+
const attrSource = rawOpen.slice(1 + tagName.length, end);
|
|
1297
|
+
const attrs = {};
|
|
1298
|
+
for (const match of attrSource.matchAll(ATTR_PATTERN)) {
|
|
1299
|
+
const name = match[1].toLowerCase();
|
|
1300
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
1301
|
+
if (name in attrs) continue;
|
|
1302
|
+
if (name === "style") {
|
|
1303
|
+
const cleanStyle = sanitizeStyle(value);
|
|
1304
|
+
if (cleanStyle !== null) attrs[name] = cleanStyle;
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
const allowed = SAFE_ATTRS.has(name) || /^data-[a-z0-9-]*$/.test(name) || /^aria-[a-z0-9-]*$/.test(name);
|
|
1308
|
+
if (!allowed) continue;
|
|
1309
|
+
if (URL_ATTRS.has(name)) {
|
|
1310
|
+
if (sanitizeUrl(value) === null) continue;
|
|
1311
|
+
}
|
|
1312
|
+
attrs[name] = value;
|
|
1313
|
+
}
|
|
1314
|
+
return serializeOpeningTag(tagName, attrs);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1163
1317
|
// src/content/serializer/HTMLSerializer.ts
|
|
1164
1318
|
var HTMLSerializer = class {
|
|
1165
1319
|
serialize(unit) {
|
|
@@ -1169,24 +1323,25 @@ var LionRapidEditor = (() => {
|
|
|
1169
1323
|
let lastIndex = 0;
|
|
1170
1324
|
let match;
|
|
1171
1325
|
while ((match = tagPattern.exec(text)) !== null) {
|
|
1172
|
-
result += text.slice(lastIndex, match.index);
|
|
1326
|
+
result += escapeHtml(text.slice(lastIndex, match.index));
|
|
1173
1327
|
const [fullMatch, closingSlash, indexStr] = match;
|
|
1174
1328
|
const styleList = styles[indexStr] ?? [];
|
|
1175
1329
|
const rawOpen = readRawStyle(styleList);
|
|
1176
|
-
|
|
1330
|
+
const safeOpen = rawOpen === null ? null : sanitizeOpeningTag(rawOpen);
|
|
1331
|
+
if (rawOpen && safeOpen !== null) {
|
|
1177
1332
|
const tagName = extractTagName(rawOpen);
|
|
1178
1333
|
const isVoid = VOID_TAGS.has(tagName);
|
|
1179
1334
|
if (closingSlash) {
|
|
1180
1335
|
if (!isVoid) result += `</${tagName}>`;
|
|
1181
1336
|
} else {
|
|
1182
|
-
result +=
|
|
1337
|
+
result += safeOpen;
|
|
1183
1338
|
}
|
|
1184
1339
|
} else {
|
|
1185
1340
|
result += fullMatch;
|
|
1186
1341
|
}
|
|
1187
1342
|
lastIndex = match.index + fullMatch.length;
|
|
1188
1343
|
}
|
|
1189
|
-
result += text.slice(lastIndex);
|
|
1344
|
+
result += escapeHtml(text.slice(lastIndex));
|
|
1190
1345
|
return result;
|
|
1191
1346
|
}
|
|
1192
1347
|
};
|
|
@@ -1236,1249 +1391,1358 @@ var LionRapidEditor = (() => {
|
|
|
1236
1391
|
}
|
|
1237
1392
|
}
|
|
1238
1393
|
|
|
1239
|
-
// src/browser/
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
__publicField(this, "
|
|
1256
|
-
|
|
1257
|
-
|
|
1394
|
+
// src/browser/editor/editor-api.ts
|
|
1395
|
+
function isRowAtPostreview(info, stageIndex) {
|
|
1396
|
+
return info !== null && typeof stageIndex === "number" && stageIndex >= info.workStageCount;
|
|
1397
|
+
}
|
|
1398
|
+
var _EditorAPI = class _EditorAPI {
|
|
1399
|
+
constructor(apiBase, sessionToken, projectId) {
|
|
1400
|
+
__publicField(this, "queue", []);
|
|
1401
|
+
__publicField(this, "apiBase");
|
|
1402
|
+
__publicField(this, "sessionToken");
|
|
1403
|
+
__publicField(this, "projectId");
|
|
1404
|
+
__publicField(this, "flushInterval", null);
|
|
1405
|
+
__publicField(this, "renewalInterval", null);
|
|
1406
|
+
__publicField(this, "onStatus", () => {
|
|
1407
|
+
});
|
|
1408
|
+
__publicField(this, "onFlushed", () => {
|
|
1409
|
+
});
|
|
1410
|
+
__publicField(this, "onSessionExpired", null);
|
|
1411
|
+
// Workflow context cache — keyed by locale (a per-target override can give
|
|
1412
|
+
// one locale a different workflow) and TTL-bound (the PM can change the
|
|
1413
|
+
// project's workflow from the dashboard mid-session; a boundary pinned at
|
|
1414
|
+
// first fetch would render buttons the server refuses). Promises are
|
|
1415
|
+
// cached so concurrent callers share one in-flight fetch.
|
|
1416
|
+
__publicField(this, "workflowInfoCache", /* @__PURE__ */ new Map());
|
|
1417
|
+
/** Whether this session may lazily create rows for keys with no target row
|
|
1418
|
+
* yet. Mirrors the server's create gate (creation is stage-0 work — PM/admin
|
|
1419
|
+
* or stage-0 assignees); refreshed from targets/status `can_create`.
|
|
1420
|
+
* Default true: older backends don't send it and don't gate creates. */
|
|
1421
|
+
__publicField(this, "canCreateMissing", true);
|
|
1422
|
+
/** True while this locale's target has an MT fill queued/running (refreshed
|
|
1423
|
+
* from targets/status `target_busy`). canEdit stays the permission truth
|
|
1424
|
+
* during the window; stage transitions would 423 until it clears, and
|
|
1425
|
+
* saves are denied as "busy" and auto-retried by the flush loop. */
|
|
1426
|
+
__publicField(this, "targetBusy", false);
|
|
1427
|
+
__publicField(this, "busyRetryTimer", null);
|
|
1428
|
+
__publicField(this, "statusAbort", null);
|
|
1429
|
+
__publicField(this, "handleVisibility", () => {
|
|
1430
|
+
if (document.visibilityState === "hidden") {
|
|
1431
|
+
this.flush();
|
|
1432
|
+
} else if (document.visibilityState === "visible") {
|
|
1433
|
+
this.maybeRenewSession();
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
this.apiBase = apiBase.replace(/\/$/, "");
|
|
1437
|
+
this.sessionToken = sessionToken;
|
|
1438
|
+
this.projectId = projectId;
|
|
1439
|
+
}
|
|
1440
|
+
setStatusCallback(cb) {
|
|
1441
|
+
this.onStatus = cb;
|
|
1442
|
+
}
|
|
1443
|
+
setFlushedCallback(cb) {
|
|
1444
|
+
this.onFlushed = cb;
|
|
1445
|
+
}
|
|
1446
|
+
setSessionExpiredCallback(cb) {
|
|
1447
|
+
this.onSessionExpired = cb;
|
|
1258
1448
|
}
|
|
1259
1449
|
/**
|
|
1260
|
-
*
|
|
1450
|
+
* Start auto-flush (30s interval + visibilitychange) and session renewal (60s check)
|
|
1261
1451
|
*/
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
return true;
|
|
1452
|
+
startAutoFlush() {
|
|
1453
|
+
this.flushInterval = setInterval(() => this.flush(), 3e4);
|
|
1454
|
+
document.addEventListener("visibilitychange", this.handleVisibility);
|
|
1455
|
+
this.renewalInterval = setInterval(() => this.maybeRenewSession(), 6e4);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Stop auto-flush and flush remaining items
|
|
1459
|
+
*/
|
|
1460
|
+
async stopAndFlush() {
|
|
1461
|
+
if (this.flushInterval) {
|
|
1462
|
+
clearInterval(this.flushInterval);
|
|
1463
|
+
this.flushInterval = null;
|
|
1275
1464
|
}
|
|
1276
|
-
if (this.
|
|
1277
|
-
|
|
1465
|
+
if (this.renewalInterval) {
|
|
1466
|
+
clearInterval(this.renewalInterval);
|
|
1467
|
+
this.renewalInterval = null;
|
|
1278
1468
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
};
|
|
1282
|
-
|
|
1283
|
-
// src/browser/block-extractor.ts
|
|
1284
|
-
var BLOCK_SELECTOR3 = "p,h1,h2,h3,h4,h5,h6,li,td,th,label,button,figcaption,dt,dd,summary,caption";
|
|
1285
|
-
var INLINE_BLOCK_SELECTOR = "a";
|
|
1286
|
-
var SKIP_TAGS2 = /* @__PURE__ */ new Set([
|
|
1287
|
-
"SCRIPT",
|
|
1288
|
-
"STYLE",
|
|
1289
|
-
"CODE",
|
|
1290
|
-
"PRE",
|
|
1291
|
-
"TEXTAREA",
|
|
1292
|
-
"NOSCRIPT",
|
|
1293
|
-
"SVG"
|
|
1294
|
-
]);
|
|
1295
|
-
var SKIP_ATTRIBUTES = [
|
|
1296
|
-
"data-lionrapid-translated",
|
|
1297
|
-
// Server already translated
|
|
1298
|
-
"data-lionrapid-client",
|
|
1299
|
-
// Client already translated
|
|
1300
|
-
"data-no-translate",
|
|
1301
|
-
// Explicitly marked to skip
|
|
1302
|
-
// Edge proxy already rendered this block with the correct translation
|
|
1303
|
-
// and styles. Skip in normal extraction so the client-side auto-translator
|
|
1304
|
-
// can't overwrite the styled DOM with a stale (or plain-text) cache entry.
|
|
1305
|
-
// The editor passes includeTranslated=true to bypass this and re-extract
|
|
1306
|
-
// edge-rendered blocks for inspection.
|
|
1307
|
-
"data-lr-block"
|
|
1308
|
-
];
|
|
1309
|
-
var BlockExtractor = class {
|
|
1310
|
-
constructor(excludePatterns = {}, excludeSelectors = [], minTextLength = 2) {
|
|
1311
|
-
__publicField(this, "excludeFilter");
|
|
1312
|
-
__publicField(this, "excludeSelectors");
|
|
1313
|
-
this.excludeFilter = new ExcludeFilter(excludePatterns, minTextLength);
|
|
1314
|
-
this.excludeSelectors = excludeSelectors;
|
|
1469
|
+
document.removeEventListener("visibilitychange", this.handleVisibility);
|
|
1470
|
+
await this.flush();
|
|
1315
1471
|
}
|
|
1316
1472
|
/**
|
|
1317
|
-
*
|
|
1318
|
-
* @param includeTranslated - If true, include elements already marked as translated (for editor mode)
|
|
1473
|
+
* Queue a translation for saving
|
|
1319
1474
|
*/
|
|
1320
|
-
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
if (
|
|
1325
|
-
|
|
1475
|
+
queueSave(item) {
|
|
1476
|
+
const idx = this.queue.findIndex(
|
|
1477
|
+
(q) => q.key === item.key && q.locale === item.locale && q.namespace === item.namespace
|
|
1478
|
+
);
|
|
1479
|
+
if (idx >= 0) {
|
|
1480
|
+
this.queue[idx] = item;
|
|
1481
|
+
} else {
|
|
1482
|
+
this.queue.push(item);
|
|
1326
1483
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1484
|
+
this.onStatus("unsaved", `${this.queue.length} unsaved`);
|
|
1485
|
+
if (this.queue.length > 50) {
|
|
1486
|
+
this.flush();
|
|
1329
1487
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Flush all queued translations to the server
|
|
1491
|
+
*/
|
|
1492
|
+
async flush() {
|
|
1493
|
+
if (this.queue.length === 0) return;
|
|
1494
|
+
const items = [...this.queue];
|
|
1495
|
+
this.onStatus("saving");
|
|
1496
|
+
try {
|
|
1497
|
+
const response = await this.authFetch(
|
|
1498
|
+
`${this.apiBase}/api/integrations/targets/batch`,
|
|
1499
|
+
{
|
|
1500
|
+
method: "PUT",
|
|
1501
|
+
headers: { "Content-Type": "application/json" },
|
|
1502
|
+
body: JSON.stringify({
|
|
1503
|
+
translations: items.map((t) => ({
|
|
1504
|
+
key: `${t.locale}:${t.namespace}:${t.key}`,
|
|
1505
|
+
value: { text: t.text, styles: t.styles }
|
|
1506
|
+
}))
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
);
|
|
1510
|
+
if (response.status === 401) {
|
|
1511
|
+
this.handleSessionExpired();
|
|
1512
|
+
return;
|
|
1343
1513
|
}
|
|
1344
|
-
if (
|
|
1345
|
-
|
|
1514
|
+
if (!response.ok) {
|
|
1515
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1346
1516
|
}
|
|
1347
|
-
const
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1517
|
+
const body = await response.json().catch(() => null);
|
|
1518
|
+
const deniedRows = body?.denied_rows ?? [];
|
|
1519
|
+
const busyKeys = new Set(
|
|
1520
|
+
deniedRows.filter((d) => d.reason === "busy").map((d) => d.key)
|
|
1521
|
+
);
|
|
1522
|
+
const hardDenied = deniedRows.filter((d) => d.reason !== "busy");
|
|
1523
|
+
const retained = [];
|
|
1524
|
+
for (const item of items) {
|
|
1525
|
+
const idx = this.queue.indexOf(item);
|
|
1526
|
+
if (busyKeys.has(`${item.locale}:${item.namespace}:${item.key}`)) {
|
|
1527
|
+
retained.push(item);
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (idx >= 0) this.queue.splice(idx, 1);
|
|
1356
1531
|
}
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1532
|
+
this.onFlushed(
|
|
1533
|
+
items.filter((i) => !busyKeys.has(`${i.locale}:${i.namespace}:${i.key}`)).map((i) => i.key)
|
|
1534
|
+
);
|
|
1535
|
+
if (retained.length > 0) {
|
|
1536
|
+
this.scheduleBusyRetry();
|
|
1360
1537
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1538
|
+
if (hardDenied.length > 0) {
|
|
1539
|
+
const lockedCount = hardDenied.filter((d) => d.reason === "locked").length;
|
|
1540
|
+
const message = hardDenied.length === 1 ? lockedCount === 1 ? "1 change not saved \u2014 row is locked (only a PM can edit it)" : "1 change not saved \u2014 you are not assigned to that row" : `${hardDenied.length} changes not saved \u2014 locked or not assigned to you`;
|
|
1541
|
+
this.onStatus("error", message);
|
|
1542
|
+
} else if (retained.length > 0) {
|
|
1543
|
+
this.onStatus(
|
|
1544
|
+
"unsaved",
|
|
1545
|
+
`${retained.length} waiting \u2014 machine translation is filling this page, retrying shortly`
|
|
1546
|
+
);
|
|
1547
|
+
} else if (this.queue.length === 0) {
|
|
1548
|
+
this.onStatus("saved", "All changes saved");
|
|
1364
1549
|
} else {
|
|
1365
|
-
|
|
1366
|
-
if (dataI18n) {
|
|
1367
|
-
key = dataI18n.includes(":") ? dataI18n : `common:${dataI18n}`;
|
|
1368
|
-
} else {
|
|
1369
|
-
key = generateAutoKey(unit.text);
|
|
1370
|
-
}
|
|
1550
|
+
this.onStatus("unsaved", `${this.queue.length} unsaved`);
|
|
1371
1551
|
}
|
|
1372
|
-
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
this.onStatus("error", `Save failed: ${err.message}`);
|
|
1373
1554
|
}
|
|
1374
|
-
this.extractOrphanInline(root, results, includeTranslated);
|
|
1375
|
-
return results;
|
|
1376
1555
|
}
|
|
1377
1556
|
/**
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
* them as `<1>...</1>`). Common shape: a `<div>` action group containing
|
|
1382
|
-
* only links.
|
|
1557
|
+
* Re-flush edits refused with reason "busy" (target MT fill in flight).
|
|
1558
|
+
* One pipeline sweep (~30 s worst case) clears the fill; a single pending
|
|
1559
|
+
* timer is enough — each retry re-schedules itself while busy rows remain.
|
|
1383
1560
|
*/
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
}
|
|
1390
|
-
for (let i = 0; i < candidates.length; i++) {
|
|
1391
|
-
elements.push(candidates[i]);
|
|
1392
|
-
}
|
|
1393
|
-
for (let i = 0; i < elements.length; i++) {
|
|
1394
|
-
const el = elements[i];
|
|
1395
|
-
if (this.isInsideSkipTag(el)) continue;
|
|
1396
|
-
if (!includeTranslated && this.hasSkipAttribute(el)) continue;
|
|
1397
|
-
if (this.matchesExcludeSelector(el)) continue;
|
|
1398
|
-
if (el.getAttribute("translate") === "no") continue;
|
|
1399
|
-
if (el.parentElement?.closest(BLOCK_SELECTOR3)) continue;
|
|
1400
|
-
if (el.parentElement?.closest(INLINE_BLOCK_SELECTOR)) continue;
|
|
1401
|
-
if (el.querySelector(BLOCK_SELECTOR3)) continue;
|
|
1402
|
-
const lrOriginal = el.getAttribute("data-lr-original");
|
|
1403
|
-
let unit;
|
|
1404
|
-
if (lrOriginal) {
|
|
1405
|
-
const temp = document.createElement(el.tagName);
|
|
1406
|
-
temp.innerHTML = lrOriginal;
|
|
1407
|
-
unit = parseBlockElement(temp);
|
|
1408
|
-
} else {
|
|
1409
|
-
unit = parseBlockElement(el);
|
|
1410
|
-
}
|
|
1411
|
-
const strippedText = unit.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
1412
|
-
if (this.excludeFilter.shouldExclude(strippedText)) continue;
|
|
1413
|
-
const lrBlock = el.getAttribute("data-lr-block");
|
|
1414
|
-
let key;
|
|
1415
|
-
if (lrBlock) {
|
|
1416
|
-
key = lrBlock;
|
|
1417
|
-
} else {
|
|
1418
|
-
const dataI18n = el.getAttribute("data-i18n");
|
|
1419
|
-
if (dataI18n) {
|
|
1420
|
-
key = dataI18n.includes(":") ? dataI18n : `common:${dataI18n}`;
|
|
1421
|
-
} else {
|
|
1422
|
-
key = generateAutoKey(unit.text);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
results.push({ element: el, unit, key });
|
|
1426
|
-
}
|
|
1561
|
+
scheduleBusyRetry() {
|
|
1562
|
+
if (this.busyRetryTimer) return;
|
|
1563
|
+
this.busyRetryTimer = setTimeout(() => {
|
|
1564
|
+
this.busyRetryTimer = null;
|
|
1565
|
+
this.flush();
|
|
1566
|
+
}, 8e3);
|
|
1427
1567
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1568
|
+
/**
|
|
1569
|
+
* Fetch translation status for a batch of keys (includes unconfirmed rows).
|
|
1570
|
+
* Cancels any in-flight status request. Caps at 200 keys per request.
|
|
1571
|
+
*/
|
|
1572
|
+
async fetchStatus(keys, locale, namespace) {
|
|
1573
|
+
if (keys.length === 0) return {};
|
|
1574
|
+
if (this.statusAbort) this.statusAbort.abort();
|
|
1575
|
+
this.statusAbort = new AbortController();
|
|
1576
|
+
const batch = keys.slice(0, 200);
|
|
1577
|
+
const params = new URLSearchParams({ locale, namespace, keys: batch.join(",") });
|
|
1578
|
+
try {
|
|
1579
|
+
const response = await this.authFetch(
|
|
1580
|
+
`${this.apiBase}/api/integrations/targets/status?${params}`,
|
|
1581
|
+
{ signal: this.statusAbort.signal }
|
|
1582
|
+
);
|
|
1583
|
+
if (response.status === 401) {
|
|
1584
|
+
this.handleSessionExpired();
|
|
1585
|
+
return {};
|
|
1433
1586
|
}
|
|
1434
|
-
|
|
1587
|
+
if (!response.ok) return {};
|
|
1588
|
+
const data = await response.json();
|
|
1589
|
+
this.canCreateMissing = data.can_create !== false;
|
|
1590
|
+
this.targetBusy = data.target_busy === true;
|
|
1591
|
+
return data.translations;
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
if (err.name === "AbortError") return {};
|
|
1594
|
+
return {};
|
|
1435
1595
|
}
|
|
1436
|
-
return false;
|
|
1437
1596
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1597
|
+
get pendingCount() {
|
|
1598
|
+
return this.queue.length;
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Fetch workflow context (stages, user's stage assignments, PM/admin flag,
|
|
1602
|
+
* advance boundary) for the EFFECTIVE workflow of `locale` — the server
|
|
1603
|
+
* resolves the per-target override (`target ?? project`) when a locale is
|
|
1604
|
+
* given. Cached per locale with a 5-min TTL so a mid-session workflow
|
|
1605
|
+
* change from the dashboard settings is picked up without a page reload.
|
|
1606
|
+
* Returns null if the fetch fails; callers should hide the strip in that case.
|
|
1607
|
+
*/
|
|
1608
|
+
async fetchWorkflowInfo(locale) {
|
|
1609
|
+
const cacheKey = locale ?? "";
|
|
1610
|
+
const cached = this.workflowInfoCache.get(cacheKey);
|
|
1611
|
+
if (cached && Date.now() - cached.fetchedAt < _EditorAPI.WORKFLOW_INFO_TTL_MS) {
|
|
1612
|
+
return cached.promise;
|
|
1613
|
+
}
|
|
1614
|
+
const promise = (async () => {
|
|
1615
|
+
try {
|
|
1616
|
+
const url = `${this.apiBase}/api/integrations/editor/workflow` + (locale ? `?locale=${encodeURIComponent(locale)}` : "");
|
|
1617
|
+
const response = await this.authFetch(url);
|
|
1618
|
+
if (response.status === 401) {
|
|
1619
|
+
this.handleSessionExpired();
|
|
1620
|
+
return null;
|
|
1444
1621
|
}
|
|
1622
|
+
if (!response.ok) return null;
|
|
1623
|
+
const data = await response.json();
|
|
1624
|
+
const stages = (Array.isArray(data.stages) ? data.stages : []).map(
|
|
1625
|
+
(s) => ({
|
|
1626
|
+
index: s.index,
|
|
1627
|
+
name: s.name,
|
|
1628
|
+
role: s.role ?? null,
|
|
1629
|
+
isPostreview: Boolean(s.is_postreview)
|
|
1630
|
+
})
|
|
1631
|
+
);
|
|
1632
|
+
return {
|
|
1633
|
+
stages,
|
|
1634
|
+
userAssignedStages: Array.isArray(data.user_assigned_stages) ? data.user_assigned_stages : [],
|
|
1635
|
+
isPmAdmin: Boolean(data.is_pm_admin),
|
|
1636
|
+
workflowMode: data.workflow_mode === "per_file" ? "per_file" : "per_row",
|
|
1637
|
+
// Older backends don't send work_stage_count; before Postreview
|
|
1638
|
+
// stages existed the boundary was len(stages), so fall back to that.
|
|
1639
|
+
workStageCount: typeof data.work_stage_count === "number" ? data.work_stage_count : stages.length
|
|
1640
|
+
};
|
|
1641
|
+
} catch {
|
|
1642
|
+
return null;
|
|
1445
1643
|
}
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1448
|
-
return
|
|
1644
|
+
})();
|
|
1645
|
+
this.workflowInfoCache.set(cacheKey, { promise, fetchedAt: Date.now() });
|
|
1646
|
+
return promise;
|
|
1449
1647
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1648
|
+
// ============================================
|
|
1649
|
+
// Session renewal
|
|
1650
|
+
// ============================================
|
|
1651
|
+
/**
|
|
1652
|
+
* Check if session is within 5min of expiry, and renew if so.
|
|
1653
|
+
*
|
|
1654
|
+
* The threshold is intentionally generous because background tabs throttle
|
|
1655
|
+
* setInterval to once-per-minute (Chrome) or even less aggressively, and
|
|
1656
|
+
* a sleeping laptop pauses timers entirely. Renewing early gives us
|
|
1657
|
+
* headroom for those cases — the renewal interval still runs before the
|
|
1658
|
+
* 15min server-side TTL elapses even if it fires a minute or two late.
|
|
1659
|
+
*/
|
|
1660
|
+
async maybeRenewSession() {
|
|
1661
|
+
const exp = this.getSessionExp();
|
|
1662
|
+
if (!exp) return;
|
|
1663
|
+
const timeLeft = exp * 1e3 - Date.now();
|
|
1664
|
+
if (timeLeft > 3e5) return;
|
|
1665
|
+
await this.renewSession();
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Attempt one renewal and signal whether the token actually changed.
|
|
1669
|
+
* Used by the 401-retry path in API calls.
|
|
1670
|
+
*/
|
|
1671
|
+
async tryRenew() {
|
|
1672
|
+
const before = this.sessionToken;
|
|
1673
|
+
await this.renewSession();
|
|
1674
|
+
return this.sessionToken !== before;
|
|
1675
|
+
}
|
|
1676
|
+
async renewSession() {
|
|
1677
|
+
try {
|
|
1678
|
+
const response = await fetch(`${this.apiBase}/api/editor/renew`, {
|
|
1679
|
+
method: "POST",
|
|
1680
|
+
headers: {
|
|
1681
|
+
"Authorization": `Bearer ${this.sessionToken}`
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
if (response.status === 401) {
|
|
1685
|
+
this.handleSessionExpired();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (!response.ok) return;
|
|
1689
|
+
const data = await response.json();
|
|
1690
|
+
this.sessionToken = data.session_token;
|
|
1452
1691
|
try {
|
|
1453
|
-
|
|
1454
|
-
|
|
1692
|
+
const raw = this.sessionToken.startsWith("es_") ? this.sessionToken.slice(3) : this.sessionToken;
|
|
1693
|
+
const parts = raw.split(".");
|
|
1694
|
+
if (parts.length === 3) {
|
|
1695
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
1696
|
+
sessionStorage.setItem("__lr_editor_session", JSON.stringify({
|
|
1697
|
+
token: this.sessionToken,
|
|
1698
|
+
exp: payload.exp || 0
|
|
1699
|
+
}));
|
|
1700
|
+
document.cookie = "__lr_editor_active=1; path=/; SameSite=Lax";
|
|
1455
1701
|
}
|
|
1456
1702
|
} catch {
|
|
1457
1703
|
}
|
|
1704
|
+
} catch {
|
|
1458
1705
|
}
|
|
1459
|
-
return false;
|
|
1460
1706
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1707
|
+
getSessionExp() {
|
|
1708
|
+
try {
|
|
1709
|
+
const raw = this.sessionToken.startsWith("es_") ? this.sessionToken.slice(3) : this.sessionToken;
|
|
1710
|
+
const parts = raw.split(".");
|
|
1711
|
+
if (parts.length !== 3) return null;
|
|
1712
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
1713
|
+
return payload.exp || null;
|
|
1714
|
+
} catch {
|
|
1715
|
+
return null;
|
|
1716
|
+
}
|
|
1470
1717
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
__publicField(this, "blockExtractor");
|
|
1493
|
-
__publicField(this, "onBlockClick");
|
|
1494
|
-
__publicField(this, "statusMap", /* @__PURE__ */ new Map());
|
|
1495
|
-
__publicField(this, "unsavedKeys", /* @__PURE__ */ new Set());
|
|
1496
|
-
__publicField(this, "onProgressUpdate", null);
|
|
1497
|
-
__publicField(this, "lastFetchTime", 0);
|
|
1498
|
-
__publicField(this, "lastFlushTime", 0);
|
|
1499
|
-
__publicField(this, "userRole");
|
|
1500
|
-
// Project workflow context — fetched once on first render, cached for the
|
|
1501
|
-
// sidebar's lifetime. Workflow shape is stable per editor session.
|
|
1502
|
-
__publicField(this, "workflowInfo", null);
|
|
1503
|
-
__publicField(this, "workflowInfoFetched", false);
|
|
1504
|
-
this.api = api;
|
|
1505
|
-
this.locale = locale;
|
|
1506
|
-
this.namespace = namespace;
|
|
1507
|
-
this.defaultLocale = defaultLocale;
|
|
1508
|
-
this.onBlockClick = onBlockClick;
|
|
1509
|
-
this.userRole = userRole;
|
|
1510
|
-
this.blockExtractor = new BlockExtractor();
|
|
1511
|
-
this.el = document.createElement("div");
|
|
1512
|
-
this.el.className = "lr-sidebar";
|
|
1513
|
-
const resizeHandle = document.createElement("div");
|
|
1514
|
-
resizeHandle.className = "lr-sidebar-resize";
|
|
1515
|
-
this.initResize(resizeHandle);
|
|
1516
|
-
this.el.appendChild(resizeHandle);
|
|
1517
|
-
const header = document.createElement("div");
|
|
1518
|
-
header.className = "lr-sidebar-header";
|
|
1519
|
-
const title = document.createElement("span");
|
|
1520
|
-
title.className = "lr-sidebar-title";
|
|
1521
|
-
title.textContent = "Edit Translation";
|
|
1522
|
-
header.appendChild(title);
|
|
1523
|
-
const closeBtn = document.createElement("button");
|
|
1524
|
-
closeBtn.className = "lr-sidebar-close";
|
|
1525
|
-
closeBtn.innerHTML = "×";
|
|
1526
|
-
closeBtn.addEventListener("click", () => this.close());
|
|
1527
|
-
header.appendChild(closeBtn);
|
|
1528
|
-
this.el.appendChild(header);
|
|
1529
|
-
this.bodyEl = document.createElement("div");
|
|
1530
|
-
this.bodyEl.className = "lr-sidebar-body";
|
|
1531
|
-
this.el.appendChild(this.bodyEl);
|
|
1532
|
-
this.statusEl = document.createElement("div");
|
|
1533
|
-
this.statusEl.className = "lr-status";
|
|
1534
|
-
this.api.setStatusCallback((status, message) => {
|
|
1535
|
-
this.statusEl.textContent = message || status;
|
|
1536
|
-
this.statusEl.className = `lr-status ${status}`;
|
|
1537
|
-
});
|
|
1538
|
-
this.api.setFlushedCallback(async (keys) => {
|
|
1539
|
-
this.lastFlushTime = Date.now();
|
|
1540
|
-
const freshStatuses = await this.api.fetchStatus(keys, this.locale, this.namespace);
|
|
1541
|
-
for (const [key, status] of Object.entries(freshStatuses)) {
|
|
1542
|
-
this.statusMap.set(key, status);
|
|
1543
|
-
this.unsavedKeys.delete(key);
|
|
1544
|
-
}
|
|
1545
|
-
this.refreshBlocksList();
|
|
1546
|
-
this.emitProgress();
|
|
1547
|
-
});
|
|
1548
|
-
if (typeof window !== "undefined") {
|
|
1549
|
-
window.__lr_dump_keys = () => {
|
|
1550
|
-
const blocks = this.blockExtractor.extract(document.body, true);
|
|
1551
|
-
const rows = blocks.map((b, i) => {
|
|
1552
|
-
const s = this.statusMap.get(b.key);
|
|
1553
|
-
return {
|
|
1554
|
-
i,
|
|
1555
|
-
key: b.key,
|
|
1556
|
-
text: b.unit.text,
|
|
1557
|
-
stylesJson: JSON.stringify(b.unit.styles),
|
|
1558
|
-
statusText: s?.text ?? "",
|
|
1559
|
-
isReady: !!s?.isReady,
|
|
1560
|
-
hasTranslation: !!s?.text
|
|
1561
|
-
};
|
|
1562
|
-
});
|
|
1563
|
-
console.table(rows);
|
|
1564
|
-
return rows;
|
|
1565
|
-
};
|
|
1566
|
-
window.__lr_editor_edit = (key, text) => {
|
|
1567
|
-
const blocks = this.blockExtractor.extract(document.body, true);
|
|
1568
|
-
const block = blocks.find((b) => b.key === key);
|
|
1569
|
-
if (!block) throw new Error(`[__lr_editor_edit] no block on page with key ${key}`);
|
|
1570
|
-
this.unsavedKeys.add(key);
|
|
1571
|
-
this.api.queueSave({
|
|
1572
|
-
key,
|
|
1573
|
-
locale: this.locale,
|
|
1574
|
-
namespace: this.namespace,
|
|
1575
|
-
text,
|
|
1576
|
-
styles: block.unit.styles
|
|
1577
|
-
});
|
|
1578
|
-
};
|
|
1579
|
-
window.__lr_editor_flush = () => this.api.flush();
|
|
1718
|
+
/**
|
|
1719
|
+
* Authenticated fetch with one-shot 401 retry. If the request comes back
|
|
1720
|
+
* 401, we attempt a session renewal and replay the request once with the
|
|
1721
|
+
* new token. If renewal fails (token past the server's 2min grace), the
|
|
1722
|
+
* caller still gets a 401 and falls through to handleSessionExpired.
|
|
1723
|
+
*
|
|
1724
|
+
* Always passes `init.signal` straight through so abortable callers
|
|
1725
|
+
* (e.g. fetchStatus) still cancel correctly.
|
|
1726
|
+
*/
|
|
1727
|
+
async authFetch(url, init = {}) {
|
|
1728
|
+
const buildHeaders = () => {
|
|
1729
|
+
const h = new Headers(init.headers);
|
|
1730
|
+
h.set("Authorization", `Bearer ${this.sessionToken}`);
|
|
1731
|
+
return h;
|
|
1732
|
+
};
|
|
1733
|
+
let response = await fetch(url, { ...init, headers: buildHeaders() });
|
|
1734
|
+
if (response.status === 401) {
|
|
1735
|
+
const renewed = await this.tryRenew();
|
|
1736
|
+
if (renewed) {
|
|
1737
|
+
response = await fetch(url, { ...init, headers: buildHeaders() });
|
|
1738
|
+
}
|
|
1580
1739
|
}
|
|
1581
|
-
|
|
1582
|
-
}
|
|
1583
|
-
open(resolved) {
|
|
1584
|
-
this.current = resolved;
|
|
1585
|
-
this.el.classList.add("open");
|
|
1586
|
-
this.renderContent(resolved);
|
|
1587
|
-
}
|
|
1588
|
-
close() {
|
|
1589
|
-
this.current = null;
|
|
1590
|
-
this.el.classList.remove("open");
|
|
1591
|
-
}
|
|
1592
|
-
isOpen() {
|
|
1593
|
-
return this.el.classList.contains("open");
|
|
1594
|
-
}
|
|
1595
|
-
updateLocale(locale) {
|
|
1596
|
-
this.locale = locale;
|
|
1597
|
-
this.statusMap.clear();
|
|
1598
|
-
this.unsavedKeys.clear();
|
|
1740
|
+
return response;
|
|
1599
1741
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1742
|
+
handleSessionExpired() {
|
|
1743
|
+
document.cookie = "__lr_editor_active=; path=/; SameSite=Lax; Max-Age=0";
|
|
1744
|
+
this.onStatus("error", "Session expired. Please reopen the editor from the dashboard.");
|
|
1745
|
+
if (this.onSessionExpired) {
|
|
1746
|
+
this.onSessionExpired();
|
|
1747
|
+
}
|
|
1602
1748
|
}
|
|
1749
|
+
// ============================================
|
|
1750
|
+
// Snapshot API
|
|
1751
|
+
// ============================================
|
|
1603
1752
|
/**
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1753
|
+
* Upload a captured snapshot. Returns the created snapshot's UUID so the
|
|
1754
|
+
* caller can build a share URL (`?__lr_snapshot={uuid}`).
|
|
1606
1755
|
*/
|
|
1607
|
-
async
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1756
|
+
async createSnapshot(snapshot) {
|
|
1757
|
+
try {
|
|
1758
|
+
const response = await fetch(`${this.apiBase}/api/editor/snapshots`, {
|
|
1759
|
+
method: "POST",
|
|
1760
|
+
headers: {
|
|
1761
|
+
"Content-Type": "application/json",
|
|
1762
|
+
"Authorization": `Bearer ${this.sessionToken}`
|
|
1763
|
+
},
|
|
1764
|
+
body: JSON.stringify({
|
|
1765
|
+
pageUrl: snapshot.pageUrl,
|
|
1766
|
+
title: snapshot.title,
|
|
1767
|
+
viewport: snapshot.viewport,
|
|
1768
|
+
textEntries: snapshot.textEntries,
|
|
1769
|
+
html: snapshot.html
|
|
1770
|
+
})
|
|
1771
|
+
});
|
|
1772
|
+
if (response.status === 401) {
|
|
1773
|
+
this.handleSessionExpired();
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
if (!response.ok) {
|
|
1777
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1778
|
+
}
|
|
1779
|
+
const data = await response.json();
|
|
1780
|
+
return { snapshotUuid: data.snapshot_uuid || data.snapshotUuid };
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
this.onStatus("error", `Snapshot failed: ${err.message}`);
|
|
1783
|
+
return null;
|
|
1614
1784
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Re-capture an existing snapshot. Server appends a new version and keeps
|
|
1788
|
+
* the same `snapshotUuid`. Returns the new version number on success.
|
|
1789
|
+
*/
|
|
1790
|
+
async recaptureSnapshot(snapshotUuid, snapshot) {
|
|
1791
|
+
try {
|
|
1792
|
+
const response = await fetch(
|
|
1793
|
+
`${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
|
|
1794
|
+
{
|
|
1795
|
+
method: "PUT",
|
|
1796
|
+
headers: {
|
|
1797
|
+
"Content-Type": "application/json",
|
|
1798
|
+
"Authorization": `Bearer ${this.sessionToken}`
|
|
1799
|
+
},
|
|
1800
|
+
body: JSON.stringify({
|
|
1801
|
+
pageUrl: snapshot.pageUrl,
|
|
1802
|
+
title: snapshot.title,
|
|
1803
|
+
viewport: snapshot.viewport,
|
|
1804
|
+
textEntries: snapshot.textEntries,
|
|
1805
|
+
html: snapshot.html
|
|
1806
|
+
})
|
|
1629
1807
|
}
|
|
1808
|
+
);
|
|
1809
|
+
if (response.status === 401) {
|
|
1810
|
+
this.handleSessionExpired();
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
if (!response.ok) {
|
|
1814
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1630
1815
|
}
|
|
1816
|
+
const data = await response.json();
|
|
1817
|
+
return {
|
|
1818
|
+
snapshotUuid: data.snapshot_uuid || data.snapshotUuid,
|
|
1819
|
+
version: data.version || 1
|
|
1820
|
+
};
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
this.onStatus("error", `Re-capture failed: ${err.message}`);
|
|
1823
|
+
return null;
|
|
1631
1824
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Fetch a single snapshot by UUID. Returns null on auth failure or 404.
|
|
1828
|
+
*/
|
|
1829
|
+
async getSnapshot(snapshotUuid) {
|
|
1830
|
+
try {
|
|
1831
|
+
const response = await fetch(
|
|
1832
|
+
`${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
|
|
1833
|
+
{
|
|
1834
|
+
headers: { "Authorization": `Bearer ${this.sessionToken}` }
|
|
1835
|
+
}
|
|
1640
1836
|
);
|
|
1837
|
+
if (response.status === 401) {
|
|
1838
|
+
this.handleSessionExpired();
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
if (!response.ok) return null;
|
|
1842
|
+
return await response.json();
|
|
1843
|
+
} catch {
|
|
1844
|
+
return null;
|
|
1641
1845
|
}
|
|
1642
|
-
this.emitProgress();
|
|
1643
1846
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1847
|
+
/**
|
|
1848
|
+
* List all snapshots the current editor session can see.
|
|
1849
|
+
*/
|
|
1850
|
+
async listSnapshots() {
|
|
1851
|
+
try {
|
|
1852
|
+
const response = await fetch(`${this.apiBase}/api/editor/snapshots`, {
|
|
1853
|
+
headers: { "Authorization": `Bearer ${this.sessionToken}` }
|
|
1854
|
+
});
|
|
1855
|
+
if (response.status === 401) {
|
|
1856
|
+
this.handleSessionExpired();
|
|
1857
|
+
return [];
|
|
1858
|
+
}
|
|
1859
|
+
if (!response.ok) return [];
|
|
1860
|
+
const data = await response.json();
|
|
1861
|
+
return data.snapshots || [];
|
|
1862
|
+
} catch {
|
|
1863
|
+
return [];
|
|
1648
1864
|
}
|
|
1649
|
-
return count;
|
|
1650
|
-
}
|
|
1651
|
-
getTotalCount() {
|
|
1652
|
-
return this.blockExtractor.extract(document.body, true).length;
|
|
1653
1865
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1866
|
+
/**
|
|
1867
|
+
* Delete (soft) a snapshot by UUID.
|
|
1868
|
+
*/
|
|
1869
|
+
async deleteSnapshot(snapshotUuid) {
|
|
1870
|
+
try {
|
|
1871
|
+
const response = await fetch(
|
|
1872
|
+
`${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
|
|
1873
|
+
{
|
|
1874
|
+
method: "DELETE",
|
|
1875
|
+
headers: { "Authorization": `Bearer ${this.sessionToken}` }
|
|
1876
|
+
}
|
|
1877
|
+
);
|
|
1878
|
+
if (response.status === 401) {
|
|
1879
|
+
this.handleSessionExpired();
|
|
1880
|
+
return false;
|
|
1881
|
+
}
|
|
1882
|
+
return response.ok;
|
|
1883
|
+
} catch {
|
|
1884
|
+
return false;
|
|
1657
1885
|
}
|
|
1658
1886
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
const freshStatus = await this.api.fetchStatus([key], this.locale, this.namespace);
|
|
1677
|
-
if (freshStatus[key] !== void 0) {
|
|
1678
|
-
this.statusMap.set(key, freshStatus[key]);
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
const status = this.statusMap.get(key) ?? null;
|
|
1682
|
-
this.addField("Key", () => {
|
|
1683
|
-
const keyEl = document.createElement("div");
|
|
1684
|
-
keyEl.className = "lr-field-key";
|
|
1685
|
-
keyEl.textContent = key;
|
|
1686
|
-
return keyEl;
|
|
1687
|
-
});
|
|
1688
|
-
this.addField("Original", () => {
|
|
1689
|
-
const origEl = document.createElement("div");
|
|
1690
|
-
origEl.className = "lr-field-original";
|
|
1691
|
-
origEl.innerHTML = this.renderTextWithTags(unit.text);
|
|
1692
|
-
return origEl;
|
|
1693
|
-
});
|
|
1694
|
-
this.renderLinkNav(element);
|
|
1695
|
-
const isSourceLocale = this.locale === this.defaultLocale;
|
|
1696
|
-
const isLocked = isSourceLocale || status?.isLocked || status?.isReady || false;
|
|
1697
|
-
const textarea = document.createElement("textarea");
|
|
1698
|
-
this.addField("Translation", () => {
|
|
1699
|
-
textarea.className = "lr-textarea";
|
|
1700
|
-
textarea.value = status?.text || resolved.translatedUnit?.text || "";
|
|
1701
|
-
textarea.placeholder = unit.text;
|
|
1702
|
-
if (isLocked) {
|
|
1703
|
-
textarea.readOnly = true;
|
|
1704
|
-
textarea.classList.add("lr-textarea-locked");
|
|
1705
|
-
textarea.title = isSourceLocale ? "Source language \u2014 switch to a target language to edit" : status?.isReady ? "Approved for delivery \u2014 read only" : "Locked \u2014 read only";
|
|
1706
|
-
}
|
|
1707
|
-
return textarea;
|
|
1708
|
-
});
|
|
1709
|
-
this.bodyEl.appendChild(this.statusEl);
|
|
1710
|
-
const previewEl = document.createElement("div");
|
|
1711
|
-
this.addField("Preview", () => {
|
|
1712
|
-
previewEl.className = "lr-preview";
|
|
1713
|
-
if (textarea.value) {
|
|
1714
|
-
previewEl.innerHTML = this.renderPreview(textarea.value, unit.styles);
|
|
1887
|
+
/**
|
|
1888
|
+
* Workflow transitions (Phase 3). Per-key endpoints on the integrations API
|
|
1889
|
+
* that mirror the dashboard's row-level transitions but are addressable by
|
|
1890
|
+
* composite editor key (locale:namespace:auto_xxx).
|
|
1891
|
+
*
|
|
1892
|
+
* Returns the parsed JSON on success, or null on failure (caller usually
|
|
1893
|
+
* shows a toast / re-fetches status).
|
|
1894
|
+
*/
|
|
1895
|
+
async transitionTarget(composite, action) {
|
|
1896
|
+
try {
|
|
1897
|
+
const response = await this.authFetch(
|
|
1898
|
+
`${this.apiBase}/api/integrations/targets/${encodeURIComponent(composite)}/${action}`,
|
|
1899
|
+
{ method: "POST" }
|
|
1900
|
+
);
|
|
1901
|
+
if (response.status === 401) {
|
|
1902
|
+
this.handleSessionExpired();
|
|
1903
|
+
return null;
|
|
1715
1904
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
if (text) {
|
|
1726
|
-
applyLivePreview(element, text, unit.styles, key, this.locale, this.namespace, unit.text, "edited");
|
|
1905
|
+
if (!response.ok) {
|
|
1906
|
+
const errBody = await response.json().catch(() => null);
|
|
1907
|
+
if (response.status === 423 && errBody?.error?.code === "TARGET_BUSY") {
|
|
1908
|
+
this.onStatus(
|
|
1909
|
+
"error",
|
|
1910
|
+
"Machine translation is filling this page \u2014 try again in a few seconds."
|
|
1911
|
+
);
|
|
1912
|
+
} else {
|
|
1913
|
+
this.onStatus("error", `${action} failed: HTTP ${response.status}`);
|
|
1727
1914
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
styles: unit.styles
|
|
1735
|
-
});
|
|
1736
|
-
});
|
|
1737
|
-
}
|
|
1738
|
-
this.renderBlocksList();
|
|
1739
|
-
if (!isLocked) {
|
|
1740
|
-
setTimeout(() => textarea.focus(), 100);
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
return await response.json();
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
this.onStatus("error", `${action} failed: ${err.message}`);
|
|
1920
|
+
return null;
|
|
1741
1921
|
}
|
|
1742
1922
|
}
|
|
1923
|
+
};
|
|
1924
|
+
__publicField(_EditorAPI, "WORKFLOW_INFO_TTL_MS", 5 * 60 * 1e3);
|
|
1925
|
+
var EditorAPI = _EditorAPI;
|
|
1926
|
+
|
|
1927
|
+
// src/browser/exclude-filter.ts
|
|
1928
|
+
var DEFAULT_PATTERNS = {
|
|
1929
|
+
emails: true,
|
|
1930
|
+
urls: true,
|
|
1931
|
+
numbers: true,
|
|
1932
|
+
phones: true
|
|
1933
|
+
};
|
|
1934
|
+
var PATTERNS = {
|
|
1935
|
+
email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
|
|
1936
|
+
url: /^(https?:\/\/|www\.)[^\s]+$/i,
|
|
1937
|
+
number: /^[\d\s.,+-]+$/,
|
|
1938
|
+
phone: /^[+\d][\d\s().-]{6,}$/
|
|
1939
|
+
};
|
|
1940
|
+
var ExcludeFilter = class {
|
|
1941
|
+
constructor(patterns = {}, minTextLength = 2) {
|
|
1942
|
+
__publicField(this, "patterns");
|
|
1943
|
+
__publicField(this, "minTextLength");
|
|
1944
|
+
this.patterns = { ...DEFAULT_PATTERNS, ...patterns };
|
|
1945
|
+
this.minTextLength = minTextLength;
|
|
1946
|
+
}
|
|
1743
1947
|
/**
|
|
1744
|
-
*
|
|
1745
|
-
* Visibility:
|
|
1746
|
-
* - Confirm: shown when row is not locked and not already ready
|
|
1747
|
-
* - Reject: shown when row is not locked and not already ready (server
|
|
1748
|
-
* returns 400 if stage_index is already 0)
|
|
1749
|
-
* - Unconfirm: shown when isConfirmed
|
|
1750
|
-
* - Mark Ready: PM/admin only, hidden when already ready
|
|
1751
|
-
* - Unmark Ready: PM/admin only, only when ready
|
|
1752
|
-
* After any successful click we re-fetch status for this key so the UI
|
|
1753
|
-
* reflects the new state and the unaffected buttons hide/show themselves.
|
|
1948
|
+
* Check if text should be excluded from translation
|
|
1754
1949
|
*/
|
|
1755
|
-
|
|
1756
|
-
const
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
const isConfirmed = !!status?.isConfirmed;
|
|
1760
|
-
const canEdit = status?.canEdit !== false;
|
|
1761
|
-
const composite = `${this.locale}:${this.namespace}:${key}`;
|
|
1762
|
-
const wrap = document.createElement("div");
|
|
1763
|
-
wrap.className = "lr-workflow-actions";
|
|
1764
|
-
const stripHost = document.createElement("div");
|
|
1765
|
-
stripHost.className = "lr-wf-strip-host";
|
|
1766
|
-
wrap.appendChild(stripHost);
|
|
1767
|
-
this.renderWorkflowStrip(stripHost, status);
|
|
1768
|
-
const addBtn = (label, action, variant = "secondary") => {
|
|
1769
|
-
const btn = document.createElement("button");
|
|
1770
|
-
btn.className = `lr-wf-btn lr-wf-${variant}`;
|
|
1771
|
-
btn.textContent = label;
|
|
1772
|
-
btn.addEventListener("click", async () => {
|
|
1773
|
-
btn.disabled = true;
|
|
1774
|
-
const result = await this.api.transitionTarget(composite, action);
|
|
1775
|
-
btn.disabled = false;
|
|
1776
|
-
if (result) {
|
|
1777
|
-
await this.refreshStatusForKey(key);
|
|
1778
|
-
}
|
|
1779
|
-
});
|
|
1780
|
-
wrap.appendChild(btn);
|
|
1781
|
-
};
|
|
1782
|
-
if (canEdit && !isLocked && !isReady) {
|
|
1783
|
-
if (!isConfirmed) {
|
|
1784
|
-
addBtn("Confirm", "confirm", "primary");
|
|
1785
|
-
addBtn("Reject", "reject", "danger");
|
|
1786
|
-
} else {
|
|
1787
|
-
const isPerFile = this.workflowInfo?.workflowMode === "per_file";
|
|
1788
|
-
if (!isPerFile) {
|
|
1789
|
-
addBtn("Advance", "advance", "primary");
|
|
1790
|
-
}
|
|
1791
|
-
addBtn("Unconfirm", "unconfirm");
|
|
1792
|
-
}
|
|
1950
|
+
shouldExclude(text) {
|
|
1951
|
+
const trimmed = text.trim();
|
|
1952
|
+
if (trimmed.length < this.minTextLength) {
|
|
1953
|
+
return true;
|
|
1793
1954
|
}
|
|
1794
|
-
if (
|
|
1795
|
-
|
|
1796
|
-
addBtn("Mark Ready", "mark-ready", "primary");
|
|
1797
|
-
} else {
|
|
1798
|
-
addBtn("Unmark Ready", "unmark-ready", "danger");
|
|
1799
|
-
}
|
|
1955
|
+
if (this.patterns.emails && PATTERNS.email.test(trimmed)) {
|
|
1956
|
+
return true;
|
|
1800
1957
|
}
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
1803
|
-
note.className = "lr-wf-note";
|
|
1804
|
-
note.textContent = typeof status?.stageIndex === "number" && status.stageIndex > 0 ? `Row already advanced (stage ${status.stageIndex + 1}) \u2014 out of your scope.` : "You are not assigned to this row at the current stage.";
|
|
1805
|
-
wrap.appendChild(note);
|
|
1958
|
+
if (this.patterns.urls && PATTERNS.url.test(trimmed)) {
|
|
1959
|
+
return true;
|
|
1806
1960
|
}
|
|
1807
|
-
if (
|
|
1808
|
-
|
|
1961
|
+
if (this.patterns.numbers && PATTERNS.number.test(trimmed)) {
|
|
1962
|
+
return true;
|
|
1963
|
+
}
|
|
1964
|
+
if (this.patterns.phones && PATTERNS.phone.test(trimmed)) {
|
|
1965
|
+
return true;
|
|
1809
1966
|
}
|
|
1967
|
+
return false;
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
// src/browser/block-extractor.ts
|
|
1972
|
+
var BLOCK_SELECTOR3 = "p,h1,h2,h3,h4,h5,h6,li,td,th,label,button,figcaption,dt,dd,summary,caption";
|
|
1973
|
+
var INLINE_BLOCK_SELECTOR = "a";
|
|
1974
|
+
var SKIP_TAGS2 = /* @__PURE__ */ new Set([
|
|
1975
|
+
"SCRIPT",
|
|
1976
|
+
"STYLE",
|
|
1977
|
+
"CODE",
|
|
1978
|
+
"PRE",
|
|
1979
|
+
"TEXTAREA",
|
|
1980
|
+
"NOSCRIPT",
|
|
1981
|
+
"SVG"
|
|
1982
|
+
]);
|
|
1983
|
+
var SKIP_ATTRIBUTES = [
|
|
1984
|
+
"data-lionrapid-translated",
|
|
1985
|
+
// Server already translated
|
|
1986
|
+
"data-lionrapid-client",
|
|
1987
|
+
// Client already translated
|
|
1988
|
+
"data-no-translate",
|
|
1989
|
+
// Explicitly marked to skip
|
|
1990
|
+
// Edge proxy already rendered this block with the correct translation
|
|
1991
|
+
// and styles. Skip in normal extraction so the client-side auto-translator
|
|
1992
|
+
// can't overwrite the styled DOM with a stale (or plain-text) cache entry.
|
|
1993
|
+
// The editor passes includeTranslated=true to bypass this and re-extract
|
|
1994
|
+
// edge-rendered blocks for inspection.
|
|
1995
|
+
"data-lr-block"
|
|
1996
|
+
];
|
|
1997
|
+
var BlockExtractor = class {
|
|
1998
|
+
constructor(excludePatterns = {}, excludeSelectors = [], minTextLength = 2) {
|
|
1999
|
+
__publicField(this, "excludeFilter");
|
|
2000
|
+
__publicField(this, "excludeSelectors");
|
|
2001
|
+
this.excludeFilter = new ExcludeFilter(excludePatterns, minTextLength);
|
|
2002
|
+
this.excludeSelectors = excludeSelectors;
|
|
1810
2003
|
}
|
|
1811
2004
|
/**
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
1815
|
-
* Highlight rule:
|
|
1816
|
-
* - PM/admin: highlight = row's current stage_index (deep-dive view)
|
|
1817
|
-
* - Non-PM: highlight = first userAssignedStages entry (fixed POV)
|
|
1818
|
-
*
|
|
1819
|
-
* Workflow info is fetched lazily on first call and cached for the sidebar's
|
|
1820
|
-
* lifetime. While the fetch is in flight, the host stays empty (no flash).
|
|
2005
|
+
* Extract all translatable block elements from a root.
|
|
2006
|
+
* @param includeTranslated - If true, include elements already marked as translated (for editor mode)
|
|
1821
2007
|
*/
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2008
|
+
extract(root, includeTranslated = false) {
|
|
2009
|
+
const results = [];
|
|
2010
|
+
const descendants = root.querySelectorAll(BLOCK_SELECTOR3);
|
|
2011
|
+
const elements = [];
|
|
2012
|
+
if (root instanceof Element && root.matches(BLOCK_SELECTOR3)) {
|
|
2013
|
+
elements.push(root);
|
|
1827
2014
|
}
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
void this.api.fetchWorkflowInfo().then((info) => {
|
|
1831
|
-
this.workflowInfo = info;
|
|
1832
|
-
if (!this.current) return;
|
|
1833
|
-
const fresh = this.statusMap.get(this.current.key) ?? status ?? null;
|
|
1834
|
-
const node = info ? this.buildWorkflowStripNode(info, fresh) : null;
|
|
1835
|
-
if (host.isConnected) host.replaceChildren(...node ? [node] : []);
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
buildWorkflowStripNode(info, status) {
|
|
1839
|
-
if (!info.stages || info.stages.length === 0) return null;
|
|
1840
|
-
let highlightIndex = null;
|
|
1841
|
-
if (info.isPmAdmin) {
|
|
1842
|
-
highlightIndex = typeof status?.stageIndex === "number" ? status.stageIndex : null;
|
|
1843
|
-
} else if (info.userAssignedStages.length > 0) {
|
|
1844
|
-
highlightIndex = info.userAssignedStages[0];
|
|
2015
|
+
for (let i = 0; i < descendants.length; i++) {
|
|
2016
|
+
elements.push(descendants[i]);
|
|
1845
2017
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
info.stages.forEach((stage, i) => {
|
|
1851
|
-
const item = document.createElement("span");
|
|
1852
|
-
item.className = "lr-wf-strip-stage";
|
|
1853
|
-
if (highlightIndex !== null && stage.index === highlightIndex) {
|
|
1854
|
-
item.classList.add("is-active");
|
|
1855
|
-
} else {
|
|
1856
|
-
item.classList.add("is-muted");
|
|
2018
|
+
for (let i = 0; i < elements.length; i++) {
|
|
2019
|
+
const el = elements[i];
|
|
2020
|
+
if (this.isInsideSkipTag(el)) {
|
|
2021
|
+
continue;
|
|
1857
2022
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
if (i < info.stages.length - 1) {
|
|
1861
|
-
const sep = document.createElement("span");
|
|
1862
|
-
sep.className = "lr-wf-strip-sep";
|
|
1863
|
-
sep.textContent = "\u2192";
|
|
1864
|
-
stagesRow.appendChild(sep);
|
|
2023
|
+
if (!includeTranslated && this.hasSkipAttribute(el)) {
|
|
2024
|
+
continue;
|
|
1865
2025
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2026
|
+
if (this.matchesExcludeSelector(el)) {
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
if (el.getAttribute("translate") === "no") {
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
if (el.querySelector(BLOCK_SELECTOR3)) {
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const lrBlock = el.getAttribute("data-lr-block");
|
|
2036
|
+
const lrOriginal = el.getAttribute("data-lr-original");
|
|
2037
|
+
let unit;
|
|
2038
|
+
if (lrOriginal) {
|
|
2039
|
+
const temp = document.createElement(el.tagName);
|
|
2040
|
+
temp.innerHTML = lrOriginal;
|
|
2041
|
+
unit = parseBlockElement(temp);
|
|
2042
|
+
} else {
|
|
2043
|
+
unit = parseBlockElement(el);
|
|
2044
|
+
}
|
|
2045
|
+
const strippedText = unit.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
2046
|
+
if (this.excludeFilter.shouldExclude(strippedText)) {
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
let key;
|
|
2050
|
+
if (lrBlock) {
|
|
2051
|
+
key = lrBlock;
|
|
2052
|
+
} else {
|
|
2053
|
+
const dataI18n = el.getAttribute("data-i18n");
|
|
2054
|
+
if (dataI18n) {
|
|
2055
|
+
key = dataI18n.includes(":") ? dataI18n : `common:${dataI18n}`;
|
|
2056
|
+
} else {
|
|
2057
|
+
key = generateAutoKey(unit.text);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
results.push({ element: el, unit, key });
|
|
1891
2061
|
}
|
|
1892
|
-
this.
|
|
2062
|
+
this.extractOrphanInline(root, results, includeTranslated);
|
|
2063
|
+
return results;
|
|
1893
2064
|
}
|
|
1894
2065
|
/**
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
2066
|
+
* Second pass: extract inline elements (currently <a>) that have no block
|
|
2067
|
+
* ancestor. These would otherwise slip through both the block pass (not in
|
|
2068
|
+
* BLOCK_SELECTOR) and the inline-style capture (no parent block to host
|
|
2069
|
+
* them as `<1>...</1>`). Common shape: a `<div>` action group containing
|
|
2070
|
+
* only links.
|
|
1898
2071
|
*/
|
|
1899
|
-
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
dest = new URL(anchor.href, window.location.href);
|
|
1905
|
-
} catch {
|
|
1906
|
-
return;
|
|
2072
|
+
extractOrphanInline(root, results, includeTranslated) {
|
|
2073
|
+
const candidates = root.querySelectorAll(INLINE_BLOCK_SELECTOR);
|
|
2074
|
+
const elements = [];
|
|
2075
|
+
if (root instanceof Element && root.matches(INLINE_BLOCK_SELECTOR)) {
|
|
2076
|
+
elements.push(root);
|
|
1907
2077
|
}
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
if (s.isReady) return "ready";
|
|
1945
|
-
if (s.isConfirmed) return "confirmed";
|
|
1946
|
-
if (s.rowState === "edited") return "edited";
|
|
1947
|
-
if (s.rowState === "machine_translated") return "mt";
|
|
1948
|
-
return "missing";
|
|
1949
|
-
}
|
|
1950
|
-
renderTextWithTags(text) {
|
|
1951
|
-
return text.replace(
|
|
1952
|
-
/<(\/?)?(\d+)(\/)?>/g,
|
|
1953
|
-
(match) => `<span class="lr-field-tag">${match}</span>`
|
|
1954
|
-
);
|
|
1955
|
-
}
|
|
1956
|
-
renderPreview(text, styles) {
|
|
1957
|
-
if (!text) return "";
|
|
1958
|
-
if (Object.keys(styles).length > 0) {
|
|
1959
|
-
const unit = { text, styles };
|
|
1960
|
-
return htmlSerializer2.serialize(unit);
|
|
2078
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2079
|
+
elements.push(candidates[i]);
|
|
2080
|
+
}
|
|
2081
|
+
for (let i = 0; i < elements.length; i++) {
|
|
2082
|
+
const el = elements[i];
|
|
2083
|
+
if (this.isInsideSkipTag(el)) continue;
|
|
2084
|
+
if (!includeTranslated && this.hasSkipAttribute(el)) continue;
|
|
2085
|
+
if (this.matchesExcludeSelector(el)) continue;
|
|
2086
|
+
if (el.getAttribute("translate") === "no") continue;
|
|
2087
|
+
if (el.parentElement?.closest(BLOCK_SELECTOR3)) continue;
|
|
2088
|
+
if (el.parentElement?.closest(INLINE_BLOCK_SELECTOR)) continue;
|
|
2089
|
+
if (el.querySelector(BLOCK_SELECTOR3)) continue;
|
|
2090
|
+
const lrOriginal = el.getAttribute("data-lr-original");
|
|
2091
|
+
let unit;
|
|
2092
|
+
if (lrOriginal) {
|
|
2093
|
+
const temp = document.createElement(el.tagName);
|
|
2094
|
+
temp.innerHTML = lrOriginal;
|
|
2095
|
+
unit = parseBlockElement(temp);
|
|
2096
|
+
} else {
|
|
2097
|
+
unit = parseBlockElement(el);
|
|
2098
|
+
}
|
|
2099
|
+
const strippedText = unit.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
2100
|
+
if (this.excludeFilter.shouldExclude(strippedText)) continue;
|
|
2101
|
+
const lrBlock = el.getAttribute("data-lr-block");
|
|
2102
|
+
let key;
|
|
2103
|
+
if (lrBlock) {
|
|
2104
|
+
key = lrBlock;
|
|
2105
|
+
} else {
|
|
2106
|
+
const dataI18n = el.getAttribute("data-i18n");
|
|
2107
|
+
if (dataI18n) {
|
|
2108
|
+
key = dataI18n.includes(":") ? dataI18n : `common:${dataI18n}`;
|
|
2109
|
+
} else {
|
|
2110
|
+
key = generateAutoKey(unit.text);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
results.push({ element: el, unit, key });
|
|
1961
2114
|
}
|
|
1962
|
-
return text;
|
|
1963
2115
|
}
|
|
1964
|
-
|
|
1965
|
-
let
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
handle.classList.remove("active");
|
|
1974
|
-
document.removeEventListener("mousemove", onMouseMove);
|
|
1975
|
-
document.removeEventListener("mouseup", onMouseUp);
|
|
1976
|
-
};
|
|
1977
|
-
handle.addEventListener("mousedown", (e) => {
|
|
1978
|
-
e.preventDefault();
|
|
1979
|
-
startX = e.clientX;
|
|
1980
|
-
startWidth = this.el.offsetWidth;
|
|
1981
|
-
handle.classList.add("active");
|
|
1982
|
-
document.addEventListener("mousemove", onMouseMove);
|
|
1983
|
-
document.addEventListener("mouseup", onMouseUp);
|
|
1984
|
-
});
|
|
2116
|
+
isInsideSkipTag(element) {
|
|
2117
|
+
let current = element.parentElement;
|
|
2118
|
+
while (current) {
|
|
2119
|
+
if (SKIP_TAGS2.has(current.tagName.toUpperCase())) {
|
|
2120
|
+
return true;
|
|
2121
|
+
}
|
|
2122
|
+
current = current.parentElement;
|
|
2123
|
+
}
|
|
2124
|
+
return false;
|
|
1985
2125
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2126
|
+
hasSkipAttribute(element) {
|
|
2127
|
+
let current = element;
|
|
2128
|
+
while (current) {
|
|
2129
|
+
for (const attr of SKIP_ATTRIBUTES) {
|
|
2130
|
+
if (current.hasAttribute(attr)) {
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
current = current.parentElement;
|
|
2135
|
+
}
|
|
2136
|
+
return false;
|
|
1990
2137
|
}
|
|
1991
|
-
|
|
1992
|
-
const
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
title.textContent = `Blocks on this page (${translatedCount}/${blocks.length} translated)`;
|
|
1999
|
-
section.appendChild(title);
|
|
2000
|
-
for (const block of blocks) {
|
|
2001
|
-
const item = document.createElement("div");
|
|
2002
|
-
item.className = "lr-block-item";
|
|
2003
|
-
const isActive = this.current && block.element === this.current.element;
|
|
2004
|
-
if (isActive) item.classList.add("active");
|
|
2005
|
-
const dot = document.createElement("span");
|
|
2006
|
-
dot.className = "lr-status-dot";
|
|
2007
|
-
dot.classList.add(this.getDotClass(block.key));
|
|
2008
|
-
item.appendChild(dot);
|
|
2009
|
-
const textWrap = document.createElement("div");
|
|
2010
|
-
textWrap.className = "lr-block-text";
|
|
2011
|
-
const displayText = block.unit.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
2012
|
-
const sourceSpan = document.createElement("div");
|
|
2013
|
-
sourceSpan.className = "lr-block-source";
|
|
2014
|
-
sourceSpan.textContent = displayText.length > 60 ? displayText.slice(0, 60) + "..." : displayText;
|
|
2015
|
-
sourceSpan.title = displayText;
|
|
2016
|
-
textWrap.appendChild(sourceSpan);
|
|
2017
|
-
const blockStatus = this.statusMap.get(block.key);
|
|
2018
|
-
if (blockStatus && blockStatus.text) {
|
|
2019
|
-
const previewSpan = document.createElement("div");
|
|
2020
|
-
previewSpan.className = "lr-block-preview";
|
|
2021
|
-
const previewText = blockStatus.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
2022
|
-
previewSpan.textContent = previewText.length > 50 ? previewText.slice(0, 50) + "..." : previewText;
|
|
2023
|
-
textWrap.appendChild(previewSpan);
|
|
2138
|
+
matchesExcludeSelector(element) {
|
|
2139
|
+
for (const selector of this.excludeSelectors) {
|
|
2140
|
+
try {
|
|
2141
|
+
if (element.matches(selector) || element.closest(selector)) {
|
|
2142
|
+
return true;
|
|
2143
|
+
}
|
|
2144
|
+
} catch {
|
|
2024
2145
|
}
|
|
2025
|
-
item.appendChild(textWrap);
|
|
2026
|
-
item.addEventListener("click", () => {
|
|
2027
|
-
block.element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
2028
|
-
this.onBlockClick({ key: block.key, unit: block.unit, translatedUnit: null, element: block.element });
|
|
2029
|
-
});
|
|
2030
|
-
section.appendChild(item);
|
|
2031
2146
|
}
|
|
2032
|
-
|
|
2147
|
+
return false;
|
|
2033
2148
|
}
|
|
2034
2149
|
};
|
|
2035
2150
|
|
|
2036
|
-
// src/browser/editor/
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2151
|
+
// src/browser/editor/locale-url.ts
|
|
2152
|
+
function localizeUrl(href, defaultLocale) {
|
|
2153
|
+
let url;
|
|
2154
|
+
try {
|
|
2155
|
+
url = new URL(href, window.location.href);
|
|
2156
|
+
} catch {
|
|
2157
|
+
return href;
|
|
2158
|
+
}
|
|
2159
|
+
if (url.origin !== window.location.origin) return href;
|
|
2160
|
+
const currentMatch = window.location.pathname.match(/^\/([a-z]{2})(\/|$)/);
|
|
2161
|
+
const currentLocale = currentMatch ? currentMatch[1] : defaultLocale;
|
|
2162
|
+
if (currentLocale === defaultLocale) return url.href;
|
|
2163
|
+
if (/^\/[a-z]{2}(\/|$)/.test(url.pathname)) return url.href;
|
|
2164
|
+
url.pathname = "/" + currentLocale + url.pathname;
|
|
2165
|
+
return url.href;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// src/browser/editor/sidebar.ts
|
|
2169
|
+
var htmlSerializer2 = new HTMLSerializer();
|
|
2170
|
+
var Sidebar = class {
|
|
2171
|
+
constructor(shadowRoot, api, locale, namespace, defaultLocale, onBlockClick, userRole = "") {
|
|
2172
|
+
__publicField(this, "el");
|
|
2173
|
+
__publicField(this, "bodyEl");
|
|
2174
|
+
__publicField(this, "statusEl");
|
|
2175
|
+
__publicField(this, "api");
|
|
2176
|
+
__publicField(this, "locale");
|
|
2177
|
+
__publicField(this, "namespace");
|
|
2178
|
+
__publicField(this, "defaultLocale");
|
|
2179
|
+
__publicField(this, "current", null);
|
|
2180
|
+
__publicField(this, "blockExtractor");
|
|
2181
|
+
__publicField(this, "onBlockClick");
|
|
2182
|
+
__publicField(this, "statusMap", /* @__PURE__ */ new Map());
|
|
2183
|
+
__publicField(this, "unsavedKeys", /* @__PURE__ */ new Set());
|
|
2184
|
+
__publicField(this, "onProgressUpdate", null);
|
|
2185
|
+
__publicField(this, "lastFetchTime", 0);
|
|
2186
|
+
__publicField(this, "lastFlushTime", 0);
|
|
2187
|
+
__publicField(this, "userRole");
|
|
2188
|
+
// Latest known workflow context for this.locale. Revalidated on every strip
|
|
2189
|
+
// render through the API's TTL-bound, per-locale cache — the workflow can
|
|
2190
|
+
// change mid-session (dashboard settings) and can differ per locale
|
|
2191
|
+
// (per-target override), so it must not be pinned at first fetch.
|
|
2192
|
+
__publicField(this, "workflowInfo", null);
|
|
2193
|
+
this.api = api;
|
|
2194
|
+
this.locale = locale;
|
|
2195
|
+
this.namespace = namespace;
|
|
2196
|
+
this.defaultLocale = defaultLocale;
|
|
2197
|
+
this.onBlockClick = onBlockClick;
|
|
2198
|
+
this.userRole = userRole;
|
|
2199
|
+
this.blockExtractor = new BlockExtractor();
|
|
2200
|
+
this.el = document.createElement("div");
|
|
2201
|
+
this.el.className = "lr-sidebar";
|
|
2202
|
+
const resizeHandle = document.createElement("div");
|
|
2203
|
+
resizeHandle.className = "lr-sidebar-resize";
|
|
2204
|
+
this.initResize(resizeHandle);
|
|
2205
|
+
this.el.appendChild(resizeHandle);
|
|
2206
|
+
const header = document.createElement("div");
|
|
2207
|
+
header.className = "lr-sidebar-header";
|
|
2208
|
+
const title = document.createElement("span");
|
|
2209
|
+
title.className = "lr-sidebar-title";
|
|
2210
|
+
title.textContent = "Edit Translation";
|
|
2211
|
+
header.appendChild(title);
|
|
2212
|
+
const closeBtn = document.createElement("button");
|
|
2213
|
+
closeBtn.className = "lr-sidebar-close";
|
|
2214
|
+
closeBtn.innerHTML = "×";
|
|
2215
|
+
closeBtn.addEventListener("click", () => this.close());
|
|
2216
|
+
header.appendChild(closeBtn);
|
|
2217
|
+
this.el.appendChild(header);
|
|
2218
|
+
this.bodyEl = document.createElement("div");
|
|
2219
|
+
this.bodyEl.className = "lr-sidebar-body";
|
|
2220
|
+
this.el.appendChild(this.bodyEl);
|
|
2221
|
+
this.statusEl = document.createElement("div");
|
|
2222
|
+
this.statusEl.className = "lr-status";
|
|
2223
|
+
this.api.setStatusCallback((status, message) => {
|
|
2224
|
+
this.statusEl.textContent = message || status;
|
|
2225
|
+
this.statusEl.className = `lr-status ${status}`;
|
|
2048
2226
|
});
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
if (document.visibilityState === "hidden") {
|
|
2056
|
-
this.flush();
|
|
2057
|
-
} else if (document.visibilityState === "visible") {
|
|
2058
|
-
this.maybeRenewSession();
|
|
2227
|
+
this.api.setFlushedCallback(async (keys) => {
|
|
2228
|
+
this.lastFlushTime = Date.now();
|
|
2229
|
+
const freshStatuses = await this.api.fetchStatus(keys, this.locale, this.namespace);
|
|
2230
|
+
for (const [key, status] of Object.entries(freshStatuses)) {
|
|
2231
|
+
this.statusMap.set(key, status);
|
|
2232
|
+
this.unsavedKeys.delete(key);
|
|
2059
2233
|
}
|
|
2234
|
+
this.refreshBlocksList();
|
|
2235
|
+
this.emitProgress();
|
|
2060
2236
|
});
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2237
|
+
if (typeof window !== "undefined") {
|
|
2238
|
+
window.__lr_dump_keys = () => {
|
|
2239
|
+
const blocks = this.blockExtractor.extract(document.body, true);
|
|
2240
|
+
const rows = blocks.map((b, i) => {
|
|
2241
|
+
const s = this.statusMap.get(b.key);
|
|
2242
|
+
return {
|
|
2243
|
+
i,
|
|
2244
|
+
key: b.key,
|
|
2245
|
+
text: b.unit.text,
|
|
2246
|
+
stylesJson: JSON.stringify(b.unit.styles),
|
|
2247
|
+
statusText: s?.text ?? "",
|
|
2248
|
+
isReady: !!s?.isReady,
|
|
2249
|
+
hasTranslation: !!s?.text
|
|
2250
|
+
};
|
|
2251
|
+
});
|
|
2252
|
+
console.table(rows);
|
|
2253
|
+
return rows;
|
|
2254
|
+
};
|
|
2255
|
+
window.__lr_editor_edit = (key, text) => {
|
|
2256
|
+
const blocks = this.blockExtractor.extract(document.body, true);
|
|
2257
|
+
const block = blocks.find((b) => b.key === key);
|
|
2258
|
+
if (!block) throw new Error(`[__lr_editor_edit] no block on page with key ${key}`);
|
|
2259
|
+
this.unsavedKeys.add(key);
|
|
2260
|
+
this.api.queueSave({
|
|
2261
|
+
key,
|
|
2262
|
+
locale: this.locale,
|
|
2263
|
+
namespace: this.namespace,
|
|
2264
|
+
text,
|
|
2265
|
+
styles: block.unit.styles
|
|
2266
|
+
});
|
|
2267
|
+
};
|
|
2268
|
+
window.__lr_editor_flush = () => this.api.flush();
|
|
2269
|
+
}
|
|
2270
|
+
shadowRoot.appendChild(this.el);
|
|
2064
2271
|
}
|
|
2065
|
-
|
|
2066
|
-
this.
|
|
2272
|
+
open(resolved) {
|
|
2273
|
+
this.current = resolved;
|
|
2274
|
+
this.el.classList.add("open");
|
|
2275
|
+
this.renderContent(resolved);
|
|
2067
2276
|
}
|
|
2068
|
-
|
|
2069
|
-
this.
|
|
2277
|
+
close() {
|
|
2278
|
+
this.current = null;
|
|
2279
|
+
this.el.classList.remove("open");
|
|
2070
2280
|
}
|
|
2071
|
-
|
|
2072
|
-
this.
|
|
2281
|
+
isOpen() {
|
|
2282
|
+
return this.el.classList.contains("open");
|
|
2073
2283
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
this.flushInterval = setInterval(() => this.flush(), 3e4);
|
|
2079
|
-
document.addEventListener("visibilitychange", this.handleVisibility);
|
|
2080
|
-
this.renewalInterval = setInterval(() => this.maybeRenewSession(), 6e4);
|
|
2284
|
+
updateLocale(locale) {
|
|
2285
|
+
this.locale = locale;
|
|
2286
|
+
this.statusMap.clear();
|
|
2287
|
+
this.unsavedKeys.clear();
|
|
2081
2288
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
*/
|
|
2085
|
-
async stopAndFlush() {
|
|
2086
|
-
if (this.flushInterval) {
|
|
2087
|
-
clearInterval(this.flushInterval);
|
|
2088
|
-
this.flushInterval = null;
|
|
2089
|
-
}
|
|
2090
|
-
if (this.renewalInterval) {
|
|
2091
|
-
clearInterval(this.renewalInterval);
|
|
2092
|
-
this.renewalInterval = null;
|
|
2093
|
-
}
|
|
2094
|
-
document.removeEventListener("visibilitychange", this.handleVisibility);
|
|
2095
|
-
await this.flush();
|
|
2289
|
+
setProgressCallback(cb) {
|
|
2290
|
+
this.onProgressUpdate = cb;
|
|
2096
2291
|
}
|
|
2097
2292
|
/**
|
|
2098
|
-
*
|
|
2293
|
+
* Hydrate the status map with server data for all page blocks.
|
|
2294
|
+
* Called once on editor init.
|
|
2099
2295
|
*/
|
|
2100
|
-
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2103
|
-
);
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
this.queue.push(item);
|
|
2108
|
-
}
|
|
2109
|
-
this.onStatus("unsaved", `${this.queue.length} unsaved`);
|
|
2110
|
-
if (this.queue.length > 50) {
|
|
2111
|
-
this.flush();
|
|
2296
|
+
async hydrateStatus() {
|
|
2297
|
+
const blocks = this.blockExtractor.extract(document.body, true);
|
|
2298
|
+
const keys = blocks.map((b) => b.key);
|
|
2299
|
+
if (keys.length === 0) return;
|
|
2300
|
+
const statuses = await this.api.fetchStatus(keys, this.locale, this.namespace);
|
|
2301
|
+
for (const [key, status] of Object.entries(statuses)) {
|
|
2302
|
+
this.statusMap.set(key, status);
|
|
2112
2303
|
}
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
body: JSON.stringify({
|
|
2128
|
-
translations: items.map((t) => ({
|
|
2129
|
-
key: `${t.locale}:${t.namespace}:${t.key}`,
|
|
2130
|
-
value: { text: t.text, styles: t.styles }
|
|
2131
|
-
}))
|
|
2132
|
-
})
|
|
2304
|
+
const divergent = [];
|
|
2305
|
+
let untranslatedCount = 0;
|
|
2306
|
+
for (const block of blocks) {
|
|
2307
|
+
const status = this.statusMap.get(block.key);
|
|
2308
|
+
if (status && status.text && !status.isReady) {
|
|
2309
|
+
applyLivePreview(block.element, status.text, block.unit.styles, block.key, this.locale, this.namespace, block.unit.text, status.rowState);
|
|
2310
|
+
} else if (!status || !status.text) {
|
|
2311
|
+
untranslatedCount++;
|
|
2312
|
+
const stamp = block.element.getAttribute("data-lr-block");
|
|
2313
|
+
if (stamp && stamp.startsWith("auto_")) {
|
|
2314
|
+
const recomputed = generateAutoKey(block.unit.text);
|
|
2315
|
+
if (recomputed !== stamp) {
|
|
2316
|
+
divergent.push({ stamped: stamp, recomputed, text: block.unit.text.slice(0, 80) });
|
|
2317
|
+
}
|
|
2133
2318
|
}
|
|
2134
|
-
);
|
|
2135
|
-
if (response.status === 401) {
|
|
2136
|
-
this.handleSessionExpired();
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
if (!response.ok) {
|
|
2140
|
-
throw new Error(`HTTP ${response.status}`);
|
|
2141
|
-
}
|
|
2142
|
-
const flushedKeys = items.map((i) => i.key);
|
|
2143
|
-
for (const item of items) {
|
|
2144
|
-
const idx = this.queue.indexOf(item);
|
|
2145
|
-
if (idx >= 0) this.queue.splice(idx, 1);
|
|
2146
|
-
}
|
|
2147
|
-
this.onFlushed(flushedKeys);
|
|
2148
|
-
if (this.queue.length === 0) {
|
|
2149
|
-
this.onStatus("saved", "All changes saved");
|
|
2150
|
-
} else {
|
|
2151
|
-
this.onStatus("unsaved", `${this.queue.length} unsaved`);
|
|
2152
2319
|
}
|
|
2153
|
-
} catch (err) {
|
|
2154
|
-
this.onStatus("error", `Save failed: ${err.message}`);
|
|
2155
2320
|
}
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
this.statusAbort = new AbortController();
|
|
2165
|
-
const batch = keys.slice(0, 200);
|
|
2166
|
-
const params = new URLSearchParams({ locale, namespace, keys: batch.join(",") });
|
|
2167
|
-
try {
|
|
2168
|
-
const response = await this.authFetch(
|
|
2169
|
-
`${this.apiBase}/api/integrations/targets/status?${params}`,
|
|
2170
|
-
{ signal: this.statusAbort.signal }
|
|
2321
|
+
if (divergent.length > 0) {
|
|
2322
|
+
console.warn(
|
|
2323
|
+
`[LionRapid Editor] ${divergent.length} block(s) where the client-side auto_ key does NOT match the server's data-lr-block stamp \u2014 JS and server key generation are diverging for these inputs. Sample (first 5):`,
|
|
2324
|
+
divergent.slice(0, 5)
|
|
2325
|
+
);
|
|
2326
|
+
} else if (untranslatedCount > 0) {
|
|
2327
|
+
console.debug(
|
|
2328
|
+
`[LionRapid Editor] ${untranslatedCount} block(s) untranslated (no server text yet) \u2014 normal for in-progress pages.`
|
|
2171
2329
|
);
|
|
2172
|
-
if (response.status === 401) {
|
|
2173
|
-
this.handleSessionExpired();
|
|
2174
|
-
return {};
|
|
2175
|
-
}
|
|
2176
|
-
if (!response.ok) return {};
|
|
2177
|
-
const data = await response.json();
|
|
2178
|
-
return data.translations;
|
|
2179
|
-
} catch (err) {
|
|
2180
|
-
if (err.name === "AbortError") return {};
|
|
2181
|
-
return {};
|
|
2182
2330
|
}
|
|
2331
|
+
this.emitProgress();
|
|
2183
2332
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2333
|
+
getTranslatedCount() {
|
|
2334
|
+
let count = 0;
|
|
2335
|
+
for (const status of this.statusMap.values()) {
|
|
2336
|
+
if (status && status.rowState !== "empty") count++;
|
|
2337
|
+
}
|
|
2338
|
+
return count;
|
|
2186
2339
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
* PM/admin flag). Cached for the editor session lifetime — workflow shape
|
|
2190
|
-
* doesn't change mid-session and the sidebar re-uses it on every row click.
|
|
2191
|
-
* Returns null if the fetch fails; callers should hide the strip in that case.
|
|
2192
|
-
*/
|
|
2193
|
-
async fetchWorkflowInfo() {
|
|
2194
|
-
if (this.workflowInfoPromise) return this.workflowInfoPromise;
|
|
2195
|
-
this.workflowInfoPromise = (async () => {
|
|
2196
|
-
try {
|
|
2197
|
-
const response = await this.authFetch(`${this.apiBase}/api/integrations/editor/workflow`);
|
|
2198
|
-
if (response.status === 401) {
|
|
2199
|
-
this.handleSessionExpired();
|
|
2200
|
-
return null;
|
|
2201
|
-
}
|
|
2202
|
-
if (!response.ok) return null;
|
|
2203
|
-
const data = await response.json();
|
|
2204
|
-
return {
|
|
2205
|
-
stages: Array.isArray(data.stages) ? data.stages : [],
|
|
2206
|
-
userAssignedStages: Array.isArray(data.user_assigned_stages) ? data.user_assigned_stages : [],
|
|
2207
|
-
isPmAdmin: Boolean(data.is_pm_admin),
|
|
2208
|
-
workflowMode: data.workflow_mode === "per_file" ? "per_file" : "per_row"
|
|
2209
|
-
};
|
|
2210
|
-
} catch {
|
|
2211
|
-
return null;
|
|
2212
|
-
}
|
|
2213
|
-
})();
|
|
2214
|
-
return this.workflowInfoPromise;
|
|
2340
|
+
getTotalCount() {
|
|
2341
|
+
return this.blockExtractor.extract(document.body, true).length;
|
|
2215
2342
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
* Check if session is within 5min of expiry, and renew if so.
|
|
2221
|
-
*
|
|
2222
|
-
* The threshold is intentionally generous because background tabs throttle
|
|
2223
|
-
* setInterval to once-per-minute (Chrome) or even less aggressively, and
|
|
2224
|
-
* a sleeping laptop pauses timers entirely. Renewing early gives us
|
|
2225
|
-
* headroom for those cases — the renewal interval still runs before the
|
|
2226
|
-
* 15min server-side TTL elapses even if it fires a minute or two late.
|
|
2227
|
-
*/
|
|
2228
|
-
async maybeRenewSession() {
|
|
2229
|
-
const exp = this.getSessionExp();
|
|
2230
|
-
if (!exp) return;
|
|
2231
|
-
const timeLeft = exp * 1e3 - Date.now();
|
|
2232
|
-
if (timeLeft > 3e5) return;
|
|
2233
|
-
await this.renewSession();
|
|
2343
|
+
emitProgress() {
|
|
2344
|
+
if (this.onProgressUpdate) {
|
|
2345
|
+
this.onProgressUpdate(this.getTranslatedCount(), this.getTotalCount());
|
|
2346
|
+
}
|
|
2234
2347
|
}
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
* Used by the 401-retry path in API calls.
|
|
2238
|
-
*/
|
|
2239
|
-
async tryRenew() {
|
|
2240
|
-
const before = this.sessionToken;
|
|
2241
|
-
await this.renewSession();
|
|
2242
|
-
return this.sessionToken !== before;
|
|
2348
|
+
destroy() {
|
|
2349
|
+
this.el.remove();
|
|
2243
2350
|
}
|
|
2244
|
-
async
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2351
|
+
async renderContent(resolved) {
|
|
2352
|
+
const { key, unit, element } = resolved;
|
|
2353
|
+
this.bodyEl.innerHTML = "";
|
|
2354
|
+
const isStale = this.lastFlushTime > this.lastFetchTime || this.lastFetchTime === 0;
|
|
2355
|
+
if (isStale) {
|
|
2356
|
+
const blocks = this.blockExtractor.extract(document.body, true);
|
|
2357
|
+
const allKeys = blocks.map((b) => b.key);
|
|
2358
|
+
if (!allKeys.includes(key)) allKeys.push(key);
|
|
2359
|
+
const freshStatuses = await this.api.fetchStatus(allKeys, this.locale, this.namespace);
|
|
2360
|
+
for (const [k, s] of Object.entries(freshStatuses)) {
|
|
2361
|
+
this.statusMap.set(k, s);
|
|
2362
|
+
}
|
|
2363
|
+
this.lastFetchTime = Date.now();
|
|
2364
|
+
} else {
|
|
2365
|
+
const freshStatus = await this.api.fetchStatus([key], this.locale, this.namespace);
|
|
2366
|
+
if (freshStatus[key] !== void 0) {
|
|
2367
|
+
this.statusMap.set(key, freshStatus[key]);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
const status = this.statusMap.get(key) ?? null;
|
|
2371
|
+
this.addField("Key", () => {
|
|
2372
|
+
const keyEl = document.createElement("div");
|
|
2373
|
+
keyEl.className = "lr-field-key";
|
|
2374
|
+
keyEl.textContent = key;
|
|
2375
|
+
return keyEl;
|
|
2376
|
+
});
|
|
2377
|
+
this.addField("Original", () => {
|
|
2378
|
+
const origEl = document.createElement("div");
|
|
2379
|
+
origEl.className = "lr-field-original";
|
|
2380
|
+
origEl.innerHTML = this.renderTextWithTags(unit.text);
|
|
2381
|
+
return origEl;
|
|
2382
|
+
});
|
|
2383
|
+
this.renderLinkNav(element);
|
|
2384
|
+
const isSourceLocale = this.locale === this.defaultLocale;
|
|
2385
|
+
const isLocked = isSourceLocale || status?.isLocked || status?.isReady || false;
|
|
2386
|
+
const cannotEdit = status ? status.canEdit === false : !this.api.canCreateMissing;
|
|
2387
|
+
const readOnly = isLocked || cannotEdit;
|
|
2388
|
+
const textarea = document.createElement("textarea");
|
|
2389
|
+
this.addField("Translation", () => {
|
|
2390
|
+
textarea.className = "lr-textarea";
|
|
2391
|
+
textarea.value = status?.text || resolved.translatedUnit?.text || "";
|
|
2392
|
+
textarea.placeholder = unit.text;
|
|
2393
|
+
if (readOnly) {
|
|
2394
|
+
textarea.readOnly = true;
|
|
2395
|
+
textarea.classList.add("lr-textarea-locked");
|
|
2396
|
+
textarea.title = isSourceLocale ? "Source language \u2014 switch to a target language to edit" : status?.isReady ? "Approved for delivery \u2014 read only" : isLocked ? "Locked \u2014 read only" : status ? "Assigned to another stage \u2014 read only" : "Not yet translated \u2014 rows are created at the translation stage";
|
|
2255
2397
|
}
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
sessionStorage.setItem("__lr_editor_session", JSON.stringify({
|
|
2265
|
-
token: this.sessionToken,
|
|
2266
|
-
exp: payload.exp || 0
|
|
2267
|
-
}));
|
|
2268
|
-
document.cookie = "__lr_editor_active=1; path=/; SameSite=Lax";
|
|
2269
|
-
}
|
|
2270
|
-
} catch {
|
|
2398
|
+
return textarea;
|
|
2399
|
+
});
|
|
2400
|
+
this.bodyEl.appendChild(this.statusEl);
|
|
2401
|
+
const previewEl = document.createElement("div");
|
|
2402
|
+
this.addField("Preview", () => {
|
|
2403
|
+
previewEl.className = "lr-preview";
|
|
2404
|
+
if (textarea.value) {
|
|
2405
|
+
previewEl.innerHTML = this.renderPreview(textarea.value, unit.styles);
|
|
2271
2406
|
}
|
|
2272
|
-
|
|
2407
|
+
return previewEl;
|
|
2408
|
+
});
|
|
2409
|
+
if (!isSourceLocale) {
|
|
2410
|
+
this.renderWorkflowActions(key, status);
|
|
2273
2411
|
}
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2412
|
+
if (!readOnly) {
|
|
2413
|
+
textarea.addEventListener("input", () => {
|
|
2414
|
+
const text = textarea.value;
|
|
2415
|
+
previewEl.innerHTML = this.renderPreview(text, unit.styles);
|
|
2416
|
+
if (text) {
|
|
2417
|
+
applyLivePreview(element, text, unit.styles, key, this.locale, this.namespace, unit.text, "edited");
|
|
2418
|
+
}
|
|
2419
|
+
this.unsavedKeys.add(key);
|
|
2420
|
+
this.api.queueSave({
|
|
2421
|
+
key,
|
|
2422
|
+
locale: this.locale,
|
|
2423
|
+
namespace: this.namespace,
|
|
2424
|
+
text,
|
|
2425
|
+
styles: unit.styles
|
|
2426
|
+
});
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
this.renderBlocksList();
|
|
2430
|
+
if (!readOnly) {
|
|
2431
|
+
setTimeout(() => textarea.focus(), 100);
|
|
2284
2432
|
}
|
|
2285
2433
|
}
|
|
2286
2434
|
/**
|
|
2287
|
-
*
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2435
|
+
* Render Confirm/Reject/Mark-Ready/Unmark-Ready buttons under the preview.
|
|
2436
|
+
* Visibility:
|
|
2437
|
+
* - Confirm: shown when row is not locked and not already ready
|
|
2438
|
+
* - Reject: shown when row is not locked and not already ready (server
|
|
2439
|
+
* returns 400 if stage_index is already 0)
|
|
2440
|
+
* - Unconfirm: shown when isConfirmed
|
|
2441
|
+
* - Mark Ready: PM/admin only, hidden when already ready
|
|
2442
|
+
* - Unmark Ready: PM/admin only, only when ready
|
|
2443
|
+
* After any successful click we re-fetch status for this key so the UI
|
|
2444
|
+
* reflects the new state and the unaffected buttons hide/show themselves.
|
|
2294
2445
|
*/
|
|
2295
|
-
|
|
2296
|
-
const
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2446
|
+
renderWorkflowActions(key, status) {
|
|
2447
|
+
const isPM = this.userRole === "project_manager" || this.userRole === "admin";
|
|
2448
|
+
const isReady = !!status?.isReady;
|
|
2449
|
+
const isLocked = !!status?.isLocked;
|
|
2450
|
+
const isConfirmed = !!status?.isConfirmed;
|
|
2451
|
+
const canEdit = status ? status.canEdit !== false : this.api.canCreateMissing;
|
|
2452
|
+
const composite = `${this.locale}:${this.namespace}:${key}`;
|
|
2453
|
+
const wrap = document.createElement("div");
|
|
2454
|
+
wrap.className = "lr-workflow-actions";
|
|
2455
|
+
const stripHost = document.createElement("div");
|
|
2456
|
+
stripHost.className = "lr-wf-strip-host";
|
|
2457
|
+
wrap.appendChild(stripHost);
|
|
2458
|
+
this.renderWorkflowStrip(stripHost, status);
|
|
2459
|
+
const addBtn = (label, action, variant = "secondary") => {
|
|
2460
|
+
const btn = document.createElement("button");
|
|
2461
|
+
btn.className = `lr-wf-btn lr-wf-${variant}`;
|
|
2462
|
+
btn.textContent = label;
|
|
2463
|
+
btn.addEventListener("click", async () => {
|
|
2464
|
+
btn.disabled = true;
|
|
2465
|
+
const result = await this.api.transitionTarget(composite, action);
|
|
2466
|
+
btn.disabled = false;
|
|
2467
|
+
if (result) {
|
|
2468
|
+
await this.refreshStatusForKey(key);
|
|
2469
|
+
}
|
|
2470
|
+
});
|
|
2471
|
+
wrap.appendChild(btn);
|
|
2300
2472
|
};
|
|
2301
|
-
|
|
2302
|
-
if (
|
|
2303
|
-
const
|
|
2304
|
-
|
|
2305
|
-
|
|
2473
|
+
const fillBusy = this.api.targetBusy;
|
|
2474
|
+
if (fillBusy && canEdit && !isLocked && !isReady) {
|
|
2475
|
+
const note = document.createElement("span");
|
|
2476
|
+
note.className = "lr-wf-note";
|
|
2477
|
+
note.textContent = "Machine translation is filling this page \u2014 edits save automatically; stage actions unlock in a moment.";
|
|
2478
|
+
wrap.appendChild(note);
|
|
2479
|
+
}
|
|
2480
|
+
if (!fillBusy && canEdit && !isLocked && !isReady) {
|
|
2481
|
+
if (!isConfirmed) {
|
|
2482
|
+
addBtn("Confirm", "confirm", "primary");
|
|
2483
|
+
if (status?.stageIndex !== 0) {
|
|
2484
|
+
addBtn("Reject", "reject", "danger");
|
|
2485
|
+
}
|
|
2486
|
+
} else {
|
|
2487
|
+
const isPerFile = this.workflowInfo?.workflowMode === "per_file";
|
|
2488
|
+
const atPostreview = isRowAtPostreview(this.workflowInfo, status?.stageIndex);
|
|
2489
|
+
if (!isPerFile && !atPostreview) {
|
|
2490
|
+
addBtn("Advance", "advance", "primary");
|
|
2491
|
+
}
|
|
2492
|
+
addBtn("Unconfirm", "unconfirm");
|
|
2493
|
+
if (!isPerFile && atPostreview) {
|
|
2494
|
+
const note = document.createElement("span");
|
|
2495
|
+
note.className = "lr-wf-note";
|
|
2496
|
+
note.textContent = this.workflowInfo && this.workflowInfo.stages.length === 0 ? "No translation workflow configured \u2014 set one in project settings to use stages." : "In final review \u2014 no further stages to advance.";
|
|
2497
|
+
wrap.appendChild(note);
|
|
2498
|
+
}
|
|
2306
2499
|
}
|
|
2307
2500
|
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2501
|
+
if (isPM) {
|
|
2502
|
+
if (!isReady) {
|
|
2503
|
+
addBtn("Mark Ready", "mark-ready", "primary");
|
|
2504
|
+
} else {
|
|
2505
|
+
addBtn("Unmark Ready", "unmark-ready", "danger");
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
if (!canEdit && !isPM && !isReady && !isLocked) {
|
|
2509
|
+
const note = document.createElement("span");
|
|
2510
|
+
note.className = "lr-wf-note";
|
|
2511
|
+
note.textContent = typeof status?.stageIndex === "number" && status.stageIndex > 0 ? `Row already advanced (stage ${status.stageIndex + 1}) \u2014 out of your scope.` : "You are not assigned to this row at the current stage.";
|
|
2512
|
+
wrap.appendChild(note);
|
|
2513
|
+
}
|
|
2514
|
+
if (wrap.children.length > 0) {
|
|
2515
|
+
this.bodyEl.appendChild(wrap);
|
|
2315
2516
|
}
|
|
2316
2517
|
}
|
|
2317
|
-
// ============================================
|
|
2318
|
-
// Snapshot API
|
|
2319
|
-
// ============================================
|
|
2320
2518
|
/**
|
|
2321
|
-
*
|
|
2322
|
-
*
|
|
2519
|
+
* Render the workflow context strip ('Workflow: A → B → C — you are at stage X').
|
|
2520
|
+
* Highlights the user's POV stage and greys the rest.
|
|
2521
|
+
*
|
|
2522
|
+
* Highlight rule:
|
|
2523
|
+
* - PM/admin: highlight = row's current stage_index (deep-dive view)
|
|
2524
|
+
* - Non-PM: highlight = first userAssignedStages entry (fixed POV)
|
|
2525
|
+
*
|
|
2526
|
+
* Renders immediately from the latest known shape, then revalidates through
|
|
2527
|
+
* the API's TTL-bound per-locale cache (cache hits resolve instantly) so a
|
|
2528
|
+
* mid-session workflow change or a per-target override is picked up. While
|
|
2529
|
+
* the first fetch is in flight, the host stays empty (no flash).
|
|
2323
2530
|
*/
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
const
|
|
2327
|
-
|
|
2328
|
-
headers: {
|
|
2329
|
-
"Content-Type": "application/json",
|
|
2330
|
-
"Authorization": `Bearer ${this.sessionToken}`
|
|
2331
|
-
},
|
|
2332
|
-
body: JSON.stringify({
|
|
2333
|
-
pageUrl: snapshot.pageUrl,
|
|
2334
|
-
title: snapshot.title,
|
|
2335
|
-
viewport: snapshot.viewport,
|
|
2336
|
-
textEntries: snapshot.textEntries,
|
|
2337
|
-
html: snapshot.html
|
|
2338
|
-
})
|
|
2339
|
-
});
|
|
2340
|
-
if (response.status === 401) {
|
|
2341
|
-
this.handleSessionExpired();
|
|
2342
|
-
return null;
|
|
2343
|
-
}
|
|
2344
|
-
if (!response.ok) {
|
|
2345
|
-
throw new Error(`HTTP ${response.status}`);
|
|
2346
|
-
}
|
|
2347
|
-
const data = await response.json();
|
|
2348
|
-
return { snapshotUuid: data.snapshot_uuid || data.snapshotUuid };
|
|
2349
|
-
} catch (err) {
|
|
2350
|
-
this.onStatus("error", `Snapshot failed: ${err.message}`);
|
|
2351
|
-
return null;
|
|
2531
|
+
renderWorkflowStrip(host, status) {
|
|
2532
|
+
if (this.workflowInfo) {
|
|
2533
|
+
const node = this.buildWorkflowStripNode(this.workflowInfo, status);
|
|
2534
|
+
host.replaceChildren(...node ? [node] : []);
|
|
2352
2535
|
}
|
|
2536
|
+
void this.api.fetchWorkflowInfo(this.locale).then((info) => {
|
|
2537
|
+
if (!info || info === this.workflowInfo) return;
|
|
2538
|
+
this.workflowInfo = info;
|
|
2539
|
+
if (!this.current) return;
|
|
2540
|
+
const fresh = this.statusMap.get(this.current.key) ?? status ?? null;
|
|
2541
|
+
const node = this.buildWorkflowStripNode(info, fresh);
|
|
2542
|
+
if (host.isConnected) host.replaceChildren(...node ? [node] : []);
|
|
2543
|
+
});
|
|
2353
2544
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
html: snapshot.html
|
|
2374
|
-
})
|
|
2375
|
-
}
|
|
2376
|
-
);
|
|
2377
|
-
if (response.status === 401) {
|
|
2378
|
-
this.handleSessionExpired();
|
|
2379
|
-
return null;
|
|
2380
|
-
}
|
|
2381
|
-
if (!response.ok) {
|
|
2382
|
-
throw new Error(`HTTP ${response.status}`);
|
|
2545
|
+
buildWorkflowStripNode(info, status) {
|
|
2546
|
+
if (!info.stages || info.stages.length === 0) return null;
|
|
2547
|
+
let highlightIndex = null;
|
|
2548
|
+
if (info.isPmAdmin) {
|
|
2549
|
+
highlightIndex = typeof status?.stageIndex === "number" ? status.stageIndex : null;
|
|
2550
|
+
} else if (info.userAssignedStages.length > 0) {
|
|
2551
|
+
highlightIndex = info.userAssignedStages[0];
|
|
2552
|
+
}
|
|
2553
|
+
const strip = document.createElement("div");
|
|
2554
|
+
strip.className = "lr-wf-strip";
|
|
2555
|
+
const stagesRow = document.createElement("div");
|
|
2556
|
+
stagesRow.className = "lr-wf-strip-stages";
|
|
2557
|
+
info.stages.forEach((stage, i) => {
|
|
2558
|
+
const item = document.createElement("span");
|
|
2559
|
+
item.className = "lr-wf-strip-stage";
|
|
2560
|
+
if (highlightIndex !== null && stage.index === highlightIndex) {
|
|
2561
|
+
item.classList.add("is-active");
|
|
2562
|
+
} else {
|
|
2563
|
+
item.classList.add("is-muted");
|
|
2383
2564
|
}
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2565
|
+
item.textContent = stage.name;
|
|
2566
|
+
stagesRow.appendChild(item);
|
|
2567
|
+
if (i < info.stages.length - 1) {
|
|
2568
|
+
const sep = document.createElement("span");
|
|
2569
|
+
sep.className = "lr-wf-strip-sep";
|
|
2570
|
+
sep.textContent = "\u2192";
|
|
2571
|
+
stagesRow.appendChild(sep);
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
strip.appendChild(stagesRow);
|
|
2575
|
+
const caption = document.createElement("div");
|
|
2576
|
+
caption.className = "lr-wf-strip-caption";
|
|
2577
|
+
if (highlightIndex !== null) {
|
|
2578
|
+
const stage = info.stages.find((s) => s.index === highlightIndex);
|
|
2579
|
+
const name = stage?.name ?? `Stage ${highlightIndex + 1}`;
|
|
2580
|
+
const label = info.isPmAdmin ? "Row currently at" : "You are here";
|
|
2581
|
+
caption.textContent = `${label}: stage ${highlightIndex + 1} of ${info.stages.length} \u2014 ${name}`;
|
|
2582
|
+
} else if (!info.isPmAdmin && info.userAssignedStages.length === 0) {
|
|
2583
|
+
caption.textContent = "You have no stage assignment on this project.";
|
|
2584
|
+
} else {
|
|
2585
|
+
caption.textContent = `${info.stages.length} stages`;
|
|
2392
2586
|
}
|
|
2587
|
+
strip.appendChild(caption);
|
|
2588
|
+
return strip;
|
|
2393
2589
|
}
|
|
2394
|
-
/**
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
headers: { "Authorization": `Bearer ${this.sessionToken}` }
|
|
2403
|
-
}
|
|
2404
|
-
);
|
|
2405
|
-
if (response.status === 401) {
|
|
2406
|
-
this.handleSessionExpired();
|
|
2407
|
-
return null;
|
|
2408
|
-
}
|
|
2409
|
-
if (!response.ok) return null;
|
|
2410
|
-
return await response.json();
|
|
2411
|
-
} catch {
|
|
2412
|
-
return null;
|
|
2590
|
+
/** Re-fetch status for one key and re-render the current view. */
|
|
2591
|
+
async refreshStatusForKey(key) {
|
|
2592
|
+
const fresh = await this.api.fetchStatus([key], this.locale, this.namespace);
|
|
2593
|
+
for (const [k, s] of Object.entries(fresh)) {
|
|
2594
|
+
this.statusMap.set(k, s);
|
|
2595
|
+
}
|
|
2596
|
+
if (this.current) {
|
|
2597
|
+
this.renderContent(this.current);
|
|
2413
2598
|
}
|
|
2599
|
+
this.emitProgress();
|
|
2414
2600
|
}
|
|
2415
2601
|
/**
|
|
2416
|
-
*
|
|
2602
|
+
* Render an "Open linked page →" button when the selected block is or
|
|
2603
|
+
* contains a same-origin link. Cross-origin links are skipped — the editor
|
|
2604
|
+
* session only applies to this site. No-op when there's no usable link.
|
|
2417
2605
|
*/
|
|
2418
|
-
|
|
2606
|
+
renderLinkNav(element) {
|
|
2607
|
+
const anchor = element.matches?.("a[href]") ? element : element.querySelector?.("a[href]");
|
|
2608
|
+
if (!anchor) return;
|
|
2609
|
+
let dest;
|
|
2419
2610
|
try {
|
|
2420
|
-
|
|
2421
|
-
headers: { "Authorization": `Bearer ${this.sessionToken}` }
|
|
2422
|
-
});
|
|
2423
|
-
if (response.status === 401) {
|
|
2424
|
-
this.handleSessionExpired();
|
|
2425
|
-
return [];
|
|
2426
|
-
}
|
|
2427
|
-
if (!response.ok) return [];
|
|
2428
|
-
const data = await response.json();
|
|
2429
|
-
return data.snapshots || [];
|
|
2611
|
+
dest = new URL(anchor.href, window.location.href);
|
|
2430
2612
|
} catch {
|
|
2431
|
-
return
|
|
2613
|
+
return;
|
|
2432
2614
|
}
|
|
2615
|
+
if (dest.origin !== window.location.origin) return;
|
|
2616
|
+
if (dest.pathname === window.location.pathname && !dest.hash) return;
|
|
2617
|
+
this.addField("Linked page", () => {
|
|
2618
|
+
const btn = document.createElement("button");
|
|
2619
|
+
btn.className = "lr-link-nav";
|
|
2620
|
+
btn.textContent = "Open linked page \u2192";
|
|
2621
|
+
btn.title = dest.pathname + dest.search;
|
|
2622
|
+
btn.addEventListener("click", () => {
|
|
2623
|
+
window.location.href = localizeUrl(dest.href, this.defaultLocale);
|
|
2624
|
+
});
|
|
2625
|
+
return btn;
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
addField(label, contentFn) {
|
|
2629
|
+
const field = document.createElement("div");
|
|
2630
|
+
field.className = "lr-field";
|
|
2631
|
+
const labelEl = document.createElement("span");
|
|
2632
|
+
labelEl.className = "lr-field-label";
|
|
2633
|
+
labelEl.textContent = label;
|
|
2634
|
+
field.appendChild(labelEl);
|
|
2635
|
+
field.appendChild(contentFn());
|
|
2636
|
+
this.bodyEl.appendChild(field);
|
|
2433
2637
|
}
|
|
2434
2638
|
/**
|
|
2435
|
-
*
|
|
2639
|
+
* Get CSS class for the status dot based on actual row state, with one
|
|
2640
|
+
* viewer-relative override: rows that have advanced past the viewer's
|
|
2641
|
+
* stage assignment render as 'readonly' (greyed) — same data for everyone,
|
|
2642
|
+
* different signal per viewer. Avoids the new-color-per-stage explosion the
|
|
2643
|
+
* user was originally considering; reuses existing semantics.
|
|
2436
2644
|
*/
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2645
|
+
getDotClass(key) {
|
|
2646
|
+
if (this.unsavedKeys.has(key)) return "unsaved";
|
|
2647
|
+
const s = this.statusMap.get(key);
|
|
2648
|
+
if (!s) return this.api.canCreateMissing ? "missing" : "readonly";
|
|
2649
|
+
if (s.isLocked) return "locked";
|
|
2650
|
+
if (s.canEdit === false && typeof s.stageIndex === "number" && s.stageIndex > 0) return "readonly";
|
|
2651
|
+
if (s.isReady) return "ready";
|
|
2652
|
+
if (s.isConfirmed) return "confirmed";
|
|
2653
|
+
if (s.rowState === "edited") return "edited";
|
|
2654
|
+
if (s.rowState === "machine_translated") return "mt";
|
|
2655
|
+
return "missing";
|
|
2656
|
+
}
|
|
2657
|
+
renderTextWithTags(text) {
|
|
2658
|
+
const tagPattern = /<(\/?)?(\d+)(\/)?>/g;
|
|
2659
|
+
let result = "";
|
|
2660
|
+
let lastIndex = 0;
|
|
2661
|
+
let match;
|
|
2662
|
+
while ((match = tagPattern.exec(text)) !== null) {
|
|
2663
|
+
result += escapeHtml(text.slice(lastIndex, match.index));
|
|
2664
|
+
result += `<span class="lr-field-tag">${escapeHtml(match[0])}</span>`;
|
|
2665
|
+
lastIndex = match.index + match[0].length;
|
|
2453
2666
|
}
|
|
2667
|
+
return result + escapeHtml(text.slice(lastIndex));
|
|
2454
2668
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
);
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2669
|
+
renderPreview(text, styles) {
|
|
2670
|
+
if (!text) return "";
|
|
2671
|
+
if (Object.keys(styles).length > 0) {
|
|
2672
|
+
const unit = { text, styles };
|
|
2673
|
+
return htmlSerializer2.serialize(unit);
|
|
2674
|
+
}
|
|
2675
|
+
return escapeHtml(text);
|
|
2676
|
+
}
|
|
2677
|
+
initResize(handle) {
|
|
2678
|
+
let startX = 0;
|
|
2679
|
+
let startWidth = 0;
|
|
2680
|
+
const onMouseMove = (e) => {
|
|
2681
|
+
const newWidth = startWidth + (startX - e.clientX);
|
|
2682
|
+
const clamped = Math.max(280, Math.min(600, newWidth));
|
|
2683
|
+
this.el.style.width = `${clamped}px`;
|
|
2684
|
+
};
|
|
2685
|
+
const onMouseUp = () => {
|
|
2686
|
+
handle.classList.remove("active");
|
|
2687
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
2688
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
2689
|
+
};
|
|
2690
|
+
handle.addEventListener("mousedown", (e) => {
|
|
2691
|
+
e.preventDefault();
|
|
2692
|
+
startX = e.clientX;
|
|
2693
|
+
startWidth = this.el.offsetWidth;
|
|
2694
|
+
handle.classList.add("active");
|
|
2695
|
+
document.addEventListener("mousemove", onMouseMove);
|
|
2696
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
refreshBlocksList() {
|
|
2700
|
+
const existing = this.bodyEl.querySelector(".lr-blocks-section");
|
|
2701
|
+
if (existing) existing.remove();
|
|
2702
|
+
this.renderBlocksList();
|
|
2703
|
+
}
|
|
2704
|
+
renderBlocksList() {
|
|
2705
|
+
const section = document.createElement("div");
|
|
2706
|
+
section.className = "lr-blocks-section";
|
|
2707
|
+
const blocks = this.blockExtractor.extract(document.body, true);
|
|
2708
|
+
const translatedCount = this.getTranslatedCount();
|
|
2709
|
+
const title = document.createElement("div");
|
|
2710
|
+
title.className = "lr-blocks-title";
|
|
2711
|
+
title.textContent = `Blocks on this page (${translatedCount}/${blocks.length} translated)`;
|
|
2712
|
+
section.appendChild(title);
|
|
2713
|
+
for (const block of blocks) {
|
|
2714
|
+
const item = document.createElement("div");
|
|
2715
|
+
item.className = "lr-block-item";
|
|
2716
|
+
const isActive = this.current && block.element === this.current.element;
|
|
2717
|
+
if (isActive) item.classList.add("active");
|
|
2718
|
+
const dot = document.createElement("span");
|
|
2719
|
+
dot.className = "lr-status-dot";
|
|
2720
|
+
dot.classList.add(this.getDotClass(block.key));
|
|
2721
|
+
item.appendChild(dot);
|
|
2722
|
+
const textWrap = document.createElement("div");
|
|
2723
|
+
textWrap.className = "lr-block-text";
|
|
2724
|
+
const displayText = block.unit.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
2725
|
+
const sourceSpan = document.createElement("div");
|
|
2726
|
+
sourceSpan.className = "lr-block-source";
|
|
2727
|
+
sourceSpan.textContent = displayText.length > 60 ? displayText.slice(0, 60) + "..." : displayText;
|
|
2728
|
+
sourceSpan.title = displayText;
|
|
2729
|
+
textWrap.appendChild(sourceSpan);
|
|
2730
|
+
const blockStatus = this.statusMap.get(block.key);
|
|
2731
|
+
if (blockStatus && blockStatus.text) {
|
|
2732
|
+
const previewSpan = document.createElement("div");
|
|
2733
|
+
previewSpan.className = "lr-block-preview";
|
|
2734
|
+
const previewText = blockStatus.text.replace(/<\/?[0-9]+\/?>/g, "");
|
|
2735
|
+
previewSpan.textContent = previewText.length > 50 ? previewText.slice(0, 50) + "..." : previewText;
|
|
2736
|
+
textWrap.appendChild(previewSpan);
|
|
2476
2737
|
}
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2738
|
+
item.appendChild(textWrap);
|
|
2739
|
+
item.addEventListener("click", () => {
|
|
2740
|
+
block.element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
2741
|
+
this.onBlockClick({ key: block.key, unit: block.unit, translatedUnit: null, element: block.element });
|
|
2742
|
+
});
|
|
2743
|
+
section.appendChild(item);
|
|
2481
2744
|
}
|
|
2745
|
+
this.bodyEl.appendChild(section);
|
|
2482
2746
|
}
|
|
2483
2747
|
};
|
|
2484
2748
|
|
|
@@ -2698,17 +2962,105 @@ var LionRapidEditor = (() => {
|
|
|
2698
2962
|
});
|
|
2699
2963
|
}
|
|
2700
2964
|
|
|
2965
|
+
// src/browser/sanitizer.ts
|
|
2966
|
+
var REMOVE_TAGS = /* @__PURE__ */ new Set([
|
|
2967
|
+
"script",
|
|
2968
|
+
"iframe",
|
|
2969
|
+
"frame",
|
|
2970
|
+
"frameset",
|
|
2971
|
+
"object",
|
|
2972
|
+
"embed",
|
|
2973
|
+
"applet",
|
|
2974
|
+
"base",
|
|
2975
|
+
"meta",
|
|
2976
|
+
"link",
|
|
2977
|
+
"template",
|
|
2978
|
+
"portal"
|
|
2979
|
+
]);
|
|
2980
|
+
var URL_ATTRS2 = /* @__PURE__ */ new Set([
|
|
2981
|
+
"href",
|
|
2982
|
+
"src",
|
|
2983
|
+
"action",
|
|
2984
|
+
"formaction",
|
|
2985
|
+
"poster",
|
|
2986
|
+
"data",
|
|
2987
|
+
"xlink:href"
|
|
2988
|
+
]);
|
|
2989
|
+
function sanitizeElement(root) {
|
|
2990
|
+
const all = [root, ...Array.from(root.querySelectorAll("*"))];
|
|
2991
|
+
for (const el of all) {
|
|
2992
|
+
const tag = el.tagName.toLowerCase();
|
|
2993
|
+
if (REMOVE_TAGS.has(tag)) {
|
|
2994
|
+
el.remove();
|
|
2995
|
+
continue;
|
|
2996
|
+
}
|
|
2997
|
+
for (const name of el.getAttributeNames()) {
|
|
2998
|
+
const lower = name.toLowerCase();
|
|
2999
|
+
if (lower.startsWith("on") || lower === "srcdoc") {
|
|
3000
|
+
el.removeAttribute(name);
|
|
3001
|
+
continue;
|
|
3002
|
+
}
|
|
3003
|
+
if (URL_ATTRS2.has(lower)) {
|
|
3004
|
+
if (sanitizeUrl(el.getAttribute(name) ?? "") === null) {
|
|
3005
|
+
el.removeAttribute(name);
|
|
3006
|
+
}
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
if (lower === "style") {
|
|
3010
|
+
const clean = sanitizeCssDeclarations(el.getAttribute(name) ?? "");
|
|
3011
|
+
if (clean === null) el.removeAttribute(name);
|
|
3012
|
+
else el.setAttribute(name, clean);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
function sanitizeStyleSheetText(css, baseHref) {
|
|
3018
|
+
let out = css.replace(/@import[^;]*(;|$)/gi, "");
|
|
3019
|
+
out = out.replace(
|
|
3020
|
+
/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi,
|
|
3021
|
+
(full, _quote, target) => {
|
|
3022
|
+
try {
|
|
3023
|
+
const resolved = new URL(target, baseHref);
|
|
3024
|
+
if (resolved.protocol === "data:") return full;
|
|
3025
|
+
if (resolved.origin === new URL(baseHref).origin) return full;
|
|
3026
|
+
} catch {
|
|
3027
|
+
}
|
|
3028
|
+
return 'url("")';
|
|
3029
|
+
}
|
|
3030
|
+
);
|
|
3031
|
+
return out.replace(/expression\s*\(/gi, "blocked(");
|
|
3032
|
+
}
|
|
3033
|
+
|
|
2701
3034
|
// src/browser/editor/snapshot-viewer.ts
|
|
2702
3035
|
async function activateSnapshotViewer(snapshotUuid, api) {
|
|
2703
3036
|
const snapshot = await api.getSnapshot(snapshotUuid);
|
|
2704
3037
|
if (!snapshot) return null;
|
|
2705
3038
|
const parser = new DOMParser();
|
|
2706
3039
|
const doc = parser.parseFromString(snapshot.html, "text/html");
|
|
3040
|
+
sanitizeElement(doc.body);
|
|
2707
3041
|
const snapshotStyles = doc.head.querySelectorAll('link[rel="stylesheet"], style');
|
|
2708
3042
|
for (let i = 0; i < snapshotStyles.length; i++) {
|
|
2709
|
-
const
|
|
2710
|
-
|
|
2711
|
-
|
|
3043
|
+
const source = snapshotStyles[i];
|
|
3044
|
+
if (source.tagName.toLowerCase() === "link") {
|
|
3045
|
+
const href = source.getAttribute("href") ?? "";
|
|
3046
|
+
let sameOrigin = false;
|
|
3047
|
+
try {
|
|
3048
|
+
sameOrigin = new URL(href, window.location.href).origin === window.location.origin;
|
|
3049
|
+
} catch {
|
|
3050
|
+
}
|
|
3051
|
+
if (!sameOrigin) continue;
|
|
3052
|
+
const clone = source.cloneNode(true);
|
|
3053
|
+
clone.setAttribute("data-lr-snapshot-style", "");
|
|
3054
|
+
document.head.appendChild(clone);
|
|
3055
|
+
} else {
|
|
3056
|
+
const style = document.createElement("style");
|
|
3057
|
+
style.textContent = sanitizeStyleSheetText(
|
|
3058
|
+
source.textContent ?? "",
|
|
3059
|
+
window.location.href
|
|
3060
|
+
);
|
|
3061
|
+
style.setAttribute("data-lr-snapshot-style", "");
|
|
3062
|
+
document.head.appendChild(style);
|
|
3063
|
+
}
|
|
2712
3064
|
}
|
|
2713
3065
|
if (snapshot.title) {
|
|
2714
3066
|
document.title = `[Snapshot] ${snapshot.title}`;
|
|
@@ -2864,7 +3216,7 @@ var LionRapidEditor = (() => {
|
|
|
2864
3216
|
const poll = setInterval(() => {
|
|
2865
3217
|
attempts++;
|
|
2866
3218
|
const lr = window.LionRapid;
|
|
2867
|
-
if (lr) {
|
|
3219
|
+
if (lr && typeof lr.getCurrentLocale === "function") {
|
|
2868
3220
|
clearInterval(poll);
|
|
2869
3221
|
activate(lr, grant, locale, apiBase, projectId);
|
|
2870
3222
|
} else if (attempts >= maxAttempts) {
|