agentic-comments 0.6.1 → 0.8.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 +78 -2
- package/SKILL.md +5 -0
- package/dist/account.d.ts +62 -0
- package/dist/account.js +114 -0
- package/dist/{chunk-VAVKBAHM.js → chunk-4G5BDTBA.js} +574 -574
- package/dist/{chunk-H6JK62SP.js → chunk-6UQ6CDLJ.js} +1 -1
- package/dist/{chunk-UNJL3N2L.js → chunk-J6JGWDUP.js} +3 -3
- package/dist/{chunk-6N53KQVU.js → chunk-LFYTCHXS.js} +1 -1
- package/dist/{chunk-T77DPNIV.js → chunk-TTNT27PG.js} +1 -1
- package/dist/chunk-UEQL27KF.js +148 -0
- package/dist/cli.js +7 -7
- package/dist/client.d.ts +3 -3
- package/dist/client.js +4 -143
- package/dist/{errors-C-xvp24-.d.ts → errors-Cin-719h.d.ts} +3 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/local.d.ts +2 -2
- package/dist/local.js +3 -3
- package/dist/mcp-IFQQHOA2.js +11 -0
- package/dist/mcp-bin.js +9 -9
- package/dist/{service-BvIE6t5n.d.ts → service-ZdZPWrwI.d.ts} +1 -1
- package/dist/{suggestions-CnkEcTuY.d.ts → suggestions-G1seGf5d.d.ts} +5 -2
- package/dist/ui.css +54 -0
- package/dist/ui.d.ts +46 -0
- package/dist/ui.js +332 -0
- package/dist/worker.d.ts +26 -2
- package/dist/worker.js +91 -3
- package/llms.txt +2 -0
- package/package.json +39 -7
- package/dist/mcp-WBAHKMF4.js +0 -11
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
// src/errors.ts
|
|
2
2
|
var CommentsError = class extends Error {
|
|
3
|
-
constructor(code, message) {
|
|
4
|
-
super(message);
|
|
3
|
+
constructor(code, message, options) {
|
|
4
|
+
super(message, options);
|
|
5
5
|
this.code = code;
|
|
6
6
|
this.name = "CommentsError";
|
|
7
7
|
}
|
|
8
8
|
code;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
// src/anchor.ts
|
|
12
|
-
import * as Y from "yjs";
|
|
13
|
-
|
|
14
11
|
// src/text.ts
|
|
15
12
|
var HIDDEN = /[\p{Cc}\p{Cf}]/u;
|
|
16
13
|
var ID = /^[\w-]{1,64}$/;
|
|
@@ -21,6 +18,7 @@ var shown = (value) => JSON.stringify(safeId(value));
|
|
|
21
18
|
var safeText = (value) => String(value).replace(/[^\p{L}\p{N}\p{P}\p{S} ]/gu, "?").slice(0, 200);
|
|
22
19
|
|
|
23
20
|
// src/anchor.ts
|
|
21
|
+
import * as Y from "yjs";
|
|
24
22
|
var MAX_QUOTE = 2e3;
|
|
25
23
|
var MAX_ANCHOR = 4096;
|
|
26
24
|
var MAX_KEY = 256;
|
|
@@ -180,521 +178,179 @@ var strictCopy = (value) => JSON.parse(
|
|
|
180
178
|
})
|
|
181
179
|
);
|
|
182
180
|
|
|
183
|
-
// src/
|
|
181
|
+
// src/threads.ts
|
|
184
182
|
import * as Y2 from "yjs";
|
|
185
|
-
var
|
|
186
|
-
var
|
|
187
|
-
var
|
|
188
|
-
var
|
|
189
|
-
var
|
|
190
|
-
var
|
|
191
|
-
var
|
|
192
|
-
var
|
|
193
|
-
var
|
|
194
|
-
var
|
|
195
|
-
var
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
|
|
200
|
-
invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
|
|
201
|
-
}
|
|
202
|
-
void [this.map, this.applied, this.rejected];
|
|
203
|
-
this.onTransaction = (tr) => {
|
|
204
|
-
if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
|
|
205
|
-
};
|
|
206
|
-
this.ydoc.on("afterTransaction", this.onTransaction);
|
|
207
|
-
this.settle();
|
|
183
|
+
var MODERATE_SCOPE = "comments:moderate";
|
|
184
|
+
var ERASED_BODY = "[erased]";
|
|
185
|
+
var THREADS = "comments.threads";
|
|
186
|
+
var MAX_BODY = 1e4;
|
|
187
|
+
var MAX_MENTIONS = 50;
|
|
188
|
+
var MAX_MESSAGES = 1e3;
|
|
189
|
+
var READ_LIMIT = 2 * MAX_MESSAGES;
|
|
190
|
+
var STATUSES = ["open", "resolved"];
|
|
191
|
+
var ANCHOR_STATES = ["attached", "edited", "orphaned", "unknown"];
|
|
192
|
+
var HANDLE = /^[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}_.-]{0,62}[\p{L}\p{N}\p{M}])?$/u;
|
|
193
|
+
var MENTION = /(?<![\p{L}\p{N}_@.])@([\p{L}\p{N}][\p{L}\p{N}\p{M}_.-]*)(?![\p{L}\p{N}\p{M}_.-]|\/)/gu;
|
|
194
|
+
function body(value) {
|
|
195
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > MAX_BODY || BODY_FORBIDDEN.test(value)) {
|
|
196
|
+
invalid(`a body must be text of 1 to ${MAX_BODY} characters, with no control, invisible, or direction characters`);
|
|
208
197
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
var handle = (value) => value.normalize("NFC").replace(/^@+/, "").toLowerCase().normalize("NFC");
|
|
201
|
+
var okHandle = (value) => HANDLE.test(value) && !VARIATION.test(value);
|
|
202
|
+
var validHandle = (value) => typeof value === "string" && okHandle(value) && handle(value) === value;
|
|
203
|
+
function mentionsOf(text, explicit) {
|
|
204
|
+
const prose = text.replace(/`[^`]*`/g, " ");
|
|
205
|
+
const found = [...prose.matchAll(MENTION)].map((m) => {
|
|
206
|
+
const name = m[1].replace(/[._-]+$/, "");
|
|
207
|
+
if (!okHandle(handle(name))) invalid(`"@${name.slice(0, 20)}" is not a valid handle: use up to 64 characters`);
|
|
208
|
+
return handle(name);
|
|
209
|
+
});
|
|
210
|
+
let listed = [];
|
|
211
|
+
if (explicit !== void 0) {
|
|
212
|
+
const snapshot = Array.isArray(explicit) ? Array.from(explicit) : [];
|
|
213
|
+
const ok = Array.isArray(explicit) && snapshot.length <= MAX_MENTIONS && snapshot.every((m) => typeof m === "string" && okHandle(handle(m)));
|
|
214
|
+
if (!ok) invalid(`mentions must be a list of up to ${MAX_MENTIONS} handles of letters, digits, and . _ -`);
|
|
215
|
+
listed = snapshot.map(handle);
|
|
216
216
|
}
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
const all = [.../* @__PURE__ */ new Set([...found, ...listed])];
|
|
218
|
+
if (all.length > MAX_MENTIONS) invalid(`a message can mention up to ${MAX_MENTIONS} handles`);
|
|
219
|
+
return all;
|
|
220
|
+
}
|
|
221
|
+
var Comments = class {
|
|
222
|
+
/** Every replica of a document must register the same custom anchor kinds. Others read those threads as malformed. */
|
|
223
|
+
constructor(ydoc, options = {}) {
|
|
224
|
+
this.ydoc = ydoc;
|
|
225
|
+
if (typeof options !== "object" || options === null || options.now !== void 0 && typeof options.now !== "function" || options.anchors !== void 0 && !Array.isArray(options.anchors) || options.exists !== void 0 && typeof options.exists !== "function") {
|
|
226
|
+
invalid("options must be an object with an optional now function, anchors list, and exists function");
|
|
227
|
+
}
|
|
228
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
229
|
+
this.exists = options.exists;
|
|
230
|
+
for (const kind of [...BUILT_IN_KINDS, ...options.anchors ?? []]) {
|
|
231
|
+
if (typeof kind?.type !== "string" || kind.type === "" || typeof kind.parse !== "function" || this.kinds.has(kind.type)) {
|
|
232
|
+
throw new CommentsError("INVALID_ANCHOR", `the anchor type "${String(kind?.type)}" is empty, has no parse, or is already defined`);
|
|
233
|
+
}
|
|
234
|
+
this.kinds.set(kind.type, kind);
|
|
235
|
+
}
|
|
219
236
|
}
|
|
237
|
+
ydoc;
|
|
238
|
+
kinds = /* @__PURE__ */ new Map();
|
|
239
|
+
now;
|
|
240
|
+
exists;
|
|
220
241
|
get map() {
|
|
221
|
-
return this.ydoc.getMap(
|
|
242
|
+
return this.ydoc.getMap(THREADS);
|
|
222
243
|
}
|
|
223
|
-
/** The
|
|
224
|
-
get
|
|
225
|
-
return
|
|
244
|
+
/** The Yjs map that holds the threads. `Notifications` watches it. */
|
|
245
|
+
get threadsMap() {
|
|
246
|
+
return this.map;
|
|
226
247
|
}
|
|
227
|
-
|
|
228
|
-
|
|
248
|
+
/** The clock, as an ISO time. Used by `Suggestions`. */
|
|
249
|
+
time() {
|
|
250
|
+
const at = this.now();
|
|
251
|
+
const text = at instanceof Date && !Number.isNaN(at.getTime()) ? at.toISOString() : "";
|
|
252
|
+
if (!ISO_TIME.test(text)) invalid("the clock must return a date between the years 0000 and 9999");
|
|
253
|
+
return text;
|
|
229
254
|
}
|
|
230
|
-
|
|
231
|
-
|
|
255
|
+
/** Whether the content under an anchor is still there. This never throws. */
|
|
256
|
+
anchorState(anchor) {
|
|
257
|
+
if (anchor.type === "text") return textState(anchor, this.ydoc);
|
|
258
|
+
if (!this.exists) return { state: "unknown" };
|
|
259
|
+
try {
|
|
260
|
+
const answer = this.exists(anchor);
|
|
261
|
+
return answer === true ? { state: "attached" } : answer === false ? { state: "orphaned" } : { state: "unknown" };
|
|
262
|
+
} catch {
|
|
263
|
+
return { state: "unknown" };
|
|
264
|
+
}
|
|
232
265
|
}
|
|
233
|
-
|
|
234
|
-
|
|
266
|
+
/** Read an anchor from stored data. This does not check the document. */
|
|
267
|
+
parseAnchor(raw) {
|
|
268
|
+
const type = raw?.type;
|
|
269
|
+
const kind = typeof raw === "object" && raw !== null && !Array.isArray(raw) && typeof type === "string" ? this.kinds.get(type) : void 0;
|
|
270
|
+
if (!kind) throw new CommentsError("INVALID_ANCHOR", `an anchor needs a type: ${[...this.kinds.keys()].join(", ")}`);
|
|
235
271
|
try {
|
|
236
|
-
|
|
237
|
-
if (
|
|
272
|
+
const parsed = copy(kind.parse(strictCopy(raw)));
|
|
273
|
+
if (JSON.stringify(parsed).length > MAX_ANCHOR) throw new Error(`an anchor holds up to ${MAX_ANCHOR} characters of JSON`);
|
|
274
|
+
if (parsed?.type !== kind.type) throw new Error(`the "${kind.type}" kind must return an anchor of its own type`);
|
|
275
|
+
return parsed;
|
|
238
276
|
} catch (error) {
|
|
239
|
-
|
|
277
|
+
if (error instanceof CommentsError) throw error;
|
|
278
|
+
throw new CommentsError("INVALID_ANCHOR", error instanceof Error ? error.message : String(error));
|
|
240
279
|
}
|
|
241
|
-
if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
|
|
242
|
-
return clean;
|
|
243
280
|
}
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
281
|
+
/** Parse an anchor from input and check it against this document. Throws `INVALID_ANCHOR` when it fails. */
|
|
282
|
+
checkAnchor(raw) {
|
|
283
|
+
const anchor = this.parseAnchor(raw);
|
|
284
|
+
if (JSON.stringify(this.parseAnchor(anchor)) !== JSON.stringify(anchor)) {
|
|
285
|
+
throw new CommentsError("INVALID_ANCHOR", "an anchor kind must parse its own output to the same anchor");
|
|
286
|
+
}
|
|
287
|
+
this.checkInDocument(anchor);
|
|
288
|
+
return anchor;
|
|
289
|
+
}
|
|
290
|
+
/** A text anchor must name text in this document, in order, and match its quote. */
|
|
291
|
+
checkInDocument(anchor) {
|
|
292
|
+
if (anchor.type !== "text") return;
|
|
293
|
+
const at = (json) => {
|
|
256
294
|
try {
|
|
257
|
-
|
|
258
|
-
if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
|
|
295
|
+
return Y2.createAbsolutePositionFromRelativePosition(Y2.createRelativePositionFromJSON(json), this.ydoc);
|
|
259
296
|
} catch {
|
|
260
|
-
|
|
297
|
+
return null;
|
|
261
298
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const groups = /* @__PURE__ */ new Map();
|
|
269
|
-
for (const entry of map.entries()) {
|
|
270
|
-
const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
|
|
271
|
-
const group = groups.get(id2);
|
|
272
|
-
if (group) group.push(entry);
|
|
273
|
-
else groups.set(id2, [entry]);
|
|
299
|
+
};
|
|
300
|
+
const from = at(anchor.start);
|
|
301
|
+
const to = at(anchor.end);
|
|
302
|
+
const unit = from?.type;
|
|
303
|
+
if (!(unit instanceof Y2.Text) || !from || !to || to.type !== unit || to.index <= from.index || textOf(unit).slice(from.index, to.index) !== anchor.text) {
|
|
304
|
+
throw new CommentsError("INVALID_ANCHOR", "a text anchor must name text in this document, in order, matching its quoted text");
|
|
274
305
|
}
|
|
275
|
-
return groups;
|
|
276
306
|
}
|
|
277
|
-
|
|
307
|
+
/** Build a message, and refuse it unless a read would show it back. Nothing is written yet. */
|
|
308
|
+
message(text, explicit, actor) {
|
|
309
|
+
const message = {
|
|
310
|
+
id: globalThis.crypto.randomUUID(),
|
|
311
|
+
at: this.time(),
|
|
312
|
+
author: { kind: actor.kind, name: actor.name },
|
|
313
|
+
body: text,
|
|
314
|
+
mentions: mentionsOf(text, explicit)
|
|
315
|
+
};
|
|
278
316
|
try {
|
|
279
|
-
|
|
280
|
-
if (!(fields instanceof Y2.Map)) throw new Error("the suggestion is not a map");
|
|
281
|
-
const author = readAuthor(fields.get("author"));
|
|
282
|
-
const createdAt = fields.get("createdAt");
|
|
283
|
-
const summary = fields.get("summary");
|
|
284
|
-
const change = fields.get("change");
|
|
285
|
-
if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
|
|
286
|
-
if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
|
|
287
|
-
throw new Error("the summary is not valid");
|
|
288
|
-
}
|
|
289
|
-
if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
|
|
290
|
-
const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
|
|
291
|
-
const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
|
|
292
|
-
const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
|
|
293
|
-
const anchor = this.comments.parseAnchor(fields.get("anchor"));
|
|
294
|
-
return {
|
|
295
|
-
id: id2,
|
|
296
|
-
anchor,
|
|
297
|
-
anchorState: this.comments.anchorState(anchor),
|
|
298
|
-
change: copy(change),
|
|
299
|
-
...summary === void 0 ? {} : { summary },
|
|
300
|
-
author,
|
|
301
|
-
createdAt,
|
|
302
|
-
status: decision?.status ?? "pending",
|
|
303
|
-
...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
|
|
304
|
-
};
|
|
317
|
+
return this.readMessage(copy(message));
|
|
305
318
|
} catch (error) {
|
|
306
|
-
|
|
319
|
+
return invalid(`the message cannot be stored: ${error.message}`);
|
|
307
320
|
}
|
|
308
321
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const author = readAuthor(value);
|
|
331
|
-
return author !== void 0 && match(author);
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Give every suggestion a person wrote a tombstone author and drop its summary. An accepted one keeps its
|
|
335
|
-
* change, because that change is already part of the content. A pending or rejected one loses its change, and
|
|
336
|
-
* can no longer be accepted. A decision they made gets a tombstone decider. A decision that a read refuses (a
|
|
337
|
-
* principal that decided its own work) is deleted, so erase never turns it into a valid one. Returns how many
|
|
338
|
-
* suggestions and decisions it changed.
|
|
339
|
-
*/
|
|
340
|
-
erasePerson(person, actor) {
|
|
341
|
-
const { match } = this.comments.checkPerson(person, actor);
|
|
342
|
-
const out = { suggestions: 0, decisions: 0 };
|
|
343
|
-
const authors = /* @__PURE__ */ new Map();
|
|
344
|
-
for (const [id2, fields] of this.map.entries()) {
|
|
345
|
-
const author = fields instanceof Y2.Map ? readAuthor(fields.get("author")) : void 0;
|
|
346
|
-
if (author) authors.set(id2, author);
|
|
347
|
-
}
|
|
348
|
-
const status = new Map(this.list().map((s) => [s.id, s.status]));
|
|
349
|
-
this.ydoc.transact(() => {
|
|
350
|
-
for (const map of [this.applied, this.rejected]) {
|
|
351
|
-
for (const [key2, raw] of [...map.entries()]) {
|
|
352
|
-
const by = readAuthor(raw?.by);
|
|
353
|
-
const author = authors.get(key2.slice(0, key2.lastIndexOf(":")));
|
|
354
|
-
if (!by || !(match(by) || author && match(author))) continue;
|
|
355
|
-
if (author && decidesOwn(author, by)) map.delete(key2);
|
|
356
|
-
else if (match(by)) {
|
|
357
|
-
map.set(key2, { ...raw, by: { kind: by.kind, name: ERASED_NAME } });
|
|
358
|
-
out.decisions++;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
for (const [id2, author] of authors) {
|
|
363
|
-
if (!match(author)) continue;
|
|
364
|
-
const fields = this.map.get(id2);
|
|
365
|
-
fields.set("author", { kind: author.kind, name: ERASED_NAME });
|
|
366
|
-
fields.delete("summary");
|
|
367
|
-
if (status.get(id2) !== "accepted") fields.set("change", null);
|
|
368
|
-
out.suggestions++;
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
return out;
|
|
372
|
-
}
|
|
373
|
-
/** Propose a change. Anyone can suggest. The host stamps the author. */
|
|
374
|
-
add(input, actor) {
|
|
375
|
-
const who = assertActor(actor);
|
|
376
|
-
const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
|
|
377
|
-
const anchor = this.comments.checkAnchor(given.anchor);
|
|
378
|
-
const change = this.change(given.change, anchor);
|
|
379
|
-
const summary = given.summary;
|
|
380
|
-
if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
|
|
381
|
-
invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
|
|
382
|
-
}
|
|
383
|
-
const id2 = globalThis.crypto.randomUUID();
|
|
384
|
-
const createdAt = this.comments.time();
|
|
385
|
-
this.ydoc.transact(() => {
|
|
386
|
-
const fields = new Y2.Map();
|
|
387
|
-
this.map.set(id2, fields);
|
|
388
|
-
fields.set("anchor", anchor);
|
|
389
|
-
fields.set("change", change);
|
|
390
|
-
if (summary !== void 0) fields.set("summary", summary);
|
|
391
|
-
fields.set("author", { kind: who.kind, name: who.name });
|
|
392
|
-
fields.set("createdAt", createdAt);
|
|
393
|
-
});
|
|
394
|
-
return this.get(id2);
|
|
395
|
-
}
|
|
396
|
-
/** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
|
|
397
|
-
mayDecide(who, s, verb) {
|
|
398
|
-
const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
|
|
399
|
-
if (!ok) throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot ${verb} this suggestion: a signed-in person, or a principal with the ${ACCEPT_SCOPE} scope that did not write it, can`);
|
|
400
|
-
if (verb === "accept" && s.author.name === ERASED_NAME && s.status !== "accepted") throw new CommentsError("FORBIDDEN", "an erased suggestion has no change left and cannot be accepted");
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Apply the change as the accepting author and record the decision, in one step. Accepting an
|
|
404
|
-
* accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
|
|
405
|
-
*/
|
|
406
|
-
accept(suggestionId, actor) {
|
|
407
|
-
const who = assertActor(actor);
|
|
408
|
-
if (this.ydoc._transaction !== null) {
|
|
409
|
-
throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
|
|
410
|
-
}
|
|
411
|
-
const { id: id2, fields } = this.fields(suggestionId);
|
|
412
|
-
const s = this.read(id2, fields);
|
|
413
|
-
this.mayDecide(who, s, "accept");
|
|
414
|
-
if (s.status === "accepted") return s;
|
|
415
|
-
if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
|
|
416
|
-
const by = { kind: who.kind, name: who.name };
|
|
417
|
-
const at = this.comments.time();
|
|
418
|
-
this.ydoc.transact((tr) => {
|
|
419
|
-
const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
|
|
420
|
-
let receipt = null;
|
|
421
|
-
let failure;
|
|
422
|
-
try {
|
|
423
|
-
if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
|
|
424
|
-
throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
|
|
425
|
-
}
|
|
426
|
-
this.comments.checkAnchor(s.anchor);
|
|
427
|
-
const change = this.change(s.change, s.anchor);
|
|
428
|
-
receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
|
|
429
|
-
if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
|
|
430
|
-
receipt = receipt === void 0 ? null : strictCopy(receipt);
|
|
431
|
-
if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
|
|
432
|
-
} catch (error) {
|
|
433
|
-
failure = error;
|
|
434
|
-
receipt = null;
|
|
435
|
-
}
|
|
436
|
-
if (failure !== void 0) {
|
|
437
|
-
const reason = safeText(failure instanceof Error ? failure.message : failure);
|
|
438
|
-
if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
|
|
439
|
-
this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
|
|
440
|
-
}
|
|
441
|
-
this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
|
|
442
|
-
});
|
|
443
|
-
return this.get(id2);
|
|
444
|
-
}
|
|
445
|
-
/** Same right as accept. A person can withdraw their own suggestion. */
|
|
446
|
-
reject(suggestionId, actor) {
|
|
447
|
-
const who = assertActor(actor);
|
|
448
|
-
const { id: id2, fields } = this.fields(suggestionId);
|
|
449
|
-
const s = this.read(id2, fields);
|
|
450
|
-
this.mayDecide(who, s, "reject");
|
|
451
|
-
if (s.status === "rejected") return s;
|
|
452
|
-
if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
|
|
453
|
-
this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
|
|
454
|
-
return this.get(id2);
|
|
455
|
-
}
|
|
456
|
-
get(suggestionId) {
|
|
457
|
-
const { id: id2, fields } = this.fields(suggestionId);
|
|
458
|
-
return this.read(id2, fields);
|
|
459
|
-
}
|
|
460
|
-
/** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
|
|
461
|
-
list(filter = {}) {
|
|
462
|
-
const given = exact(filter, ["status", "anchorState"], "a filter");
|
|
463
|
-
if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
|
|
464
|
-
if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
|
|
465
|
-
return this.readAll().suggestions.filter((s) => given.status === void 0 || s.status === given.status).filter((s) => given.anchorState === void 0 || s.anchorState.state === given.anchorState).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
466
|
-
}
|
|
467
|
-
/** Suggestions that could not be read, and reverts that failed. */
|
|
468
|
-
problems() {
|
|
469
|
-
return [...this.readAll().problems, ...this.failures];
|
|
470
|
-
}
|
|
471
|
-
readAll() {
|
|
472
|
-
const suggestions = [];
|
|
473
|
-
const problems = [];
|
|
474
|
-
const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
|
|
475
|
-
for (const [id2, fields] of this.map.entries()) {
|
|
476
|
-
try {
|
|
477
|
-
suggestions.push(this.read(id2, fields, indexes));
|
|
478
|
-
} catch (error) {
|
|
479
|
-
problems.push({ id: safeId(id2), reason: error.message });
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
return { suggestions, problems };
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Two replicas can accept one suggestion before they meet. After a merge, the application of the
|
|
486
|
-
* lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
|
|
487
|
-
* A revert that throws leaves its record, so the next merge tries again.
|
|
488
|
-
*/
|
|
489
|
-
settle() {
|
|
490
|
-
const groups = this.index(this.applied);
|
|
491
|
-
const indexes = { applied: groups, rejected: this.index(this.rejected) };
|
|
492
|
-
this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
|
|
493
|
-
this.ydoc.transact(() => {
|
|
494
|
-
for (const [id2, entries] of groups) {
|
|
495
|
-
if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
|
|
496
|
-
let s;
|
|
497
|
-
try {
|
|
498
|
-
s = this.read(id2, this.map.get(id2), indexes);
|
|
499
|
-
} catch {
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
for (const loser of this.records(entries, id2, s.author).slice(1)) {
|
|
503
|
-
const key2 = `${id2}:${loser.client}`;
|
|
504
|
-
if (this.host.idempotent) {
|
|
505
|
-
this.applied.delete(key2);
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
|
-
if (loser.receipt === null) {
|
|
509
|
-
this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
|
|
510
|
-
this.applied.delete(key2);
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
try {
|
|
514
|
-
this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
|
|
515
|
-
this.applied.delete(key2);
|
|
516
|
-
} catch (error) {
|
|
517
|
-
this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
};
|
|
524
|
-
|
|
525
|
-
// src/threads.ts
|
|
526
|
-
import * as Y3 from "yjs";
|
|
527
|
-
var MODERATE_SCOPE = "comments:moderate";
|
|
528
|
-
var ERASED_BODY = "[erased]";
|
|
529
|
-
var THREADS = "comments.threads";
|
|
530
|
-
var MAX_BODY = 1e4;
|
|
531
|
-
var MAX_MENTIONS = 50;
|
|
532
|
-
var MAX_MESSAGES = 1e3;
|
|
533
|
-
var READ_LIMIT = 2 * MAX_MESSAGES;
|
|
534
|
-
var STATUSES = ["open", "resolved"];
|
|
535
|
-
var ANCHOR_STATES = ["attached", "edited", "orphaned", "unknown"];
|
|
536
|
-
var HANDLE = /^[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}_.-]{0,62}[\p{L}\p{N}\p{M}])?$/u;
|
|
537
|
-
var MENTION = /(?<![\p{L}\p{N}_@.])@([\p{L}\p{N}][\p{L}\p{N}\p{M}_.-]*)(?![\p{L}\p{N}\p{M}_.-]|\/)/gu;
|
|
538
|
-
function body(value) {
|
|
539
|
-
if (typeof value !== "string" || value.trim() === "" || value.length > MAX_BODY || BODY_FORBIDDEN.test(value)) {
|
|
540
|
-
invalid(`a body must be text of 1 to ${MAX_BODY} characters, with no control, invisible, or direction characters`);
|
|
541
|
-
}
|
|
542
|
-
return value;
|
|
543
|
-
}
|
|
544
|
-
var handle = (value) => value.normalize("NFC").replace(/^@+/, "").toLowerCase().normalize("NFC");
|
|
545
|
-
var okHandle = (value) => HANDLE.test(value) && !VARIATION.test(value);
|
|
546
|
-
var validHandle = (value) => typeof value === "string" && okHandle(value) && handle(value) === value;
|
|
547
|
-
function mentionsOf(text, explicit) {
|
|
548
|
-
const prose = text.replace(/`[^`]*`/g, " ");
|
|
549
|
-
const found = [...prose.matchAll(MENTION)].map((m) => {
|
|
550
|
-
const name = m[1].replace(/[._-]+$/, "");
|
|
551
|
-
if (!okHandle(handle(name))) invalid(`"@${name.slice(0, 20)}" is not a valid handle: use up to 64 characters`);
|
|
552
|
-
return handle(name);
|
|
553
|
-
});
|
|
554
|
-
let listed = [];
|
|
555
|
-
if (explicit !== void 0) {
|
|
556
|
-
const snapshot = Array.isArray(explicit) ? Array.from(explicit) : [];
|
|
557
|
-
const ok = Array.isArray(explicit) && snapshot.length <= MAX_MENTIONS && snapshot.every((m) => typeof m === "string" && okHandle(handle(m)));
|
|
558
|
-
if (!ok) invalid(`mentions must be a list of up to ${MAX_MENTIONS} handles of letters, digits, and . _ -`);
|
|
559
|
-
listed = snapshot.map(handle);
|
|
560
|
-
}
|
|
561
|
-
const all = [.../* @__PURE__ */ new Set([...found, ...listed])];
|
|
562
|
-
if (all.length > MAX_MENTIONS) invalid(`a message can mention up to ${MAX_MENTIONS} handles`);
|
|
563
|
-
return all;
|
|
564
|
-
}
|
|
565
|
-
var Comments = class {
|
|
566
|
-
/** Every replica of a document must register the same custom anchor kinds. Others read those threads as malformed. */
|
|
567
|
-
constructor(ydoc, options = {}) {
|
|
568
|
-
this.ydoc = ydoc;
|
|
569
|
-
if (typeof options !== "object" || options === null || options.now !== void 0 && typeof options.now !== "function" || options.anchors !== void 0 && !Array.isArray(options.anchors) || options.exists !== void 0 && typeof options.exists !== "function") {
|
|
570
|
-
invalid("options must be an object with an optional now function, anchors list, and exists function");
|
|
571
|
-
}
|
|
572
|
-
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
573
|
-
this.exists = options.exists;
|
|
574
|
-
for (const kind of [...BUILT_IN_KINDS, ...options.anchors ?? []]) {
|
|
575
|
-
if (typeof kind?.type !== "string" || kind.type === "" || typeof kind.parse !== "function" || this.kinds.has(kind.type)) {
|
|
576
|
-
throw new CommentsError("INVALID_ANCHOR", `the anchor type "${String(kind?.type)}" is empty, has no parse, or is already defined`);
|
|
577
|
-
}
|
|
578
|
-
this.kinds.set(kind.type, kind);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
ydoc;
|
|
582
|
-
kinds = /* @__PURE__ */ new Map();
|
|
583
|
-
now;
|
|
584
|
-
exists;
|
|
585
|
-
get map() {
|
|
586
|
-
return this.ydoc.getMap(THREADS);
|
|
587
|
-
}
|
|
588
|
-
/** The Yjs map that holds the threads. `Notifications` watches it. */
|
|
589
|
-
get threadsMap() {
|
|
590
|
-
return this.map;
|
|
591
|
-
}
|
|
592
|
-
/** The clock, as an ISO time. Used by `Suggestions`. */
|
|
593
|
-
time() {
|
|
594
|
-
const at = this.now();
|
|
595
|
-
const text = at instanceof Date && !Number.isNaN(at.getTime()) ? at.toISOString() : "";
|
|
596
|
-
if (!ISO_TIME.test(text)) invalid("the clock must return a date between the years 0000 and 9999");
|
|
597
|
-
return text;
|
|
598
|
-
}
|
|
599
|
-
/** Whether the content under an anchor is still there. This never throws. */
|
|
600
|
-
anchorState(anchor) {
|
|
601
|
-
if (anchor.type === "text") return textState(anchor, this.ydoc);
|
|
602
|
-
if (!this.exists) return { state: "unknown" };
|
|
603
|
-
try {
|
|
604
|
-
const answer = this.exists(anchor);
|
|
605
|
-
return answer === true ? { state: "attached" } : answer === false ? { state: "orphaned" } : { state: "unknown" };
|
|
606
|
-
} catch {
|
|
607
|
-
return { state: "unknown" };
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
/** Read an anchor from stored data. This does not check the document. */
|
|
611
|
-
parseAnchor(raw) {
|
|
612
|
-
const type = raw?.type;
|
|
613
|
-
const kind = typeof raw === "object" && raw !== null && !Array.isArray(raw) && typeof type === "string" ? this.kinds.get(type) : void 0;
|
|
614
|
-
if (!kind) throw new CommentsError("INVALID_ANCHOR", `an anchor needs a type: ${[...this.kinds.keys()].join(", ")}`);
|
|
615
|
-
try {
|
|
616
|
-
const parsed = copy(kind.parse(strictCopy(raw)));
|
|
617
|
-
if (JSON.stringify(parsed).length > MAX_ANCHOR) throw new Error(`an anchor holds up to ${MAX_ANCHOR} characters of JSON`);
|
|
618
|
-
if (parsed?.type !== kind.type) throw new Error(`the "${kind.type}" kind must return an anchor of its own type`);
|
|
619
|
-
return parsed;
|
|
620
|
-
} catch (error) {
|
|
621
|
-
if (error instanceof CommentsError) throw error;
|
|
622
|
-
throw new CommentsError("INVALID_ANCHOR", error instanceof Error ? error.message : String(error));
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
/** Parse an anchor from input and check it against this document. Throws `INVALID_ANCHOR` when it fails. */
|
|
626
|
-
checkAnchor(raw) {
|
|
627
|
-
const anchor = this.parseAnchor(raw);
|
|
628
|
-
if (JSON.stringify(this.parseAnchor(anchor)) !== JSON.stringify(anchor)) {
|
|
629
|
-
throw new CommentsError("INVALID_ANCHOR", "an anchor kind must parse its own output to the same anchor");
|
|
630
|
-
}
|
|
631
|
-
this.checkInDocument(anchor);
|
|
632
|
-
return anchor;
|
|
633
|
-
}
|
|
634
|
-
/** A text anchor must name text in this document, in order, and match its quote. */
|
|
635
|
-
checkInDocument(anchor) {
|
|
636
|
-
if (anchor.type !== "text") return;
|
|
637
|
-
const at = (json) => {
|
|
638
|
-
try {
|
|
639
|
-
return Y3.createAbsolutePositionFromRelativePosition(Y3.createRelativePositionFromJSON(json), this.ydoc);
|
|
640
|
-
} catch {
|
|
641
|
-
return null;
|
|
642
|
-
}
|
|
643
|
-
};
|
|
644
|
-
const from = at(anchor.start);
|
|
645
|
-
const to = at(anchor.end);
|
|
646
|
-
const unit = from?.type;
|
|
647
|
-
if (!(unit instanceof Y3.Text) || !from || !to || to.type !== unit || to.index <= from.index || textOf(unit).slice(from.index, to.index) !== anchor.text) {
|
|
648
|
-
throw new CommentsError("INVALID_ANCHOR", "a text anchor must name text in this document, in order, matching its quoted text");
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
/** Build a message, and refuse it unless a read would show it back. Nothing is written yet. */
|
|
652
|
-
message(text, explicit, actor) {
|
|
653
|
-
const message = {
|
|
654
|
-
id: globalThis.crypto.randomUUID(),
|
|
655
|
-
at: this.time(),
|
|
656
|
-
author: { kind: actor.kind, name: actor.name },
|
|
657
|
-
body: text,
|
|
658
|
-
mentions: mentionsOf(text, explicit)
|
|
659
|
-
};
|
|
660
|
-
try {
|
|
661
|
-
return this.readMessage(copy(message));
|
|
662
|
-
} catch (error) {
|
|
663
|
-
return invalid(`the message cannot be stored: ${error.message}`);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
readMessage(raw) {
|
|
667
|
-
const m = raw ?? {};
|
|
668
|
-
const author = readAuthor(m.author);
|
|
669
|
-
if (typeof m.id !== "string" || !ID.test(m.id) || !validDate(m.at) || !author || typeof m.body !== "string" || m.body.trim() === "" || m.body.length > MAX_BODY || BODY_FORBIDDEN.test(m.body)) {
|
|
670
|
-
throw new Error("a message has no id, ISO time, valid author, or body");
|
|
671
|
-
}
|
|
672
|
-
if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
|
|
673
|
-
const editor = m.editedBy === void 0 ? void 0 : readAuthor(m.editedBy);
|
|
674
|
-
if (m.editedAt === void 0 !== (m.editedBy === void 0) || m.editedAt !== void 0 && !validDate(m.editedAt) || m.editedBy !== void 0 && !editor) {
|
|
675
|
-
throw new Error("a message has a bad edit stamp");
|
|
676
|
-
}
|
|
677
|
-
if (m.erased !== void 0 && (m.erased !== true || m.body !== ERASED_BODY || author.name !== ERASED_NAME || m.mentions.length > 0)) throw new Error("a message has a bad erased flag");
|
|
678
|
-
return {
|
|
679
|
-
id: m.id,
|
|
680
|
-
at: m.at,
|
|
681
|
-
author,
|
|
682
|
-
body: m.body,
|
|
683
|
-
mentions: [...m.mentions],
|
|
684
|
-
...editor ? { editedAt: m.editedAt, editedBy: editor } : {},
|
|
685
|
-
...m.erased ? { erased: true } : {}
|
|
686
|
-
};
|
|
322
|
+
readMessage(raw) {
|
|
323
|
+
const m = raw ?? {};
|
|
324
|
+
const author = readAuthor(m.author);
|
|
325
|
+
if (typeof m.id !== "string" || !ID.test(m.id) || !validDate(m.at) || !author || typeof m.body !== "string" || m.body.trim() === "" || m.body.length > MAX_BODY || BODY_FORBIDDEN.test(m.body)) {
|
|
326
|
+
throw new Error("a message has no id, ISO time, valid author, or body");
|
|
327
|
+
}
|
|
328
|
+
if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
|
|
329
|
+
const editor = m.editedBy === void 0 ? void 0 : readAuthor(m.editedBy);
|
|
330
|
+
if (m.editedAt === void 0 !== (m.editedBy === void 0) || m.editedAt !== void 0 && !validDate(m.editedAt) || m.editedBy !== void 0 && !editor) {
|
|
331
|
+
throw new Error("a message has a bad edit stamp");
|
|
332
|
+
}
|
|
333
|
+
if (m.erased !== void 0 && (m.erased !== true || m.body !== ERASED_BODY || author.name !== ERASED_NAME || m.mentions.length > 0)) throw new Error("a message has a bad erased flag");
|
|
334
|
+
return {
|
|
335
|
+
id: m.id,
|
|
336
|
+
at: m.at,
|
|
337
|
+
author,
|
|
338
|
+
body: m.body,
|
|
339
|
+
mentions: [...m.mentions],
|
|
340
|
+
...editor ? { editedAt: m.editedAt, editedBy: editor } : {},
|
|
341
|
+
...m.erased ? { erased: true } : {}
|
|
342
|
+
};
|
|
687
343
|
}
|
|
688
344
|
/** A thread with a bad anchor or status is malformed. A bad message is skipped and named in `issues`. */
|
|
689
345
|
read(id2, fields) {
|
|
690
346
|
try {
|
|
691
347
|
if (!ID.test(id2)) throw new Error("the thread id is not short and plain");
|
|
692
|
-
if (!(fields instanceof
|
|
348
|
+
if (!(fields instanceof Y2.Map)) throw new Error("the thread is not a map");
|
|
693
349
|
const state = fields.get("state") ?? {};
|
|
694
350
|
const by = readAuthor(state.by);
|
|
695
351
|
if (!STATUSES.includes(state.status) || !by || !validDate(state.at)) throw new Error("the thread status is not valid");
|
|
696
352
|
const list = fields.get("messages");
|
|
697
|
-
if (!(list instanceof
|
|
353
|
+
if (!(list instanceof Y2.Array)) throw new Error("the thread has no message list");
|
|
698
354
|
const messages = [];
|
|
699
355
|
const issues = [];
|
|
700
356
|
const raw = list.toArray();
|
|
@@ -753,11 +409,11 @@ var Comments = class {
|
|
|
753
409
|
const first = this.message(body(given.body), given.mentions, who);
|
|
754
410
|
const id2 = globalThis.crypto.randomUUID();
|
|
755
411
|
this.ydoc.transact(() => {
|
|
756
|
-
const fields = new
|
|
412
|
+
const fields = new Y2.Map();
|
|
757
413
|
this.map.set(id2, fields);
|
|
758
414
|
fields.set("anchor", anchor);
|
|
759
415
|
fields.set("state", { status: "open", by: copy(first.author), at: first.at });
|
|
760
|
-
const messages = new
|
|
416
|
+
const messages = new Y2.Array();
|
|
761
417
|
fields.set("messages", messages);
|
|
762
418
|
messages.push([copy(first)]);
|
|
763
419
|
});
|
|
@@ -902,7 +558,7 @@ var Comments = class {
|
|
|
902
558
|
}
|
|
903
559
|
/** The raw stored threads, for the code that must see data a read hides. `list` is missing when the message field is bad. */
|
|
904
560
|
stored() {
|
|
905
|
-
return [...this.map.entries()].flatMap(([id2, fields]) => fields instanceof
|
|
561
|
+
return [...this.map.entries()].flatMap(([id2, fields]) => fields instanceof Y2.Map ? [{ id: id2, fields, list: fields.get("messages") instanceof Y2.Array ? fields.get("messages") : void 0 }] : []);
|
|
906
562
|
}
|
|
907
563
|
/**
|
|
908
564
|
* What a person wrote in this document: their messages, the status change of theirs that stands, and the
|
|
@@ -911,115 +567,458 @@ var Comments = class {
|
|
|
911
567
|
* lost a merge. When it is not 0 the export is not complete. A person may export their own data.
|
|
912
568
|
*/
|
|
913
569
|
exportPerson(person, actor) {
|
|
914
|
-
const { name, match } = this.checkPerson(person, actor);
|
|
915
|
-
const threads = [];
|
|
916
|
-
const edits = [];
|
|
917
|
-
let shownCount = 0;
|
|
918
|
-
for (const thread of this.list()) {
|
|
919
|
-
const messages = thread.messages.filter((m) => match(m.author));
|
|
920
|
-
shownCount += messages.length;
|
|
921
|
-
const status = match(thread.statusBy);
|
|
922
|
-
if (status) shownCount++;
|
|
923
|
-
for (const m of thread.messages) if (m.editedBy && match(m.editedBy) && !match(m.author)) edits.push({ threadId: thread.id, messageId: m.id, at: m.editedAt });
|
|
924
|
-
if (messages.length === 0 && !status) continue;
|
|
925
|
-
threads.push({
|
|
926
|
-
threadId: thread.id,
|
|
927
|
-
anchor: copy(thread.anchor),
|
|
928
|
-
messages: copy(messages),
|
|
929
|
-
...status ? { statusChange: { status: thread.status, at: thread.statusAt, by: copy(thread.statusBy) } } : {}
|
|
930
|
-
});
|
|
931
|
-
}
|
|
570
|
+
const { name, match } = this.checkPerson(person, actor);
|
|
571
|
+
const threads = [];
|
|
572
|
+
const edits = [];
|
|
573
|
+
let shownCount = 0;
|
|
574
|
+
for (const thread of this.list()) {
|
|
575
|
+
const messages = thread.messages.filter((m) => match(m.author));
|
|
576
|
+
shownCount += messages.length;
|
|
577
|
+
const status = match(thread.statusBy);
|
|
578
|
+
if (status) shownCount++;
|
|
579
|
+
for (const m of thread.messages) if (m.editedBy && match(m.editedBy) && !match(m.author)) edits.push({ threadId: thread.id, messageId: m.id, at: m.editedAt });
|
|
580
|
+
if (messages.length === 0 && !status) continue;
|
|
581
|
+
threads.push({
|
|
582
|
+
threadId: thread.id,
|
|
583
|
+
anchor: copy(thread.anchor),
|
|
584
|
+
messages: copy(messages),
|
|
585
|
+
...status ? { statusChange: { status: thread.status, at: thread.statusAt, by: copy(thread.statusBy) } } : {}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
let found = 0;
|
|
589
|
+
const named = (value) => {
|
|
590
|
+
const author = readAuthor(value);
|
|
591
|
+
return author !== void 0 && match(author);
|
|
592
|
+
};
|
|
593
|
+
for (const { fields, list } of this.stored()) {
|
|
594
|
+
for (const raw of list?.toArray() ?? []) if (named(raw?.author) || named(raw?.editedBy)) found++;
|
|
595
|
+
if (named(fields.get("state")?.by)) found++;
|
|
596
|
+
}
|
|
597
|
+
return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Replace every message a person wrote with a tombstone: the body becomes "[erased]", the author name
|
|
601
|
+
* becomes "erased", and the mentions go. The author kind stays, so machine-made work stays marked. Thread
|
|
602
|
+
* shape, order, and times stay. A status change of theirs, and their name as the editor of another person's
|
|
603
|
+
* message, get the tombstone name. It works on the stored entries, so it reaches a bad message, a message past
|
|
604
|
+
* the read limit, a thread that cannot be read, and every copy that a merge hid. Names inside other people's
|
|
605
|
+
* messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
|
|
606
|
+
*/
|
|
607
|
+
erasePerson(person, actor) {
|
|
608
|
+
const { match } = this.checkPerson(person, actor);
|
|
609
|
+
const tomb = (a) => ({ kind: a.kind, name: ERASED_NAME });
|
|
610
|
+
const out = { messages: 0, statuses: 0, edits: 0 };
|
|
611
|
+
this.ydoc.transact(() => {
|
|
612
|
+
for (const { fields, list } of this.stored()) {
|
|
613
|
+
const entries = list?.toArray() ?? [];
|
|
614
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
615
|
+
const raw = entries[i];
|
|
616
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
617
|
+
const author = readAuthor(raw.author);
|
|
618
|
+
const editor = readAuthor(raw.editedBy);
|
|
619
|
+
let next;
|
|
620
|
+
if (author && match(author)) {
|
|
621
|
+
const id2 = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : globalThis.crypto.randomUUID();
|
|
622
|
+
const at = validDate(raw.at) ? raw.at : this.time();
|
|
623
|
+
next = { id: id2, at, author: tomb(author), body: ERASED_BODY, mentions: [], editedAt: this.time(), editedBy: tomb(author), erased: true };
|
|
624
|
+
out.messages++;
|
|
625
|
+
} else if (editor && match(editor)) {
|
|
626
|
+
next = { ...raw, editedBy: tomb(editor) };
|
|
627
|
+
out.edits++;
|
|
628
|
+
}
|
|
629
|
+
if (!next) continue;
|
|
630
|
+
list.delete(i, 1);
|
|
631
|
+
list.insert(i, [next]);
|
|
632
|
+
}
|
|
633
|
+
const state = fields.get("state");
|
|
634
|
+
const by = readAuthor(state?.by);
|
|
635
|
+
if (state && by && match(by)) {
|
|
636
|
+
fields.set("state", { ...state, by: tomb(by) });
|
|
637
|
+
out.statuses++;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
get(threadId) {
|
|
644
|
+
const { id: id2, fields } = this.fields(threadId);
|
|
645
|
+
return this.read(id2, fields).thread;
|
|
646
|
+
}
|
|
647
|
+
/** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
|
|
648
|
+
list(filter = {}) {
|
|
649
|
+
const given = exact(filter, ["status", "anchorType", "mentioning", "anchorState"], "a filter");
|
|
650
|
+
if (given.status !== void 0 && !STATUSES.includes(given.status)) invalid(`status must be ${STATUSES.join(" or ")}`);
|
|
651
|
+
if (given.anchorType !== void 0 && typeof given.anchorType !== "string") invalid("anchorType must be text");
|
|
652
|
+
if (given.mentioning !== void 0 && typeof given.mentioning !== "string") invalid("mentioning must be text");
|
|
653
|
+
if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
|
|
654
|
+
const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
|
|
655
|
+
return this.readAll().threads.filter((t) => given.status === void 0 || t.status === given.status).filter((t) => given.anchorType === void 0 || t.anchor.type === given.anchorType).filter((t) => given.anchorState === void 0 || t.anchorState.state === given.anchorState).filter((t) => wanted === void 0 || t.messages.some((m) => m.mentions.includes(wanted))).sort((a, b) => Date.parse(a.messages[0].at) - Date.parse(b.messages[0].at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
656
|
+
}
|
|
657
|
+
/** Threads or messages that could not be read, for example from a replica that wrote bad data. */
|
|
658
|
+
problems() {
|
|
659
|
+
return this.readAll().problems;
|
|
660
|
+
}
|
|
661
|
+
readAll() {
|
|
662
|
+
const threads = [];
|
|
663
|
+
const problems = [];
|
|
664
|
+
for (const [id2, fields] of this.map.entries()) {
|
|
665
|
+
try {
|
|
666
|
+
const { thread, issues } = this.read(id2, fields);
|
|
667
|
+
threads.push(thread);
|
|
668
|
+
for (const reason of issues) problems.push({ id: safeId(id2), reason });
|
|
669
|
+
} catch (error) {
|
|
670
|
+
problems.push({ id: safeId(id2), reason: error.message });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return { threads, problems };
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/suggestions.ts
|
|
678
|
+
import * as Y3 from "yjs";
|
|
679
|
+
var ACCEPT_SCOPE = "comments:accept";
|
|
680
|
+
var MAX_CHANGE = 64e3;
|
|
681
|
+
var MAX_RECEIPT = 16e3;
|
|
682
|
+
var MAX_SUMMARY = 1e3;
|
|
683
|
+
var SUGGESTIONS = "comments.suggestions";
|
|
684
|
+
var APPLIED = "comments.suggestion-applied";
|
|
685
|
+
var REJECTED = "comments.suggestion-rejected";
|
|
686
|
+
var decidesOwn = (author, decider) => author.name !== ERASED_NAME && author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
|
|
687
|
+
var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
|
|
688
|
+
var thenable = (value) => typeof value?.then === "function";
|
|
689
|
+
var Suggestions = class {
|
|
690
|
+
constructor(comments, host) {
|
|
691
|
+
this.comments = comments;
|
|
692
|
+
this.host = host;
|
|
693
|
+
if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
|
|
694
|
+
invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
|
|
695
|
+
}
|
|
696
|
+
void [this.map, this.applied, this.rejected];
|
|
697
|
+
this.onTransaction = (tr) => {
|
|
698
|
+
if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
|
|
699
|
+
};
|
|
700
|
+
this.ydoc.on("afterTransaction", this.onTransaction);
|
|
701
|
+
this.settle();
|
|
702
|
+
}
|
|
703
|
+
comments;
|
|
704
|
+
host;
|
|
705
|
+
onTransaction;
|
|
706
|
+
failures = [];
|
|
707
|
+
/** Stop listening to the document. */
|
|
708
|
+
close() {
|
|
709
|
+
this.ydoc.off("afterTransaction", this.onTransaction);
|
|
710
|
+
}
|
|
711
|
+
get ydoc() {
|
|
712
|
+
return this.comments.ydoc;
|
|
713
|
+
}
|
|
714
|
+
get map() {
|
|
715
|
+
return this.ydoc.getMap(SUGGESTIONS);
|
|
716
|
+
}
|
|
717
|
+
/** The three Yjs maps that hold suggestions and their decisions. `Notifications` watches them. */
|
|
718
|
+
get maps() {
|
|
719
|
+
return { suggestions: this.map, applied: this.applied, rejected: this.rejected };
|
|
720
|
+
}
|
|
721
|
+
get applied() {
|
|
722
|
+
return this.ydoc.getMap(APPLIED);
|
|
723
|
+
}
|
|
724
|
+
get rejected() {
|
|
725
|
+
return this.ydoc.getMap(REJECTED);
|
|
726
|
+
}
|
|
727
|
+
change(raw, anchor) {
|
|
728
|
+
let clean;
|
|
729
|
+
try {
|
|
730
|
+
clean = strictCopy(raw);
|
|
731
|
+
if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean), { anchor: copy(anchor) }));
|
|
732
|
+
} catch (error) {
|
|
733
|
+
return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
|
|
734
|
+
}
|
|
735
|
+
if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
|
|
736
|
+
return clean;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Records of one kind for one suggestion, in client order. A record that is not valid is not counted:
|
|
740
|
+
* one with no author or time, a receipt that is not small JSON, or a decider who decided their own work.
|
|
741
|
+
*/
|
|
742
|
+
records(entries, id2, author) {
|
|
743
|
+
const out = [];
|
|
744
|
+
for (const [key2, raw] of entries) {
|
|
745
|
+
const client = clientOf(key2.slice(id2.length + 1));
|
|
746
|
+
const r = raw ?? {};
|
|
747
|
+
const by = readAuthor(r.by);
|
|
748
|
+
if (client === void 0 || !by || !validDate(r.at) || !author || decidesOwn(author, by)) continue;
|
|
749
|
+
let receipt = null;
|
|
750
|
+
try {
|
|
751
|
+
receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
|
|
752
|
+
if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
|
|
753
|
+
} catch {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
out.push({ client, by, at: r.at, receipt });
|
|
757
|
+
}
|
|
758
|
+
return out.sort((a, b) => Number(a.receipt !== null) - Number(b.receipt !== null) || a.client - b.client);
|
|
759
|
+
}
|
|
760
|
+
/** Group the entries of a decision map by suggestion id in one pass, so a read does not scan the map per suggestion. */
|
|
761
|
+
index(map) {
|
|
762
|
+
const groups = /* @__PURE__ */ new Map();
|
|
763
|
+
for (const entry of map.entries()) {
|
|
764
|
+
const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
|
|
765
|
+
const group = groups.get(id2);
|
|
766
|
+
if (group) group.push(entry);
|
|
767
|
+
else groups.set(id2, [entry]);
|
|
768
|
+
}
|
|
769
|
+
return groups;
|
|
770
|
+
}
|
|
771
|
+
read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
|
|
772
|
+
try {
|
|
773
|
+
if (!ID.test(id2)) throw new Error("the suggestion id is not short and plain");
|
|
774
|
+
if (!(fields instanceof Y3.Map)) throw new Error("the suggestion is not a map");
|
|
775
|
+
const author = readAuthor(fields.get("author"));
|
|
776
|
+
const createdAt = fields.get("createdAt");
|
|
777
|
+
const summary = fields.get("summary");
|
|
778
|
+
const change = fields.get("change");
|
|
779
|
+
if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
|
|
780
|
+
if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
|
|
781
|
+
throw new Error("the summary is not valid");
|
|
782
|
+
}
|
|
783
|
+
if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
|
|
784
|
+
const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
|
|
785
|
+
const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
|
|
786
|
+
const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
|
|
787
|
+
const anchor = this.comments.parseAnchor(fields.get("anchor"));
|
|
788
|
+
return {
|
|
789
|
+
id: id2,
|
|
790
|
+
anchor,
|
|
791
|
+
anchorState: this.comments.anchorState(anchor),
|
|
792
|
+
change: copy(change),
|
|
793
|
+
...summary === void 0 ? {} : { summary },
|
|
794
|
+
author,
|
|
795
|
+
createdAt,
|
|
796
|
+
status: decision?.status ?? "pending",
|
|
797
|
+
...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
|
|
798
|
+
};
|
|
799
|
+
} catch (error) {
|
|
800
|
+
throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
fields(id2) {
|
|
804
|
+
if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
|
|
805
|
+
return { id: id2, fields: this.map.get(id2) };
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The suggestions a person wrote, and the decisions they made, in this document. `unread` counts stored
|
|
809
|
+
* suggestions and decision records with their name that this export cannot show: a suggestion that cannot
|
|
810
|
+
* be read, a decision that lost a merge, a refused decision, or a decision with no suggestion. When it is not
|
|
811
|
+
* 0 the export is not complete. A person may export their own data.
|
|
812
|
+
*/
|
|
813
|
+
exportPerson(person, actor) {
|
|
814
|
+
const { match } = this.comments.checkPerson(person, actor);
|
|
815
|
+
const all = this.list();
|
|
816
|
+
const authored = all.filter((s) => match(s.author));
|
|
817
|
+
const decided = all.flatMap((s) => s.decidedBy && s.decidedAt && s.status !== "pending" && match(s.decidedBy) ? [{ id: s.id, status: s.status, at: s.decidedAt }] : []);
|
|
932
818
|
let found = 0;
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
}
|
|
941
|
-
return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
|
|
819
|
+
for (const fields of this.map.values()) if (fields instanceof Y3.Map && this.named(fields.get("author"), match)) found++;
|
|
820
|
+
for (const map of [this.applied, this.rejected]) for (const raw of map.values()) if (this.named(raw?.by, match)) found++;
|
|
821
|
+
return { authored, decided, unread: Math.max(0, found - authored.length - decided.length) };
|
|
822
|
+
}
|
|
823
|
+
named(value, match) {
|
|
824
|
+
const author = readAuthor(value);
|
|
825
|
+
return author !== void 0 && match(author);
|
|
942
826
|
}
|
|
943
827
|
/**
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
* messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
|
|
828
|
+
* Give every suggestion a person wrote a tombstone author and drop its summary. An accepted one keeps its
|
|
829
|
+
* change, because that change is already part of the content. A pending or rejected one loses its change, and
|
|
830
|
+
* can no longer be accepted. A decision they made gets a tombstone decider. A decision that a read refuses (a
|
|
831
|
+
* principal that decided its own work) is deleted, so erase never turns it into a valid one. Returns how many
|
|
832
|
+
* suggestions and decisions it changed.
|
|
950
833
|
*/
|
|
951
834
|
erasePerson(person, actor) {
|
|
952
|
-
const { match } = this.checkPerson(person, actor);
|
|
953
|
-
const
|
|
954
|
-
const
|
|
835
|
+
const { match } = this.comments.checkPerson(person, actor);
|
|
836
|
+
const out = { suggestions: 0, decisions: 0 };
|
|
837
|
+
const authors = /* @__PURE__ */ new Map();
|
|
838
|
+
for (const [id2, fields] of this.map.entries()) {
|
|
839
|
+
const author = fields instanceof Y3.Map ? readAuthor(fields.get("author")) : void 0;
|
|
840
|
+
if (author) authors.set(id2, author);
|
|
841
|
+
}
|
|
842
|
+
const status = new Map(this.list().map((s) => [s.id, s.status]));
|
|
955
843
|
this.ydoc.transact(() => {
|
|
956
|
-
for (const
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const id2 = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : globalThis.crypto.randomUUID();
|
|
966
|
-
const at = validDate(raw.at) ? raw.at : this.time();
|
|
967
|
-
next = { id: id2, at, author: tomb(author), body: ERASED_BODY, mentions: [], editedAt: this.time(), editedBy: tomb(author), erased: true };
|
|
968
|
-
out.messages++;
|
|
969
|
-
} else if (editor && match(editor)) {
|
|
970
|
-
next = { ...raw, editedBy: tomb(editor) };
|
|
971
|
-
out.edits++;
|
|
844
|
+
for (const map of [this.applied, this.rejected]) {
|
|
845
|
+
for (const [key2, raw] of [...map.entries()]) {
|
|
846
|
+
const by = readAuthor(raw?.by);
|
|
847
|
+
const author = authors.get(key2.slice(0, key2.lastIndexOf(":")));
|
|
848
|
+
if (!by || !(match(by) || author && match(author))) continue;
|
|
849
|
+
if (author && decidesOwn(author, by)) map.delete(key2);
|
|
850
|
+
else if (match(by)) {
|
|
851
|
+
map.set(key2, { ...raw, by: { kind: by.kind, name: ERASED_NAME } });
|
|
852
|
+
out.decisions++;
|
|
972
853
|
}
|
|
973
|
-
if (!next) continue;
|
|
974
|
-
list.delete(i, 1);
|
|
975
|
-
list.insert(i, [next]);
|
|
976
|
-
}
|
|
977
|
-
const state = fields.get("state");
|
|
978
|
-
const by = readAuthor(state?.by);
|
|
979
|
-
if (state && by && match(by)) {
|
|
980
|
-
fields.set("state", { ...state, by: tomb(by) });
|
|
981
|
-
out.statuses++;
|
|
982
854
|
}
|
|
983
855
|
}
|
|
856
|
+
for (const [id2, author] of authors) {
|
|
857
|
+
if (!match(author)) continue;
|
|
858
|
+
const fields = this.map.get(id2);
|
|
859
|
+
fields.set("author", { kind: author.kind, name: ERASED_NAME });
|
|
860
|
+
fields.delete("summary");
|
|
861
|
+
if (status.get(id2) !== "accepted") fields.set("change", null);
|
|
862
|
+
out.suggestions++;
|
|
863
|
+
}
|
|
984
864
|
});
|
|
985
865
|
return out;
|
|
986
866
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
867
|
+
/** Propose a change. Anyone can suggest. The host stamps the author. */
|
|
868
|
+
add(input, actor) {
|
|
869
|
+
const who = assertActor(actor);
|
|
870
|
+
const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
|
|
871
|
+
const anchor = this.comments.checkAnchor(given.anchor);
|
|
872
|
+
const change = this.change(given.change, anchor);
|
|
873
|
+
const summary = given.summary;
|
|
874
|
+
if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
|
|
875
|
+
invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
|
|
876
|
+
}
|
|
877
|
+
const id2 = globalThis.crypto.randomUUID();
|
|
878
|
+
const createdAt = this.comments.time();
|
|
879
|
+
this.ydoc.transact(() => {
|
|
880
|
+
const fields = new Y3.Map();
|
|
881
|
+
this.map.set(id2, fields);
|
|
882
|
+
fields.set("anchor", anchor);
|
|
883
|
+
fields.set("change", change);
|
|
884
|
+
if (summary !== void 0) fields.set("summary", summary);
|
|
885
|
+
fields.set("author", { kind: who.kind, name: who.name });
|
|
886
|
+
fields.set("createdAt", createdAt);
|
|
887
|
+
});
|
|
888
|
+
return this.get(id2);
|
|
990
889
|
}
|
|
991
|
-
/**
|
|
890
|
+
/** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
|
|
891
|
+
mayDecide(who, s, verb) {
|
|
892
|
+
const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
|
|
893
|
+
if (!ok) throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot ${verb} this suggestion: a signed-in person, or a principal with the ${ACCEPT_SCOPE} scope that did not write it, can`);
|
|
894
|
+
if (verb === "accept" && s.author.name === ERASED_NAME && s.status !== "accepted") throw new CommentsError("FORBIDDEN", "an erased suggestion has no change left and cannot be accepted");
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Apply the change as the accepting author and record the decision, in one step. Accepting an
|
|
898
|
+
* accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
|
|
899
|
+
*/
|
|
900
|
+
accept(suggestionId, actor) {
|
|
901
|
+
const who = assertActor(actor);
|
|
902
|
+
if (this.ydoc._transaction !== null) {
|
|
903
|
+
throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
|
|
904
|
+
}
|
|
905
|
+
const { id: id2, fields } = this.fields(suggestionId);
|
|
906
|
+
const s = this.read(id2, fields);
|
|
907
|
+
this.mayDecide(who, s, "accept");
|
|
908
|
+
if (s.status === "accepted") return s;
|
|
909
|
+
if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
|
|
910
|
+
const by = { kind: who.kind, name: who.name };
|
|
911
|
+
const at = this.comments.time();
|
|
912
|
+
this.ydoc.transact((tr) => {
|
|
913
|
+
const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
|
|
914
|
+
let receipt = null;
|
|
915
|
+
let failure;
|
|
916
|
+
try {
|
|
917
|
+
if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
|
|
918
|
+
throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
|
|
919
|
+
}
|
|
920
|
+
this.comments.checkAnchor(s.anchor);
|
|
921
|
+
const change = this.change(s.change, s.anchor);
|
|
922
|
+
receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
|
|
923
|
+
if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
|
|
924
|
+
receipt = receipt === void 0 ? null : strictCopy(receipt);
|
|
925
|
+
if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
|
|
926
|
+
} catch (error) {
|
|
927
|
+
failure = error;
|
|
928
|
+
receipt = null;
|
|
929
|
+
}
|
|
930
|
+
if (failure !== void 0) {
|
|
931
|
+
const reason = safeText(failure instanceof Error ? failure.message : failure);
|
|
932
|
+
if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
|
|
933
|
+
this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
|
|
934
|
+
}
|
|
935
|
+
this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
|
|
936
|
+
});
|
|
937
|
+
return this.get(id2);
|
|
938
|
+
}
|
|
939
|
+
/** Same right as accept. A person can withdraw their own suggestion. */
|
|
940
|
+
reject(suggestionId, actor) {
|
|
941
|
+
const who = assertActor(actor);
|
|
942
|
+
const { id: id2, fields } = this.fields(suggestionId);
|
|
943
|
+
const s = this.read(id2, fields);
|
|
944
|
+
this.mayDecide(who, s, "reject");
|
|
945
|
+
if (s.status === "rejected") return s;
|
|
946
|
+
if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
|
|
947
|
+
this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
|
|
948
|
+
return this.get(id2);
|
|
949
|
+
}
|
|
950
|
+
get(suggestionId) {
|
|
951
|
+
const { id: id2, fields } = this.fields(suggestionId);
|
|
952
|
+
return this.read(id2, fields);
|
|
953
|
+
}
|
|
954
|
+
/** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
|
|
992
955
|
list(filter = {}) {
|
|
993
|
-
const given = exact(filter, ["status", "
|
|
994
|
-
if (given.
|
|
995
|
-
if (given.
|
|
996
|
-
|
|
997
|
-
if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
|
|
998
|
-
const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
|
|
999
|
-
return this.readAll().threads.filter((t) => given.status === void 0 || t.status === given.status).filter((t) => given.anchorType === void 0 || t.anchor.type === given.anchorType).filter((t) => given.anchorState === void 0 || t.anchorState.state === given.anchorState).filter((t) => wanted === void 0 || t.messages.some((m) => m.mentions.includes(wanted))).sort((a, b) => Date.parse(a.messages[0].at) - Date.parse(b.messages[0].at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
956
|
+
const given = exact(filter, ["status", "anchorState"], "a filter");
|
|
957
|
+
if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
|
|
958
|
+
if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
|
|
959
|
+
return this.readAll().suggestions.filter((s) => given.status === void 0 || s.status === given.status).filter((s) => given.anchorState === void 0 || s.anchorState.state === given.anchorState).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1000
960
|
}
|
|
1001
|
-
/**
|
|
961
|
+
/** Suggestions that could not be read, and reverts that failed. */
|
|
1002
962
|
problems() {
|
|
1003
|
-
return this.readAll().problems;
|
|
963
|
+
return [...this.readAll().problems, ...this.failures];
|
|
1004
964
|
}
|
|
1005
965
|
readAll() {
|
|
1006
|
-
const
|
|
966
|
+
const suggestions = [];
|
|
1007
967
|
const problems = [];
|
|
968
|
+
const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
|
|
1008
969
|
for (const [id2, fields] of this.map.entries()) {
|
|
1009
970
|
try {
|
|
1010
|
-
|
|
1011
|
-
threads.push(thread);
|
|
1012
|
-
for (const reason of issues) problems.push({ id: safeId(id2), reason });
|
|
971
|
+
suggestions.push(this.read(id2, fields, indexes));
|
|
1013
972
|
} catch (error) {
|
|
1014
973
|
problems.push({ id: safeId(id2), reason: error.message });
|
|
1015
974
|
}
|
|
1016
975
|
}
|
|
1017
|
-
return {
|
|
976
|
+
return { suggestions, problems };
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Two replicas can accept one suggestion before they meet. After a merge, the application of the
|
|
980
|
+
* lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
|
|
981
|
+
* A revert that throws leaves its record, so the next merge tries again.
|
|
982
|
+
*/
|
|
983
|
+
settle() {
|
|
984
|
+
const groups = this.index(this.applied);
|
|
985
|
+
const indexes = { applied: groups, rejected: this.index(this.rejected) };
|
|
986
|
+
this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
|
|
987
|
+
this.ydoc.transact(() => {
|
|
988
|
+
for (const [id2, entries] of groups) {
|
|
989
|
+
if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
|
|
990
|
+
let s;
|
|
991
|
+
try {
|
|
992
|
+
s = this.read(id2, this.map.get(id2), indexes);
|
|
993
|
+
} catch {
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
for (const loser of this.records(entries, id2, s.author).slice(1)) {
|
|
997
|
+
const key2 = `${id2}:${loser.client}`;
|
|
998
|
+
if (this.host.idempotent) {
|
|
999
|
+
this.applied.delete(key2);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (loser.receipt === null) {
|
|
1003
|
+
this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
|
|
1004
|
+
this.applied.delete(key2);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
|
|
1009
|
+
this.applied.delete(key2);
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1018
1016
|
}
|
|
1019
1017
|
};
|
|
1020
1018
|
|
|
1021
1019
|
export {
|
|
1022
1020
|
CommentsError,
|
|
1021
|
+
shown,
|
|
1023
1022
|
textOf,
|
|
1024
1023
|
textAnchor,
|
|
1025
1024
|
textRange,
|
|
@@ -1027,14 +1026,15 @@ export {
|
|
|
1027
1026
|
ERASED_NAME,
|
|
1028
1027
|
Actor,
|
|
1029
1028
|
stamp,
|
|
1029
|
+
assertActor,
|
|
1030
1030
|
readAuthor,
|
|
1031
1031
|
authorLabel,
|
|
1032
1032
|
invalid,
|
|
1033
1033
|
exact,
|
|
1034
1034
|
validDate,
|
|
1035
|
-
ACCEPT_SCOPE,
|
|
1036
|
-
Suggestions,
|
|
1037
1035
|
MODERATE_SCOPE,
|
|
1038
1036
|
ERASED_BODY,
|
|
1039
|
-
Comments
|
|
1037
|
+
Comments,
|
|
1038
|
+
ACCEPT_SCOPE,
|
|
1039
|
+
Suggestions
|
|
1040
1040
|
};
|