@xleddyl/nuxt-cms 0.4.1 → 0.4.3
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 +74 -96
- package/dist/module.json +1 -1
- package/dist/module.mjs +86 -22
- package/dist/runtime/app/components/cms/BlocksField.vue +30 -5
- package/dist/runtime/app/components/cms/DropdownMenu.vue +1 -5
- package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/EntryDrawer.vue +32 -13
- package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +20 -3
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +2 -2
- package/dist/runtime/app/components/cms/Form.d.vue.ts +8 -6
- package/dist/runtime/app/components/cms/Form.vue +26 -5
- package/dist/runtime/app/components/cms/Form.vue.d.ts +8 -6
- package/dist/runtime/app/components/cms/FormField.vue +6 -1
- package/dist/runtime/app/components/cms/ImportModal.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/ImportModal.vue +252 -0
- package/dist/runtime/app/components/cms/ImportModal.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/ListFilters.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/ListFilters.vue +151 -0
- package/dist/runtime/app/components/cms/ListFilters.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/RelationField.vue +3 -1
- package/dist/runtime/app/components/cms/VisualEditor.vue +81 -12
- package/dist/runtime/app/pages/admin-collection.vue +158 -7
- package/dist/runtime/app/pages/admin-content.vue +18 -7
- package/dist/runtime/app/pages/admin-entry.vue +1 -0
- package/dist/runtime/app/pages/admin-preview.vue +6 -3
- package/dist/runtime/app/preview/PreviewOverlay.vue +4 -0
- package/dist/runtime/app/utils/ui.d.ts +2 -1
- package/dist/runtime/app/utils/ui.js +1 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection-export.get.d.ts +2 -0
- package/dist/runtime/server/api/collection-export.get.js +67 -0
- package/dist/runtime/server/api/collection-import.post.d.ts +2 -0
- package/dist/runtime/server/api/collection-import.post.js +85 -0
- package/dist/runtime/server/api/collection.get.js +7 -15
- package/dist/runtime/server/utils/import.d.ts +31 -0
- package/dist/runtime/server/utils/import.js +202 -0
- package/dist/runtime/server/utils/list-query.d.ts +15 -0
- package/dist/runtime/server/utils/list-query.js +54 -0
- package/dist/runtime/server/utils/media-check.d.ts +2 -0
- package/dist/runtime/server/utils/media-check.js +1 -1
- package/dist/runtime/server/utils/media-references.js +4 -1
- package/dist/runtime/server/utils/registry.js +1 -1
- package/dist/runtime/server/utils/search.js +23 -4
- package/dist/runtime/shared/duplicate.d.ts +10 -0
- package/dist/runtime/shared/duplicate.js +54 -0
- package/dist/runtime/shared/graphql-sdl.js +4 -7
- package/dist/runtime/shared/index.d.ts +49 -2
- package/dist/runtime/shared/index.js +33 -0
- package/dist/runtime/shared/layout.d.ts +3 -0
- package/dist/runtime/shared/layout.js +35 -2
- package/dist/runtime/shared/list-filters.d.ts +20 -0
- package/dist/runtime/shared/list-filters.js +83 -0
- package/dist/runtime/shared/transfer.d.ts +70 -0
- package/dist/runtime/shared/transfer.js +256 -0
- package/dist/runtime/shared/validation.d.ts +15 -2
- package/dist/runtime/shared/validation.js +102 -9
- 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,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 {
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
51
|
-
|
|
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
|
|
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 {};
|