rails-markup 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1087 @@
1
+ Object.assign(RailsMarkupToolbar, {
2
+ _storageKey() {
3
+ const endpoint = (this.endpoint || "/feedback/api").replace(/\/+$/, "") || "/";
4
+ return `rm-annotations:${encodeURIComponent(endpoint)}`;
5
+ },
6
+ _pageUrl() { return window.location.pathname + window.location.search; },
7
+ _pageStorageKey() { return this._storageKey() + ":" + this._pageUrl(); },
8
+ _saveToStorage() {
9
+ try {
10
+ // Cross-tab storage-event merging is deferred: safely reconciling ordered
11
+ // upserts and tombstones needs conflict semantics, not a last-write merge.
12
+ localStorage.setItem(this._storageKey(), JSON.stringify({
13
+ annotations: this.annotations,
14
+ nextId: this.nextId,
15
+ outbox: this.outbox,
16
+ legacyMigrations: this.legacyMigrations
17
+ }));
18
+ return true;
19
+ }
20
+ catch (e) {
21
+ console.warn("[rails-markup] save failed:", e);
22
+ return false;
23
+ }
24
+ },
25
+ _persistLocalMutation(type, dirtyFields, mutate) {
26
+ return this._commitLocalStateChange(() => {
27
+ const annotation = mutate();
28
+ this._queueLocalMutation(type, annotation, dirtyFields);
29
+ });
30
+ },
31
+ _queueLocalMutation(type, annotation, dirtyFields) {
32
+ const currentEntry = this.outbox[annotation.clientId];
33
+ const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
34
+ const baseRevision = Number.isInteger(currentEntry?.baseRevision)
35
+ ? currentEntry.baseRevision
36
+ : (Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0);
37
+
38
+ if (type === "delete") {
39
+ this.outbox[annotation.clientId] = {
40
+ type: "delete",
41
+ clientId: annotation.clientId,
42
+ revision,
43
+ baseRevision,
44
+ syncState: "pending"
45
+ };
46
+ } else {
47
+ const existingDirtyFields = currentEntry?.type === "upsert" ? currentEntry.dirtyFields : [];
48
+ annotation.dirtyFields = this._mergeDirtyFields(annotation.dirtyFields, existingDirtyFields, dirtyFields);
49
+ annotation.syncState = "pending";
50
+ annotation.revision = revision;
51
+ this.outbox[annotation.clientId] = {
52
+ type: "upsert",
53
+ clientId: annotation.clientId,
54
+ revision,
55
+ baseRevision,
56
+ syncState: "pending",
57
+ annotation: this._desiredState(annotation),
58
+ dirtyFields: annotation.dirtyFields.slice()
59
+ };
60
+ }
61
+ },
62
+ _commitLocalStateChange(mutate) {
63
+ const snapshot = this._localStateSnapshot();
64
+ mutate();
65
+ if (this._saveToStorage()) {
66
+ this._storageError = null;
67
+ this._scheduleOutboxFlush();
68
+ return true;
69
+ }
70
+
71
+ this.annotations = snapshot.annotations;
72
+ this.outbox = snapshot.outbox;
73
+ this.nextId = snapshot.nextId;
74
+ this.legacyMigrations = snapshot.legacyMigrations;
75
+ this._storageError = "Changes could not be saved in this browser. Free storage space and try again.";
76
+ this._renderPins();
77
+ this._rebuildList();
78
+ this._updateCount();
79
+ const panel = document.getElementById("rm-panel");
80
+ if (panel) panel.style.display = "flex";
81
+ const fab = document.getElementById("rm-fab");
82
+ if (fab) fab.setAttribute("aria-expanded", "true");
83
+ return false;
84
+ },
85
+ _localStateSnapshot() {
86
+ return JSON.parse(JSON.stringify({
87
+ annotations: this.annotations,
88
+ outbox: this.outbox,
89
+ nextId: this.nextId,
90
+ legacyMigrations: this.legacyMigrations
91
+ }));
92
+ },
93
+ _mergeDirtyFields(...collections) {
94
+ const merged = [];
95
+ collections.flat().forEach(field => {
96
+ const canonical = field === "selectedText" ? "selected_text" : field;
97
+ if (canonical && !merged.includes(canonical)) merged.push(canonical);
98
+ });
99
+ return merged;
100
+ },
101
+ _scheduleOutboxFlush() {
102
+ if (this._outboxFlushScheduled) return;
103
+ this._outboxFlushScheduled = true;
104
+ this._outboxFlushTimer = setTimeout(() => {
105
+ this._outboxFlushScheduled = false;
106
+ this._outboxFlushTimer = null;
107
+ Promise.resolve(this._flushOutbox()).catch(error => console.warn("[rails-markup] outbox flush failed:", error));
108
+ }, 0);
109
+ },
110
+ _flushOutbox() {
111
+ if (this._flushPromise) return this._flushPromise;
112
+ if (this._pullPromise) {
113
+ if (!this._flushAfterPullPromise) {
114
+ this._flushAfterPullPromise = this._pullPromise.then(() => {
115
+ this._flushAfterPullPromise = null;
116
+ return this._flushOutbox();
117
+ });
118
+ }
119
+ return this._flushAfterPullPromise;
120
+ }
121
+ if (!navigator.onLine || !this.serverOnline) return Promise.resolve();
122
+ if (this._syncRetryTimer) return Promise.resolve();
123
+
124
+ this._flushPromise = this._runOutboxFlush().finally(() => {
125
+ this._flushPromise = null;
126
+ });
127
+ return this._flushPromise;
128
+ },
129
+ async _runOutboxFlush() {
130
+ const clientIds = Object.keys(this.outbox);
131
+ for (const clientId of clientIds) {
132
+ const current = this.outbox[clientId];
133
+ if (!current || current.syncState === "failed") continue;
134
+
135
+ const snapshot = this._immutableCopy(current);
136
+ let result;
137
+ try {
138
+ result = await this._sendOutboxEntry(snapshot);
139
+ } catch (error) {
140
+ result = { kind: "retryable", error };
141
+ }
142
+
143
+ if (result.kind === "success") {
144
+ if (!this._outboxEntryMatches(snapshot)) {
145
+ const supersedingDelete = this._advanceSupersedingDeleteBaseRevision(snapshot, result.data);
146
+ if (supersedingDelete === "failed") break;
147
+ clientIds.push(clientId);
148
+ continue;
149
+ }
150
+ this._handleSyncSuccess(snapshot, result.data);
151
+ this._resetSyncRetry();
152
+ continue;
153
+ }
154
+ if (result.kind === "auth") {
155
+ this._setSyncUnavailable(result.message || "Authentication required");
156
+ break;
157
+ }
158
+ if (result.kind === "terminal") {
159
+ if (!this._outboxEntryMatches(snapshot)) {
160
+ clientIds.push(clientId);
161
+ continue;
162
+ }
163
+ this._markSyncFailed(snapshot);
164
+ continue;
165
+ }
166
+ if (result.kind === "conflict") {
167
+ const resolution = this._reconcileSyncConflict(snapshot, result);
168
+ await this._pullAnnotations();
169
+ if (resolution === "retry" && this.outbox[clientId]) {
170
+ clientIds.push(clientId);
171
+ continue;
172
+ }
173
+ this._resetSyncRetry();
174
+ continue;
175
+ }
176
+ if (result.kind === "malformed") {
177
+ if (!this._outboxEntryMatches(snapshot)) {
178
+ clientIds.push(clientId);
179
+ continue;
180
+ }
181
+ if (this._recordMalformedResponse(snapshot)) continue;
182
+ break;
183
+ }
184
+
185
+ this.serverOnline = false;
186
+ this._updateStatus();
187
+ this._scheduleSyncRetry(result.retryAfter);
188
+ break;
189
+ }
190
+ },
191
+ _advanceSupersedingDeleteBaseRevision(snapshot, server) {
192
+ const current = this.outbox[snapshot.clientId];
193
+ const supersedesUpsert = snapshot.type === "upsert"
194
+ && current?.type === "delete"
195
+ && current.clientId === snapshot.clientId
196
+ && Number.isInteger(current.revision)
197
+ && current.revision > snapshot.revision
198
+ && Number.isInteger(server?.revision);
199
+ if (!supersedesUpsert) return "not-applicable";
200
+
201
+ const tombstone = this._immutableCopy(current);
202
+ const committed = this._commitLocalStateChange(() => {
203
+ if (!this._outboxEntryMatches(tombstone)) return;
204
+ const baseRevision = Number.isInteger(tombstone.baseRevision) ? tombstone.baseRevision : 0;
205
+ this.outbox[snapshot.clientId].baseRevision = Math.max(baseRevision, server.revision);
206
+ });
207
+ return committed ? "advanced" : "failed";
208
+ },
209
+ async _sendOutboxEntry(snapshot) {
210
+ const request = this._sameOriginMutationRequest(`/annotations/${encodeURIComponent(snapshot.clientId)}`);
211
+ if (!request) return { kind: "auth", message: "Sync unavailable: endpoint must be same-origin" };
212
+
213
+ const options = {
214
+ method: snapshot.type === "delete" ? "DELETE" : "PUT",
215
+ headers: request.headers,
216
+ credentials: "same-origin",
217
+ redirect: "manual",
218
+ signal: AbortSignal.timeout(5000)
219
+ };
220
+ if (snapshot.type === "delete") {
221
+ options.body = JSON.stringify({
222
+ baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
223
+ });
224
+ } else {
225
+ options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
226
+ dirtyFields: snapshot.dirtyFields || [],
227
+ baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
228
+ }));
229
+ }
230
+
231
+ const response = await fetch(request.url, options);
232
+ return this._classifySyncResponse(snapshot, response);
233
+ },
234
+ _sameOriginMutationRequest(path) {
235
+ const url = this._sameOriginEndpointUrl(path);
236
+ if (!url) return null;
237
+ const headers = { "Content-Type": "application/json", "Accept": "application/json" };
238
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
239
+ if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
240
+ return { url, headers };
241
+ },
242
+ _sameOriginEndpointUrl(path) {
243
+ try {
244
+ const rawUrl = `${this.endpoint.replace(/\/$/, "")}/${String(path).replace(/^\//, "")}`;
245
+ const resolved = new URL(rawUrl, window.location.href);
246
+ if (resolved.origin !== window.location.origin) return null;
247
+ return this.endpoint.startsWith("/") && !this.endpoint.startsWith("//")
248
+ ? resolved.pathname + resolved.search
249
+ : resolved.href;
250
+ } catch {
251
+ return null;
252
+ }
253
+ },
254
+ async _classifySyncResponse(snapshot, response) {
255
+ const status = response.status;
256
+ if (status >= 300 && status < 400) return { kind: "auth", message: "Authentication required" };
257
+ if (status === 401 || status === 403 || response.type === "opaqueredirect") {
258
+ return { kind: "auth", message: "Authentication required" };
259
+ }
260
+ if ([408, 425, 429].includes(status) || status >= 500) {
261
+ return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
262
+ }
263
+ if (status === 409) return this._classifyConflictResponse(snapshot, response);
264
+ if (status >= 400) return { kind: "terminal" };
265
+ if (!response.ok) return { kind: "retryable" };
266
+ if (snapshot.type === "delete") return { kind: "success", data: null };
267
+
268
+ const contentType = response.headers.get("Content-Type") || "";
269
+ if (!contentType.toLowerCase().includes("application/json")) {
270
+ return { kind: "auth", message: "Sync unavailable: expected JSON" };
271
+ }
272
+
273
+ try {
274
+ const data = await response.json();
275
+ if (!this._validServerAnnotation(data, snapshot.clientId)) return { kind: "malformed" };
276
+ return { kind: "success", data };
277
+ } catch {
278
+ return { kind: "malformed" };
279
+ }
280
+ },
281
+ async _classifyConflictResponse(snapshot, response) {
282
+ const contentType = response.headers.get("Content-Type") || "";
283
+ if (!contentType.toLowerCase().includes("application/json")) return { kind: "malformed" };
284
+
285
+ try {
286
+ const body = await response.json();
287
+ if (!this._plainObject(body) || !Object.prototype.hasOwnProperty.call(body, "annotation")) {
288
+ return { kind: "malformed" };
289
+ }
290
+ if (body.annotation === null && snapshot.type === "upsert") {
291
+ return { kind: "conflict", missing: true, data: null };
292
+ }
293
+ if (!this._validServerAnnotation(body.annotation, snapshot.clientId)) return { kind: "malformed" };
294
+ return { kind: "conflict", missing: false, data: body.annotation };
295
+ } catch {
296
+ return { kind: "malformed" };
297
+ }
298
+ },
299
+ _validServerAnnotation(data, expectedClientId) {
300
+ if (!this._plainObject(data)) return false;
301
+ const required = [
302
+ "id", "clientId", "userId", "authorName", "content", "intent", "severity",
303
+ "status", "selectedText", "pageUrl", "target", "metadata", "thread",
304
+ "createdAt", "updatedAt", "revision"
305
+ ];
306
+ if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
307
+ if (typeof data.id !== "string" || data.id.length === 0) return false;
308
+ if (data.clientId !== expectedClientId) return false;
309
+ if (!(data.userId === null || Number.isInteger(data.userId))) return false;
310
+ if (!(data.authorName === null || typeof data.authorName === "string")) return false;
311
+ if (typeof data.content !== "string") return false;
312
+ if (!["fix", "change", "question", "approve"].includes(data.intent)) return false;
313
+ if (!["suggestion", "important", "blocking"].includes(data.severity)) return false;
314
+ if (!["pending", "acknowledged", "resolved", "dismissed"].includes(data.status)) return false;
315
+ if (!(data.selectedText === null || typeof data.selectedText === "string")) return false;
316
+ if (typeof data.pageUrl !== "string" || data.pageUrl.length === 0) return false;
317
+ if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
318
+ if (!Array.isArray(data.thread)) return false;
319
+ if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
320
+ if (!Number.isInteger(data.revision) || data.revision < 0) return false;
321
+ return true;
322
+ },
323
+ _plainObject(value) {
324
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
325
+ },
326
+ _validServerTimestamp(value) {
327
+ return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
328
+ },
329
+ _handleSyncSuccess(snapshot, data) {
330
+ if (!this._outboxEntryMatches(snapshot)) return;
331
+ this._commitLocalStateChange(() => {
332
+ if (!this._outboxEntryMatches(snapshot)) return;
333
+ if (snapshot.type === "upsert") {
334
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
335
+ if (annotation) this._mergeSuccessfulUpsert(annotation, data, snapshot.dirtyFields || []);
336
+ }
337
+ delete this.outbox[snapshot.clientId];
338
+ });
339
+ this._renderPins();
340
+ this._rebuildList();
341
+ this._updateCount();
342
+ },
343
+ _mergeSuccessfulUpsert(annotation, server, sentDirtyFields) {
344
+ if (this._serverRepresentationIsStale(annotation, server)) {
345
+ annotation.dirtyFields = (annotation.dirtyFields || []).filter(field => !sentDirtyFields.includes(field));
346
+ annotation.syncState = "synced";
347
+ return;
348
+ }
349
+ annotation.serverId = server.id;
350
+ annotation.serverRevision = server.revision;
351
+ annotation.userId = server.userId;
352
+ annotation.authorName = server.authorName;
353
+ annotation.createdAt = server.createdAt;
354
+ annotation.serverUpdatedAt = server.updatedAt || annotation.serverUpdatedAt;
355
+ annotation.comment = server.content;
356
+ annotation.intent = server.intent;
357
+ annotation.severity = server.severity;
358
+ annotation.status = server.status;
359
+ annotation.selectedText = server.selectedText;
360
+ annotation.element = server.target || {};
361
+ annotation.metadata = server.metadata || {};
362
+ annotation.thread = server.thread || [];
363
+ annotation.pageUrl = server.pageUrl || annotation.pageUrl;
364
+ annotation.pathname = server.pageUrl || annotation.pathname;
365
+ annotation.dirtyFields = (annotation.dirtyFields || []).filter(field => !sentDirtyFields.includes(field));
366
+ annotation.syncState = "synced";
367
+ },
368
+ _reconcileSyncConflict(snapshot, conflict) {
369
+ if (!this._outboxEntryMatches(snapshot)) return "stop";
370
+
371
+ let resolution = "stop";
372
+ const committed = this._commitLocalStateChange(() => {
373
+ if (!this._outboxEntryMatches(snapshot)) return;
374
+ const entry = this.outbox[snapshot.clientId];
375
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
376
+
377
+ if (snapshot.type === "delete") {
378
+ delete this.outbox[snapshot.clientId];
379
+ if (annotation) this._mergePulledAnnotation(annotation, conflict.data, null);
380
+ else this.annotations.push(this._annotationFromServer(conflict.data));
381
+ this._assignDisplayIds();
382
+ return;
383
+ }
384
+
385
+ if (conflict.missing) {
386
+ if (entry.missingConflictRebased) {
387
+ entry.syncState = "failed";
388
+ if (annotation) annotation.syncState = "failed";
389
+ return;
390
+ }
391
+ if (!annotation) {
392
+ entry.syncState = "failed";
393
+ return;
394
+ }
395
+ // Recreate once from the complete desired browser state. Replaying a
396
+ // narrow edit delta onto a new row can omit required fields and lose
397
+ // the user's annotation to a permanent validation failure.
398
+ annotation.dirtyFields = this._legacyDirtyFields(annotation);
399
+ entry.annotation = this._desiredState(annotation);
400
+ entry.dirtyFields = annotation.dirtyFields.slice();
401
+ entry.baseRevision = 0;
402
+ entry.missingConflictRebased = true;
403
+ annotation.serverId = null;
404
+ annotation.serverRevision = 0;
405
+ resolution = "retry";
406
+ return;
407
+ }
408
+
409
+ if (!annotation) {
410
+ entry.syncState = "failed";
411
+ return;
412
+ }
413
+ this._mergePulledAnnotation(annotation, conflict.data, entry);
414
+ entry.annotation = this._desiredState(annotation);
415
+ entry.dirtyFields = (annotation.dirtyFields || []).slice();
416
+ entry.baseRevision = conflict.data.revision;
417
+ delete entry.missingConflictRebased;
418
+ resolution = "retry";
419
+ });
420
+ if (!committed) return "stop";
421
+ this._renderPins();
422
+ this._rebuildList();
423
+ this._updateCount();
424
+ return resolution;
425
+ },
426
+ _markSyncFailed(snapshot) {
427
+ if (!this._outboxEntryMatches(snapshot)) return;
428
+ this._commitLocalStateChange(() => {
429
+ const entry = this.outbox[snapshot.clientId];
430
+ if (!entry || !this._outboxEntryMatches(snapshot)) return;
431
+ entry.syncState = "failed";
432
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
433
+ if (annotation) annotation.syncState = "failed";
434
+ });
435
+ this._rebuildList();
436
+ },
437
+ _recordMalformedResponse(snapshot) {
438
+ if (!this._outboxEntryMatches(snapshot)) return true;
439
+ const attempts = (this.outbox[snapshot.clientId].malformedAttempts || 0) + 1;
440
+ if (attempts >= this._syncMalformedLimit) {
441
+ this._markSyncFailed(snapshot);
442
+ return true;
443
+ }
444
+ this._commitLocalStateChange(() => {
445
+ if (this._outboxEntryMatches(snapshot)) this.outbox[snapshot.clientId].malformedAttempts = attempts;
446
+ });
447
+ this._scheduleSyncRetry();
448
+ return false;
449
+ },
450
+ _outboxEntryMatches(snapshot) {
451
+ const current = this.outbox[snapshot.clientId];
452
+ return Boolean(current) && JSON.stringify(current) === JSON.stringify(snapshot);
453
+ },
454
+ _immutableCopy(value) {
455
+ return JSON.parse(JSON.stringify(value));
456
+ },
457
+ _retryAfterDelay(response) {
458
+ const value = response.headers.get("Retry-After");
459
+ if (!value) return null;
460
+ const seconds = Number(value);
461
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
462
+ if (!Number.isFinite(delay)) return null;
463
+ return Math.min(this._syncMaxRetryDelay, Math.max(0, delay));
464
+ },
465
+ _scheduleSyncRetry(requestedDelay) {
466
+ if (this._syncRetryTimer) return;
467
+ this._syncRetryAttempt += 1;
468
+ const exponential = this._syncBaseRetryDelay * (2 ** (this._syncRetryAttempt - 1));
469
+ this._syncRetryDelay = Math.min(this._syncMaxRetryDelay, requestedDelay ?? exponential);
470
+ this._syncRetryTimer = setTimeout(async () => {
471
+ this._syncRetryTimer = null;
472
+ await this._checkHealth();
473
+ }, this._syncRetryDelay);
474
+ },
475
+ _resetSyncRetry() {
476
+ this._syncRetryAttempt = 0;
477
+ this._syncRetryDelay = 0;
478
+ if (this._syncRetryTimer) clearTimeout(this._syncRetryTimer);
479
+ this._syncRetryTimer = null;
480
+ this._syncUnavailable = null;
481
+ },
482
+ _setSyncUnavailable(message) {
483
+ this.serverOnline = false;
484
+ this._syncUnavailable = message;
485
+ this._updateStatus();
486
+ },
487
+ _retrySync(clientId) {
488
+ const entry = this.outbox[clientId];
489
+ if (!entry || entry.syncState !== "failed") return;
490
+ const committed = this._commitLocalStateChange(() => {
491
+ entry.syncState = "pending";
492
+ const annotation = this.annotations.find(candidate => candidate.clientId === clientId);
493
+ if (annotation) annotation.syncState = "pending";
494
+ });
495
+ if (!committed) return;
496
+ this._rebuildList();
497
+ },
498
+ _loadFromStorage() {
499
+ try {
500
+ let raw = localStorage.getItem(this._storageKey());
501
+ if (raw) {
502
+ const data = JSON.parse(raw);
503
+ const validDocument = data && typeof data === "object" && !Array.isArray(data);
504
+ this.annotations = validDocument && Array.isArray(data.annotations) ? data.annotations : [];
505
+ this.nextId = validDocument && Number.isInteger(data.nextId) && data.nextId > 0 ? data.nextId : (this.annotations.length + 1);
506
+ this.outbox = validDocument && data.outbox && typeof data.outbox === "object" && !Array.isArray(data.outbox) ? data.outbox : {};
507
+ this.legacyMigrations = validDocument && data.legacyMigrations && typeof data.legacyMigrations === "object" && !Array.isArray(data.legacyMigrations)
508
+ ? data.legacyMigrations
509
+ : {};
510
+ }
511
+ // Pre-1.3 bare storage has no endpoint provenance, so it is left intact
512
+ // unless the host explicitly designates this endpoint. Page-qualified
513
+ // legacy keys are only claimed by a toolbar currently on that exact page.
514
+ const migratedKeys = this._migrateUnnamespacedStorage();
515
+ migratedKeys.push(...this._migratePageAnnotations());
516
+ this._normalizeStoredState();
517
+ this._recordLegacyMigrations();
518
+ if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
519
+ this._rebuildList();
520
+ this._updateCount();
521
+ } catch (e) { console.warn("[rails-markup] load failed:", e); }
522
+ },
523
+ _migrateUnnamespacedStorage() {
524
+ const sourceKeys = [];
525
+ const designatedEndpoint = (this.legacyStorageEndpoint || "").replace(/\/+$/, "");
526
+ const currentEndpoint = (this.endpoint || "").replace(/\/+$/, "");
527
+ const claimBareStorage = Boolean(designatedEndpoint) && designatedEndpoint === currentEndpoint;
528
+ const currentPageKey = `rm-annotations:${this._pageUrl()}`;
529
+ for (let index = 0; index < localStorage.length; index++) {
530
+ const key = localStorage.key(index);
531
+ if ((claimBareStorage && key === "rm-annotations") || key === currentPageKey) sourceKeys.push(key);
532
+ }
533
+
534
+ const migratedKeys = [];
535
+ const legacyAnnotations = [];
536
+ const legacyOutbox = {};
537
+ const consolidatedClientIds = new Set(
538
+ this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId))
539
+ );
540
+ sourceKeys.forEach(key => {
541
+ try {
542
+ const data = JSON.parse(localStorage.getItem(key));
543
+ if (!this._plainObject(data)) return;
544
+ const hasAnnotations = Array.isArray(data.annotations);
545
+ const hasOutbox = key === "rm-annotations" && this._plainObject(data.outbox);
546
+ if (!hasAnnotations && !hasOutbox) return;
547
+
548
+ if (hasAnnotations) {
549
+ data.annotations.forEach((annotation, index) => {
550
+ if (!this._plainObject(annotation)) return;
551
+ const fingerprint = this._legacyMigrationFingerprint(key, index, annotation);
552
+ const migratedClientId = this.legacyMigrations[fingerprint];
553
+ if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
554
+ if (this._validClientId(migratedClientId)) annotation.clientId = migratedClientId;
555
+ if (this._validClientId(annotation.clientId) && consolidatedClientIds.has(annotation.clientId)) return;
556
+ Object.defineProperty(annotation, "_legacyMigrationFingerprint", {
557
+ configurable: true,
558
+ value: fingerprint
559
+ });
560
+ legacyAnnotations.push(annotation);
561
+ if (this._validClientId(annotation.clientId)) consolidatedClientIds.add(annotation.clientId);
562
+ });
563
+ }
564
+ if (hasOutbox) Object.assign(legacyOutbox, data.outbox);
565
+ if (Number.isInteger(data.nextId) && data.nextId > this.nextId) this.nextId = data.nextId;
566
+ migratedKeys.push(key);
567
+ } catch {}
568
+ });
569
+
570
+ this.annotations = legacyAnnotations.concat(this.annotations);
571
+ this.outbox = Object.assign({}, legacyOutbox, this.outbox);
572
+ return migratedKeys;
573
+ },
574
+ _migratePageAnnotations() {
575
+ // Find and merge per-page annotation keys only within this endpoint namespace.
576
+ const prefix = this._storageKey() + ":";
577
+ const migratedKeys = [];
578
+ const seenIds = new Set(this.annotations.map(a => a.id));
579
+ const consolidatedClientIds = new Set(this.annotations.map(a => a.clientId).filter(clientId => this._validClientId(clientId)));
580
+ for (let i = 0; i < localStorage.length; i++) {
581
+ const key = localStorage.key(i);
582
+ if (key && key.startsWith(prefix)) {
583
+ try {
584
+ const data = JSON.parse(localStorage.getItem(key));
585
+ if (data && Array.isArray(data.annotations)) {
586
+ data.annotations.forEach((a, index) => {
587
+ const fingerprint = this._legacyMigrationFingerprint(key, index, a);
588
+ const migratedClientId = this.legacyMigrations[fingerprint];
589
+ if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
590
+ if (this._validClientId(migratedClientId)) a.clientId = migratedClientId;
591
+ Object.defineProperty(a, "_legacyMigrationFingerprint", { configurable: true, value: fingerprint });
592
+ // Legacy ids were per-page counters, so different pages can reuse
593
+ // the same id. Reassign a fresh id on collision instead of
594
+ // dropping the annotation (which silently lost data).
595
+ if (seenIds.has(a.id)) a.id = this.nextId++;
596
+ seenIds.add(a.id);
597
+ this.annotations.push(a);
598
+ if (a.id >= this.nextId) this.nextId = a.id + 1;
599
+ });
600
+ migratedKeys.push(key);
601
+ }
602
+ } catch {}
603
+ }
604
+ }
605
+ return migratedKeys;
606
+ },
607
+ _legacyMigrationFingerprint(key, index, annotation) {
608
+ const input = `${key}\u0000${index}\u0000${JSON.stringify(annotation)}`;
609
+ let hash = 2166136261;
610
+ for (let i = 0; i < input.length; i++) hash = Math.imul(hash ^ input.charCodeAt(i), 16777619);
611
+ return `${key}:${index}:${(hash >>> 0).toString(16)}`;
612
+ },
613
+ _recordLegacyMigrations() {
614
+ this.annotations.forEach(annotation => {
615
+ if (!annotation._legacyMigrationFingerprint) return;
616
+ this.legacyMigrations[annotation._legacyMigrationFingerprint] = annotation.clientId;
617
+ delete annotation._legacyMigrationFingerprint;
618
+ });
619
+ },
620
+ _cleanupMigratedKeys(keys) {
621
+ keys.forEach(key => {
622
+ try { localStorage.removeItem(key); }
623
+ catch (e) { console.warn("[rails-markup] legacy cleanup failed:", e); }
624
+ });
625
+ },
626
+ _normalizeStoredState() {
627
+ if (!this.outbox || typeof this.outbox !== "object" || Array.isArray(this.outbox)) this.outbox = {};
628
+
629
+ const usedClientIds = new Set(this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId)));
630
+ const invalidOutboxOwners = this._invalidOutboxOwners();
631
+ const normalized = this.annotations.map((annotation, index) => {
632
+ const originalClientId = annotation.clientId;
633
+ if (!this._validClientId(originalClientId)) {
634
+ let replacementClientId;
635
+ do { replacementClientId = this._newClientId(); } while (usedClientIds.has(replacementClientId));
636
+ usedClientIds.add(replacementClientId);
637
+ annotation.clientId = replacementClientId;
638
+ if (invalidOutboxOwners.get(originalClientId) === annotation) this._rekeyOutbox(originalClientId, replacementClientId);
639
+ }
640
+ if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
641
+ if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
642
+ if (!Number.isInteger(annotation.serverRevision) || annotation.serverRevision < 0) {
643
+ annotation.serverRevision = Number.isInteger(annotation.server_revision) && annotation.server_revision >= 0
644
+ ? annotation.server_revision
645
+ : 0;
646
+ }
647
+ if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
648
+ annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
649
+ annotation.pathname = annotation.pageUrl;
650
+
651
+ return { annotation, index };
652
+ });
653
+
654
+ this._normalizeOutboxEnvelopes();
655
+
656
+ const byClientId = new Map();
657
+ normalized.forEach(candidate => {
658
+ const current = byClientId.get(candidate.annotation.clientId);
659
+ if (!current || this._isNewerLocalRecord(candidate, current)) byClientId.set(candidate.annotation.clientId, candidate);
660
+ });
661
+ this.annotations = Array.from(byClientId.values())
662
+ .sort((left, right) => left.index - right.index)
663
+ .map(candidate => candidate.annotation);
664
+
665
+ this._assignDisplayIds();
666
+ this.annotations.forEach(annotation => {
667
+ const mapped = annotation.serverId != null;
668
+ const queuedEntry = this.outbox[annotation.clientId];
669
+ const queued = Boolean(queuedEntry);
670
+ const annotationRevision = Number.isInteger(annotation.revision) && annotation.revision >= 0 ? annotation.revision : 0;
671
+ annotation.revision = annotationRevision;
672
+ annotation.syncState = (queued && annotation.syncState === "failed")
673
+ ? "failed"
674
+ : ((queued || !mapped) ? "pending" : "synced");
675
+ if (!this.outbox[annotation.clientId] && !mapped) {
676
+ annotation.dirtyFields = this._legacyDirtyFields(annotation);
677
+ this.outbox[annotation.clientId] = {
678
+ type: "upsert",
679
+ clientId: annotation.clientId,
680
+ revision: annotation.revision,
681
+ baseRevision: annotation.serverRevision,
682
+ syncState: "pending",
683
+ annotation: this._desiredState(annotation),
684
+ dirtyFields: annotation.dirtyFields.slice()
685
+ };
686
+ }
687
+ });
688
+ },
689
+ _normalizeOutboxEnvelopes() {
690
+ const normalized = {};
691
+
692
+ Object.entries(this.outbox).forEach(([storedClientId, candidate]) => {
693
+ if (!this._plainObject(candidate)) return;
694
+
695
+ const nestedClientId = candidate.annotation?.clientId;
696
+ const clientId = this._validClientId(nestedClientId)
697
+ ? nestedClientId
698
+ : (this._validClientId(candidate.clientId)
699
+ ? candidate.clientId
700
+ : (this._validClientId(storedClientId) ? storedClientId : null));
701
+ if (!clientId) return;
702
+
703
+ const type = candidate.type === "delete"
704
+ ? "delete"
705
+ : ((candidate.type === "upsert" || this._plainObject(candidate.annotation)) ? "upsert" : null);
706
+ if (!type) return;
707
+
708
+ const annotation = this.annotations.find(record => record.clientId === clientId);
709
+ const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
710
+ const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
711
+ const candidateBaseRevision = Number.isInteger(candidate.baseRevision) && candidate.baseRevision >= 0
712
+ ? candidate.baseRevision
713
+ : 0;
714
+ const annotationBaseRevision = Number.isInteger(annotation?.serverRevision) && annotation.serverRevision >= 0
715
+ ? annotation.serverRevision
716
+ : 0;
717
+ const envelope = Object.assign({}, candidate, {
718
+ type,
719
+ clientId,
720
+ revision: Math.max(candidateRevision, annotationRevision),
721
+ baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
722
+ syncState: candidate.syncState === "failed" ? "failed" : "pending"
723
+ });
724
+
725
+ if (type === "upsert") {
726
+ envelope.annotation = Object.assign({}, candidate.annotation, { clientId });
727
+ envelope.dirtyFields = this._mergeDirtyFields(candidate.dirtyFields || envelope.annotation.dirtyFields || []);
728
+ }
729
+ normalized[clientId] = envelope;
730
+ });
731
+
732
+ this.outbox = normalized;
733
+ },
734
+ _isNewerLocalRecord(candidate, current) {
735
+ const timestamp = value => {
736
+ const parsed = Date.parse(value || "");
737
+ return Number.isNaN(parsed) ? -Infinity : parsed;
738
+ };
739
+ const candidateUpdatedAt = timestamp(candidate.annotation.updatedAt || candidate.annotation.updated_at);
740
+ const currentUpdatedAt = timestamp(current.annotation.updatedAt || current.annotation.updated_at);
741
+ if (candidateUpdatedAt !== currentUpdatedAt) return candidateUpdatedAt > currentUpdatedAt;
742
+ return candidate.index > current.index;
743
+ },
744
+ _assignDisplayIds() {
745
+ const validIds = this.annotations
746
+ .map(annotation => annotation.id)
747
+ .filter(id => Number.isInteger(id) && id > 0);
748
+ let candidateId = Math.max(1, Number.isInteger(this.nextId) ? this.nextId : 1, ...validIds.map(id => id + 1));
749
+ const usedIds = new Set();
750
+
751
+ this.annotations.forEach(annotation => {
752
+ if (!Number.isInteger(annotation.id) || annotation.id <= 0 || usedIds.has(annotation.id)) {
753
+ while (usedIds.has(candidateId)) candidateId += 1;
754
+ annotation.id = candidateId++;
755
+ }
756
+ usedIds.add(annotation.id);
757
+ });
758
+
759
+ this.nextId = Math.max(candidateId, ...Array.from(usedIds, id => id + 1), 1);
760
+ },
761
+ _legacyDirtyFields(annotation) {
762
+ const fields = this._browserCreateFields();
763
+ if (annotation.status && annotation.status !== "pending") fields.push("status");
764
+ return fields;
765
+ },
766
+ _browserCreateFields() {
767
+ return ["content", "intent", "severity", "selected_text", "target", "page_url", "metadata"];
768
+ },
769
+ _desiredState(annotation) {
770
+ const sourceMetadata = annotation.metadata && typeof annotation.metadata === "object" ? annotation.metadata : {};
771
+ const metadata = {};
772
+ ["tool", "url", "localId", "sessionId", "screenshot"].forEach(key => {
773
+ if (sourceMetadata[key] != null) metadata[key] = sourceMetadata[key];
774
+ });
775
+ if (metadata.tool == null) metadata.tool = "rails-markup";
776
+ if (metadata.url == null && annotation.url) metadata.url = annotation.url;
777
+ if (metadata.localId == null && annotation.id != null) metadata.localId = annotation.id;
778
+ if (metadata.sessionId == null && this.sessionId != null) metadata.sessionId = this.sessionId;
779
+ if (metadata.screenshot == null && annotation.screenshot) metadata.screenshot = annotation.screenshot;
780
+
781
+ return JSON.parse(JSON.stringify({
782
+ clientId: annotation.clientId,
783
+ page_url: annotation.pageUrl || annotation.pathname || this._pageUrl(),
784
+ content: annotation.comment ?? annotation.content ?? "",
785
+ intent: annotation.intent,
786
+ severity: annotation.severity,
787
+ selected_text: annotation.selectedText ?? annotation.selected_text ?? null,
788
+ target: annotation.element || annotation.target || {},
789
+ metadata,
790
+ status: annotation.status || "pending"
791
+ }));
792
+ },
793
+ _validClientId(clientId) {
794
+ return typeof clientId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(clientId);
795
+ },
796
+ _invalidOutboxOwners() {
797
+ const owners = new Map();
798
+ this.annotations.forEach(annotation => {
799
+ const clientId = annotation.clientId;
800
+ if (!clientId || this._validClientId(clientId) || owners.has(clientId) || !this.outbox[clientId]) return;
801
+
802
+ const peers = this.annotations.filter(candidate => candidate.clientId === clientId);
803
+ const desired = this.outbox[clientId].annotation || this.outbox[clientId];
804
+ const desiredId = desired.id ?? desired.metadata?.localId;
805
+ const desiredComment = desired.comment ?? desired.content;
806
+ const owner = peers.find(candidate => desiredId != null && candidate.id === desiredId)
807
+ || peers.find(candidate => desiredComment != null && (candidate.comment ?? candidate.content) === desiredComment)
808
+ || peers[0];
809
+ owners.set(clientId, owner);
810
+ });
811
+ return owners;
812
+ },
813
+ _rekeyOutbox(oldClientId, newClientId) {
814
+ if (!oldClientId || !this.outbox[oldClientId]) return;
815
+ const entry = this.outbox[oldClientId];
816
+ delete this.outbox[oldClientId];
817
+ if (entry.annotation) entry.annotation.clientId = newClientId;
818
+ if (entry.clientId) entry.clientId = newClientId;
819
+ this.outbox[newClientId] = entry;
820
+ },
821
+ _newClientId() {
822
+ if (global.crypto && typeof global.crypto.randomUUID === "function") return global.crypto.randomUUID();
823
+ const bytes = new Uint8Array(16);
824
+ if (global.crypto && typeof global.crypto.getRandomValues === "function") global.crypto.getRandomValues(bytes);
825
+ else bytes.forEach((_, index) => { bytes[index] = Math.floor(Math.random() * 256); });
826
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
827
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
828
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
829
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
830
+ },
831
+ _pullAnnotations() {
832
+ const pageUrl = this._pageUrl();
833
+ if (this._pullPromise && this._pullPageUrl === pageUrl) return this._pullPromise;
834
+
835
+ const previousPull = this._pullPromise;
836
+ const operation = previousPull
837
+ ? previousPull.then(() => this._performAnnotationPull(pageUrl))
838
+ : this._performAnnotationPull(pageUrl);
839
+ let trackedPull;
840
+ trackedPull = operation.finally(() => {
841
+ if (this._pullPromise !== trackedPull) return;
842
+ this._pullPromise = null;
843
+ this._pullPageUrl = null;
844
+ });
845
+ this._pullPageUrl = pageUrl;
846
+ this._pullPromise = trackedPull;
847
+ return trackedPull;
848
+ },
849
+ _performAnnotationPull(pageUrl) {
850
+ return this._runAnnotationPull(pageUrl)
851
+ .then(complete => {
852
+ this._pullNeeded = !complete;
853
+ return complete;
854
+ })
855
+ .catch(() => {
856
+ this._pullNeeded = true;
857
+ return false;
858
+ });
859
+ },
860
+ async _runAnnotationPull(pageUrl) {
861
+ const url = this._sameOriginEndpointUrl(`/annotations?page_url=${encodeURIComponent(pageUrl)}`);
862
+ if (!url) {
863
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
864
+ return false;
865
+ }
866
+
867
+ const response = await fetch(url, {
868
+ method: "GET",
869
+ headers: { "Accept": "application/json" },
870
+ credentials: "same-origin",
871
+ redirect: "manual",
872
+ signal: AbortSignal.timeout(5000)
873
+ });
874
+ if (!response.ok || response.status >= 300 || response.type === "opaqueredirect") return false;
875
+ const contentType = response.headers.get("Content-Type") || "";
876
+ if (!contentType.toLowerCase().includes("application/json")) return false;
877
+
878
+ let records;
879
+ try { records = await response.json(); }
880
+ catch { return false; }
881
+ if (pageUrl !== this._pageUrl() || !this._validPullRecords(records, pageUrl)) return false;
882
+ return this._reconcilePull(pageUrl, records);
883
+ },
884
+ _validPullRecords(records, pageUrl) {
885
+ if (!Array.isArray(records)) return false;
886
+ const clientIds = new Set();
887
+ return records.every(record => {
888
+ const clientId = record?.clientId;
889
+ if (!this._validClientId(clientId) || clientIds.has(clientId)) return false;
890
+ clientIds.add(clientId);
891
+ return record.pageUrl === pageUrl && this._validServerAnnotation(record, clientId);
892
+ });
893
+ },
894
+ _reconcilePull(pageUrl, records) {
895
+ const committed = this._commitLocalStateChange(() => {
896
+ const presentClientIds = new Set(records.map(record => record.clientId));
897
+ records.forEach(server => {
898
+ const outboxEntry = this.outbox[server.clientId];
899
+ if (outboxEntry?.type === "delete") return;
900
+
901
+ const annotation = this.annotations.find(candidate => candidate.clientId === server.clientId);
902
+ if (annotation) this._mergePulledAnnotation(annotation, server, outboxEntry);
903
+ else this.annotations.push(this._annotationFromServer(server));
904
+ });
905
+
906
+ this.annotations = this.annotations.filter(annotation => {
907
+ const annotationPage = annotation.pageUrl || annotation.pathname;
908
+ if (annotationPage !== pageUrl || presentClientIds.has(annotation.clientId)) return true;
909
+ return Boolean(this.outbox[annotation.clientId]) || annotation.syncState !== "synced";
910
+ });
911
+
912
+ this._assignDisplayIds();
913
+ Object.entries(this.outbox).forEach(([clientId, entry]) => {
914
+ if (entry?.type !== "upsert") return;
915
+ const annotation = this.annotations.find(candidate => candidate.clientId === clientId);
916
+ if (!annotation) return;
917
+ entry.annotation = this._desiredState(annotation);
918
+ entry.dirtyFields = (annotation.dirtyFields || []).slice();
919
+ entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
920
+ });
921
+ });
922
+ if (!committed) return false;
923
+ this._renderPins();
924
+ this._rebuildList();
925
+ this._updateCount();
926
+ return true;
927
+ },
928
+ _mergePulledAnnotation(annotation, server, outboxEntry) {
929
+ if (this._serverRepresentationIsStale(annotation, server)) return;
930
+ const dirtyFields = new Set(this._mergeDirtyFields(annotation.dirtyFields || [], outboxEntry?.dirtyFields || []));
931
+ const browserFields = [
932
+ ["content", "comment"],
933
+ ["intent", "intent"],
934
+ ["severity", "severity"],
935
+ ["selected_text", "selectedText"],
936
+ ["target", "element"],
937
+ ["metadata", "metadata"],
938
+ ["status", "status"]
939
+ ];
940
+ browserFields.forEach(([dirtyField, localField]) => {
941
+ if (dirtyFields.has(dirtyField)) return;
942
+ const serverField = {
943
+ content: "content", selected_text: "selectedText", target: "target"
944
+ }[dirtyField] || dirtyField;
945
+ annotation[localField] = this._immutableCopy(server[serverField]);
946
+ });
947
+ if (!dirtyFields.has("page_url")) {
948
+ annotation.pageUrl = server.pageUrl;
949
+ annotation.pathname = server.pageUrl;
950
+ }
951
+ annotation.serverId = server.id;
952
+ annotation.serverRevision = server.revision;
953
+ annotation.userId = server.userId;
954
+ annotation.authorName = server.authorName;
955
+ annotation.createdAt = server.createdAt;
956
+ annotation.serverUpdatedAt = server.updatedAt;
957
+ annotation.thread = this._immutableCopy(server.thread);
958
+ annotation.dirtyFields = Array.from(dirtyFields);
959
+ annotation.syncState = outboxEntry ? (outboxEntry.syncState || "pending") : "synced";
960
+ if (!dirtyFields.has("metadata") && server.metadata?.url) annotation.url = server.metadata.url;
961
+ },
962
+ _annotationFromServer(server) {
963
+ return {
964
+ id: null,
965
+ clientId: server.clientId,
966
+ serverId: server.id,
967
+ serverRevision: server.revision,
968
+ userId: server.userId,
969
+ authorName: server.authorName,
970
+ syncState: "synced",
971
+ serverUpdatedAt: server.updatedAt,
972
+ dirtyFields: [],
973
+ revision: 0,
974
+ comment: server.content,
975
+ intent: server.intent,
976
+ severity: server.severity,
977
+ status: server.status,
978
+ selectedText: server.selectedText,
979
+ element: this._immutableCopy(server.target),
980
+ metadata: this._immutableCopy(server.metadata),
981
+ pathname: server.pageUrl,
982
+ pageUrl: server.pageUrl,
983
+ url: server.metadata?.url || server.pageUrl,
984
+ thread: this._immutableCopy(server.thread),
985
+ createdAt: server.createdAt
986
+ };
987
+ },
988
+ _serverRepresentationIsStale(annotation, server) {
989
+ if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
990
+ return server.revision < annotation.serverRevision;
991
+ }
992
+ const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
993
+ const serverTimestamp = Date.parse(server.updatedAt || "");
994
+ return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
995
+ },
996
+ _onVisibilityChange() {
997
+ if (document.hidden) {
998
+ // Tab hidden — pause health checks to save resources
999
+ if (this.healthInterval) { clearInterval(this.healthInterval); this.healthInterval = null; }
1000
+ } else {
1001
+ // Tab visible — resume health checks immediately
1002
+ if (!this.healthInterval) {
1003
+ this._checkHealth();
1004
+ this.healthInterval = setInterval(() => this._checkHealth(), this.healthIntervalMs);
1005
+ }
1006
+ }
1007
+ },
1008
+ _onOnline() {
1009
+ return this._checkHealth();
1010
+ },
1011
+ _checkHealth() {
1012
+ if (this._healthPromise) return this._healthPromise;
1013
+ this._healthPromise = this._runHealthCheck().finally(() => { this._healthPromise = null; });
1014
+ return this._healthPromise;
1015
+ },
1016
+ async _runHealthCheck() {
1017
+ try {
1018
+ const healthUrl = this._sameOriginEndpointUrl("/health");
1019
+ if (!healthUrl) {
1020
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1021
+ return;
1022
+ }
1023
+ const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) });
1024
+ const was = this.serverOnline;
1025
+ this.serverOnline = resp.ok;
1026
+ this._updateStatus();
1027
+ if (!was && this.serverOnline) await this._initSession();
1028
+ if (this.serverOnline && (!was || this._pullNeeded)) await this._pullAnnotations();
1029
+ if (this.serverOnline) await this._flushOutbox();
1030
+ } catch {
1031
+ this.serverOnline = false;
1032
+ this._updateStatus();
1033
+ }
1034
+ },
1035
+ async _initSession() {
1036
+ if (!this.serverOnline) return;
1037
+ try {
1038
+ const request = this._sameOriginMutationRequest("/sessions");
1039
+ if (!request) {
1040
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1041
+ return;
1042
+ }
1043
+ const resp = await fetch(request.url, {
1044
+ method: "POST", headers: request.headers, credentials: "same-origin",
1045
+ body: JSON.stringify({ url: window.location.href, metadata: { tool: "rails-markup" } }),
1046
+ signal: AbortSignal.timeout(5000)
1047
+ });
1048
+ if (resp.ok) {
1049
+ const data = await resp.json();
1050
+ this.sessionId = data.id;
1051
+ }
1052
+ } catch (e) { console.warn("[rails-markup] session init failed:", e); }
1053
+ },
1054
+ async _synchronizeCurrentPage(initializeSession) {
1055
+ if (!this.serverOnline) return;
1056
+ if (initializeSession) await this._initSession();
1057
+ await this._pullAnnotations();
1058
+ await this._flushOutbox();
1059
+ },
1060
+ async _pushToServer(annotation) {
1061
+ if (!this.serverOnline) return;
1062
+ try {
1063
+ const request = this._sameOriginMutationRequest(`/sessions/${encodeURIComponent(this.sessionId || "local")}/annotations`);
1064
+ if (!request) {
1065
+ this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
1066
+ return;
1067
+ }
1068
+ await fetch(request.url, {
1069
+ method: "POST", headers: request.headers, credentials: "same-origin",
1070
+ body: JSON.stringify({
1071
+ page_url: this._pageUrl(),
1072
+ clientId: annotation.clientId,
1073
+ content: annotation.comment,
1074
+ intent: annotation.intent,
1075
+ severity: annotation.severity,
1076
+ selected_text: annotation.selectedText || null,
1077
+ target: annotation.element || {},
1078
+ metadata: Object.assign(
1079
+ { localId: annotation.id, url: annotation.url },
1080
+ annotation.screenshot ? { screenshot: annotation.screenshot } : {}
1081
+ )
1082
+ }),
1083
+ signal: AbortSignal.timeout(5000)
1084
+ });
1085
+ } catch (e) { console.warn("[rails-markup] push failed:", e); }
1086
+ },
1087
+ });