@aloth/olcli 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/cli.js +107 -5
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +103 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +317 -47
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +45 -5
- package/dist/mcp.js.map +1 -1
- package/dist/tracked-changes.d.ts +136 -0
- package/dist/tracked-changes.d.ts.map +1 -0
- package/dist/tracked-changes.js +250 -0
- package/dist/tracked-changes.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracked changes: the insertions and deletions Overleaf records while track
|
|
3
|
+
* changes is on, shown as suggestions in the review panel.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions only, for the same reason as `diff.ts` and `rename-plan.ts`:
|
|
6
|
+
* everything decided here is decided from the data a doc join returns, so it
|
|
7
|
+
* is unit-testable without an Overleaf account. The network side lives in
|
|
8
|
+
* `client.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Two document formats exist, and they store tracked changes differently:
|
|
11
|
+
*
|
|
12
|
+
* - `sharejs-text-ot` docs carry `ranges.changes`, each
|
|
13
|
+
* `{ id, op: { i | d, p }, metadata: { user_id, ts } }`. `p` is an offset
|
|
14
|
+
* into the visible text. Tracked-deleted text is no longer in the doc at
|
|
15
|
+
* all; it survives only in `op.d`.
|
|
16
|
+
* - `history-ot` docs carry a StringFileData snapshot whose `content` still
|
|
17
|
+
* contains the tracked-deleted text, with `trackedChanges` given as
|
|
18
|
+
* `{ range: { pos, length }, tracking: { type, userId, ts } }` over that
|
|
19
|
+
* content. The server assigns these no ids. The Overleaf editor derives
|
|
20
|
+
* `change-<type>-<visible position>` and this module does the same, so the
|
|
21
|
+
* ids are only stable until the doc is next edited.
|
|
22
|
+
*
|
|
23
|
+
* Positions reported by this module are always offsets into the visible text,
|
|
24
|
+
* which is what the editor shows and what `olcli pull` downloads, so a
|
|
25
|
+
* position means the same thing for both formats.
|
|
26
|
+
*/
|
|
27
|
+
export type TrackedChangeKind = 'insert' | 'delete';
|
|
28
|
+
/** One tracked change as stored in a single doc, before any project context is added. */
|
|
29
|
+
export interface DocTrackedChange {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: TrackedChangeKind;
|
|
32
|
+
/** Offset into the visible text, tracked deletes excluded. */
|
|
33
|
+
position: number;
|
|
34
|
+
/** Inserted text for an insert, removed text for a delete. */
|
|
35
|
+
text: string;
|
|
36
|
+
/** Null when Overleaf recorded no user, e.g. edits written by a sync integration. */
|
|
37
|
+
userId: string | null;
|
|
38
|
+
timestamp?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface TrackedChangeAuthor {
|
|
41
|
+
id: string | null;
|
|
42
|
+
name?: string;
|
|
43
|
+
email?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ReviewUser {
|
|
46
|
+
id: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
first_name?: string;
|
|
49
|
+
last_name?: string;
|
|
50
|
+
}
|
|
51
|
+
/** A ShareJS text op component, the format `applyOtUpdate` takes for sharejs-text-ot docs. */
|
|
52
|
+
export interface ShareJsTextOp {
|
|
53
|
+
p: number;
|
|
54
|
+
i?: string;
|
|
55
|
+
d?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Marks the op as an undo. The ranges tracker uses it to recognize an
|
|
58
|
+
* insertion that restores a tracked deletion and drop that deletion,
|
|
59
|
+
* rather than shift it along as if new text had been typed in front of it.
|
|
60
|
+
* The Overleaf editor sets it on every op it sends to reject a change.
|
|
61
|
+
*/
|
|
62
|
+
u?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Read `ranges.changes` of a sharejs-text-ot doc. The texts must already be
|
|
66
|
+
* decoded: with `encodeRanges` the join sends `op.i` and `op.d` as UTF-8
|
|
67
|
+
* bytes in a binary string, exactly like the doc lines.
|
|
68
|
+
*
|
|
69
|
+
* Order is preserved. It is the ranges tracker's order, which `planRejection`
|
|
70
|
+
* relies on to order changes that share a position.
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseShareJsChanges(ranges: any): DocTrackedChange[];
|
|
73
|
+
/**
|
|
74
|
+
* Read the tracked changes of a history-ot snapshot and compute the visible
|
|
75
|
+
* text. Mirrors `buildRangesFromSnapshot` in the Overleaf web frontend,
|
|
76
|
+
* including how it derives change ids.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseHistoryOtChanges(snapshot: any): {
|
|
79
|
+
changes: DocTrackedChange[];
|
|
80
|
+
visibleContent: string;
|
|
81
|
+
};
|
|
82
|
+
export declare class RejectConflictError extends Error {
|
|
83
|
+
constructor(message: string);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the ops that reject `changes` in a sharejs-text-ot doc whose visible
|
|
87
|
+
* text is `content`: a tracked insertion is removed, a tracked deletion is
|
|
88
|
+
* put back. Returns the ops in the order they must be sent and the text they
|
|
89
|
+
* produce.
|
|
90
|
+
*
|
|
91
|
+
* `changes` must be in the ranges tracker's order (a filter of
|
|
92
|
+
* `parseShareJsChanges` output keeps it).
|
|
93
|
+
*
|
|
94
|
+
* The ops are applied one after another, each to the result of the previous
|
|
95
|
+
* one, so they run from the highest position to the lowest: undoing a change
|
|
96
|
+
* then never moves a change that has yet to be undone. That is the ordering
|
|
97
|
+
* the Overleaf editor uses, and its own comment explains the failure it
|
|
98
|
+
* prevents. A word replaced as "foo bar baz" -> "foo quux baz" is a tracked
|
|
99
|
+
* insertion of "quux" at 4 and a tracked deletion of "bar" at 8. Removing
|
|
100
|
+
* "quux" first and then restoring "bar" at 8 gives "foo bazbar"; restoring
|
|
101
|
+
* "bar" first gives "foo quuxbar baz", and removing "quux" then gives
|
|
102
|
+
* "foo bar baz".
|
|
103
|
+
*
|
|
104
|
+
* Changes at the same position run in reverse tracker order. The tracker
|
|
105
|
+
* keeps a deletion ahead of an insertion at the same offset, so the
|
|
106
|
+
* insertion is removed before the deleted text is put back in front of
|
|
107
|
+
* where it was, and several deletions at one offset are restored last one
|
|
108
|
+
* first, which leaves them in their original order.
|
|
109
|
+
*
|
|
110
|
+
* Every step is checked against the text it applies to, exactly as the
|
|
111
|
+
* server checks a delete op. A change that no longer matches throws
|
|
112
|
+
* `RejectConflictError` and nothing is returned, so a stale or overlapping
|
|
113
|
+
* selection fails here instead of on the server or, worse, removing the
|
|
114
|
+
* wrong text.
|
|
115
|
+
*/
|
|
116
|
+
export declare function planRejection(content: string, changes: DocTrackedChange[]): {
|
|
117
|
+
ops: ShareJsTextOp[];
|
|
118
|
+
content: string;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Apply ShareJS text ops the way the server's text type does, one component
|
|
122
|
+
* after another, refusing a delete whose text does not match.
|
|
123
|
+
*/
|
|
124
|
+
export declare function applyTextOps(content: string, ops: ShareJsTextOp[]): string;
|
|
125
|
+
/**
|
|
126
|
+
* Map user ids to people. Project owner and members come from the project
|
|
127
|
+
* itself; `changesUsers` (GET /project/<id>/changes/users) adds people who
|
|
128
|
+
* made changes but are no longer members. Members win on conflict, the same
|
|
129
|
+
* precedence the Overleaf editor uses.
|
|
130
|
+
*/
|
|
131
|
+
export declare function buildUserDirectory(project: any, changesUsers?: unknown[]): Map<string, ReviewUser>;
|
|
132
|
+
export declare function describeAuthor(userId: string | null, users: Map<string, ReviewUser>): TrackedChangeAuthor;
|
|
133
|
+
export declare function formatAuthor(author: TrackedChangeAuthor): string;
|
|
134
|
+
/** Unified diff of a doc before and after a rejection, for the preview. */
|
|
135
|
+
export declare function renderRejectionDiff(path: string, before: string, after: string): string;
|
|
136
|
+
//# sourceMappingURL=tracked-changes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracked-changes.d.ts","sourceRoot":"","sources":["../src/tracked-changes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEpD,yFAAyF;AACzF,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,iBAAiB,CAAC;IACxB,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,qFAAqF;IACrF,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,8FAA8F;AAC9F,MAAM,WAAW,aAAa;IAC5B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,CAAC,CAAC,EAAE,MAAM,CAAC;IACX;;;;;OAKG;IACH,CAAC,CAAC,EAAE,OAAO,CAAC;CACb;AAWD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,GAAG,GAAG,gBAAgB,EAAE,CAcnE;AAaD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,GAAG,GAAG;IAAE,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAyC5G;AAED,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,EAAE,GAAG;IAAE,GAAG,EAAE,aAAa,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CA4BrH;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,MAAM,CAgB1E;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,GAAG,EAAE,YAAY,GAAE,OAAO,EAAO,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAetG;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,mBAAmB,CAUzG;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAGhE;AAED,2EAA2E;AAC3E,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvF"}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracked changes: the insertions and deletions Overleaf records while track
|
|
3
|
+
* changes is on, shown as suggestions in the review panel.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions only, for the same reason as `diff.ts` and `rename-plan.ts`:
|
|
6
|
+
* everything decided here is decided from the data a doc join returns, so it
|
|
7
|
+
* is unit-testable without an Overleaf account. The network side lives in
|
|
8
|
+
* `client.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Two document formats exist, and they store tracked changes differently:
|
|
11
|
+
*
|
|
12
|
+
* - `sharejs-text-ot` docs carry `ranges.changes`, each
|
|
13
|
+
* `{ id, op: { i | d, p }, metadata: { user_id, ts } }`. `p` is an offset
|
|
14
|
+
* into the visible text. Tracked-deleted text is no longer in the doc at
|
|
15
|
+
* all; it survives only in `op.d`.
|
|
16
|
+
* - `history-ot` docs carry a StringFileData snapshot whose `content` still
|
|
17
|
+
* contains the tracked-deleted text, with `trackedChanges` given as
|
|
18
|
+
* `{ range: { pos, length }, tracking: { type, userId, ts } }` over that
|
|
19
|
+
* content. The server assigns these no ids. The Overleaf editor derives
|
|
20
|
+
* `change-<type>-<visible position>` and this module does the same, so the
|
|
21
|
+
* ids are only stable until the doc is next edited.
|
|
22
|
+
*
|
|
23
|
+
* Positions reported by this module are always offsets into the visible text,
|
|
24
|
+
* which is what the editor shows and what `olcli pull` downloads, so a
|
|
25
|
+
* position means the same thing for both formats.
|
|
26
|
+
*/
|
|
27
|
+
import { createTwoFilesPatch } from 'diff';
|
|
28
|
+
function normalizeTimestamp(value) {
|
|
29
|
+
if (typeof value === 'string')
|
|
30
|
+
return value;
|
|
31
|
+
if (typeof value === 'number' || value instanceof Date) {
|
|
32
|
+
const date = new Date(value);
|
|
33
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read `ranges.changes` of a sharejs-text-ot doc. The texts must already be
|
|
39
|
+
* decoded: with `encodeRanges` the join sends `op.i` and `op.d` as UTF-8
|
|
40
|
+
* bytes in a binary string, exactly like the doc lines.
|
|
41
|
+
*
|
|
42
|
+
* Order is preserved. It is the ranges tracker's order, which `planRejection`
|
|
43
|
+
* relies on to order changes that share a position.
|
|
44
|
+
*/
|
|
45
|
+
export function parseShareJsChanges(ranges) {
|
|
46
|
+
const changes = [];
|
|
47
|
+
for (const change of ranges?.changes ?? []) {
|
|
48
|
+
const op = change?.op;
|
|
49
|
+
if (typeof change?.id !== 'string' || !op || typeof op.p !== 'number')
|
|
50
|
+
continue;
|
|
51
|
+
const userId = typeof change.metadata?.user_id === 'string' ? change.metadata.user_id : null;
|
|
52
|
+
const timestamp = normalizeTimestamp(change.metadata?.ts);
|
|
53
|
+
if (typeof op.i === 'string') {
|
|
54
|
+
changes.push({ id: change.id, kind: 'insert', position: op.p, text: op.i, userId, timestamp });
|
|
55
|
+
}
|
|
56
|
+
else if (typeof op.d === 'string') {
|
|
57
|
+
changes.push({ id: change.id, kind: 'delete', position: op.p, text: op.d, userId, timestamp });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return changes;
|
|
61
|
+
}
|
|
62
|
+
function isHistoryOtTrackedChange(value) {
|
|
63
|
+
return typeof value?.range?.pos === 'number'
|
|
64
|
+
&& typeof value?.range?.length === 'number'
|
|
65
|
+
&& (value?.tracking?.type === 'insert' || value?.tracking?.type === 'delete');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read the tracked changes of a history-ot snapshot and compute the visible
|
|
69
|
+
* text. Mirrors `buildRangesFromSnapshot` in the Overleaf web frontend,
|
|
70
|
+
* including how it derives change ids.
|
|
71
|
+
*/
|
|
72
|
+
export function parseHistoryOtChanges(snapshot) {
|
|
73
|
+
const content = typeof snapshot?.content === 'string' ? snapshot.content : '';
|
|
74
|
+
const tracked = (snapshot?.trackedChanges ?? [])
|
|
75
|
+
.filter(isHistoryOtTrackedChange)
|
|
76
|
+
.sort((a, b) => a.range.pos - b.range.pos);
|
|
77
|
+
const deletes = tracked.filter(change => change.tracking.type === 'delete');
|
|
78
|
+
// A snapshot position inside or after a tracked delete moves back by the
|
|
79
|
+
// part of that delete in front of it; inside one it lands on its start.
|
|
80
|
+
const toVisible = (pos) => {
|
|
81
|
+
let shift = 0;
|
|
82
|
+
for (const change of deletes) {
|
|
83
|
+
if (change.range.pos >= pos)
|
|
84
|
+
break;
|
|
85
|
+
shift += Math.min(change.range.pos + change.range.length, pos) - change.range.pos;
|
|
86
|
+
}
|
|
87
|
+
return pos - shift;
|
|
88
|
+
};
|
|
89
|
+
let visibleContent = '';
|
|
90
|
+
let cursor = 0;
|
|
91
|
+
for (const change of deletes) {
|
|
92
|
+
if (change.range.pos < cursor)
|
|
93
|
+
continue;
|
|
94
|
+
visibleContent += content.slice(cursor, change.range.pos);
|
|
95
|
+
cursor = change.range.pos + change.range.length;
|
|
96
|
+
}
|
|
97
|
+
visibleContent += content.slice(cursor);
|
|
98
|
+
const changes = tracked.map(change => {
|
|
99
|
+
const position = toVisible(change.range.pos);
|
|
100
|
+
const kind = change.tracking.type;
|
|
101
|
+
return {
|
|
102
|
+
id: `change-${kind}-${position}`,
|
|
103
|
+
kind,
|
|
104
|
+
position,
|
|
105
|
+
text: content.slice(change.range.pos, change.range.pos + change.range.length),
|
|
106
|
+
userId: typeof change.tracking.userId === 'string' ? change.tracking.userId : null,
|
|
107
|
+
timestamp: normalizeTimestamp(change.tracking.ts)
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
return { changes, visibleContent };
|
|
111
|
+
}
|
|
112
|
+
export class RejectConflictError extends Error {
|
|
113
|
+
constructor(message) {
|
|
114
|
+
super(message);
|
|
115
|
+
this.name = 'RejectConflictError';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function quote(text, max = 60) {
|
|
119
|
+
const shown = text.length > max ? `${text.slice(0, max)}...` : text;
|
|
120
|
+
return JSON.stringify(shown);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build the ops that reject `changes` in a sharejs-text-ot doc whose visible
|
|
124
|
+
* text is `content`: a tracked insertion is removed, a tracked deletion is
|
|
125
|
+
* put back. Returns the ops in the order they must be sent and the text they
|
|
126
|
+
* produce.
|
|
127
|
+
*
|
|
128
|
+
* `changes` must be in the ranges tracker's order (a filter of
|
|
129
|
+
* `parseShareJsChanges` output keeps it).
|
|
130
|
+
*
|
|
131
|
+
* The ops are applied one after another, each to the result of the previous
|
|
132
|
+
* one, so they run from the highest position to the lowest: undoing a change
|
|
133
|
+
* then never moves a change that has yet to be undone. That is the ordering
|
|
134
|
+
* the Overleaf editor uses, and its own comment explains the failure it
|
|
135
|
+
* prevents. A word replaced as "foo bar baz" -> "foo quux baz" is a tracked
|
|
136
|
+
* insertion of "quux" at 4 and a tracked deletion of "bar" at 8. Removing
|
|
137
|
+
* "quux" first and then restoring "bar" at 8 gives "foo bazbar"; restoring
|
|
138
|
+
* "bar" first gives "foo quuxbar baz", and removing "quux" then gives
|
|
139
|
+
* "foo bar baz".
|
|
140
|
+
*
|
|
141
|
+
* Changes at the same position run in reverse tracker order. The tracker
|
|
142
|
+
* keeps a deletion ahead of an insertion at the same offset, so the
|
|
143
|
+
* insertion is removed before the deleted text is put back in front of
|
|
144
|
+
* where it was, and several deletions at one offset are restored last one
|
|
145
|
+
* first, which leaves them in their original order.
|
|
146
|
+
*
|
|
147
|
+
* Every step is checked against the text it applies to, exactly as the
|
|
148
|
+
* server checks a delete op. A change that no longer matches throws
|
|
149
|
+
* `RejectConflictError` and nothing is returned, so a stale or overlapping
|
|
150
|
+
* selection fails here instead of on the server or, worse, removing the
|
|
151
|
+
* wrong text.
|
|
152
|
+
*/
|
|
153
|
+
export function planRejection(content, changes) {
|
|
154
|
+
const ordered = changes
|
|
155
|
+
.map((change, index) => ({ change, index }))
|
|
156
|
+
.sort((a, b) => b.change.position - a.change.position || b.index - a.index);
|
|
157
|
+
let text = content;
|
|
158
|
+
const ops = [];
|
|
159
|
+
for (const { change } of ordered) {
|
|
160
|
+
const { position } = change;
|
|
161
|
+
if (position < 0 || position > text.length) {
|
|
162
|
+
throw new RejectConflictError(`Tracked change ${change.id} points at position ${position}, outside the document (length ${text.length})`);
|
|
163
|
+
}
|
|
164
|
+
if (change.kind === 'insert') {
|
|
165
|
+
const found = text.slice(position, position + change.text.length);
|
|
166
|
+
if (found !== change.text) {
|
|
167
|
+
throw new RejectConflictError(`Tracked change ${change.id} no longer matches the document: expected ${quote(change.text)} at position ${position}, found ${quote(found)}. If it overlaps another selected change, reject them one at a time`);
|
|
168
|
+
}
|
|
169
|
+
ops.push({ p: position, d: change.text, u: true });
|
|
170
|
+
text = text.slice(0, position) + text.slice(position + change.text.length);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
ops.push({ p: position, i: change.text, u: true });
|
|
174
|
+
text = text.slice(0, position) + change.text + text.slice(position);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { ops, content: text };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Apply ShareJS text ops the way the server's text type does, one component
|
|
181
|
+
* after another, refusing a delete whose text does not match.
|
|
182
|
+
*/
|
|
183
|
+
export function applyTextOps(content, ops) {
|
|
184
|
+
let text = content;
|
|
185
|
+
for (const op of ops) {
|
|
186
|
+
if (typeof op.i === 'string') {
|
|
187
|
+
text = text.slice(0, op.p) + op.i + text.slice(op.p);
|
|
188
|
+
}
|
|
189
|
+
else if (typeof op.d === 'string') {
|
|
190
|
+
const deleted = text.slice(op.p, op.p + op.d.length);
|
|
191
|
+
if (deleted !== op.d) {
|
|
192
|
+
throw new Error(`Delete component '${op.d}' does not match deleted text '${deleted}'`);
|
|
193
|
+
}
|
|
194
|
+
text = text.slice(0, op.p) + text.slice(op.p + op.d.length);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
throw new Error('Unknown op type');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return text;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Map user ids to people. Project owner and members come from the project
|
|
204
|
+
* itself; `changesUsers` (GET /project/<id>/changes/users) adds people who
|
|
205
|
+
* made changes but are no longer members. Members win on conflict, the same
|
|
206
|
+
* precedence the Overleaf editor uses.
|
|
207
|
+
*/
|
|
208
|
+
export function buildUserDirectory(project, changesUsers = []) {
|
|
209
|
+
const users = new Map();
|
|
210
|
+
const add = (user, id) => {
|
|
211
|
+
if (typeof id !== 'string' || !id)
|
|
212
|
+
return;
|
|
213
|
+
users.set(id, {
|
|
214
|
+
id,
|
|
215
|
+
email: typeof user?.email === 'string' ? user.email : undefined,
|
|
216
|
+
first_name: typeof user?.first_name === 'string' ? user.first_name : undefined,
|
|
217
|
+
last_name: typeof user?.last_name === 'string' ? user.last_name : undefined
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
for (const user of changesUsers)
|
|
221
|
+
add(user, user?.id ?? user?._id);
|
|
222
|
+
if (project?.owner)
|
|
223
|
+
add(project.owner, project.owner._id ?? project.owner.id);
|
|
224
|
+
for (const member of project?.members ?? [])
|
|
225
|
+
add(member, member?._id ?? member?.id);
|
|
226
|
+
return users;
|
|
227
|
+
}
|
|
228
|
+
export function describeAuthor(userId, users) {
|
|
229
|
+
if (!userId)
|
|
230
|
+
return { id: null };
|
|
231
|
+
const user = users.get(userId);
|
|
232
|
+
if (!user)
|
|
233
|
+
return { id: userId };
|
|
234
|
+
const name = [user.first_name, user.last_name].filter(Boolean).join(' ').trim();
|
|
235
|
+
return {
|
|
236
|
+
id: userId,
|
|
237
|
+
...(name ? { name } : {}),
|
|
238
|
+
...(user.email ? { email: user.email } : {})
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
export function formatAuthor(author) {
|
|
242
|
+
if (author.name && author.email)
|
|
243
|
+
return `${author.name} <${author.email}>`;
|
|
244
|
+
return author.name || author.email || author.id || 'unknown user';
|
|
245
|
+
}
|
|
246
|
+
/** Unified diff of a doc before and after a rejection, for the preview. */
|
|
247
|
+
export function renderRejectionDiff(path, before, after) {
|
|
248
|
+
return createTwoFilesPatch(`a/${path}`, `b/${path}`, before, after, undefined, undefined, { context: 2 });
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=tracked-changes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracked-changes.js","sourceRoot":"","sources":["../src/tracked-changes.ts"],"names":[],"mappings":"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"}
|