emdash 0.9.0 → 0.11.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-Ded_1vng.mjs} +167 -254
- package/dist/apply-Ded_1vng.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.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +94 -43
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +12 -11
- 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-gFn1r0vA.mjs} +4 -4
- package/dist/{byline-BSaNL1w7.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
- package/dist/{bylines-CvJ3PYz2.mjs → bylines-DTFI8nDM.mjs} +5 -5
- package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
- package/dist/{cache-C6N_hhN7.mjs → cache-BAJbeoZ8.mjs} +3 -3
- package/dist/{cache-C6N_hhN7.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
- package/dist/{chunks-NBQVDOci.mjs → chunks-BK1oZS-l.mjs} +2 -2
- package/dist/{chunks-NBQVDOci.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
- package/dist/cli/index.mjs +342 -95
- 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-CERxPUN0.mjs} +14 -3
- package/dist/content-CERxPUN0.mjs.map +1 -0
- package/dist/database/instrumentation.d.mts +6 -4
- package/dist/database/instrumentation.d.mts.map +1 -1
- package/dist/database/instrumentation.mjs +19 -7
- package/dist/database/instrumentation.mjs.map +1 -1
- 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-Cg-rC4Gj.d.mts} +110 -87
- package/dist/index-Cg-rC4Gj.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +29 -28
- package/dist/{load-DDqMMvZL.mjs → load-DR1VwFXR.mjs} +2 -2
- package/dist/{load-DDqMMvZL.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
- package/dist/{loader-CKLbBnhK.mjs → loader-ou_PXAjg.mjs} +31 -6
- package/dist/loader-ou_PXAjg.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-1fFhub9c.mjs} +22 -10
- package/dist/media-1fFhub9c.mjs.map +1 -0
- 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-8c_meo_K.mjs} +13 -13
- package/dist/{query-Cg9ZKRQ0.mjs.map → query-8c_meo_K.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-Do34mz_P.mjs} +7 -6
- package/dist/registry-Do34mz_P.mjs.map +1 -0
- package/dist/{request-cache-B-bmkipQ.mjs → request-cache-D4I69LeL.mjs} +6 -2
- package/dist/request-cache-D4I69LeL.mjs.map +1 -0
- package/dist/request-context.d.mts +27 -1
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs +16 -3
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-C7ADox5q.mjs → runner-DIcU2UCC.mjs} +465 -148
- package/dist/runner-DIcU2UCC.mjs.map +1 -0
- package/dist/{runner-Bnoj7vjK.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
- package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +3 -3
- package/dist/{search-dOGEccMa.mjs → search-DuWhx4NG.mjs} +322 -108
- package/dist/search-DuWhx4NG.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-Bw76xAxo.mjs +407 -0
- package/dist/taxonomies-Bw76xAxo.mjs.map +1 -0
- package/dist/taxonomy-D6NvlKo8.mjs +218 -0
- package/dist/taxonomy-D6NvlKo8.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-CIOg5AR8.mjs → types-56BKbld_.mjs} +1 -1
- package/dist/types-56BKbld_.mjs.map +1 -0
- 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-CrtWgIvl.d.mts → types-BQx6ZXpR.d.mts} +10 -1
- package/dist/types-BQx6ZXpR.d.mts.map +1 -0
- 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-DiI8NOG_.mjs +16 -0
- package/dist/types-DiI8NOG_.mjs.map +1 -0
- package/dist/{types-BuBIptGk.d.mts → types-IN5z_S3P.d.mts} +158 -92
- package/dist/types-IN5z_S3P.d.mts.map +1 -0
- package/dist/{types-BSyXeCFW.d.mts → types-IZSZfEwv.d.mts} +4 -3
- package/dist/types-IZSZfEwv.d.mts.map +1 -0
- 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-BfQh_C_y.d.mts → validate-CO3JjFV5.d.mts} +22 -5
- package/dist/validate-CO3JjFV5.d.mts.map +1 -0
- package/dist/{validate-Baqf0slj.mjs → validate-UK4Ja1uo.mjs} +14 -10
- package/dist/validate-UK4Ja1uo.mjs.map +1 -0
- package/dist/{validation-BfEI7tNe.mjs → validation-Vc5DQkJa.mjs} +5 -5
- package/dist/{validation-BfEI7tNe.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
- package/dist/version-Bg31I_Ff.mjs +7 -0
- package/dist/{version-DoxrVdYf.mjs.map → version-Bg31I_Ff.mjs.map} +1 -1
- package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-CHnJUP2l.mjs} +8 -3
- package/dist/zod-generator-CHnJUP2l.mjs.map +1 -0
- package/package.json +9 -8
- package/src/api/errors.ts +5 -0
- package/src/api/handlers/content.ts +20 -0
- package/src/api/handlers/dashboard.ts +29 -36
- package/src/api/handlers/media-allowlist.ts +40 -0
- package/src/api/handlers/media.ts +1 -1
- package/src/api/handlers/menus.ts +400 -89
- package/src/api/handlers/taxonomies.ts +273 -97
- package/src/api/handlers/validate-media-fields.ts +125 -0
- package/src/api/schemas/common.ts +7 -0
- package/src/api/schemas/media.ts +23 -3
- package/src/api/schemas/menus.ts +23 -0
- package/src/api/schemas/schema.ts +11 -2
- package/src/api/schemas/taxonomies.ts +39 -0
- package/src/astro/integration/routes.ts +10 -0
- package/src/astro/middleware.ts +46 -11
- 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/media/upload-url.ts +10 -4
- package/src/astro/routes/api/media.ts +12 -4
- 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/astro/types.ts +5 -1
- package/src/auth/rate-limit.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +81 -6
- package/src/cli/commands/bundle.ts +18 -15
- package/src/cli/commands/export-seed.ts +139 -24
- package/src/cli/commands/plugin-init.ts +216 -90
- package/src/database/instrumentation.ts +22 -8
- package/src/database/migrations/016_api_tokens.ts +18 -3
- package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
- package/src/database/migrations/037_credential_algorithm.ts +18 -0
- package/src/database/migrations/runner.ts +4 -0
- package/src/database/repositories/content.ts +11 -0
- package/src/database/repositories/media.ts +40 -10
- package/src/database/repositories/taxonomy.ts +193 -89
- package/src/database/types.ts +12 -3
- package/src/emdash-runtime.ts +16 -3
- package/src/fields/file.ts +7 -6
- package/src/fields/image.ts +12 -11
- package/src/fields/types.ts +3 -0
- package/src/i18n/resolve.ts +37 -0
- package/src/index.ts +1 -1
- package/src/loader.ts +49 -2
- package/src/mcp/server.ts +114 -26
- package/src/media/mime.ts +75 -0
- package/src/menus/index.ts +143 -124
- package/src/menus/types.ts +15 -1
- package/src/plugins/types.ts +81 -191
- package/src/request-cache.ts +6 -2
- package/src/request-context.ts +42 -2
- package/src/schema/registry.ts +5 -5
- package/src/schema/types.ts +3 -2
- package/src/schema/zod-generator.ts +12 -2
- package/src/seed/apply.ts +157 -54
- package/src/seed/types.ts +18 -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/media-BW32b4gi.mjs.map +0 -1
- package/dist/registry-Dw70ChxB.mjs.map +0 -1
- package/dist/request-cache-B-bmkipQ.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-4fVtCIm0.mjs +0 -68
- package/dist/types-4fVtCIm0.mjs.map +0 -1
- package/dist/types-BSyXeCFW.d.mts.map +0 -1
- package/dist/types-BuBIptGk.d.mts.map +0 -1
- package/dist/types-CIOg5AR8.mjs.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
|
@@ -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,56 @@ 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
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Error returned when a menu lookup by `name` matches multiple locale
|
|
50
|
+
* variants and the caller did not pass `locale` to disambiguate. Maps to
|
|
51
|
+
* HTTP 400 via `mapErrorStatus`. The available locales are surfaced in the
|
|
52
|
+
* message so MCP/REST callers can recover by re-issuing with `locale`.
|
|
53
|
+
*/
|
|
54
|
+
function ambiguousMenuLocaleError(
|
|
55
|
+
name: string,
|
|
56
|
+
locales: readonly string[],
|
|
57
|
+
): { success: false; error: { code: "AMBIGUOUS_LOCALE"; message: string } } {
|
|
58
|
+
const sortedLocales = locales.toSorted();
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
error: {
|
|
62
|
+
code: "AMBIGUOUS_LOCALE",
|
|
63
|
+
message: `Menu '${name}' exists in multiple locales (${sortedLocales.join(
|
|
64
|
+
", ",
|
|
65
|
+
)}); pass 'locale' to disambiguate.`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
// ---------------------------------------------------------------------------
|
|
37
71
|
// Menu handlers
|
|
38
72
|
// ---------------------------------------------------------------------------
|
|
39
73
|
|
|
40
74
|
/**
|
|
41
|
-
* List
|
|
75
|
+
* List menus with item counts. Filter by `locale` when provided; otherwise
|
|
76
|
+
* return every menu row (each locale counts as its own menu for admin listing
|
|
77
|
+
* purposes).
|
|
42
78
|
*/
|
|
43
|
-
export async function handleMenuList(
|
|
79
|
+
export async function handleMenuList(
|
|
80
|
+
db: Kysely<Database>,
|
|
81
|
+
options: { locale?: string } = {},
|
|
82
|
+
): Promise<ApiResult<MenuListItem[]>> {
|
|
44
83
|
try {
|
|
45
84
|
// Single query: LEFT JOIN + GROUP BY for the per-menu item count.
|
|
46
85
|
// Avoids the N+1 of one count query per menu.
|
|
47
|
-
|
|
86
|
+
let query = db
|
|
48
87
|
.selectFrom("_emdash_menus as m")
|
|
49
88
|
.leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
|
|
50
89
|
.select(({ fn }) => [
|
|
@@ -53,11 +92,22 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
|
|
|
53
92
|
"m.label",
|
|
54
93
|
"m.created_at",
|
|
55
94
|
"m.updated_at",
|
|
95
|
+
"m.locale",
|
|
96
|
+
"m.translation_group",
|
|
56
97
|
fn.count<number>("i.id").as("itemCount"),
|
|
57
98
|
])
|
|
58
|
-
.groupBy([
|
|
59
|
-
|
|
60
|
-
|
|
99
|
+
.groupBy([
|
|
100
|
+
"m.id",
|
|
101
|
+
"m.name",
|
|
102
|
+
"m.label",
|
|
103
|
+
"m.created_at",
|
|
104
|
+
"m.updated_at",
|
|
105
|
+
"m.locale",
|
|
106
|
+
"m.translation_group",
|
|
107
|
+
])
|
|
108
|
+
.orderBy("m.name", "asc");
|
|
109
|
+
if (options.locale !== undefined) query = query.where("m.locale", "=", options.locale);
|
|
110
|
+
const rows = await query.execute();
|
|
61
111
|
|
|
62
112
|
// SQLite returns count as `number`, but some dialects (Postgres)
|
|
63
113
|
// return `string` from a count() aggregate. Normalize to number.
|
|
@@ -67,6 +117,8 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
|
|
|
67
117
|
label: row.label,
|
|
68
118
|
created_at: row.created_at,
|
|
69
119
|
updated_at: row.updated_at,
|
|
120
|
+
locale: row.locale,
|
|
121
|
+
translation_group: row.translation_group,
|
|
70
122
|
itemCount: typeof row.itemCount === "string" ? Number(row.itemCount) : row.itemCount,
|
|
71
123
|
}));
|
|
72
124
|
|
|
@@ -80,42 +132,132 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
|
|
|
80
132
|
}
|
|
81
133
|
|
|
82
134
|
/**
|
|
83
|
-
* Create a new menu.
|
|
135
|
+
* Create a new menu. When `translationOf` is supplied the new menu joins the
|
|
136
|
+
* source menu's translation_group (and gets the source's items cloned).
|
|
84
137
|
*/
|
|
85
138
|
export async function handleMenuCreate(
|
|
86
139
|
db: Kysely<Database>,
|
|
87
|
-
input: { name: string; label: string },
|
|
140
|
+
input: { name: string; label: string; locale?: string; translationOf?: string },
|
|
88
141
|
): Promise<ApiResult<MenuRow>> {
|
|
89
142
|
try {
|
|
143
|
+
// Translating from a source menu only makes sense when the caller
|
|
144
|
+
// names the target locale: otherwise we'd silently clone into the
|
|
145
|
+
// configured default, which is almost never what's intended (and
|
|
146
|
+
// will collide if the source is already the default-locale menu).
|
|
147
|
+
// Enforced here so REST/SDK callers get the same guard as MCP.
|
|
148
|
+
if (input.translationOf && !input.locale) {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
error: {
|
|
152
|
+
code: "VALIDATION_ERROR",
|
|
153
|
+
message: "`locale` is required when `translationOf` is provided",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Resolve translation group + source (if we're creating a translation).
|
|
159
|
+
let translationGroup: string | null = null;
|
|
160
|
+
let sourceMenu: MenuRow | null = null;
|
|
161
|
+
if (input.translationOf) {
|
|
162
|
+
const src = await db
|
|
163
|
+
.selectFrom("_emdash_menus")
|
|
164
|
+
.selectAll()
|
|
165
|
+
.where("id", "=", input.translationOf)
|
|
166
|
+
.executeTakeFirst();
|
|
167
|
+
if (!src) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: { code: "NOT_FOUND", message: "Source menu for translation not found" },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
sourceMenu = src;
|
|
174
|
+
translationGroup = src.translation_group ?? src.id;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Duplicate guard: same (name, locale). Falls back to the configured
|
|
178
|
+
// defaultLocale to match the column DEFAULT set by migration 036.
|
|
179
|
+
const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
|
|
90
180
|
const existing = await db
|
|
91
181
|
.selectFrom("_emdash_menus")
|
|
92
182
|
.select("id")
|
|
93
183
|
.where("name", "=", input.name)
|
|
184
|
+
.where("locale", "=", effectiveLocale)
|
|
94
185
|
.executeTakeFirst();
|
|
95
|
-
|
|
96
186
|
if (existing) {
|
|
97
187
|
return {
|
|
98
188
|
success: false,
|
|
99
|
-
error: {
|
|
189
|
+
error: {
|
|
190
|
+
code: "CONFLICT",
|
|
191
|
+
message: `Menu "${input.name}" already exists${
|
|
192
|
+
input.locale ? ` in locale "${input.locale}"` : ""
|
|
193
|
+
}`,
|
|
194
|
+
},
|
|
100
195
|
};
|
|
101
196
|
}
|
|
102
197
|
|
|
103
198
|
const id = ulid();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
199
|
+
|
|
200
|
+
await withTransaction(db, async (trx) => {
|
|
201
|
+
await trx
|
|
202
|
+
.insertInto("_emdash_menus")
|
|
203
|
+
.values({
|
|
204
|
+
id,
|
|
205
|
+
name: input.name,
|
|
206
|
+
label: input.label,
|
|
207
|
+
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
|
208
|
+
translation_group: translationGroup ?? id,
|
|
209
|
+
})
|
|
210
|
+
.execute();
|
|
211
|
+
|
|
212
|
+
// Clone items from the source menu (same reference_ids — they are
|
|
213
|
+
// translation_groups, which are locale-agnostic). Each clone
|
|
214
|
+
// inherits the source item's translation_group so a nav entry
|
|
215
|
+
// identifies as the same logical item across menu translations.
|
|
216
|
+
if (sourceMenu) {
|
|
217
|
+
const sourceItems = await trx
|
|
218
|
+
.selectFrom("_emdash_menu_items")
|
|
219
|
+
.selectAll()
|
|
220
|
+
.where("menu_id", "=", sourceMenu.id)
|
|
221
|
+
.orderBy("sort_order", "asc")
|
|
222
|
+
.execute();
|
|
223
|
+
if (sourceItems.length > 0) {
|
|
224
|
+
// Build old-id → new-id map so parent pointers land on the clones.
|
|
225
|
+
const idMap = new Map<string, string>();
|
|
226
|
+
for (const item of sourceItems) idMap.set(item.id, ulid());
|
|
227
|
+
|
|
228
|
+
await trx
|
|
229
|
+
.insertInto("_emdash_menu_items")
|
|
230
|
+
.values(
|
|
231
|
+
sourceItems.map((item) => {
|
|
232
|
+
const newId = idMap.get(item.id)!;
|
|
233
|
+
return {
|
|
234
|
+
id: newId,
|
|
235
|
+
menu_id: id,
|
|
236
|
+
parent_id: item.parent_id ? (idMap.get(item.parent_id) ?? null) : null,
|
|
237
|
+
sort_order: item.sort_order,
|
|
238
|
+
type: item.type,
|
|
239
|
+
reference_collection: item.reference_collection,
|
|
240
|
+
reference_id: item.reference_id,
|
|
241
|
+
custom_url: item.custom_url,
|
|
242
|
+
label: item.label,
|
|
243
|
+
title_attr: item.title_attr,
|
|
244
|
+
target: item.target,
|
|
245
|
+
css_classes: item.css_classes,
|
|
246
|
+
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
|
247
|
+
translation_group: item.translation_group ?? item.id,
|
|
248
|
+
};
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
.execute();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
112
255
|
|
|
113
256
|
const menu = await db
|
|
114
257
|
.selectFrom("_emdash_menus")
|
|
115
258
|
.selectAll()
|
|
116
259
|
.where("id", "=", id)
|
|
117
260
|
.executeTakeFirstOrThrow();
|
|
118
|
-
|
|
119
261
|
return { success: true, data: menu };
|
|
120
262
|
} catch {
|
|
121
263
|
return {
|
|
@@ -126,18 +268,18 @@ export async function handleMenuCreate(
|
|
|
126
268
|
}
|
|
127
269
|
|
|
128
270
|
/**
|
|
129
|
-
* Get a single menu
|
|
271
|
+
* Get a single menu by name. Honours an optional `locale` filter; when two
|
|
272
|
+
* menus share a name across locales, the locale distinguishes them.
|
|
130
273
|
*/
|
|
131
274
|
export async function handleMenuGet(
|
|
132
275
|
db: Kysely<Database>,
|
|
133
276
|
name: string,
|
|
277
|
+
options: { locale?: string } = {},
|
|
134
278
|
): Promise<ApiResult<MenuWithItems>> {
|
|
135
279
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.where("name", "=", name)
|
|
140
|
-
.executeTakeFirst();
|
|
280
|
+
let query = db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
|
|
281
|
+
if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
|
|
282
|
+
const menu = await query.orderBy("locale", "asc").executeTakeFirst();
|
|
141
283
|
|
|
142
284
|
if (!menu) {
|
|
143
285
|
return {
|
|
@@ -163,26 +305,73 @@ export async function handleMenuGet(
|
|
|
163
305
|
}
|
|
164
306
|
|
|
165
307
|
/**
|
|
166
|
-
*
|
|
308
|
+
* Get a menu by id. Useful when the caller already has the id (e.g. after
|
|
309
|
+
* creating a translation and navigating to it).
|
|
167
310
|
*/
|
|
168
|
-
export async function
|
|
311
|
+
export async function handleMenuGetById(
|
|
169
312
|
db: Kysely<Database>,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
): Promise<ApiResult<MenuRow>> {
|
|
313
|
+
id: string,
|
|
314
|
+
): Promise<ApiResult<MenuWithItems>> {
|
|
173
315
|
try {
|
|
174
316
|
const menu = await db
|
|
175
317
|
.selectFrom("_emdash_menus")
|
|
176
|
-
.
|
|
177
|
-
.where("
|
|
318
|
+
.selectAll()
|
|
319
|
+
.where("id", "=", id)
|
|
178
320
|
.executeTakeFirst();
|
|
179
|
-
|
|
180
321
|
if (!menu) {
|
|
181
322
|
return {
|
|
182
323
|
success: false,
|
|
183
|
-
error: { code: "NOT_FOUND", message: `Menu '${
|
|
324
|
+
error: { code: "NOT_FOUND", message: `Menu '${id}' not found` },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const items = await db
|
|
328
|
+
.selectFrom("_emdash_menu_items")
|
|
329
|
+
.selectAll()
|
|
330
|
+
.where("menu_id", "=", menu.id)
|
|
331
|
+
.orderBy("sort_order", "asc")
|
|
332
|
+
.execute();
|
|
333
|
+
return { success: true, data: { ...menu, items } };
|
|
334
|
+
} catch {
|
|
335
|
+
return {
|
|
336
|
+
success: false,
|
|
337
|
+
error: { code: "MENU_GET_ERROR", message: "Failed to fetch menu" },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Update a menu's label. The name + locale are immutable.
|
|
344
|
+
*/
|
|
345
|
+
export async function handleMenuUpdate(
|
|
346
|
+
db: Kysely<Database>,
|
|
347
|
+
name: string,
|
|
348
|
+
input: { label?: string; locale?: string },
|
|
349
|
+
): Promise<ApiResult<MenuRow>> {
|
|
350
|
+
try {
|
|
351
|
+
// Fetch every row matching the name (filtered by locale if supplied)
|
|
352
|
+
// so we can fail loud when an omitted-locale lookup is ambiguous.
|
|
353
|
+
// (name, locale) is unique, so length > 1 only happens when the
|
|
354
|
+
// caller didn't pass `locale` and the menu exists in >1 translation.
|
|
355
|
+
let query = db.selectFrom("_emdash_menus").select(["id", "locale"]).where("name", "=", name);
|
|
356
|
+
if (input.locale !== undefined) query = query.where("locale", "=", input.locale);
|
|
357
|
+
const matches = await query.execute();
|
|
358
|
+
|
|
359
|
+
if (matches.length === 0) {
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
error: {
|
|
363
|
+
code: "NOT_FOUND",
|
|
364
|
+
message: `Menu '${name}' not found${input.locale ? ` in locale '${input.locale}'` : ""}`,
|
|
365
|
+
},
|
|
184
366
|
};
|
|
185
367
|
}
|
|
368
|
+
if (matches.length > 1) {
|
|
369
|
+
return ambiguousMenuLocaleError(
|
|
370
|
+
name,
|
|
371
|
+
matches.map((m) => m.locale),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const menu = matches[0]!;
|
|
186
375
|
|
|
187
376
|
if (input.label) {
|
|
188
377
|
await db
|
|
@@ -197,7 +386,6 @@ export async function handleMenuUpdate(
|
|
|
197
386
|
.selectAll()
|
|
198
387
|
.where("id", "=", menu.id)
|
|
199
388
|
.executeTakeFirstOrThrow();
|
|
200
|
-
|
|
201
389
|
return { success: true, data: updated };
|
|
202
390
|
} catch {
|
|
203
391
|
return {
|
|
@@ -208,32 +396,43 @@ export async function handleMenuUpdate(
|
|
|
208
396
|
}
|
|
209
397
|
|
|
210
398
|
/**
|
|
211
|
-
* Delete a menu and
|
|
399
|
+
* Delete a menu (and items, via cascade).
|
|
212
400
|
*/
|
|
213
401
|
export async function handleMenuDelete(
|
|
214
402
|
db: Kysely<Database>,
|
|
215
403
|
name: string,
|
|
404
|
+
options: { locale?: string } = {},
|
|
216
405
|
): Promise<ApiResult<{ deleted: true }>> {
|
|
217
406
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.executeTakeFirst();
|
|
407
|
+
// See ambiguousMenuLocaleError for why we fetch all matches.
|
|
408
|
+
let query = db.selectFrom("_emdash_menus").select(["id", "locale"]).where("name", "=", name);
|
|
409
|
+
if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
|
|
410
|
+
const matches = await query.execute();
|
|
223
411
|
|
|
224
|
-
if (
|
|
412
|
+
if (matches.length === 0) {
|
|
225
413
|
return {
|
|
226
414
|
success: false,
|
|
227
|
-
error: {
|
|
415
|
+
error: {
|
|
416
|
+
code: "NOT_FOUND",
|
|
417
|
+
message: `Menu '${name}' not found${
|
|
418
|
+
options.locale ? ` in locale '${options.locale}'` : ""
|
|
419
|
+
}`,
|
|
420
|
+
},
|
|
228
421
|
};
|
|
229
422
|
}
|
|
423
|
+
if (matches.length > 1) {
|
|
424
|
+
return ambiguousMenuLocaleError(
|
|
425
|
+
name,
|
|
426
|
+
matches.map((m) => m.locale),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const menu = matches[0]!;
|
|
230
430
|
|
|
231
431
|
// D1 has FOREIGN KEYS off by default, so the migration's `ON DELETE
|
|
232
432
|
// CASCADE` won't fire there. Delete items explicitly first — this is
|
|
233
433
|
// idempotent on SQLite/Postgres where the cascade also fires.
|
|
234
434
|
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
|
|
235
435
|
await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
|
|
236
|
-
|
|
237
436
|
return { success: true, data: { deleted: true } };
|
|
238
437
|
} catch {
|
|
239
438
|
return {
|
|
@@ -243,6 +442,53 @@ export async function handleMenuDelete(
|
|
|
243
442
|
}
|
|
244
443
|
}
|
|
245
444
|
|
|
445
|
+
/**
|
|
446
|
+
* List every translation of a menu (by id or translation_group).
|
|
447
|
+
*/
|
|
448
|
+
export async function handleMenuTranslations(
|
|
449
|
+
db: Kysely<Database>,
|
|
450
|
+
idOrGroup: string,
|
|
451
|
+
): Promise<ApiResult<MenuTranslationsResponse>> {
|
|
452
|
+
try {
|
|
453
|
+
const anchor = await db
|
|
454
|
+
.selectFrom("_emdash_menus")
|
|
455
|
+
.selectAll()
|
|
456
|
+
.where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
|
|
457
|
+
.executeTakeFirst();
|
|
458
|
+
if (!anchor) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
error: { code: "NOT_FOUND", message: "Menu not found" },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const group = anchor.translation_group ?? anchor.id;
|
|
465
|
+
const rows = await db
|
|
466
|
+
.selectFrom("_emdash_menus")
|
|
467
|
+
.selectAll()
|
|
468
|
+
.where("translation_group", "=", group)
|
|
469
|
+
.orderBy("locale", "asc")
|
|
470
|
+
.execute();
|
|
471
|
+
return {
|
|
472
|
+
success: true,
|
|
473
|
+
data: {
|
|
474
|
+
translationGroup: group,
|
|
475
|
+
translations: rows.map((row) => ({
|
|
476
|
+
id: row.id,
|
|
477
|
+
name: row.name,
|
|
478
|
+
locale: row.locale,
|
|
479
|
+
label: row.label,
|
|
480
|
+
updatedAt: row.updated_at,
|
|
481
|
+
})),
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
} catch {
|
|
485
|
+
return {
|
|
486
|
+
success: false,
|
|
487
|
+
error: { code: "MENU_TRANSLATIONS_ERROR", message: "Failed to list menu translations" },
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
246
492
|
// ---------------------------------------------------------------------------
|
|
247
493
|
// Menu item handlers
|
|
248
494
|
// ---------------------------------------------------------------------------
|
|
@@ -261,26 +507,38 @@ export interface CreateMenuItemInput {
|
|
|
261
507
|
}
|
|
262
508
|
|
|
263
509
|
/**
|
|
264
|
-
* Add an item to a menu.
|
|
510
|
+
* Add an item to a menu. The item inherits the menu's locale (so listing
|
|
511
|
+
* items by locale stays trivial).
|
|
265
512
|
*/
|
|
266
513
|
export async function handleMenuItemCreate(
|
|
267
514
|
db: Kysely<Database>,
|
|
268
515
|
menuName: string,
|
|
269
516
|
input: CreateMenuItemInput,
|
|
517
|
+
options: { locale?: string } = {},
|
|
270
518
|
): Promise<ApiResult<MenuItemRow>> {
|
|
271
519
|
try {
|
|
272
|
-
|
|
520
|
+
// Same fail-loud rule as handleMenuUpdate / Delete / SetItems —
|
|
521
|
+
// see ambiguousMenuLocaleError for the rationale.
|
|
522
|
+
let menuQuery = db
|
|
273
523
|
.selectFrom("_emdash_menus")
|
|
274
|
-
.select("id")
|
|
275
|
-
.where("name", "=", menuName)
|
|
276
|
-
|
|
524
|
+
.select(["id", "locale"])
|
|
525
|
+
.where("name", "=", menuName);
|
|
526
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
527
|
+
const matches = await menuQuery.execute();
|
|
277
528
|
|
|
278
|
-
if (
|
|
529
|
+
if (matches.length === 0) {
|
|
279
530
|
return {
|
|
280
531
|
success: false,
|
|
281
532
|
error: { code: "NOT_FOUND", message: "Menu not found" },
|
|
282
533
|
};
|
|
283
534
|
}
|
|
535
|
+
if (matches.length > 1) {
|
|
536
|
+
return ambiguousMenuLocaleError(
|
|
537
|
+
menuName,
|
|
538
|
+
matches.map((m) => m.locale),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const menu = matches[0]!;
|
|
284
542
|
|
|
285
543
|
let sortOrder = input.sortOrder ?? 0;
|
|
286
544
|
if (input.sortOrder === undefined) {
|
|
@@ -290,7 +548,6 @@ export async function handleMenuItemCreate(
|
|
|
290
548
|
.where("menu_id", "=", menu.id)
|
|
291
549
|
.where("parent_id", "is", input.parentId ?? null)
|
|
292
550
|
.executeTakeFirst();
|
|
293
|
-
|
|
294
551
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely fn.max returns unknown; always a number for sort_order column
|
|
295
552
|
sortOrder = ((maxOrder?.max as number) ?? -1) + 1;
|
|
296
553
|
}
|
|
@@ -311,6 +568,8 @@ export async function handleMenuItemCreate(
|
|
|
311
568
|
title_attr: input.titleAttr ?? null,
|
|
312
569
|
target: input.target ?? null,
|
|
313
570
|
css_classes: input.cssClasses ?? null,
|
|
571
|
+
locale: menu.locale,
|
|
572
|
+
translation_group: id,
|
|
314
573
|
})
|
|
315
574
|
.execute();
|
|
316
575
|
|
|
@@ -319,7 +578,6 @@ export async function handleMenuItemCreate(
|
|
|
319
578
|
.selectAll()
|
|
320
579
|
.where("id", "=", id)
|
|
321
580
|
.executeTakeFirstOrThrow();
|
|
322
|
-
|
|
323
581
|
return { success: true, data: item };
|
|
324
582
|
} catch {
|
|
325
583
|
return {
|
|
@@ -347,20 +605,30 @@ export async function handleMenuItemUpdate(
|
|
|
347
605
|
menuName: string,
|
|
348
606
|
itemId: string,
|
|
349
607
|
input: UpdateMenuItemInput,
|
|
608
|
+
options: { locale?: string } = {},
|
|
350
609
|
): Promise<ApiResult<MenuItemRow>> {
|
|
351
610
|
try {
|
|
352
|
-
|
|
611
|
+
// See ambiguousMenuLocaleError for the rationale.
|
|
612
|
+
let menuQuery = db
|
|
353
613
|
.selectFrom("_emdash_menus")
|
|
354
|
-
.select("id")
|
|
355
|
-
.where("name", "=", menuName)
|
|
356
|
-
|
|
614
|
+
.select(["id", "locale"])
|
|
615
|
+
.where("name", "=", menuName);
|
|
616
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
617
|
+
const matches = await menuQuery.execute();
|
|
357
618
|
|
|
358
|
-
if (
|
|
619
|
+
if (matches.length === 0) {
|
|
359
620
|
return {
|
|
360
621
|
success: false,
|
|
361
622
|
error: { code: "NOT_FOUND", message: "Menu not found" },
|
|
362
623
|
};
|
|
363
624
|
}
|
|
625
|
+
if (matches.length > 1) {
|
|
626
|
+
return ambiguousMenuLocaleError(
|
|
627
|
+
menuName,
|
|
628
|
+
matches.map((m) => m.locale),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const menu = matches[0]!;
|
|
364
632
|
|
|
365
633
|
const item = await db
|
|
366
634
|
.selectFrom("_emdash_menu_items")
|
|
@@ -394,7 +662,6 @@ export async function handleMenuItemUpdate(
|
|
|
394
662
|
.selectAll()
|
|
395
663
|
.where("id", "=", itemId)
|
|
396
664
|
.executeTakeFirstOrThrow();
|
|
397
|
-
|
|
398
665
|
return { success: true, data: updated };
|
|
399
666
|
} catch {
|
|
400
667
|
return {
|
|
@@ -411,20 +678,30 @@ export async function handleMenuItemDelete(
|
|
|
411
678
|
db: Kysely<Database>,
|
|
412
679
|
menuName: string,
|
|
413
680
|
itemId: string,
|
|
681
|
+
options: { locale?: string } = {},
|
|
414
682
|
): Promise<ApiResult<{ deleted: true }>> {
|
|
415
683
|
try {
|
|
416
|
-
|
|
684
|
+
// See ambiguousMenuLocaleError for the rationale.
|
|
685
|
+
let menuQuery = db
|
|
417
686
|
.selectFrom("_emdash_menus")
|
|
418
|
-
.select("id")
|
|
419
|
-
.where("name", "=", menuName)
|
|
420
|
-
|
|
687
|
+
.select(["id", "locale"])
|
|
688
|
+
.where("name", "=", menuName);
|
|
689
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
690
|
+
const matches = await menuQuery.execute();
|
|
421
691
|
|
|
422
|
-
if (
|
|
692
|
+
if (matches.length === 0) {
|
|
423
693
|
return {
|
|
424
694
|
success: false,
|
|
425
695
|
error: { code: "NOT_FOUND", message: "Menu not found" },
|
|
426
696
|
};
|
|
427
697
|
}
|
|
698
|
+
if (matches.length > 1) {
|
|
699
|
+
return ambiguousMenuLocaleError(
|
|
700
|
+
menuName,
|
|
701
|
+
matches.map((m) => m.locale),
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const menu = matches[0]!;
|
|
428
705
|
|
|
429
706
|
const result = await db
|
|
430
707
|
.deleteFrom("_emdash_menu_items")
|
|
@@ -486,6 +763,7 @@ export async function handleMenuSetItems(
|
|
|
486
763
|
db: Kysely<Database>,
|
|
487
764
|
menuName: string,
|
|
488
765
|
items: MenuSetItemsInput[],
|
|
766
|
+
options: { locale?: string } = {},
|
|
489
767
|
): Promise<ApiResult<{ name: string; itemCount: number }>> {
|
|
490
768
|
// Validate parentIndex references — must be strictly earlier so
|
|
491
769
|
// the array can be inserted in order with parents resolved first.
|
|
@@ -508,24 +786,38 @@ export async function handleMenuSetItems(
|
|
|
508
786
|
}
|
|
509
787
|
|
|
510
788
|
try {
|
|
511
|
-
//
|
|
512
|
-
//
|
|
789
|
+
// Sentinels thrown from inside the transaction so the rollback
|
|
790
|
+
// fires before we return the structured error.
|
|
513
791
|
const notFoundSentinel = Symbol("menu-not-found");
|
|
792
|
+
// We capture the locale list rather than constructing the error
|
|
793
|
+
// inside the transaction, so the helper stays the single source
|
|
794
|
+
// of truth for AMBIGUOUS_LOCALE message shape.
|
|
795
|
+
let ambiguousLocales: string[] | null = null;
|
|
796
|
+
const ambiguousSentinel = Symbol("menu-ambiguous-locale");
|
|
514
797
|
|
|
515
798
|
try {
|
|
516
799
|
await withTransaction(db, async (trx) => {
|
|
517
800
|
// Existence check INSIDE the transaction so a concurrent
|
|
518
801
|
// menu_delete between lookup and write can't leave orphan
|
|
519
|
-
// items on D1 (FKs disabled by default).
|
|
520
|
-
|
|
802
|
+
// items on D1 (FKs disabled by default). Same fail-loud
|
|
803
|
+
// rule as handleMenuUpdate / handleMenuDelete.
|
|
804
|
+
let menuQuery = trx
|
|
521
805
|
.selectFrom("_emdash_menus")
|
|
522
|
-
.select("id")
|
|
523
|
-
.where("name", "=", menuName)
|
|
524
|
-
|
|
806
|
+
.select(["id", "locale"])
|
|
807
|
+
.where("name", "=", menuName);
|
|
808
|
+
if (options.locale !== undefined) {
|
|
809
|
+
menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
810
|
+
}
|
|
811
|
+
const matches = await menuQuery.execute();
|
|
525
812
|
|
|
526
|
-
if (
|
|
813
|
+
if (matches.length === 0) {
|
|
527
814
|
throw notFoundSentinel;
|
|
528
815
|
}
|
|
816
|
+
if (matches.length > 1) {
|
|
817
|
+
ambiguousLocales = matches.map((m) => m.locale);
|
|
818
|
+
throw ambiguousSentinel;
|
|
819
|
+
}
|
|
820
|
+
const menu = matches[0]!;
|
|
529
821
|
|
|
530
822
|
await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
|
|
531
823
|
|
|
@@ -551,6 +843,7 @@ export async function handleMenuSetItems(
|
|
|
551
843
|
title_attr: item.titleAttr ?? null,
|
|
552
844
|
target: item.target ?? null,
|
|
553
845
|
css_classes: item.cssClasses ?? null,
|
|
846
|
+
locale: menu.locale,
|
|
554
847
|
})
|
|
555
848
|
.execute();
|
|
556
849
|
insertedIds.push(id);
|
|
@@ -566,9 +859,17 @@ export async function handleMenuSetItems(
|
|
|
566
859
|
if (error === notFoundSentinel) {
|
|
567
860
|
return {
|
|
568
861
|
success: false,
|
|
569
|
-
error: {
|
|
862
|
+
error: {
|
|
863
|
+
code: "NOT_FOUND",
|
|
864
|
+
message: `Menu '${menuName}' not found${
|
|
865
|
+
options.locale ? ` in locale '${options.locale}'` : ""
|
|
866
|
+
}`,
|
|
867
|
+
},
|
|
570
868
|
};
|
|
571
869
|
}
|
|
870
|
+
if (error === ambiguousSentinel && ambiguousLocales) {
|
|
871
|
+
return ambiguousMenuLocaleError(menuName, ambiguousLocales);
|
|
872
|
+
}
|
|
572
873
|
throw error;
|
|
573
874
|
}
|
|
574
875
|
|
|
@@ -589,20 +890,30 @@ export async function handleMenuItemReorder(
|
|
|
589
890
|
db: Kysely<Database>,
|
|
590
891
|
menuName: string,
|
|
591
892
|
items: ReorderItem[],
|
|
893
|
+
options: { locale?: string } = {},
|
|
592
894
|
): Promise<ApiResult<MenuItemRow[]>> {
|
|
593
895
|
try {
|
|
594
|
-
|
|
896
|
+
// See ambiguousMenuLocaleError for the rationale.
|
|
897
|
+
let menuQuery = db
|
|
595
898
|
.selectFrom("_emdash_menus")
|
|
596
|
-
.select("id")
|
|
597
|
-
.where("name", "=", menuName)
|
|
598
|
-
|
|
899
|
+
.select(["id", "locale"])
|
|
900
|
+
.where("name", "=", menuName);
|
|
901
|
+
if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
|
|
902
|
+
const matches = await menuQuery.execute();
|
|
599
903
|
|
|
600
|
-
if (
|
|
904
|
+
if (matches.length === 0) {
|
|
601
905
|
return {
|
|
602
906
|
success: false,
|
|
603
907
|
error: { code: "NOT_FOUND", message: "Menu not found" },
|
|
604
908
|
};
|
|
605
909
|
}
|
|
910
|
+
if (matches.length > 1) {
|
|
911
|
+
return ambiguousMenuLocaleError(
|
|
912
|
+
menuName,
|
|
913
|
+
matches.map((m) => m.locale),
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
const menu = matches[0]!;
|
|
606
917
|
|
|
607
918
|
const updatedItems = await withTransaction(db, async (trx) => {
|
|
608
919
|
for (const item of items) {
|