agentic-comments 0.5.1 → 0.6.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 +74 -13
- package/SKILL.md +5 -1
- package/dist/{chunk-QJXI3P5D.js → chunk-2NBFTJ47.js} +1 -1
- package/dist/{chunk-REFQNIEP.js → chunk-ES3NZ7SX.js} +1 -1
- package/dist/{chunk-TFRLOBJW.js → chunk-SR2A5OZW.js} +12 -5
- package/dist/{chunk-NQWRFKJ6.js → chunk-T77DPNIV.js} +21 -1
- package/dist/{chunk-BSNBDKR2.js → chunk-VAVKBAHM.js} +278 -6
- package/dist/cli.js +29 -8
- package/dist/client.d.ts +29 -4
- package/dist/client.js +17 -5
- package/dist/errors-C-xvp24-.d.ts +7 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +18 -3
- package/dist/local.d.ts +2 -2
- package/dist/local.js +3 -3
- package/dist/mcp-JX5L6VQW.js +11 -0
- package/dist/mcp-bin.js +5 -5
- package/dist/{service-hpWbnSmc.d.ts → service-BvIE6t5n.d.ts} +24 -1
- package/dist/{suggestions-CvJwo6r4.d.ts → suggestions-CnkEcTuY.d.ts} +128 -1
- package/dist/worker.d.ts +10 -5
- package/dist/worker.js +36 -6
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/dist/errors-CNgWMVQT.d.ts +0 -7
- package/dist/mcp-XUV3RPOO.js +0 -11
package/README.md
CHANGED
|
@@ -53,6 +53,39 @@ normal sync, and every replica sees the same threads.
|
|
|
53
53
|
- A malformed thread from a bad replica is skipped. A bad message is left out, and `skipped` on the
|
|
54
54
|
thread counts those. `problems()` names them.
|
|
55
55
|
|
|
56
|
+
## Edit, delete, export, and erase
|
|
57
|
+
|
|
58
|
+
- `comments.editMessage(threadId, messageId, { body, mentions? }, actor)` changes a body. The id, author,
|
|
59
|
+
time, and place stay. The message gets `editedAt` and `editedBy`. A resolved thread refuses an edit.
|
|
60
|
+
- `comments.deleteMessage(threadId, messageId, actor)` removes one message. The last message of a thread
|
|
61
|
+
stays: delete the thread instead.
|
|
62
|
+
- `comments.deleteThread(threadId, actor)` removes a thread. Its author can do it while every message is theirs.
|
|
63
|
+
- `comments.exportPerson(name, actor)` returns the messages a person wrote, the status change of theirs that
|
|
64
|
+
stands, and their edits to other people's messages. It holds no one else's words. `unread` counts stored
|
|
65
|
+
entries with their name that the read cannot show (a bad message, an unreadable thread). When it is not 0,
|
|
66
|
+
the export is not complete. `suggestions.exportPerson` lists their suggestions and decisions, with its own `unread` count.
|
|
67
|
+
- `comments.erasePerson(name, actor)` replaces each message of that person with a tombstone: the body is
|
|
68
|
+
`[erased]`, the author name is `erased`, and mentions go. The author kind stays, so machine-made work stays
|
|
69
|
+
marked. The thread keeps its shape, order, and times. It works on the stored entries, so it also reaches a bad
|
|
70
|
+
message, a message past the read limit, and a copy that a merge hid. A status change of theirs and their name
|
|
71
|
+
as an editor get the tombstone name. `suggestions.erasePerson` does the same for their suggestions
|
|
72
|
+
(it drops the summary, and the change of a pending or rejected one, which can then no longer be accepted)
|
|
73
|
+
and their decisions. No principal can have the name `erased`.
|
|
74
|
+
|
|
75
|
+
A self-export or self-erase covers only the actor's own kind: a message that `human:ada` wrote and one that
|
|
76
|
+
`user_token:ada` wrote are two principals, and `unread` does not count the other kind. Ask a moderator for
|
|
77
|
+
both. Erase rewrites a status record, so an erase and a real status change made at once on two replicas keep
|
|
78
|
+
only one of them. An accepted suggestion keeps its `change`, which is still the author's text.
|
|
79
|
+
|
|
80
|
+
Only the author can edit or delete a message, and a person can export or erase their own data: the same kind
|
|
81
|
+
and name. A principal with the `comments:moderate` scope (`MODERATE_SCOPE`) can do it for anyone, and an
|
|
82
|
+
export or erase by a moderator covers every kind with that name. The host gives that scope to an owner or
|
|
83
|
+
admin. An edit made by a moderator records the moderator in `editedBy`. Nobody but a moderator can delete an
|
|
84
|
+
erased message or a thread that holds one.
|
|
85
|
+
|
|
86
|
+
Replicas that edit one message at once leave a copy each. When the copies agree on the author kind and time,
|
|
87
|
+
the read keeps the erased copy, or else the latest edit. An edit and a delete made at once keep the edit.
|
|
88
|
+
|
|
56
89
|
## Anchors for plain text
|
|
57
90
|
|
|
58
91
|
`agentic-comments/anchor` pins a range of a plain string. It needs no Yjs and no framework.
|
|
@@ -173,8 +206,8 @@ AGENTIC_COMMENTS_AS=agent_of_human:scribe npx agentic-comments suggest --text-qu
|
|
|
173
206
|
npx agentic-comments accept <id>
|
|
174
207
|
```
|
|
175
208
|
|
|
176
|
-
- Commands: `init`, `list`, `show`, `comment`, `reply`, `resolve`, `reopen`, `relocate`, `
|
|
177
|
-
`suggest`, `accept`, `reject`, `mcp`. Run `npx agentic-comments --help` for flags.
|
|
209
|
+
- Commands: `init`, `list`, `show`, `comment`, `reply`, `resolve`, `reopen`, `relocate`, `edit-message`,
|
|
210
|
+
`delete-message`, `delete-thread`, `export-person`, `erase-person`, `suggestions`, `suggest`, `accept`, `reject`, `mcp`. Run `npx agentic-comments --help` for flags.
|
|
178
211
|
- Every result is JSON. An error is JSON on stderr with a `code`, and the exit code is 1. A usage error
|
|
179
212
|
exits 2.
|
|
180
213
|
- The principal comes from `--as` or `AGENTIC_COMMENTS_AS`, as `kind:name`. The name starts with A-Z a-z 0-9 `_` `-`, then uses A-Z a-z 0-9 `.` `_` `-`, and is at most 128 characters. Scopes come only from
|
|
@@ -185,8 +218,10 @@ npx agentic-comments accept <id>
|
|
|
185
218
|
the MCP server on stdio. The operator fixes the principal and the
|
|
186
219
|
scopes when the server starts. No tool takes an author or a scope. A call with any key a tool does not list is refused, so an agent
|
|
187
220
|
cannot pick its own kind or rights. Tools: `list_threads`, `get_thread`, `add_comment`, `reply_to_thread`, `resolve_thread`,
|
|
188
|
-
`reopen_thread`, `relocate_thread`, `
|
|
189
|
-
`reject_suggestion`.
|
|
221
|
+
`reopen_thread`, `relocate_thread`, `edit_message`, `delete_message`, `delete_thread`, `export_person`,
|
|
222
|
+
`erase_person`, `list_suggestions`, `suggest_change`, `accept_suggestion`, `reject_suggestion`.
|
|
223
|
+
Another person's message, thread, or data needs `comments:moderate` in `AGENTIC_COMMENTS_SCOPES`. The delete
|
|
224
|
+
tools and `erase_person` carry the destructive hint.
|
|
190
225
|
- A text change is `{"replace": "new text"}`. A change for a cell, slide, or canvas anchor is
|
|
191
226
|
`{"value": <JSON>}`.
|
|
192
227
|
- From code: `import { FileStore, CommentsService, operatorActor } from "agentic-comments/local"`.
|
|
@@ -197,17 +232,26 @@ npx agentic-comments accept <id>
|
|
|
197
232
|
document in a D1 database through the `Store` port of `agentic-identity`. It checks every request with the
|
|
198
233
|
`Auth` port, so the host picks who the caller is and no request carries an author.
|
|
199
234
|
|
|
200
|
-
|
|
201
|
-
`
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
By default the Worker signs requests in with `agentic-identity`. Principals and their credentials live in the same
|
|
236
|
+
`Store` as the documents (`createStoreAuth`). A request sends `Authorization: Bearer <key>`. The scopes of the
|
|
237
|
+
principal decide what it may do (see the table below). A request with no valid credential gets `401`. A valid
|
|
238
|
+
credential whose scopes fall short gets `403`. A store failure while the credential is read gets `503`, never `401`.
|
|
239
|
+
Issue credentials with `issueCredential` from `agentic-identity`.
|
|
204
240
|
A request whose `Authorization` header is not `Bearer` followed by 1 to 512 bytes of A-Z a-z 0-9 `_` `-` gets `401`, before any custom `Auth` runs.
|
|
205
241
|
A write with no `Authorization` header (a session cookie, for example) gets `403` unless its `Origin` is in the `origins`
|
|
206
242
|
option of `createApp`. The default lists none.
|
|
207
243
|
|
|
244
|
+
**Static token list (dev fallback).** `AGENTIC_COMMENTS_TOKENS` is read only when `AGENTIC_COMMENTS_UNSAFE_STATIC_TOKENS=1`.
|
|
245
|
+
Identity credentials are tried first, then the list. A Worker that sets the list without the switch answers every
|
|
246
|
+
`/api/v1` request with `503 NOT_CONFIGURED`. The list is JSON from a bearer token to `kind:name`, or to
|
|
247
|
+
`{ "as": "kind:name", "scopes": [...] }`. A token is 32 to 512 bytes, from A-Z a-z 0-9 `_` `-`.
|
|
248
|
+
A name starts with A-Z a-z 0-9 `_` `-`, then uses A-Z a-z 0-9 `.` `_` `-`, and is at most 128 characters.
|
|
249
|
+
One bad entry refuses the whole table. A token with no scopes holds `comments:comment`. Never use the list in production.
|
|
250
|
+
|
|
208
251
|
```sh
|
|
209
252
|
wrangler secret put AGENTIC_COMMENTS_TOKENS
|
|
210
253
|
# {"<openssl rand -hex 32>": {"as": "human:ada", "scopes": ["comments:write"]}, "<another>": "agent_of_human:scribe"}
|
|
254
|
+
wrangler secret put AGENTIC_COMMENTS_UNSAFE_STATIC_TOKENS # the value is 1
|
|
211
255
|
```
|
|
212
256
|
|
|
213
257
|
Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document route fails with `500 MISCONFIGURED`. Use `unknown` with
|
|
@@ -216,9 +260,13 @@ Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document ro
|
|
|
216
260
|
| Scope | Allows |
|
|
217
261
|
| --- | --- |
|
|
218
262
|
| `comments:read` | Read documents, threads, and suggestions. |
|
|
219
|
-
| `comments:comment` | Also add threads, reply, resolve, reopen, and suggest. This is the default for a token with no scopes. |
|
|
263
|
+
| `comments:comment` | Also add threads, reply, resolve, reopen, and suggest. This is the default for a static-list token with no scopes. |
|
|
220
264
|
| `comments:write` | Also create documents, relocate, and accept or reject. |
|
|
221
|
-
| `comments:admin` | Also let an agent accept or reject (the `comments:accept` right). |
|
|
265
|
+
| `comments:admin` | Also let an agent accept or reject (the `comments:accept` right), and edit, delete, export, or erase what another person wrote (the `comments:moderate` right). |
|
|
266
|
+
|
|
267
|
+
With `comments:comment` a principal edits and deletes its own messages, deletes a thread when every message is
|
|
268
|
+
its own, and erases its own data. With `comments:read` it can export its own data, and it cannot change anything.
|
|
269
|
+
Export of another person's data and any change to another person's words need `comments:admin`.
|
|
222
270
|
|
|
223
271
|
| Route | Action |
|
|
224
272
|
| --- | --- |
|
|
@@ -229,6 +277,10 @@ Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document ro
|
|
|
229
277
|
| `GET /api/v1/docs/:doc/threads/:id` | Read one thread. |
|
|
230
278
|
| `POST /api/v1/docs/:doc/threads/:id/replies` | Reply. |
|
|
231
279
|
| `POST .../threads/:id/resolve`, `reopen`, `relocate` | Change a thread. |
|
|
280
|
+
| `PATCH`, `DELETE .../threads/:id/messages/:message` | Edit (`{body, mentions?}`) or delete a message. |
|
|
281
|
+
| `DELETE .../threads/:id` | Delete a thread. |
|
|
282
|
+
| `GET .../people/:name/export` | Export what a person wrote in this document (`comments:read` for yourself, `comments:admin` for anyone). |
|
|
283
|
+
| `DELETE .../people/:name` | Erase what a person wrote in this document: replace it with a tombstone (`comments:comment` for yourself, `comments:admin` for anyone). |
|
|
232
284
|
| `GET`, `POST /api/v1/docs/:doc/suggestions` | List or suggest. |
|
|
233
285
|
| `POST .../suggestions/:id/accept`, `reject` | Decide. An agent never decides its own suggestion. |
|
|
234
286
|
|
|
@@ -261,7 +313,7 @@ try {
|
|
|
261
313
|
```
|
|
262
314
|
|
|
263
315
|
- The methods match the routes: `me`, `createDocument`, `getDocument`, `listThreads`, `getThread`, `addThread`, `reply`,
|
|
264
|
-
`resolve`, `reopen`, `relocate`, `listSuggestions`, `suggest`, `accept`, `reject`. Each takes an optional `{ signal }`.
|
|
316
|
+
`resolve`, `reopen`, `relocate`, `editMessage`, `deleteMessage`, `deleteThread`, `exportPerson`, `erasePerson`, `listSuggestions`, `suggest`, `accept`, `reject`. Each takes an optional `{ signal }`.
|
|
265
317
|
- `baseUrl` is the Worker URL, `http` or `https`, with no query, hash, or credentials.
|
|
266
318
|
- The client never sends an author. The Worker stamps one from the token.
|
|
267
319
|
- A failed call throws `CommentsApiError`. `code` is the API error code, or `UNKNOWN` when the reply is not an API
|
|
@@ -280,7 +332,7 @@ To deploy your own copy, log in with `wrangler login` or set `CLOUDFLARE_API_TOK
|
|
|
280
332
|
|
|
281
333
|
```sh
|
|
282
334
|
npx wrangler d1 create agentic-comments-demo --location weur # once. Put the id in wrangler.demo.jsonc.
|
|
283
|
-
npx wrangler secret put AGENTIC_COMMENTS_TOKENS --config wrangler.demo.jsonc #
|
|
335
|
+
npx wrangler secret put AGENTIC_COMMENTS_TOKENS --config wrangler.demo.jsonc # the demo uses the static list. See HTTP API
|
|
284
336
|
npm run deploy:demo
|
|
285
337
|
```
|
|
286
338
|
|
|
@@ -361,7 +413,8 @@ const stop = notifications.subscribe(async (event) => {
|
|
|
361
413
|
- Events come from the data and not from the calls, so a status change that two replicas make at once gives
|
|
362
414
|
each replica its own event, and a thread that arrives whole gives events for its current state only.
|
|
363
415
|
- Every replica of a document must register the same custom anchor kinds.
|
|
364
|
-
-
|
|
416
|
+
- Export and erase work on one document. A person who wrote in many documents needs one call for each.
|
|
417
|
+
- A repeated message id hides every copy, unless every copy is an edit: then the read keeps one. A peer that appends a copy of a thread's only message makes
|
|
365
418
|
the thread unreadable.
|
|
366
419
|
- The quoted text of a text anchor is not checked when a thread is read. A peer can store any text there.
|
|
367
420
|
`textAnchor` refuses a quote whose JSON form passes 4,096 characters. Quotes with many newlines or
|
|
@@ -372,6 +425,14 @@ const stop = notifications.subscribe(async (event) => {
|
|
|
372
425
|
- A body mentions anyone named as `@handle`. Put `@` text in backticks to avoid that. Any two backticks in a body pair up, so a loose backtick
|
|
373
426
|
can hide a real mention that sits between two of them. A scoped
|
|
374
427
|
package name such as `@scope/pkg` is not a mention.
|
|
428
|
+
- Erase does not touch a name that another person typed in their own message.
|
|
429
|
+
- Replicas that delete two messages of one thread at once can leave a thread with no message. Only a moderator
|
|
430
|
+
can delete it. A reply made on another replica at the same time as `deleteThread` is lost with the thread.
|
|
431
|
+
- After you merge replicas, run `exportPerson`. When `unread` is not 0, a copy that the merge hid or a bad entry
|
|
432
|
+
still holds the person's name: run `erasePerson` again. An entry whose author is not valid cannot be tied to
|
|
433
|
+
a person, so export and erase skip it. An edit or an erase makes no notification event, also when two replicas edit one message at once. A delete works on a resolved thread, and an edit does not.
|
|
434
|
+
- Yjs drops the old text of a deleted or replaced message only when the document has garbage collection on
|
|
435
|
+
(the default). A copy that you saved before an erase or delete still holds it.
|
|
375
436
|
- Mentions are stored in lowercase, with no leading `@`. A handle holds up to 64 letters, digits, marks,
|
|
376
437
|
and `. _ -`. A longer handle in a body is refused.
|
|
377
438
|
|
package/SKILL.md
CHANGED
|
@@ -25,8 +25,12 @@ Check `thread.anchorState`. An `orphaned` thread has lost its content. Read it,
|
|
|
25
25
|
Suggest a change with `suggestions.add({ anchor, change, summary }, actor)`. Accept needs a person or
|
|
26
26
|
the `comments:accept` scope. You never accept or reject your own suggestion.
|
|
27
27
|
|
|
28
|
+
Change your own words with `edit_message`, `delete_message`, and `delete_thread`. Another person's messages need the
|
|
29
|
+
`comments:moderate` scope. `export_person` and `erase_person` read or tombstone what one person wrote (your own, or
|
|
30
|
+
anyone's with that scope). Check `unread` in an export.
|
|
31
|
+
|
|
28
32
|
Over MCP, call `list_threads`, `add_comment`, `reply_to_thread`, `resolve_thread`, `suggest_change`,
|
|
29
|
-
`accept_suggestion`,
|
|
33
|
+
`accept_suggestion`, `reject_suggestion`, and the tools above. You never pass an author or a scope.
|
|
30
34
|
|
|
31
35
|
## Rules
|
|
32
36
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VERSION
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ES3NZ7SX.js";
|
|
4
4
|
import {
|
|
5
5
|
CommentsService
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-T77DPNIV.js";
|
|
7
7
|
import {
|
|
8
8
|
CommentsError
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-VAVKBAHM.js";
|
|
10
10
|
|
|
11
11
|
// src/mcp.ts
|
|
12
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -21,6 +21,8 @@ var anchor = z.discriminatedUnion("type", [
|
|
|
21
21
|
]).describe("Where the thread or suggestion points.");
|
|
22
22
|
var id = z.string().describe("The thread or suggestion id.");
|
|
23
23
|
var body = z.string().describe("The message text. Write @handle to mention someone.");
|
|
24
|
+
var messageId = z.string().describe("The message id, from get_thread.");
|
|
25
|
+
var person = z.string().describe("The name of the person, as shown on their messages.");
|
|
24
26
|
var mentions = z.array(z.string()).optional().describe("Extra handles to mention.");
|
|
25
27
|
function ok(value) {
|
|
26
28
|
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
@@ -33,10 +35,10 @@ function createMcpServer(store, actor) {
|
|
|
33
35
|
const service = new CommentsService(store, actor);
|
|
34
36
|
const server = new McpServer({ name: "agentic-comments", version: VERSION });
|
|
35
37
|
const tools = /* @__PURE__ */ new Map();
|
|
36
|
-
const tool = (name, description, shape, readOnly, run) => {
|
|
38
|
+
const tool = (name, description, shape, readOnly, run, destructive = false) => {
|
|
37
39
|
const strict = z.object(shape).strict();
|
|
38
40
|
tools.set(name, { strict, run });
|
|
39
|
-
server.registerTool(name, { description, inputSchema: strict, annotations: { readOnlyHint: readOnly, destructiveHint:
|
|
41
|
+
server.registerTool(name, { description, inputSchema: strict, annotations: { readOnlyHint: readOnly, destructiveHint: destructive, openWorldHint: false } }, (() => {
|
|
40
42
|
throw new Error("calls are handled by the server");
|
|
41
43
|
}));
|
|
42
44
|
};
|
|
@@ -48,6 +50,11 @@ function createMcpServer(store, actor) {
|
|
|
48
50
|
tool("resolve_thread", "Resolve a thread.", { id }, false, (i) => service.resolve(i.id));
|
|
49
51
|
tool("reopen_thread", "Reopen a resolved thread.", { id }, false, (i) => service.reopen(i.id));
|
|
50
52
|
tool("relocate_thread", "Move a text anchor to its quote when the quote occurs exactly once.", { id }, false, (i) => service.relocate(i.id));
|
|
53
|
+
tool("edit_message", "Change the body of a message you wrote. Needs the moderate scope for another person's message.", { id, messageId, body, mentions }, false, (i) => service.editMessage(i.id, i.messageId, { body: i.body, mentions: i.mentions }), true);
|
|
54
|
+
tool("delete_message", "Delete a message you wrote. The last message of a thread stays: delete the thread instead.", { id, messageId }, false, (i) => service.deleteMessage(i.id, i.messageId), true);
|
|
55
|
+
tool("delete_thread", "Delete a thread. Works while every message is yours. Otherwise it needs the moderate scope.", { id }, false, (i) => service.deleteThread(i.id), true);
|
|
56
|
+
tool("export_person", "Export the messages, status changes, and suggestions one person wrote. Your own, or any with the moderate scope.", { person }, true, (i) => service.exportPerson(i.person));
|
|
57
|
+
tool("erase_person", "Replace everything one person wrote with a tombstone. The thread shape stays. Your own, or any with the moderate scope.", { person }, false, (i) => service.erasePerson(i.person), true);
|
|
51
58
|
tool("list_suggestions", "List suggested changes.", { status: z.enum(["pending", "accepted", "rejected"]).optional(), anchorState: state }, true, (i) => service.suggestions(i));
|
|
52
59
|
tool(
|
|
53
60
|
"suggest_change",
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
textAnchor,
|
|
8
8
|
textOf,
|
|
9
9
|
textRange
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-VAVKBAHM.js";
|
|
11
11
|
|
|
12
12
|
// src/local-host.ts
|
|
13
13
|
import * as Y from "yjs";
|
|
@@ -144,6 +144,26 @@ var CommentsService = class {
|
|
|
144
144
|
relocate(threadId) {
|
|
145
145
|
return this.write(({ comments }) => comments.relocate(threadId, this.actor));
|
|
146
146
|
}
|
|
147
|
+
editMessage(threadId, messageId, input) {
|
|
148
|
+
return this.write(({ comments }) => comments.editMessage(threadId, messageId, exact(input, ["body", "mentions"], "an edit"), this.actor));
|
|
149
|
+
}
|
|
150
|
+
deleteMessage(threadId, messageId) {
|
|
151
|
+
return this.write(({ comments }) => comments.deleteMessage(threadId, messageId, this.actor));
|
|
152
|
+
}
|
|
153
|
+
deleteThread(threadId) {
|
|
154
|
+
return this.write(({ comments }) => comments.deleteThread(threadId, this.actor));
|
|
155
|
+
}
|
|
156
|
+
/** Everything the store holds that this person wrote: messages, status changes, and suggestions. */
|
|
157
|
+
exportPerson(person) {
|
|
158
|
+
return this.read(({ comments, suggestions }) => ({ ...comments.exportPerson(person, this.actor), suggestions: suggestions.exportPerson(person, this.actor) }));
|
|
159
|
+
}
|
|
160
|
+
/** Tombstone everything this person wrote. Both parts are checked before either is written. */
|
|
161
|
+
erasePerson(person) {
|
|
162
|
+
return this.write(({ comments, suggestions }) => {
|
|
163
|
+
comments.checkPerson(person, this.actor);
|
|
164
|
+
return { ...comments.erasePerson(person, this.actor), ...suggestions.erasePerson(person, this.actor) };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
147
167
|
suggestions(filter = {}) {
|
|
148
168
|
const given = exact(filter, ["status", "anchorState"], "a filter");
|
|
149
169
|
return this.read(({ suggestions }) => suggestions.list(given));
|
|
@@ -115,6 +115,7 @@ function onlyOccurrence(type, quote) {
|
|
|
115
115
|
|
|
116
116
|
// src/principal.ts
|
|
117
117
|
import { PRINCIPAL_KINDS } from "agentic-identity";
|
|
118
|
+
var ERASED_NAME = "erased";
|
|
118
119
|
var MAX_NAME = 128;
|
|
119
120
|
var issued = /* @__PURE__ */ new WeakSet();
|
|
120
121
|
var Actor = class _Actor {
|
|
@@ -131,6 +132,7 @@ var Actor = class _Actor {
|
|
|
131
132
|
const author = readAuthor(input);
|
|
132
133
|
const given = author ? input.scopes : void 0;
|
|
133
134
|
const scopes = given === void 0 ? [] : Array.isArray(given) ? Array.from(given) : void 0;
|
|
135
|
+
if (author?.name === ERASED_NAME) throw new CommentsError("INVALID_AUTHOR", `the name "${ERASED_NAME}" is kept for erased authors`);
|
|
134
136
|
if (!author || !scopes || !scopes.every((s) => typeof s === "string" && s !== "")) {
|
|
135
137
|
throw new CommentsError("INVALID_AUTHOR", `an author needs a kind (${PRINCIPAL_KINDS.join(", ")}), a name, and scopes that are a list of text`);
|
|
136
138
|
}
|
|
@@ -187,7 +189,7 @@ var MAX_SUMMARY = 1e3;
|
|
|
187
189
|
var SUGGESTIONS = "comments.suggestions";
|
|
188
190
|
var APPLIED = "comments.suggestion-applied";
|
|
189
191
|
var REJECTED = "comments.suggestion-rejected";
|
|
190
|
-
var decidesOwn = (author, decider) => author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
|
|
192
|
+
var decidesOwn = (author, decider) => author.name !== ERASED_NAME && author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
|
|
191
193
|
var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
|
|
192
194
|
var thenable = (value) => typeof value?.then === "function";
|
|
193
195
|
var Suggestions = class {
|
|
@@ -308,6 +310,66 @@ var Suggestions = class {
|
|
|
308
310
|
if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
|
|
309
311
|
return { id: id2, fields: this.map.get(id2) };
|
|
310
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* The suggestions a person wrote, and the decisions they made, in this document. `unread` counts stored
|
|
315
|
+
* suggestions and decision records with their name that this export cannot show: a suggestion that cannot
|
|
316
|
+
* be read, a decision that lost a merge, a refused decision, or a decision with no suggestion. When it is not
|
|
317
|
+
* 0 the export is not complete. A person may export their own data.
|
|
318
|
+
*/
|
|
319
|
+
exportPerson(person, actor) {
|
|
320
|
+
const { match } = this.comments.checkPerson(person, actor);
|
|
321
|
+
const all = this.list();
|
|
322
|
+
const authored = all.filter((s) => match(s.author));
|
|
323
|
+
const decided = all.flatMap((s) => s.decidedBy && s.decidedAt && s.status !== "pending" && match(s.decidedBy) ? [{ id: s.id, status: s.status, at: s.decidedAt }] : []);
|
|
324
|
+
let found = 0;
|
|
325
|
+
for (const fields of this.map.values()) if (fields instanceof Y2.Map && this.named(fields.get("author"), match)) found++;
|
|
326
|
+
for (const map of [this.applied, this.rejected]) for (const raw of map.values()) if (this.named(raw?.by, match)) found++;
|
|
327
|
+
return { authored, decided, unread: Math.max(0, found - authored.length - decided.length) };
|
|
328
|
+
}
|
|
329
|
+
named(value, match) {
|
|
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
|
+
}
|
|
311
373
|
/** Propose a change. Anyone can suggest. The host stamps the author. */
|
|
312
374
|
add(input, actor) {
|
|
313
375
|
const who = assertActor(actor);
|
|
@@ -335,6 +397,7 @@ var Suggestions = class {
|
|
|
335
397
|
mayDecide(who, s, verb) {
|
|
336
398
|
const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
|
|
337
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");
|
|
338
401
|
}
|
|
339
402
|
/**
|
|
340
403
|
* Apply the change as the accepting author and record the decision, in one step. Accepting an
|
|
@@ -461,6 +524,8 @@ var Suggestions = class {
|
|
|
461
524
|
|
|
462
525
|
// src/threads.ts
|
|
463
526
|
import * as Y3 from "yjs";
|
|
527
|
+
var MODERATE_SCOPE = "comments:moderate";
|
|
528
|
+
var ERASED_BODY = "[erased]";
|
|
464
529
|
var THREADS = "comments.threads";
|
|
465
530
|
var MAX_BODY = 1e4;
|
|
466
531
|
var MAX_MENTIONS = 50;
|
|
@@ -605,7 +670,20 @@ var Comments = class {
|
|
|
605
670
|
throw new Error("a message has no id, ISO time, valid author, or body");
|
|
606
671
|
}
|
|
607
672
|
if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
|
|
608
|
-
|
|
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
|
+
};
|
|
609
687
|
}
|
|
610
688
|
/** A thread with a bad anchor or status is malformed. A bad message is skipped and named in `issues`. */
|
|
611
689
|
read(id2, fields) {
|
|
@@ -622,11 +700,32 @@ var Comments = class {
|
|
|
622
700
|
const raw = list.toArray();
|
|
623
701
|
const counts = /* @__PURE__ */ new Map();
|
|
624
702
|
for (const m of raw) counts.set(m?.id, (counts.get(m?.id) ?? 0) + 1);
|
|
703
|
+
const parsed = raw.map((entry) => {
|
|
704
|
+
try {
|
|
705
|
+
return { message: this.readMessage(entry) };
|
|
706
|
+
} catch (error) {
|
|
707
|
+
return { error };
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
const copies = (id3) => parsed.flatMap((p) => p.message?.id === id3 ? [p.message] : []);
|
|
625
711
|
let past = 0;
|
|
626
|
-
|
|
712
|
+
let superseded = 0;
|
|
713
|
+
parsed.forEach((entry, index) => {
|
|
627
714
|
try {
|
|
628
|
-
|
|
629
|
-
|
|
715
|
+
if (entry.error) throw entry.error;
|
|
716
|
+
const message = entry.message;
|
|
717
|
+
if (counts.get(message.id) > 1) {
|
|
718
|
+
const same = copies(message.id);
|
|
719
|
+
const names = new Set(same.filter((c) => !c.erased).map((c) => c.author.name));
|
|
720
|
+
const agree = same.every((c) => c.editedAt !== void 0 && c.at === message.at && c.author.kind === message.author.kind) && names.size <= 1;
|
|
721
|
+
if (same.length !== counts.get(message.id) || !agree) throw new Error("a message id repeats");
|
|
722
|
+
const rank = (c) => `${c.erased ? 1 : 0}${c.editedAt}${JSON.stringify(c)}`;
|
|
723
|
+
const best = same.reduce((a, b) => rank(b) > rank(a) ? b : a);
|
|
724
|
+
if (best !== message) {
|
|
725
|
+
superseded++;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
630
729
|
if (messages.length >= READ_LIMIT) past++;
|
|
631
730
|
else messages.push(message);
|
|
632
731
|
} catch (error) {
|
|
@@ -636,7 +735,7 @@ var Comments = class {
|
|
|
636
735
|
if (past > 0) issues.push(`${past} valid messages past the read limit are not shown`);
|
|
637
736
|
if (messages.length === 0) throw new Error("the thread has no valid message");
|
|
638
737
|
const anchor = this.parseAnchor(fields.get("anchor"));
|
|
639
|
-
const thread = { id: id2, anchor, anchorState: this.anchorState(anchor), status: state.status, statusBy: by, statusAt: state.at, messages, skipped: raw.length - messages.length };
|
|
738
|
+
const thread = { id: id2, anchor, anchorState: this.anchorState(anchor), status: state.status, statusBy: by, statusAt: state.at, messages, skipped: raw.length - messages.length - superseded };
|
|
640
739
|
return { thread, issues };
|
|
641
740
|
} catch (error) {
|
|
642
741
|
throw new CommentsError("CORRUPT", `thread ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
|
|
@@ -715,6 +814,176 @@ var Comments = class {
|
|
|
715
814
|
reopen(threadId, actor) {
|
|
716
815
|
return this.setStatus(threadId, "open", actor);
|
|
717
816
|
}
|
|
817
|
+
/** A principal may change what it wrote. A principal with the moderate scope may change what anyone wrote. A tombstone is nobody's. */
|
|
818
|
+
mayChange(who, message) {
|
|
819
|
+
return who.scopes.includes(MODERATE_SCOPE) || !message.erased && who.kind === message.author.kind && who.name === message.author.name;
|
|
820
|
+
}
|
|
821
|
+
forbid(who, what) {
|
|
822
|
+
throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot ${what}: only its author, or a principal with the ${MODERATE_SCOPE} scope, can`);
|
|
823
|
+
}
|
|
824
|
+
/** Replace every stored copy of a message with `next`, or remove it when `next` is undefined. */
|
|
825
|
+
rewrite(fields, messageId, next) {
|
|
826
|
+
const list = fields.get("messages");
|
|
827
|
+
const at = list.toArray().flatMap((m, i) => m?.id === messageId ? [i] : []);
|
|
828
|
+
this.ydoc.transact(() => {
|
|
829
|
+
for (const i of [...at].reverse()) list.delete(i, 1);
|
|
830
|
+
if (next) list.insert(at[0], [copy(next)]);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
find(threadId, messageId) {
|
|
834
|
+
const { id: id2, fields } = this.fields(threadId);
|
|
835
|
+
const { thread } = this.read(id2, fields);
|
|
836
|
+
const message = thread.messages.find((m) => m.id === messageId);
|
|
837
|
+
if (!message) throw new CommentsError("NO_MESSAGE", `thread ${shown(id2)} has no message with id ${shown(messageId)}`);
|
|
838
|
+
return { id: id2, fields, thread, message };
|
|
839
|
+
}
|
|
840
|
+
/** Change the body of a message. Its author and time stay, and it records who edited it and when. */
|
|
841
|
+
editMessage(threadId, messageId, input, actor) {
|
|
842
|
+
const who = assertActor(actor);
|
|
843
|
+
const given = exact(input, ["body", "mentions"], "an edit");
|
|
844
|
+
const text = body(given.body);
|
|
845
|
+
const { id: id2, fields, thread, message } = this.find(threadId, messageId);
|
|
846
|
+
if (message.erased) throw new CommentsError("FORBIDDEN", "an erased message cannot be edited");
|
|
847
|
+
if (!this.mayChange(who, message)) this.forbid(who, "edit this message");
|
|
848
|
+
if (thread.status === "resolved") throw new CommentsError("THREAD_RESOLVED", `thread ${shown(id2)} is resolved; reopen it to edit`);
|
|
849
|
+
const next = this.readEdited(message, text, given.mentions, who);
|
|
850
|
+
this.rewrite(fields, message.id, next);
|
|
851
|
+
return this.find(id2, message.id).message;
|
|
852
|
+
}
|
|
853
|
+
readEdited(message, text, explicit, who) {
|
|
854
|
+
const edited = { ...message, body: text, mentions: mentionsOf(text, explicit), editedAt: this.time(), editedBy: { kind: who.kind, name: who.name } };
|
|
855
|
+
try {
|
|
856
|
+
return this.readMessage(copy(edited));
|
|
857
|
+
} catch (error) {
|
|
858
|
+
return invalid(`the message cannot be stored: ${error.message}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Remove one message, also from a resolved thread. The last message of a thread stays: delete the thread instead. Replicas that delete
|
|
863
|
+
* two messages at once can leave a thread with none. A moderator can still delete that thread.
|
|
864
|
+
*/
|
|
865
|
+
deleteMessage(threadId, messageId, actor) {
|
|
866
|
+
const who = assertActor(actor);
|
|
867
|
+
const { id: id2, fields, thread, message } = this.find(threadId, messageId);
|
|
868
|
+
if (!this.mayChange(who, message)) this.forbid(who, "delete this message");
|
|
869
|
+
if (thread.messages.length === 1) invalid(`thread ${shown(id2)} has only this message; delete the thread instead`);
|
|
870
|
+
this.rewrite(fields, message.id, void 0);
|
|
871
|
+
return { threadId: id2, messageId: message.id };
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Remove a thread and all its messages. Its author may do it when every message is theirs. Once someone
|
|
875
|
+
* else replied, a message is erased, or a stored message cannot be read, it needs the moderate scope. A
|
|
876
|
+
* moderator can also remove a thread that cannot be read at all.
|
|
877
|
+
*/
|
|
878
|
+
deleteThread(threadId, actor) {
|
|
879
|
+
const who = assertActor(actor);
|
|
880
|
+
const { id: id2, fields } = this.fields(threadId);
|
|
881
|
+
if (!who.scopes.includes(MODERATE_SCOPE)) {
|
|
882
|
+
const { thread } = this.read(id2, fields);
|
|
883
|
+
if (thread.skipped > 0 || !thread.messages.every((m) => this.mayChange(who, m))) this.forbid(who, "delete this thread");
|
|
884
|
+
}
|
|
885
|
+
this.map.delete(id2);
|
|
886
|
+
return { threadId: id2 };
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Check who an export or erase is for, and that the actor may. A person may act on their own data: the same
|
|
890
|
+
* kind and name. A principal with the moderate scope may act on anyone's, and then every kind with that name is
|
|
891
|
+
* included. Returns the name and a test for an author. Used by `Suggestions`.
|
|
892
|
+
*/
|
|
893
|
+
checkPerson(person, actor) {
|
|
894
|
+
const who = assertActor(actor);
|
|
895
|
+
if (typeof person !== "string" || person === "" || person !== person.trim() || person.length > 128) invalid("a person is a name of 1 to 128 characters");
|
|
896
|
+
if (person === ERASED_NAME) invalid("a tombstone is not a person");
|
|
897
|
+
const moderator = who.scopes.includes(MODERATE_SCOPE);
|
|
898
|
+
if (who.name !== person && !moderator) {
|
|
899
|
+
throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot read or erase the data of another person without the ${MODERATE_SCOPE} scope`);
|
|
900
|
+
}
|
|
901
|
+
return { name: person, match: (a) => a.name === person && (moderator || a.kind === who.kind) };
|
|
902
|
+
}
|
|
903
|
+
/** The raw stored threads, for the code that must see data a read hides. `list` is missing when the message field is bad. */
|
|
904
|
+
stored() {
|
|
905
|
+
return [...this.map.entries()].flatMap(([id2, fields]) => fields instanceof Y3.Map ? [{ id: id2, fields, list: fields.get("messages") instanceof Y3.Array ? fields.get("messages") : void 0 }] : []);
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* What a person wrote in this document: their messages, the status change of theirs that stands, and the
|
|
909
|
+
* edits they made to other people's messages. `unread` counts stored entries with their name that this read
|
|
910
|
+
* cannot show: a bad message, a thread that cannot be read, a message past the read limit, or a copy that
|
|
911
|
+
* lost a merge. When it is not 0 the export is not complete. A person may export their own data.
|
|
912
|
+
*/
|
|
913
|
+
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
|
+
}
|
|
932
|
+
let found = 0;
|
|
933
|
+
const named = (value) => {
|
|
934
|
+
const author = readAuthor(value);
|
|
935
|
+
return author !== void 0 && match(author);
|
|
936
|
+
};
|
|
937
|
+
for (const { fields, list } of this.stored()) {
|
|
938
|
+
for (const raw of list?.toArray() ?? []) if (named(raw?.author) || named(raw?.editedBy)) found++;
|
|
939
|
+
if (named(fields.get("state")?.by)) found++;
|
|
940
|
+
}
|
|
941
|
+
return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Replace every message a person wrote with a tombstone: the body becomes "[erased]", the author name
|
|
945
|
+
* becomes "erased", and the mentions go. The author kind stays, so machine-made work stays marked. Thread
|
|
946
|
+
* shape, order, and times stay. A status change of theirs, and their name as the editor of another person's
|
|
947
|
+
* message, get the tombstone name. It works on the stored entries, so it reaches a bad message, a message past
|
|
948
|
+
* the read limit, a thread that cannot be read, and every copy that a merge hid. Names inside other people's
|
|
949
|
+
* messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
|
|
950
|
+
*/
|
|
951
|
+
erasePerson(person, actor) {
|
|
952
|
+
const { match } = this.checkPerson(person, actor);
|
|
953
|
+
const tomb = (a) => ({ kind: a.kind, name: ERASED_NAME });
|
|
954
|
+
const out = { messages: 0, statuses: 0, edits: 0 };
|
|
955
|
+
this.ydoc.transact(() => {
|
|
956
|
+
for (const { fields, list } of this.stored()) {
|
|
957
|
+
const entries = list?.toArray() ?? [];
|
|
958
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
959
|
+
const raw = entries[i];
|
|
960
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
961
|
+
const author = readAuthor(raw.author);
|
|
962
|
+
const editor = readAuthor(raw.editedBy);
|
|
963
|
+
let next;
|
|
964
|
+
if (author && match(author)) {
|
|
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++;
|
|
972
|
+
}
|
|
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
|
+
}
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
return out;
|
|
986
|
+
}
|
|
718
987
|
get(threadId) {
|
|
719
988
|
const { id: id2, fields } = this.fields(threadId);
|
|
720
989
|
return this.read(id2, fields).thread;
|
|
@@ -755,6 +1024,7 @@ export {
|
|
|
755
1024
|
textAnchor,
|
|
756
1025
|
textRange,
|
|
757
1026
|
PRINCIPAL_KINDS,
|
|
1027
|
+
ERASED_NAME,
|
|
758
1028
|
Actor,
|
|
759
1029
|
stamp,
|
|
760
1030
|
readAuthor,
|
|
@@ -764,5 +1034,7 @@ export {
|
|
|
764
1034
|
validDate,
|
|
765
1035
|
ACCEPT_SCOPE,
|
|
766
1036
|
Suggestions,
|
|
1037
|
+
MODERATE_SCOPE,
|
|
1038
|
+
ERASED_BODY,
|
|
767
1039
|
Comments
|
|
768
1040
|
};
|