@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,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapters binding the data repositories to the Management API ports.
|
|
3
|
+
*
|
|
4
|
+
* Tree shaping lives here because "is this a folder", "does it have children" and
|
|
5
|
+
* "which icon" are presentation concerns rather than storage ones.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
ContentTypePort,
|
|
9
|
+
DataTypePort,
|
|
10
|
+
FolderPort,
|
|
11
|
+
PortFailure,
|
|
12
|
+
Principal,
|
|
13
|
+
SkipTake,
|
|
14
|
+
TemplatePort,
|
|
15
|
+
TreePage,
|
|
16
|
+
} from '@crombolo/api-management'
|
|
17
|
+
import {
|
|
18
|
+
type ComponentModel,
|
|
19
|
+
type ContentTypeAggregate,
|
|
20
|
+
type DataTypeModel,
|
|
21
|
+
ObjectTypes,
|
|
22
|
+
type Page,
|
|
23
|
+
SystemNodes,
|
|
24
|
+
type TreeItem,
|
|
25
|
+
} from '@crombolo/core'
|
|
26
|
+
import {
|
|
27
|
+
CONTENT_TYPE_OBJECT_TYPES,
|
|
28
|
+
type ComponentFileStore,
|
|
29
|
+
ComponentRepository,
|
|
30
|
+
type ContentTypeKind,
|
|
31
|
+
ContentTypeRepository,
|
|
32
|
+
type ContentTypeRepositoryOptions,
|
|
33
|
+
DataTypeRepository,
|
|
34
|
+
type Db,
|
|
35
|
+
FolderRepository,
|
|
36
|
+
Locks,
|
|
37
|
+
type NodeRepository,
|
|
38
|
+
type NodeRow,
|
|
39
|
+
} from '@crombolo/data'
|
|
40
|
+
import { aliasFromName } from '@crombolo/schema'
|
|
41
|
+
import { readContentTypeUdt, writeContentTypeUdt } from '../content-type-udt.ts'
|
|
42
|
+
import { classifyTypeChange } from '../schema-classify.ts'
|
|
43
|
+
import { contentTypeJsonSchema, schemaForStorage, valueTypeName } from '../value-schemas.ts'
|
|
44
|
+
import { createSchemaFileWriter, type SchemaFiles } from './schema-files.ts'
|
|
45
|
+
|
|
46
|
+
async function toTreeItems(
|
|
47
|
+
nodes: NodeRepository,
|
|
48
|
+
rows: readonly NodeRow[],
|
|
49
|
+
objectType: string,
|
|
50
|
+
icon: (row: NodeRow) => string | null,
|
|
51
|
+
): Promise<TreeItem[]> {
|
|
52
|
+
// One grouped query rather than one per row.
|
|
53
|
+
const counts = await nodes.childCounts(
|
|
54
|
+
rows.map((row) => row.id),
|
|
55
|
+
objectType,
|
|
56
|
+
)
|
|
57
|
+
const items: TreeItem[] = []
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
const parent = row.parentId > 0 ? await nodes.byId(row.parentId) : undefined
|
|
60
|
+
items.push({
|
|
61
|
+
key: row.key,
|
|
62
|
+
name: row.text ?? '',
|
|
63
|
+
hasChildren: (counts.get(row.id) ?? 0) > 0,
|
|
64
|
+
parentKey: parent?.key ?? null,
|
|
65
|
+
icon: icon(row),
|
|
66
|
+
isFolder: false,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
return items
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A settings tree mixes folders and items. Folders come first; an item's
|
|
74
|
+
* decoration (icon, element flag, editor) is filled in by the caller.
|
|
75
|
+
*/
|
|
76
|
+
export function createSettingsTree(
|
|
77
|
+
nodes: NodeRepository,
|
|
78
|
+
containerType: string,
|
|
79
|
+
itemType: string,
|
|
80
|
+
decorate: (item: TreeItem) => Promise<void>,
|
|
81
|
+
) {
|
|
82
|
+
const types = (foldersOnly: boolean | undefined) =>
|
|
83
|
+
foldersOnly ? [containerType] : [containerType, itemType]
|
|
84
|
+
|
|
85
|
+
const shape = async (rows: readonly NodeRow[]): Promise<TreeItem[]> => {
|
|
86
|
+
const items: TreeItem[] = []
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const parent = row.parentId > 0 ? await nodes.byId(row.parentId) : undefined
|
|
89
|
+
const inside = await nodes.childrenOf(row.id, [containerType, itemType], 0, 1)
|
|
90
|
+
const item: TreeItem = {
|
|
91
|
+
key: row.key,
|
|
92
|
+
name: row.text ?? '',
|
|
93
|
+
hasChildren: inside.total > 0,
|
|
94
|
+
parentKey: parent && parent.objectType === containerType ? parent.key : null,
|
|
95
|
+
icon: row.objectType === containerType ? 'icon-folder' : null,
|
|
96
|
+
isFolder: row.objectType === containerType,
|
|
97
|
+
}
|
|
98
|
+
if (!item.isFolder) await decorate(item)
|
|
99
|
+
items.push(item)
|
|
100
|
+
}
|
|
101
|
+
return items
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const page = async (parentKey: string | null, paging: TreePage): Promise<Page<TreeItem>> => {
|
|
105
|
+
const parentId = parentKey ? (await nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
106
|
+
if (parentId === undefined) return { total: 0, items: [] }
|
|
107
|
+
const result = await nodes.childrenOf(
|
|
108
|
+
parentId,
|
|
109
|
+
types(paging.foldersOnly),
|
|
110
|
+
paging.skip,
|
|
111
|
+
paging.take,
|
|
112
|
+
)
|
|
113
|
+
return { total: result.total, items: await shape(result.items) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
shape,
|
|
118
|
+
nodes,
|
|
119
|
+
containerType,
|
|
120
|
+
itemType,
|
|
121
|
+
treeRoot: (paging: TreePage) => page(null, paging),
|
|
122
|
+
treeChildren: (parentKey: string, paging: TreePage) => page(parentKey, paging),
|
|
123
|
+
ancestors: async (descendantKey: string) => shape(await nodes.ancestors(descendantKey)),
|
|
124
|
+
items: async (keys: readonly string[]) => shape(await nodes.byKeys(keys)),
|
|
125
|
+
async siblings(target: string, before: number, after: number, foldersOnly?: boolean) {
|
|
126
|
+
const window = await nodes.siblings(target, types(foldersOnly), before, after)
|
|
127
|
+
if (!window) return undefined
|
|
128
|
+
return { ...window, items: await shape(window.items) }
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Runs a port's schema-changing methods under the schema lock, so a change and
|
|
135
|
+
* the files it rewrites land together before the schema watcher reads them.
|
|
136
|
+
*/
|
|
137
|
+
export function underSchemaLock<P extends object>(
|
|
138
|
+
db: Db,
|
|
139
|
+
port: P,
|
|
140
|
+
methods: readonly (keyof P)[],
|
|
141
|
+
): P {
|
|
142
|
+
const locked = { ...port }
|
|
143
|
+
for (const name of methods) {
|
|
144
|
+
const method = port[name] as (...args: unknown[]) => Promise<unknown>
|
|
145
|
+
locked[name] = ((...args: unknown[]) =>
|
|
146
|
+
db.locks.withLock(Locks.Schema, () => method(...args))) as P[typeof name]
|
|
147
|
+
}
|
|
148
|
+
return locked
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createFolderPort(
|
|
152
|
+
folders: FolderRepository,
|
|
153
|
+
tree: ReturnType<typeof createSettingsTree>,
|
|
154
|
+
afterChange: () => Promise<void>,
|
|
155
|
+
): FolderPort {
|
|
156
|
+
return {
|
|
157
|
+
folder: (key) => folders.byKey(key),
|
|
158
|
+
async createFolder(input) {
|
|
159
|
+
const created = await folders.create(input)
|
|
160
|
+
return { key: created.key }
|
|
161
|
+
},
|
|
162
|
+
async renameFolder(key, name) {
|
|
163
|
+
const ok = await folders.rename(key, name)
|
|
164
|
+
if (ok) await afterChange()
|
|
165
|
+
return ok
|
|
166
|
+
},
|
|
167
|
+
async deleteFolder(key) {
|
|
168
|
+
const result = await folders.delete(key)
|
|
169
|
+
if (result === 'deleted') await afterChange()
|
|
170
|
+
return result
|
|
171
|
+
},
|
|
172
|
+
siblings: tree.siblings,
|
|
173
|
+
async search(query, kind, paging) {
|
|
174
|
+
const types = [
|
|
175
|
+
...(kind !== 'Item' ? [tree.containerType] : []),
|
|
176
|
+
...(kind !== 'Folder' ? [tree.itemType] : []),
|
|
177
|
+
]
|
|
178
|
+
const hits: NodeRow[] = []
|
|
179
|
+
for (const type of types)
|
|
180
|
+
hits.push(...(await tree.nodes.search(type, query, { skip: 0, take: 10_000 })).items)
|
|
181
|
+
hits.sort(
|
|
182
|
+
(a, b) =>
|
|
183
|
+
Number(b.objectType === tree.containerType) -
|
|
184
|
+
Number(a.objectType === tree.containerType) ||
|
|
185
|
+
(a.text ?? '').localeCompare(b.text ?? ''),
|
|
186
|
+
)
|
|
187
|
+
return {
|
|
188
|
+
total: hits.length,
|
|
189
|
+
items: await tree.shape(hits.slice(paging.skip, paging.skip + paging.take)),
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface ContentTypePortOptions extends ContentTypeRepositoryOptions {
|
|
196
|
+
/** Content types by default; media types share the port. */
|
|
197
|
+
kind?: ContentTypeKind
|
|
198
|
+
/** Lets "Create template" write the view file. */
|
|
199
|
+
componentFiles?: ComponentFileStore
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createContentTypePort(
|
|
203
|
+
db: Db,
|
|
204
|
+
schemaFiles?: SchemaFiles,
|
|
205
|
+
options: ContentTypePortOptions = {},
|
|
206
|
+
): ContentTypePort {
|
|
207
|
+
const kind = options.kind ?? 'document'
|
|
208
|
+
const repo = new ContentTypeRepository(db, { nodeState: options.nodeState, kind })
|
|
209
|
+
const nodes = repo.nodes
|
|
210
|
+
const objectType = repo.objectType
|
|
211
|
+
const containerType = CONTENT_TYPE_OBJECT_TYPES[kind].container
|
|
212
|
+
const folders = new FolderRepository(db, containerType, objectType)
|
|
213
|
+
const files = schemaFiles ? createSchemaFileWriter(db, schemaFiles, kind) : undefined
|
|
214
|
+
const templates = new ComponentRepository(db, options.componentFiles)
|
|
215
|
+
const rewrite = () => files?.rewriteAll() ?? Promise.resolve()
|
|
216
|
+
|
|
217
|
+
const decorate = async (item: TreeItem) => {
|
|
218
|
+
const aggregate = await repo.byKey(item.key)
|
|
219
|
+
item.icon = aggregate?.icon ?? (kind === 'document' ? 'icon-document' : 'icon-picture')
|
|
220
|
+
item.isElement = aggregate?.isElement ?? false
|
|
221
|
+
}
|
|
222
|
+
const tree = createSettingsTree(nodes, containerType, objectType, decorate)
|
|
223
|
+
const shape = async (rows: readonly NodeRow[]): Promise<TreeItem[]> => {
|
|
224
|
+
const items = await toTreeItems(nodes, rows, objectType, () => null)
|
|
225
|
+
for (const item of items) await decorate(item)
|
|
226
|
+
return items
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return underSchemaLock(
|
|
230
|
+
db,
|
|
231
|
+
{
|
|
232
|
+
byKey: (key) => repo.byKey(key),
|
|
233
|
+
byKeys: (keys) => repo.byKeys(keys),
|
|
234
|
+
|
|
235
|
+
async create(aggregate, principal: Principal) {
|
|
236
|
+
if (await repo.aliasExists(aggregate.alias)) {
|
|
237
|
+
return { ok: false as const, reason: `The alias '${aggregate.alias}' is already in use.` }
|
|
238
|
+
}
|
|
239
|
+
const refused = files?.refuse(aggregate)
|
|
240
|
+
if (refused) return refused
|
|
241
|
+
// A new type can only be additive, and the version moves before the write so
|
|
242
|
+
// the file that lands carries the version it belongs to.
|
|
243
|
+
await files?.bumpVersion('additive')
|
|
244
|
+
await repo.save(aggregate)
|
|
245
|
+
await files?.write(aggregate)
|
|
246
|
+
void principal
|
|
247
|
+
return { ok: true as const }
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async update(key, aggregate, principal: Principal) {
|
|
251
|
+
if (await repo.aliasExists(aggregate.alias, key)) {
|
|
252
|
+
return { ok: false as const, reason: `The alias '${aggregate.alias}' is already in use.` }
|
|
253
|
+
}
|
|
254
|
+
const refused = files?.refuse(aggregate)
|
|
255
|
+
if (refused) return refused
|
|
256
|
+
const before = await repo.byKey(key)
|
|
257
|
+
// Classified here, while the database still holds the old type: once it is
|
|
258
|
+
// saved there is nothing left to compare it with.
|
|
259
|
+
if (files)
|
|
260
|
+
await files.bumpVersion(await classifyTypeChange(db, before, { ...aggregate, key }))
|
|
261
|
+
// The editor does not send tree placement; keep what the type has.
|
|
262
|
+
await repo.save({ ...aggregate, key, parentKey: before?.parentKey ?? aggregate.parentKey })
|
|
263
|
+
await files?.write({ ...aggregate, key }, before?.alias)
|
|
264
|
+
void principal
|
|
265
|
+
return { ok: true as const }
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async remove(key) {
|
|
269
|
+
const before = await repo.byKey(key)
|
|
270
|
+
const removed = await repo.delete(key)
|
|
271
|
+
if (removed && before) await files?.remove(before.alias)
|
|
272
|
+
return removed
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
allowedAtRoot: (paging) => repo.allowedAtRoot(paging.skip, paging.take),
|
|
276
|
+
allowedInLibrary: (paging) => repo.allowedInLibrary(paging.skip, paging.take),
|
|
277
|
+
allowedChildren: (key, paging) => repo.allowedChildren(key, paging.skip, paging.take),
|
|
278
|
+
search: (query, options) => repo.search(query, options),
|
|
279
|
+
compositionReferences: (key) => repo.compositionReferences(key),
|
|
280
|
+
allowedParents: (key) => repo.allowedParents(key),
|
|
281
|
+
|
|
282
|
+
async availableCompositions(request) {
|
|
283
|
+
const available = await repo.availableCompositions(request)
|
|
284
|
+
const out: Array<{
|
|
285
|
+
type: ContentTypeAggregate
|
|
286
|
+
folderPath: string[]
|
|
287
|
+
isCompatible: boolean
|
|
288
|
+
}> = []
|
|
289
|
+
for (const a of available) out.push({ ...a, folderPath: await folders.pathOf(a.type.key) })
|
|
290
|
+
return out
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async move(key, parentKey) {
|
|
294
|
+
const moved = await repo.move(key, parentKey)
|
|
295
|
+
if (moved) await rewrite()
|
|
296
|
+
return moved
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async copy(key, parentKey) {
|
|
300
|
+
const copy = await repo.copy(key, parentKey)
|
|
301
|
+
if (copy) await files?.write(copy)
|
|
302
|
+
return copy
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async createTemplate(key, template, principal: Principal) {
|
|
306
|
+
const aggregate = await repo.byKey(key)
|
|
307
|
+
if (!aggregate) {
|
|
308
|
+
const failure: PortFailure = {
|
|
309
|
+
ok: false,
|
|
310
|
+
reason: 'The content type could not be found.',
|
|
311
|
+
status: 404,
|
|
312
|
+
}
|
|
313
|
+
return failure
|
|
314
|
+
}
|
|
315
|
+
const refused = files?.refuse(aggregate)
|
|
316
|
+
if (refused) return refused
|
|
317
|
+
const alias = template.alias || aggregate.alias
|
|
318
|
+
const name = template.name || aggregate.name
|
|
319
|
+
const saved =
|
|
320
|
+
(await templates.byAlias(alias)) ??
|
|
321
|
+
(await templates.save({
|
|
322
|
+
key: crypto.randomUUID(),
|
|
323
|
+
name,
|
|
324
|
+
alias,
|
|
325
|
+
content: scaffoldTemplate(name, alias),
|
|
326
|
+
}))
|
|
327
|
+
const allowed = aggregate.allowedComponentKeys.includes(saved.key)
|
|
328
|
+
? aggregate.allowedComponentKeys
|
|
329
|
+
: [...aggregate.allowedComponentKeys, saved.key]
|
|
330
|
+
const updated: ContentTypeAggregate = {
|
|
331
|
+
...aggregate,
|
|
332
|
+
allowedComponentKeys: allowed,
|
|
333
|
+
defaultComponentKey:
|
|
334
|
+
template.isDefault || !aggregate.defaultComponentKey
|
|
335
|
+
? saved.key
|
|
336
|
+
: aggregate.defaultComponentKey,
|
|
337
|
+
}
|
|
338
|
+
await repo.save(updated)
|
|
339
|
+
await files?.write(updated)
|
|
340
|
+
void principal
|
|
341
|
+
return { ok: true as const, key: saved.key }
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async exportUdt(key) {
|
|
345
|
+
const aggregate = await repo.byKey(key)
|
|
346
|
+
if (!aggregate) return undefined
|
|
347
|
+
const aliases = new Map<string, string>()
|
|
348
|
+
for (const reference of [
|
|
349
|
+
...aggregate.compositions.map((c) => c.contentTypeKey),
|
|
350
|
+
...aggregate.allowedContentTypes.map((a) => a.contentTypeKey),
|
|
351
|
+
]) {
|
|
352
|
+
const other = await repo.byKey(reference)
|
|
353
|
+
if (other) aliases.set(reference, other.alias)
|
|
354
|
+
}
|
|
355
|
+
const componentAliases = new Map<string, string>()
|
|
356
|
+
for (const componentKey of [
|
|
357
|
+
...aggregate.allowedComponentKeys,
|
|
358
|
+
...(aggregate.defaultComponentKey ? [aggregate.defaultComponentKey] : []),
|
|
359
|
+
]) {
|
|
360
|
+
const template = await templates.byKey(componentKey)
|
|
361
|
+
if (template) componentAliases.set(componentKey, template.alias)
|
|
362
|
+
}
|
|
363
|
+
return writeContentTypeUdt(aggregate, kind, {
|
|
364
|
+
aliasOf: (k: string) => aliases.get(k),
|
|
365
|
+
templateAliasOf: (k: string) => componentAliases.get(k),
|
|
366
|
+
})
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async importUdt(xml, targetKey) {
|
|
370
|
+
const parsed = readContentTypeUdt(xml)
|
|
371
|
+
if (!parsed) return { status: 'invalid', reason: 'The file is not a content type export.' }
|
|
372
|
+
if (parsed.kind !== kind)
|
|
373
|
+
return { status: 'mismatch', reason: `The file describes a ${parsed.kind} type.` }
|
|
374
|
+
|
|
375
|
+
const existing = targetKey ? await repo.byKey(targetKey) : undefined
|
|
376
|
+
if (targetKey && !existing) return { status: 'not-found' }
|
|
377
|
+
if (existing && parsed.alias !== existing.alias)
|
|
378
|
+
return {
|
|
379
|
+
status: 'mismatch',
|
|
380
|
+
reason: `The file describes '${parsed.alias}', not '${existing.alias}'.`,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const dataTypes = new DataTypeRepository(db)
|
|
384
|
+
// Creating from a file exported elsewhere: its container and property keys
|
|
385
|
+
// still belong to that type, so a new type mints its own. An update keeps
|
|
386
|
+
// them, because the alias guard above proved the file describes this type.
|
|
387
|
+
const reuseKeys = existing !== undefined
|
|
388
|
+
const containers = parsed.containers.map(
|
|
389
|
+
(container: (typeof parsed.containers)[number]) => ({
|
|
390
|
+
key: (reuseKeys && container.key) || crypto.randomUUID(),
|
|
391
|
+
name: container.name,
|
|
392
|
+
alias: container.alias,
|
|
393
|
+
type: container.type,
|
|
394
|
+
sortOrder: container.sortOrder,
|
|
395
|
+
parentKey: null,
|
|
396
|
+
}),
|
|
397
|
+
)
|
|
398
|
+
const byAlias = new Map(
|
|
399
|
+
containers.map((container: { alias: string; key: string }) => [
|
|
400
|
+
container.alias,
|
|
401
|
+
container.key,
|
|
402
|
+
]),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
const properties = []
|
|
406
|
+
for (const property of parsed.properties) {
|
|
407
|
+
if (!property.dataTypeKey)
|
|
408
|
+
return { status: 'invalid', reason: `'${property.alias}' names no data type.` }
|
|
409
|
+
if (!(await dataTypes.byKey(property.dataTypeKey)))
|
|
410
|
+
return {
|
|
411
|
+
status: 'invalid',
|
|
412
|
+
reason: `The data type for '${property.alias}' is not on this site.`,
|
|
413
|
+
}
|
|
414
|
+
properties.push({
|
|
415
|
+
key: (reuseKeys && property.key) || crypto.randomUUID(),
|
|
416
|
+
alias: property.alias,
|
|
417
|
+
name: property.name,
|
|
418
|
+
description: property.description,
|
|
419
|
+
dataTypeKey: property.dataTypeKey,
|
|
420
|
+
containerKey: property.tabAlias ? (byAlias.get(property.tabAlias) ?? null) : null,
|
|
421
|
+
sortOrder: property.sortOrder,
|
|
422
|
+
variesByCulture: property.variesByCulture,
|
|
423
|
+
variesBySegment: property.variesBySegment,
|
|
424
|
+
mandatory: property.mandatory,
|
|
425
|
+
mandatoryMessage: property.mandatoryMessage,
|
|
426
|
+
regEx: property.regEx,
|
|
427
|
+
regExMessage: property.regExMessage,
|
|
428
|
+
labelOnTop: property.labelOnTop,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const keyFor = async (alias: string) => (await repo.byAlias(alias))?.key
|
|
433
|
+
const compositions = []
|
|
434
|
+
for (const alias of parsed.compositionAliases) {
|
|
435
|
+
const found = await keyFor(alias)
|
|
436
|
+
if (found)
|
|
437
|
+
compositions.push({ contentTypeKey: found, compositionType: 'Composition' as const })
|
|
438
|
+
}
|
|
439
|
+
const allowed = []
|
|
440
|
+
for (const [index, alias] of parsed.allowedAliases.entries()) {
|
|
441
|
+
const found = await keyFor(alias)
|
|
442
|
+
if (found) allowed.push({ contentTypeKey: found, sortOrder: index })
|
|
443
|
+
}
|
|
444
|
+
const componentKeys = []
|
|
445
|
+
for (const alias of parsed.componentAliases) {
|
|
446
|
+
const template = await templates.byAlias(alias)
|
|
447
|
+
if (template) componentKeys.push(template.key)
|
|
448
|
+
}
|
|
449
|
+
const defaultComponent = parsed.defaultTemplateAlias
|
|
450
|
+
? ((await templates.byAlias(parsed.defaultTemplateAlias))?.key ?? null)
|
|
451
|
+
: null
|
|
452
|
+
|
|
453
|
+
const aggregate: ContentTypeAggregate = {
|
|
454
|
+
...(existing ?? {
|
|
455
|
+
collectionKey: null,
|
|
456
|
+
cleanup: {
|
|
457
|
+
preventCleanup: false,
|
|
458
|
+
keepAllVersionsNewerThanDays: null,
|
|
459
|
+
keepLatestVersionPerDayForDays: null,
|
|
460
|
+
},
|
|
461
|
+
parentKey: null,
|
|
462
|
+
}),
|
|
463
|
+
key: existing?.key ?? crypto.randomUUID(),
|
|
464
|
+
alias: parsed.alias,
|
|
465
|
+
name: parsed.name,
|
|
466
|
+
description: parsed.description,
|
|
467
|
+
icon: parsed.icon ?? 'icon-document',
|
|
468
|
+
allowedAsRoot: parsed.allowedAsRoot,
|
|
469
|
+
isElement: parsed.isElement,
|
|
470
|
+
allowedInLibrary: parsed.allowedInLibrary,
|
|
471
|
+
variesByCulture: parsed.variesByCulture,
|
|
472
|
+
variesBySegment: parsed.variesBySegment,
|
|
473
|
+
properties,
|
|
474
|
+
containers,
|
|
475
|
+
compositions,
|
|
476
|
+
allowedContentTypes: allowed,
|
|
477
|
+
allowedComponentKeys: componentKeys,
|
|
478
|
+
defaultComponentKey: defaultComponent,
|
|
479
|
+
} as ContentTypeAggregate
|
|
480
|
+
|
|
481
|
+
if (!existing && (await repo.aliasExists(aggregate.alias)))
|
|
482
|
+
return { status: 'invalid', reason: `The alias '${aggregate.alias}' is already in use.` }
|
|
483
|
+
await repo.save(aggregate)
|
|
484
|
+
await files?.write(aggregate)
|
|
485
|
+
return { status: 'ok', key: aggregate.key }
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
async jsonSchema(key) {
|
|
489
|
+
const aggregate = await repo.byKey(key)
|
|
490
|
+
if (!aggregate) return undefined
|
|
491
|
+
const dataTypes = new DataTypeRepository(db)
|
|
492
|
+
const properties = []
|
|
493
|
+
for (const property of aggregate.properties) {
|
|
494
|
+
const dataType = await dataTypes.byKey(property.dataTypeKey)
|
|
495
|
+
properties.push({
|
|
496
|
+
alias: property.alias,
|
|
497
|
+
name: property.name,
|
|
498
|
+
description: property.description,
|
|
499
|
+
mandatory: property.mandatory,
|
|
500
|
+
storage: dataType?.dbType ?? 'Ntext',
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
return contentTypeJsonSchema(aggregate, properties)
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
folders: underSchemaLock(db, createFolderPort(folders, tree, rewrite), [
|
|
507
|
+
'createFolder',
|
|
508
|
+
'renameFolder',
|
|
509
|
+
'deleteFolder',
|
|
510
|
+
]),
|
|
511
|
+
treeRoot: tree.treeRoot,
|
|
512
|
+
treeChildren: tree.treeChildren,
|
|
513
|
+
|
|
514
|
+
async root(paging: SkipTake): Promise<Page<TreeItem>> {
|
|
515
|
+
const page = await nodes.roots(objectType, paging.skip, paging.take)
|
|
516
|
+
return { total: page.total, items: await shape(page.items) }
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
async children(parentKey: string, paging: SkipTake): Promise<Page<TreeItem>> {
|
|
520
|
+
const parent = await nodes.byKey(parentKey)
|
|
521
|
+
if (!parent) return { total: 0, items: [] }
|
|
522
|
+
const page = await nodes.children(parent.id, objectType, paging.skip, paging.take)
|
|
523
|
+
return { total: page.total, items: await shape(page.items) }
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
ancestors: tree.ancestors,
|
|
527
|
+
items: tree.items,
|
|
528
|
+
},
|
|
529
|
+
['create', 'update', 'remove', 'move', 'copy', 'createTemplate', 'importUdt'],
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function createDataTypePort(db: Db, schemaFiles?: SchemaFiles): DataTypePort {
|
|
534
|
+
const repo = new DataTypeRepository(db)
|
|
535
|
+
const nodes = repo.nodes
|
|
536
|
+
const objectType = ObjectTypes.DataType
|
|
537
|
+
const folders = new FolderRepository(db, ObjectTypes.DataTypeContainer, objectType)
|
|
538
|
+
const files = schemaFiles ? createSchemaFileWriter(db, schemaFiles) : undefined
|
|
539
|
+
const rewrite = () => files?.rewriteAll() ?? Promise.resolve()
|
|
540
|
+
const tree = createSettingsTree(
|
|
541
|
+
nodes,
|
|
542
|
+
ObjectTypes.DataTypeContainer,
|
|
543
|
+
objectType,
|
|
544
|
+
async (item) => {
|
|
545
|
+
const model = await repo.byKey(item.key)
|
|
546
|
+
item.icon = 'icon-autofill'
|
|
547
|
+
item.editorUiAlias = model?.editorUiAlias ?? null
|
|
548
|
+
},
|
|
549
|
+
)
|
|
550
|
+
const shape = (rows: readonly NodeRow[]) =>
|
|
551
|
+
toTreeItems(nodes, rows, objectType, () => 'icon-autofill')
|
|
552
|
+
|
|
553
|
+
/** A data type saved in the backoffice gets a file alias from its name, once. */
|
|
554
|
+
const withAlias = async (model: DataTypeModel): Promise<DataTypeModel> => {
|
|
555
|
+
const existing = await repo.byKey(model.key)
|
|
556
|
+
if (existing?.alias) return { ...model, alias: existing.alias }
|
|
557
|
+
if (model.alias) return model
|
|
558
|
+
const base = aliasFromName(model.name)
|
|
559
|
+
let alias = base
|
|
560
|
+
for (let i = 2; await repo.byAlias(alias); i++) alias = `${base}${i}`
|
|
561
|
+
return { ...model, alias }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return underSchemaLock(
|
|
565
|
+
db,
|
|
566
|
+
{
|
|
567
|
+
byKey: (key) => repo.byKey(key),
|
|
568
|
+
byKeys: (keys) => repo.byKeys(keys),
|
|
569
|
+
async valueSchema(key) {
|
|
570
|
+
const model = await repo.byKey(key)
|
|
571
|
+
if (!model) return undefined
|
|
572
|
+
return {
|
|
573
|
+
valueTypeName: valueTypeName(model.dbType),
|
|
574
|
+
jsonSchema: schemaForStorage(model.dbType),
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
async save(model: DataTypeModel) {
|
|
578
|
+
const saved = await repo.save(await withAlias(model))
|
|
579
|
+
await files?.writeDataType(saved)
|
|
580
|
+
},
|
|
581
|
+
async remove(key) {
|
|
582
|
+
const before = await repo.byKey(key)
|
|
583
|
+
if (!before) return false
|
|
584
|
+
if ((await repo.referencedBy(key)).length > 0) return 'in-use'
|
|
585
|
+
const removed = await repo.delete(key)
|
|
586
|
+
if (removed && before.alias) await files?.removeDataType(before.alias)
|
|
587
|
+
return removed
|
|
588
|
+
},
|
|
589
|
+
filter: (criteria, paging) => repo.filter(criteria, paging),
|
|
590
|
+
search: (query, paging) => repo.filter({ name: query }, paging),
|
|
591
|
+
referencedBy: (key) => repo.referencedBy(key),
|
|
592
|
+
async move(key, parentKey) {
|
|
593
|
+
const moved = await repo.move(key, parentKey)
|
|
594
|
+
if (moved) await rewrite()
|
|
595
|
+
return moved
|
|
596
|
+
},
|
|
597
|
+
async copy(key, parentKey) {
|
|
598
|
+
const copy = await repo.copy(key, parentKey)
|
|
599
|
+
if (!copy) return undefined
|
|
600
|
+
const aliased = await repo.save(await withAlias(copy))
|
|
601
|
+
await files?.writeDataType(aliased)
|
|
602
|
+
return aliased
|
|
603
|
+
},
|
|
604
|
+
folders: underSchemaLock(db, createFolderPort(folders, tree, rewrite), [
|
|
605
|
+
'createFolder',
|
|
606
|
+
'renameFolder',
|
|
607
|
+
'deleteFolder',
|
|
608
|
+
]),
|
|
609
|
+
treeRoot: tree.treeRoot,
|
|
610
|
+
treeChildren: tree.treeChildren,
|
|
611
|
+
|
|
612
|
+
async root(paging) {
|
|
613
|
+
const page = await nodes.roots(objectType, paging.skip, paging.take)
|
|
614
|
+
return { total: page.total, items: await shape(page.items) }
|
|
615
|
+
},
|
|
616
|
+
async children(parentKey, paging) {
|
|
617
|
+
const parent = await nodes.byKey(parentKey)
|
|
618
|
+
if (!parent) return { total: 0, items: [] }
|
|
619
|
+
const page = await nodes.children(parent.id, objectType, paging.skip, paging.take)
|
|
620
|
+
return { total: page.total, items: await shape(page.items) }
|
|
621
|
+
},
|
|
622
|
+
ancestors: tree.ancestors,
|
|
623
|
+
items: tree.items,
|
|
624
|
+
},
|
|
625
|
+
['save', 'remove', 'move', 'copy'],
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function toIdentifier(value: string): string {
|
|
630
|
+
const cleaned = value.replace(/[^A-Za-z0-9]+(.)?/g, (_, next: string | undefined) =>
|
|
631
|
+
next ? next.toUpperCase() : '',
|
|
632
|
+
)
|
|
633
|
+
const identifier = cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
|
|
634
|
+
return /^[A-Za-z]/.test(identifier) ? identifier : `Template${identifier}`
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** The starter view a new template gets, mirroring Umbraco's scaffold. */
|
|
638
|
+
export function scaffoldTemplate(
|
|
639
|
+
name: string,
|
|
640
|
+
alias: string,
|
|
641
|
+
layout: string | null = null,
|
|
642
|
+
): string {
|
|
643
|
+
const layoutLine = layout ? `export const layout = '${layout}'\n\n` : ''
|
|
644
|
+
return `import type { PageProps } from '@crombolo/crombolo'
|
|
645
|
+
|
|
646
|
+
${layoutLine}export default function ${toIdentifier(name || alias)}({ model }: PageProps) {
|
|
647
|
+
return (
|
|
648
|
+
<div>
|
|
649
|
+
<h1>{model.name}</h1>
|
|
650
|
+
</div>
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
`
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const RAZOR_LAYOUT_BLOCK = /^\s*@\{\s*Layout\s*=\s*(null|"([^"]*)")\s*;\s*\}\s*/
|
|
657
|
+
const RAZOR_PREAMBLE = /^\s*(?:@(?:using|inherits)\b[^\n]*\n\s*)*/
|
|
658
|
+
const LAYOUT_EXPORT = /^export\s+const\s+layout\s*=\s*['"][^'"]*['"]\s*;?[ \t]*\n*/m
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The template editor is Umbraco's, and speaks Razor: a new template starts as
|
|
662
|
+
* its Razor scaffold, and picking a master template prepends a
|
|
663
|
+
* `@{ Layout = "x.cshtml"; }` block (`"undefined.cshtml"` when the master is
|
|
664
|
+
* removed from a view that has no such block). Views here are TSX, so the
|
|
665
|
+
* untouched scaffold becomes the TSX one, and a layout block becomes the view's
|
|
666
|
+
* `export const layout`. Anything else is saved as written.
|
|
667
|
+
*/
|
|
668
|
+
export function fromBackofficeTemplate(content: string, name: string, alias: string): string {
|
|
669
|
+
const afterPreamble = content.replace(RAZOR_PREAMBLE, '')
|
|
670
|
+
const block = RAZOR_LAYOUT_BLOCK.exec(afterPreamble)
|
|
671
|
+
if (!block) return content
|
|
672
|
+
const target = block[2]?.replace(/\.cshtml$/i, '')
|
|
673
|
+
const layout = target && target !== 'undefined' ? target : null
|
|
674
|
+
const rest = afterPreamble.slice(block[0].length)
|
|
675
|
+
if (rest.trim() === '') return scaffoldTemplate(name, alias, layout)
|
|
676
|
+
const withoutLayout = rest.replace(LAYOUT_EXPORT, '')
|
|
677
|
+
return layout ? `export const layout = '${layout}'\n\n${withoutLayout}` : withoutLayout
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** The layout a view names, which the template tree shows as its master. */
|
|
681
|
+
export function layoutAliasOf(content: string | null): string | null {
|
|
682
|
+
const match = /export\s+const\s+layout\s*=\s*['"]([^'"]+)['"]/.exec(content ?? '')
|
|
683
|
+
return match?.[1] ?? null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function createTemplatePort(db: Db, files: ComponentFileStore): TemplatePort {
|
|
687
|
+
const repo = new ComponentRepository(db, files)
|
|
688
|
+
|
|
689
|
+
/** Every template with its master resolved from the view, one pass. */
|
|
690
|
+
const withMasters = async (): Promise<ComponentModel[]> => {
|
|
691
|
+
const all = await repo.all()
|
|
692
|
+
const byAlias = new Map(all.map((t) => [t.alias, t]))
|
|
693
|
+
return all.map((t) => {
|
|
694
|
+
const layout = layoutAliasOf(t.content)
|
|
695
|
+
return { ...t, masterKey: layout ? (byAlias.get(layout)?.key ?? null) : null }
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
const toItem = (t: ComponentModel, all: readonly ComponentModel[]): TreeItem => ({
|
|
699
|
+
key: t.key,
|
|
700
|
+
name: t.name,
|
|
701
|
+
hasChildren: all.some((o) => o.masterKey === t.key),
|
|
702
|
+
parentKey: t.masterKey ?? null,
|
|
703
|
+
icon: 'icon-layout',
|
|
704
|
+
isFolder: false,
|
|
705
|
+
})
|
|
706
|
+
const pageOf = (items: TreeItem[], paging: SkipTake): Page<TreeItem> => ({
|
|
707
|
+
total: items.length,
|
|
708
|
+
items: items.slice(paging.skip, paging.skip + paging.take),
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
async byKey(key) {
|
|
713
|
+
const model = await repo.byKey(key)
|
|
714
|
+
if (!model) return undefined
|
|
715
|
+
const layout = layoutAliasOf(model.content)
|
|
716
|
+
const master = layout ? await repo.byAlias(layout) : undefined
|
|
717
|
+
return { ...model, masterKey: master?.key ?? null }
|
|
718
|
+
},
|
|
719
|
+
async save(model: ComponentModel) {
|
|
720
|
+
await repo.save({
|
|
721
|
+
...model,
|
|
722
|
+
content:
|
|
723
|
+
model.content === null
|
|
724
|
+
? model.content
|
|
725
|
+
: fromBackofficeTemplate(model.content, model.name, model.alias),
|
|
726
|
+
})
|
|
727
|
+
},
|
|
728
|
+
remove: (key) => repo.delete(key),
|
|
729
|
+
scaffold: scaffoldTemplate,
|
|
730
|
+
|
|
731
|
+
async root(paging) {
|
|
732
|
+
const all = await withMasters()
|
|
733
|
+
return pageOf(
|
|
734
|
+
all.filter((t) => !t.masterKey).map((t) => toItem(t, all)),
|
|
735
|
+
paging,
|
|
736
|
+
)
|
|
737
|
+
},
|
|
738
|
+
async children(parentKey, paging) {
|
|
739
|
+
const all = await withMasters()
|
|
740
|
+
return pageOf(
|
|
741
|
+
all.filter((t) => t.masterKey === parentKey).map((t) => toItem(t, all)),
|
|
742
|
+
paging,
|
|
743
|
+
)
|
|
744
|
+
},
|
|
745
|
+
async ancestors(descendantKey) {
|
|
746
|
+
const all = await withMasters()
|
|
747
|
+
const chain: TreeItem[] = []
|
|
748
|
+
let current = all.find((t) => t.key === descendantKey)
|
|
749
|
+
while (current?.masterKey) {
|
|
750
|
+
const master = all.find((t) => t.key === current?.masterKey)
|
|
751
|
+
if (!master) break
|
|
752
|
+
chain.unshift(toItem(master, all))
|
|
753
|
+
current = master
|
|
754
|
+
}
|
|
755
|
+
return chain
|
|
756
|
+
},
|
|
757
|
+
async siblings(target, before, after) {
|
|
758
|
+
const all = await withMasters()
|
|
759
|
+
const self = all.find((t) => t.key === target)
|
|
760
|
+
if (!self) return undefined
|
|
761
|
+
const group = all.filter((t) => (t.masterKey ?? null) === (self.masterKey ?? null))
|
|
762
|
+
const index = group.findIndex((t) => t.key === target)
|
|
763
|
+
const start = Math.max(0, index - before)
|
|
764
|
+
const end = Math.min(group.length, index + after + 1)
|
|
765
|
+
return {
|
|
766
|
+
items: group.slice(start, end).map((t) => toItem(t, all)),
|
|
767
|
+
totalBefore: start,
|
|
768
|
+
totalAfter: group.length - end,
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
async search(query, paging) {
|
|
772
|
+
const q = query.toLowerCase()
|
|
773
|
+
const all = (await withMasters()).filter(
|
|
774
|
+
(t) => t.name.toLowerCase().includes(q) || t.alias.toLowerCase().includes(q),
|
|
775
|
+
)
|
|
776
|
+
return { total: all.length, items: all.slice(paging.skip, paging.skip + paging.take) }
|
|
777
|
+
},
|
|
778
|
+
async items(keys) {
|
|
779
|
+
const all = await withMasters()
|
|
780
|
+
return all.filter((t) => keys.includes(t.key)).map((t) => toItem(t, all))
|
|
781
|
+
},
|
|
782
|
+
}
|
|
783
|
+
}
|