@kernhq/module-quire 0.10.4 → 0.10.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/client/mock.ts +339 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-quire",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Kern Quire: collaborative documents, spaces and page trees",
5
5
  "homepage": "https://github.com/KernAIO/module-quire#readme",
6
6
  "license": "AGPL-3.0-only",
@@ -1,9 +1,13 @@
1
1
  import type {
2
+ Comment,
3
+ CommentAnchor,
4
+ CommentThread,
2
5
  Database,
3
6
  DatabaseRef,
4
7
  Row as DatabaseRow,
5
8
  Page,
6
9
  PageNode,
10
+ PageVersion,
7
11
  Property,
8
12
  PropertyConfig,
9
13
  PropertyType,
@@ -113,8 +117,13 @@ export function createMockQuireApi() {
113
117
  })
114
118
 
115
119
  const pages: Row[] = [
116
- page(101, uid(1), 'Welcome', 'a'),
117
- page(102, uid(1), 'Working here', 'b'),
120
+ page(101, uid(1), 'Welcome', 'a', null, { publishedVersionId: uid(152) }),
121
+ // A page with a draft readers cannot see yet, so the banner above the body is reachable. The
122
+ // server only ever sets this on a page that has been published once, and neither does this.
123
+ page(102, uid(1), 'Working here', 'b', null, {
124
+ publishedVersionId: uid(153),
125
+ hasUnpublishedChanges: true,
126
+ }),
118
127
  page(103, uid(1), 'Your first week', 'ba', 102),
119
128
  page(104, uid(1), 'Time off', 'bb', 102),
120
129
  page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
@@ -237,6 +246,138 @@ export function createMockQuireApi() {
237
246
  pages.push(row)
238
247
  }
239
248
 
249
+ /**
250
+ * The people the app's own mock signs you in as.
251
+ *
252
+ * Written out rather than derived: this module cannot see the shell's mock, and a comment with
253
+ * nobody's id on it loses the delete control only its author is offered — so the margin would
254
+ * look complete and be missing the one action that belongs to you.
255
+ */
256
+ const ME = '01920000-0000-7000-8000-000000000001'
257
+ const COLLEAGUE = '01920000-0000-7000-8000-000000000002'
258
+
259
+ /**
260
+ * What the pages used to say, and what people have asked about them.
261
+ *
262
+ * Seeded rather than left empty. Version history and the comment margin are two of the three
263
+ * things a page screen is for, and until these existed the demo interface answered the history
264
+ * sheet with "The history could not be loaded" and never drew a margin at all — in exactly the
265
+ * environment used for demos and end-to-end tests. Two pages differ on purpose, so a page with a
266
+ * margin and a page without one are both reachable.
267
+ */
268
+ const version = (
269
+ n: number,
270
+ pageId: string,
271
+ kind: PageVersion['kind'],
272
+ label: string | null,
273
+ preview: string,
274
+ msAgo: number,
275
+ authorId: string,
276
+ ): PageVersion => ({
277
+ id: uid(n),
278
+ workspaceId: '' as PageVersion['workspaceId'],
279
+ pageId,
280
+ kind,
281
+ label,
282
+ preview,
283
+ // The server reports the length of the encoded document; the order of magnitude is all any
284
+ // screen does with it.
285
+ size: preview.length * 4,
286
+ authorId: authorId as PageVersion['authorId'],
287
+ createdAt: iso(msAgo),
288
+ // Which version readers are served is a property of the page, so it is worked out on the way
289
+ // out rather than stored here twice and left to disagree with itself.
290
+ published: false,
291
+ })
292
+
293
+ const versions: PageVersion[] = [
294
+ version(150, uid(101), 'publish', 'The first handbook', 'Welcome to Northstar.', 9e7, ME),
295
+ version(
296
+ 151,
297
+ uid(101),
298
+ 'auto',
299
+ null,
300
+ 'Welcome to Northstar. We are a small team and we write things down.',
301
+ 108e5,
302
+ COLLEAGUE,
303
+ ),
304
+ version(
305
+ 152,
306
+ uid(101),
307
+ 'publish',
308
+ null,
309
+ 'Welcome to Northstar. We are a small team and we write things down, so that nobody has to ask the same question twice.',
310
+ 72e5,
311
+ ME,
312
+ ),
313
+ version(153, uid(102), 'publish', null, 'How this team works, in one page.', 108e5, ME),
314
+ version(
315
+ 154,
316
+ uid(102),
317
+ 'auto',
318
+ null,
319
+ 'How this team works, in one page. Start with your first week.',
320
+ 36e5,
321
+ COLLEAGUE,
322
+ ),
323
+ ]
324
+
325
+ const richDoc = (text: string): Record<string, unknown> => ({
326
+ type: 'doc',
327
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
328
+ })
329
+
330
+ /** The same dumb walk the server does: whatever the editor produced, minus everything but text. */
331
+ const flatten = (body: unknown): string => {
332
+ const out: string[] = []
333
+ const walk = (node: unknown): void => {
334
+ if (!node || typeof node !== 'object') return
335
+ const n = node as { text?: unknown; content?: unknown[] }
336
+ if (typeof n.text === 'string') out.push(n.text)
337
+ if (Array.isArray(n.content)) for (const child of n.content) walk(child)
338
+ }
339
+ walk(body)
340
+ return out.join(' ').replace(/\s+/g, ' ').trim()
341
+ }
342
+
343
+ const comment = (
344
+ n: number,
345
+ pageId: string,
346
+ threadId: string,
347
+ parentId: string | null,
348
+ authorId: string,
349
+ text: string,
350
+ msAgo: number,
351
+ ): Comment => ({
352
+ id: uid(n),
353
+ workspaceId: '' as Comment['workspaceId'],
354
+ pageId,
355
+ parentId,
356
+ threadId,
357
+ authorId: authorId as Comment['authorId'],
358
+ body: richDoc(text),
359
+ bodyText: text,
360
+ mentionIds: [],
361
+ /*
362
+ * No anchor, and none of these quote anything.
363
+ *
364
+ * An anchor is a pair of Yjs relative positions into a document that only exists behind the
365
+ * collab service, and there is no collab service here — a made-up one would point at nothing
366
+ * and the editor would draw a highlight over the wrong words, which is worse than no highlight.
367
+ */
368
+ anchor: null,
369
+ quotedText: '',
370
+ resolvedAt: null,
371
+ resolvedBy: null,
372
+ editedAt: null,
373
+ createdAt: iso(msAgo),
374
+ })
375
+
376
+ const comments: Comment[] = [
377
+ comment(160, uid(101), uid(160), null, COLLEAGUE, 'Should this mention the on-call rota?', 72e5),
378
+ comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
379
+ ]
380
+
240
381
  let seq = 900
241
382
  const nextId = () => uid(++seq)
242
383
  const strip = ({ _order, ...p }: Row): Page => p
@@ -261,6 +402,43 @@ export function createMockQuireApi() {
261
402
 
262
403
  const notFound = (what: string) => Object.assign(new Error(`${what} not found`), { code: 'NOT_FOUND' })
263
404
 
405
+ const theVersion = (id: string): PageVersion => {
406
+ const found = versions.find((v) => v.id === id)
407
+ if (!found) throw notFound('Version')
408
+ return found
409
+ }
410
+ const theComment = (id: string): Comment => {
411
+ const found = comments.find((c) => c.id === id)
412
+ if (!found) throw notFound('Comment')
413
+ return found
414
+ }
415
+
416
+ /** Which version a reader is served, which the list and the sheet both have to agree about. */
417
+ const publishedOn = (pageId: string) => pages.find((p) => p.id === pageId)?.publishedVersionId ?? null
418
+ const asVersion = (v: PageVersion): PageVersion => ({ ...v, published: v.id === publishedOn(v.pageId) })
419
+
420
+ /**
421
+ * Write down what the page says now, exactly where the server takes a version.
422
+ *
423
+ * There is no document behind this, so the newest version's prose stands in for the live one —
424
+ * enough that restoring writes a new row saying what it restored, which is the behaviour the
425
+ * history sheet is judged on.
426
+ */
427
+ const capture = (pageId: string, kind: PageVersion['kind'], label: string | null, preview?: string) => {
428
+ const latest = versions.filter((v) => v.pageId === pageId).at(-1)
429
+ const taken = version(
430
+ ++seq,
431
+ pageId,
432
+ kind,
433
+ label,
434
+ preview ?? latest?.preview ?? found(pageId).title,
435
+ 0,
436
+ ME,
437
+ )
438
+ versions.push(taken)
439
+ return taken
440
+ }
441
+
264
442
  const theDatabase = (id: string): Database => {
265
443
  const db = databases.find((d) => d.id === id)
266
444
  if (!db) throw notFound('Database')
@@ -563,6 +741,165 @@ export function createMockQuireApi() {
563
741
  },
564
742
  },
565
743
 
744
+ versions: {
745
+ list: async ({ pageId, limit = 50 }: { pageId: string; limit?: number }) => ({
746
+ // Newest first, and the ids sort because they are minted in order — the same thing the
747
+ // server gets from ordering on a uuidv7.
748
+ items: versions
749
+ .filter((v) => v.pageId === pageId)
750
+ .sort((a, b) => (a.id < b.id ? 1 : -1))
751
+ .slice(0, limit)
752
+ .map(asVersion),
753
+ nextCursor: null,
754
+ }),
755
+
756
+ get: async ({ versionId }: { versionId: string }) => {
757
+ const found = theVersion(versionId)
758
+ return {
759
+ ...asVersion(found),
760
+ text: found.preview,
761
+ // The server renders the stored document; the escaping is the part worth keeping, since
762
+ // a screen hands this straight to a renderer.
763
+ html: `<p>${found.preview.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</p>`,
764
+ }
765
+ },
766
+
767
+ create: async ({ pageId, label = null }: { pageId: string; label?: string | null }) =>
768
+ asVersion(capture(pageId, 'auto', label)),
769
+
770
+ restore: async ({ versionId }: { versionId: string }) => {
771
+ const wanted = theVersion(versionId)
772
+ // The state about to be replaced is captured first, so restoring is itself undoable — the
773
+ // reason the sheet offers it without a confirmation.
774
+ capture(wanted.pageId, 'auto', null)
775
+ const restored = capture(wanted.pageId, 'restore', wanted.label, wanted.preview)
776
+ touch(found(wanted.pageId))
777
+ return asVersion(restored)
778
+ },
779
+ },
780
+
781
+ comments: {
782
+ list: async ({
783
+ pageId,
784
+ includeResolved = false,
785
+ }: {
786
+ pageId: string
787
+ includeResolved?: boolean
788
+ }): Promise<CommentThread[]> => {
789
+ const byThread = new Map<string, Comment[]>()
790
+ for (const c of comments.filter((c) => c.pageId === pageId)) {
791
+ byThread.set(c.threadId, [...(byThread.get(c.threadId) ?? []), c])
792
+ }
793
+ const threads: CommentThread[] = []
794
+ for (const [threadId, list] of byThread) {
795
+ const ordered = [...list].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
796
+ // A thread whose root was deleted while replies remain is still somebody's conversation,
797
+ // so the oldest remaining comment leads it — as it does on the server.
798
+ const lead = ordered.find((c) => c.id === threadId) ?? ordered[0]
799
+ if (!lead) continue
800
+ const resolved = Boolean(lead.resolvedAt)
801
+ if (resolved && !includeResolved) continue
802
+ threads.push({
803
+ id: threadId,
804
+ root: structuredClone(lead),
805
+ replies: ordered.filter((c) => c.id !== lead.id).map((c) => structuredClone(c)),
806
+ resolved,
807
+ })
808
+ }
809
+ return threads
810
+ },
811
+
812
+ create: async (input: {
813
+ pageId: string
814
+ body: Record<string, unknown>
815
+ anchor?: CommentAnchor | null
816
+ quotedText?: string
817
+ parentId?: string | null
818
+ }) => {
819
+ const parent = input.parentId ? theComment(input.parentId) : null
820
+ const id = nextId()
821
+ const made: Comment = {
822
+ id,
823
+ workspaceId: '' as Comment['workspaceId'],
824
+ pageId: input.pageId,
825
+ parentId: parent?.id ?? null,
826
+ // A reply belongs to the thread its parent is in, never to a thread of its own.
827
+ threadId: parent?.threadId ?? id,
828
+ authorId: ME as Comment['authorId'],
829
+ body: input.body,
830
+ bodyText: flatten(input.body),
831
+ mentionIds: [],
832
+ anchor: input.anchor ?? null,
833
+ quotedText: input.quotedText ?? '',
834
+ resolvedAt: null,
835
+ resolvedBy: null,
836
+ editedAt: null,
837
+ createdAt: new Date().toISOString(),
838
+ }
839
+ comments.push(made)
840
+ return structuredClone(made)
841
+ },
842
+
843
+ update: async ({ commentId, body }: { commentId: string; body: Record<string, unknown> }) => {
844
+ const found = theComment(commentId)
845
+ found.body = body
846
+ found.bodyText = flatten(body)
847
+ found.editedAt = new Date().toISOString()
848
+ return structuredClone(found)
849
+ },
850
+
851
+ remove: async ({ commentId }: { commentId: string }) => {
852
+ const found = theComment(commentId)
853
+ comments.splice(comments.indexOf(found), 1)
854
+ return { ok: true as const }
855
+ },
856
+
857
+ resolve: async ({ commentId, resolved = true }: { commentId: string; resolved?: boolean }) => {
858
+ const lead = theComment(commentId)
859
+ // Resolving is a property of the conversation, so it is written on the comment that leads
860
+ // it and read from there — never on each reply.
861
+ lead.resolvedAt = resolved ? new Date().toISOString() : null
862
+ lead.resolvedBy = resolved ? (ME as Comment['resolvedBy']) : null
863
+ const rest = comments.filter((c) => c.threadId === lead.threadId && c.id !== lead.id)
864
+ return {
865
+ id: lead.threadId,
866
+ root: structuredClone(lead),
867
+ replies: rest.map((c) => structuredClone(c)),
868
+ resolved,
869
+ }
870
+ },
871
+ },
872
+
873
+ publishing: {
874
+ publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
875
+ const row = found(pageId)
876
+ if (row.kind !== 'page')
877
+ throw Object.assign(new Error('Only a page has a published version; a live doc is always live'), {
878
+ code: 'BAD_REQUEST',
879
+ })
880
+ const taken = capture(pageId, 'publish', label)
881
+ row.publishedVersionId = taken.id
882
+ row.hasUnpublishedChanges = false
883
+ touch(row)
884
+ return strip(row)
885
+ },
886
+
887
+ revert: async ({ pageId }: { pageId: string }) => {
888
+ const row = found(pageId)
889
+ if (!row.publishedVersionId)
890
+ throw Object.assign(
891
+ new Error('This page has never been published, so there is nothing to go back to'),
892
+ { code: 'BAD_REQUEST' },
893
+ )
894
+ // The draft being discarded is kept, because discarding it should not be a way to lose an
895
+ // afternoon's writing with no way back.
896
+ capture(pageId, 'auto', null)
897
+ row.hasUnpublishedChanges = false
898
+ touch(row)
899
+ return strip(row)
900
+ },
901
+ },
902
+
566
903
  databases: {
567
904
  list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
568
905
  databases