@kernhq/module-quire 0.14.0 → 0.16.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/dist/contract/models.d.ts +209 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +151 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +3 -3
- package/dist/contract/router.d.ts +509 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +211 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +573 -1269
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +180 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/markdown.d.ts.map +1 -1
- package/dist/server/export/markdown.js +84 -1
- package/dist/server/export/markdown.js.map +1 -1
- package/dist/server/render.d.ts +190 -1
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +413 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +260 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +108 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/index.d.ts +6 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +27 -3
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/objects.d.ts +61 -0
- package/dist/server/services/objects.d.ts.map +1 -0
- package/dist/server/services/objects.js +110 -0
- package/dist/server/services/objects.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/unfurl.d.ts +154 -0
- package/dist/server/services/unfurl.d.ts.map +1 -0
- package/dist/server/services/unfurl.js +593 -0
- package/dist/server/services/unfurl.js.map +1 -0
- package/dist/server/services/versions.d.ts +12 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/client/components/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +33 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +327 -0
- package/src/client/index.ts +19 -0
- package/src/client/mock.ts +274 -0
- package/src/client/pages/PageView.svelte +50 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/query.ts +17 -0
- package/src/contract/models.ts +191 -0
- package/src/contract/permissions.ts +33 -0
- package/src/contract/router.ts +228 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { and, asc, eq, isNull, or } from 'drizzle-orm';
|
|
3
|
+
import { TEMPLATE_STARTER_KEYS, TemplateSpaceBody } from '../../contract/index.js';
|
|
4
|
+
import { pageDocFromBase64, pageDocFromState } from '../document.js';
|
|
5
|
+
import { pageDocToYState } from '../import/ydoc.js';
|
|
6
|
+
import { textFromPageDoc } from '../render.js';
|
|
7
|
+
import { pages, pageVersions, templates } from '../schema.js';
|
|
8
|
+
import { documentNameOf } from './pages.js';
|
|
9
|
+
/**
|
|
10
|
+
* How deep `fillNode` will descend, and how many pages a space template may hold.
|
|
11
|
+
*
|
|
12
|
+
* The depth cap is not decoration. `doc` is a JSONB column, so what comes back is whatever was
|
|
13
|
+
* written there — and a recursive walk over an adversarially nested document is a stack overflow,
|
|
14
|
+
* which in Node is a process, not an exception. The page cap is the same argument at the other end:
|
|
15
|
+
* a space template that made four thousand pages in one request would be an outage wearing a
|
|
16
|
+
* feature's clothes.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_DOC_DEPTH = 64;
|
|
19
|
+
const MAX_TEMPLATE_PAGES = 200;
|
|
20
|
+
// =====================================================================================
|
|
21
|
+
// The five starters
|
|
22
|
+
// =====================================================================================
|
|
23
|
+
/**
|
|
24
|
+
* The starters' own words, in every locale the platform ships.
|
|
25
|
+
*
|
|
26
|
+
* `en` is the reference and the other four are typed against its keys, so a missing translation is
|
|
27
|
+
* a compile error rather than an English heading in an Arabic page. The strings are deliberately
|
|
28
|
+
* plain — a starter is scaffolding somebody writes over, and prompts they have to delete are worse
|
|
29
|
+
* than headings they can type under.
|
|
30
|
+
*/
|
|
31
|
+
const en = {
|
|
32
|
+
'meeting-notes.name': 'Meeting notes',
|
|
33
|
+
'meeting-notes.description': 'Who was there, what was decided, and who does what next.',
|
|
34
|
+
'meeting-notes.attendees': 'Attendees',
|
|
35
|
+
'meeting-notes.agenda': 'Agenda',
|
|
36
|
+
'meeting-notes.notes': 'Notes',
|
|
37
|
+
'meeting-notes.decisions': 'Decisions',
|
|
38
|
+
'meeting-notes.actions': 'Actions',
|
|
39
|
+
'decision-record.name': 'Decision record',
|
|
40
|
+
'decision-record.description': 'One decision, why it was taken, and what it commits you to.',
|
|
41
|
+
'decision-record.status': 'Status',
|
|
42
|
+
'decision-record.status_value': 'Proposed',
|
|
43
|
+
'decision-record.context': 'Context',
|
|
44
|
+
'decision-record.decision': 'Decision',
|
|
45
|
+
'decision-record.consequences': 'Consequences',
|
|
46
|
+
'decision-record.alternatives': 'Alternatives considered',
|
|
47
|
+
'requirements.name': 'Requirements',
|
|
48
|
+
'requirements.description': 'What a piece of work has to do, and what it deliberately does not.',
|
|
49
|
+
'requirements.summary': 'Summary',
|
|
50
|
+
'requirements.goals': 'Goals',
|
|
51
|
+
'requirements.out_of_scope': 'Out of scope',
|
|
52
|
+
'requirements.requirements': 'Requirements',
|
|
53
|
+
'requirements.questions': 'Open questions',
|
|
54
|
+
'retrospective.name': 'Retrospective',
|
|
55
|
+
'retrospective.description': 'What went well, what got in the way, and what to change.',
|
|
56
|
+
'retrospective.well': 'What went well',
|
|
57
|
+
'retrospective.blocked': 'What got in the way',
|
|
58
|
+
'retrospective.try': 'What we will try',
|
|
59
|
+
'retrospective.actions': 'Actions',
|
|
60
|
+
'how-to.name': 'How-to',
|
|
61
|
+
'how-to.description': 'A task somebody can follow from start to finish.',
|
|
62
|
+
'how-to.before': 'Before you start',
|
|
63
|
+
'how-to.steps': 'Steps',
|
|
64
|
+
'how-to.check': 'Check it worked',
|
|
65
|
+
'how-to.trouble': 'If something goes wrong',
|
|
66
|
+
};
|
|
67
|
+
const ar = {
|
|
68
|
+
'meeting-notes.name': 'محضر اجتماع',
|
|
69
|
+
'meeting-notes.description': 'من حضر، وما الذي تقرّر، ومن يفعل ماذا بعد ذلك.',
|
|
70
|
+
'meeting-notes.attendees': 'الحاضرون',
|
|
71
|
+
'meeting-notes.agenda': 'جدول الأعمال',
|
|
72
|
+
'meeting-notes.notes': 'الملاحظات',
|
|
73
|
+
'meeting-notes.decisions': 'القرارات',
|
|
74
|
+
'meeting-notes.actions': 'الإجراءات',
|
|
75
|
+
'decision-record.name': 'سجل قرار',
|
|
76
|
+
'decision-record.description': 'قرار واحد، ولماذا اتُّخذ، وما الذي يلزمك به.',
|
|
77
|
+
'decision-record.status': 'الحالة',
|
|
78
|
+
'decision-record.status_value': 'مقترح',
|
|
79
|
+
'decision-record.context': 'السياق',
|
|
80
|
+
'decision-record.decision': 'القرار',
|
|
81
|
+
'decision-record.consequences': 'النتائج',
|
|
82
|
+
'decision-record.alternatives': 'البدائل التي نُظر فيها',
|
|
83
|
+
'requirements.name': 'المتطلبات',
|
|
84
|
+
'requirements.description': 'ما يجب أن ينجزه العمل، وما لا ينجزه عن قصد.',
|
|
85
|
+
'requirements.summary': 'الملخص',
|
|
86
|
+
'requirements.goals': 'الأهداف',
|
|
87
|
+
'requirements.out_of_scope': 'خارج النطاق',
|
|
88
|
+
'requirements.requirements': 'المتطلبات',
|
|
89
|
+
'requirements.questions': 'أسئلة مفتوحة',
|
|
90
|
+
'retrospective.name': 'مراجعة استعادية',
|
|
91
|
+
'retrospective.description': 'ما سار جيدًا، وما أعاق العمل، وما الذي نغيّره.',
|
|
92
|
+
'retrospective.well': 'ما سار جيدًا',
|
|
93
|
+
'retrospective.blocked': 'ما أعاق العمل',
|
|
94
|
+
'retrospective.try': 'ما سنجرّبه',
|
|
95
|
+
'retrospective.actions': 'الإجراءات',
|
|
96
|
+
'how-to.name': 'دليل عملي',
|
|
97
|
+
'how-to.description': 'مهمة يمكن لأي شخص تنفيذها من أولها إلى آخرها.',
|
|
98
|
+
'how-to.before': 'قبل أن تبدأ',
|
|
99
|
+
'how-to.steps': 'الخطوات',
|
|
100
|
+
'how-to.check': 'تأكّد من نجاحها',
|
|
101
|
+
'how-to.trouble': 'إذا حدث خطأ',
|
|
102
|
+
};
|
|
103
|
+
const de = {
|
|
104
|
+
'meeting-notes.name': 'Besprechungsnotizen',
|
|
105
|
+
'meeting-notes.description': 'Wer da war, was entschieden wurde und wer als Nächstes was tut.',
|
|
106
|
+
'meeting-notes.attendees': 'Teilnehmende',
|
|
107
|
+
'meeting-notes.agenda': 'Tagesordnung',
|
|
108
|
+
'meeting-notes.notes': 'Notizen',
|
|
109
|
+
'meeting-notes.decisions': 'Entscheidungen',
|
|
110
|
+
'meeting-notes.actions': 'Aufgaben',
|
|
111
|
+
'decision-record.name': 'Entscheidungsprotokoll',
|
|
112
|
+
'decision-record.description': 'Eine Entscheidung, warum sie getroffen wurde und wozu sie verpflichtet.',
|
|
113
|
+
'decision-record.status': 'Status',
|
|
114
|
+
'decision-record.status_value': 'Vorgeschlagen',
|
|
115
|
+
'decision-record.context': 'Kontext',
|
|
116
|
+
'decision-record.decision': 'Entscheidung',
|
|
117
|
+
'decision-record.consequences': 'Folgen',
|
|
118
|
+
'decision-record.alternatives': 'Geprüfte Alternativen',
|
|
119
|
+
'requirements.name': 'Anforderungen',
|
|
120
|
+
'requirements.description': 'Was eine Arbeit leisten muss – und was bewusst nicht.',
|
|
121
|
+
'requirements.summary': 'Zusammenfassung',
|
|
122
|
+
'requirements.goals': 'Ziele',
|
|
123
|
+
'requirements.out_of_scope': 'Nicht im Umfang',
|
|
124
|
+
'requirements.requirements': 'Anforderungen',
|
|
125
|
+
'requirements.questions': 'Offene Fragen',
|
|
126
|
+
'retrospective.name': 'Retrospektive',
|
|
127
|
+
'retrospective.description': 'Was gut lief, was im Weg stand und was sich ändern soll.',
|
|
128
|
+
'retrospective.well': 'Was gut lief',
|
|
129
|
+
'retrospective.blocked': 'Was im Weg stand',
|
|
130
|
+
'retrospective.try': 'Was wir ausprobieren',
|
|
131
|
+
'retrospective.actions': 'Aufgaben',
|
|
132
|
+
'how-to.name': 'Anleitung',
|
|
133
|
+
'how-to.description': 'Eine Aufgabe, die jemand von Anfang bis Ende durchführen kann.',
|
|
134
|
+
'how-to.before': 'Bevor Sie beginnen',
|
|
135
|
+
'how-to.steps': 'Schritte',
|
|
136
|
+
'how-to.check': 'Ergebnis prüfen',
|
|
137
|
+
'how-to.trouble': 'Wenn etwas schiefgeht',
|
|
138
|
+
};
|
|
139
|
+
const fa = {
|
|
140
|
+
'meeting-notes.name': 'یادداشت جلسه',
|
|
141
|
+
'meeting-notes.description': 'چه کسانی بودند، چه تصمیمی گرفته شد و بعد چه کسی چه میکند.',
|
|
142
|
+
'meeting-notes.attendees': 'حاضران',
|
|
143
|
+
'meeting-notes.agenda': 'دستور جلسه',
|
|
144
|
+
'meeting-notes.notes': 'یادداشتها',
|
|
145
|
+
'meeting-notes.decisions': 'تصمیمها',
|
|
146
|
+
'meeting-notes.actions': 'کارها',
|
|
147
|
+
'decision-record.name': 'سند تصمیم',
|
|
148
|
+
'decision-record.description': 'یک تصمیم، دلیل گرفتن آن، و آنچه شما را به آن متعهد میکند.',
|
|
149
|
+
'decision-record.status': 'وضعیت',
|
|
150
|
+
'decision-record.status_value': 'پیشنهادی',
|
|
151
|
+
'decision-record.context': 'زمینه',
|
|
152
|
+
'decision-record.decision': 'تصمیم',
|
|
153
|
+
'decision-record.consequences': 'پیامدها',
|
|
154
|
+
'decision-record.alternatives': 'گزینههای بررسیشده',
|
|
155
|
+
'requirements.name': 'نیازمندیها',
|
|
156
|
+
'requirements.description': 'کاری که باید انجام شود، و آنچه عمداً انجام نمیشود.',
|
|
157
|
+
'requirements.summary': 'خلاصه',
|
|
158
|
+
'requirements.goals': 'هدفها',
|
|
159
|
+
'requirements.out_of_scope': 'خارج از دامنه',
|
|
160
|
+
'requirements.requirements': 'نیازمندیها',
|
|
161
|
+
'requirements.questions': 'پرسشهای باز',
|
|
162
|
+
'retrospective.name': 'بازنگری',
|
|
163
|
+
'retrospective.description': 'چه چیزی خوب پیش رفت، چه چیزی مانع شد و چه چیزی را تغییر میدهیم.',
|
|
164
|
+
'retrospective.well': 'چه چیزی خوب پیش رفت',
|
|
165
|
+
'retrospective.blocked': 'چه چیزی مانع شد',
|
|
166
|
+
'retrospective.try': 'چه چیزی را میآزماییم',
|
|
167
|
+
'retrospective.actions': 'کارها',
|
|
168
|
+
'how-to.name': 'راهنمای گامبهگام',
|
|
169
|
+
'how-to.description': 'کاری که هر کس بتواند از آغاز تا پایان دنبالش کند.',
|
|
170
|
+
'how-to.before': 'پیش از شروع',
|
|
171
|
+
'how-to.steps': 'گامها',
|
|
172
|
+
'how-to.check': 'درستی کار را بررسی کنید',
|
|
173
|
+
'how-to.trouble': 'اگر چیزی اشتباه پیش رفت',
|
|
174
|
+
};
|
|
175
|
+
const tr = {
|
|
176
|
+
'meeting-notes.name': 'Toplantı notları',
|
|
177
|
+
'meeting-notes.description': 'Kimler vardı, ne karara bağlandı ve sırada kim ne yapıyor.',
|
|
178
|
+
'meeting-notes.attendees': 'Katılanlar',
|
|
179
|
+
'meeting-notes.agenda': 'Gündem',
|
|
180
|
+
'meeting-notes.notes': 'Notlar',
|
|
181
|
+
'meeting-notes.decisions': 'Kararlar',
|
|
182
|
+
'meeting-notes.actions': 'İşler',
|
|
183
|
+
'decision-record.name': 'Karar kaydı',
|
|
184
|
+
'decision-record.description': 'Tek bir karar, neden alındığı ve neye bağladığı.',
|
|
185
|
+
'decision-record.status': 'Durum',
|
|
186
|
+
'decision-record.status_value': 'Önerildi',
|
|
187
|
+
'decision-record.context': 'Bağlam',
|
|
188
|
+
'decision-record.decision': 'Karar',
|
|
189
|
+
'decision-record.consequences': 'Sonuçlar',
|
|
190
|
+
'decision-record.alternatives': 'Değerlendirilen seçenekler',
|
|
191
|
+
'requirements.name': 'Gereksinimler',
|
|
192
|
+
'requirements.description': 'Bir işin yapması gerekenler ve bilerek yapmadıkları.',
|
|
193
|
+
'requirements.summary': 'Özet',
|
|
194
|
+
'requirements.goals': 'Hedefler',
|
|
195
|
+
'requirements.out_of_scope': 'Kapsam dışı',
|
|
196
|
+
'requirements.requirements': 'Gereksinimler',
|
|
197
|
+
'requirements.questions': 'Açık sorular',
|
|
198
|
+
'retrospective.name': 'Retrospektif',
|
|
199
|
+
'retrospective.description': 'Ne iyi gitti, ne engel oldu ve neyi değiştireceğiz.',
|
|
200
|
+
'retrospective.well': 'Ne iyi gitti',
|
|
201
|
+
'retrospective.blocked': 'Ne engel oldu',
|
|
202
|
+
'retrospective.try': 'Ne deneyeceğiz',
|
|
203
|
+
'retrospective.actions': 'İşler',
|
|
204
|
+
'how-to.name': 'Nasıl yapılır',
|
|
205
|
+
'how-to.description': 'Birinin baştan sona izleyebileceği bir iş.',
|
|
206
|
+
'how-to.before': 'Başlamadan önce',
|
|
207
|
+
'how-to.steps': 'Adımlar',
|
|
208
|
+
'how-to.check': 'Çalıştığını doğrulayın',
|
|
209
|
+
'how-to.trouble': 'Bir şey ters giderse',
|
|
210
|
+
};
|
|
211
|
+
const STARTER_TEXT = { ar, de, en, fa, tr };
|
|
212
|
+
/**
|
|
213
|
+
* The reader's own language, or English.
|
|
214
|
+
*
|
|
215
|
+
* `principal.locale` is whatever core stored, which may be a region tag (`en-GB`, `pt-BR`), so the
|
|
216
|
+
* base subtag is what is looked up. A locale this module has no table for falls back rather than
|
|
217
|
+
* throwing: a page in the wrong language is a disappointment, and a 500 when somebody presses "New
|
|
218
|
+
* page" is a broken product.
|
|
219
|
+
*/
|
|
220
|
+
function stringsFor(locale) {
|
|
221
|
+
const base = (locale ?? 'en').toLowerCase().split(/[-_]/)[0] ?? 'en';
|
|
222
|
+
return STARTER_TEXT[base] ?? en;
|
|
223
|
+
}
|
|
224
|
+
/** A paragraph, empty when there is nothing to put in it — an empty one is a line to type on. */
|
|
225
|
+
const p = (text) => text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
|
|
226
|
+
const h = (text) => ({
|
|
227
|
+
type: 'heading',
|
|
228
|
+
attrs: { level: 2 },
|
|
229
|
+
content: [{ type: 'text', text }],
|
|
230
|
+
});
|
|
231
|
+
const bullets = () => ({
|
|
232
|
+
type: 'bulletList',
|
|
233
|
+
content: [{ type: 'listItem', content: [p()] }],
|
|
234
|
+
});
|
|
235
|
+
const numbered = () => ({
|
|
236
|
+
type: 'orderedList',
|
|
237
|
+
content: [{ type: 'listItem', content: [p()] }],
|
|
238
|
+
});
|
|
239
|
+
const tasks = () => ({
|
|
240
|
+
type: 'taskList',
|
|
241
|
+
content: [{ type: 'taskItem', attrs: { checked: false }, content: [p()] }],
|
|
242
|
+
});
|
|
243
|
+
/**
|
|
244
|
+
* The line every starter opens with.
|
|
245
|
+
*
|
|
246
|
+
* `{{date}}` and `{{author}}` are the two built-ins the plan names, and putting them at the top of
|
|
247
|
+
* all five is what makes the feature legible: somebody who has never read this file sees the braces
|
|
248
|
+
* become a date the first time they make a page, and now knows what a variable is.
|
|
249
|
+
*/
|
|
250
|
+
const byline = () => p('{{date}} · {{author}}');
|
|
251
|
+
/**
|
|
252
|
+
* Every node in these bodies is one `renderPageDoc` draws and `buildPageExtensions` produces.
|
|
253
|
+
*
|
|
254
|
+
* That is the editor-schema rule, and a template is where breaking it costs most: a starter using a
|
|
255
|
+
* node the renderer has no case for would export blank, publish blank and print blank, for every
|
|
256
|
+
* page anybody ever made from it. Nothing checks it here — `render.test.ts` checks the renderer
|
|
257
|
+
* against the schema, and these use only paragraph, heading, bulletList, orderedList, listItem,
|
|
258
|
+
* taskList, taskItem and text.
|
|
259
|
+
*
|
|
260
|
+
* **None of them declares a variable, and that is a decision rather than an omission.** A declared
|
|
261
|
+
* variable has a `label` and, for a `select`, a list of `options` — an author's own words, in the
|
|
262
|
+
* author's own language. A shipped starter has no author, so every one of those strings would have
|
|
263
|
+
* to join the table above and be translated five ways to ask somebody for a sprint number. The two
|
|
264
|
+
* built-ins are filled from the request and need no words at all, which is why a starter can use
|
|
265
|
+
* them and nothing else.
|
|
266
|
+
*/
|
|
267
|
+
const STARTERS = {
|
|
268
|
+
'meeting-notes': {
|
|
269
|
+
icon: 'users',
|
|
270
|
+
body: (s) => [
|
|
271
|
+
byline(),
|
|
272
|
+
h(s['meeting-notes.attendees']),
|
|
273
|
+
bullets(),
|
|
274
|
+
h(s['meeting-notes.agenda']),
|
|
275
|
+
bullets(),
|
|
276
|
+
h(s['meeting-notes.notes']),
|
|
277
|
+
p(),
|
|
278
|
+
h(s['meeting-notes.decisions']),
|
|
279
|
+
bullets(),
|
|
280
|
+
h(s['meeting-notes.actions']),
|
|
281
|
+
tasks(),
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
'decision-record': {
|
|
285
|
+
icon: 'flag',
|
|
286
|
+
body: (s) => [
|
|
287
|
+
byline(),
|
|
288
|
+
h(s['decision-record.status']),
|
|
289
|
+
p(s['decision-record.status_value']),
|
|
290
|
+
h(s['decision-record.context']),
|
|
291
|
+
p(),
|
|
292
|
+
h(s['decision-record.decision']),
|
|
293
|
+
p(),
|
|
294
|
+
h(s['decision-record.consequences']),
|
|
295
|
+
p(),
|
|
296
|
+
h(s['decision-record.alternatives']),
|
|
297
|
+
bullets(),
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
requirements: {
|
|
301
|
+
icon: 'target',
|
|
302
|
+
body: (s) => [
|
|
303
|
+
byline(),
|
|
304
|
+
h(s['requirements.summary']),
|
|
305
|
+
p(),
|
|
306
|
+
h(s['requirements.goals']),
|
|
307
|
+
bullets(),
|
|
308
|
+
h(s['requirements.out_of_scope']),
|
|
309
|
+
bullets(),
|
|
310
|
+
h(s['requirements.requirements']),
|
|
311
|
+
tasks(),
|
|
312
|
+
h(s['requirements.questions']),
|
|
313
|
+
bullets(),
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
retrospective: {
|
|
317
|
+
icon: 'refresh-cw',
|
|
318
|
+
body: (s) => [
|
|
319
|
+
byline(),
|
|
320
|
+
h(s['retrospective.well']),
|
|
321
|
+
bullets(),
|
|
322
|
+
h(s['retrospective.blocked']),
|
|
323
|
+
bullets(),
|
|
324
|
+
h(s['retrospective.try']),
|
|
325
|
+
bullets(),
|
|
326
|
+
h(s['retrospective.actions']),
|
|
327
|
+
tasks(),
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
'how-to': {
|
|
331
|
+
icon: 'wrench',
|
|
332
|
+
body: (s) => [
|
|
333
|
+
byline(),
|
|
334
|
+
h(s['how-to.before']),
|
|
335
|
+
bullets(),
|
|
336
|
+
h(s['how-to.steps']),
|
|
337
|
+
numbered(),
|
|
338
|
+
h(s['how-to.check']),
|
|
339
|
+
p(),
|
|
340
|
+
h(s['how-to.trouble']),
|
|
341
|
+
p(),
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
/** A starter as the picker draws it, in one language. */
|
|
346
|
+
function starterChoice(key, s) {
|
|
347
|
+
return {
|
|
348
|
+
id: null,
|
|
349
|
+
key,
|
|
350
|
+
builtIn: true,
|
|
351
|
+
kind: 'page',
|
|
352
|
+
spaceId: null,
|
|
353
|
+
name: s[`${key}.name`],
|
|
354
|
+
description: s[`${key}.description`],
|
|
355
|
+
icon: STARTERS[key].icon,
|
|
356
|
+
variables: [],
|
|
357
|
+
updatedAt: null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** A starter's body, built fresh every time — the constants above are shared and never mutated. */
|
|
361
|
+
function starterDoc(key, s) {
|
|
362
|
+
return { type: 'doc', content: STARTERS[key].body(s) };
|
|
363
|
+
}
|
|
364
|
+
// =====================================================================================
|
|
365
|
+
// Variables
|
|
366
|
+
// =====================================================================================
|
|
367
|
+
/**
|
|
368
|
+
* What a placeholder looks like: `{{name}}`, with optional spaces inside the braces.
|
|
369
|
+
*
|
|
370
|
+
* The name is the same grammar `TemplateVariableName` enforces — lowercase, digits, underscores —
|
|
371
|
+
* so a template cannot declare a name this pattern would not find, and nothing else in a page can
|
|
372
|
+
* accidentally look like one.
|
|
373
|
+
*/
|
|
374
|
+
const PLACEHOLDER = /\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g;
|
|
375
|
+
/**
|
|
376
|
+
* Replace the placeholders in one string.
|
|
377
|
+
*
|
|
378
|
+
* The replacement is a **function**, which is the whole point. `String.replace` with a string
|
|
379
|
+
* replacement reads `$&`, `$1` and `$'` in the *replacement* as back-references, so somebody
|
|
380
|
+
* answering a template's question with `$&` would get their own placeholder back, and `$'` would
|
|
381
|
+
* paste the rest of the paragraph. A function is handed the match and its return value is used
|
|
382
|
+
* verbatim.
|
|
383
|
+
*
|
|
384
|
+
* An unknown name is left exactly as it was written rather than removed. A template one release
|
|
385
|
+
* ahead of its server, or a body somebody typed braces into by hand, should show a placeholder the
|
|
386
|
+
* author can see and fix — deleting text somebody wrote is the worse of the two failures.
|
|
387
|
+
*
|
|
388
|
+
* The scan is single-pass, so a value containing `{{other}}` is characters and not a second
|
|
389
|
+
* substitution: `replace` never re-reads what it has written.
|
|
390
|
+
*/
|
|
391
|
+
function fillText(text, values) {
|
|
392
|
+
return text.replace(PLACEHOLDER, (whole, name) => values.get(name) ?? whole);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The same document with its text filled in — a **copy**, never the original.
|
|
396
|
+
*
|
|
397
|
+
* Cloning matters for two different reasons at once. The starter bodies are module-level constants
|
|
398
|
+
* shared by every request in the process, so filling one in place would leak one workspace's answers
|
|
399
|
+
* into the next workspace's page. And a stored `doc` is JSONB the caller may hold a reference to.
|
|
400
|
+
*
|
|
401
|
+
* Attributes are copied and never substituted. A `{{…}}` inside a link's `href` or an image's
|
|
402
|
+
* `fileId` is not prose, and filling it would let a template's answer decide where a link points —
|
|
403
|
+
* which is a template author writing a URL somebody else's answer completes.
|
|
404
|
+
*/
|
|
405
|
+
function fillNode(node, values, depth) {
|
|
406
|
+
const out = {};
|
|
407
|
+
if (typeof node.type === 'string')
|
|
408
|
+
out.type = node.type;
|
|
409
|
+
if (typeof node.text === 'string')
|
|
410
|
+
out.text = fillText(node.text, values);
|
|
411
|
+
if (node.attrs && typeof node.attrs === 'object')
|
|
412
|
+
out.attrs = { ...node.attrs };
|
|
413
|
+
if (Array.isArray(node.marks))
|
|
414
|
+
out.marks = node.marks.map((mark) => ({ ...mark }));
|
|
415
|
+
if (Array.isArray(node.content) && depth < MAX_DOC_DEPTH)
|
|
416
|
+
out.content = node.content
|
|
417
|
+
.filter((child) => typeof child === 'object' && child !== null)
|
|
418
|
+
.map((child) => fillNode(child, values, depth + 1));
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
/** A whole document with its text filled in. An empty or malformed one stays empty. */
|
|
422
|
+
export function fillPageDoc(doc, values) {
|
|
423
|
+
if (!doc || !Array.isArray(doc.content))
|
|
424
|
+
return { type: 'doc', content: [] };
|
|
425
|
+
return {
|
|
426
|
+
type: 'doc',
|
|
427
|
+
content: doc.content
|
|
428
|
+
.filter((node) => typeof node === 'object' && node !== null)
|
|
429
|
+
.map((node) => fillNode(node, values, 1)),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Everything a placeholder may become: what the module fills, then what somebody typed.
|
|
434
|
+
*
|
|
435
|
+
* The built-ins go in first and the answers second, which is the wrong order for precedence and the
|
|
436
|
+
* right one here: `TemplateVariableName` refuses the reserved names outright, so nothing a template
|
|
437
|
+
* declares can collide with one and the ordering never decides anything. It is written this way so
|
|
438
|
+
* that if that refusal is ever relaxed, an author's own variable wins over ours rather than being
|
|
439
|
+
* silently overwritten by it.
|
|
440
|
+
*
|
|
441
|
+
* `{{workspace}}` is reserved and **not filled**. Reserving it is what keeps adding it later a
|
|
442
|
+
* non-breaking change — an author who had taken the name would otherwise find their template
|
|
443
|
+
* quietly rendering something else — and filling it needs a workspace name this module would have
|
|
444
|
+
* to ask core for on every instantiation. Until something needs it, an unfilled reserved name
|
|
445
|
+
* behaves exactly as an unknown one: it stays on the page, visible.
|
|
446
|
+
*/
|
|
447
|
+
function valuesFor(principal, locale, spaceName, declared, supplied) {
|
|
448
|
+
const now = new Date();
|
|
449
|
+
const values = new Map();
|
|
450
|
+
// Through Intl, so a Persian workspace gets Persian digits and an Arabic one an Arabic calendar
|
|
451
|
+
// — the same rule every number and date in Kern follows.
|
|
452
|
+
const tag = locale || 'en';
|
|
453
|
+
values.set('date', new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(now));
|
|
454
|
+
values.set('time', new Intl.DateTimeFormat(tag, { timeStyle: 'short' }).format(now));
|
|
455
|
+
values.set('author', principal.name || principal.email || '');
|
|
456
|
+
values.set('space', spaceName);
|
|
457
|
+
for (const variable of declared) {
|
|
458
|
+
const given = supplied[variable.name];
|
|
459
|
+
const value = given === undefined || given === '' ? (variable.default ?? '') : given;
|
|
460
|
+
if (variable.required && value === '')
|
|
461
|
+
throw KernError.badRequest(`"${variable.label}" is needed before this can be made`);
|
|
462
|
+
values.set(variable.name, value);
|
|
463
|
+
}
|
|
464
|
+
return values;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Whether this failure is that named constraint, **looked for in `cause` and not in the message**.
|
|
468
|
+
*
|
|
469
|
+
* Drizzle wraps every failed statement in a `DrizzleQueryError` whose text is the SQL and its
|
|
470
|
+
* parameters; the Postgres error — with its `23505` and the name of the index it violated — is one
|
|
471
|
+
* link down the `cause` chain. A `String(err).includes('…_uq')` therefore never matches, silently,
|
|
472
|
+
* and the friendly conflict it was meant to raise turns into a raw driver error with the whole
|
|
473
|
+
* statement in it. That is what this shipped as until the test asked for the conflict by name.
|
|
474
|
+
*/
|
|
475
|
+
function violates(err, constraint) {
|
|
476
|
+
let at = err;
|
|
477
|
+
for (let depth = 0; at && depth < 6; depth += 1) {
|
|
478
|
+
const candidate = at;
|
|
479
|
+
if (candidate.code === '23505' &&
|
|
480
|
+
(candidate.constraint === constraint || (candidate.message ?? '').includes(constraint)))
|
|
481
|
+
return true;
|
|
482
|
+
at = candidate.cause;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
/** Two variables of the same name is a form with two fields writing to one placeholder. */
|
|
487
|
+
function checkVariables(variables) {
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
for (const variable of variables) {
|
|
490
|
+
if (seen.has(variable.name))
|
|
491
|
+
throw KernError.badRequest(`Two of these fields are called "${variable.name}"`);
|
|
492
|
+
seen.add(variable.name);
|
|
493
|
+
if (variable.type === 'select' && variable.options.length === 0)
|
|
494
|
+
throw KernError.badRequest(`"${variable.label}" is a list with nothing in it`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// =====================================================================================
|
|
498
|
+
// The service
|
|
499
|
+
// =====================================================================================
|
|
500
|
+
export function toTemplate(row) {
|
|
501
|
+
return {
|
|
502
|
+
id: row.id,
|
|
503
|
+
workspaceId: row.workspaceId,
|
|
504
|
+
spaceId: row.spaceId,
|
|
505
|
+
kind: row.kind,
|
|
506
|
+
key: row.key,
|
|
507
|
+
builtIn: row.builtIn,
|
|
508
|
+
name: row.name,
|
|
509
|
+
description: row.description,
|
|
510
|
+
icon: row.icon,
|
|
511
|
+
doc: (row.doc ?? {}),
|
|
512
|
+
variables: (row.variables ?? []),
|
|
513
|
+
createdBy: row.createdBy,
|
|
514
|
+
createdAt: row.createdAt.toISOString(),
|
|
515
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const toChoice = (row) => ({
|
|
519
|
+
id: row.id,
|
|
520
|
+
key: row.key,
|
|
521
|
+
builtIn: row.builtIn,
|
|
522
|
+
kind: row.kind,
|
|
523
|
+
spaceId: row.spaceId,
|
|
524
|
+
name: row.name,
|
|
525
|
+
description: row.description,
|
|
526
|
+
icon: row.icon,
|
|
527
|
+
variables: (row.variables ?? []),
|
|
528
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
529
|
+
});
|
|
530
|
+
export function quireTemplates(kernel, access, pagesSvc, spacesSvc) {
|
|
531
|
+
/**
|
|
532
|
+
* A page's prose, as a document.
|
|
533
|
+
*
|
|
534
|
+
* The live collaborative document first, because that is what the person looking at the page can
|
|
535
|
+
* see — saving a template from a page and getting last week's published copy would be baffling.
|
|
536
|
+
* The newest stored version is the fallback, for a page whose document the collab service has
|
|
537
|
+
* forgotten or has never held.
|
|
538
|
+
*/
|
|
539
|
+
async function docOfPage(tx, workspaceId, pageId) {
|
|
540
|
+
const live = await kernel
|
|
541
|
+
.call('collab.document.state', {
|
|
542
|
+
name: documentNameOf({ workspaceId, id: pageId }),
|
|
543
|
+
})
|
|
544
|
+
.catch(() => null);
|
|
545
|
+
const fromLive = pageDocFromBase64(live?.state ?? null);
|
|
546
|
+
if (fromLive)
|
|
547
|
+
return fromLive;
|
|
548
|
+
const [version] = await tx
|
|
549
|
+
.select({ state: pageVersions.state })
|
|
550
|
+
.from(pageVersions)
|
|
551
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.pageId, pageId)))
|
|
552
|
+
.orderBy(asc(pageVersions.id))
|
|
553
|
+
.limit(1);
|
|
554
|
+
return version ? pageDocFromState(version.state) : null;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* The space's tree as a template body — only the pages this person may read.
|
|
558
|
+
*
|
|
559
|
+
* That filter is the whole security question this procedure has. A space template copies other
|
|
560
|
+
* people's prose into something anybody who may create a page can then read, so a page a
|
|
561
|
+
* page-scoped DENY has closed to the author must not travel into it. A skipped page takes its
|
|
562
|
+
* descendants with it: a child whose parent was left out has nowhere to hang, and lifting it to
|
|
563
|
+
* the top would put a restricted page's child in the template anyway.
|
|
564
|
+
*
|
|
565
|
+
* Rows of a database are excluded for the same reason `pages.tree` excludes them — five hundred
|
|
566
|
+
* rows under one node is not a template, it is a copy of a database without its columns.
|
|
567
|
+
*/
|
|
568
|
+
async function treeOfSpace(tx, principal, workspaceId, spaceId) {
|
|
569
|
+
const rows = await tx
|
|
570
|
+
.select({
|
|
571
|
+
id: pages.id,
|
|
572
|
+
parentId: pages.parentId,
|
|
573
|
+
title: pages.title,
|
|
574
|
+
icon: pages.icon,
|
|
575
|
+
})
|
|
576
|
+
.from(pages)
|
|
577
|
+
.where(and(eq(pages.workspaceId, workspaceId), eq(pages.spaceId, spaceId), isNull(pages.deletedAt), isNull(pages.archivedAt), isNull(pages.databaseId)))
|
|
578
|
+
.orderBy(asc(pages.position));
|
|
579
|
+
if (rows.length > MAX_TEMPLATE_PAGES)
|
|
580
|
+
throw KernError.badRequest(`A space template holds at most ${MAX_TEMPLATE_PAGES} pages, and this space has ${rows.length}`);
|
|
581
|
+
const parentOf = new Map(rows.map((row) => [row.id, row.parentId]));
|
|
582
|
+
const ancestorsOf = (id) => {
|
|
583
|
+
const chain = [];
|
|
584
|
+
const seen = new Set([id]);
|
|
585
|
+
let at = parentOf.get(id) ?? null;
|
|
586
|
+
while (at !== null && !seen.has(at)) {
|
|
587
|
+
chain.push(at);
|
|
588
|
+
seen.add(at);
|
|
589
|
+
at = parentOf.get(at) ?? null;
|
|
590
|
+
}
|
|
591
|
+
return chain;
|
|
592
|
+
};
|
|
593
|
+
const readable = new Set();
|
|
594
|
+
for (const row of rows)
|
|
595
|
+
if (await access.canPage(principal, 'quire.page.view', workspaceId, {
|
|
596
|
+
pageId: row.id,
|
|
597
|
+
spaceId,
|
|
598
|
+
ancestorIds: ancestorsOf(row.id),
|
|
599
|
+
}))
|
|
600
|
+
readable.add(row.id);
|
|
601
|
+
const build = async (parentId, depth) => {
|
|
602
|
+
if (depth > 16)
|
|
603
|
+
return [];
|
|
604
|
+
const out = [];
|
|
605
|
+
for (const row of rows) {
|
|
606
|
+
if (row.parentId !== parentId)
|
|
607
|
+
continue;
|
|
608
|
+
// A page the author may not read is left out, and so is everything under it.
|
|
609
|
+
if (!readable.has(row.id))
|
|
610
|
+
continue;
|
|
611
|
+
out.push({
|
|
612
|
+
title: row.title,
|
|
613
|
+
icon: row.icon,
|
|
614
|
+
doc: ((await docOfPage(tx, workspaceId, row.id)) ?? { type: 'doc', content: [] }),
|
|
615
|
+
children: await build(row.id, depth + 1),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
};
|
|
620
|
+
return build(null, 0);
|
|
621
|
+
}
|
|
622
|
+
/** Write a document into a page that has just been made, and mirror its text for search. */
|
|
623
|
+
async function writeBody(tx, workspaceId, pageId, doc) {
|
|
624
|
+
if ((doc.content ?? []).length === 0)
|
|
625
|
+
return;
|
|
626
|
+
const state = pageDocToYState(doc);
|
|
627
|
+
await kernel.call('collab.document.replace', {
|
|
628
|
+
name: documentNameOf({ workspaceId, id: pageId }),
|
|
629
|
+
state: state.toString('base64'),
|
|
630
|
+
});
|
|
631
|
+
/*
|
|
632
|
+
* The mirrored column, so a page made from a template is findable before anybody edits it.
|
|
633
|
+
*
|
|
634
|
+
* No version row is written, deliberately. `VersionKind` has `auto`, `publish`, `restore` and
|
|
635
|
+
* `import`, and none of them is what this is — labelling it `import` would put "Imported" in a
|
|
636
|
+
* history the reader is meant to trust. The first version is taken the first time somebody
|
|
637
|
+
* writes in the page, which is the same moment it would be for a blank one.
|
|
638
|
+
*/
|
|
639
|
+
await tx
|
|
640
|
+
.update(pages)
|
|
641
|
+
.set({ text: textFromPageDoc(doc) })
|
|
642
|
+
.where(and(eq(pages.workspaceId, workspaceId), eq(pages.id, pageId)));
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
/** The row, for a caller that needs its scope before deciding anything. */
|
|
646
|
+
async row(tx, workspaceId, templateId) {
|
|
647
|
+
const [row] = await tx
|
|
648
|
+
.select()
|
|
649
|
+
.from(templates)
|
|
650
|
+
.where(and(eq(templates.workspaceId, workspaceId), eq(templates.id, templateId)))
|
|
651
|
+
.limit(1);
|
|
652
|
+
if (!row)
|
|
653
|
+
throw KernError.notFound('Template');
|
|
654
|
+
return row;
|
|
655
|
+
},
|
|
656
|
+
/**
|
|
657
|
+
* What may be made here: the starters, plus this workspace's own.
|
|
658
|
+
*
|
|
659
|
+
* The override is applied by key: a row carrying `retrospective` takes the shipped
|
|
660
|
+
* retrospective's place in the list rather than appearing after it. That is what makes the
|
|
661
|
+
* copy-on-write story visible — a workspace that has edited one starter sees five entries, not
|
|
662
|
+
* six — and it is why the partial-unique index on `(workspace_id, key)` exists.
|
|
663
|
+
*
|
|
664
|
+
* Starters are page templates, so `kind: 'space'` answers rows only. That is not a gap: a space
|
|
665
|
+
* template is a shape of somebody's own organisation, and there is no such thing as a generic
|
|
666
|
+
* one worth shipping.
|
|
667
|
+
*/
|
|
668
|
+
async list(tx, workspaceId, kind, spaceId, locale) {
|
|
669
|
+
const rows = await tx
|
|
670
|
+
.select()
|
|
671
|
+
.from(templates)
|
|
672
|
+
.where(and(eq(templates.workspaceId, workspaceId), eq(templates.kind, kind),
|
|
673
|
+
// Workspace-wide always, plus this space's own when a space was named.
|
|
674
|
+
spaceId
|
|
675
|
+
? or(isNull(templates.spaceId), eq(templates.spaceId, spaceId))
|
|
676
|
+
: isNull(templates.spaceId)))
|
|
677
|
+
.orderBy(asc(templates.name));
|
|
678
|
+
const byKey = new Map(rows.filter((row) => row.key !== null).map((row) => [row.key, row]));
|
|
679
|
+
const out = [];
|
|
680
|
+
if (kind === 'page') {
|
|
681
|
+
const s = stringsFor(locale);
|
|
682
|
+
for (const key of TEMPLATE_STARTER_KEYS) {
|
|
683
|
+
const override = byKey.get(key);
|
|
684
|
+
out.push(override ? toChoice(override) : starterChoice(key, s));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Everything else, in name order. A row whose key names a starter this release no longer
|
|
688
|
+
// ships is an ordinary template and lands here, which is the read side of the rule that
|
|
689
|
+
// `Template.key` is a string rather than the enum.
|
|
690
|
+
for (const row of rows)
|
|
691
|
+
if (row.key === null || !TEMPLATE_STARTER_KEYS.includes(row.key))
|
|
692
|
+
out.push(toChoice(row));
|
|
693
|
+
return out;
|
|
694
|
+
},
|
|
695
|
+
async get(tx, workspaceId, templateId) {
|
|
696
|
+
return toTemplate(await this.row(tx, workspaceId, templateId));
|
|
697
|
+
},
|
|
698
|
+
/**
|
|
699
|
+
* Save a page — or a space's whole tree — as a template.
|
|
700
|
+
*
|
|
701
|
+
* The body is read here rather than taken from the caller, and that is the point of the
|
|
702
|
+
* procedure: a client that could post a document would make "save as template" a way to write
|
|
703
|
+
* arbitrary prose into something everybody in the space is then offered.
|
|
704
|
+
*/
|
|
705
|
+
async createFromPage(tx, principal, workspaceId, input) {
|
|
706
|
+
checkVariables(input.variables);
|
|
707
|
+
// The check constraint holds the pair, but a 23514 reaching a person as "violates constraint
|
|
708
|
+
// templates_key_matches_built_in" is not an error anybody can act on.
|
|
709
|
+
if (input.kind === 'space' && input.spaceId !== null)
|
|
710
|
+
throw KernError.badRequest('A space template makes a space, so it cannot live inside one');
|
|
711
|
+
const doc = await this.bodyFor(tx, principal, workspaceId, input.kind, input.sourceId);
|
|
712
|
+
const [row] = await tx
|
|
713
|
+
.insert(templates)
|
|
714
|
+
.values({
|
|
715
|
+
id: uuidv7(),
|
|
716
|
+
workspaceId,
|
|
717
|
+
spaceId: input.spaceId,
|
|
718
|
+
kind: input.kind,
|
|
719
|
+
key: input.key,
|
|
720
|
+
builtIn: input.key !== null,
|
|
721
|
+
name: input.name,
|
|
722
|
+
description: input.description,
|
|
723
|
+
icon: input.icon,
|
|
724
|
+
doc,
|
|
725
|
+
variables: input.variables,
|
|
726
|
+
createdBy: principal.userId,
|
|
727
|
+
})
|
|
728
|
+
.returning()
|
|
729
|
+
.catch((err) => {
|
|
730
|
+
// The partial-unique index on `(workspace_id, key)`: one override per starter.
|
|
731
|
+
if (violates(err, 'templates_ws_key_uq'))
|
|
732
|
+
throw KernError.conflict('This workspace already has its own version of that template');
|
|
733
|
+
throw err;
|
|
734
|
+
});
|
|
735
|
+
return toTemplate(row);
|
|
736
|
+
},
|
|
737
|
+
/** The body a template of this kind takes from this source, checked and read. */
|
|
738
|
+
async bodyFor(tx, principal, workspaceId, kind, sourceId) {
|
|
739
|
+
if (kind === 'space') {
|
|
740
|
+
await access.spaceRow(tx, workspaceId, sourceId);
|
|
741
|
+
return { pages: await treeOfSpace(tx, principal, workspaceId, sourceId) };
|
|
742
|
+
}
|
|
743
|
+
const doc = await docOfPage(tx, workspaceId, sourceId);
|
|
744
|
+
if (!doc || (doc.content ?? []).length === 0)
|
|
745
|
+
throw KernError.badRequest('There is nothing written on this page to make a template from');
|
|
746
|
+
return doc;
|
|
747
|
+
},
|
|
748
|
+
async update(tx, principal, workspaceId, templateId, patch) {
|
|
749
|
+
const existing = await this.row(tx, workspaceId, templateId);
|
|
750
|
+
if (patch.variables)
|
|
751
|
+
checkVariables(patch.variables);
|
|
752
|
+
if (patch.spaceId !== undefined && existing.kind === 'space' && patch.spaceId !== null)
|
|
753
|
+
throw KernError.badRequest('A space template makes a space, so it cannot live inside one');
|
|
754
|
+
const doc = patch.sourceId === undefined
|
|
755
|
+
? undefined
|
|
756
|
+
: await this.bodyFor(tx, principal, workspaceId, existing.kind, patch.sourceId);
|
|
757
|
+
const [row] = await tx
|
|
758
|
+
.update(templates)
|
|
759
|
+
.set({
|
|
760
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
761
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
762
|
+
...(patch.icon === undefined ? {} : { icon: patch.icon }),
|
|
763
|
+
...(patch.spaceId === undefined ? {} : { spaceId: patch.spaceId }),
|
|
764
|
+
...(patch.variables === undefined ? {} : { variables: patch.variables }),
|
|
765
|
+
...(doc === undefined ? {} : { doc }),
|
|
766
|
+
updatedAt: new Date(),
|
|
767
|
+
})
|
|
768
|
+
.where(and(eq(templates.workspaceId, workspaceId), eq(templates.id, templateId)))
|
|
769
|
+
.returning();
|
|
770
|
+
if (!row)
|
|
771
|
+
throw KernError.notFound('Template');
|
|
772
|
+
return toTemplate(row);
|
|
773
|
+
},
|
|
774
|
+
/** Gone. For a row that replaced a starter, the shipped one is back the moment this returns. */
|
|
775
|
+
async remove(tx, workspaceId, templateId) {
|
|
776
|
+
const deleted = await tx
|
|
777
|
+
.delete(templates)
|
|
778
|
+
.where(and(eq(templates.workspaceId, workspaceId), eq(templates.id, templateId)))
|
|
779
|
+
.returning({ id: templates.id });
|
|
780
|
+
if (deleted.length === 0)
|
|
781
|
+
throw KernError.notFound('Template');
|
|
782
|
+
},
|
|
783
|
+
/**
|
|
784
|
+
* What `instantiate` is about to make, whichever of the two ways it was addressed.
|
|
785
|
+
*
|
|
786
|
+
* Resolved in one place so the handler never has to hold "a row or a starter" in its head, and
|
|
787
|
+
* so the refusal for "both" and "neither" is written once.
|
|
788
|
+
*/
|
|
789
|
+
async resolve(tx, workspaceId, locale, templateId, starterKey) {
|
|
790
|
+
if ((templateId === null) === (starterKey === null))
|
|
791
|
+
throw KernError.badRequest('Name a template or a starter, and not both');
|
|
792
|
+
if (starterKey !== null) {
|
|
793
|
+
const s = stringsFor(locale);
|
|
794
|
+
/*
|
|
795
|
+
* A workspace that has edited this starter has a row standing in for it, and addressing the
|
|
796
|
+
* starter by key has to reach that row — otherwise the picker would offer the customised one
|
|
797
|
+
* and pressing it would make the shipped one.
|
|
798
|
+
*/
|
|
799
|
+
const [override] = await tx
|
|
800
|
+
.select()
|
|
801
|
+
.from(templates)
|
|
802
|
+
.where(and(eq(templates.workspaceId, workspaceId), eq(templates.key, starterKey)))
|
|
803
|
+
.limit(1);
|
|
804
|
+
if (override)
|
|
805
|
+
return {
|
|
806
|
+
kind: override.kind,
|
|
807
|
+
name: override.name,
|
|
808
|
+
doc: (override.doc ?? {}),
|
|
809
|
+
variables: (override.variables ?? []),
|
|
810
|
+
};
|
|
811
|
+
return {
|
|
812
|
+
kind: 'page',
|
|
813
|
+
name: s[`${starterKey}.name`],
|
|
814
|
+
doc: starterDoc(starterKey, s),
|
|
815
|
+
variables: [],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
const row = await this.row(tx, workspaceId, templateId);
|
|
819
|
+
return {
|
|
820
|
+
kind: row.kind,
|
|
821
|
+
name: row.name,
|
|
822
|
+
doc: (row.doc ?? {}),
|
|
823
|
+
variables: (row.variables ?? []),
|
|
824
|
+
};
|
|
825
|
+
},
|
|
826
|
+
/**
|
|
827
|
+
* Make a page from a page template.
|
|
828
|
+
*
|
|
829
|
+
* The page is created exactly as `pages.create` makes one — same ranks, same parent rules — and
|
|
830
|
+
* then given a body. A template is a starting point, not a different kind of page.
|
|
831
|
+
*/
|
|
832
|
+
async instantiatePage(tx, principal, workspaceId, resolved, input) {
|
|
833
|
+
const space = await access.spaceRow(tx, workspaceId, input.spaceId);
|
|
834
|
+
const values = valuesFor(principal, principal.locale, space.name, resolved.variables, input.values);
|
|
835
|
+
const title = fillText(input.title || resolved.name, values).slice(0, 300);
|
|
836
|
+
const page = await pagesSvc.create(tx, principal, workspaceId, {
|
|
837
|
+
spaceId: input.spaceId,
|
|
838
|
+
parentId: input.parentId,
|
|
839
|
+
title,
|
|
840
|
+
kind: 'page',
|
|
841
|
+
icon: null,
|
|
842
|
+
afterId: input.afterId,
|
|
843
|
+
});
|
|
844
|
+
await writeBody(tx, workspaceId, page.id, fillPageDoc(resolved.doc, values));
|
|
845
|
+
return { spaceId: input.spaceId, pageId: page.id, pageCount: 1 };
|
|
846
|
+
},
|
|
847
|
+
/**
|
|
848
|
+
* Make a whole space from a space template.
|
|
849
|
+
*
|
|
850
|
+
* The tree is written depth-first with `pages.create`, so every page gets a real rank among its
|
|
851
|
+
* siblings rather than a rank this file invents — the one thing that must not be re-implemented,
|
|
852
|
+
* because two orderings of the same tree is a sidebar that disagrees with itself.
|
|
853
|
+
*/
|
|
854
|
+
async instantiateSpace(tx, principal, workspaceId, resolved, input) {
|
|
855
|
+
const parsed = TemplateSpaceBody.safeParse(resolved.doc);
|
|
856
|
+
if (!parsed.success)
|
|
857
|
+
throw KernError.badRequest('This template does not hold a space');
|
|
858
|
+
const values = valuesFor(principal, principal.locale, input.name, resolved.variables, input.values);
|
|
859
|
+
const space = await spacesSvc.create(tx, principal, workspaceId, {
|
|
860
|
+
key: input.key,
|
|
861
|
+
name: fillText(input.name, values).slice(0, 120),
|
|
862
|
+
description: '',
|
|
863
|
+
icon: null,
|
|
864
|
+
visibility: 'open',
|
|
865
|
+
});
|
|
866
|
+
let made = 0;
|
|
867
|
+
let first = null;
|
|
868
|
+
const write = async (nodes, parentId) => {
|
|
869
|
+
let afterId = null;
|
|
870
|
+
for (const node of nodes) {
|
|
871
|
+
if (made >= MAX_TEMPLATE_PAGES)
|
|
872
|
+
throw KernError.badRequest(`A space template makes at most ${MAX_TEMPLATE_PAGES} pages`);
|
|
873
|
+
const page = await pagesSvc.create(tx, principal, workspaceId, {
|
|
874
|
+
spaceId: space.id,
|
|
875
|
+
parentId,
|
|
876
|
+
title: fillText(node.title, values).slice(0, 300),
|
|
877
|
+
kind: 'page',
|
|
878
|
+
icon: node.icon,
|
|
879
|
+
afterId,
|
|
880
|
+
});
|
|
881
|
+
made += 1;
|
|
882
|
+
first ??= page.id;
|
|
883
|
+
afterId = page.id;
|
|
884
|
+
await writeBody(tx, workspaceId, page.id, fillPageDoc(node.doc, values));
|
|
885
|
+
await write(node.children, page.id);
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
await write(parsed.data.pages, null);
|
|
889
|
+
// Opening a space means opening its home page, so the first page of the template is it. A
|
|
890
|
+
// template with no pages leaves it null rather than pointing at nothing.
|
|
891
|
+
if (first)
|
|
892
|
+
await spacesSvc.update(tx, workspaceId, space.id, { homepageId: first });
|
|
893
|
+
return { spaceId: space.id, pageId: first, pageCount: made };
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
//# sourceMappingURL=templates.js.map
|