texlite 0.8.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +75 -8
- package/OPERATIONS.md +19 -2
- package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-3CHb5012.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-zPpsUhWJ.js} +1 -1
- package/dist/client/assets/Dashboard-CWfSLzD3.js +2 -0
- package/dist/client/assets/{GitDialog-BCfSHuMB.js → GitDialog-BUU2U_LV.js} +2 -2
- package/dist/client/assets/HistoryDialog-B6aopsrw.js +7 -0
- package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-CM445Tk0.js} +1 -1
- package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BFVNczSk.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-Cf0zBf8u.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-TPbe11iR.js} +1 -1
- package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-CDQvIlQ4.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +27 -0
- package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +3 -0
- package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-CuqHPUnn.js} +1 -1
- package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-gRLM3lLx.js} +1 -1
- package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-B4PI0vEk.js} +1 -1
- package/dist/client/assets/check-BXvzrdoz.js +1 -0
- package/dist/client/assets/chevron-left-BB5MYb7g.js +1 -0
- package/dist/client/assets/hard-drive-6uYFyezl.js +1 -0
- package/dist/client/assets/index-BRT5O-Hg.js +5 -0
- package/dist/client/assets/index-C0t11tlT.js +14 -0
- package/dist/client/assets/index-DXWp2bzM.css +1 -0
- package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-DOAxDSfz.js} +1 -1
- package/dist/client/assets/minimize-2-BWtbag2I.js +1 -0
- package/dist/client/assets/{minus-Beso_n-6.js → minus-9eVJnugf.js} +1 -1
- package/dist/client/assets/{plus-BfEC7mzf.js → plus-BGXipRoG.js} +1 -1
- package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Dvp3O90f.js} +1 -1
- package/dist/client/assets/{search-DL2UGvdj.js → search-b1ORp1k8.js} +1 -1
- package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-Bk0_MzWi.js} +1 -1
- package/dist/client/assets/{spellCheck-L9gD2d1M.js → spellCheck-BJjWgnef.js} +1 -1
- package/dist/client/assets/{tags-CjkWzXqX.js → tags-JAkbmWns.js} +1 -1
- package/dist/client/assets/{type-D1mmDdLM.js → type-CE2DYkPH.js} +1 -1
- package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus-CKalKSsw.js} +1 -1
- package/dist/client/assets/x-Bq29njHL.js +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +45 -4
- package/dist/server/cli.js +4 -0
- package/dist/server/collaboration.js +219 -7
- package/dist/server/commentMentions.js +139 -0
- package/dist/server/config.js +9 -2
- package/dist/server/db.js +59 -0
- package/dist/server/editHistory.js +411 -0
- package/dist/server/editHistoryRetry.js +90 -0
- package/dist/server/history.js +136 -20
- package/dist/server/historyRetention.js +37 -0
- package/dist/server/i18n.js +4 -0
- package/dist/server/index.js +6 -0
- package/dist/server/logRotation.js +82 -0
- package/dist/server/pm2.js +7 -0
- package/dist/server/routes/comments.js +107 -16
- package/dist/server/routes/projectHistory.js +93 -7
- package/dist/server/routes/projectShared.js +2 -1
- package/dist/server/routes/projects.js +3 -1
- package/package.json +1 -1
- package/texlite.config.example.json +4 -1
- package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
- package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
- package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
- package/dist/client/assets/check-bnimQPun.js +0 -1
- package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
- package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
- package/dist/client/assets/index-CzVDvC5h.js +0 -14
- package/dist/client/assets/index-RjUuWk8F.css +0 -1
- package/dist/client/assets/x-BDSEosRE.js +0 -1
|
@@ -11,6 +11,7 @@ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, modifyAwareness
|
|
|
11
11
|
import { listProjectFilesAsync, outputRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
12
12
|
import { collaborationProjectAccessFromStatement, canEdit, prepareCollaborationProjectAccessStatement } from "./projects.js";
|
|
13
13
|
import { reanchorFileComments } from "./anchors.js";
|
|
14
|
+
import { hashText } from "./editHistory.js";
|
|
14
15
|
const MESSAGE_SYNC = 0;
|
|
15
16
|
const MESSAGE_AWARENESS = 1;
|
|
16
17
|
const MESSAGE_QUERY_AWARENESS = 3;
|
|
@@ -43,11 +44,17 @@ const STATE_SAVE_DELAY_MS = 750;
|
|
|
43
44
|
const ROOM_IDLE_MS = 30_000;
|
|
44
45
|
const FORMAT_LEASE_TTL_MS = 45_000;
|
|
45
46
|
const MAX_FORMAT_LEASE_WAITERS = MAX_PROJECT_SESSIONS * 2;
|
|
47
|
+
// A normal typing burst produces a few small Yjs updates. Keep adjacent
|
|
48
|
+
// updates from the same user together without ever merging across another
|
|
49
|
+
// collaborator's edit, so the resulting record remains attributable.
|
|
50
|
+
const EDIT_SEGMENT_IDLE_MS = 30_000;
|
|
51
|
+
const MAX_EDIT_SPANS_PER_STEP = 96;
|
|
52
|
+
const MAX_EDIT_PREVIEW_CHARS = 1_200;
|
|
46
53
|
// Completion notifications are useful only to browsers that were already
|
|
47
54
|
// present when the operation finished. The database and retained manifests
|
|
48
55
|
// are the authority for a later workspace open.
|
|
49
56
|
const COMPLETED_COMPILE_STATE_TTL_MS = 60_000;
|
|
50
|
-
const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
|
|
57
|
+
const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision", "historyWarning"];
|
|
51
58
|
const COLORS = [
|
|
52
59
|
["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
|
|
53
60
|
["#9a58b5", "#9a58b533"], ["#d27b18", "#d27b1833"], ["#3f7d20", "#3f7d2033"],
|
|
@@ -64,6 +71,8 @@ export class CollaborationService {
|
|
|
64
71
|
projectGenerations = new Map();
|
|
65
72
|
snapshotBarriers = new Map();
|
|
66
73
|
pendingConnections = new Map();
|
|
74
|
+
/** Transient server-side status, restored into each newly opened room. */
|
|
75
|
+
historyWarnings = new Set();
|
|
67
76
|
closed = false;
|
|
68
77
|
userByIdStatement;
|
|
69
78
|
collaborationProjectAccessStatement;
|
|
@@ -397,6 +406,16 @@ export class CollaborationService {
|
|
|
397
406
|
const filePath = filePathInput ? safeRelativePath(filePathInput) : "";
|
|
398
407
|
this.bumpFiles(room, { kind: "update", path: filePath });
|
|
399
408
|
}
|
|
409
|
+
setHistoryWarning(projectId, warning) {
|
|
410
|
+
if (warning)
|
|
411
|
+
this.historyWarnings.add(projectId);
|
|
412
|
+
else
|
|
413
|
+
this.historyWarnings.delete(projectId);
|
|
414
|
+
const room = this.rooms.get(projectId);
|
|
415
|
+
if (!room || room.meta.get("historyWarning") === warning)
|
|
416
|
+
return;
|
|
417
|
+
room.doc.transact(() => room.meta.set("historyWarning", warning), META_ORIGIN);
|
|
418
|
+
}
|
|
400
419
|
signalComments(projectId) {
|
|
401
420
|
const room = this.rooms.get(projectId);
|
|
402
421
|
if (!room)
|
|
@@ -565,6 +584,7 @@ export class CollaborationService {
|
|
|
565
584
|
this.roomInitializations.clear();
|
|
566
585
|
this.snapshotBarriers.clear();
|
|
567
586
|
this.pendingConnections.clear();
|
|
587
|
+
this.historyWarnings.clear();
|
|
568
588
|
}
|
|
569
589
|
initializeRoom(projectId, generation) {
|
|
570
590
|
const existing = this.rooms.get(projectId);
|
|
@@ -684,7 +704,9 @@ export class CollaborationService {
|
|
|
684
704
|
awarenessOwners: new Map(),
|
|
685
705
|
allowedPaths: new Set(),
|
|
686
706
|
persistedContent: new Map(),
|
|
707
|
+
observedContent: new Map(),
|
|
687
708
|
dirtyPaths: new Set(),
|
|
709
|
+
pendingEditSegments: [],
|
|
688
710
|
textObservers: new Map(),
|
|
689
711
|
saveTimer: null,
|
|
690
712
|
stateSaveTimer: null,
|
|
@@ -707,6 +729,12 @@ export class CollaborationService {
|
|
|
707
729
|
// longer exists. The database/source tree are authoritative after a
|
|
708
730
|
// restart; clear the markers before any browser can sync them back.
|
|
709
731
|
const recoveredMetadataChanged = bootstrap.recoveredState && this.clearRecoveredMetadata(room);
|
|
732
|
+
// History bookkeeping is not stored in a project Yjs document, but a
|
|
733
|
+
// temporary failure must remain visible if its room was idled out before
|
|
734
|
+
// the editor reconnects.
|
|
735
|
+
if (this.historyWarnings.has(projectId)) {
|
|
736
|
+
room.doc.transact(() => room.meta.set("historyWarning", true), META_ORIGIN);
|
|
737
|
+
}
|
|
710
738
|
this.sanitizeCompileStates(room);
|
|
711
739
|
let recoveredDirty = false;
|
|
712
740
|
const diskPaths = new Set();
|
|
@@ -727,13 +755,17 @@ export class CollaborationService {
|
|
|
727
755
|
else {
|
|
728
756
|
replaceText(text, file.content);
|
|
729
757
|
}
|
|
758
|
+
room.observedContent.set(file.path, text.toString());
|
|
730
759
|
}
|
|
731
760
|
for (const name of room.doc.share.keys()) {
|
|
732
761
|
if (!name.startsWith(SOURCE_PREFIX))
|
|
733
762
|
continue;
|
|
734
763
|
const filePath = name.slice(SOURCE_PREFIX.length);
|
|
735
|
-
if (!diskPaths.has(filePath))
|
|
736
|
-
|
|
764
|
+
if (!diskPaths.has(filePath)) {
|
|
765
|
+
const text = room.doc.getText(name);
|
|
766
|
+
replaceText(text, "");
|
|
767
|
+
room.observedContent.set(filePath, text.toString());
|
|
768
|
+
}
|
|
737
769
|
}
|
|
738
770
|
}, DISK_ORIGIN);
|
|
739
771
|
const rejectedRecoveredPaths = this.rejectOversizedTexts(room);
|
|
@@ -839,9 +871,17 @@ export class CollaborationService {
|
|
|
839
871
|
const text = room.doc.getText(typeName(filePath));
|
|
840
872
|
if (room.textObservers.has(filePath))
|
|
841
873
|
return text;
|
|
842
|
-
const observer = (
|
|
843
|
-
|
|
874
|
+
const observer = (event, transaction) => {
|
|
875
|
+
const before = room.observedContent.get(filePath) ?? text.toString();
|
|
876
|
+
const after = text.toString();
|
|
877
|
+
if (transaction.origin === DISK_ORIGIN || transaction.origin === HTTP_ORIGIN || transaction.origin === META_ORIGIN) {
|
|
878
|
+
// Non-collaborative mutations deliberately reset the source chain.
|
|
879
|
+
// They are represented by recovery snapshots, not by a guessed user
|
|
880
|
+
// edit record; keeping this mirror current prevents later edits from
|
|
881
|
+
// being mapped across such a replacement.
|
|
882
|
+
room.observedContent.set(filePath, after);
|
|
844
883
|
return;
|
|
884
|
+
}
|
|
845
885
|
if (!room.allowedPaths.has(filePath)) {
|
|
846
886
|
// A client can still have the old Y.Text bound briefly after another
|
|
847
887
|
// session deletes or moves a file. Correct any late update immediately;
|
|
@@ -850,12 +890,60 @@ export class CollaborationService {
|
|
|
850
890
|
room.doc.transact(() => replaceText(text, ""), DISK_ORIGIN);
|
|
851
891
|
return;
|
|
852
892
|
}
|
|
893
|
+
if (isConnectionOrigin(transaction.origin) && before !== after) {
|
|
894
|
+
const connection = transaction.origin;
|
|
895
|
+
this.recordEditStep(room, filePath, connection, event, before, after);
|
|
896
|
+
}
|
|
897
|
+
room.observedContent.set(filePath, after);
|
|
853
898
|
room.dirtyPaths.add(filePath);
|
|
854
899
|
};
|
|
855
900
|
text.observe(observer);
|
|
856
901
|
room.textObservers.set(filePath, observer);
|
|
857
902
|
return text;
|
|
858
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* Keep live collaborative changes in short, author-isolated segments. The
|
|
906
|
+
* segment is only handed to the history service after `flushRoom()` has
|
|
907
|
+
* made the matching source content durable.
|
|
908
|
+
*/
|
|
909
|
+
recordEditStep(room, filePath, connection, event, before, after) {
|
|
910
|
+
const spans = editSpansFromYDelta(event.delta, before, after);
|
|
911
|
+
if (!spans.length)
|
|
912
|
+
return;
|
|
913
|
+
const createdAt = new Date().toISOString();
|
|
914
|
+
const activeLease = this.activeFormatLease(room, filePath);
|
|
915
|
+
const kind = activeLease?.connection === connection ? "format" : "edit";
|
|
916
|
+
const step = {
|
|
917
|
+
beforeHash: hashText(before),
|
|
918
|
+
afterHash: hashText(after),
|
|
919
|
+
createdAt,
|
|
920
|
+
spans
|
|
921
|
+
};
|
|
922
|
+
const previous = room.pendingEditSegments.at(-1);
|
|
923
|
+
const previousTime = previous ? Date.parse(previous.updatedAt) : Number.NaN;
|
|
924
|
+
if (previous
|
|
925
|
+
&& previous.filePath === filePath
|
|
926
|
+
&& previous.authorId === connection.user.id
|
|
927
|
+
&& previous.kind === kind
|
|
928
|
+
&& previous.afterHash === step.beforeHash
|
|
929
|
+
&& Number.isFinite(previousTime)
|
|
930
|
+
&& Date.parse(createdAt) - previousTime <= EDIT_SEGMENT_IDLE_MS) {
|
|
931
|
+
previous.steps.push(step);
|
|
932
|
+
previous.afterHash = step.afterHash;
|
|
933
|
+
previous.updatedAt = createdAt;
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
room.pendingEditSegments.push({
|
|
937
|
+
filePath,
|
|
938
|
+
authorId: connection.user.id,
|
|
939
|
+
kind,
|
|
940
|
+
beforeHash: step.beforeHash,
|
|
941
|
+
afterHash: step.afterHash,
|
|
942
|
+
createdAt,
|
|
943
|
+
updatedAt: createdAt,
|
|
944
|
+
steps: [step]
|
|
945
|
+
});
|
|
946
|
+
}
|
|
859
947
|
handleMessage(room, connection, bytes) {
|
|
860
948
|
const refreshedUser = this.userByIdStatement.get(connection.user.id);
|
|
861
949
|
if (!refreshedUser || refreshedUser.disabled) {
|
|
@@ -1287,21 +1375,24 @@ export class CollaborationService {
|
|
|
1287
1375
|
if (room.saveTimer)
|
|
1288
1376
|
clearTimeout(room.saveTimer);
|
|
1289
1377
|
room.saveTimer = null;
|
|
1290
|
-
this.rejectOversizedTexts(room);
|
|
1378
|
+
const rejectedDuringFlush = this.rejectOversizedTexts(room);
|
|
1291
1379
|
this.persistRoomState(room);
|
|
1292
1380
|
let changed = false;
|
|
1293
1381
|
const changedPaths = [];
|
|
1294
1382
|
const failedPaths = [...room.rejectedPaths];
|
|
1383
|
+
const finalizedPaths = new Set(rejectedDuringFlush);
|
|
1295
1384
|
const dirtyPaths = [...room.dirtyPaths];
|
|
1296
1385
|
for (const filePath of dirtyPaths) {
|
|
1297
1386
|
if (!room.allowedPaths.has(filePath)) {
|
|
1298
1387
|
room.dirtyPaths.delete(filePath);
|
|
1388
|
+
finalizedPaths.add(filePath);
|
|
1299
1389
|
continue;
|
|
1300
1390
|
}
|
|
1301
1391
|
const next = this.trackedText(room, filePath).toString();
|
|
1302
1392
|
const previous = room.persistedContent.get(filePath) ?? "";
|
|
1303
1393
|
if (next === previous) {
|
|
1304
1394
|
room.dirtyPaths.delete(filePath);
|
|
1395
|
+
finalizedPaths.add(filePath);
|
|
1305
1396
|
continue;
|
|
1306
1397
|
}
|
|
1307
1398
|
if (Buffer.byteLength(next, "utf8") > maxCollaborativeFileBytes(this.config)) {
|
|
@@ -1328,6 +1419,7 @@ export class CollaborationService {
|
|
|
1328
1419
|
}
|
|
1329
1420
|
room.persistedContent.set(filePath, next);
|
|
1330
1421
|
room.dirtyPaths.delete(filePath);
|
|
1422
|
+
finalizedPaths.add(filePath);
|
|
1331
1423
|
try {
|
|
1332
1424
|
reanchorFileComments(this.db, room.projectId, filePath, previous, next);
|
|
1333
1425
|
}
|
|
@@ -1335,6 +1427,13 @@ export class CollaborationService {
|
|
|
1335
1427
|
changed = true;
|
|
1336
1428
|
changedPaths.push(filePath);
|
|
1337
1429
|
}
|
|
1430
|
+
const persistedPaths = new Set(changedPaths);
|
|
1431
|
+
const edits = room.pendingEditSegments.filter((segment) => persistedPaths.has(segment.filePath));
|
|
1432
|
+
// A reverted edit or rejected oversized file did not produce a durable
|
|
1433
|
+
// source revision, so it must not become a misleading edit-history row.
|
|
1434
|
+
if (finalizedPaths.size > 0) {
|
|
1435
|
+
room.pendingEditSegments = room.pendingEditSegments.filter((segment) => !finalizedPaths.has(segment.filePath));
|
|
1436
|
+
}
|
|
1338
1437
|
if (changed && room.lastModifiedUserId) {
|
|
1339
1438
|
this.db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
|
|
1340
1439
|
.run(new Date().toISOString(), room.lastModifiedUserId, room.projectId);
|
|
@@ -1343,7 +1442,7 @@ export class CollaborationService {
|
|
|
1343
1442
|
this.signalComments(room.projectId);
|
|
1344
1443
|
if (changed && this.onPersist) {
|
|
1345
1444
|
try {
|
|
1346
|
-
this.onPersist({ projectId: room.projectId, userId: room.lastModifiedUserId, paths: changedPaths, durationMs: performance.now() - startedAt });
|
|
1445
|
+
this.onPersist({ projectId: room.projectId, userId: room.lastModifiedUserId, paths: changedPaths, edits, durationMs: performance.now() - startedAt });
|
|
1347
1446
|
}
|
|
1348
1447
|
catch { /* Source durability must not depend on optional history bookkeeping. */ }
|
|
1349
1448
|
}
|
|
@@ -1520,6 +1619,119 @@ function replaceText(text, content) {
|
|
|
1520
1619
|
if (content)
|
|
1521
1620
|
text.insert(0, content);
|
|
1522
1621
|
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Turn Y.Text's transaction delta into compact positional spans. We retain
|
|
1624
|
+
* lengths for exact range mapping, while previews keep a pasted/formatter
|
|
1625
|
+
* change from turning the edit-history database into a second source store.
|
|
1626
|
+
*/
|
|
1627
|
+
function editSpansFromYDelta(delta, before, after) {
|
|
1628
|
+
if (before === after)
|
|
1629
|
+
return [];
|
|
1630
|
+
let beforeOffset = 0;
|
|
1631
|
+
let afterOffset = 0;
|
|
1632
|
+
let invalid = false;
|
|
1633
|
+
const spans = [];
|
|
1634
|
+
let pending = null;
|
|
1635
|
+
const flushPending = () => {
|
|
1636
|
+
if (!pending)
|
|
1637
|
+
return;
|
|
1638
|
+
const deletedLength = pending.deleted.length;
|
|
1639
|
+
const insertedLength = pending.inserted.length;
|
|
1640
|
+
if (deletedLength || insertedLength) {
|
|
1641
|
+
spans.push(editSpan(pending.beforeStart, pending.beforeStart + deletedLength, pending.afterStart, pending.afterStart + insertedLength, pending.deleted, pending.inserted));
|
|
1642
|
+
}
|
|
1643
|
+
pending = null;
|
|
1644
|
+
};
|
|
1645
|
+
const startPending = () => {
|
|
1646
|
+
if (!pending)
|
|
1647
|
+
pending = { beforeStart: beforeOffset, afterStart: afterOffset, deleted: "", inserted: "" };
|
|
1648
|
+
return pending;
|
|
1649
|
+
};
|
|
1650
|
+
for (const raw of delta) {
|
|
1651
|
+
if (!raw || typeof raw !== "object") {
|
|
1652
|
+
invalid = true;
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1655
|
+
const operation = raw;
|
|
1656
|
+
if (typeof operation.retain === "number" && Number.isInteger(operation.retain) && operation.retain >= 0) {
|
|
1657
|
+
flushPending();
|
|
1658
|
+
beforeOffset += operation.retain;
|
|
1659
|
+
afterOffset += operation.retain;
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
if (typeof operation.delete === "number" && Number.isInteger(operation.delete) && operation.delete >= 0) {
|
|
1663
|
+
const length = operation.delete;
|
|
1664
|
+
if (beforeOffset + length > before.length) {
|
|
1665
|
+
invalid = true;
|
|
1666
|
+
break;
|
|
1667
|
+
}
|
|
1668
|
+
startPending().deleted += before.slice(beforeOffset, beforeOffset + length);
|
|
1669
|
+
beforeOffset += length;
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
if (typeof operation.insert === "string") {
|
|
1673
|
+
startPending().inserted += operation.insert;
|
|
1674
|
+
afterOffset += operation.insert.length;
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
invalid = true;
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
flushPending();
|
|
1681
|
+
// Yjs normally emits retains for all unchanged text. Be defensive about
|
|
1682
|
+
// omitted trailing retains, then reject anything whose coordinate model does
|
|
1683
|
+
// not reconstruct the actual strings.
|
|
1684
|
+
const beforeRemaining = before.length - beforeOffset;
|
|
1685
|
+
const afterRemaining = after.length - afterOffset;
|
|
1686
|
+
if (beforeRemaining === afterRemaining && beforeRemaining >= 0) {
|
|
1687
|
+
beforeOffset = before.length;
|
|
1688
|
+
afterOffset = after.length;
|
|
1689
|
+
}
|
|
1690
|
+
if (invalid || beforeOffset !== before.length || afterOffset !== after.length || spans.length > MAX_EDIT_SPANS_PER_STEP) {
|
|
1691
|
+
return [wholeDocumentEditSpan(before, after)];
|
|
1692
|
+
}
|
|
1693
|
+
return spans.length ? spans : [wholeDocumentEditSpan(before, after)];
|
|
1694
|
+
}
|
|
1695
|
+
function wholeDocumentEditSpan(before, after) {
|
|
1696
|
+
const prefix = commonPrefixLength(before, after);
|
|
1697
|
+
const suffix = commonSuffixLength(before, after, prefix);
|
|
1698
|
+
return editSpan(prefix, before.length - suffix, prefix, after.length - suffix, before.slice(prefix, before.length - suffix), after.slice(prefix, after.length - suffix));
|
|
1699
|
+
}
|
|
1700
|
+
function editSpan(beforeStart, beforeEnd, afterStart, afterEnd, deleted, inserted) {
|
|
1701
|
+
const deletedPreview = previewEditText(deleted);
|
|
1702
|
+
const insertedPreview = previewEditText(inserted);
|
|
1703
|
+
return {
|
|
1704
|
+
beforeStart,
|
|
1705
|
+
beforeEnd,
|
|
1706
|
+
afterStart,
|
|
1707
|
+
afterEnd,
|
|
1708
|
+
deletedLength: deleted.length,
|
|
1709
|
+
insertedLength: inserted.length,
|
|
1710
|
+
deletedPreview,
|
|
1711
|
+
insertedPreview,
|
|
1712
|
+
truncated: deletedPreview.length < deleted.length || insertedPreview.length < inserted.length
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
function previewEditText(value) {
|
|
1716
|
+
if (value.length <= MAX_EDIT_PREVIEW_CHARS)
|
|
1717
|
+
return value;
|
|
1718
|
+
const edge = Math.floor((MAX_EDIT_PREVIEW_CHARS - 1) / 2);
|
|
1719
|
+
return `${value.slice(0, edge)}…${value.slice(value.length - edge)}`;
|
|
1720
|
+
}
|
|
1721
|
+
function commonPrefixLength(left, right) {
|
|
1722
|
+
const max = Math.min(left.length, right.length);
|
|
1723
|
+
let index = 0;
|
|
1724
|
+
while (index < max && left.charCodeAt(index) === right.charCodeAt(index))
|
|
1725
|
+
index += 1;
|
|
1726
|
+
return index;
|
|
1727
|
+
}
|
|
1728
|
+
function commonSuffixLength(left, right, prefixLength) {
|
|
1729
|
+
const max = Math.min(left.length, right.length) - prefixLength;
|
|
1730
|
+
let index = 0;
|
|
1731
|
+
while (index < max && left.charCodeAt(left.length - 1 - index) === right.charCodeAt(right.length - 1 - index))
|
|
1732
|
+
index += 1;
|
|
1733
|
+
return index;
|
|
1734
|
+
}
|
|
1523
1735
|
function syncUpdateMessage(update) {
|
|
1524
1736
|
const encoder = encoding.createEncoder();
|
|
1525
1737
|
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* List active users who can access a project. The owner is included even
|
|
4
|
+
* though they have no project_members row. Callers can exclude the author
|
|
5
|
+
* when presenting a mention picker.
|
|
6
|
+
*/
|
|
7
|
+
export function mentionableUsersForProject(db, projectId, excludeUserId) {
|
|
8
|
+
const exclusion = excludeUserId ? "AND user.id <> ?" : "";
|
|
9
|
+
const parameters = excludeUserId ? [projectId, excludeUserId] : [projectId];
|
|
10
|
+
const rows = db.prepare(`SELECT DISTINCT user.id, user.username, user.display_name
|
|
11
|
+
FROM users user
|
|
12
|
+
JOIN projects project ON project.id = ?
|
|
13
|
+
LEFT JOIN project_members member ON member.project_id = project.id AND member.user_id = user.id
|
|
14
|
+
WHERE user.disabled = 0
|
|
15
|
+
AND (user.id = project.owner_id OR member.user_id IS NOT NULL)
|
|
16
|
+
${exclusion}
|
|
17
|
+
ORDER BY user.display_name COLLATE NOCASE, user.username COLLATE NOCASE`)
|
|
18
|
+
.all(...parameters);
|
|
19
|
+
return rows.map((user) => ({ id: user.id, username: user.username, displayName: user.display_name }));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Extract mention tokens without taking ownership of ordinary `@` text.
|
|
23
|
+
* The surrounding-character rule intentionally excludes e-mail addresses;
|
|
24
|
+
* the candidate lookup below then makes a token a mention only when it is an
|
|
25
|
+
* exact, currently accessible username.
|
|
26
|
+
*/
|
|
27
|
+
export function mentionedUsernames(content, candidates) {
|
|
28
|
+
const available = new Map(candidates.map((candidate) => [candidate.username.toLocaleLowerCase(), candidate.username]));
|
|
29
|
+
const usernames = new Set();
|
|
30
|
+
const pattern = /(^|[^\p{L}\p{N}_.-])@([\p{L}\p{N}_.-]+)/gu;
|
|
31
|
+
for (const match of content.matchAll(pattern)) {
|
|
32
|
+
let token = match[2];
|
|
33
|
+
let username = available.get(token.toLocaleLowerCase());
|
|
34
|
+
// A sentence-ending period or comma is not part of the mention, even
|
|
35
|
+
// though a username itself is allowed to contain dots and hyphens.
|
|
36
|
+
while (!username && /[.,;:!?)}\]\u201d\u2019]$/u.test(token)) {
|
|
37
|
+
token = token.slice(0, -1);
|
|
38
|
+
username = available.get(token.toLocaleLowerCase());
|
|
39
|
+
}
|
|
40
|
+
if (username)
|
|
41
|
+
usernames.add(username);
|
|
42
|
+
}
|
|
43
|
+
return [...usernames];
|
|
44
|
+
}
|
|
45
|
+
/** Persist first-class notifications for exact mentions in one comment item. */
|
|
46
|
+
export function createCommentMentions(db, input) {
|
|
47
|
+
const candidates = mentionableUsersForProject(db, input.projectId);
|
|
48
|
+
const previousUsernames = new Set(input.previousContent ? mentionedUsernames(input.previousContent, candidates) : []);
|
|
49
|
+
const recipients = mentionedUsernames(input.content, candidates)
|
|
50
|
+
.map((username) => candidates.find((candidate) => candidate.username === username))
|
|
51
|
+
.filter((candidate) => Boolean(candidate && candidate.id !== input.authorId && !previousUsernames.has(candidate.username)));
|
|
52
|
+
if (!recipients.length)
|
|
53
|
+
return;
|
|
54
|
+
const insert = db.prepare(`INSERT INTO comment_mentions
|
|
55
|
+
(id, project_id, comment_id, reply_id, mentioned_user_id, mentioned_by_user_id, created_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
57
|
+
ON CONFLICT DO UPDATE SET
|
|
58
|
+
mentioned_by_user_id = excluded.mentioned_by_user_id,
|
|
59
|
+
created_at = excluded.created_at,
|
|
60
|
+
read_at = NULL,
|
|
61
|
+
read_reason = NULL`);
|
|
62
|
+
for (const recipient of recipients) {
|
|
63
|
+
insert.run(randomUUID(), input.projectId, input.commentId, input.replyId ?? null, recipient.id, input.authorId, input.createdAt);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Return unread mention counts scoped to one receiving user. */
|
|
67
|
+
export function unreadMentionCountsForProjects(db, projectIds, userId) {
|
|
68
|
+
const counts = new Map();
|
|
69
|
+
for (let offset = 0; offset < projectIds.length; offset += 200) {
|
|
70
|
+
const chunk = projectIds.slice(offset, offset + 200);
|
|
71
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
72
|
+
const rows = db.prepare(`SELECT project_id, COUNT(*) AS count
|
|
73
|
+
FROM comment_mentions
|
|
74
|
+
WHERE mentioned_user_id = ? AND read_at IS NULL AND project_id IN (${placeholders})
|
|
75
|
+
GROUP BY project_id`).all(userId, ...chunk);
|
|
76
|
+
for (const row of rows)
|
|
77
|
+
counts.set(row.project_id, Number(row.count) || 0);
|
|
78
|
+
}
|
|
79
|
+
return counts;
|
|
80
|
+
}
|
|
81
|
+
function mentionFromRow(row) {
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
projectId: row.project_id,
|
|
85
|
+
commentId: row.comment_id,
|
|
86
|
+
replyId: row.reply_id,
|
|
87
|
+
filePath: row.file_path,
|
|
88
|
+
content: row.reply_id ? row.reply_content ?? "" : row.comment_content,
|
|
89
|
+
resolved: Boolean(row.resolved),
|
|
90
|
+
createdAt: row.created_at,
|
|
91
|
+
readAt: row.read_at,
|
|
92
|
+
readReason: row.read_reason
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const mentionSelect = `SELECT mention.id, mention.project_id, mention.comment_id, mention.reply_id,
|
|
96
|
+
comment.file_path, comment.content AS comment_content, reply.content AS reply_content,
|
|
97
|
+
comment.resolved, mention.created_at, mention.read_at, mention.read_reason
|
|
98
|
+
FROM comment_mentions mention
|
|
99
|
+
JOIN comments comment ON comment.id = mention.comment_id
|
|
100
|
+
LEFT JOIN comment_replies reply ON reply.id = mention.reply_id`;
|
|
101
|
+
export function listCommentMentions(db, projectId, userId, options = {}) {
|
|
102
|
+
const limit = Math.min(100, Math.max(1, options.limit ?? 50));
|
|
103
|
+
const unreadClause = options.unreadOnly ? "AND mention.read_at IS NULL" : "";
|
|
104
|
+
const fileClause = options.filePath ? "AND comment.file_path = ?" : "";
|
|
105
|
+
const parameters = options.filePath
|
|
106
|
+
? [projectId, userId, options.filePath, limit]
|
|
107
|
+
: [projectId, userId, limit];
|
|
108
|
+
const rows = db.prepare(`${mentionSelect}
|
|
109
|
+
WHERE mention.project_id = ? AND mention.mentioned_user_id = ? ${unreadClause} ${fileClause}
|
|
110
|
+
ORDER BY mention.created_at DESC LIMIT ?`).all(...parameters);
|
|
111
|
+
return rows.map(mentionFromRow);
|
|
112
|
+
}
|
|
113
|
+
export function commentMentionForUser(db, mentionId, projectId, userId) {
|
|
114
|
+
const row = db.prepare(`${mentionSelect}
|
|
115
|
+
WHERE mention.id = ? AND mention.project_id = ? AND mention.mentioned_user_id = ?`)
|
|
116
|
+
.get(mentionId, projectId, userId);
|
|
117
|
+
return row ? mentionFromRow(row) : null;
|
|
118
|
+
}
|
|
119
|
+
/** Mark an item read after the UI has opened and located its target thread. */
|
|
120
|
+
export function markCommentMentionRead(db, mentionId, userId, readAt, reason = "opened") {
|
|
121
|
+
return db.prepare(`UPDATE comment_mentions
|
|
122
|
+
SET read_at = ?, read_reason = ?
|
|
123
|
+
WHERE id = ? AND mentioned_user_id = ? AND read_at IS NULL`)
|
|
124
|
+
.run(readAt, reason, mentionId, userId).changes > 0;
|
|
125
|
+
}
|
|
126
|
+
/** Explicitly clear every unread notification in a project for one recipient. */
|
|
127
|
+
export function markAllCommentMentionsRead(db, projectId, userId, readAt) {
|
|
128
|
+
return db.prepare(`UPDATE comment_mentions
|
|
129
|
+
SET read_at = ?, read_reason = 'manual'
|
|
130
|
+
WHERE project_id = ? AND mentioned_user_id = ? AND read_at IS NULL`)
|
|
131
|
+
.run(readAt, projectId, userId).changes;
|
|
132
|
+
}
|
|
133
|
+
/** Resolving a thread makes all existing unread mentions in it non-actionable. */
|
|
134
|
+
export function markCommentMentionsResolved(db, commentId, resolvedAt) {
|
|
135
|
+
return db.prepare(`UPDATE comment_mentions
|
|
136
|
+
SET read_at = ?, read_reason = 'resolved'
|
|
137
|
+
WHERE comment_id = ? AND read_at IS NULL`)
|
|
138
|
+
.run(resolvedAt, commentId).changes;
|
|
139
|
+
}
|
package/dist/server/config.js
CHANGED
|
@@ -22,8 +22,9 @@ export const CONFIG_DEFAULTS = {
|
|
|
22
22
|
maxFileSizeMB: 50,
|
|
23
23
|
pdfLoadingStrategy: "auto",
|
|
24
24
|
pdfRangeThresholdMB: 5,
|
|
25
|
-
historyMaxVersions:
|
|
25
|
+
historyMaxVersions: 0,
|
|
26
26
|
historyMaxStorageMB: 128,
|
|
27
|
+
editHistoryMaxStorageMB: 32,
|
|
27
28
|
git: "git",
|
|
28
29
|
gitOperationTimeoutSeconds: 120,
|
|
29
30
|
githubApiBaseUrl: "https://api.github.com"
|
|
@@ -35,8 +36,9 @@ const CONFIG_LIMITS = {
|
|
|
35
36
|
maxCompileJobs: [1, 32],
|
|
36
37
|
maxFileSizeMB: [1, 2_048],
|
|
37
38
|
pdfRangeThresholdMB: [1, 2_048],
|
|
38
|
-
historyMaxVersions: [
|
|
39
|
+
historyMaxVersions: [0, 50_000],
|
|
39
40
|
historyMaxStorageMB: [16, 102_400],
|
|
41
|
+
editHistoryMaxStorageMB: [4, 102_400],
|
|
40
42
|
gitOperationTimeoutSeconds: [1, 3_600]
|
|
41
43
|
};
|
|
42
44
|
export function loadConfig(configPathOverride) {
|
|
@@ -70,6 +72,7 @@ export function loadConfig(configPathOverride) {
|
|
|
70
72
|
const pdfRangeThresholdMB = integerSetting("pdf.rangeThresholdMB", process.env.TEXLITE_PDF_RANGE_THRESHOLD_MB, fileConfig.pdf?.rangeThresholdMB, CONFIG_DEFAULTS.pdfRangeThresholdMB, CONFIG_LIMITS.pdfRangeThresholdMB);
|
|
71
73
|
const historyMaxVersions = integerSetting("history.maxVersions", process.env.TEXLITE_HISTORY_MAX_VERSIONS, fileConfig.history?.maxVersions, CONFIG_DEFAULTS.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
|
|
72
74
|
const historyMaxStorageMB = integerSetting("history.maxStorageMB", process.env.TEXLITE_HISTORY_MAX_STORAGE_MB, fileConfig.history?.maxStorageMB, CONFIG_DEFAULTS.historyMaxStorageMB, CONFIG_LIMITS.historyMaxStorageMB);
|
|
75
|
+
const editHistoryMaxStorageMB = integerSetting("editHistory.maxStorageMB", process.env.TEXLITE_EDIT_HISTORY_MAX_STORAGE_MB, fileConfig.editHistory?.maxStorageMB, CONFIG_DEFAULTS.editHistoryMaxStorageMB, CONFIG_LIMITS.editHistoryMaxStorageMB);
|
|
73
76
|
const gitOperationTimeoutSeconds = integerSetting("git.operationTimeoutSeconds", process.env.TEXLITE_GIT_TIMEOUT, fileConfig.git?.operationTimeoutSeconds, CONFIG_DEFAULTS.gitOperationTimeoutSeconds, CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
74
77
|
const config = {
|
|
75
78
|
configPath,
|
|
@@ -94,6 +97,7 @@ export function loadConfig(configPathOverride) {
|
|
|
94
97
|
pdfRangeThresholdBytes: pdfRangeThresholdMB * 1024 * 1024,
|
|
95
98
|
historyMaxVersions,
|
|
96
99
|
historyMaxStorageBytes: historyMaxStorageMB * 1024 * 1024,
|
|
100
|
+
editHistoryMaxStorageBytes: editHistoryMaxStorageMB * 1024 * 1024,
|
|
97
101
|
git: stringSetting("git.binary", process.env.TEXLITE_GIT, fileConfig.git?.binary, CONFIG_DEFAULTS.git, { min: 1, max: 256 }),
|
|
98
102
|
gitOperationTimeoutMs: gitOperationTimeoutSeconds * 1000,
|
|
99
103
|
githubApiBaseUrl: stringSetting("git.githubApiBaseUrl", process.env.TEXLITE_GITHUB_API_URL, fileConfig.git?.githubApiBaseUrl, CONFIG_DEFAULTS.githubApiBaseUrl, { min: 1, max: 2_048 }).replace(/\/+$/, "")
|
|
@@ -132,6 +136,7 @@ export function validateConfig(config) {
|
|
|
132
136
|
validateInteger("pdf.rangeThresholdMB", config.pdfRangeThresholdBytes / (1024 * 1024), CONFIG_LIMITS.pdfRangeThresholdMB);
|
|
133
137
|
validateInteger("history.maxVersions", config.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
|
|
134
138
|
validateInteger("history.maxStorageMB", config.historyMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.historyMaxStorageMB);
|
|
139
|
+
validateInteger("editHistory.maxStorageMB", config.editHistoryMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.editHistoryMaxStorageMB);
|
|
135
140
|
validateInteger("git.operationTimeoutSeconds", config.gitOperationTimeoutMs / 1000, CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
136
141
|
}
|
|
137
142
|
function isEngine(value) {
|
|
@@ -176,6 +181,8 @@ function validateFileConfig(config) {
|
|
|
176
181
|
const history = optionalSection(config.history, "history");
|
|
177
182
|
optionalInteger(history?.maxVersions, "history.maxVersions", CONFIG_LIMITS.historyMaxVersions);
|
|
178
183
|
optionalInteger(history?.maxStorageMB, "history.maxStorageMB", CONFIG_LIMITS.historyMaxStorageMB);
|
|
184
|
+
const editHistory = optionalSection(config.editHistory, "editHistory");
|
|
185
|
+
optionalInteger(editHistory?.maxStorageMB, "editHistory.maxStorageMB", CONFIG_LIMITS.editHistoryMaxStorageMB);
|
|
179
186
|
const latex = optionalSection(config.latex, "latex");
|
|
180
187
|
optionalString(latex?.latexmk, "latex.latexmk", { min: 1, max: 256 });
|
|
181
188
|
if (latex && Object.prototype.hasOwnProperty.call(latex, "defaultEngine") && !isEngine(latex.defaultEngine)) {
|
package/dist/server/db.js
CHANGED
|
@@ -143,6 +143,27 @@ function migrate(db) {
|
|
|
143
143
|
);
|
|
144
144
|
CREATE INDEX IF NOT EXISTS comment_replies_comment_id ON comment_replies(comment_id, created_at);
|
|
145
145
|
|
|
146
|
+
-- Mentions are personal notification records, rather than a mutable
|
|
147
|
+
-- counter on comments or projects. This makes unread state naturally
|
|
148
|
+
-- per user and lets resolving one thread clear its existing notifications.
|
|
149
|
+
CREATE TABLE IF NOT EXISTS comment_mentions (
|
|
150
|
+
id TEXT PRIMARY KEY,
|
|
151
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
152
|
+
comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
|
153
|
+
reply_id TEXT REFERENCES comment_replies(id) ON DELETE CASCADE,
|
|
154
|
+
mentioned_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
155
|
+
mentioned_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
156
|
+
read_at TEXT,
|
|
157
|
+
read_reason TEXT CHECK (read_reason IN ('opened', 'resolved', 'manual')),
|
|
158
|
+
created_at TEXT NOT NULL
|
|
159
|
+
);
|
|
160
|
+
CREATE INDEX IF NOT EXISTS comment_mentions_receiver_unread
|
|
161
|
+
ON comment_mentions(mentioned_user_id, project_id, created_at DESC)
|
|
162
|
+
WHERE read_at IS NULL;
|
|
163
|
+
CREATE INDEX IF NOT EXISTS comment_mentions_comment_id ON comment_mentions(comment_id);
|
|
164
|
+
CREATE UNIQUE INDEX IF NOT EXISTS comment_mentions_source_recipient
|
|
165
|
+
ON comment_mentions(comment_id, IFNULL(reply_id, ''), mentioned_user_id);
|
|
166
|
+
|
|
146
167
|
CREATE TABLE IF NOT EXISTS compile_runs (
|
|
147
168
|
id TEXT PRIMARY KEY,
|
|
148
169
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
@@ -174,6 +195,37 @@ function migrate(db) {
|
|
|
174
195
|
updated_at TEXT NOT NULL
|
|
175
196
|
);
|
|
176
197
|
|
|
198
|
+
-- Recovery snapshots and collaborative edit records serve different
|
|
199
|
+
-- purposes. Snapshots are content-addressed restore points; these rows
|
|
200
|
+
-- retain the ordered, author-attributed text changes that led to the
|
|
201
|
+
-- current source, so a selected range can show who changed it.
|
|
202
|
+
CREATE TABLE IF NOT EXISTS project_edit_segments (
|
|
203
|
+
id TEXT PRIMARY KEY,
|
|
204
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
205
|
+
file_path TEXT NOT NULL,
|
|
206
|
+
author_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
207
|
+
kind TEXT NOT NULL CHECK (kind IN ('edit', 'format')),
|
|
208
|
+
before_hash TEXT NOT NULL,
|
|
209
|
+
after_hash TEXT NOT NULL,
|
|
210
|
+
steps_json TEXT NOT NULL,
|
|
211
|
+
steps_bytes INTEGER NOT NULL DEFAULT 0,
|
|
212
|
+
created_at TEXT NOT NULL,
|
|
213
|
+
updated_at TEXT NOT NULL
|
|
214
|
+
);
|
|
215
|
+
CREATE INDEX IF NOT EXISTS project_edit_segments_project_file_updated
|
|
216
|
+
ON project_edit_segments(project_id, file_path, updated_at DESC);
|
|
217
|
+
|
|
218
|
+
-- Records the newest missing delta per file after retention drops an edit
|
|
219
|
+
-- segment. Selection history uses it to stop at a known boundary instead
|
|
220
|
+
-- of presenting an incomplete attribution as a full edit trail.
|
|
221
|
+
CREATE TABLE IF NOT EXISTS project_edit_history_boundaries (
|
|
222
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
223
|
+
file_path TEXT NOT NULL,
|
|
224
|
+
after_hash TEXT NOT NULL,
|
|
225
|
+
updated_at TEXT NOT NULL,
|
|
226
|
+
PRIMARY KEY (project_id, file_path)
|
|
227
|
+
);
|
|
228
|
+
|
|
177
229
|
CREATE TABLE IF NOT EXISTS project_dictionary_words (
|
|
178
230
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
179
231
|
word TEXT NOT NULL COLLATE NOCASE,
|
|
@@ -228,6 +280,13 @@ function migrate(db) {
|
|
|
228
280
|
updated_at TEXT NOT NULL
|
|
229
281
|
);
|
|
230
282
|
`);
|
|
283
|
+
const editColumns = db.prepare("PRAGMA table_info(project_edit_segments)").all();
|
|
284
|
+
if (!editColumns.some((column) => column.name === "steps_bytes")) {
|
|
285
|
+
db.transaction(() => {
|
|
286
|
+
db.exec("ALTER TABLE project_edit_segments ADD COLUMN steps_bytes INTEGER NOT NULL DEFAULT 0");
|
|
287
|
+
db.exec("UPDATE project_edit_segments SET steps_bytes = LENGTH(CAST(steps_json AS BLOB))");
|
|
288
|
+
})();
|
|
289
|
+
}
|
|
231
290
|
const projectColumns = db.prepare("PRAGMA table_info(projects)").all();
|
|
232
291
|
if (!projectColumns.some((column) => column.name === "latexmkrc")) {
|
|
233
292
|
db.exec("ALTER TABLE projects ADD COLUMN latexmkrc TEXT");
|