@xleddyl/nuxt-cms 0.4.2 → 0.4.4

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.
Files changed (59) hide show
  1. package/README.md +74 -96
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +84 -16
  4. package/dist/runtime/app/components/cms/BlocksField.vue +30 -5
  5. package/dist/runtime/app/components/cms/DropdownMenu.vue +1 -5
  6. package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +3 -0
  7. package/dist/runtime/app/components/cms/EntryDrawer.vue +32 -13
  8. package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +3 -0
  9. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
  10. package/dist/runtime/app/components/cms/EntryForm.vue +20 -3
  11. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
  12. package/dist/runtime/app/components/cms/FieldInput.vue +2 -2
  13. package/dist/runtime/app/components/cms/Form.d.vue.ts +8 -6
  14. package/dist/runtime/app/components/cms/Form.vue +26 -5
  15. package/dist/runtime/app/components/cms/Form.vue.d.ts +8 -6
  16. package/dist/runtime/app/components/cms/FormField.vue +6 -1
  17. package/dist/runtime/app/components/cms/ImportModal.d.vue.ts +18 -0
  18. package/dist/runtime/app/components/cms/ImportModal.vue +252 -0
  19. package/dist/runtime/app/components/cms/ImportModal.vue.d.ts +18 -0
  20. package/dist/runtime/app/components/cms/ListFilters.d.vue.ts +18 -0
  21. package/dist/runtime/app/components/cms/ListFilters.vue +151 -0
  22. package/dist/runtime/app/components/cms/ListFilters.vue.d.ts +18 -0
  23. package/dist/runtime/app/components/cms/RelationField.vue +3 -1
  24. package/dist/runtime/app/components/cms/VisualEditor.vue +81 -12
  25. package/dist/runtime/app/pages/admin-collection.vue +158 -7
  26. package/dist/runtime/app/pages/admin-content.vue +18 -7
  27. package/dist/runtime/app/pages/admin-entry.vue +1 -0
  28. package/dist/runtime/app/pages/admin-preview.vue +6 -3
  29. package/dist/runtime/app/preview/PreviewOverlay.vue +4 -0
  30. package/dist/runtime/app/utils/ui.d.ts +2 -1
  31. package/dist/runtime/app/utils/ui.js +1 -0
  32. package/dist/runtime/assets/main.css +1 -1
  33. package/dist/runtime/server/api/collection-export.get.d.ts +2 -0
  34. package/dist/runtime/server/api/collection-export.get.js +67 -0
  35. package/dist/runtime/server/api/collection-import.post.d.ts +2 -0
  36. package/dist/runtime/server/api/collection-import.post.js +85 -0
  37. package/dist/runtime/server/api/collection.get.js +7 -15
  38. package/dist/runtime/server/utils/import.d.ts +31 -0
  39. package/dist/runtime/server/utils/import.js +202 -0
  40. package/dist/runtime/server/utils/list-query.d.ts +15 -0
  41. package/dist/runtime/server/utils/list-query.js +54 -0
  42. package/dist/runtime/server/utils/media-check.d.ts +2 -0
  43. package/dist/runtime/server/utils/media-check.js +1 -1
  44. package/dist/runtime/server/utils/media-references.js +4 -1
  45. package/dist/runtime/server/utils/registry.js +1 -1
  46. package/dist/runtime/server/utils/search.js +23 -4
  47. package/dist/runtime/shared/duplicate.d.ts +10 -0
  48. package/dist/runtime/shared/duplicate.js +54 -0
  49. package/dist/runtime/shared/index.d.ts +49 -2
  50. package/dist/runtime/shared/index.js +33 -0
  51. package/dist/runtime/shared/layout.d.ts +3 -0
  52. package/dist/runtime/shared/layout.js +35 -2
  53. package/dist/runtime/shared/list-filters.d.ts +20 -0
  54. package/dist/runtime/shared/list-filters.js +83 -0
  55. package/dist/runtime/shared/transfer.d.ts +70 -0
  56. package/dist/runtime/shared/transfer.js +256 -0
  57. package/dist/runtime/shared/validation.d.ts +15 -2
  58. package/dist/runtime/shared/validation.js +102 -9
  59. package/package.json +1 -1
@@ -0,0 +1,67 @@
1
+ import { createError, defineEventHandler, getValidatedQuery, setResponseHeader } from "h3";
2
+ import { z } from "zod";
3
+ import { cmsDialect, useDb } from "#cms-db";
4
+ import { isCollectionKind } from "../../shared/index.js";
5
+ import {
6
+ canTransferCsv,
7
+ entryRowsToCsv,
8
+ exportFileName,
9
+ exportRow,
10
+ TRANSFER_FORMATS
11
+ } from "../../shared/transfer.js";
12
+ import { listOrderBy, listWhere, readListFilters } from "../utils/list-query.js";
13
+ import { decodeRows, getContentI18n, getRegistryEntry } from "../utils/registry.js";
14
+ import { attachManyToMany } from "../utils/relations.js";
15
+ import { requireAdmin } from "../utils/require-admin.js";
16
+ import { chunks, KEY_CHUNK } from "../utils/write-statements.js";
17
+ const querySchema = z.object({
18
+ format: z.enum(TRANSFER_FORMATS).default("json"),
19
+ search: z.string().trim().max(200).optional(),
20
+ sort: z.string().trim().max(64).optional(),
21
+ order: z.enum(["asc", "desc"]).default("desc")
22
+ });
23
+ export default defineEventHandler(async (event) => {
24
+ await requireAdmin(event);
25
+ const { name, entry, table } = getRegistryEntry(event);
26
+ if (!isCollectionKind(entry)) {
27
+ throw createError({ statusCode: 405, statusMessage: "Only collections can be exported" });
28
+ }
29
+ const { format, search, sort, order } = await getValidatedQuery(event, querySchema.parse);
30
+ if (format === "csv" && !canTransferCsv(entry)) {
31
+ throw createError({
32
+ statusCode: 400,
33
+ statusMessage: "CSV needs an entry without blocks and many-to-many fields"
34
+ });
35
+ }
36
+ const db = useDb();
37
+ const where = listWhere(cmsDialect, entry, table, {
38
+ search,
39
+ filters: readListFilters(event, entry)
40
+ });
41
+ const base = db.select().from(table);
42
+ const rows = await (where ? base.where(where) : base).orderBy(
43
+ ...listOrderBy(table, sort, order)
44
+ );
45
+ for (const chunk of chunks(rows, KEY_CHUNK)) await attachManyToMany(db, name, entry, chunk);
46
+ decodeRows(entry, rows);
47
+ const items = rows.map((row) => exportRow(entry, row));
48
+ const now = /* @__PURE__ */ new Date();
49
+ setResponseHeader(event, "cache-control", "no-store");
50
+ setResponseHeader(
51
+ event,
52
+ "content-disposition",
53
+ `attachment; filename="${exportFileName(name, format, now)}"`
54
+ );
55
+ if (format === "csv") {
56
+ setResponseHeader(event, "content-type", "text/csv; charset=utf-8");
57
+ return entryRowsToCsv(entry, items, getContentI18n());
58
+ }
59
+ setResponseHeader(event, "content-type", "application/json; charset=utf-8");
60
+ const file = {
61
+ collection: name,
62
+ exportedAt: now.toISOString(),
63
+ total: items.length,
64
+ items
65
+ };
66
+ return JSON.stringify(file, null, 2);
67
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../shared/transfer.js").CmsImportPreview>>;
2
+ export default _default;
@@ -0,0 +1,85 @@
1
+ import {
2
+ createError,
3
+ defineEventHandler,
4
+ getRequestHeader,
5
+ getValidatedQuery,
6
+ readRawBody
7
+ } from "h3";
8
+ import { z } from "zod";
9
+ import { runBatch, useDb } from "#cms-db";
10
+ import { isCollectionKind } from "../../shared/index.js";
11
+ import {
12
+ canTransferCsv,
13
+ IMPORT_MAX_BYTES,
14
+ IMPORT_MODES,
15
+ importSource,
16
+ TRANSFER_FORMATS
17
+ } from "../../shared/transfer.js";
18
+ import { mapConstraintErrors } from "../utils/db-errors.js";
19
+ import {
20
+ checkImportRows,
21
+ importPreview,
22
+ importWriteStatements,
23
+ validateImportRows
24
+ } from "../utils/import.js";
25
+ import { mediaLookup } from "../utils/media-check.js";
26
+ import { getContentI18n, getRegistryEntry } from "../utils/registry.js";
27
+ import { requireAdmin } from "../utils/require-admin.js";
28
+ const querySchema = z.object({
29
+ format: z.enum(TRANSFER_FORMATS),
30
+ mode: z.enum(IMPORT_MODES).default("create"),
31
+ dryRun: z.stringbool().default(true)
32
+ });
33
+ function tooLarge() {
34
+ throw createError({
35
+ statusCode: 413,
36
+ statusMessage: `The file is larger than ${IMPORT_MAX_BYTES / 1024 / 1024} MB`
37
+ });
38
+ }
39
+ export default defineEventHandler(async (event) => {
40
+ await requireAdmin(event);
41
+ const { name, entry, table } = getRegistryEntry(event);
42
+ if (!isCollectionKind(entry)) {
43
+ throw createError({ statusCode: 405, statusMessage: "Only collections can be imported" });
44
+ }
45
+ const { format, mode, dryRun } = await getValidatedQuery(event, querySchema.parse);
46
+ if (format === "csv" && !canTransferCsv(entry)) {
47
+ throw createError({
48
+ statusCode: 400,
49
+ statusMessage: "CSV needs an entry without blocks and many-to-many fields"
50
+ });
51
+ }
52
+ if (Number(getRequestHeader(event, "content-length") ?? 0) > IMPORT_MAX_BYTES) tooLarge();
53
+ const text = await readRawBody(event, "utf8") ?? "";
54
+ if (new TextEncoder().encode(text).length > IMPORT_MAX_BYTES) tooLarge();
55
+ if (!text.trim()) throw createError({ statusCode: 400, statusMessage: "The file is empty" });
56
+ const i18n = getContentI18n();
57
+ const source = importSource(entry, format, text, i18n);
58
+ const db = useDb();
59
+ const rows = await checkImportRows(validateImportRows(entry, source.rows, i18n), {
60
+ db,
61
+ name,
62
+ entry,
63
+ table,
64
+ mode,
65
+ media: source.errors.length ? null : await mediaLookup()
66
+ });
67
+ const preview = importPreview(entry, rows, {
68
+ format,
69
+ mode,
70
+ defaultLocale: i18n.defaultLocale,
71
+ errors: source.errors
72
+ });
73
+ if (dryRun) return preview;
74
+ if (preview.errors.length || preview.valid !== preview.total || !preview.total) {
75
+ throw createError({
76
+ statusCode: 400,
77
+ statusMessage: "The file has errors: check it again and fix them before importing",
78
+ data: preview
79
+ });
80
+ }
81
+ await mapConstraintErrors(
82
+ () => runBatch((tx) => importWriteStatements(tx, name, entry, table, rows))
83
+ );
84
+ return { ...preview, imported: true };
85
+ });
@@ -1,10 +1,10 @@
1
- import { asc, count, desc, sql } from "drizzle-orm";
1
+ import { count } from "drizzle-orm";
2
2
  import { defineEventHandler, getValidatedQuery } from "h3";
3
3
  import { z } from "zod";
4
4
  import { cmsDialect, useDb } from "#cms-db";
5
5
  import { pageRoutes } from "../../shared/index.js";
6
- import { searchLikePattern } from "../../shared/search.js";
7
6
  import { decodeRows, getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
7
+ import { listOrderBy, listWhere, readListFilters } from "../utils/list-query.js";
8
8
  import { readPages } from "../utils/page-storage.js";
9
9
  import { attachManyToMany, relationTitles } from "../utils/relations.js";
10
10
  import { requireAdmin } from "../utils/require-admin.js";
@@ -49,25 +49,17 @@ export default defineEventHandler(async (event) => {
49
49
  }
50
50
  const columns = tableColumns(table);
51
51
  const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
52
- let where;
53
- if (search) {
54
- const column = titleColumn ?? idColumn(table);
55
- const text = cmsDialect === "postgres" ? sql`cast(${column} as text)` : sql`${column}`;
56
- where = sql`lower(${text}) like ${searchLikePattern(search.toLowerCase())} escape '\\'`;
57
- }
52
+ const where = listWhere(cmsDialect, entry, table, {
53
+ search,
54
+ filters: readListFilters(event, entry)
55
+ });
58
56
  const selection = light ? Object.fromEntries([
59
57
  ["id", idColumn(table)],
60
58
  ...entry.titleField && titleColumn ? [[entry.titleField, titleColumn]] : [],
61
59
  ...entry.drafts && Object.hasOwn(columns, "status") ? [["status", columns.status]] : []
62
60
  ]) : void 0;
63
- const sortColumn = sort && Object.hasOwn(columns, sort) ? columns[sort] : void 0;
64
- const orderBy = sortColumn ?? columns.createdAt ?? idColumn(table);
65
- const direction = sortColumn && order === "asc" ? asc : desc;
66
- const tiebreaker = idColumn(table);
67
61
  const base = selection ? db.select(selection).from(table) : db.select().from(table);
68
- const items = await (where ? base.where(where) : base).orderBy(
69
- ...orderBy === tiebreaker ? [direction(orderBy)] : [direction(orderBy), desc(tiebreaker)]
70
- ).limit(limit).offset(offset);
62
+ const items = await (where ? base.where(where) : base).orderBy(...listOrderBy(table, sort, order)).limit(limit).offset(offset);
71
63
  const [counted] = await (where ? db.select({ total: count() }).from(table).where(where) : db.select({ total: count() }).from(table));
72
64
  if (light) return { items, total: counted?.total ?? 0, relations: {} };
73
65
  await attachManyToMany(db, name, entry, items);
@@ -0,0 +1,31 @@
1
+ import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
2
+ import type { CmsDb } from '#cms-db';
3
+ import type { CmsEntry, CmsI18n } from '../../shared/index.js';
4
+ import type { CmsImportAction, CmsImportIssue, CmsImportMode, CmsImportPreview, CmsImportSourceRow, CmsTransferFormat } from '../../shared/transfer.js';
5
+ import type { MediaLookup } from './media-references.js';
6
+ type Row = Record<string, unknown>;
7
+ export interface PreparedImportRow {
8
+ index: number;
9
+ id: string | null;
10
+ values: Row | null;
11
+ action: CmsImportAction;
12
+ errors: CmsImportIssue[];
13
+ }
14
+ export interface ImportContext {
15
+ db: CmsDb;
16
+ name: string;
17
+ entry: CmsEntry;
18
+ table: SQLiteTable;
19
+ mode: CmsImportMode;
20
+ media: MediaLookup | null;
21
+ }
22
+ export declare function validateImportRows(entry: CmsEntry, rows: CmsImportSourceRow[], i18n: CmsI18n): PreparedImportRow[];
23
+ export declare function checkImportRows(rows: PreparedImportRow[], context: ImportContext): Promise<PreparedImportRow[]>;
24
+ export declare function importPreview(entry: CmsEntry, rows: PreparedImportRow[], options: {
25
+ format: CmsTransferFormat;
26
+ mode: CmsImportMode;
27
+ defaultLocale: string;
28
+ errors?: string[];
29
+ }): CmsImportPreview;
30
+ export declare function importWriteStatements(db: CmsDb, name: string, entry: CmsEntry, table: SQLiteTable, rows: PreparedImportRow[]): PromiseLike<unknown>[];
31
+ export {};
@@ -0,0 +1,202 @@
1
+ import { eq, inArray } from "drizzle-orm";
2
+ import { uniqueFieldKeys } from "../../shared/duplicate.js";
3
+ import { IMPORT_ID_PATTERN, importIssuePath, importRowTitle } from "../../shared/transfer.js";
4
+ import { buildEntrySchema } from "../../shared/validation.js";
5
+ import { customId } from "./custom-id.js";
6
+ import { mediaReferences } from "./media-references.js";
7
+ import {
8
+ encodeColumnValues,
9
+ resolveTable,
10
+ withContentDefaults,
11
+ withTimestamps,
12
+ withUpdatedAt
13
+ } from "./registry.js";
14
+ import { manyToManyStatements, splitRelationValues } from "./relations.js";
15
+ import { chunks, KEY_CHUNK } from "./write-statements.js";
16
+ const META_KEYS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt"]);
17
+ export function validateImportRows(entry, rows, i18n) {
18
+ const schema = buildEntrySchema(entry, i18n);
19
+ return rows.map((row) => {
20
+ const errors = [...row.errors];
21
+ const rawId = row.values.id;
22
+ let id = null;
23
+ if (rawId !== void 0 && rawId !== null && rawId !== "") {
24
+ if (typeof rawId === "string" && IMPORT_ID_PATTERN.test(rawId)) id = rawId;
25
+ else
26
+ errors.push({
27
+ path: "id",
28
+ message: "Invalid id: use letters, numbers, _ and -, up to 128 characters"
29
+ });
30
+ }
31
+ const input = Object.fromEntries(
32
+ Object.entries(row.values).filter(([key]) => !META_KEYS.has(key))
33
+ );
34
+ const result = schema.safeParse(input);
35
+ if (!result.success) {
36
+ const reported = new Set(errors.map((issue) => issue.path));
37
+ for (const issue of result.error.issues) {
38
+ const path = importIssuePath(issue.path);
39
+ if (!reported.has(path)) errors.push({ path, message: issue.message });
40
+ }
41
+ }
42
+ return {
43
+ index: row.index,
44
+ id,
45
+ values: result.success ? result.data : null,
46
+ action: "create",
47
+ errors
48
+ };
49
+ });
50
+ }
51
+ async function existingIds(db, table, ids) {
52
+ const column = table.id;
53
+ const found = /* @__PURE__ */ new Set();
54
+ for (const chunk of chunks([...new Set(ids)], KEY_CHUNK)) {
55
+ const rows = await db.select({ id: column }).from(table).where(inArray(column, chunk));
56
+ for (const row of rows) found.add(String(row.id));
57
+ }
58
+ return found;
59
+ }
60
+ function relationIds(value) {
61
+ if (Array.isArray(value)) return value.filter((id) => typeof id === "string");
62
+ return typeof value === "string" && value ? [value] : [];
63
+ }
64
+ function addError(row, path, message) {
65
+ row.errors.push({ path, message });
66
+ }
67
+ async function checkIds(rows, context) {
68
+ const first = /* @__PURE__ */ new Map();
69
+ for (const row of rows) {
70
+ if (!row.id) continue;
71
+ const seen = first.get(row.id);
72
+ if (seen !== void 0) addError(row, "id", `Same id as row ${seen}`);
73
+ else first.set(row.id, row.index);
74
+ }
75
+ const existing = await existingIds(context.db, context.table, [...first.keys()]);
76
+ for (const row of rows) {
77
+ if (!row.id || !existing.has(row.id)) continue;
78
+ if (context.mode === "create") addError(row, "id", "An entry with this id already exists");
79
+ else row.action = "update";
80
+ }
81
+ }
82
+ async function checkRelations(rows, context) {
83
+ const fileIds = new Set(rows.flatMap((row) => row.id ? [row.id] : []));
84
+ for (const [key, field] of Object.entries(context.entry.fields)) {
85
+ if (field.type !== "relation" || !field.to) continue;
86
+ const target = resolveTable(field.to);
87
+ if (!target) continue;
88
+ const wanted = rows.flatMap((row) => relationIds(row.values?.[key]));
89
+ if (!wanted.length) continue;
90
+ const found = await existingIds(context.db, target, wanted);
91
+ if (field.to === context.name) for (const id of fileIds) found.add(id);
92
+ for (const row of rows) {
93
+ const missing = relationIds(row.values?.[key]).filter((id) => !found.has(id));
94
+ if (missing.length) addError(row, key, `Unknown ${field.to} id: ${missing.join(", ")}`);
95
+ }
96
+ }
97
+ }
98
+ async function checkUnique(rows, context) {
99
+ const columns = context.table;
100
+ for (const key of uniqueFieldKeys(context.entry)) {
101
+ const column = columns[key];
102
+ if (!column) continue;
103
+ const owners = /* @__PURE__ */ new Map();
104
+ for (const row of rows) {
105
+ const value = row.values?.[key];
106
+ if (typeof value !== "string" || !value) continue;
107
+ const owner = owners.get(value);
108
+ if (owner) addError(row, key, `Same value as row ${owner.index}`);
109
+ else owners.set(value, row);
110
+ }
111
+ for (const chunk of chunks([...owners.keys()], KEY_CHUNK)) {
112
+ const found = await context.db.select({ id: columns.id, value: column }).from(context.table).where(inArray(column, chunk));
113
+ for (const match of found) {
114
+ const row = owners.get(String(match.value));
115
+ if (row && row.id !== String(match.id))
116
+ addError(row, key, "This value is already used by another entry");
117
+ }
118
+ }
119
+ }
120
+ }
121
+ async function checkMedia(rows, context) {
122
+ if (!context.media) return;
123
+ const references = new Map(
124
+ rows.map((row) => [row, row.values ? mediaReferences(context.entry.fields, row.values) : []])
125
+ );
126
+ const keys = [...new Set([...references.values()].flat().map((reference) => reference.key))];
127
+ if (!keys.length) return;
128
+ const known = await context.media(keys);
129
+ for (const [row, list] of references) {
130
+ for (const reference of list) {
131
+ if (!known.has(reference.key))
132
+ addError(row, reference.field, `Media not found in the library: ${reference.key}`);
133
+ }
134
+ }
135
+ }
136
+ export async function checkImportRows(rows, context) {
137
+ await checkIds(rows, context);
138
+ await checkRelations(rows, context);
139
+ await checkUnique(rows, context);
140
+ await checkMedia(rows, context);
141
+ return rows;
142
+ }
143
+ export function importPreview(entry, rows, options) {
144
+ const valid = rows.filter((row) => !row.errors.length);
145
+ return {
146
+ format: options.format,
147
+ mode: options.mode,
148
+ total: rows.length,
149
+ valid: valid.length,
150
+ create: valid.filter((row) => row.action === "create").length,
151
+ update: valid.filter((row) => row.action === "update").length,
152
+ errors: options.errors ?? [],
153
+ rows: rows.map((row) => ({
154
+ index: row.index,
155
+ id: row.id,
156
+ action: row.action,
157
+ title: row.values ? importRowTitle(entry, row.values, options.defaultLocale) : null,
158
+ errors: row.errors
159
+ })),
160
+ imported: false
161
+ };
162
+ }
163
+ function deferredSelfRelations(entry, name) {
164
+ return Object.entries(entry.fields).filter(
165
+ ([, field]) => field.type === "relation" && field.to === name && field.cardinality !== "many-to-many" && !field.required
166
+ ).map(([key]) => key);
167
+ }
168
+ export function importWriteStatements(db, name, entry, table, rows) {
169
+ const idCol = table.id;
170
+ const created = new Set(
171
+ rows.flatMap((row) => row.action === "create" && row.id ? [row.id] : [])
172
+ );
173
+ const selfKeys = deferredSelfRelations(entry, name);
174
+ const main = [];
175
+ const later = [];
176
+ for (const row of rows) {
177
+ if (!row.values) continue;
178
+ const { values, lists } = splitRelationValues(entry, encodeColumnValues(entry, row.values));
179
+ const deferred = {};
180
+ for (const key of selfKeys) {
181
+ const target = values[key];
182
+ if (typeof target === "string" && created.has(target)) {
183
+ deferred[key] = target;
184
+ values[key] = null;
185
+ }
186
+ }
187
+ const id = row.id ?? customId(entry.id);
188
+ if (row.action === "update") {
189
+ main.push(
190
+ db.update(table).set(withUpdatedAt(table, withContentDefaults(entry, values))).where(eq(idCol, id))
191
+ );
192
+ } else {
193
+ if (entry.drafts) values.status ??= "draft";
194
+ withContentDefaults(entry, values);
195
+ main.push(db.insert(table).values(withTimestamps(table, { ...values, id })));
196
+ }
197
+ if (Object.keys(deferred).length)
198
+ later.push(db.update(table).set(deferred).where(eq(idCol, id)));
199
+ later.push(...manyToManyStatements(db, name, id, lists));
200
+ }
201
+ return [...main, ...later];
202
+ }
@@ -0,0 +1,15 @@
1
+ import type { SQL } from 'drizzle-orm';
2
+ import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
3
+ import type { H3Event } from 'h3';
4
+ import type { CmsEntry } from '../../shared/index.js';
5
+ import type { CmsListFilters } from '../../shared/list-filters.js';
6
+ export type ListDialect = 'sqlite' | 'postgres';
7
+ export interface ListQueryInput {
8
+ search?: string;
9
+ filters?: CmsListFilters;
10
+ }
11
+ export declare function readListFilters(event: H3Event, entry: CmsEntry): CmsListFilters;
12
+ export declare function listSearchCondition(dialect: ListDialect, entry: CmsEntry, table: SQLiteTable, search: string): SQL;
13
+ export declare function listFilterConditions(entry: CmsEntry, table: SQLiteTable, filters: CmsListFilters): SQL[];
14
+ export declare function listWhere(dialect: ListDialect, entry: CmsEntry, table: SQLiteTable, { search, filters }: ListQueryInput): SQL | undefined;
15
+ export declare function listOrderBy(table: SQLiteTable, sort?: string, order?: 'asc' | 'desc'): SQL<unknown>[];
@@ -0,0 +1,54 @@
1
+ import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
2
+ import { createError, getQuery } from "h3";
3
+ import { selectValuesMatching } from "../../shared/index.js";
4
+ import { parseListFilters } from "../../shared/list-filters.js";
5
+ import { searchLikePattern } from "../../shared/search.js";
6
+ export function readListFilters(event, entry) {
7
+ const { filters, errors } = parseListFilters(entry, getQuery(event));
8
+ if (errors.length) throw createError({ statusCode: 400, statusMessage: errors.join("; ") });
9
+ return filters;
10
+ }
11
+ export function listSearchCondition(dialect, entry, table, search) {
12
+ const columns = table;
13
+ const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
14
+ const column = titleColumn ?? columns.id;
15
+ const text = dialect === "postgres" ? sql`cast(${column} as text)` : sql`${column}`;
16
+ const like = sql`lower(${text}) like ${searchLikePattern(search.toLowerCase())} escape '\\'`;
17
+ const titleField = entry.titleField ? entry.fields[entry.titleField] : void 0;
18
+ const labelled = titleColumn && titleField?.type === "select" ? selectValuesMatching(titleField, search) : [];
19
+ return labelled.length ? or(like, inArray(column, labelled)) : like;
20
+ }
21
+ export function listFilterConditions(entry, table, filters) {
22
+ const columns = table;
23
+ const conditions = [];
24
+ for (const [key, value] of Object.entries(filters)) {
25
+ if (!Object.hasOwn(columns, key)) {
26
+ throw createError({ statusCode: 400, statusMessage: `Unknown filter '${key}'` });
27
+ }
28
+ const column = columns[key];
29
+ if (entry.fields[key]?.type === "boolean") {
30
+ conditions.push(
31
+ value === "true" ? eq(column, true) : or(eq(column, false), isNull(column))
32
+ );
33
+ continue;
34
+ }
35
+ conditions.push(eq(column, value));
36
+ }
37
+ return conditions;
38
+ }
39
+ export function listWhere(dialect, entry, table, { search, filters = {} }) {
40
+ const conditions = [
41
+ ...search ? [listSearchCondition(dialect, entry, table, search)] : [],
42
+ ...listFilterConditions(entry, table, filters)
43
+ ];
44
+ if (!conditions.length) return void 0;
45
+ return conditions.length === 1 ? conditions[0] : and(...conditions);
46
+ }
47
+ export function listOrderBy(table, sort, order = "desc") {
48
+ const columns = table;
49
+ const tiebreaker = columns.id;
50
+ const sortColumn = sort && Object.hasOwn(columns, sort) ? columns[sort] : void 0;
51
+ const orderBy = sortColumn ?? columns.createdAt ?? tiebreaker;
52
+ const direction = sortColumn && order === "asc" ? asc : desc;
53
+ return orderBy === tiebreaker ? [direction(orderBy)] : [direction(orderBy), desc(tiebreaker)];
54
+ }
@@ -1,2 +1,4 @@
1
1
  import type { FieldConfig } from '../../shared/index.js';
2
+ import type { MediaLookup } from './media-references.js';
3
+ export declare function mediaLookup(): Promise<MediaLookup | null>;
2
4
  export declare function assertMediaExists(fields: Record<string, FieldConfig>, values: Record<string, unknown>): Promise<void>;
@@ -3,7 +3,7 @@ import { cms_media } from "#cms-tables";
3
3
  import { useRuntimeConfig } from "#imports";
4
4
  import { useMediaIndex } from "./media-index.js";
5
5
  import { assertKnownMedia, mediaKeysInTable, mediaReferences } from "./media-references.js";
6
- async function mediaLookup() {
6
+ export async function mediaLookup() {
7
7
  const { media } = useRuntimeConfig().cms;
8
8
  if (media.storage !== "local") {
9
9
  return (keys) => mediaKeysInTable(useDb(), cms_media, keys);
@@ -1,6 +1,6 @@
1
1
  import { inArray } from "drizzle-orm";
2
2
  import { createError } from "h3";
3
- import { PAGE_PATH_FIELD, pageFields, pageRouteOf } from "../../shared/index.js";
3
+ import { PAGE_PATH_FIELD, pageFields, pageRouteOf, selectOptionLabel } from "../../shared/index.js";
4
4
  const LOOKUP_CHUNK = 90;
5
5
  function mediaKeysOf(value) {
6
6
  if (typeof value === "string") return value ? [value] : [];
@@ -36,6 +36,9 @@ function titleOf(entry, row) {
36
36
  return pageRouteOf(entry, String(row.id))?.label ?? (typeof path === "string" ? path : null);
37
37
  }
38
38
  const value = entry.titleField ? row[entry.titleField] : void 0;
39
+ const titleField = entry.titleField ? entry.fields[entry.titleField] : void 0;
40
+ if (typeof value === "string" && titleField?.type === "select")
41
+ return selectOptionLabel(titleField, value) || null;
39
42
  if (typeof value === "string") return value || null;
40
43
  if (value && typeof value === "object") {
41
44
  const first = Object.values(value).find(
@@ -56,7 +56,7 @@ export function getRegistryEntry(event) {
56
56
  }
57
57
  export function buildValidator(entry, path) {
58
58
  const fields = path ? pageFields(entry, path) : entry.fields;
59
- return buildEntrySchema({ ...entry, fields }, getContentI18n());
59
+ return buildEntrySchema({ ...entry, fields, path }, getContentI18n());
60
60
  }
61
61
  export function requirePageRoute(entry, key) {
62
62
  const route = pageRouteOf(entry, key);
@@ -8,7 +8,9 @@ import {
8
8
  pageFields,
9
9
  pageFieldsTableName,
10
10
  pageMediaTableName,
11
- pageRouteOf
11
+ pageRouteOf,
12
+ selectOptionLabel,
13
+ selectValuesMatching
12
14
  } from "../../shared/index.js";
13
15
  import {
14
16
  CMS_SEARCH_HITS_PER_ROW,
@@ -47,8 +49,13 @@ function fieldTexts(field, value, defaultLocale) {
47
49
  if (isTranslatableField(field)) {
48
50
  return Object.entries(decodeTranslatableValue(value, defaultLocale) ?? {}).filter((pair) => typeof pair[1] === "string" && !!pair[1]).map(([locale, text]) => ({ locale, text: plainText(field, text) }));
49
51
  }
50
- if (isMultiSelect(field) && Array.isArray(value)) {
51
- return [{ locale: null, text: value.map(String).join(", ") }];
52
+ if (field.type === "select") {
53
+ const values = Array.isArray(value) ? value : [value];
54
+ const text = values.map((item) => {
55
+ const label = selectOptionLabel(field, item);
56
+ return label === String(item) ? label : `${label} (${String(item)})`;
57
+ }).join(", ");
58
+ return [{ locale: null, text }];
52
59
  }
53
60
  if (typeof value === "string") return [{ locale: null, text: plainText(field, value) }];
54
61
  return [{ locale: null, text: JSON.stringify(value) }];
@@ -90,6 +97,8 @@ function titleOf(entry, row, defaultLocale) {
90
97
  if (entry.kind === "single" || !entry.titleField) return null;
91
98
  const value = row[entry.titleField];
92
99
  if (value == null) return null;
100
+ const titleField = entry.fields[entry.titleField];
101
+ if (titleField?.type === "select") return selectOptionLabel(titleField, value) || null;
93
102
  if (typeof value === "object") {
94
103
  const values = value;
95
104
  const text = [values[defaultLocale], ...Object.values(values)].find(
@@ -101,7 +110,17 @@ function titleOf(entry, row, defaultLocale) {
101
110
  }
102
111
  async function tableRows(context, entry, table) {
103
112
  const columns = columnsOf(table);
104
- const conditions = Object.entries(entry.fields).filter(([key, field]) => isSearchableField(field) && Object.hasOwn(columns, key)).map(([key]) => searchLikeCondition(context.dialect, columns[key], context.pattern));
113
+ const searchable = Object.entries(entry.fields).filter(
114
+ ([key, field]) => isSearchableField(field) && Object.hasOwn(columns, key)
115
+ );
116
+ const conditions = searchable.map(
117
+ ([key]) => searchLikeCondition(context.dialect, columns[key], context.pattern)
118
+ );
119
+ for (const [key, field] of searchable) {
120
+ if (field.type !== "select" || isMultiSelect(field)) continue;
121
+ const values = selectValuesMatching(field, context.term);
122
+ if (values.length) conditions.push(inArray(columns[key], values));
123
+ }
105
124
  if (!conditions.length) return [];
106
125
  const query = context.db.select().from(table).where(or(...conditions));
107
126
  const ordered = columns.updatedAt ? query.orderBy(desc(columns.updatedAt)) : query;
@@ -0,0 +1,10 @@
1
+ import type { CmsEntry, FieldConfig } from './index.js';
2
+ export declare const DUPLICATE_TITLE_SUFFIX = " (copy)";
3
+ type DuplicatedEntry = Pick<CmsEntry, 'kind' | 'fields' | 'drafts' | 'titleField'>;
4
+ export declare function isUniqueField(field: FieldConfig): boolean;
5
+ export declare function uniqueFieldKeys(entry: Pick<CmsEntry, 'fields'>): string[];
6
+ export declare function duplicateToken(): string;
7
+ export declare function duplicateSlug(value: string, token: string): string;
8
+ export declare function duplicateFormKeys(entry: DuplicatedEntry): string[];
9
+ export declare function duplicateEntryValues(entry: DuplicatedEntry, source: Record<string, unknown>, token?: string): Record<string, unknown>;
10
+ export {};