@kernhq/module-quire 0.10.4 → 0.10.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-quire",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
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,
@@ -29,6 +33,21 @@ const iso = (msAgo = 0) => new Date(now - msAgo).toISOString()
29
33
 
30
34
  const uid = (n: number) => `01920000-0000-7000-8000-0000000${String(n).padStart(5, '0')}`
31
35
 
36
+ /**
37
+ * The people the app's own mock signs you in as.
38
+ *
39
+ * Written out rather than derived: this module cannot see the shell's mock, and a comment with
40
+ * nobody's id on it loses the delete control only its author is offered — so the margin would look
41
+ * complete and be missing the one action that belongs to you.
42
+ *
43
+ * Declared here, above every factory that reads them, rather than beside the comment seed that used
44
+ * to own them: the page factory now stamps an author too, and it is called while the module body is
45
+ * still running. A `const` further down the same scope is in its temporal dead zone at that point,
46
+ * so the whole mock throws on import and every Quire screen renders nothing.
47
+ */
48
+ const ME = '01920000-0000-7000-8000-000000000001'
49
+ const COLLEAGUE = '01920000-0000-7000-8000-000000000002'
50
+
32
51
  interface Row extends Page {
33
52
  /** the mock keeps trashed rows in the same list, as the server does */
34
53
  _order: string
@@ -102,8 +121,16 @@ export function createMockQuireApi() {
102
121
  coverUrl: null,
103
122
  publishedVersionId: null,
104
123
  hasUnpublishedChanges: false,
105
- createdBy: null,
106
- updatedBy: null,
124
+ /*
125
+ * A seeded page has an author, because the byline reads one.
126
+ *
127
+ * These were both null, so `PageView` took its "author unknown" fallback on every page in the
128
+ * demo — the one environment where the byline is ever looked at — and the named path it now
129
+ * has shipped without anything rendering it. `ME` is the demo's signed-in member, so it
130
+ * resolves through `core.workspaces.members.list` like a comment's author does.
131
+ */
132
+ createdBy: ME as Page['createdBy'],
133
+ updatedBy: ME as Page['updatedBy'],
107
134
  createdAt: iso(9e7),
108
135
  updatedAt: iso(36e5),
109
136
  archivedAt: null,
@@ -113,8 +140,13 @@ export function createMockQuireApi() {
113
140
  })
114
141
 
115
142
  const pages: Row[] = [
116
- page(101, uid(1), 'Welcome', 'a'),
117
- page(102, uid(1), 'Working here', 'b'),
143
+ page(101, uid(1), 'Welcome', 'a', null, { publishedVersionId: uid(152) }),
144
+ // A page with a draft readers cannot see yet, so the banner above the body is reachable. The
145
+ // server only ever sets this on a page that has been published once, and neither does this.
146
+ page(102, uid(1), 'Working here', 'b', null, {
147
+ publishedVersionId: uid(153),
148
+ hasUnpublishedChanges: true,
149
+ }),
118
150
  page(103, uid(1), 'Your first week', 'ba', 102),
119
151
  page(104, uid(1), 'Time off', 'bb', 102),
120
152
  page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
@@ -237,6 +269,128 @@ export function createMockQuireApi() {
237
269
  pages.push(row)
238
270
  }
239
271
 
272
+ /**
273
+ * What the pages used to say, and what people have asked about them.
274
+ *
275
+ * Seeded rather than left empty. Version history and the comment margin are two of the three
276
+ * things a page screen is for, and until these existed the demo interface answered the history
277
+ * sheet with "The history could not be loaded" and never drew a margin at all — in exactly the
278
+ * environment used for demos and end-to-end tests. Two pages differ on purpose, so a page with a
279
+ * margin and a page without one are both reachable.
280
+ */
281
+ const version = (
282
+ n: number,
283
+ pageId: string,
284
+ kind: PageVersion['kind'],
285
+ label: string | null,
286
+ preview: string,
287
+ msAgo: number,
288
+ authorId: string,
289
+ ): PageVersion => ({
290
+ id: uid(n),
291
+ workspaceId: '' as PageVersion['workspaceId'],
292
+ pageId,
293
+ kind,
294
+ label,
295
+ preview,
296
+ // The server reports the length of the encoded document; the order of magnitude is all any
297
+ // screen does with it.
298
+ size: preview.length * 4,
299
+ authorId: authorId as PageVersion['authorId'],
300
+ createdAt: iso(msAgo),
301
+ // Which version readers are served is a property of the page, so it is worked out on the way
302
+ // out rather than stored here twice and left to disagree with itself.
303
+ published: false,
304
+ })
305
+
306
+ const versions: PageVersion[] = [
307
+ version(150, uid(101), 'publish', 'The first handbook', 'Welcome to Northstar.', 9e7, ME),
308
+ version(
309
+ 151,
310
+ uid(101),
311
+ 'auto',
312
+ null,
313
+ 'Welcome to Northstar. We are a small team and we write things down.',
314
+ 108e5,
315
+ COLLEAGUE,
316
+ ),
317
+ version(
318
+ 152,
319
+ uid(101),
320
+ 'publish',
321
+ null,
322
+ 'Welcome to Northstar. We are a small team and we write things down, so that nobody has to ask the same question twice.',
323
+ 72e5,
324
+ ME,
325
+ ),
326
+ version(153, uid(102), 'publish', null, 'How this team works, in one page.', 108e5, ME),
327
+ version(
328
+ 154,
329
+ uid(102),
330
+ 'auto',
331
+ null,
332
+ 'How this team works, in one page. Start with your first week.',
333
+ 36e5,
334
+ COLLEAGUE,
335
+ ),
336
+ ]
337
+
338
+ const richDoc = (text: string): Record<string, unknown> => ({
339
+ type: 'doc',
340
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
341
+ })
342
+
343
+ /** The same dumb walk the server does: whatever the editor produced, minus everything but text. */
344
+ const flatten = (body: unknown): string => {
345
+ const out: string[] = []
346
+ const walk = (node: unknown): void => {
347
+ if (!node || typeof node !== 'object') return
348
+ const n = node as { text?: unknown; content?: unknown[] }
349
+ if (typeof n.text === 'string') out.push(n.text)
350
+ if (Array.isArray(n.content)) for (const child of n.content) walk(child)
351
+ }
352
+ walk(body)
353
+ return out.join(' ').replace(/\s+/g, ' ').trim()
354
+ }
355
+
356
+ const comment = (
357
+ n: number,
358
+ pageId: string,
359
+ threadId: string,
360
+ parentId: string | null,
361
+ authorId: string,
362
+ text: string,
363
+ msAgo: number,
364
+ ): Comment => ({
365
+ id: uid(n),
366
+ workspaceId: '' as Comment['workspaceId'],
367
+ pageId,
368
+ parentId,
369
+ threadId,
370
+ authorId: authorId as Comment['authorId'],
371
+ body: richDoc(text),
372
+ bodyText: text,
373
+ mentionIds: [],
374
+ /*
375
+ * No anchor, and none of these quote anything.
376
+ *
377
+ * An anchor is a pair of Yjs relative positions into a document that only exists behind the
378
+ * collab service, and there is no collab service here — a made-up one would point at nothing
379
+ * and the editor would draw a highlight over the wrong words, which is worse than no highlight.
380
+ */
381
+ anchor: null,
382
+ quotedText: '',
383
+ resolvedAt: null,
384
+ resolvedBy: null,
385
+ editedAt: null,
386
+ createdAt: iso(msAgo),
387
+ })
388
+
389
+ const comments: Comment[] = [
390
+ comment(160, uid(101), uid(160), null, COLLEAGUE, 'Should this mention the on-call rota?', 72e5),
391
+ comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
392
+ ]
393
+
240
394
  let seq = 900
241
395
  const nextId = () => uid(++seq)
242
396
  const strip = ({ _order, ...p }: Row): Page => p
@@ -261,6 +415,43 @@ export function createMockQuireApi() {
261
415
 
262
416
  const notFound = (what: string) => Object.assign(new Error(`${what} not found`), { code: 'NOT_FOUND' })
263
417
 
418
+ const theVersion = (id: string): PageVersion => {
419
+ const found = versions.find((v) => v.id === id)
420
+ if (!found) throw notFound('Version')
421
+ return found
422
+ }
423
+ const theComment = (id: string): Comment => {
424
+ const found = comments.find((c) => c.id === id)
425
+ if (!found) throw notFound('Comment')
426
+ return found
427
+ }
428
+
429
+ /** Which version a reader is served, which the list and the sheet both have to agree about. */
430
+ const publishedOn = (pageId: string) => pages.find((p) => p.id === pageId)?.publishedVersionId ?? null
431
+ const asVersion = (v: PageVersion): PageVersion => ({ ...v, published: v.id === publishedOn(v.pageId) })
432
+
433
+ /**
434
+ * Write down what the page says now, exactly where the server takes a version.
435
+ *
436
+ * There is no document behind this, so the newest version's prose stands in for the live one —
437
+ * enough that restoring writes a new row saying what it restored, which is the behaviour the
438
+ * history sheet is judged on.
439
+ */
440
+ const capture = (pageId: string, kind: PageVersion['kind'], label: string | null, preview?: string) => {
441
+ const latest = versions.filter((v) => v.pageId === pageId).at(-1)
442
+ const taken = version(
443
+ ++seq,
444
+ pageId,
445
+ kind,
446
+ label,
447
+ preview ?? latest?.preview ?? found(pageId).title,
448
+ 0,
449
+ ME,
450
+ )
451
+ versions.push(taken)
452
+ return taken
453
+ }
454
+
264
455
  const theDatabase = (id: string): Database => {
265
456
  const db = databases.find((d) => d.id === id)
266
457
  if (!db) throw notFound('Database')
@@ -563,6 +754,165 @@ export function createMockQuireApi() {
563
754
  },
564
755
  },
565
756
 
757
+ versions: {
758
+ list: async ({ pageId, limit = 50 }: { pageId: string; limit?: number }) => ({
759
+ // Newest first, and the ids sort because they are minted in order — the same thing the
760
+ // server gets from ordering on a uuidv7.
761
+ items: versions
762
+ .filter((v) => v.pageId === pageId)
763
+ .sort((a, b) => (a.id < b.id ? 1 : -1))
764
+ .slice(0, limit)
765
+ .map(asVersion),
766
+ nextCursor: null,
767
+ }),
768
+
769
+ get: async ({ versionId }: { versionId: string }) => {
770
+ const found = theVersion(versionId)
771
+ return {
772
+ ...asVersion(found),
773
+ text: found.preview,
774
+ // The server renders the stored document; the escaping is the part worth keeping, since
775
+ // a screen hands this straight to a renderer.
776
+ html: `<p>${found.preview.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</p>`,
777
+ }
778
+ },
779
+
780
+ create: async ({ pageId, label = null }: { pageId: string; label?: string | null }) =>
781
+ asVersion(capture(pageId, 'auto', label)),
782
+
783
+ restore: async ({ versionId }: { versionId: string }) => {
784
+ const wanted = theVersion(versionId)
785
+ // The state about to be replaced is captured first, so restoring is itself undoable — the
786
+ // reason the sheet offers it without a confirmation.
787
+ capture(wanted.pageId, 'auto', null)
788
+ const restored = capture(wanted.pageId, 'restore', wanted.label, wanted.preview)
789
+ touch(found(wanted.pageId))
790
+ return asVersion(restored)
791
+ },
792
+ },
793
+
794
+ comments: {
795
+ list: async ({
796
+ pageId,
797
+ includeResolved = false,
798
+ }: {
799
+ pageId: string
800
+ includeResolved?: boolean
801
+ }): Promise<CommentThread[]> => {
802
+ const byThread = new Map<string, Comment[]>()
803
+ for (const c of comments.filter((c) => c.pageId === pageId)) {
804
+ byThread.set(c.threadId, [...(byThread.get(c.threadId) ?? []), c])
805
+ }
806
+ const threads: CommentThread[] = []
807
+ for (const [threadId, list] of byThread) {
808
+ const ordered = [...list].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
809
+ // A thread whose root was deleted while replies remain is still somebody's conversation,
810
+ // so the oldest remaining comment leads it — as it does on the server.
811
+ const lead = ordered.find((c) => c.id === threadId) ?? ordered[0]
812
+ if (!lead) continue
813
+ const resolved = Boolean(lead.resolvedAt)
814
+ if (resolved && !includeResolved) continue
815
+ threads.push({
816
+ id: threadId,
817
+ root: structuredClone(lead),
818
+ replies: ordered.filter((c) => c.id !== lead.id).map((c) => structuredClone(c)),
819
+ resolved,
820
+ })
821
+ }
822
+ return threads
823
+ },
824
+
825
+ create: async (input: {
826
+ pageId: string
827
+ body: Record<string, unknown>
828
+ anchor?: CommentAnchor | null
829
+ quotedText?: string
830
+ parentId?: string | null
831
+ }) => {
832
+ const parent = input.parentId ? theComment(input.parentId) : null
833
+ const id = nextId()
834
+ const made: Comment = {
835
+ id,
836
+ workspaceId: '' as Comment['workspaceId'],
837
+ pageId: input.pageId,
838
+ parentId: parent?.id ?? null,
839
+ // A reply belongs to the thread its parent is in, never to a thread of its own.
840
+ threadId: parent?.threadId ?? id,
841
+ authorId: ME as Comment['authorId'],
842
+ body: input.body,
843
+ bodyText: flatten(input.body),
844
+ mentionIds: [],
845
+ anchor: input.anchor ?? null,
846
+ quotedText: input.quotedText ?? '',
847
+ resolvedAt: null,
848
+ resolvedBy: null,
849
+ editedAt: null,
850
+ createdAt: new Date().toISOString(),
851
+ }
852
+ comments.push(made)
853
+ return structuredClone(made)
854
+ },
855
+
856
+ update: async ({ commentId, body }: { commentId: string; body: Record<string, unknown> }) => {
857
+ const found = theComment(commentId)
858
+ found.body = body
859
+ found.bodyText = flatten(body)
860
+ found.editedAt = new Date().toISOString()
861
+ return structuredClone(found)
862
+ },
863
+
864
+ remove: async ({ commentId }: { commentId: string }) => {
865
+ const found = theComment(commentId)
866
+ comments.splice(comments.indexOf(found), 1)
867
+ return { ok: true as const }
868
+ },
869
+
870
+ resolve: async ({ commentId, resolved = true }: { commentId: string; resolved?: boolean }) => {
871
+ const lead = theComment(commentId)
872
+ // Resolving is a property of the conversation, so it is written on the comment that leads
873
+ // it and read from there — never on each reply.
874
+ lead.resolvedAt = resolved ? new Date().toISOString() : null
875
+ lead.resolvedBy = resolved ? (ME as Comment['resolvedBy']) : null
876
+ const rest = comments.filter((c) => c.threadId === lead.threadId && c.id !== lead.id)
877
+ return {
878
+ id: lead.threadId,
879
+ root: structuredClone(lead),
880
+ replies: rest.map((c) => structuredClone(c)),
881
+ resolved,
882
+ }
883
+ },
884
+ },
885
+
886
+ publishing: {
887
+ publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
888
+ const row = found(pageId)
889
+ if (row.kind !== 'page')
890
+ throw Object.assign(new Error('Only a page has a published version; a live doc is always live'), {
891
+ code: 'BAD_REQUEST',
892
+ })
893
+ const taken = capture(pageId, 'publish', label)
894
+ row.publishedVersionId = taken.id
895
+ row.hasUnpublishedChanges = false
896
+ touch(row)
897
+ return strip(row)
898
+ },
899
+
900
+ revert: async ({ pageId }: { pageId: string }) => {
901
+ const row = found(pageId)
902
+ if (!row.publishedVersionId)
903
+ throw Object.assign(
904
+ new Error('This page has never been published, so there is nothing to go back to'),
905
+ { code: 'BAD_REQUEST' },
906
+ )
907
+ // The draft being discarded is kept, because discarding it should not be a way to lose an
908
+ // afternoon's writing with no way back.
909
+ capture(pageId, 'auto', null)
910
+ row.hasUnpublishedChanges = false
911
+ touch(row)
912
+ return strip(row)
913
+ },
914
+ },
915
+
566
916
  databases: {
567
917
  list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
568
918
  databases
@@ -301,7 +301,18 @@ async function trash() {
301
301
  </div>
302
302
 
303
303
  <div class="byline">
304
- <Avatar id={doc.updatedBy} name={editor?.name} src={editor?.avatarUrl ?? undefined} size={24} />
304
+ <!--
305
+ No avatar for an author nobody can name.
306
+
307
+ `Avatar` with no `name` draws a "?" disc whose `title` is empty, so an unresolved author put
308
+ a meaningless glyph at the head of the one line whose job is to say who touched the page —
309
+ and a screen reader read it out as "question mark". `updatedBy` is null for every page the
310
+ demo seeds and for any row written before the column existed, so this is the common path,
311
+ not the edge. The sentence beside it already falls back to wording that claims no author.
312
+ -->
313
+ {#if editor}
314
+ <Avatar id={doc.updatedBy} name={editor.name} src={editor.avatarUrl ?? undefined} size={24} />
315
+ {/if}
305
316
  <span>
306
317
  {editor
307
318
  ? t('edited_ago_by', { when: relativeTime(doc.updatedAt), who: editor.name })