emdash 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{adapters-DoNJiveC.d.mts → adapters-BktHA7EO.d.mts} +1 -1
- package/dist/{adapters-DoNJiveC.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
- package/dist/{apply-BzltprvY.mjs → apply-UsrFuO7l.mjs} +156 -254
- package/dist/apply-UsrFuO7l.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.mjs +10 -2
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.mjs +5 -5
- package/dist/astro/middleware/redirect.mjs +5 -5
- package/dist/astro/middleware/request-context.mjs +4 -4
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.mjs +35 -34
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +8 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
- package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
- package/dist/{byline-BSaNL1w7.mjs → byline-C3vnhIpU.mjs} +4 -4
- package/dist/{byline-BSaNL1w7.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
- package/dist/{bylines-CvJ3PYz2.mjs → bylines-esI7ioa9.mjs} +5 -5
- package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-esI7ioa9.mjs.map} +1 -1
- package/dist/{cache-C6N_hhN7.mjs → cache-fTzxgMFJ.mjs} +3 -3
- package/dist/{cache-C6N_hhN7.mjs.map → cache-fTzxgMFJ.mjs.map} +1 -1
- package/dist/{chunks-NBQVDOci.mjs → chunks-Da2-b-oA.mjs} +2 -2
- package/dist/{chunks-NBQVDOci.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
- package/dist/cli/index.mjs +251 -79
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
- package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
- package/dist/{content-8lOYF0pr.mjs → content-C7G4QXkK.mjs} +14 -3
- package/dist/content-C7G4QXkK.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
- package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
- package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
- package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
- package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
- package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
- package/dist/{index-BFRaVcD6.d.mts → index-DjPMOfO0.d.mts} +82 -67
- package/dist/index-DjPMOfO0.d.mts.map +1 -0
- package/dist/index.d.mts +10 -10
- package/dist/index.mjs +28 -27
- package/dist/{load-DDqMMvZL.mjs → load-sXRuM7Us.mjs} +2 -2
- package/dist/{load-DDqMMvZL.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
- package/dist/{loader-CKLbBnhK.mjs → loader-Bx2_9-5e.mjs} +31 -6
- package/dist/loader-Bx2_9-5e.mjs.map +1 -0
- package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
- package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/media/local-runtime.mjs +3 -3
- package/dist/{media-BW32b4gi.mjs → media-D8FbNsl0.mjs} +2 -2
- package/dist/{media-BW32b4gi.mjs.map → media-D8FbNsl0.mjs.map} +1 -1
- package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
- package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
- package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
- package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
- package/dist/page/index.d.mts +2 -2
- package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
- package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
- package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
- package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
- package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
- package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
- package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
- package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
- package/dist/{query-Cg9ZKRQ0.mjs → query-Bo-msrmu.mjs} +13 -13
- package/dist/{query-Cg9ZKRQ0.mjs.map → query-Bo-msrmu.mjs.map} +1 -1
- package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
- package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
- package/dist/{registry-Dw70ChxB.mjs → registry-Beb7wxFc.mjs} +5 -5
- package/dist/{registry-Dw70ChxB.mjs.map → registry-Beb7wxFc.mjs.map} +1 -1
- package/dist/{request-cache-B-bmkipQ.mjs → request-cache-C-tIpYIw.mjs} +1 -1
- package/dist/{request-cache-B-bmkipQ.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
- package/dist/{runner-Bnoj7vjK.d.mts → runner-Clwe4Mme.d.mts} +2 -2
- package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Clwe4Mme.d.mts.map} +1 -1
- package/dist/{runner-C7ADox5q.mjs → runner-DMnlIkh4.mjs} +433 -138
- package/dist/runner-DMnlIkh4.mjs.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +3 -3
- package/dist/{search-dOGEccMa.mjs → search-DkN-BqsS.mjs} +164 -92
- package/dist/search-DkN-BqsS.mjs.map +1 -0
- package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
- package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +15 -14
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/taxonomies-CTtewrSQ.mjs +407 -0
- package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
- package/dist/taxonomy-DSxx2K2L.mjs +218 -0
- package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
- package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
- package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
- package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
- package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
- package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
- package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
- package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
- package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
- package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
- package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
- package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
- package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
- package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
- package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
- package/dist/{types-4fVtCIm0.mjs → types-CoO6mpV3.mjs} +1 -1
- package/dist/{types-4fVtCIm0.mjs.map → types-CoO6mpV3.mjs.map} +1 -1
- package/dist/{types-BuBIptGk.d.mts → types-D19uBYWn.d.mts} +149 -4
- package/dist/types-D19uBYWn.d.mts.map +1 -0
- package/dist/{types-BSyXeCFW.d.mts → types-Dl1fgFjn.d.mts} +1 -1
- package/dist/{types-BSyXeCFW.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
- package/dist/{types-CrtWgIvl.d.mts → types-Dtx1mSMX.d.mts} +9 -1
- package/dist/types-Dtx1mSMX.d.mts.map +1 -0
- package/dist/{types-CIOg5AR8.mjs → types-Eg829jj9.mjs} +1 -1
- package/dist/{types-CIOg5AR8.mjs.map → types-Eg829jj9.mjs.map} +1 -1
- package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
- package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
- package/dist/{validate-Baqf0slj.mjs → validate-CBIbxM3L.mjs} +14 -10
- package/dist/validate-CBIbxM3L.mjs.map +1 -0
- package/dist/{validate-BfQh_C_y.d.mts → validate-DHGwADqO.d.mts} +18 -5
- package/dist/validate-DHGwADqO.d.mts.map +1 -0
- package/dist/{validation-BfEI7tNe.mjs → validation-B1NYiEos.mjs} +5 -5
- package/dist/{validation-BfEI7tNe.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
- package/dist/version-CMD42IRC.mjs +7 -0
- package/dist/{version-DoxrVdYf.mjs.map → version-CMD42IRC.mjs.map} +1 -1
- package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-BNJDQBSZ.mjs} +8 -3
- package/dist/zod-generator-BNJDQBSZ.mjs.map +1 -0
- package/package.json +6 -6
- package/src/api/handlers/content.ts +11 -0
- package/src/api/handlers/dashboard.ts +29 -36
- package/src/api/handlers/menus.ts +256 -75
- package/src/api/handlers/taxonomies.ts +273 -97
- package/src/api/schemas/common.ts +7 -0
- package/src/api/schemas/menus.ts +23 -0
- package/src/api/schemas/taxonomies.ts +39 -0
- package/src/astro/integration/routes.ts +10 -0
- package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
- package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
- package/src/astro/routes/api/menus/[name]/items.ts +16 -6
- package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
- package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
- package/src/astro/routes/api/menus/[name].ts +19 -10
- package/src/astro/routes/api/menus/index.ts +9 -6
- package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
- package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
- package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
- package/src/astro/routes/api/taxonomies/index.ts +9 -6
- package/src/cli/commands/export-seed.ts +82 -21
- package/src/cli/commands/plugin-init.ts +216 -90
- package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/content.ts +11 -0
- package/src/database/repositories/taxonomy.ts +193 -89
- package/src/database/types.ts +10 -2
- package/src/i18n/resolve.ts +37 -0
- package/src/loader.ts +49 -2
- package/src/mcp/server.ts +77 -18
- package/src/menus/index.ts +143 -124
- package/src/menus/types.ts +15 -1
- package/src/schema/zod-generator.ts +12 -2
- package/src/seed/apply.ts +140 -54
- package/src/seed/types.ts +14 -1
- package/src/seed/validate.ts +27 -13
- package/src/taxonomies/index.ts +230 -213
- package/src/taxonomies/types.ts +10 -0
- package/dist/apply-BzltprvY.mjs.map +0 -1
- package/dist/content-8lOYF0pr.mjs.map +0 -1
- package/dist/index-BFRaVcD6.d.mts.map +0 -1
- package/dist/loader-CKLbBnhK.mjs.map +0 -1
- package/dist/runner-C7ADox5q.mjs.map +0 -1
- package/dist/search-dOGEccMa.mjs.map +0 -1
- package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
- package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
- package/dist/types-BuBIptGk.d.mts.map +0 -1
- package/dist/types-CrtWgIvl.d.mts.map +0 -1
- package/dist/validate-Baqf0slj.mjs.map +0 -1
- package/dist/validate-BfQh_C_y.d.mts.map +0 -1
- package/dist/version-DoxrVdYf.mjs +0 -7
- package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
|
@@ -140,21 +140,22 @@ async function fetchRecentItems(
|
|
|
140
140
|
|
|
141
141
|
const collectionsWithTitle = new Set(titleFields.map((r) => r.collection_slug));
|
|
142
142
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
// Issue one query per collection in parallel, then merge in JS.
|
|
144
|
+
// A single UNION ALL across N collections trips D1's
|
|
145
|
+
// SQLITE_LIMIT_COMPOUND_SELECT cap when N is large enough (#895);
|
|
146
|
+
// per-collection queries side-step that. Each query fetches at most
|
|
147
|
+
// 10 rows, so the merge handles at most N * 10 rows before slicing.
|
|
148
|
+
const perCollection = await Promise.all(
|
|
149
|
+
collections.map(async (col) => {
|
|
150
|
+
validateIdentifier(col.slug);
|
|
151
|
+
const table = `ec_${col.slug}`;
|
|
152
|
+
const hasTitle = collectionsWithTitle.has(col.slug);
|
|
153
|
+
|
|
154
|
+
// Use title column if it exists, otherwise fall back to slug, id.
|
|
155
|
+
// All output uses snake_case to avoid SQLite quoting issues on D1.
|
|
156
|
+
const titleExpr = hasTitle ? sql`COALESCE(title, slug, id)` : sql`COALESCE(slug, id)`;
|
|
157
|
+
|
|
158
|
+
const result = await sql<RecentItemRow>`
|
|
158
159
|
SELECT
|
|
159
160
|
id,
|
|
160
161
|
${sql.lit(col.slug)} AS collection,
|
|
@@ -168,27 +169,19 @@ async function fetchRecentItems(
|
|
|
168
169
|
WHERE deleted_at IS NULL
|
|
169
170
|
ORDER BY updated_at DESC
|
|
170
171
|
LIMIT 10
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const result = await sql<RecentItemRow>`
|
|
185
|
-
SELECT * FROM (${combined})
|
|
186
|
-
ORDER BY updated_at DESC
|
|
187
|
-
LIMIT 10
|
|
188
|
-
`.execute(db);
|
|
189
|
-
|
|
190
|
-
// Map snake_case DB rows → camelCase API shape
|
|
191
|
-
return result.rows.map((row) => ({
|
|
172
|
+
`.execute(db);
|
|
173
|
+
return result.rows;
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Merge across collections, sort by updated_at desc, take top 10.
|
|
178
|
+
const merged = perCollection
|
|
179
|
+
.flat()
|
|
180
|
+
.toSorted((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
|
|
181
|
+
.slice(0, 10);
|
|
182
|
+
|
|
183
|
+
// Map snake_case DB rows to camelCase API shape
|
|
184
|
+
return merged.map((row) => ({
|
|
192
185
|
id: row.id,
|
|
193
186
|
collection: row.collection,
|
|
194
187
|
collectionLabel: row.collection_label,
|
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Menu CRUD handlers
|
|
2
|
+
* Menu CRUD handlers.
|
|
3
3
|
*
|
|
4
|
-
* Business logic for menu and menu-item endpoints.
|
|
5
|
-
*
|
|
4
|
+
* Business logic for menu and menu-item endpoints. Routes are thin wrappers
|
|
5
|
+
* that parse input, check auth, and call these.
|
|
6
|
+
*
|
|
7
|
+
* i18n: Menus are per-locale. `(name, locale)` is unique, so the same `name`
|
|
8
|
+
* (e.g. "primary") can exist in several locales within one translation_group.
|
|
9
|
+
* Menu items carry a `locale` + `translation_group` as well, and their
|
|
10
|
+
* `reference_id` points at the referenced content's translation_group (not a
|
|
11
|
+
* specific row id), so a single menu item target survives content translations.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import type { Kysely } from "kysely";
|
|
14
|
+
import type { Kysely, Selectable } from "kysely";
|
|
9
15
|
import { ulid } from "ulidx";
|
|
10
16
|
|
|
11
17
|
import { withTransaction } from "../../database/transaction.js";
|
|
12
18
|
import type { Database, MenuItemTable, MenuTable } from "../../database/types.js";
|
|
19
|
+
import { getI18nConfig } from "../../i18n/config.js";
|
|
13
20
|
import type { ApiResult } from "../types.js";
|
|
14
21
|
|
|
15
22
|
// ---------------------------------------------------------------------------
|
|
16
23
|
// Response types
|
|
17
24
|
// ---------------------------------------------------------------------------
|
|
18
25
|
|
|
19
|
-
type MenuRow =
|
|
20
|
-
|
|
21
|
-
updated_at: string;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
type MenuItemRow = Omit<MenuItemTable, "created_at"> & {
|
|
25
|
-
created_at: string;
|
|
26
|
-
};
|
|
26
|
+
export type MenuRow = Selectable<MenuTable>;
|
|
27
|
+
export type MenuItemRow = Selectable<MenuItemTable>;
|
|
27
28
|
|
|
28
29
|
export interface MenuListItem extends MenuRow {
|
|
29
30
|
itemCount: number;
|
|
@@ -33,18 +34,34 @@ export interface MenuWithItems extends MenuRow {
|
|
|
33
34
|
items: MenuItemRow[];
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export interface MenuTranslationsResponse {
|
|
38
|
+
translationGroup: string | null;
|
|
39
|
+
translations: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
locale: string;
|
|
43
|
+
label: string;
|
|
44
|
+
updatedAt: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
// ---------------------------------------------------------------------------
|
|
37
49
|
// Menu handlers
|
|
38
50
|
// ---------------------------------------------------------------------------
|
|
39
51
|
|
|
40
52
|
/**
|
|
41
|
-
* List
|
|
53
|
+
* List menus with item counts. Filter by `locale` when provided; otherwise
|
|
54
|
+
* return every menu row (each locale counts as its own menu for admin listing
|
|
55
|
+
* purposes).
|
|
42
56
|
*/
|
|
43
|
-
export async function handleMenuList(
|
|
57
|
+
export async function handleMenuList(
|
|
58
|
+
db: Kysely<Database>,
|
|
59
|
+
options: { locale?: string } = {},
|
|
60
|
+
): Promise<ApiResult<MenuListItem[]>> {
|
|
44
61
|
try {
|
|
45
62
|
// Single query: LEFT JOIN + GROUP BY for the per-menu item count.
|
|
46
63
|
// Avoids the N+1 of one count query per menu.
|
|
47
|
-
|
|
64
|
+
let query = db
|
|
48
65
|
.selectFrom("_emdash_menus as m")
|
|
49
66
|
.leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
|
|
50
67
|
.select(({ fn }) => [
|
|
@@ -53,11 +70,22 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
|
|
|
53
70
|
"m.label",
|
|
54
71
|
"m.created_at",
|
|
55
72
|
"m.updated_at",
|
|
73
|
+
"m.locale",
|
|
74
|
+
"m.translation_group",
|
|
56
75
|
fn.count<number>("i.id").as("itemCount"),
|
|
57
76
|
])
|
|
58
|
-
.groupBy([
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
.groupBy([
|
|
78
|
+
"m.id",
|
|
79
|
+
"m.name",
|
|
80
|
+
"m.label",
|
|
81
|
+
"m.created_at",
|
|
82
|
+
"m.updated_at",
|
|
83
|
+
"m.locale",
|
|
84
|
+
"m.translation_group",
|
|
85
|
+
])
|
|
86
|
+
.orderBy("m.name", "asc");
|
|
87
|
+
if (options.locale !== undefined) query = query.where("m.locale", "=", options.locale);
|
|
88
|
+
const rows = await query.execute();
|
|
61
89
|
|
|
62
90
|
// SQLite returns count as `number`, but some dialects (Postgres)
|
|
63
91
|
// return `string` from a count() aggregate. Normalize to number.
|
|
@@ -67,6 +95,8 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
|
|
|
67
95
|
label: row.label,
|
|
68
96
|
created_at: row.created_at,
|
|
69
97
|
updated_at: row.updated_at,
|
|
98
|
+
locale: row.locale,
|
|
99
|
+
translation_group: row.translation_group,
|
|
70
100
|
itemCount: typeof row.itemCount === "string" ? Number(row.itemCount) : row.itemCount,
|
|
71
101
|
}));
|
|
72
102
|
|
|
@@ -80,42 +110,117 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
|
|
|
80
110
|
}
|
|
81
111
|
|
|
82
112
|
/**
|
|
83
|
-
* Create a new menu.
|
|
113
|
+
* Create a new menu. When `translationOf` is supplied the new menu joins the
|
|
114
|
+
* source menu's translation_group (and gets the source's items cloned).
|
|
84
115
|
*/
|
|
85
116
|
export async function handleMenuCreate(
|
|
86
117
|
db: Kysely<Database>,
|
|
87
|
-
input: { name: string; label: string },
|
|
118
|
+
input: { name: string; label: string; locale?: string; translationOf?: string },
|
|
88
119
|
): Promise<ApiResult<MenuRow>> {
|
|
89
120
|
try {
|
|
121
|
+
// Resolve translation group + source (if we're creating a translation).
|
|
122
|
+
let translationGroup: string | null = null;
|
|
123
|
+
let sourceMenu: MenuRow | null = null;
|
|
124
|
+
if (input.translationOf) {
|
|
125
|
+
const src = await db
|
|
126
|
+
.selectFrom("_emdash_menus")
|
|
127
|
+
.selectAll()
|
|
128
|
+
.where("id", "=", input.translationOf)
|
|
129
|
+
.executeTakeFirst();
|
|
130
|
+
if (!src) {
|
|
131
|
+
return {
|
|
132
|
+
success: false,
|
|
133
|
+
error: { code: "NOT_FOUND", message: "Source menu for translation not found" },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
sourceMenu = src;
|
|
137
|
+
translationGroup = src.translation_group ?? src.id;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Duplicate guard: same (name, locale). Falls back to the configured
|
|
141
|
+
// defaultLocale to match the column DEFAULT set by migration 036.
|
|
142
|
+
const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
|
|
90
143
|
const existing = await db
|
|
91
144
|
.selectFrom("_emdash_menus")
|
|
92
145
|
.select("id")
|
|
93
146
|
.where("name", "=", input.name)
|
|
147
|
+
.where("locale", "=", effectiveLocale)
|
|
94
148
|
.executeTakeFirst();
|
|
95
|
-
|
|
96
149
|
if (existing) {
|
|
97
150
|
return {
|
|
98
151
|
success: false,
|
|
99
|
-
error: {
|
|
152
|
+
error: {
|
|
153
|
+
code: "CONFLICT",
|
|
154
|
+
message: `Menu "${input.name}" already exists${
|
|
155
|
+
input.locale ? ` in locale "${input.locale}"` : ""
|
|
156
|
+
}`,
|
|
157
|
+
},
|
|
100
158
|
};
|
|
101
159
|
}
|
|
102
160
|
|
|
103
161
|
const id = ulid();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
162
|
+
|
|
163
|
+
await withTransaction(db, async (trx) => {
|
|
164
|
+
await trx
|
|
165
|
+
.insertInto("_emdash_menus")
|
|
166
|
+
.values({
|
|
167
|
+
id,
|
|
168
|
+
name: input.name,
|
|
169
|
+
label: input.label,
|
|
170
|
+
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
|
171
|
+
translation_group: translationGroup ?? id,
|
|
172
|
+
})
|
|
173
|
+
.execute();
|
|
174
|
+
|
|
175
|
+
// Clone items from the source menu (same reference_ids — they are
|
|
176
|
+
// translation_groups, which are locale-agnostic). Each clone
|
|
177
|
+
// inherits the source item's translation_group so a nav entry
|
|
178
|
+
// identifies as the same logical item across menu translations.
|
|
179
|
+
if (sourceMenu) {
|
|
180
|
+
const sourceItems = await trx
|
|
181
|
+
.selectFrom("_emdash_menu_items")
|
|
182
|
+
.selectAll()
|
|
183
|
+
.where("menu_id", "=", sourceMenu.id)
|
|
184
|
+
.orderBy("sort_order", "asc")
|
|
185
|
+
.execute();
|
|
186
|
+
if (sourceItems.length > 0) {
|
|
187
|
+
// Build old-id → new-id map so parent pointers land on the clones.
|
|
188
|
+
const idMap = new Map<string, string>();
|
|
189
|
+
for (const item of sourceItems) idMap.set(item.id, ulid());
|
|
190
|
+
|
|
191
|
+
await trx
|
|
192
|
+
.insertInto("_emdash_menu_items")
|
|
193
|
+
.values(
|
|
194
|
+
sourceItems.map((item) => {
|
|
195
|
+
const newId = idMap.get(item.id)!;
|
|
196
|
+
return {
|
|
197
|
+
id: newId,
|
|
198
|
+
menu_id: id,
|
|
199
|
+
parent_id: item.parent_id ? (idMap.get(item.parent_id) ?? null) : null,
|
|
200
|
+
sort_order: item.sort_order,
|
|
201
|
+
type: item.type,
|
|
202
|
+
reference_collection: item.reference_collection,
|
|
203
|
+
reference_id: item.reference_id,
|
|
204
|
+
custom_url: item.custom_url,
|
|
205
|
+
label: item.label,
|
|
206
|
+
title_attr: item.title_attr,
|
|
207
|
+
target: item.target,
|
|
208
|
+
css_classes: item.css_classes,
|
|
209
|
+
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
|
210
|
+
translation_group: item.translation_group ?? item.id,
|
|
211
|
+
};
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
.execute();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
112
218
|
|
|
113
219
|
const menu = await db
|
|
114
220
|
.selectFrom("_emdash_menus")
|
|
115
221
|
.selectAll()
|
|
116
222
|
.where("id", "=", id)
|
|
117
223
|
.executeTakeFirstOrThrow();
|
|
118
|
-
|
|
119
224
|
return { success: true, data: menu };
|
|
120
225
|
} catch {
|
|
121
226
|
return {
|
|
@@ -126,18 +231,18 @@ export async function handleMenuCreate(
|
|
|
126
231
|
}
|
|
127
232
|
|
|
128
233
|
/**
|
|
129
|
-
* Get a single menu
|
|
234
|
+
* Get a single menu by name. Honours an optional `locale` filter; when two
|
|
235
|
+
* menus share a name across locales, the locale distinguishes them.
|
|
130
236
|
*/
|
|
131
237
|
export async function handleMenuGet(
|
|
132
238
|
db: Kysely<Database>,
|
|
133
239
|
name: string,
|
|
240
|
+
options: { locale?: string } = {},
|
|
134
241
|
): Promise<ApiResult<MenuWithItems>> {
|
|
135
242
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.where("name", "=", name)
|
|
140
|
-
.executeTakeFirst();
|
|
243
|
+
let query = db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
|
|
244
|
+
if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
|
|
245
|
+
const menu = await query.orderBy("locale", "asc").executeTakeFirst();
|
|
141
246
|
|
|
142
247
|
if (!menu) {
|
|
143
248
|
return {
|
|
@@ -163,19 +268,52 @@ export async function handleMenuGet(
|
|
|
163
268
|
}
|
|
164
269
|
|
|
165
270
|
/**
|
|
166
|
-
*
|
|
271
|
+
* Get a menu by id. Useful when the caller already has the id (e.g. after
|
|
272
|
+
* creating a translation and navigating to it).
|
|
167
273
|
*/
|
|
168
|
-
export async function
|
|
274
|
+
export async function handleMenuGetById(
|
|
169
275
|
db: Kysely<Database>,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
): Promise<ApiResult<MenuRow>> {
|
|
276
|
+
id: string,
|
|
277
|
+
): Promise<ApiResult<MenuWithItems>> {
|
|
173
278
|
try {
|
|
174
279
|
const menu = await db
|
|
175
280
|
.selectFrom("_emdash_menus")
|
|
176
|
-
.
|
|
177
|
-
.where("
|
|
281
|
+
.selectAll()
|
|
282
|
+
.where("id", "=", id)
|
|
178
283
|
.executeTakeFirst();
|
|
284
|
+
if (!menu) {
|
|
285
|
+
return {
|
|
286
|
+
success: false,
|
|
287
|
+
error: { code: "NOT_FOUND", message: `Menu '${id}' not found` },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const items = await db
|
|
291
|
+
.selectFrom("_emdash_menu_items")
|
|
292
|
+
.selectAll()
|
|
293
|
+
.where("menu_id", "=", menu.id)
|
|
294
|
+
.orderBy("sort_order", "asc")
|
|
295
|
+
.execute();
|
|
296
|
+
return { success: true, data: { ...menu, items } };
|
|
297
|
+
} catch {
|
|
298
|
+
return {
|
|
299
|
+
success: false,
|
|
300
|
+
error: { code: "MENU_GET_ERROR", message: "Failed to fetch menu" },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Update a menu's label. The name + locale are immutable.
|
|
307
|
+
*/
|
|
308
|
+
export async function handleMenuUpdate(
|
|
309
|
+
db: Kysely<Database>,
|
|
310
|
+
name: string,
|
|
311
|
+
input: { label?: string; locale?: string },
|
|
312
|
+
): Promise<ApiResult<MenuRow>> {
|
|
313
|
+
try {
|
|
314
|
+
let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
|
|
315
|
+
if (input.locale !== undefined) query = query.where("locale", "=", input.locale);
|
|
316
|
+
const menu = await query.executeTakeFirst();
|
|
179
317
|
|
|
180
318
|
if (!menu) {
|
|
181
319
|
return {
|
|
@@ -197,7 +335,6 @@ export async function handleMenuUpdate(
|
|
|
197
335
|
.selectAll()
|
|
198
336
|
.where("id", "=", menu.id)
|
|
199
337
|
.executeTakeFirstOrThrow();
|
|
200
|
-
|
|
201
338
|
return { success: true, data: updated };
|
|
202
339
|
} catch {
|
|
203
340
|
return {
|
|
@@ -208,18 +345,17 @@ export async function handleMenuUpdate(
|
|
|
208
345
|
}
|
|
209
346
|
|
|
210
347
|
/**
|
|
211
|
-
* Delete a menu and
|
|
348
|
+
* Delete a menu (and items, via cascade).
|
|
212
349
|
*/
|
|
213
350
|
export async function handleMenuDelete(
|
|
214
351
|
db: Kysely<Database>,
|
|
215
352
|
name: string,
|
|
353
|
+
options: { locale?: string } = {},
|
|
216
354
|
): Promise<ApiResult<{ deleted: true }>> {
|
|
217
355
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
.where("name", "=", name)
|
|
222
|
-
.executeTakeFirst();
|
|
356
|
+
let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
|
|
357
|
+
if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
|
|
358
|
+
const menu = await query.executeTakeFirst();
|
|
223
359
|
|
|
224
360
|
if (!menu) {
|
|
225
361
|
return {
|
|
@@ -233,7 +369,6 @@ export async function handleMenuDelete(
|
|
|
233
369
|
// idempotent on SQLite/Postgres where the cascade also fires.
|
|
234
370
|
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
|
|
235
371
|
await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
|
|
236
|
-
|
|
237
372
|
return { success: true, data: { deleted: true } };
|
|
238
373
|
} catch {
|
|
239
374
|
return {
|
|
@@ -243,6 +378,53 @@ export async function handleMenuDelete(
|
|
|
243
378
|
}
|
|
244
379
|
}
|
|
245
380
|
|
|
381
|
+
/**
|
|
382
|
+
* List every translation of a menu (by id or translation_group).
|
|
383
|
+
*/
|
|
384
|
+
export async function handleMenuTranslations(
|
|
385
|
+
db: Kysely<Database>,
|
|
386
|
+
idOrGroup: string,
|
|
387
|
+
): Promise<ApiResult<MenuTranslationsResponse>> {
|
|
388
|
+
try {
|
|
389
|
+
const anchor = await db
|
|
390
|
+
.selectFrom("_emdash_menus")
|
|
391
|
+
.selectAll()
|
|
392
|
+
.where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
|
|
393
|
+
.executeTakeFirst();
|
|
394
|
+
if (!anchor) {
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: { code: "NOT_FOUND", message: "Menu not found" },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const group = anchor.translation_group ?? anchor.id;
|
|
401
|
+
const rows = await db
|
|
402
|
+
.selectFrom("_emdash_menus")
|
|
403
|
+
.selectAll()
|
|
404
|
+
.where("translation_group", "=", group)
|
|
405
|
+
.orderBy("locale", "asc")
|
|
406
|
+
.execute();
|
|
407
|
+
return {
|
|
408
|
+
success: true,
|
|
409
|
+
data: {
|
|
410
|
+
translationGroup: group,
|
|
411
|
+
translations: rows.map((row) => ({
|
|
412
|
+
id: row.id,
|
|
413
|
+
name: row.name,
|
|
414
|
+
locale: row.locale,
|
|
415
|
+
label: row.label,
|
|
416
|
+
updatedAt: row.updated_at,
|
|
417
|
+
})),
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
} catch {
|
|
421
|
+
return {
|
|
422
|
+
success: false,
|
|
423
|
+
error: { code: "MENU_TRANSLATIONS_ERROR", message: "Failed to list menu translations" },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
246
428
|
// ---------------------------------------------------------------------------
|
|
247
429
|
// Menu item handlers
|
|
248
430
|
// ---------------------------------------------------------------------------
|
|
@@ -261,19 +443,22 @@ export interface CreateMenuItemInput {
|
|
|
261
443
|
}
|
|
262
444
|
|
|
263
445
|
/**
|
|
264
|
-
* Add an item to a menu.
|
|
446
|
+
* Add an item to a menu. The item inherits the menu's locale (so listing
|
|
447
|
+
* items by locale stays trivial).
|
|
265
448
|
*/
|
|
266
449
|
export async function handleMenuItemCreate(
|
|
267
450
|
db: Kysely<Database>,
|
|
268
451
|
menuName: string,
|
|
269
452
|
input: CreateMenuItemInput,
|
|
453
|
+
options: { locale?: string } = {},
|
|
270
454
|
): Promise<ApiResult<MenuItemRow>> {
|
|
271
455
|
try {
|
|
272
|
-
|
|
456
|
+
let menuQuery = db
|
|
273
457
|
.selectFrom("_emdash_menus")
|
|
274
|
-
.select("id")
|
|
275
|
-
.where("name", "=", menuName)
|
|
276
|
-
|
|
458
|
+
.select(["id", "locale"])
|
|
459
|
+
.where("name", "=", menuName);
|
|
460
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
461
|
+
const menu = await menuQuery.executeTakeFirst();
|
|
277
462
|
|
|
278
463
|
if (!menu) {
|
|
279
464
|
return {
|
|
@@ -290,7 +475,6 @@ export async function handleMenuItemCreate(
|
|
|
290
475
|
.where("menu_id", "=", menu.id)
|
|
291
476
|
.where("parent_id", "is", input.parentId ?? null)
|
|
292
477
|
.executeTakeFirst();
|
|
293
|
-
|
|
294
478
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely fn.max returns unknown; always a number for sort_order column
|
|
295
479
|
sortOrder = ((maxOrder?.max as number) ?? -1) + 1;
|
|
296
480
|
}
|
|
@@ -311,6 +495,8 @@ export async function handleMenuItemCreate(
|
|
|
311
495
|
title_attr: input.titleAttr ?? null,
|
|
312
496
|
target: input.target ?? null,
|
|
313
497
|
css_classes: input.cssClasses ?? null,
|
|
498
|
+
locale: menu.locale,
|
|
499
|
+
translation_group: id,
|
|
314
500
|
})
|
|
315
501
|
.execute();
|
|
316
502
|
|
|
@@ -319,7 +505,6 @@ export async function handleMenuItemCreate(
|
|
|
319
505
|
.selectAll()
|
|
320
506
|
.where("id", "=", id)
|
|
321
507
|
.executeTakeFirstOrThrow();
|
|
322
|
-
|
|
323
508
|
return { success: true, data: item };
|
|
324
509
|
} catch {
|
|
325
510
|
return {
|
|
@@ -347,13 +532,12 @@ export async function handleMenuItemUpdate(
|
|
|
347
532
|
menuName: string,
|
|
348
533
|
itemId: string,
|
|
349
534
|
input: UpdateMenuItemInput,
|
|
535
|
+
options: { locale?: string } = {},
|
|
350
536
|
): Promise<ApiResult<MenuItemRow>> {
|
|
351
537
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
.where("name", "=", menuName)
|
|
356
|
-
.executeTakeFirst();
|
|
538
|
+
let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
|
|
539
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
540
|
+
const menu = await menuQuery.executeTakeFirst();
|
|
357
541
|
|
|
358
542
|
if (!menu) {
|
|
359
543
|
return {
|
|
@@ -394,7 +578,6 @@ export async function handleMenuItemUpdate(
|
|
|
394
578
|
.selectAll()
|
|
395
579
|
.where("id", "=", itemId)
|
|
396
580
|
.executeTakeFirstOrThrow();
|
|
397
|
-
|
|
398
581
|
return { success: true, data: updated };
|
|
399
582
|
} catch {
|
|
400
583
|
return {
|
|
@@ -411,13 +594,12 @@ export async function handleMenuItemDelete(
|
|
|
411
594
|
db: Kysely<Database>,
|
|
412
595
|
menuName: string,
|
|
413
596
|
itemId: string,
|
|
597
|
+
options: { locale?: string } = {},
|
|
414
598
|
): Promise<ApiResult<{ deleted: true }>> {
|
|
415
599
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.where("name", "=", menuName)
|
|
420
|
-
.executeTakeFirst();
|
|
600
|
+
let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
|
|
601
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
602
|
+
const menu = await menuQuery.executeTakeFirst();
|
|
421
603
|
|
|
422
604
|
if (!menu) {
|
|
423
605
|
return {
|
|
@@ -589,13 +771,12 @@ export async function handleMenuItemReorder(
|
|
|
589
771
|
db: Kysely<Database>,
|
|
590
772
|
menuName: string,
|
|
591
773
|
items: ReorderItem[],
|
|
774
|
+
options: { locale?: string } = {},
|
|
592
775
|
): Promise<ApiResult<MenuItemRow[]>> {
|
|
593
776
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
.where("name", "=", menuName)
|
|
598
|
-
.executeTakeFirst();
|
|
777
|
+
let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
|
|
778
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
779
|
+
const menu = await menuQuery.executeTakeFirst();
|
|
599
780
|
|
|
600
781
|
if (!menu) {
|
|
601
782
|
return {
|