@crombolo/server 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** The dictionary port over the repository, with `.udt` import and export. */
|
|
2
|
+
import type { DictionaryPort } from '@crombolo/api-management'
|
|
3
|
+
import { type Db, DictionaryRepository } from '@crombolo/data'
|
|
4
|
+
import { importDictionaryUdt } from '../dictionary-transfer.ts'
|
|
5
|
+
import type { MediaFileStore } from '../media-files.ts'
|
|
6
|
+
import { readUdt, type UdtItem, writeUdt } from '../udt.ts'
|
|
7
|
+
|
|
8
|
+
export function createDictionaryPort(
|
|
9
|
+
db: Db,
|
|
10
|
+
files: MediaFileStore,
|
|
11
|
+
options: { onChange?: () => void } = {},
|
|
12
|
+
): DictionaryPort {
|
|
13
|
+
const repo = new DictionaryRepository(db)
|
|
14
|
+
const changed = <T>(result: T): T => {
|
|
15
|
+
if (result === 'Success') options.onChange?.()
|
|
16
|
+
return result
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function toUdt(key: string, includeChildren: boolean): Promise<UdtItem | undefined> {
|
|
20
|
+
const item = await repo.get(key)
|
|
21
|
+
if (!item) return undefined
|
|
22
|
+
const children: UdtItem[] = []
|
|
23
|
+
if (includeChildren)
|
|
24
|
+
for (const child of await repo.children(item.key)) {
|
|
25
|
+
const nested = await toUdt(child.key, true)
|
|
26
|
+
if (nested) children.push(nested)
|
|
27
|
+
}
|
|
28
|
+
return { key: item.key, name: item.name, translations: item.translations, children }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
all: (filter) => repo.all(filter),
|
|
33
|
+
get: (key) => repo.get(key),
|
|
34
|
+
items: (keys) => repo.many(keys),
|
|
35
|
+
children: (parentKey) => repo.children(parentKey),
|
|
36
|
+
ancestry: (key) => repo.ancestry(key),
|
|
37
|
+
create: async (item) => changed(await repo.create(item)),
|
|
38
|
+
update: async (key, change) => changed(await repo.update(key, change)),
|
|
39
|
+
move: async (key, parentKey) => changed(await repo.move(key, parentKey)),
|
|
40
|
+
delete: async (key) => changed(await repo.delete(key)),
|
|
41
|
+
|
|
42
|
+
async export(key, includeChildren) {
|
|
43
|
+
const item = await toUdt(key, includeChildren)
|
|
44
|
+
if (!item) return undefined
|
|
45
|
+
return { fileName: `${item.name}.udt`, content: writeUdt(item) }
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async import(temporaryFileId, parentKey) {
|
|
49
|
+
const upload = await files.readTemporary(temporaryFileId)
|
|
50
|
+
if (!upload)
|
|
51
|
+
return { ok: false, status: 'NotFound', reason: 'The uploaded file has expired.' }
|
|
52
|
+
if (!upload.fileName.toLowerCase().endsWith('.udt'))
|
|
53
|
+
return { ok: false, status: 'Invalid', reason: 'Only .udt files can be imported.' }
|
|
54
|
+
const text = new TextDecoder().decode(upload.bytes)
|
|
55
|
+
// The reader is consulted first only so a malformed file and an absent
|
|
56
|
+
// parent keep the distinct statuses the contract names for them.
|
|
57
|
+
const items = readUdt(text)
|
|
58
|
+
if (!items)
|
|
59
|
+
return { ok: false, status: 'Invalid', reason: 'The file is not a dictionary export.' }
|
|
60
|
+
if (parentKey && !(await repo.get(parentKey)))
|
|
61
|
+
return { ok: false, status: 'ParentNotFound', reason: 'The parent item does not exist.' }
|
|
62
|
+
|
|
63
|
+
// The same implementation `crombolo dictionary import` uses.
|
|
64
|
+
const outcome = await importDictionaryUdt(db, text, parentKey ?? null)
|
|
65
|
+
await files.deleteTemporary(temporaryFileId)
|
|
66
|
+
if (!outcome.ok)
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
status: 'Invalid',
|
|
70
|
+
reason: 'No item in the file could be imported: its name is already in use.',
|
|
71
|
+
}
|
|
72
|
+
options.onChange?.()
|
|
73
|
+
return { ok: true, key: items[0]?.key as string }
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/** Adapters binding the document repository to the API port and the renderer. */
|
|
2
|
+
import type {
|
|
3
|
+
DocumentPort,
|
|
4
|
+
PortFailure,
|
|
5
|
+
Principal,
|
|
6
|
+
SaveDocument,
|
|
7
|
+
SkipTake,
|
|
8
|
+
} from '@crombolo/api-management'
|
|
9
|
+
import {
|
|
10
|
+
type DocumentAggregate,
|
|
11
|
+
normaliseUuid,
|
|
12
|
+
ObjectTypes,
|
|
13
|
+
type Page,
|
|
14
|
+
type TreeItem,
|
|
15
|
+
toPublishedValue,
|
|
16
|
+
} from '@crombolo/core'
|
|
17
|
+
import {
|
|
18
|
+
appendCacheInstruction,
|
|
19
|
+
ContentTypeRepository,
|
|
20
|
+
type Db,
|
|
21
|
+
DictionaryRepository,
|
|
22
|
+
DocumentRepository,
|
|
23
|
+
type DocumentRepositoryOptions,
|
|
24
|
+
DomainRepository,
|
|
25
|
+
LanguageRepository,
|
|
26
|
+
NOTIFIABLE_ACTIONS,
|
|
27
|
+
type NodeRow,
|
|
28
|
+
NotificationRepository,
|
|
29
|
+
PublishBlockedError,
|
|
30
|
+
RedirectRepository,
|
|
31
|
+
WriteRejectedError,
|
|
32
|
+
} from '@crombolo/data'
|
|
33
|
+
import type { PublishedCache, PublishedContentSource, PublishedNode } from '@crombolo/render'
|
|
34
|
+
import {
|
|
35
|
+
DOMAINS_FILE,
|
|
36
|
+
nodeReferenceFor,
|
|
37
|
+
readDomainsFile,
|
|
38
|
+
replaceNodeDeclarations,
|
|
39
|
+
writeDomains,
|
|
40
|
+
} from '../domains.ts'
|
|
41
|
+
import { TemporaryFileMissingError } from '../file-intake.ts'
|
|
42
|
+
import { NO_REDIRECT_TRACKING, type RedirectTracker } from '../redirects.ts'
|
|
43
|
+
|
|
44
|
+
async function toTreeItems(
|
|
45
|
+
repo: DocumentRepository,
|
|
46
|
+
rows: readonly NodeRow[],
|
|
47
|
+
): Promise<TreeItem[]> {
|
|
48
|
+
const counts = await repo.nodes.childCounts(
|
|
49
|
+
rows.map((row) => row.id),
|
|
50
|
+
ObjectTypes.Document,
|
|
51
|
+
)
|
|
52
|
+
const items: TreeItem[] = []
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
const parent = row.parentId > 0 ? await repo.nodes.byId(row.parentId) : undefined
|
|
55
|
+
const aggregate = await repo.load(row)
|
|
56
|
+
items.push({
|
|
57
|
+
key: row.key,
|
|
58
|
+
name: row.text ?? '',
|
|
59
|
+
hasChildren: (counts.get(row.id) ?? 0) > 0,
|
|
60
|
+
parentKey: parent && parent.objectType === ObjectTypes.Document ? parent.key : null,
|
|
61
|
+
icon: aggregate?.contentTypeIcon ?? 'icon-document',
|
|
62
|
+
isFolder: false,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return items
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createDocumentPort(
|
|
69
|
+
db: Db,
|
|
70
|
+
cache: PublishedCache,
|
|
71
|
+
options: DocumentRepositoryOptions = {},
|
|
72
|
+
/** Without one, a rename or a move records no redirect. */
|
|
73
|
+
tracker: RedirectTracker = NO_REDIRECT_TRACKING,
|
|
74
|
+
/** The site's own directory: where `domains.toml` is, when the site keeps one. */
|
|
75
|
+
siteDir?: string,
|
|
76
|
+
): DocumentPort {
|
|
77
|
+
const repo = new DocumentRepository(db, options)
|
|
78
|
+
const domains = new DomainRepository(db)
|
|
79
|
+
const notifications = new NotificationRepository(db)
|
|
80
|
+
|
|
81
|
+
/** The signed-in user's row id, which versions record as their author. */
|
|
82
|
+
const userIdOf = async (principal: Principal | undefined): Promise<number | undefined> => {
|
|
83
|
+
if (!principal) return undefined
|
|
84
|
+
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
85
|
+
principal.id,
|
|
86
|
+
])
|
|
87
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A node behind the database's schema state may read but not write: 409. */
|
|
91
|
+
const failure = (error: unknown) => {
|
|
92
|
+
if (error instanceof WriteRejectedError)
|
|
93
|
+
return { ok: false as const, reason: error.message, status: 409 }
|
|
94
|
+
if (error instanceof PublishBlockedError) return { ok: false as const, reason: error.message }
|
|
95
|
+
if (error instanceof TemporaryFileMissingError)
|
|
96
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Publishing changes what is reachable, so the cache must be dropped. */
|
|
101
|
+
const invalidate = () => cache.invalidate()
|
|
102
|
+
|
|
103
|
+
const validationFailure = (errors: NonNullable<PortFailure['errors']>): PortFailure => ({
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: 'One or more properties did not pass validation',
|
|
106
|
+
status: 400,
|
|
107
|
+
errors,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
byKey: (key) => repo.byKey(key),
|
|
112
|
+
byKeyPublished: (key) => repo.byKeyPublished(key),
|
|
113
|
+
validate: (input, cultures) => repo.validate(input, cultures),
|
|
114
|
+
|
|
115
|
+
async createAndPublish(input: SaveDocument, cultures: string[] | null, principal: Principal) {
|
|
116
|
+
try {
|
|
117
|
+
const userId = await userIdOf(principal)
|
|
118
|
+
const result = await repo.createAndPublish({ ...input, userId }, cultures)
|
|
119
|
+
if (!result.ok) return validationFailure(result.errors)
|
|
120
|
+
invalidate()
|
|
121
|
+
return { ok: true as const, key: result.document.key }
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return failure(error)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async updateAndPublish(
|
|
128
|
+
key: string,
|
|
129
|
+
input: SaveDocument,
|
|
130
|
+
cultures: string[] | null,
|
|
131
|
+
principal: Principal,
|
|
132
|
+
) {
|
|
133
|
+
try {
|
|
134
|
+
const userId = await userIdOf(principal)
|
|
135
|
+
const before = await tracker.capture(key)
|
|
136
|
+
const result = await repo.updateAndPublish({ ...input, key, userId }, cultures)
|
|
137
|
+
if (!result) return { ok: false as const, reason: 'The document could not be found.' }
|
|
138
|
+
if (!result.ok) return validationFailure(result.errors)
|
|
139
|
+
invalidate()
|
|
140
|
+
await tracker.commit(before)
|
|
141
|
+
return { ok: true as const }
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failure(error)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async treeRoot(paging: SkipTake) {
|
|
148
|
+
const page = await repo.children(null, paging.skip, paging.take)
|
|
149
|
+
return { total: page.total, items: await repo.treeItems(page.items) }
|
|
150
|
+
},
|
|
151
|
+
async treeChildren(parentKey: string, paging: SkipTake) {
|
|
152
|
+
const page = await repo.children(parentKey, paging.skip, paging.take)
|
|
153
|
+
return { total: page.total, items: await repo.treeItems(page.items) }
|
|
154
|
+
},
|
|
155
|
+
treeAncestors: async (descendantKey: string) =>
|
|
156
|
+
repo.treeItems(
|
|
157
|
+
(await repo.nodes.ancestors(descendantKey)).filter(
|
|
158
|
+
(row) => row.objectType === ObjectTypes.Document,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
async documentItems(keys: readonly string[]) {
|
|
162
|
+
const rows = await Promise.all(keys.map((key) => repo.nodes.byKey(key)))
|
|
163
|
+
return repo.treeItems(
|
|
164
|
+
rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.Document),
|
|
165
|
+
)
|
|
166
|
+
},
|
|
167
|
+
async treeSiblings(target: string, before: number, after: number) {
|
|
168
|
+
const window = await repo.siblings(target, before, after)
|
|
169
|
+
if (!window) return undefined
|
|
170
|
+
return { ...window, items: await repo.treeItems(window.items) }
|
|
171
|
+
},
|
|
172
|
+
async search(query, options) {
|
|
173
|
+
const page = await repo.search(query, options)
|
|
174
|
+
return { total: page.total, items: await repo.treeItems(page.items) }
|
|
175
|
+
},
|
|
176
|
+
async recycleBin(parentKey: string | null, paging: SkipTake) {
|
|
177
|
+
const page = await repo.trashed(parentKey, paging.skip, paging.take)
|
|
178
|
+
return { total: page.total, items: await repo.treeItems(page.items) }
|
|
179
|
+
},
|
|
180
|
+
originalParent: (key) => repo.originalParent(key),
|
|
181
|
+
async recycleBinSiblings(target: string, before: number, after: number) {
|
|
182
|
+
const window = await repo.siblings(target, before, after)
|
|
183
|
+
if (!window) return undefined
|
|
184
|
+
return { ...window, items: await repo.treeItems(window.items) }
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async create(input: SaveDocument, principal: Principal) {
|
|
188
|
+
try {
|
|
189
|
+
const created = await repo.create({ ...input, userId: await userIdOf(principal) })
|
|
190
|
+
return { ok: true as const, key: created.key }
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return failure(error)
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async update(key: string, input: SaveDocument, principal: Principal) {
|
|
197
|
+
try {
|
|
198
|
+
const updated = await repo.update({ ...input, key, userId: await userIdOf(principal) })
|
|
199
|
+
if (!updated) return { ok: false as const, reason: 'The document could not be found.' }
|
|
200
|
+
invalidate()
|
|
201
|
+
return { ok: true as const }
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return failure(error)
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async publish(key: string, cultures: string[] | null) {
|
|
208
|
+
try {
|
|
209
|
+
const before = await tracker.capture(key)
|
|
210
|
+
const published = await repo.publish(key, cultures)
|
|
211
|
+
if (!published) return { ok: false as const, reason: 'The document could not be found.' }
|
|
212
|
+
invalidate()
|
|
213
|
+
await tracker.commit(before)
|
|
214
|
+
return { ok: true as const }
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return failure(error)
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async unpublish(key: string, cultures: string[] | null) {
|
|
221
|
+
try {
|
|
222
|
+
const result = await repo.unpublish(key, cultures)
|
|
223
|
+
if (!result) return { ok: false as const, reason: 'The document could not be found.' }
|
|
224
|
+
invalidate()
|
|
225
|
+
return { ok: true as const }
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return failure(error)
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async moveToRecycleBin(key: string) {
|
|
232
|
+
const moved = await repo.moveToRecycleBin(key)
|
|
233
|
+
if (moved) invalidate()
|
|
234
|
+
return moved
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async remove(key: string) {
|
|
238
|
+
const removed = await repo.delete(key)
|
|
239
|
+
if (removed) invalidate()
|
|
240
|
+
return removed
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async move(key, targetKey) {
|
|
244
|
+
try {
|
|
245
|
+
const before = await tracker.capture(key)
|
|
246
|
+
const result = await repo.move(key, targetKey)
|
|
247
|
+
if (result === 'notFound')
|
|
248
|
+
return {
|
|
249
|
+
ok: false as const,
|
|
250
|
+
reason: 'The document or target could not be found.',
|
|
251
|
+
status: 404,
|
|
252
|
+
}
|
|
253
|
+
if (result === 'invalid')
|
|
254
|
+
return {
|
|
255
|
+
ok: false as const,
|
|
256
|
+
reason: 'A document cannot be moved below itself or into the recycle bin.',
|
|
257
|
+
status: 400,
|
|
258
|
+
}
|
|
259
|
+
invalidate()
|
|
260
|
+
await tracker.commit(before)
|
|
261
|
+
return { ok: true as const }
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return failure(error)
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
async copy(key, targetKey, options, principal) {
|
|
268
|
+
try {
|
|
269
|
+
const copied = await repo.copy(key, targetKey, {
|
|
270
|
+
includeDescendants: options.includeDescendants,
|
|
271
|
+
userId: await userIdOf(principal),
|
|
272
|
+
})
|
|
273
|
+
if (!copied)
|
|
274
|
+
return {
|
|
275
|
+
ok: false as const,
|
|
276
|
+
reason: 'The document or target could not be found.',
|
|
277
|
+
status: 404,
|
|
278
|
+
}
|
|
279
|
+
return { ok: true as const, key: copied }
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return failure(error)
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async sort(parentKey, sorting) {
|
|
286
|
+
try {
|
|
287
|
+
const before = await tracker.captureChildren(parentKey)
|
|
288
|
+
const result = await repo.sort(parentKey, sorting)
|
|
289
|
+
if (result === 'notFound')
|
|
290
|
+
return {
|
|
291
|
+
ok: false as const,
|
|
292
|
+
reason: 'The parent, or one of the items, could not be found under it.',
|
|
293
|
+
status: 404,
|
|
294
|
+
}
|
|
295
|
+
invalidate()
|
|
296
|
+
await tracker.commit(before)
|
|
297
|
+
return { ok: true as const }
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return failure(error)
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
async sortChildrenBy(parentKey, field, direction) {
|
|
304
|
+
try {
|
|
305
|
+
const before = await tracker.captureChildren(parentKey)
|
|
306
|
+
const result = await repo.sortChildrenBy(parentKey, field, direction)
|
|
307
|
+
if (result === 'notFound')
|
|
308
|
+
return { ok: false as const, reason: 'The document could not be found.', status: 404 }
|
|
309
|
+
invalidate()
|
|
310
|
+
await tracker.commit(before)
|
|
311
|
+
return { ok: true as const }
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return failure(error)
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
async restore(key, targetKey) {
|
|
318
|
+
try {
|
|
319
|
+
const result = await repo.restore(key, targetKey)
|
|
320
|
+
if (result === 'notFound')
|
|
321
|
+
return {
|
|
322
|
+
ok: false as const,
|
|
323
|
+
reason: 'The document is not in the recycle bin.',
|
|
324
|
+
status: 404,
|
|
325
|
+
}
|
|
326
|
+
if (result === 'invalid')
|
|
327
|
+
return {
|
|
328
|
+
ok: false as const,
|
|
329
|
+
reason: 'The restore target does not exist or is in the recycle bin.',
|
|
330
|
+
status: 400,
|
|
331
|
+
}
|
|
332
|
+
invalidate()
|
|
333
|
+
return { ok: true as const }
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return failure(error)
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
async emptyRecycleBin() {
|
|
340
|
+
for (const node of await repo.trashedRoots()) await repo.delete(node.key)
|
|
341
|
+
invalidate()
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async publishBranch(key, cultures, includeUnpublished) {
|
|
345
|
+
try {
|
|
346
|
+
const before = await tracker.capture(key)
|
|
347
|
+
const result = await repo.publishBranch(key, cultures, includeUnpublished)
|
|
348
|
+
if (!result)
|
|
349
|
+
return { ok: false as const, reason: 'The document could not be found.', status: 404 }
|
|
350
|
+
invalidate()
|
|
351
|
+
await tracker.commit(before)
|
|
352
|
+
return { ok: true as const, ...result }
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return failure(error)
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
atVersion: (versionId) => repo.atVersion(versionId),
|
|
359
|
+
schedule: (key, entries) => repo.setSchedule(key, entries),
|
|
360
|
+
|
|
361
|
+
async domains(key) {
|
|
362
|
+
const node = await repo.nodes.byKey(key)
|
|
363
|
+
if (!node || node.objectType !== repo.objectType) return undefined
|
|
364
|
+
return domains.forNode(node.id)
|
|
365
|
+
},
|
|
366
|
+
async setDomains(key, assignment) {
|
|
367
|
+
const node = await repo.nodes.byKey(key)
|
|
368
|
+
if (!node || node.objectType !== repo.objectType) return { status: 'notFound' as const }
|
|
369
|
+
const result = await domains.setForNode(node.id, assignment)
|
|
370
|
+
if (result.status !== 'ok') return result
|
|
371
|
+
await appendCacheInstruction(db, {
|
|
372
|
+
kind: 'content',
|
|
373
|
+
payload: { key: node.key },
|
|
374
|
+
by: options.nodeId,
|
|
375
|
+
})
|
|
376
|
+
invalidate()
|
|
377
|
+
|
|
378
|
+
// A site that keeps `domains.toml` keeps it in the file: the next boot
|
|
379
|
+
// converges onto it, so a change saved only here would be undone. Only
|
|
380
|
+
// this page's entries are rewritten, so `${VAR}` in the others survives.
|
|
381
|
+
const declarations = siteDir ? readDomainsFile(siteDir) : undefined
|
|
382
|
+
if (!declarations) return result
|
|
383
|
+
await writeDomains(
|
|
384
|
+
siteDir as string,
|
|
385
|
+
replaceNodeDeclarations(
|
|
386
|
+
declarations.declarations,
|
|
387
|
+
await nodeReferenceFor(db, declarations.declarations, node),
|
|
388
|
+
assignment.domains.map((domain) => ({
|
|
389
|
+
host: domain.domainName,
|
|
390
|
+
culture: domain.isoCode || undefined,
|
|
391
|
+
})),
|
|
392
|
+
assignment.defaultIsoCode ?? undefined,
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
return { status: 'ok' as const, file: DOMAINS_FILE }
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
async notifications(key, principal) {
|
|
399
|
+
const node = await repo.nodes.byKey(key)
|
|
400
|
+
const userId = await userIdOf(principal)
|
|
401
|
+
if (!node || node.objectType !== repo.objectType || userId === undefined) return undefined
|
|
402
|
+
const subscribed = new Set(await notifications.subscribed(userId, node.id))
|
|
403
|
+
return NOTIFIABLE_ACTIONS.map((action) => ({
|
|
404
|
+
...action,
|
|
405
|
+
subscribed: subscribed.has(action.actionId),
|
|
406
|
+
}))
|
|
407
|
+
},
|
|
408
|
+
async setNotifications(key, principal, actionIds) {
|
|
409
|
+
const node = await repo.nodes.byKey(key)
|
|
410
|
+
const userId = await userIdOf(principal)
|
|
411
|
+
if (!node || node.objectType !== repo.objectType || userId === undefined) return false
|
|
412
|
+
await notifications.set(userId, node.id, actionIds)
|
|
413
|
+
return true
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
versions: (key) => repo.versions(key),
|
|
417
|
+
async systemUserKey() {
|
|
418
|
+
const rows = await db.query<{ key: string }>(
|
|
419
|
+
'SELECT key FROM user_account ORDER BY id LIMIT 1',
|
|
420
|
+
)
|
|
421
|
+
return rows[0] ? normaliseUuid(String(rows[0].key)) : null
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async rollback(versionId: string) {
|
|
425
|
+
const result = await repo.rollback(versionId)
|
|
426
|
+
if (result) invalidate()
|
|
427
|
+
return result !== undefined
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
setPreventCleanup: (versionId, prevent) => repo.setPreventCleanup(versionId, prevent),
|
|
431
|
+
|
|
432
|
+
// An unpublished document, or culture, has no URL, which the editor renders as such.
|
|
433
|
+
urls: (key: string) => cache.urls(key),
|
|
434
|
+
|
|
435
|
+
async root(paging: SkipTake): Promise<Page<TreeItem>> {
|
|
436
|
+
const page = await repo.children(null, paging.skip, paging.take)
|
|
437
|
+
return { total: page.total, items: await toTreeItems(repo, page.items) }
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
async children(parentKey: string, paging: SkipTake): Promise<Page<TreeItem>> {
|
|
441
|
+
const page = await repo.children(parentKey, paging.skip, paging.take)
|
|
442
|
+
return { total: page.total, items: await toTreeItems(repo, page.items) }
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
async ancestors(descendantKey: string): Promise<TreeItem[]> {
|
|
446
|
+
return toTreeItems(repo, await repo.nodes.ancestors(descendantKey))
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
async items(keys: readonly string[]): Promise<TreeItem[]> {
|
|
450
|
+
return toTreeItems(repo, await repo.nodes.byKeys(keys))
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async collection(parentKey: string | null, paging: SkipTake): Promise<Page<DocumentAggregate>> {
|
|
454
|
+
const page = await repo.children(parentKey, paging.skip, paging.take)
|
|
455
|
+
const items: DocumentAggregate[] = []
|
|
456
|
+
for (const row of page.items) {
|
|
457
|
+
const aggregate = await repo.load(row)
|
|
458
|
+
if (aggregate) items.push(aggregate)
|
|
459
|
+
}
|
|
460
|
+
return { total: page.total, items }
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function createPublishedContentSource(
|
|
466
|
+
db: Db,
|
|
467
|
+
options: DocumentRepositoryOptions = {},
|
|
468
|
+
): PublishedContentSource {
|
|
469
|
+
const repo = new DocumentRepository(db, options)
|
|
470
|
+
return {
|
|
471
|
+
async loadPublished(): Promise<PublishedNode[]> {
|
|
472
|
+
const rows = await repo.loadPublished()
|
|
473
|
+
return rows.map((row) => ({
|
|
474
|
+
key: row.key,
|
|
475
|
+
id: row.id,
|
|
476
|
+
parentId: row.parentId,
|
|
477
|
+
level: row.level,
|
|
478
|
+
path: row.path,
|
|
479
|
+
sortOrder: row.sortOrder,
|
|
480
|
+
name: row.name,
|
|
481
|
+
contentTypeAlias: row.contentTypeAlias,
|
|
482
|
+
componentAlias: row.componentAlias,
|
|
483
|
+
createDate: row.createDate,
|
|
484
|
+
updateDate: row.updateDate,
|
|
485
|
+
cultures: row.cultures,
|
|
486
|
+
names: row.names,
|
|
487
|
+
properties: row.values.map((value) => ({
|
|
488
|
+
alias: value.alias,
|
|
489
|
+
editorAlias: value.editorAlias ?? '',
|
|
490
|
+
culture: value.culture,
|
|
491
|
+
segment: value.segment,
|
|
492
|
+
value: toPublishedValue(value.editorAlias, value.value),
|
|
493
|
+
config: value.config,
|
|
494
|
+
})),
|
|
495
|
+
}))
|
|
496
|
+
},
|
|
497
|
+
async loadMedia() {
|
|
498
|
+
const media = new DocumentRepository(db, { ...options, kind: 'media' })
|
|
499
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
500
|
+
'SELECT unique_id FROM node WHERE node_object_type = ? AND trashed = ?',
|
|
501
|
+
[ObjectTypes.Media, db.dialect.boolValue(false)],
|
|
502
|
+
)
|
|
503
|
+
const nodes = []
|
|
504
|
+
for (const row of rows) {
|
|
505
|
+
const item = await media.byKey(normaliseUuid(String(row.unique_id)))
|
|
506
|
+
if (!item) continue
|
|
507
|
+
nodes.push({
|
|
508
|
+
key: item.key,
|
|
509
|
+
id: (await media.nodes.byKey(item.key))?.id ?? 0,
|
|
510
|
+
name: item.variants[0]?.name ?? '',
|
|
511
|
+
mediaTypeAlias: item.contentTypeAlias,
|
|
512
|
+
properties: item.values.map((value) => ({
|
|
513
|
+
alias: value.alias,
|
|
514
|
+
editorAlias: value.editorAlias ?? '',
|
|
515
|
+
culture: value.culture,
|
|
516
|
+
segment: value.segment,
|
|
517
|
+
value: toPublishedValue(value.editorAlias, value.value),
|
|
518
|
+
config: value.config,
|
|
519
|
+
})),
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
return nodes
|
|
523
|
+
},
|
|
524
|
+
async loadLanguages() {
|
|
525
|
+
return (await new LanguageRepository(db).all()).map((l) => ({
|
|
526
|
+
isoCode: l.isoCode,
|
|
527
|
+
isDefault: l.isDefault,
|
|
528
|
+
fallbackIsoCode: l.fallbackIsoCode,
|
|
529
|
+
}))
|
|
530
|
+
},
|
|
531
|
+
loadDictionary: () => new DictionaryRepository(db).translations(),
|
|
532
|
+
async loadDomains() {
|
|
533
|
+
return (await new DomainRepository(db).all()).map((row) => ({
|
|
534
|
+
nodeId: row.nodeId,
|
|
535
|
+
domainName: row.domainName,
|
|
536
|
+
isoCode: row.isoCode,
|
|
537
|
+
isWildcard: row.isWildcard,
|
|
538
|
+
}))
|
|
539
|
+
},
|
|
540
|
+
/**
|
|
541
|
+
* Published library elements. `loadPublished` is keyed on the repository's own
|
|
542
|
+
* object type, so the same query that builds the content cache builds this one
|
|
543
|
+
* with `kind: 'element'`.
|
|
544
|
+
*/
|
|
545
|
+
async loadElements() {
|
|
546
|
+
const elements = new DocumentRepository(db, { ...options, kind: 'element' })
|
|
547
|
+
const types = new ContentTypeRepository(db)
|
|
548
|
+
const keyOfType = new Map<string, string>()
|
|
549
|
+
const rows = await elements.loadPublished()
|
|
550
|
+
const out = []
|
|
551
|
+
for (const row of rows) {
|
|
552
|
+
if (!keyOfType.has(row.contentTypeAlias))
|
|
553
|
+
keyOfType.set(
|
|
554
|
+
row.contentTypeAlias,
|
|
555
|
+
(await types.byAlias(row.contentTypeAlias))?.key ?? '',
|
|
556
|
+
)
|
|
557
|
+
out.push({
|
|
558
|
+
key: row.key,
|
|
559
|
+
contentTypeKey: keyOfType.get(row.contentTypeAlias) as string,
|
|
560
|
+
contentTypeAlias: row.contentTypeAlias,
|
|
561
|
+
cultures: row.cultures,
|
|
562
|
+
properties: row.values.map((value) => ({
|
|
563
|
+
alias: value.alias,
|
|
564
|
+
editorAlias: value.editorAlias ?? '',
|
|
565
|
+
culture: value.culture,
|
|
566
|
+
segment: value.segment,
|
|
567
|
+
value: toPublishedValue(value.editorAlias, value.value),
|
|
568
|
+
config: value.config,
|
|
569
|
+
})),
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
return out
|
|
573
|
+
},
|
|
574
|
+
loadContentTypeAliases: () => new ContentTypeRepository(db).aliasesByKey(),
|
|
575
|
+
|
|
576
|
+
async loadRedirects() {
|
|
577
|
+
return (await new RedirectRepository(db).all()).map((row) => ({
|
|
578
|
+
key: row.key,
|
|
579
|
+
matchKind: row.matchKind,
|
|
580
|
+
pattern: row.pattern,
|
|
581
|
+
rootKey: row.rootKey,
|
|
582
|
+
culture: row.culture,
|
|
583
|
+
targetKind: row.targetKind,
|
|
584
|
+
target: row.target,
|
|
585
|
+
statusCode: row.statusCode,
|
|
586
|
+
sortOrder: row.sortOrder,
|
|
587
|
+
source: row.source,
|
|
588
|
+
}))
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** A document's current draft in the shape the renderer takes, for preview. */
|
|
594
|
+
export async function loadDraftNode(
|
|
595
|
+
db: Db,
|
|
596
|
+
key: string,
|
|
597
|
+
options: DocumentRepositoryOptions = {},
|
|
598
|
+
): Promise<PublishedNode | undefined> {
|
|
599
|
+
const repo = new DocumentRepository(db, options)
|
|
600
|
+
const node = await repo.nodes.byKey(key)
|
|
601
|
+
if (!node || node.objectType !== repo.objectType) return undefined
|
|
602
|
+
const draft = await repo.load(node)
|
|
603
|
+
if (!draft) return undefined
|
|
604
|
+
const template = draft.componentKey ? await repo.nodes.byKey(draft.componentKey) : undefined
|
|
605
|
+
const componentAlias = template
|
|
606
|
+
? ((
|
|
607
|
+
await db.query<{ alias: string }>('SELECT alias FROM template WHERE node_id = ?', [
|
|
608
|
+
template.id,
|
|
609
|
+
])
|
|
610
|
+
)[0]?.alias ?? null)
|
|
611
|
+
: null
|
|
612
|
+
const invariant = draft.variants.find((v) => v.culture === null) ?? draft.variants[0]
|
|
613
|
+
return {
|
|
614
|
+
key: node.key,
|
|
615
|
+
id: node.id,
|
|
616
|
+
parentId: node.parentId,
|
|
617
|
+
level: node.level,
|
|
618
|
+
path: node.path,
|
|
619
|
+
sortOrder: node.sortOrder,
|
|
620
|
+
name: invariant?.name ?? node.text ?? '',
|
|
621
|
+
contentTypeAlias: draft.contentTypeAlias,
|
|
622
|
+
componentAlias: componentAlias ? String(componentAlias) : null,
|
|
623
|
+
createDate: node.createDate ?? new Date(),
|
|
624
|
+
updateDate: invariant?.updateDate ?? new Date(),
|
|
625
|
+
cultures: draft.variants.flatMap((v) => (v.culture ? [v.culture] : [])),
|
|
626
|
+
names: Object.fromEntries(
|
|
627
|
+
draft.variants.flatMap((v) => (v.culture ? [[v.culture, v.name]] : [])),
|
|
628
|
+
),
|
|
629
|
+
properties: draft.values.map((value) => ({
|
|
630
|
+
alias: value.alias,
|
|
631
|
+
editorAlias: value.editorAlias ?? '',
|
|
632
|
+
culture: value.culture,
|
|
633
|
+
segment: value.segment,
|
|
634
|
+
value: toPublishedValue(value.editorAlias, value.value),
|
|
635
|
+
})),
|
|
636
|
+
}
|
|
637
|
+
}
|