@lionrapid/core 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/base/event-emitter.d.ts.map +1 -1
  2. package/dist/base/event-emitter.js +2 -1
  3. package/dist/base/event-emitter.js.map +1 -1
  4. package/dist/browser/attribute-extractor.d.ts.map +1 -1
  5. package/dist/browser/attribute-extractor.js +2 -1
  6. package/dist/browser/attribute-extractor.js.map +1 -1
  7. package/dist/browser/auto-init.d.ts +73 -0
  8. package/dist/browser/auto-init.d.ts.map +1 -1
  9. package/dist/browser/auto-init.js +193 -62
  10. package/dist/browser/auto-init.js.map +1 -1
  11. package/dist/browser/block-extractor.d.ts.map +1 -1
  12. package/dist/browser/block-extractor.js +3 -1
  13. package/dist/browser/block-extractor.js.map +1 -1
  14. package/dist/browser/editor/editor-api.d.ts +50 -6
  15. package/dist/browser/editor/editor-api.d.ts.map +1 -1
  16. package/dist/browser/editor/editor-api.js +121 -19
  17. package/dist/browser/editor/editor-api.js.map +1 -1
  18. package/dist/browser/editor/editor-init.js +4 -1
  19. package/dist/browser/editor/editor-init.js.map +1 -1
  20. package/dist/browser/editor/sidebar.d.ts +4 -3
  21. package/dist/browser/editor/sidebar.d.ts.map +1 -1
  22. package/dist/browser/editor/sidebar.js +93 -27
  23. package/dist/browser/editor/sidebar.js.map +1 -1
  24. package/dist/browser/editor/snapshot-viewer.d.ts.map +1 -1
  25. package/dist/browser/editor/snapshot-viewer.js +32 -3
  26. package/dist/browser/editor/snapshot-viewer.js.map +1 -1
  27. package/dist/browser/lionrapid.auto.dev.js +444 -149
  28. package/dist/browser/lionrapid.auto.dev.js.map +4 -4
  29. package/dist/browser/lionrapid.auto.js +444 -149
  30. package/dist/browser/lionrapid.auto.js.map +4 -4
  31. package/dist/browser/lionrapid.auto.min.js +4 -3
  32. package/dist/browser/lionrapid.auto.min.js.map +4 -4
  33. package/dist/browser/lionrapid.editor.js +1467 -1115
  34. package/dist/browser/lionrapid.editor.js.map +4 -4
  35. package/dist/browser/lionrapid.editor.min.js +4 -3
  36. package/dist/browser/lionrapid.editor.min.js.map +4 -4
  37. package/dist/browser/lionrapid.esm.js +590 -101
  38. package/dist/browser/lionrapid.esm.js.map +4 -4
  39. package/dist/browser/lionrapid.esm.min.js +3 -2
  40. package/dist/browser/lionrapid.esm.min.js.map +4 -4
  41. package/dist/browser/lionrapid.iife.dev.js +590 -101
  42. package/dist/browser/lionrapid.iife.dev.js.map +4 -4
  43. package/dist/browser/lionrapid.iife.js +590 -101
  44. package/dist/browser/lionrapid.iife.js.map +4 -4
  45. package/dist/browser/lionrapid.iife.min.js +3 -2
  46. package/dist/browser/lionrapid.iife.min.js.map +4 -4
  47. package/dist/browser/lionrapid.umd.js +590 -101
  48. package/dist/browser/lionrapid.umd.js.map +4 -4
  49. package/dist/browser/lionrapid.umd.min.js +3 -2
  50. package/dist/browser/lionrapid.umd.min.js.map +4 -4
  51. package/dist/browser/sanitizer.d.ts +37 -0
  52. package/dist/browser/sanitizer.d.ts.map +1 -0
  53. package/dist/browser/sanitizer.js +93 -0
  54. package/dist/browser/sanitizer.js.map +1 -0
  55. package/dist/builder.d.ts +8 -0
  56. package/dist/builder.d.ts.map +1 -1
  57. package/dist/builder.js +33 -5
  58. package/dist/builder.js.map +1 -1
  59. package/dist/config/lionrapid.config.d.ts +0 -1
  60. package/dist/config/lionrapid.config.d.ts.map +1 -1
  61. package/dist/config/repository.config.d.ts +2 -2
  62. package/dist/config/repository.config.d.ts.map +1 -1
  63. package/dist/content/index.d.ts +3 -0
  64. package/dist/content/index.d.ts.map +1 -1
  65. package/dist/content/index.js +5 -0
  66. package/dist/content/index.js.map +1 -1
  67. package/dist/content/sanitizer/HTMLSanitizer.d.ts +105 -0
  68. package/dist/content/sanitizer/HTMLSanitizer.d.ts.map +1 -0
  69. package/dist/content/sanitizer/HTMLSanitizer.js +257 -0
  70. package/dist/content/sanitizer/HTMLSanitizer.js.map +1 -0
  71. package/dist/content/serializer/HTMLSerializer.d.ts +6 -2
  72. package/dist/content/serializer/HTMLSerializer.d.ts.map +1 -1
  73. package/dist/content/serializer/HTMLSerializer.js +16 -8
  74. package/dist/content/serializer/HTMLSerializer.js.map +1 -1
  75. package/dist/content/splitter/BlockSplitter.d.ts +66 -0
  76. package/dist/content/splitter/BlockSplitter.d.ts.map +1 -0
  77. package/dist/content/splitter/BlockSplitter.js +219 -0
  78. package/dist/content/splitter/BlockSplitter.js.map +1 -0
  79. package/dist/index.d.ts +4 -2
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +17 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/lionRapid.d.ts +2 -2
  84. package/dist/lionRapid.d.ts.map +1 -1
  85. package/dist/lionRapid.js +19 -7
  86. package/dist/lionRapid.js.map +1 -1
  87. package/dist/repositories/network-repository.d.ts +30 -8
  88. package/dist/repositories/network-repository.d.ts.map +1 -1
  89. package/dist/repositories/network-repository.js +88 -74
  90. package/dist/repositories/network-repository.js.map +1 -1
  91. package/dist/types/lionRapid.types.d.ts +2 -0
  92. package/dist/types/lionRapid.types.d.ts.map +1 -1
  93. package/dist/types/translation.types.d.ts +0 -3
  94. package/dist/types/translation.types.d.ts.map +1 -1
  95. package/dist/utils/context-utils.d.ts +1 -2
  96. package/dist/utils/context-utils.d.ts.map +1 -1
  97. package/dist/utils/context-utils.js +3 -6
  98. package/dist/utils/context-utils.js.map +1 -1
  99. package/dist/utils/locale.d.ts +57 -0
  100. package/dist/utils/locale.d.ts.map +1 -0
  101. package/dist/utils/locale.js +116 -0
  102. package/dist/utils/locale.js.map +1 -0
  103. package/dist/utils/translation-flow.d.ts +1 -1
  104. package/dist/utils/translation-flow.d.ts.map +1 -1
  105. package/dist/utils/translation-flow.js +2 -3
  106. package/dist/utils/translation-flow.js.map +1 -1
  107. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
- if (rawOpen) {
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 += rawOpen;
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/exclude-filter.ts
1240
- var DEFAULT_PATTERNS = {
1241
- emails: true,
1242
- urls: true,
1243
- numbers: true,
1244
- phones: true
1245
- };
1246
- var PATTERNS = {
1247
- email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
1248
- url: /^(https?:\/\/|www\.)[^\s]+$/i,
1249
- number: /^[\d\s.,+-]+$/,
1250
- phone: /^[+\d][\d\s().-]{6,}$/
1251
- };
1252
- var ExcludeFilter = class {
1253
- constructor(patterns = {}, minTextLength = 2) {
1254
- __publicField(this, "patterns");
1255
- __publicField(this, "minTextLength");
1256
- this.patterns = { ...DEFAULT_PATTERNS, ...patterns };
1257
- this.minTextLength = minTextLength;
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
- * Check if text should be excluded from translation
1450
+ * Start auto-flush (30s interval + visibilitychange) and session renewal (60s check)
1261
1451
  */
1262
- shouldExclude(text) {
1263
- const trimmed = text.trim();
1264
- if (trimmed.length < this.minTextLength) {
1265
- return true;
1266
- }
1267
- if (this.patterns.emails && PATTERNS.email.test(trimmed)) {
1268
- return true;
1269
- }
1270
- if (this.patterns.urls && PATTERNS.url.test(trimmed)) {
1271
- return true;
1272
- }
1273
- if (this.patterns.numbers && PATTERNS.number.test(trimmed)) {
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.patterns.phones && PATTERNS.phone.test(trimmed)) {
1277
- return true;
1465
+ if (this.renewalInterval) {
1466
+ clearInterval(this.renewalInterval);
1467
+ this.renewalInterval = null;
1278
1468
  }
1279
- return false;
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
- * Extract all translatable block elements from a root.
1318
- * @param includeTranslated - If true, include elements already marked as translated (for editor mode)
1473
+ * Queue a translation for saving
1319
1474
  */
1320
- extract(root, includeTranslated = false) {
1321
- const results = [];
1322
- const descendants = root.querySelectorAll(BLOCK_SELECTOR3);
1323
- const elements = [];
1324
- if (root instanceof Element && root.matches(BLOCK_SELECTOR3)) {
1325
- elements.push(root);
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
- for (let i = 0; i < descendants.length; i++) {
1328
- elements.push(descendants[i]);
1484
+ this.onStatus("unsaved", `${this.queue.length} unsaved`);
1485
+ if (this.queue.length > 50) {
1486
+ this.flush();
1329
1487
  }
1330
- for (let i = 0; i < elements.length; i++) {
1331
- const el = elements[i];
1332
- if (this.isInsideSkipTag(el)) {
1333
- continue;
1334
- }
1335
- if (!includeTranslated && this.hasSkipAttribute(el)) {
1336
- continue;
1337
- }
1338
- if (this.matchesExcludeSelector(el)) {
1339
- continue;
1340
- }
1341
- if (el.getAttribute("translate") === "no") {
1342
- continue;
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 (el.querySelector(BLOCK_SELECTOR3)) {
1345
- continue;
1514
+ if (!response.ok) {
1515
+ throw new Error(`HTTP ${response.status}`);
1346
1516
  }
1347
- const lrBlock = el.getAttribute("data-lr-block");
1348
- const lrOriginal = el.getAttribute("data-lr-original");
1349
- let unit;
1350
- if (lrOriginal) {
1351
- const temp = document.createElement(el.tagName);
1352
- temp.innerHTML = lrOriginal;
1353
- unit = parseBlockElement(temp);
1354
- } else {
1355
- unit = parseBlockElement(el);
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
- const strippedText = unit.text.replace(/<\/?[0-9]+\/?>/g, "");
1358
- if (this.excludeFilter.shouldExclude(strippedText)) {
1359
- continue;
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
- let key;
1362
- if (lrBlock) {
1363
- key = lrBlock;
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
- const dataI18n = el.getAttribute("data-i18n");
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
- results.push({ element: el, unit, key });
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
- * Second pass: extract inline elements (currently <a>) that have no block
1379
- * ancestor. These would otherwise slip through both the block pass (not in
1380
- * BLOCK_SELECTOR) and the inline-style capture (no parent block to host
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
- extractOrphanInline(root, results, includeTranslated) {
1385
- const candidates = root.querySelectorAll(INLINE_BLOCK_SELECTOR);
1386
- const elements = [];
1387
- if (root instanceof Element && root.matches(INLINE_BLOCK_SELECTOR)) {
1388
- elements.push(root);
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
- isInsideSkipTag(element) {
1429
- let current = element.parentElement;
1430
- while (current) {
1431
- if (SKIP_TAGS2.has(current.tagName)) {
1432
- return true;
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
- current = current.parentElement;
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
- hasSkipAttribute(element) {
1439
- let current = element;
1440
- while (current) {
1441
- for (const attr of SKIP_ATTRIBUTES) {
1442
- if (current.hasAttribute(attr)) {
1443
- return true;
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
- current = current.parentElement;
1447
- }
1448
- return false;
1644
+ })();
1645
+ this.workflowInfoCache.set(cacheKey, { promise, fetchedAt: Date.now() });
1646
+ return promise;
1449
1647
  }
1450
- matchesExcludeSelector(element) {
1451
- for (const selector of this.excludeSelectors) {
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
- if (element.matches(selector) || element.closest(selector)) {
1454
- return true;
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
- // src/browser/editor/locale-url.ts
1464
- function localizeUrl(href, defaultLocale) {
1465
- let url;
1466
- try {
1467
- url = new URL(href, window.location.href);
1468
- } catch {
1469
- return href;
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
- if (url.origin !== window.location.origin) return href;
1472
- const currentMatch = window.location.pathname.match(/^\/([a-z]{2})(\/|$)/);
1473
- const currentLocale = currentMatch ? currentMatch[1] : defaultLocale;
1474
- if (currentLocale === defaultLocale) return url.href;
1475
- if (/^\/[a-z]{2}(\/|$)/.test(url.pathname)) return url.href;
1476
- url.pathname = "/" + currentLocale + url.pathname;
1477
- return url.href;
1478
- }
1479
-
1480
- // src/browser/editor/sidebar.ts
1481
- var htmlSerializer2 = new HTMLSerializer();
1482
- var Sidebar = class {
1483
- constructor(shadowRoot, api, locale, namespace, defaultLocale, onBlockClick, userRole = "") {
1484
- __publicField(this, "el");
1485
- __publicField(this, "bodyEl");
1486
- __publicField(this, "statusEl");
1487
- __publicField(this, "api");
1488
- __publicField(this, "locale");
1489
- __publicField(this, "namespace");
1490
- __publicField(this, "defaultLocale");
1491
- __publicField(this, "current", null);
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 = "&times;";
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
- shadowRoot.appendChild(this.el);
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
- setProgressCallback(cb) {
1601
- this.onProgressUpdate = cb;
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
- * Hydrate the status map with server data for all page blocks.
1605
- * Called once on editor init.
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 hydrateStatus() {
1608
- const blocks = this.blockExtractor.extract(document.body, true);
1609
- const keys = blocks.map((b) => b.key);
1610
- if (keys.length === 0) return;
1611
- const statuses = await this.api.fetchStatus(keys, this.locale, this.namespace);
1612
- for (const [key, status] of Object.entries(statuses)) {
1613
- this.statusMap.set(key, status);
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
- const divergent = [];
1616
- let untranslatedCount = 0;
1617
- for (const block of blocks) {
1618
- const status = this.statusMap.get(block.key);
1619
- if (status && status.text && !status.isReady) {
1620
- applyLivePreview(block.element, status.text, block.unit.styles, block.key, this.locale, this.namespace, block.unit.text, status.rowState);
1621
- } else if (!status || !status.text) {
1622
- untranslatedCount++;
1623
- const stamp = block.element.getAttribute("data-lr-block");
1624
- if (stamp && stamp.startsWith("auto_")) {
1625
- const recomputed = generateAutoKey(block.unit.text);
1626
- if (recomputed !== stamp) {
1627
- divergent.push({ stamped: stamp, recomputed, text: block.unit.text.slice(0, 80) });
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
- if (divergent.length > 0) {
1633
- console.warn(
1634
- `[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):`,
1635
- divergent.slice(0, 5)
1636
- );
1637
- } else if (untranslatedCount > 0) {
1638
- console.debug(
1639
- `[LionRapid Editor] ${untranslatedCount} block(s) untranslated (no server text yet) \u2014 normal for in-progress pages.`
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
- getTranslatedCount() {
1645
- let count = 0;
1646
- for (const status of this.statusMap.values()) {
1647
- if (status && status.rowState !== "empty") count++;
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
- emitProgress() {
1655
- if (this.onProgressUpdate) {
1656
- this.onProgressUpdate(this.getTranslatedCount(), this.getTotalCount());
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
- destroy() {
1660
- this.el.remove();
1661
- }
1662
- async renderContent(resolved) {
1663
- const { key, unit, element } = resolved;
1664
- this.bodyEl.innerHTML = "";
1665
- const isStale = this.lastFlushTime > this.lastFetchTime || this.lastFetchTime === 0;
1666
- if (isStale) {
1667
- const blocks = this.blockExtractor.extract(document.body, true);
1668
- const allKeys = blocks.map((b) => b.key);
1669
- if (!allKeys.includes(key)) allKeys.push(key);
1670
- const freshStatuses = await this.api.fetchStatus(allKeys, this.locale, this.namespace);
1671
- for (const [k, s] of Object.entries(freshStatuses)) {
1672
- this.statusMap.set(k, s);
1673
- }
1674
- this.lastFetchTime = Date.now();
1675
- } else {
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
- return previewEl;
1717
- });
1718
- if (!isSourceLocale) {
1719
- this.renderWorkflowActions(key, status);
1720
- }
1721
- if (!isLocked) {
1722
- textarea.addEventListener("input", () => {
1723
- const text = textarea.value;
1724
- previewEl.innerHTML = this.renderPreview(text, unit.styles);
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
- this.unsavedKeys.add(key);
1729
- this.api.queueSave({
1730
- key,
1731
- locale: this.locale,
1732
- namespace: this.namespace,
1733
- text,
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
- * Render Confirm/Reject/Mark-Ready/Unmark-Ready buttons under the preview.
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
- renderWorkflowActions(key, status) {
1756
- const isPM = this.userRole === "project_manager" || this.userRole === "admin";
1757
- const isReady = !!status?.isReady;
1758
- const isLocked = !!status?.isLocked;
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 (isPM) {
1795
- if (!isReady) {
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 (!canEdit && !isPM && !isReady && !isLocked) {
1802
- const note = document.createElement("span");
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 (wrap.children.length > 0) {
1808
- this.bodyEl.appendChild(wrap);
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
- * Render the workflow context strip ('Workflow: A → B → C — you are at stage X').
1813
- * Highlights the user's POV stage and greys the rest.
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
- renderWorkflowStrip(host, status) {
1823
- if (this.workflowInfo) {
1824
- const node = this.buildWorkflowStripNode(this.workflowInfo, status);
1825
- host.replaceChildren(...node ? [node] : []);
1826
- return;
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
- if (this.workflowInfoFetched) return;
1829
- this.workflowInfoFetched = true;
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
- const strip = document.createElement("div");
1847
- strip.className = "lr-wf-strip";
1848
- const stagesRow = document.createElement("div");
1849
- stagesRow.className = "lr-wf-strip-stages";
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
- item.textContent = stage.name;
1859
- stagesRow.appendChild(item);
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
- strip.appendChild(stagesRow);
1868
- const caption = document.createElement("div");
1869
- caption.className = "lr-wf-strip-caption";
1870
- if (highlightIndex !== null) {
1871
- const stage = info.stages.find((s) => s.index === highlightIndex);
1872
- const name = stage?.name ?? `Stage ${highlightIndex + 1}`;
1873
- const label = info.isPmAdmin ? "Row currently at" : "You are here";
1874
- caption.textContent = `${label}: stage ${highlightIndex + 1} of ${info.stages.length} \u2014 ${name}`;
1875
- } else if (!info.isPmAdmin && info.userAssignedStages.length === 0) {
1876
- caption.textContent = "You have no stage assignment on this project.";
1877
- } else {
1878
- caption.textContent = `${info.stages.length} stages`;
1879
- }
1880
- strip.appendChild(caption);
1881
- return strip;
1882
- }
1883
- /** Re-fetch status for one key and re-render the current view. */
1884
- async refreshStatusForKey(key) {
1885
- const fresh = await this.api.fetchStatus([key], this.locale, this.namespace);
1886
- for (const [k, s] of Object.entries(fresh)) {
1887
- this.statusMap.set(k, s);
1888
- }
1889
- if (this.current) {
1890
- this.renderContent(this.current);
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.emitProgress();
2062
+ this.extractOrphanInline(root, results, includeTranslated);
2063
+ return results;
1893
2064
  }
1894
2065
  /**
1895
- * Render an "Open linked page →" button when the selected block is or
1896
- * contains a same-origin link. Cross-origin links are skipped — the editor
1897
- * session only applies to this site. No-op when there's no usable link.
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
- renderLinkNav(element) {
1900
- const anchor = element.matches?.("a[href]") ? element : element.querySelector?.("a[href]");
1901
- if (!anchor) return;
1902
- let dest;
1903
- try {
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
- if (dest.origin !== window.location.origin) return;
1909
- if (dest.pathname === window.location.pathname && !dest.hash) return;
1910
- this.addField("Linked page", () => {
1911
- const btn = document.createElement("button");
1912
- btn.className = "lr-link-nav";
1913
- btn.textContent = "Open linked page \u2192";
1914
- btn.title = dest.pathname + dest.search;
1915
- btn.addEventListener("click", () => {
1916
- window.location.href = localizeUrl(dest.href, this.defaultLocale);
1917
- });
1918
- return btn;
1919
- });
1920
- }
1921
- addField(label, contentFn) {
1922
- const field = document.createElement("div");
1923
- field.className = "lr-field";
1924
- const labelEl = document.createElement("span");
1925
- labelEl.className = "lr-field-label";
1926
- labelEl.textContent = label;
1927
- field.appendChild(labelEl);
1928
- field.appendChild(contentFn());
1929
- this.bodyEl.appendChild(field);
1930
- }
1931
- /**
1932
- * Get CSS class for the status dot based on actual row state, with one
1933
- * viewer-relative override: rows that have advanced past the viewer's
1934
- * stage assignment render as 'readonly' (greyed) — same data for everyone,
1935
- * different signal per viewer. Avoids the new-color-per-stage explosion the
1936
- * user was originally considering; reuses existing semantics.
1937
- */
1938
- getDotClass(key) {
1939
- if (this.unsavedKeys.has(key)) return "unsaved";
1940
- const s = this.statusMap.get(key);
1941
- if (!s) return "missing";
1942
- if (s.isLocked) return "locked";
1943
- if (s.canEdit === false && typeof s.stageIndex === "number" && s.stageIndex > 0) return "readonly";
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
- initResize(handle) {
1965
- let startX = 0;
1966
- let startWidth = 0;
1967
- const onMouseMove = (e) => {
1968
- const newWidth = startWidth + (startX - e.clientX);
1969
- const clamped = Math.max(280, Math.min(600, newWidth));
1970
- this.el.style.width = `${clamped}px`;
1971
- };
1972
- const onMouseUp = () => {
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
- refreshBlocksList() {
1987
- const existing = this.bodyEl.querySelector(".lr-blocks-section");
1988
- if (existing) existing.remove();
1989
- this.renderBlocksList();
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
- renderBlocksList() {
1992
- const section = document.createElement("div");
1993
- section.className = "lr-blocks-section";
1994
- const blocks = this.blockExtractor.extract(document.body, true);
1995
- const translatedCount = this.getTranslatedCount();
1996
- const title = document.createElement("div");
1997
- title.className = "lr-blocks-title";
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
- this.bodyEl.appendChild(section);
2147
+ return false;
2033
2148
  }
2034
2149
  };
2035
2150
 
2036
- // src/browser/editor/editor-api.ts
2037
- var EditorAPI = class {
2038
- constructor(apiBase, sessionToken, projectId) {
2039
- __publicField(this, "queue", []);
2040
- __publicField(this, "apiBase");
2041
- __publicField(this, "sessionToken");
2042
- __publicField(this, "projectId");
2043
- __publicField(this, "flushInterval", null);
2044
- __publicField(this, "renewalInterval", null);
2045
- __publicField(this, "onStatus", () => {
2046
- });
2047
- __publicField(this, "onFlushed", () => {
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 = "&times;";
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
- __publicField(this, "onSessionExpired", null);
2050
- // Workflow info is project-scoped and stable for the editor session lifetime.
2051
- // Cached as a Promise so concurrent callers share one fetch.
2052
- __publicField(this, "workflowInfoPromise", null);
2053
- __publicField(this, "statusAbort", null);
2054
- __publicField(this, "handleVisibility", () => {
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
- this.apiBase = apiBase.replace(/\/$/, "");
2062
- this.sessionToken = sessionToken;
2063
- this.projectId = projectId;
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
- setStatusCallback(cb) {
2066
- this.onStatus = cb;
2272
+ open(resolved) {
2273
+ this.current = resolved;
2274
+ this.el.classList.add("open");
2275
+ this.renderContent(resolved);
2067
2276
  }
2068
- setFlushedCallback(cb) {
2069
- this.onFlushed = cb;
2277
+ close() {
2278
+ this.current = null;
2279
+ this.el.classList.remove("open");
2070
2280
  }
2071
- setSessionExpiredCallback(cb) {
2072
- this.onSessionExpired = cb;
2281
+ isOpen() {
2282
+ return this.el.classList.contains("open");
2073
2283
  }
2074
- /**
2075
- * Start auto-flush (30s interval + visibilitychange) and session renewal (60s check)
2076
- */
2077
- startAutoFlush() {
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
- * Stop auto-flush and flush remaining items
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
- * Queue a translation for saving
2293
+ * Hydrate the status map with server data for all page blocks.
2294
+ * Called once on editor init.
2099
2295
  */
2100
- queueSave(item) {
2101
- const idx = this.queue.findIndex(
2102
- (q) => q.key === item.key && q.locale === item.locale && q.namespace === item.namespace
2103
- );
2104
- if (idx >= 0) {
2105
- this.queue[idx] = item;
2106
- } else {
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
- * Flush all queued translations to the server
2116
- */
2117
- async flush() {
2118
- if (this.queue.length === 0) return;
2119
- const items = [...this.queue];
2120
- this.onStatus("saving");
2121
- try {
2122
- const response = await this.authFetch(
2123
- `${this.apiBase}/api/integrations/targets/batch`,
2124
- {
2125
- method: "PUT",
2126
- headers: { "Content-Type": "application/json" },
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
- * Fetch translation status for a batch of keys (includes unconfirmed rows).
2159
- * Cancels any in-flight status request. Caps at 200 keys per request.
2160
- */
2161
- async fetchStatus(keys, locale, namespace) {
2162
- if (keys.length === 0) return {};
2163
- if (this.statusAbort) this.statusAbort.abort();
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
- get pendingCount() {
2185
- return this.queue.length;
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
- * Fetch workflow context for the project (stages, user's stage assignments,
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
- // Session renewal
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
- * Attempt one renewal and signal whether the token actually changed.
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 renewSession() {
2245
- try {
2246
- const response = await fetch(`${this.apiBase}/api/editor/renew`, {
2247
- method: "POST",
2248
- headers: {
2249
- "Authorization": `Bearer ${this.sessionToken}`
2250
- }
2251
- });
2252
- if (response.status === 401) {
2253
- this.handleSessionExpired();
2254
- return;
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
- if (!response.ok) return;
2257
- const data = await response.json();
2258
- this.sessionToken = data.session_token;
2259
- try {
2260
- const raw = this.sessionToken.startsWith("es_") ? this.sessionToken.slice(3) : this.sessionToken;
2261
- const parts = raw.split(".");
2262
- if (parts.length === 3) {
2263
- const payload = JSON.parse(atob(parts[1]));
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
- } catch {
2407
+ return previewEl;
2408
+ });
2409
+ if (!isSourceLocale) {
2410
+ this.renderWorkflowActions(key, status);
2273
2411
  }
2274
- }
2275
- getSessionExp() {
2276
- try {
2277
- const raw = this.sessionToken.startsWith("es_") ? this.sessionToken.slice(3) : this.sessionToken;
2278
- const parts = raw.split(".");
2279
- if (parts.length !== 3) return null;
2280
- const payload = JSON.parse(atob(parts[1]));
2281
- return payload.exp || null;
2282
- } catch {
2283
- return null;
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
- * Authenticated fetch with one-shot 401 retry. If the request comes back
2288
- * 401, we attempt a session renewal and replay the request once with the
2289
- * new token. If renewal fails (token past the server's 2min grace), the
2290
- * caller still gets a 401 and falls through to handleSessionExpired.
2291
- *
2292
- * Always passes `init.signal` straight through so abortable callers
2293
- * (e.g. fetchStatus) still cancel correctly.
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
- async authFetch(url, init = {}) {
2296
- const buildHeaders = () => {
2297
- const h = new Headers(init.headers);
2298
- h.set("Authorization", `Bearer ${this.sessionToken}`);
2299
- return h;
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
- let response = await fetch(url, { ...init, headers: buildHeaders() });
2302
- if (response.status === 401) {
2303
- const renewed = await this.tryRenew();
2304
- if (renewed) {
2305
- response = await fetch(url, { ...init, headers: buildHeaders() });
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
- return response;
2309
- }
2310
- handleSessionExpired() {
2311
- document.cookie = "__lr_editor_active=; path=/; SameSite=Lax; Max-Age=0";
2312
- this.onStatus("error", "Session expired. Please reopen the editor from the dashboard.");
2313
- if (this.onSessionExpired) {
2314
- this.onSessionExpired();
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
- * Upload a captured snapshot. Returns the created snapshot's UUID so the
2322
- * caller can build a share URL (`?__lr_snapshot={uuid}`).
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
- async createSnapshot(snapshot) {
2325
- try {
2326
- const response = await fetch(`${this.apiBase}/api/editor/snapshots`, {
2327
- method: "POST",
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
- * Re-capture an existing snapshot. Server appends a new version and keeps
2356
- * the same `snapshotUuid`. Returns the new version number on success.
2357
- */
2358
- async recaptureSnapshot(snapshotUuid, snapshot) {
2359
- try {
2360
- const response = await fetch(
2361
- `${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
2362
- {
2363
- method: "PUT",
2364
- headers: {
2365
- "Content-Type": "application/json",
2366
- "Authorization": `Bearer ${this.sessionToken}`
2367
- },
2368
- body: JSON.stringify({
2369
- pageUrl: snapshot.pageUrl,
2370
- title: snapshot.title,
2371
- viewport: snapshot.viewport,
2372
- textEntries: snapshot.textEntries,
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
- const data = await response.json();
2385
- return {
2386
- snapshotUuid: data.snapshot_uuid || data.snapshotUuid,
2387
- version: data.version || 1
2388
- };
2389
- } catch (err) {
2390
- this.onStatus("error", `Re-capture failed: ${err.message}`);
2391
- return null;
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
- * Fetch a single snapshot by UUID. Returns null on auth failure or 404.
2396
- */
2397
- async getSnapshot(snapshotUuid) {
2398
- try {
2399
- const response = await fetch(
2400
- `${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
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
- * List all snapshots the current editor session can see.
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
- async listSnapshots() {
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
- const response = await fetch(`${this.apiBase}/api/editor/snapshots`, {
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
- * Delete (soft) a snapshot by UUID.
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
- async deleteSnapshot(snapshotUuid) {
2438
- try {
2439
- const response = await fetch(
2440
- `${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
2441
- {
2442
- method: "DELETE",
2443
- headers: { "Authorization": `Bearer ${this.sessionToken}` }
2444
- }
2445
- );
2446
- if (response.status === 401) {
2447
- this.handleSessionExpired();
2448
- return false;
2449
- }
2450
- return response.ok;
2451
- } catch {
2452
- return false;
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
- * Workflow transitions (Phase 3). Per-key endpoints on the integrations API
2457
- * that mirror the dashboard's row-level transitions but are addressable by
2458
- * composite editor key (locale:namespace:auto_xxx).
2459
- *
2460
- * Returns the parsed JSON on success, or null on failure (caller usually
2461
- * shows a toast / re-fetches status).
2462
- */
2463
- async transitionTarget(composite, action) {
2464
- try {
2465
- const response = await this.authFetch(
2466
- `${this.apiBase}/api/integrations/targets/${encodeURIComponent(composite)}/${action}`,
2467
- { method: "POST" }
2468
- );
2469
- if (response.status === 401) {
2470
- this.handleSessionExpired();
2471
- return null;
2472
- }
2473
- if (!response.ok) {
2474
- this.onStatus("error", `${action} failed: HTTP ${response.status}`);
2475
- return null;
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
- return await response.json();
2478
- } catch (err) {
2479
- this.onStatus("error", `${action} failed: ${err.message}`);
2480
- return null;
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 clone = snapshotStyles[i].cloneNode(true);
2710
- clone.setAttribute("data-lr-snapshot-style", "");
2711
- document.head.appendChild(clone);
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) {