@crombolo/data 0.0.0-stage → 0.9.1
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 +31 -4
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +128 -0
- package/src/index.ts +60 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +431 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +191 -0
- package/src/repositories/content-types.ts +831 -0
- package/src/repositories/data-types.ts +222 -0
- package/src/repositories/dictionary.ts +259 -0
- package/src/repositories/documents.ts +2389 -0
- package/src/repositories/domains.ts +108 -0
- package/src/repositories/folders.ts +120 -0
- package/src/repositories/form-entries.ts +314 -0
- package/src/repositories/form-workflows.ts +213 -0
- package/src/repositories/languages.ts +108 -0
- package/src/repositories/log-searches.ts +33 -0
- package/src/repositories/members.ts +427 -0
- package/src/repositories/nodes.ts +361 -0
- package/src/repositories/notifications.ts +54 -0
- package/src/repositories/public-access.ts +187 -0
- package/src/repositories/redirects.ts +274 -0
- package/src/repositories/references.ts +162 -0
- package/src/repositories/tags.ts +93 -0
- package/src/repositories/templates.ts +114 -0
- package/src/repositories/transfer-runs.ts +190 -0
- package/src/repositories/user-data.ts +90 -0
- package/src/repositories/users.ts +898 -0
- package/src/schema/assistant.ts +74 -0
- package/src/schema/built-in-content-types.ts +106 -0
- package/src/schema/built-in-media-types.ts +335 -0
- package/src/schema/built-in-member-types.ts +61 -0
- package/src/schema/bundle-components.ts +49 -0
- package/src/schema/bundles-rename.ts +32 -0
- package/src/schema/change-report.ts +46 -0
- package/src/schema/client-id-prefix.ts +43 -0
- package/src/schema/content-editing.ts +50 -0
- package/src/schema/content-seed.ts +586 -0
- package/src/schema/content.ts +263 -0
- package/src/schema/created-packages.ts +56 -0
- package/src/schema/elements.ts +62 -0
- package/src/schema/form-permission-prefix.ts +50 -0
- package/src/schema/form-permissions.ts +26 -0
- package/src/schema/form-workflows.ts +54 -0
- package/src/schema/forms.ts +63 -0
- package/src/schema/group-permissions.ts +20 -0
- package/src/schema/identity.ts +176 -0
- package/src/schema/log-viewer.ts +68 -0
- package/src/schema/member-property-type.ts +30 -0
- package/src/schema/members.ts +97 -0
- package/src/schema/plan.ts +73 -0
- package/src/schema/public-access.ts +56 -0
- package/src/schema/recycle-bin.ts +21 -0
- package/src/schema/redirects.ts +57 -0
- package/src/schema/rich-text-ui.ts +34 -0
- package/src/schema/seed.ts +298 -0
- package/src/schema/server-role.ts +21 -0
- package/src/schema/since-version.ts +26 -0
- package/src/schema/transfer-runs.ts +71 -0
- package/src/schema/upgrade-report.ts +43 -0
- package/src/schema/users-localisation.ts +85 -0
- package/src/schema/values.ts +108 -0
- package/src/schema-ops.ts +35 -0
- package/src/schema-state.ts +196 -0
- package/README.md +0 -3
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/** Data types: the property editor plus its configuration. */
|
|
2
|
+
import {
|
|
3
|
+
type DataTypeModel,
|
|
4
|
+
normaliseUuid,
|
|
5
|
+
ObjectTypes,
|
|
6
|
+
type Page,
|
|
7
|
+
SystemNodes,
|
|
8
|
+
} from '@crombolo/core'
|
|
9
|
+
import type { Db } from '../database.ts'
|
|
10
|
+
import { NodeRepository } from './nodes.ts'
|
|
11
|
+
|
|
12
|
+
function parseValues(config: unknown): Array<{ alias: string; value: unknown }> {
|
|
13
|
+
if (typeof config !== 'string' || config.length === 0) return []
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(config) as unknown
|
|
16
|
+
if (Array.isArray(parsed)) return parsed as Array<{ alias: string; value: unknown }>
|
|
17
|
+
if (parsed && typeof parsed === 'object') {
|
|
18
|
+
return Object.entries(parsed as Record<string, unknown>).map(([alias, value]) => ({
|
|
19
|
+
alias,
|
|
20
|
+
value,
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// A malformed configuration should not make the data type unopenable.
|
|
25
|
+
}
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class DataTypeRepository {
|
|
30
|
+
#db: Db
|
|
31
|
+
#nodes: NodeRepository
|
|
32
|
+
|
|
33
|
+
constructor(db: Db) {
|
|
34
|
+
this.#db = db
|
|
35
|
+
this.#nodes = new NodeRepository(db)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get nodes(): NodeRepository {
|
|
39
|
+
return this.#nodes
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async byKey(key: string): Promise<DataTypeModel | undefined> {
|
|
43
|
+
const rows = await this.#db.query(
|
|
44
|
+
`SELECT n.unique_id, n.text, n.parent_id, d.alias, d.editor_alias, d.editor_ui_alias, d.db_type, d.config
|
|
45
|
+
FROM data_type d JOIN node n ON n.id = d.node_id
|
|
46
|
+
WHERE n.unique_id = ?`,
|
|
47
|
+
[normaliseUuid(key)],
|
|
48
|
+
)
|
|
49
|
+
const row = rows[0]
|
|
50
|
+
if (!row) return undefined
|
|
51
|
+
const parent =
|
|
52
|
+
Number(row.parent_id) > 0 ? await this.#nodes.byId(Number(row.parent_id)) : undefined
|
|
53
|
+
return {
|
|
54
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
55
|
+
alias: (row.alias as string | null) ?? null,
|
|
56
|
+
name: String(row.text ?? ''),
|
|
57
|
+
editorAlias: String(row.editor_alias),
|
|
58
|
+
editorUiAlias: (row.editor_ui_alias as string | null) ?? null,
|
|
59
|
+
dbType: String(row.db_type),
|
|
60
|
+
values: parseValues(row.config),
|
|
61
|
+
parentKey: parent?.key ?? null,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async byAlias(alias: string): Promise<DataTypeModel | undefined> {
|
|
66
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
67
|
+
'SELECT n.unique_id FROM data_type d JOIN node n ON n.id = d.node_id WHERE d.alias = ?',
|
|
68
|
+
[alias],
|
|
69
|
+
)
|
|
70
|
+
return rows[0] ? this.byKey(String(rows[0].unique_id)) : undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async byKeys(keys: readonly string[]): Promise<DataTypeModel[]> {
|
|
74
|
+
const found: DataTypeModel[] = []
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
const dataType = await this.byKey(key)
|
|
77
|
+
if (dataType) found.push(dataType)
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The storage type, which decides which `property_data` column a value uses. */
|
|
83
|
+
async storageTypeByPropertyTypeId(propertyTypeId: number): Promise<string | undefined> {
|
|
84
|
+
const rows = await this.#db.query<{ db_type: string }>(
|
|
85
|
+
`SELECT d.db_type FROM property_type p JOIN data_type d ON d.node_id = p.data_type_id
|
|
86
|
+
WHERE p.id = ?`,
|
|
87
|
+
[propertyTypeId],
|
|
88
|
+
)
|
|
89
|
+
return rows[0] ? String(rows[0].db_type) : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async list(skip: number, take: number): Promise<Page<DataTypeModel>> {
|
|
93
|
+
const page = await this.#nodes.children(SystemNodes.Root, ObjectTypes.DataType, skip, take)
|
|
94
|
+
const items: DataTypeModel[] = []
|
|
95
|
+
for (const node of page.items) {
|
|
96
|
+
const dataType = await this.byKey(node.key)
|
|
97
|
+
if (dataType) items.push(dataType)
|
|
98
|
+
}
|
|
99
|
+
return { total: page.total, items }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async all(): Promise<DataTypeModel[]> {
|
|
103
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
104
|
+
'SELECT n.unique_id FROM data_type d JOIN node n ON n.id = d.node_id ORDER BY n.text',
|
|
105
|
+
)
|
|
106
|
+
return this.byKeys(rows.map((r) => String(r.unique_id)))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The editor picker's filter: by name fragment and editor aliases. */
|
|
110
|
+
async filter(
|
|
111
|
+
criteria: { name?: string; editorAlias?: string; editorUiAlias?: string },
|
|
112
|
+
paging: { skip: number; take: number },
|
|
113
|
+
): Promise<{ total: number; items: DataTypeModel[] }> {
|
|
114
|
+
const name = criteria.name?.toLowerCase()
|
|
115
|
+
const items = (await this.all()).filter(
|
|
116
|
+
(d) =>
|
|
117
|
+
(!name || d.name.toLowerCase().includes(name)) &&
|
|
118
|
+
(!criteria.editorAlias || d.editorAlias === criteria.editorAlias) &&
|
|
119
|
+
(!criteria.editorUiAlias || d.editorUiAlias === criteria.editorUiAlias),
|
|
120
|
+
)
|
|
121
|
+
return { total: items.length, items: items.slice(paging.skip, paging.skip + paging.take) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Property types using this data type, with the type each belongs to. */
|
|
125
|
+
async referencedBy(key: string): Promise<
|
|
126
|
+
Array<{
|
|
127
|
+
propertyKey: string
|
|
128
|
+
alias: string
|
|
129
|
+
name: string
|
|
130
|
+
contentType: { key: string; alias: string; name: string; icon: string }
|
|
131
|
+
}>
|
|
132
|
+
> {
|
|
133
|
+
const node = await this.#nodes.byKey(key)
|
|
134
|
+
if (!node) return []
|
|
135
|
+
const rows = await this.#db.query(
|
|
136
|
+
`SELECT p.unique_id, p.alias, p.name, ctn.unique_id AS type_key, ct.alias AS type_alias, ctn.text AS type_name, ct.icon
|
|
137
|
+
FROM property_type p
|
|
138
|
+
JOIN content_type ct ON ct.node_id = p.content_type_id
|
|
139
|
+
JOIN node ctn ON ctn.id = ct.node_id
|
|
140
|
+
WHERE p.data_type_id = ? AND p.retired_at IS NULL AND ct.retired_at IS NULL
|
|
141
|
+
ORDER BY ctn.text, p.sort_order`,
|
|
142
|
+
[node.id],
|
|
143
|
+
)
|
|
144
|
+
return rows.map((row) => ({
|
|
145
|
+
propertyKey: normaliseUuid(String(row.unique_id)),
|
|
146
|
+
alias: String(row.alias),
|
|
147
|
+
name: String(row.name),
|
|
148
|
+
contentType: {
|
|
149
|
+
key: normaliseUuid(String(row.type_key)),
|
|
150
|
+
alias: String(row.type_alias),
|
|
151
|
+
name: String(row.type_name ?? ''),
|
|
152
|
+
icon: String(row.icon ?? 'icon-document'),
|
|
153
|
+
},
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async move(key: string, parentKey: string | null): Promise<boolean> {
|
|
158
|
+
const node = await this.#nodes.byKey(key)
|
|
159
|
+
if (!node || node.objectType !== ObjectTypes.DataType) return false
|
|
160
|
+
const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
161
|
+
if (parentId === undefined) return false
|
|
162
|
+
await this.#nodes.move(node.id, parentId)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async copy(key: string, parentKey: string | null): Promise<DataTypeModel | undefined> {
|
|
167
|
+
const source = await this.byKey(key)
|
|
168
|
+
if (!source) return undefined
|
|
169
|
+
return this.save({
|
|
170
|
+
...source,
|
|
171
|
+
key: crypto.randomUUID(),
|
|
172
|
+
alias: null,
|
|
173
|
+
name: `${source.name} (copy)`,
|
|
174
|
+
parentKey,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async save(model: DataTypeModel, userId?: number): Promise<DataTypeModel> {
|
|
179
|
+
return this.#db.transaction(async (tx) => {
|
|
180
|
+
const repo = new DataTypeRepository(tx)
|
|
181
|
+
const existing = await repo.nodes.byKey(model.key)
|
|
182
|
+
const config = JSON.stringify(
|
|
183
|
+
Object.fromEntries(model.values.map((value) => [value.alias, value.value])),
|
|
184
|
+
)
|
|
185
|
+
if (existing) {
|
|
186
|
+
await repo.nodes.rename(existing.id, model.name)
|
|
187
|
+
await tx.exec(
|
|
188
|
+
'UPDATE data_type SET alias = ?, editor_alias = ?, editor_ui_alias = ?, db_type = ?, config = ? WHERE node_id = ?',
|
|
189
|
+
[model.alias, model.editorAlias, model.editorUiAlias, model.dbType, config, existing.id],
|
|
190
|
+
)
|
|
191
|
+
} else {
|
|
192
|
+
const parentId = model.parentKey
|
|
193
|
+
? ((await repo.nodes.byKey(model.parentKey))?.id ?? SystemNodes.Root)
|
|
194
|
+
: SystemNodes.Root
|
|
195
|
+
const node = await repo.nodes.create({
|
|
196
|
+
key: model.key,
|
|
197
|
+
parentId,
|
|
198
|
+
objectType: ObjectTypes.DataType,
|
|
199
|
+
text: model.name,
|
|
200
|
+
userId,
|
|
201
|
+
})
|
|
202
|
+
await tx.exec(
|
|
203
|
+
'INSERT INTO data_type (node_id, alias, editor_alias, editor_ui_alias, db_type, config) VALUES (?, ?, ?, ?, ?, ?)',
|
|
204
|
+
[node.id, model.alias, model.editorAlias, model.editorUiAlias, model.dbType, config],
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
const saved = await repo.byKey(model.key)
|
|
208
|
+
if (!saved) throw new Error('Data type could not be read back after saving.')
|
|
209
|
+
return saved
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async delete(key: string): Promise<boolean> {
|
|
214
|
+
const node = await this.#nodes.byKey(key)
|
|
215
|
+
if (!node || node.objectType !== ObjectTypes.DataType) return false
|
|
216
|
+
return this.#db.transaction(async (tx) => {
|
|
217
|
+
await tx.exec('DELETE FROM data_type WHERE node_id = ?', [node.id])
|
|
218
|
+
await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
|
|
219
|
+
return true
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/** Dictionary items: a tree of keys, each with a translation per language. */
|
|
2
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
3
|
+
import type { Db } from '../database.ts'
|
|
4
|
+
|
|
5
|
+
export interface DictionaryTranslation {
|
|
6
|
+
isoCode: string
|
|
7
|
+
translation: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DictionaryItem {
|
|
11
|
+
key: string
|
|
12
|
+
name: string
|
|
13
|
+
parentKey: string | null
|
|
14
|
+
translations: DictionaryTranslation[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DictionaryWriteResult =
|
|
18
|
+
| 'Success'
|
|
19
|
+
| 'DuplicateItemKey'
|
|
20
|
+
| 'DuplicateKey'
|
|
21
|
+
| 'ParentNotFound'
|
|
22
|
+
| 'NotFound'
|
|
23
|
+
| 'InvalidParent'
|
|
24
|
+
| 'InvalidLanguage'
|
|
25
|
+
|
|
26
|
+
interface Row {
|
|
27
|
+
key: string
|
|
28
|
+
item_key: string
|
|
29
|
+
parent_key: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DictionaryRepository {
|
|
33
|
+
#db: Db
|
|
34
|
+
|
|
35
|
+
constructor(db: Db) {
|
|
36
|
+
this.#db = db
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async #rows(where = '', params: unknown[] = []): Promise<Row[]> {
|
|
40
|
+
return this.#db.query<Row>(
|
|
41
|
+
`SELECT key, item_key, parent_key FROM dictionary_item ${where} ORDER BY item_key`,
|
|
42
|
+
params,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async #withTranslations(rows: Row[]): Promise<DictionaryItem[]> {
|
|
47
|
+
if (rows.length === 0) return []
|
|
48
|
+
const keys = rows.map((r) => normaliseUuid(String(r.key)))
|
|
49
|
+
const texts = await this.#db.query<{ dictionary_key: string; iso_code: string; value: string }>(
|
|
50
|
+
`SELECT t.dictionary_key, l.iso_code, t.value FROM dictionary_text t
|
|
51
|
+
JOIN language l ON l.id = t.language_id
|
|
52
|
+
WHERE t.dictionary_key IN (${keys.map(() => '?').join(', ')})
|
|
53
|
+
ORDER BY l.iso_code`,
|
|
54
|
+
keys,
|
|
55
|
+
)
|
|
56
|
+
return rows.map((row) => {
|
|
57
|
+
const key = normaliseUuid(String(row.key))
|
|
58
|
+
return {
|
|
59
|
+
key,
|
|
60
|
+
name: String(row.item_key),
|
|
61
|
+
parentKey: row.parent_key ? normaliseUuid(String(row.parent_key)) : null,
|
|
62
|
+
translations: texts
|
|
63
|
+
.filter((t) => normaliseUuid(String(t.dictionary_key)) === key)
|
|
64
|
+
.map((t) => ({ isoCode: String(t.iso_code), translation: String(t.value) })),
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async get(key: string): Promise<DictionaryItem | undefined> {
|
|
70
|
+
return (
|
|
71
|
+
await this.#withTranslations(await this.#rows('WHERE key = ?', [normaliseUuid(key)]))
|
|
72
|
+
)[0]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async byName(name: string): Promise<DictionaryItem | undefined> {
|
|
76
|
+
return (await this.#withTranslations(await this.#rows('WHERE item_key = ?', [name])))[0]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async many(keys: readonly string[]): Promise<DictionaryItem[]> {
|
|
80
|
+
const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
|
|
81
|
+
if (wanted.length === 0) return []
|
|
82
|
+
return this.#withTranslations(
|
|
83
|
+
await this.#rows(`WHERE key IN (${wanted.map(() => '?').join(', ')})`, wanted),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every item, or those whose key contains `filter`, in tree order by key. */
|
|
88
|
+
async all(filter?: string): Promise<DictionaryItem[]> {
|
|
89
|
+
const rows = filter
|
|
90
|
+
? await this.#rows('WHERE LOWER(item_key) LIKE ?', [`%${filter.toLowerCase()}%`])
|
|
91
|
+
: await this.#rows()
|
|
92
|
+
return this.#withTranslations(rows)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async children(
|
|
96
|
+
parentKey: string | null,
|
|
97
|
+
): Promise<Array<{ key: string; name: string; parentKey: string | null; hasChildren: boolean }>> {
|
|
98
|
+
const rows = parentKey
|
|
99
|
+
? await this.#rows('WHERE parent_key = ?', [normaliseUuid(parentKey)])
|
|
100
|
+
: await this.#rows('WHERE parent_key IS NULL')
|
|
101
|
+
const parents = new Set(
|
|
102
|
+
(
|
|
103
|
+
await this.#db.query<{ parent_key: string }>(
|
|
104
|
+
'SELECT DISTINCT parent_key FROM dictionary_item WHERE parent_key IS NOT NULL',
|
|
105
|
+
)
|
|
106
|
+
).map((r) => normaliseUuid(String(r.parent_key))),
|
|
107
|
+
)
|
|
108
|
+
return rows.map((row) => {
|
|
109
|
+
const key = normaliseUuid(String(row.key))
|
|
110
|
+
return {
|
|
111
|
+
key,
|
|
112
|
+
name: String(row.item_key),
|
|
113
|
+
parentKey: row.parent_key ? normaliseUuid(String(row.parent_key)) : null,
|
|
114
|
+
hasChildren: parents.has(key),
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The item and its ancestors, root first. */
|
|
120
|
+
async ancestry(key: string): Promise<DictionaryItem[]> {
|
|
121
|
+
const chain: DictionaryItem[] = []
|
|
122
|
+
let current = await this.get(key)
|
|
123
|
+
while (current) {
|
|
124
|
+
chain.unshift(current)
|
|
125
|
+
current = current.parentKey ? await this.get(current.parentKey) : undefined
|
|
126
|
+
}
|
|
127
|
+
return chain
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async #descendantKeys(key: string): Promise<string[]> {
|
|
131
|
+
const out: string[] = []
|
|
132
|
+
const queue = [normaliseUuid(key)]
|
|
133
|
+
while (queue.length > 0) {
|
|
134
|
+
const next = queue.shift() as string
|
|
135
|
+
for (const child of await this.#rows('WHERE parent_key = ?', [next])) {
|
|
136
|
+
const childKey = normaliseUuid(String(child.key))
|
|
137
|
+
out.push(childKey)
|
|
138
|
+
queue.push(childKey)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #languageIds(
|
|
145
|
+
translations: readonly DictionaryTranslation[],
|
|
146
|
+
): Promise<Map<string, number> | undefined> {
|
|
147
|
+
const languages = await this.#db.query<{ id: number; iso_code: string }>(
|
|
148
|
+
'SELECT id, iso_code FROM language',
|
|
149
|
+
)
|
|
150
|
+
const byIso = new Map(languages.map((l) => [String(l.iso_code).toLowerCase(), Number(l.id)]))
|
|
151
|
+
const ids = new Map<string, number>()
|
|
152
|
+
for (const t of translations) {
|
|
153
|
+
const id = byIso.get(t.isoCode.toLowerCase())
|
|
154
|
+
if (id === undefined) return undefined
|
|
155
|
+
ids.set(t.isoCode, id)
|
|
156
|
+
}
|
|
157
|
+
return ids
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async #writeTranslations(
|
|
161
|
+
key: string,
|
|
162
|
+
translations: readonly DictionaryTranslation[],
|
|
163
|
+
ids: Map<string, number>,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
await this.#db.exec('DELETE FROM dictionary_text WHERE dictionary_key = ?', [key])
|
|
166
|
+
for (const t of translations)
|
|
167
|
+
await this.#db.exec(
|
|
168
|
+
'INSERT INTO dictionary_text (dictionary_key, language_id, value) VALUES (?, ?, ?)',
|
|
169
|
+
[key, ids.get(t.isoCode), t.translation],
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async create(item: DictionaryItem): Promise<DictionaryWriteResult> {
|
|
174
|
+
const key = normaliseUuid(item.key)
|
|
175
|
+
return this.#db.transaction(async (tx) => {
|
|
176
|
+
const repo = new DictionaryRepository(tx)
|
|
177
|
+
if (await repo.get(key)) return 'DuplicateKey'
|
|
178
|
+
if (await repo.byName(item.name)) return 'DuplicateItemKey'
|
|
179
|
+
if (item.parentKey && !(await repo.get(item.parentKey))) return 'ParentNotFound'
|
|
180
|
+
const ids = await repo.#languageIds(item.translations)
|
|
181
|
+
if (!ids) return 'InvalidLanguage'
|
|
182
|
+
await tx.exec('INSERT INTO dictionary_item (key, parent_key, item_key) VALUES (?, ?, ?)', [
|
|
183
|
+
key,
|
|
184
|
+
item.parentKey ? normaliseUuid(item.parentKey) : null,
|
|
185
|
+
item.name,
|
|
186
|
+
])
|
|
187
|
+
await repo.#writeTranslations(key, item.translations, ids)
|
|
188
|
+
return 'Success'
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async update(
|
|
193
|
+
key: string,
|
|
194
|
+
change: { name: string; translations: readonly DictionaryTranslation[] },
|
|
195
|
+
): Promise<DictionaryWriteResult> {
|
|
196
|
+
const k = normaliseUuid(key)
|
|
197
|
+
return this.#db.transaction(async (tx) => {
|
|
198
|
+
const repo = new DictionaryRepository(tx)
|
|
199
|
+
if (!(await repo.get(k))) return 'NotFound'
|
|
200
|
+
const clash = await repo.byName(change.name)
|
|
201
|
+
if (clash && clash.key !== k) return 'DuplicateItemKey'
|
|
202
|
+
const ids = await repo.#languageIds(change.translations)
|
|
203
|
+
if (!ids) return 'InvalidLanguage'
|
|
204
|
+
await tx.exec('UPDATE dictionary_item SET item_key = ? WHERE key = ?', [change.name, k])
|
|
205
|
+
await repo.#writeTranslations(k, change.translations, ids)
|
|
206
|
+
return 'Success'
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async move(key: string, parentKey: string | null): Promise<DictionaryWriteResult> {
|
|
211
|
+
const k = normaliseUuid(key)
|
|
212
|
+
return this.#db.transaction(async (tx) => {
|
|
213
|
+
const repo = new DictionaryRepository(tx)
|
|
214
|
+
if (!(await repo.get(k))) return 'NotFound'
|
|
215
|
+
if (parentKey) {
|
|
216
|
+
const parent = normaliseUuid(parentKey)
|
|
217
|
+
if (!(await repo.get(parent))) return 'ParentNotFound'
|
|
218
|
+
if (parent === k || (await repo.#descendantKeys(k)).includes(parent)) return 'InvalidParent'
|
|
219
|
+
}
|
|
220
|
+
await tx.exec('UPDATE dictionary_item SET parent_key = ? WHERE key = ?', [
|
|
221
|
+
parentKey ? normaliseUuid(parentKey) : null,
|
|
222
|
+
k,
|
|
223
|
+
])
|
|
224
|
+
return 'Success'
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Deletes the item and everything below it. */
|
|
229
|
+
async delete(key: string): Promise<DictionaryWriteResult> {
|
|
230
|
+
const k = normaliseUuid(key)
|
|
231
|
+
return this.#db.transaction(async (tx) => {
|
|
232
|
+
const repo = new DictionaryRepository(tx)
|
|
233
|
+
if (!(await repo.get(k))) return 'NotFound'
|
|
234
|
+
const doomed = [...(await repo.#descendantKeys(k)).reverse(), k]
|
|
235
|
+
for (const d of doomed) {
|
|
236
|
+
await tx.exec('DELETE FROM dictionary_text WHERE dictionary_key = ?', [d])
|
|
237
|
+
await tx.exec('DELETE FROM dictionary_item WHERE key = ?', [d])
|
|
238
|
+
}
|
|
239
|
+
return 'Success'
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Every translation, keyed by item name then iso code, for rendering. */
|
|
244
|
+
async translations(): Promise<Map<string, Map<string, string>>> {
|
|
245
|
+
const rows = await this.#db.query<{ item_key: string; iso_code: string; value: string }>(
|
|
246
|
+
`SELECT d.item_key, l.iso_code, t.value FROM dictionary_item d
|
|
247
|
+
JOIN dictionary_text t ON t.dictionary_key = d.key
|
|
248
|
+
JOIN language l ON l.id = t.language_id`,
|
|
249
|
+
)
|
|
250
|
+
const out = new Map<string, Map<string, string>>()
|
|
251
|
+
for (const row of rows) {
|
|
252
|
+
const name = String(row.item_key)
|
|
253
|
+
const entry = out.get(name) ?? new Map<string, string>()
|
|
254
|
+
entry.set(String(row.iso_code), String(row.value))
|
|
255
|
+
out.set(name, entry)
|
|
256
|
+
}
|
|
257
|
+
return out
|
|
258
|
+
}
|
|
259
|
+
}
|