@kidecms/core 0.1.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/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- package/virtual.d.ts +61 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { and, asc, desc, eq, like, lte, or, sql } from "drizzle-orm";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { hashPassword } from "./auth";
|
|
4
|
+
import { getCollectionMap, getTranslatableFieldNames, isStructuralField } from "./define";
|
|
5
|
+
import { getDb } from "./runtime";
|
|
6
|
+
import { getSchema } from "./schema";
|
|
7
|
+
import { cloneValue, createRichTextFromPlainText, slugify } from "./values";
|
|
8
|
+
const now = () => new Date().toISOString();
|
|
9
|
+
const pick = (input, keys) => Object.fromEntries(keys.filter((key) => key in input).map((key) => [key, input[key]]));
|
|
10
|
+
const isJsonField = (field) => field.type === "richText" ||
|
|
11
|
+
field.type === "array" ||
|
|
12
|
+
field.type === "json" ||
|
|
13
|
+
field.type === "blocks" ||
|
|
14
|
+
(field.type === "relation" && field.hasMany);
|
|
15
|
+
const ensureCollection = (config, slug) => {
|
|
16
|
+
const collection = getCollectionMap(config)[slug];
|
|
17
|
+
if (!collection) {
|
|
18
|
+
const available = config.collections.map((collectionEntry) => collectionEntry.slug).join(", ");
|
|
19
|
+
throw new Error(`Unknown collection "${slug}". Available collections: ${available}`);
|
|
20
|
+
}
|
|
21
|
+
return collection;
|
|
22
|
+
};
|
|
23
|
+
const getDefaultStatus = (collection) => (collection.drafts ? "draft" : "published");
|
|
24
|
+
const isEmptyValue = (value) => value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
25
|
+
const coerceString = (value) => (value === null ? "" : String(value).trim());
|
|
26
|
+
const coerceNumber = (value) => (value === "" || value === null ? undefined : Number(value));
|
|
27
|
+
const coerceBoolean = (value) => value === true || value === "true" || value === "on" || value === 1 || value === "1";
|
|
28
|
+
const coerceRelation = (field, value) => {
|
|
29
|
+
if (!("hasMany" in field && field.hasMany))
|
|
30
|
+
return value ? String(value) : "";
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return value.map((item) => String(item));
|
|
33
|
+
const stringValue = String(value).trim();
|
|
34
|
+
if (stringValue.startsWith("[")) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(stringValue);
|
|
37
|
+
if (Array.isArray(parsed))
|
|
38
|
+
return parsed.map((item) => String(item)).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// fall through to comma split
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return stringValue
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((item) => item.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
};
|
|
49
|
+
const coerceArray = (value) => {
|
|
50
|
+
if (Array.isArray(value))
|
|
51
|
+
return value;
|
|
52
|
+
if (typeof value === "string") {
|
|
53
|
+
return value
|
|
54
|
+
.split(/\n|,/)
|
|
55
|
+
.map((item) => item.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
};
|
|
60
|
+
const coerceJsonOrBlocks = (field, value) => {
|
|
61
|
+
const fallback = field.defaultValue ?? (field.type === "blocks" ? [] : {});
|
|
62
|
+
if (typeof value === "string") {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
return trimmed ? JSON.parse(trimmed) : fallback;
|
|
65
|
+
}
|
|
66
|
+
return value ?? fallback;
|
|
67
|
+
};
|
|
68
|
+
const coerceRichText = (value) => {
|
|
69
|
+
if (typeof value === "string") {
|
|
70
|
+
const trimmed = value.trim();
|
|
71
|
+
if (trimmed.startsWith("{")) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(trimmed);
|
|
74
|
+
if (parsed?.type === "root")
|
|
75
|
+
return parsed;
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
}
|
|
79
|
+
return createRichTextFromPlainText(value);
|
|
80
|
+
}
|
|
81
|
+
return value ?? createRichTextFromPlainText("");
|
|
82
|
+
};
|
|
83
|
+
const coerceFieldValue = (field, value) => {
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
switch (field.type) {
|
|
87
|
+
case "text":
|
|
88
|
+
case "slug":
|
|
89
|
+
case "email":
|
|
90
|
+
case "image":
|
|
91
|
+
case "date":
|
|
92
|
+
return coerceString(value);
|
|
93
|
+
case "number":
|
|
94
|
+
return coerceNumber(value);
|
|
95
|
+
case "boolean":
|
|
96
|
+
return coerceBoolean(value);
|
|
97
|
+
case "select":
|
|
98
|
+
return value === "" || value === null ? "" : String(value);
|
|
99
|
+
case "relation":
|
|
100
|
+
return coerceRelation(field, value);
|
|
101
|
+
case "array":
|
|
102
|
+
return coerceArray(value);
|
|
103
|
+
case "json":
|
|
104
|
+
case "blocks":
|
|
105
|
+
return coerceJsonOrBlocks(field, value);
|
|
106
|
+
case "richText":
|
|
107
|
+
return coerceRichText(value);
|
|
108
|
+
default:
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const prepareIncomingData = (collection, input, locale, existing) => {
|
|
113
|
+
const data = {};
|
|
114
|
+
const translatableFields = new Set(getTranslatableFieldNames(collection));
|
|
115
|
+
for (const [fieldName, field] of Object.entries(collection.fields)) {
|
|
116
|
+
if (locale && !translatableFields.has(fieldName))
|
|
117
|
+
continue;
|
|
118
|
+
if (!locale && translatableFields.has(fieldName) && input[fieldName] === undefined && existing)
|
|
119
|
+
continue;
|
|
120
|
+
const rawValue = input[fieldName];
|
|
121
|
+
const coercedValue = coerceFieldValue(field, rawValue);
|
|
122
|
+
if (coercedValue === undefined) {
|
|
123
|
+
if (!existing && field.defaultValue !== undefined) {
|
|
124
|
+
data[fieldName] = cloneValue(field.defaultValue);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
data[fieldName] = coercedValue;
|
|
129
|
+
}
|
|
130
|
+
for (const [fieldName, field] of Object.entries(collection.fields)) {
|
|
131
|
+
if (field.type !== "slug")
|
|
132
|
+
continue;
|
|
133
|
+
if (!isEmptyValue(data[fieldName])) {
|
|
134
|
+
data[fieldName] = slugify(String(data[fieldName]));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const sourceField = field.from;
|
|
138
|
+
const sourceValue = sourceField ? (data[sourceField] ?? input[sourceField]) : undefined;
|
|
139
|
+
if (sourceValue)
|
|
140
|
+
data[fieldName] = slugify(String(sourceValue));
|
|
141
|
+
}
|
|
142
|
+
for (const [fieldName, field] of Object.entries(collection.fields)) {
|
|
143
|
+
if (!field.required)
|
|
144
|
+
continue;
|
|
145
|
+
const candidate = data[fieldName] ?? existing?.[fieldName];
|
|
146
|
+
if (isEmptyValue(candidate))
|
|
147
|
+
throw new Error(`Field "${fieldName}" is required.`);
|
|
148
|
+
}
|
|
149
|
+
return data;
|
|
150
|
+
};
|
|
151
|
+
const serializeForDb = (collection, data) => {
|
|
152
|
+
const result = {};
|
|
153
|
+
for (const [key, value] of Object.entries(data)) {
|
|
154
|
+
const field = collection.fields[key];
|
|
155
|
+
result[key] = field && isJsonField(field) && value !== undefined && value !== null ? JSON.stringify(value) : value;
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
};
|
|
159
|
+
const deserializeFromDb = (collection, row) => {
|
|
160
|
+
const result = { ...row };
|
|
161
|
+
for (const [key, field] of Object.entries(collection.fields)) {
|
|
162
|
+
if (isJsonField(field) && typeof result[key] === "string") {
|
|
163
|
+
try {
|
|
164
|
+
result[key] = JSON.parse(result[key]);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// leave as string if not valid JSON
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
};
|
|
173
|
+
const canAccess = async (collection, operation, context, doc) => {
|
|
174
|
+
if (context._system)
|
|
175
|
+
return true;
|
|
176
|
+
const rule = collection.access?.[operation];
|
|
177
|
+
if (!rule)
|
|
178
|
+
return true;
|
|
179
|
+
return rule({ user: context.user ?? null, doc: doc ?? null, operation, collection: collection.slug });
|
|
180
|
+
};
|
|
181
|
+
const getHookContext = (collection, operation, context) => ({
|
|
182
|
+
user: context.user ?? null,
|
|
183
|
+
operation,
|
|
184
|
+
collection: collection.slug,
|
|
185
|
+
timestamp: now(),
|
|
186
|
+
cache: context.cache,
|
|
187
|
+
});
|
|
188
|
+
const getTableRefs = async (collectionSlug) => {
|
|
189
|
+
const tables = getSchema().cmsTables[collectionSlug];
|
|
190
|
+
if (!tables)
|
|
191
|
+
throw new Error(`No tables found for collection "${collectionSlug}".`);
|
|
192
|
+
return tables;
|
|
193
|
+
};
|
|
194
|
+
export const createCms = (config) => {
|
|
195
|
+
const collectionMap = getCollectionMap(config);
|
|
196
|
+
const createCollectionApi = (slug) => {
|
|
197
|
+
const collection = ensureCollection(config, slug);
|
|
198
|
+
const overlayLocale = (doc, translations, locale) => {
|
|
199
|
+
const baseDoc = { ...doc };
|
|
200
|
+
const translatableFields = getTranslatableFieldNames(collection);
|
|
201
|
+
const availableLocales = [...new Set(translations.map((translation) => String(translation._languageCode)))];
|
|
202
|
+
if (locale) {
|
|
203
|
+
const translation = translations.find((entry) => entry._languageCode === locale);
|
|
204
|
+
if (translation) {
|
|
205
|
+
for (const fieldName of translatableFields) {
|
|
206
|
+
if (translation[fieldName] !== undefined && translation[fieldName] !== null) {
|
|
207
|
+
baseDoc[fieldName] = translation[fieldName];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
...baseDoc,
|
|
214
|
+
_availableLocales: availableLocales,
|
|
215
|
+
_locale: locale ?? null,
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
const stripSensitiveFields = (doc) => {
|
|
219
|
+
if (!collection.auth)
|
|
220
|
+
return doc;
|
|
221
|
+
const { password: _password, ...rest } = doc;
|
|
222
|
+
return rest;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
async find(options = {}, context = {}) {
|
|
226
|
+
const allowed = await canAccess(collection, "read", context);
|
|
227
|
+
if (!allowed)
|
|
228
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
229
|
+
const db = await getDb();
|
|
230
|
+
const tables = await getTableRefs(slug);
|
|
231
|
+
const status = options.status ?? (collection.drafts ? "published" : "any");
|
|
232
|
+
const conditions = [];
|
|
233
|
+
if (status !== "any" && collection.drafts) {
|
|
234
|
+
conditions.push(eq(tables.main._status, status));
|
|
235
|
+
}
|
|
236
|
+
if (options.where) {
|
|
237
|
+
for (const [key, value] of Object.entries(options.where)) {
|
|
238
|
+
if (key in tables.main) {
|
|
239
|
+
conditions.push(eq(tables.main[key], value));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (options.search?.trim()) {
|
|
244
|
+
const searchTerm = `%${options.search.trim().toLowerCase()}%`;
|
|
245
|
+
const searchableTypes = new Set(["text", "slug", "email", "select"]);
|
|
246
|
+
const searchConditions = Object.entries(collection.fields)
|
|
247
|
+
.filter(([, field]) => searchableTypes.has(field.type))
|
|
248
|
+
.filter(([name]) => name in tables.main)
|
|
249
|
+
.map(([name]) => like(sql `lower(${tables.main[name]})`, searchTerm));
|
|
250
|
+
if (searchConditions.length > 0) {
|
|
251
|
+
conditions.push(or(...searchConditions));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let query = db.select().from(tables.main);
|
|
255
|
+
if (conditions.length > 0) {
|
|
256
|
+
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
|
257
|
+
}
|
|
258
|
+
if (options.sort) {
|
|
259
|
+
const col = tables.main[options.sort.field];
|
|
260
|
+
if (col) {
|
|
261
|
+
query = query.orderBy(options.sort.direction === "desc" ? desc(col) : asc(col));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (options.limit)
|
|
265
|
+
query = query.limit(options.limit);
|
|
266
|
+
if (options.offset)
|
|
267
|
+
query = query.offset(options.offset);
|
|
268
|
+
const rows = await query;
|
|
269
|
+
const docs = rows.map((row) => {
|
|
270
|
+
const doc = deserializeFromDb(collection, row);
|
|
271
|
+
if (status === "published" && typeof row._published === "string") {
|
|
272
|
+
try {
|
|
273
|
+
const snapshot = JSON.parse(row._published);
|
|
274
|
+
for (const key of Object.keys(collection.fields)) {
|
|
275
|
+
if (key in snapshot)
|
|
276
|
+
doc[key] = snapshot[key];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch { }
|
|
280
|
+
}
|
|
281
|
+
return doc;
|
|
282
|
+
});
|
|
283
|
+
if (tables.translations && (options.locale || config.locales)) {
|
|
284
|
+
const docIds = docs.map((doc) => String(doc._id));
|
|
285
|
+
if (docIds.length > 0) {
|
|
286
|
+
const allTranslations = await db
|
|
287
|
+
.select()
|
|
288
|
+
.from(tables.translations)
|
|
289
|
+
.where(sql `${tables.translations._entityId} IN (${sql.join(docIds.map((id) => sql `${id}`), sql `, `)})`);
|
|
290
|
+
const parsedTranslations = allTranslations.map((translation) => {
|
|
291
|
+
const parsed = { ...translation };
|
|
292
|
+
const translatableFields = getTranslatableFieldNames(collection);
|
|
293
|
+
for (const fieldName of translatableFields) {
|
|
294
|
+
const field = collection.fields[fieldName];
|
|
295
|
+
if (isJsonField(field) && typeof parsed[fieldName] === "string") {
|
|
296
|
+
try {
|
|
297
|
+
parsed[fieldName] = JSON.parse(parsed[fieldName]);
|
|
298
|
+
}
|
|
299
|
+
catch { }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return parsed;
|
|
303
|
+
});
|
|
304
|
+
return docs.map((doc) => {
|
|
305
|
+
const docTranslations = parsedTranslations.filter((translation) => translation._entityId === doc._id);
|
|
306
|
+
return stripSensitiveFields(overlayLocale(doc, docTranslations, options.locale));
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return docs.map((doc) => stripSensitiveFields(overlayLocale(doc, [], options.locale)));
|
|
311
|
+
},
|
|
312
|
+
async findOne(filter, context = {}) {
|
|
313
|
+
const docs = await this.find({
|
|
314
|
+
where: pick(filter, Object.keys(collection.fields).concat(["_id", "_status", "slug"])),
|
|
315
|
+
locale: filter.locale,
|
|
316
|
+
status: filter.status,
|
|
317
|
+
limit: 1,
|
|
318
|
+
}, context);
|
|
319
|
+
return docs[0] ?? null;
|
|
320
|
+
},
|
|
321
|
+
async findById(id, options = {}, context = {}) {
|
|
322
|
+
const db = await getDb();
|
|
323
|
+
const tables = await getTableRefs(slug);
|
|
324
|
+
const rows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
325
|
+
if (rows.length === 0)
|
|
326
|
+
return null;
|
|
327
|
+
const doc = deserializeFromDb(collection, rows[0]);
|
|
328
|
+
const allowed = await canAccess(collection, "read", context, doc);
|
|
329
|
+
if (!allowed)
|
|
330
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
331
|
+
if (options.status && options.status !== "any" && doc._status !== options.status)
|
|
332
|
+
return null;
|
|
333
|
+
const effectiveStatus = options.status ?? (collection.drafts ? "published" : "any");
|
|
334
|
+
if (effectiveStatus === "published" && typeof rows[0]._published === "string") {
|
|
335
|
+
try {
|
|
336
|
+
const snapshot = JSON.parse(rows[0]._published);
|
|
337
|
+
for (const key of Object.keys(collection.fields)) {
|
|
338
|
+
if (key in snapshot)
|
|
339
|
+
doc[key] = snapshot[key];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch { }
|
|
343
|
+
}
|
|
344
|
+
let translations = [];
|
|
345
|
+
if (tables.translations) {
|
|
346
|
+
const rawTranslations = await db
|
|
347
|
+
.select()
|
|
348
|
+
.from(tables.translations)
|
|
349
|
+
.where(eq(tables.translations._entityId, id));
|
|
350
|
+
translations = rawTranslations.map((translation) => {
|
|
351
|
+
const parsed = { ...translation };
|
|
352
|
+
for (const fieldName of getTranslatableFieldNames(collection)) {
|
|
353
|
+
const field = collection.fields[fieldName];
|
|
354
|
+
if (isJsonField(field) && typeof parsed[fieldName] === "string") {
|
|
355
|
+
try {
|
|
356
|
+
parsed[fieldName] = JSON.parse(parsed[fieldName]);
|
|
357
|
+
}
|
|
358
|
+
catch { }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return parsed;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return stripSensitiveFields(overlayLocale(doc, translations, options.locale));
|
|
365
|
+
},
|
|
366
|
+
async create(data, context = {}) {
|
|
367
|
+
const allowed = await canAccess(collection, "create", context);
|
|
368
|
+
if (!allowed)
|
|
369
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
370
|
+
const db = await getDb();
|
|
371
|
+
const tables = await getTableRefs(slug);
|
|
372
|
+
const hookContext = getHookContext(collection, "create", context);
|
|
373
|
+
const preparedInput = prepareIncomingData(collection, data, undefined);
|
|
374
|
+
if (collection.auth && typeof preparedInput.password === "string" && preparedInput.password) {
|
|
375
|
+
preparedInput.password = await hashPassword(preparedInput.password);
|
|
376
|
+
}
|
|
377
|
+
const transformedInput = collection.hooks?.beforeCreate
|
|
378
|
+
? await collection.hooks.beforeCreate(preparedInput, hookContext)
|
|
379
|
+
: preparedInput;
|
|
380
|
+
const createdAt = now();
|
|
381
|
+
const docId = typeof data._id === "string" ? String(data._id) : nanoid();
|
|
382
|
+
const docValues = {
|
|
383
|
+
_id: docId,
|
|
384
|
+
...serializeForDb(collection, transformedInput),
|
|
385
|
+
};
|
|
386
|
+
if (collection.drafts) {
|
|
387
|
+
const status = data._status === "published" ? "published" : getDefaultStatus(collection);
|
|
388
|
+
docValues._status = status;
|
|
389
|
+
if (status === "published")
|
|
390
|
+
docValues._publishedAt = createdAt;
|
|
391
|
+
}
|
|
392
|
+
if (collection.timestamps !== false) {
|
|
393
|
+
docValues._createdAt = createdAt;
|
|
394
|
+
docValues._updatedAt = createdAt;
|
|
395
|
+
}
|
|
396
|
+
await db.insert(tables.main).values(docValues);
|
|
397
|
+
if (collection.versions && tables.versions) {
|
|
398
|
+
await db.insert(tables.versions).values({
|
|
399
|
+
_id: nanoid(),
|
|
400
|
+
_docId: docId,
|
|
401
|
+
_version: 1,
|
|
402
|
+
_snapshot: JSON.stringify({ ...transformedInput, _status: docValues._status }),
|
|
403
|
+
_createdAt: createdAt,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const result = await this.findById(docId, {}, context);
|
|
407
|
+
await collection.hooks?.afterCreate?.(result, hookContext);
|
|
408
|
+
return result;
|
|
409
|
+
},
|
|
410
|
+
async update(id, data, context = {}) {
|
|
411
|
+
const db = await getDb();
|
|
412
|
+
const tables = await getTableRefs(slug);
|
|
413
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
414
|
+
if (existingRows.length === 0)
|
|
415
|
+
throw new Error(`${collection.labels.singular} not found.`);
|
|
416
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
417
|
+
const allowed = await canAccess(collection, "update", context, existing);
|
|
418
|
+
if (!allowed)
|
|
419
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
420
|
+
const accessCtx = { user: context.user ?? null, doc: existing, operation: "update", collection: slug };
|
|
421
|
+
for (const [fieldName, field] of Object.entries(collection.fields)) {
|
|
422
|
+
if (field.access?.update && data[fieldName] !== undefined) {
|
|
423
|
+
const fieldAllowed = await field.access.update(accessCtx);
|
|
424
|
+
if (!fieldAllowed)
|
|
425
|
+
delete data[fieldName];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const hookContext = getHookContext(collection, "update", context);
|
|
429
|
+
const preparedInput = prepareIncomingData(collection, data, undefined, existing);
|
|
430
|
+
if (collection.auth && typeof preparedInput.password === "string" && preparedInput.password) {
|
|
431
|
+
preparedInput.password = await hashPassword(preparedInput.password);
|
|
432
|
+
}
|
|
433
|
+
const transformedInput = collection.hooks?.beforeUpdate
|
|
434
|
+
? await collection.hooks.beforeUpdate(preparedInput, existing, hookContext)
|
|
435
|
+
: preparedInput;
|
|
436
|
+
if (collection.drafts && existing._status === "published" && !existingRows[0]._published) {
|
|
437
|
+
const snapshot = {};
|
|
438
|
+
for (const fieldName of Object.keys(collection.fields)) {
|
|
439
|
+
if (existing[fieldName] !== undefined)
|
|
440
|
+
snapshot[fieldName] = existing[fieldName];
|
|
441
|
+
}
|
|
442
|
+
await db
|
|
443
|
+
.update(tables.main)
|
|
444
|
+
.set({ _published: JSON.stringify(snapshot) })
|
|
445
|
+
.where(eq(tables.main._id, id));
|
|
446
|
+
}
|
|
447
|
+
const updateValues = {
|
|
448
|
+
...serializeForDb(collection, transformedInput),
|
|
449
|
+
};
|
|
450
|
+
if (collection.timestamps !== false) {
|
|
451
|
+
updateValues._updatedAt = now();
|
|
452
|
+
}
|
|
453
|
+
await db.update(tables.main).set(updateValues).where(eq(tables.main._id, id));
|
|
454
|
+
if (collection.versions && tables.versions) {
|
|
455
|
+
const versionRows = await db
|
|
456
|
+
.select({ maxVersion: sql `coalesce(max(_version), 0)` })
|
|
457
|
+
.from(tables.versions)
|
|
458
|
+
.where(eq(tables.versions._docId, id));
|
|
459
|
+
const nextVersion = Number(versionRows[0]?.maxVersion ?? 0) + 1;
|
|
460
|
+
const maxVersions = collection.versions.max;
|
|
461
|
+
await db.insert(tables.versions).values({
|
|
462
|
+
_id: nanoid(),
|
|
463
|
+
_docId: id,
|
|
464
|
+
_version: nextVersion,
|
|
465
|
+
_snapshot: JSON.stringify({ ...existing, ...transformedInput }),
|
|
466
|
+
_createdAt: now(),
|
|
467
|
+
});
|
|
468
|
+
if (maxVersions) {
|
|
469
|
+
const allVersions = await db
|
|
470
|
+
.select()
|
|
471
|
+
.from(tables.versions)
|
|
472
|
+
.where(eq(tables.versions._docId, id))
|
|
473
|
+
.orderBy(desc(tables.versions._version));
|
|
474
|
+
const toDelete = allVersions.slice(maxVersions);
|
|
475
|
+
for (const version of toDelete) {
|
|
476
|
+
await db.delete(tables.versions).where(eq(tables.versions._id, version._id));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const result = await this.findById(id, { status: "any" }, context);
|
|
481
|
+
await collection.hooks?.afterUpdate?.(result, hookContext);
|
|
482
|
+
return result;
|
|
483
|
+
},
|
|
484
|
+
async delete(id, context = {}) {
|
|
485
|
+
const db = await getDb();
|
|
486
|
+
const tables = await getTableRefs(slug);
|
|
487
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
488
|
+
if (existingRows.length === 0)
|
|
489
|
+
return;
|
|
490
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
491
|
+
const allowed = await canAccess(collection, "delete", context, existing);
|
|
492
|
+
if (!allowed)
|
|
493
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
494
|
+
const hookContext = getHookContext(collection, "delete", context);
|
|
495
|
+
await collection.hooks?.beforeDelete?.(existing, hookContext);
|
|
496
|
+
if (tables.translations)
|
|
497
|
+
await db.delete(tables.translations).where(eq(tables.translations._entityId, id));
|
|
498
|
+
if (tables.versions)
|
|
499
|
+
await db.delete(tables.versions).where(eq(tables.versions._docId, id));
|
|
500
|
+
await db.delete(tables.main).where(eq(tables.main._id, id));
|
|
501
|
+
await collection.hooks?.afterDelete?.(existing, hookContext);
|
|
502
|
+
},
|
|
503
|
+
async publish(id, context = {}) {
|
|
504
|
+
if (!collection.drafts)
|
|
505
|
+
throw new Error(`${collection.labels.singular} does not support draft status.`);
|
|
506
|
+
const db = await getDb();
|
|
507
|
+
const tables = await getTableRefs(slug);
|
|
508
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
509
|
+
if (existingRows.length === 0)
|
|
510
|
+
throw new Error(`${collection.labels.singular} not found.`);
|
|
511
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
512
|
+
const allowed = await canAccess(collection, "publish", context, existing);
|
|
513
|
+
if (!allowed)
|
|
514
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
515
|
+
const hookContext = getHookContext(collection, "publish", context);
|
|
516
|
+
if (collection.hooks?.beforePublish) {
|
|
517
|
+
await collection.hooks.beforePublish(existing, hookContext);
|
|
518
|
+
}
|
|
519
|
+
const timestamp = now();
|
|
520
|
+
const updateValues = {
|
|
521
|
+
_status: "published",
|
|
522
|
+
_publishedAt: timestamp,
|
|
523
|
+
_publishAt: null,
|
|
524
|
+
_unpublishAt: null,
|
|
525
|
+
_published: null,
|
|
526
|
+
};
|
|
527
|
+
if (collection.timestamps !== false)
|
|
528
|
+
updateValues._updatedAt = timestamp;
|
|
529
|
+
await db.update(tables.main).set(updateValues).where(eq(tables.main._id, id));
|
|
530
|
+
const result = await this.findById(id, { status: "any" }, context);
|
|
531
|
+
await collection.hooks?.afterPublish?.(result, hookContext);
|
|
532
|
+
return result;
|
|
533
|
+
},
|
|
534
|
+
async unpublish(id, context = {}) {
|
|
535
|
+
if (!collection.drafts)
|
|
536
|
+
throw new Error(`${collection.labels.singular} does not support draft status.`);
|
|
537
|
+
const db = await getDb();
|
|
538
|
+
const tables = await getTableRefs(slug);
|
|
539
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
540
|
+
if (existingRows.length === 0)
|
|
541
|
+
throw new Error(`${collection.labels.singular} not found.`);
|
|
542
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
543
|
+
const allowed = await canAccess(collection, "publish", context, existing);
|
|
544
|
+
if (!allowed)
|
|
545
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
546
|
+
const hookContext = getHookContext(collection, "unpublish", context);
|
|
547
|
+
if (collection.hooks?.beforeUnpublish) {
|
|
548
|
+
await collection.hooks.beforeUnpublish(existing, hookContext);
|
|
549
|
+
}
|
|
550
|
+
const updateValues = {
|
|
551
|
+
_status: "draft",
|
|
552
|
+
_publishedAt: null,
|
|
553
|
+
_publishAt: null,
|
|
554
|
+
_unpublishAt: null,
|
|
555
|
+
_published: null,
|
|
556
|
+
};
|
|
557
|
+
if (collection.timestamps !== false)
|
|
558
|
+
updateValues._updatedAt = now();
|
|
559
|
+
await db.update(tables.main).set(updateValues).where(eq(tables.main._id, id));
|
|
560
|
+
const result = (await this.findById(id, { status: "any" }, context));
|
|
561
|
+
await collection.hooks?.afterUnpublish?.(result, hookContext);
|
|
562
|
+
return result;
|
|
563
|
+
},
|
|
564
|
+
async schedule(id, publishAt, unpublishAt, context = {}) {
|
|
565
|
+
if (!collection.drafts)
|
|
566
|
+
throw new Error(`${collection.labels.singular} does not support draft status.`);
|
|
567
|
+
const db = await getDb();
|
|
568
|
+
const tables = await getTableRefs(slug);
|
|
569
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
570
|
+
if (existingRows.length === 0)
|
|
571
|
+
throw new Error(`${collection.labels.singular} not found.`);
|
|
572
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
573
|
+
const scheduleAllowed = await canAccess(collection, "schedule", context, existing);
|
|
574
|
+
const publishAllowed = collection.access?.schedule
|
|
575
|
+
? scheduleAllowed
|
|
576
|
+
: await canAccess(collection, "publish", context, existing);
|
|
577
|
+
if (!scheduleAllowed && !publishAllowed)
|
|
578
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
579
|
+
const hookContext = getHookContext(collection, "schedule", context);
|
|
580
|
+
if (collection.hooks?.beforeSchedule) {
|
|
581
|
+
await collection.hooks.beforeSchedule(existing, hookContext);
|
|
582
|
+
}
|
|
583
|
+
const updateValues = {
|
|
584
|
+
_status: "scheduled",
|
|
585
|
+
_publishAt: publishAt,
|
|
586
|
+
_unpublishAt: unpublishAt ?? null,
|
|
587
|
+
};
|
|
588
|
+
if (collection.timestamps !== false)
|
|
589
|
+
updateValues._updatedAt = now();
|
|
590
|
+
await db.update(tables.main).set(updateValues).where(eq(tables.main._id, id));
|
|
591
|
+
const result = (await this.findById(id, { status: "any" }, context));
|
|
592
|
+
await collection.hooks?.afterSchedule?.(result, hookContext);
|
|
593
|
+
return result;
|
|
594
|
+
},
|
|
595
|
+
async discardDraft(id, context = {}) {
|
|
596
|
+
if (!collection.drafts)
|
|
597
|
+
throw new Error(`${collection.labels.singular} does not support draft status.`);
|
|
598
|
+
const db = await getDb();
|
|
599
|
+
const tables = await getTableRefs(slug);
|
|
600
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
601
|
+
if (existingRows.length === 0)
|
|
602
|
+
throw new Error(`${collection.labels.singular} not found.`);
|
|
603
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
604
|
+
const allowed = await canAccess(collection, "update", context, existing);
|
|
605
|
+
if (!allowed)
|
|
606
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
607
|
+
const rawPublished = existingRows[0]._published;
|
|
608
|
+
if (!rawPublished)
|
|
609
|
+
return (await this.findById(id, { status: "any" }, context));
|
|
610
|
+
const snapshot = JSON.parse(rawPublished);
|
|
611
|
+
const restoreValues = { _published: null };
|
|
612
|
+
for (const [fieldName, field] of Object.entries(collection.fields)) {
|
|
613
|
+
if (fieldName in snapshot) {
|
|
614
|
+
restoreValues[fieldName] =
|
|
615
|
+
isJsonField(field) && snapshot[fieldName] !== null
|
|
616
|
+
? JSON.stringify(snapshot[fieldName])
|
|
617
|
+
: snapshot[fieldName];
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
await db.update(tables.main).set(restoreValues).where(eq(tables.main._id, id));
|
|
621
|
+
return (await this.findById(id, { status: "any" }, context));
|
|
622
|
+
},
|
|
623
|
+
async count(filter = {}, context = {}) {
|
|
624
|
+
const allowed = await canAccess(collection, "read", context);
|
|
625
|
+
if (!allowed)
|
|
626
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
627
|
+
const db = await getDb();
|
|
628
|
+
const tables = await getTableRefs(slug);
|
|
629
|
+
const status = filter.status ?? (collection.drafts ? "published" : "any");
|
|
630
|
+
const conditions = [];
|
|
631
|
+
if (status !== "any" && collection.drafts) {
|
|
632
|
+
conditions.push(eq(tables.main._status, status));
|
|
633
|
+
}
|
|
634
|
+
if (filter.where) {
|
|
635
|
+
for (const [key, value] of Object.entries(filter.where)) {
|
|
636
|
+
if (key in tables.main) {
|
|
637
|
+
conditions.push(eq(tables.main[key], value));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (filter.search?.trim()) {
|
|
642
|
+
const searchTerm = `%${filter.search.trim().toLowerCase()}%`;
|
|
643
|
+
const searchableTypes = new Set(["text", "slug", "email", "select"]);
|
|
644
|
+
const searchConditions = Object.entries(collection.fields)
|
|
645
|
+
.filter(([, field]) => searchableTypes.has(field.type))
|
|
646
|
+
.filter(([name]) => name in tables.main)
|
|
647
|
+
.map(([name]) => like(sql `lower(${tables.main[name]})`, searchTerm));
|
|
648
|
+
if (searchConditions.length > 0) {
|
|
649
|
+
conditions.push(or(...searchConditions));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
let query = db.select({ total: sql `count(*)` }).from(tables.main);
|
|
653
|
+
if (conditions.length > 0) {
|
|
654
|
+
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
|
655
|
+
}
|
|
656
|
+
const result = await query;
|
|
657
|
+
return Number(result[0]?.total ?? 0);
|
|
658
|
+
},
|
|
659
|
+
async versions(id) {
|
|
660
|
+
const tables = await getTableRefs(slug);
|
|
661
|
+
if (!tables.versions)
|
|
662
|
+
return [];
|
|
663
|
+
const db = await getDb();
|
|
664
|
+
const rows = await db
|
|
665
|
+
.select()
|
|
666
|
+
.from(tables.versions)
|
|
667
|
+
.where(eq(tables.versions._docId, id))
|
|
668
|
+
.orderBy(desc(tables.versions._version));
|
|
669
|
+
return rows.map((row) => ({
|
|
670
|
+
version: row._version,
|
|
671
|
+
createdAt: row._createdAt,
|
|
672
|
+
snapshot: typeof row._snapshot === "string" ? JSON.parse(row._snapshot) : row._snapshot,
|
|
673
|
+
}));
|
|
674
|
+
},
|
|
675
|
+
async restore(id, versionNumber, context = {}) {
|
|
676
|
+
const versionList = await this.versions(id);
|
|
677
|
+
const version = versionList.find((entry) => entry.version === versionNumber);
|
|
678
|
+
if (!version)
|
|
679
|
+
throw new Error(`Version ${versionNumber} not found.`);
|
|
680
|
+
return this.update(id, version.snapshot, context);
|
|
681
|
+
},
|
|
682
|
+
async getTranslations(id) {
|
|
683
|
+
const tables = await getTableRefs(slug);
|
|
684
|
+
if (!tables.translations)
|
|
685
|
+
return {};
|
|
686
|
+
const db = await getDb();
|
|
687
|
+
const rows = await db.select().from(tables.translations).where(eq(tables.translations._entityId, id));
|
|
688
|
+
const result = {};
|
|
689
|
+
for (const row of rows) {
|
|
690
|
+
const translation = row;
|
|
691
|
+
const locale = String(translation._languageCode);
|
|
692
|
+
const values = {};
|
|
693
|
+
for (const fieldName of getTranslatableFieldNames(collection)) {
|
|
694
|
+
const field = collection.fields[fieldName];
|
|
695
|
+
let value = translation[fieldName];
|
|
696
|
+
if (isJsonField(field) && typeof value === "string") {
|
|
697
|
+
try {
|
|
698
|
+
value = JSON.parse(value);
|
|
699
|
+
}
|
|
700
|
+
catch { }
|
|
701
|
+
}
|
|
702
|
+
values[fieldName] = value;
|
|
703
|
+
}
|
|
704
|
+
result[locale] = values;
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
},
|
|
708
|
+
async upsertTranslation(id, locale, data, context = {}) {
|
|
709
|
+
const db = await getDb();
|
|
710
|
+
const tables = await getTableRefs(slug);
|
|
711
|
+
if (!tables.translations)
|
|
712
|
+
throw new Error(`Collection "${slug}" does not support translations.`);
|
|
713
|
+
const existingRows = await db.select().from(tables.main).where(eq(tables.main._id, id)).limit(1);
|
|
714
|
+
if (existingRows.length === 0)
|
|
715
|
+
throw new Error(`${collection.labels.singular} not found.`);
|
|
716
|
+
const existing = deserializeFromDb(collection, existingRows[0]);
|
|
717
|
+
const allowed = await canAccess(collection, "update", context, existing);
|
|
718
|
+
if (!allowed)
|
|
719
|
+
throw new Error(`Access denied for ${collection.slug}.`);
|
|
720
|
+
const translatableFields = getTranslatableFieldNames(collection);
|
|
721
|
+
const translatedValues = prepareIncomingData(collection, data, locale, existing);
|
|
722
|
+
const filtered = pick(translatedValues, translatableFields);
|
|
723
|
+
const serialized = serializeForDb(collection, filtered);
|
|
724
|
+
const existingTranslation = await db
|
|
725
|
+
.select()
|
|
726
|
+
.from(tables.translations)
|
|
727
|
+
.where(and(eq(tables.translations._entityId, id), eq(tables.translations._languageCode, locale)))
|
|
728
|
+
.limit(1);
|
|
729
|
+
if (existingTranslation.length > 0) {
|
|
730
|
+
await db
|
|
731
|
+
.update(tables.translations)
|
|
732
|
+
.set(serialized)
|
|
733
|
+
.where(and(eq(tables.translations._entityId, id), eq(tables.translations._languageCode, locale)));
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
await db.insert(tables.translations).values({
|
|
737
|
+
_id: nanoid(),
|
|
738
|
+
_entityId: id,
|
|
739
|
+
_languageCode: locale,
|
|
740
|
+
...serialized,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
if (collection.timestamps !== false) {
|
|
744
|
+
await db.update(tables.main).set({ _updatedAt: now() }).where(eq(tables.main._id, id));
|
|
745
|
+
}
|
|
746
|
+
if (collection.versions && tables.versions) {
|
|
747
|
+
const versionRows = await db
|
|
748
|
+
.select({ maxVersion: sql `coalesce(max(_version), 0)` })
|
|
749
|
+
.from(tables.versions)
|
|
750
|
+
.where(eq(tables.versions._docId, id));
|
|
751
|
+
const nextVersion = Number(versionRows[0]?.maxVersion ?? 0) + 1;
|
|
752
|
+
await db.insert(tables.versions).values({
|
|
753
|
+
_id: nanoid(),
|
|
754
|
+
_docId: id,
|
|
755
|
+
_version: nextVersion,
|
|
756
|
+
_snapshot: JSON.stringify({ ...existing, _translations: { [locale]: filtered } }),
|
|
757
|
+
_createdAt: now(),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
return (await this.findById(id, { locale, status: "any" }, context));
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
const collectionApiEntries = Object.keys(collectionMap).map((slug) => [slug, createCollectionApi(slug)]);
|
|
765
|
+
return {
|
|
766
|
+
...Object.fromEntries(collectionApiEntries),
|
|
767
|
+
meta: {
|
|
768
|
+
getCollections: () => config.collections.map((collection) => ({
|
|
769
|
+
slug: collection.slug,
|
|
770
|
+
labels: collection.labels,
|
|
771
|
+
pathPrefix: collection.pathPrefix,
|
|
772
|
+
drafts: !!collection.drafts,
|
|
773
|
+
versions: collection.versions?.max ?? 0,
|
|
774
|
+
})),
|
|
775
|
+
getFields: (slug) => ensureCollection(config, slug).fields,
|
|
776
|
+
getCollection: (slug) => ensureCollection(config, slug),
|
|
777
|
+
getRouteForDocument: (slug, doc) => {
|
|
778
|
+
const collection = ensureCollection(config, slug);
|
|
779
|
+
const slugValue = String(doc.slug ?? "");
|
|
780
|
+
return collection.pathPrefix
|
|
781
|
+
? `/${collection.pathPrefix}/${slugValue}`
|
|
782
|
+
: `/${slugValue === "home" ? "" : slugValue}`;
|
|
783
|
+
},
|
|
784
|
+
getConfig: () => config,
|
|
785
|
+
getLocales: () => config.locales,
|
|
786
|
+
isTranslatableField: (slug, fieldName) => {
|
|
787
|
+
const collection = ensureCollection(config, slug);
|
|
788
|
+
const field = collection.fields[fieldName];
|
|
789
|
+
return !!field?.translatable && !isStructuralField(field);
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
scheduled: {
|
|
793
|
+
async processPublishing(cache) {
|
|
794
|
+
const db = await getDb();
|
|
795
|
+
const timestamp = now();
|
|
796
|
+
let published = 0;
|
|
797
|
+
let unpublished = 0;
|
|
798
|
+
for (const collection of config.collections) {
|
|
799
|
+
if (!collection.drafts)
|
|
800
|
+
continue;
|
|
801
|
+
const tables = await getTableRefs(collection.slug);
|
|
802
|
+
const collectionApiInstance = createCollectionApi(collection.slug);
|
|
803
|
+
const ctx = { cache, _system: true };
|
|
804
|
+
const toPublish = await db
|
|
805
|
+
.select()
|
|
806
|
+
.from(tables.main)
|
|
807
|
+
.where(and(eq(tables.main._status, "scheduled"), lte(tables.main._publishAt, timestamp)));
|
|
808
|
+
for (const row of toPublish) {
|
|
809
|
+
const doc = row;
|
|
810
|
+
await collectionApiInstance.publish(String(doc._id), ctx);
|
|
811
|
+
published++;
|
|
812
|
+
}
|
|
813
|
+
const toUnpublish = await db
|
|
814
|
+
.select()
|
|
815
|
+
.from(tables.main)
|
|
816
|
+
.where(and(eq(tables.main._status, "published"), sql `${tables.main._unpublishAt} IS NOT NULL`, lte(tables.main._unpublishAt, timestamp)));
|
|
817
|
+
for (const row of toUnpublish) {
|
|
818
|
+
const doc = row;
|
|
819
|
+
await collectionApiInstance.unpublish(String(doc._id), ctx);
|
|
820
|
+
unpublished++;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return { published, unpublished };
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
};
|