@karibsen/eponyme 0.1.1 → 0.2.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.
Files changed (61) hide show
  1. package/README.md +126 -12
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +46 -36
  4. package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
  5. package/dist/runtime/components/editor/EponymeForm.vue +2 -2
  6. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  7. package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
  8. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  9. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  10. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  11. package/dist/runtime/components/fields/RichTextField.vue +51 -10
  12. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  13. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  14. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  15. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  16. package/dist/runtime/composables/useEponyme.js +5 -3
  17. package/dist/runtime/composables/useEponymeCollection.js +3 -2
  18. package/dist/runtime/composables/useEponymeForm.js +4 -1
  19. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  20. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  21. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  22. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  23. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  24. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  25. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  26. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  27. package/dist/runtime/server/api/eponyme-forms/[path].post.js +14 -1
  28. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  29. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  30. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  31. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  32. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  33. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  34. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  35. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  36. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  37. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  38. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  39. package/dist/runtime/server/services/eponyme-store.d.ts +75 -2
  40. package/dist/runtime/server/services/eponyme-store.js +232 -7
  41. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  42. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  43. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  44. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  45. package/dist/runtime/types/field.d.ts +1 -0
  46. package/dist/runtime/types/hooks.d.ts +67 -0
  47. package/dist/runtime/types/hooks.js +0 -0
  48. package/dist/runtime/types/nuxt.d.ts +10 -0
  49. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  50. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  51. package/dist/runtime/utils/date-locale.d.ts +10 -0
  52. package/dist/runtime/utils/date-locale.js +1 -0
  53. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  54. package/dist/runtime/utils/downloadable-link.js +35 -0
  55. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  56. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  57. package/dist/runtime/utils/get-field-default-value.js +1 -1
  58. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  59. package/dist/runtime/utils/hydration-cache.js +3 -0
  60. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  61. package/package.json +3 -1
@@ -1,34 +1,149 @@
1
1
  <script setup>
2
- import { useRoute, useSeoMeta } from "#app";
2
+ import { nextTick, ref } from "vue";
3
+ import { refreshNuxtData, useRequestFetch, useRoute, useSeoMeta } from "#app";
3
4
  import EponymeNavigationLink from "../components/editor/EponymeNavigationLink.vue";
4
5
  import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
6
+ import EPButton from "../components/ui/EPButton.vue";
7
+ import EPDialog from "../components/ui/EPDialog.vue";
8
+ import EPToast from "../components/ui/EPToast.vue";
9
+ import { useEponymeAuth } from "../composables/useEponymeAuth";
5
10
  import { useEponymeConfig } from "../composables/useEponymeConfig";
6
11
  import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
7
12
  const config = useEponymeConfig();
8
13
  const route = useRoute();
14
+ const auth = useEponymeAuth();
15
+ const request = useRequestFetch();
9
16
  const eponymes = getEponymeSchemas(config);
10
17
  const collections = getEponymeCollections(config);
11
18
  const forms = getEponymeForms(config);
12
19
  const entries = { ...eponymes, ...collections, ...forms };
13
20
  useSeoMeta({ title: "Eponyme" });
21
+ const fileInput = ref();
22
+ const exporting = ref(false);
23
+ const importing = ref(false);
24
+ const preview = ref();
25
+ const pendingFile = ref();
26
+ const toast = ref({ open: false, title: "", description: "", variant: "success" });
14
27
  function label(name) {
15
28
  return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
16
29
  }
30
+ async function showToast(variant, title, description) {
31
+ toast.value.open = false;
32
+ await nextTick();
33
+ toast.value = { open: true, variant, title, description };
34
+ }
35
+ function describeError(caught, fallback) {
36
+ const error = caught;
37
+ const mismatch = error?.data?.data?.schemaMismatch;
38
+ const message = error?.statusMessage || error?.data?.statusMessage || fallback;
39
+ return mismatch?.length ? `${message} (${mismatch.join(", ")})` : message;
40
+ }
41
+ async function exportContent() {
42
+ exporting.value = true;
43
+ try {
44
+ const file = await request("/api/eponyme-export", { cache: "no-store" });
45
+ const url = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: "application/json" }));
46
+ const link = document.createElement("a");
47
+ link.href = url;
48
+ link.download = `eponyme-export-${file.eponyme.exportedAt.slice(0, 10)}.json`;
49
+ link.click();
50
+ URL.revokeObjectURL(url);
51
+ } catch (caught) {
52
+ await showToast("error", "Export failed", describeError(caught, "Unable to export the content."));
53
+ } finally {
54
+ exporting.value = false;
55
+ }
56
+ }
57
+ async function reviewFile(event) {
58
+ const input = event.target;
59
+ const file = input.files?.[0];
60
+ input.value = "";
61
+ if (!file) return;
62
+ importing.value = true;
63
+ try {
64
+ const parsed = JSON.parse(await file.text());
65
+ preview.value = await request("/api/eponyme-import?dryRun=1", { method: "POST", body: parsed });
66
+ pendingFile.value = parsed;
67
+ } catch (caught) {
68
+ pendingFile.value = void 0;
69
+ preview.value = void 0;
70
+ await showToast("error", "Import refused", caught instanceof SyntaxError ? "This file is not valid JSON." : describeError(caught, "Unable to read this export file."));
71
+ } finally {
72
+ importing.value = false;
73
+ }
74
+ }
75
+ async function confirmImport() {
76
+ if (!pendingFile.value) return;
77
+ importing.value = true;
78
+ try {
79
+ const result = await request("/api/eponyme-import", { method: "POST", body: pendingFile.value });
80
+ closePreview();
81
+ await refreshNuxtData();
82
+ await showToast(
83
+ "success",
84
+ "Content imported",
85
+ `${result.created} created, ${result.updated} updated${result.skipped.length ? `, ${result.skipped.length} skipped` : ""}.`
86
+ );
87
+ } catch (caught) {
88
+ await showToast("error", "Import failed", describeError(caught, "Unable to import this file."));
89
+ } finally {
90
+ importing.value = false;
91
+ }
92
+ }
93
+ function closePreview() {
94
+ preview.value = void 0;
95
+ pendingFile.value = void 0;
96
+ }
17
97
  </script>
18
98
 
19
99
  <template>
20
100
  <main class="eponyme-root ep:flex ep:min-h-screen ep:flex-col ep:bg-theme-ep ep:font-sans ep:text-text-ep ep:md:flex-row">
21
101
  <EponymeSidebar :base-path="route.path" />
22
102
  <section class="ep:mx-auto ep:w-full ep:max-w-3xl ep:px-6 ep:py-8 ep:md:px-10 ep:md:py-12">
23
- <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
24
- Eponyme
25
- </p>
26
- <h1 class="ep:mt-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
27
- Content entries
28
- </h1>
29
- <p class="ep:mt-2 ep:text-sm ep:text-muted-ep">
30
- Choose the content area you want to update.
31
- </p>
103
+ <div class="ep:flex ep:flex-wrap ep:items-start ep:justify-between ep:gap-4">
104
+ <div>
105
+ <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
106
+ Eponyme
107
+ </p>
108
+ <h1 class="ep:mt-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
109
+ Content entries
110
+ </h1>
111
+ <p class="ep:mt-2 ep:text-sm ep:text-muted-ep">
112
+ Choose the content area you want to update.
113
+ </p>
114
+ </div>
115
+ <div
116
+ v-if="auth.canEdit.value"
117
+ class="ep:flex ep:shrink-0 ep:items-center ep:gap-2"
118
+ >
119
+ <EPButton
120
+ label="Export"
121
+ icon="mingcute:download-2-line"
122
+ size="sm"
123
+ :loading="exporting"
124
+ :disabled="exporting"
125
+ title="Download every entry as a JSON file"
126
+ @click="exportContent"
127
+ />
128
+ <EPButton
129
+ v-if="auth.isOwner.value"
130
+ label="Import"
131
+ icon="mingcute:upload-2-line"
132
+ size="sm"
133
+ :loading="importing"
134
+ :disabled="importing"
135
+ title="Apply an export file from another environment"
136
+ @click="fileInput?.click()"
137
+ />
138
+ <input
139
+ ref="fileInput"
140
+ type="file"
141
+ accept="application/json,.json"
142
+ class="ep:hidden"
143
+ @change="reviewFile"
144
+ >
145
+ </div>
146
+ </div>
32
147
  <nav
33
148
  class="ep:mt-8 ep:grid ep:gap-3 ep:sm:grid-cols-2"
34
149
  aria-label="Content entries"
@@ -43,6 +158,54 @@ function label(name) {
43
158
  />
44
159
  </nav>
45
160
  </section>
161
+ <ClientOnly>
162
+ <EPDialog
163
+ :open="Boolean(preview)"
164
+ title="Import content"
165
+ description="Entries in the file overwrite their counterpart. Nothing is ever deleted."
166
+ @update:open="!$event && closePreview()"
167
+ >
168
+ <div v-if="preview">
169
+ <ul class="ep:m-0 ep:list-none ep:space-y-1 ep:p-0 ep:text-sm ep:text-muted-ep">
170
+ <li><span class="ep:font-semibold ep:text-white">{{ preview.created }}</span> to create</li>
171
+ <li><span class="ep:font-semibold ep:text-white">{{ preview.updated }}</span> to overwrite</li>
172
+ <li><span class="ep:font-semibold ep:text-white">{{ preview.skipped.length }}</span> skipped</li>
173
+ </ul>
174
+ <ul
175
+ v-if="preview.skipped.length"
176
+ class="ep:mt-4 ep:max-h-40 ep:list-none ep:space-y-2 ep:overflow-y-auto ep:p-0 ep:text-xs ep:text-muted-ep"
177
+ >
178
+ <li
179
+ v-for="skipped in preview.skipped"
180
+ :key="skipped.name"
181
+ >
182
+ <span class="ep:font-medium ep:text-white">{{ skipped.name }}</span> — {{ skipped.reason }}
183
+ </li>
184
+ </ul>
185
+ <div class="ep:mt-6 ep:flex ep:justify-end ep:gap-2">
186
+ <EPButton
187
+ label="Cancel"
188
+ variant="ghost"
189
+ @click="closePreview"
190
+ />
191
+ <EPButton
192
+ label="Import"
193
+ variant="primary"
194
+ :loading="importing"
195
+ :disabled="importing || !preview.created && !preview.updated"
196
+ @click="confirmImport"
197
+ />
198
+ </div>
199
+ </div>
200
+ </EPDialog>
201
+ <EPToast
202
+ :open="toast.open"
203
+ :title="toast.title"
204
+ :description="toast.description"
205
+ :variant="toast.variant"
206
+ @update:open="toast.open = $event"
207
+ />
208
+ </ClientOnly>
46
209
  </main>
47
210
  </template>
48
211
 
@@ -1,6 +1,8 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL, readBody, setResponseStatus } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  assertEponymeMutationOrigin(event);
6
8
  const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
@@ -8,7 +10,12 @@ export default defineEventHandler(async (event) => {
8
10
  if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
9
11
  const requestedAction = getQuery(event).action;
10
12
  const action = requestedAction === "draft" ? "draft" : "publish";
11
- const result = await useEponymeService().patch(name, await readBody(event), action, user.id);
13
+ const service = useEponymeService();
14
+ const collection = splitEponymeCollectionEntry(service, name);
15
+ const body = await readBody(event);
16
+ const beforeSave = { name, collection, action, data: body, userId: user.id };
17
+ await callEponymeBlockingHook("eponyme:entry:beforeSave", beforeSave);
18
+ const result = await service.patch(name, beforeSave.data, action, user.id);
12
19
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
13
20
  if ("conflict" in result && result.conflict)
14
21
  throw createError({ statusCode: 409, message: "This entry was changed by someone else. Reload the page to get the latest version." });
@@ -16,5 +23,14 @@ export default defineEventHandler(async (event) => {
16
23
  setResponseStatus(event, 422);
17
24
  return { errors: result.errors };
18
25
  }
26
+ await callEponymeHook(action === "publish" ? "eponyme:entry:published" : "eponyme:entry:saved", {
27
+ name,
28
+ collection,
29
+ action,
30
+ status: result.status,
31
+ publishedAt: result.publishedAt,
32
+ data: result.data,
33
+ userId: user.id
34
+ });
19
35
  return requestedAction ? result : { data: result.data };
20
36
  });
@@ -1,11 +1,16 @@
1
1
  import { createError, defineEventHandler, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  assertEponymeMutationOrigin(event);
6
- await requireEponymeUser(event, { roles: ["editor", "owner"] });
8
+ const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
7
9
  const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-collections\//, ""));
8
- if (!name || !await useEponymeService().deleteCollectionEntry(name))
10
+ const service = useEponymeService();
11
+ const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
12
+ if (!collection || !await service.deleteCollectionEntry(name))
9
13
  throw createError({ statusCode: 404, statusMessage: "Eponyme collection entry not found." });
14
+ await callEponymeHook("eponyme:entry:trashed", { name, collection, userId: user.id });
10
15
  return { deleted: true };
11
16
  });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../services/eponyme-store.js").EponymeExportFile>>;
2
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { defineEventHandler, setResponseHeader } from "h3";
2
+ import { useEponymeService } from "../services/eponyme-service.js";
3
+ import { requireEponymeUser } from "../utils/auth.js";
4
+ export default defineEventHandler(async (event) => {
5
+ await requireEponymeUser(event, { roles: ["editor", "owner"] });
6
+ setResponseHeader(event, "Cache-Control", "no-store");
7
+ const file = await useEponymeService().exportContent();
8
+ const day = file.eponyme.exportedAt.slice(0, 10);
9
+ setResponseHeader(event, "Content-Disposition", `attachment; filename="eponyme-export-${day}.json"`);
10
+ return file;
11
+ });
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getRequestHeader, readRawBody, setResponseHeader, setResponseStatus } from "h3";
2
2
  import { useEponymeFormService } from "../../services/eponyme-form-service.js";
3
3
  import { readEponymeFormRoute } from "../../utils/form-route.js";
4
+ import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
4
5
  export default defineEventHandler(async (event) => {
5
6
  const route = readEponymeFormRoute(event);
6
7
  const service = useEponymeFormService();
@@ -25,12 +26,24 @@ export default defineEventHandler(async (event) => {
25
26
  setResponseStatus(event, 201);
26
27
  return { submitted: true };
27
28
  }
28
- const result = await service.submit(route.name, payload);
29
+ const validated = service.validate(route.name, payload);
30
+ if (validated && "errors" in validated) {
31
+ setResponseStatus(event, 422);
32
+ return { errors: validated.errors };
33
+ }
34
+ const beforeSubmit = { form: route.name, data: validated?.data ?? {} };
35
+ await callEponymeBlockingHook("eponyme:form:beforeSubmit", beforeSubmit);
36
+ const result = await service.submit(route.name, beforeSubmit.data);
29
37
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme form not found." });
30
38
  if ("errors" in result) {
31
39
  setResponseStatus(event, 422);
32
40
  return { errors: result.errors };
33
41
  }
42
+ await callEponymeHook("eponyme:form:submitted", {
43
+ form: route.name,
44
+ data: result.submission.data,
45
+ id: result.submission.id
46
+ });
34
47
  setResponseStatus(event, 201);
35
48
  return { submitted: true, id: result.submission.id };
36
49
  });
@@ -1,2 +1,2 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeResult | import("../../services/eponyme-store.js").EponymeConflict>>;
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeResult>>;
2
2
  export default _default;
@@ -1,6 +1,8 @@
1
1
  import { createError, defineEventHandler, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  assertEponymeMutationOrigin(event);
6
8
  const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
@@ -10,9 +12,19 @@ export default defineEventHandler(async (event) => {
10
12
  const [, name, rawVersionId] = match;
11
13
  const versionId = Number(rawVersionId);
12
14
  if (!Number.isSafeInteger(versionId)) throw createError({ statusCode: 404, statusMessage: "Eponyme version not found." });
13
- const result = await useEponymeService().restore(name, versionId, user.id);
15
+ const service = useEponymeService();
16
+ const result = await service.restore(name, versionId, user.id);
14
17
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme version not found." });
15
- if ("conflict" in result && result.conflict)
18
+ if ("conflict" in result)
16
19
  throw createError({ statusCode: 409, message: "This entry was changed by someone else. Reload the page to get the latest version." });
20
+ await callEponymeHook("eponyme:entry:restored", {
21
+ name,
22
+ collection: splitEponymeCollectionEntry(service, name),
23
+ action: "restore",
24
+ status: result.status,
25
+ publishedAt: result.publishedAt,
26
+ data: result.data,
27
+ userId: user.id
28
+ });
17
29
  return result;
18
30
  });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../services/eponyme-store.js").EponymeImportResult>>;
2
+ export default _default;
@@ -0,0 +1,31 @@
1
+ import { createError, defineEventHandler, getQuery, getRequestHeader, readRawBody, setResponseHeader } from "h3";
2
+ import { useEponymeService } from "../services/eponyme-service.js";
3
+ import { assertEponymeMutationOrigin, requireEponymeUser } from "../utils/auth.js";
4
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
5
+ export default defineEventHandler(async (event) => {
6
+ assertEponymeMutationOrigin(event);
7
+ const user = await requireEponymeUser(event, { roles: ["owner"] });
8
+ setResponseHeader(event, "Cache-Control", "no-store");
9
+ const declaredLength = Number(getRequestHeader(event, "content-length"));
10
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES)
11
+ throw createError({ statusCode: 413, statusMessage: "Import file is too large." });
12
+ const raw = await readRawBody(event, "utf8");
13
+ if (raw && Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES)
14
+ throw createError({ statusCode: 413, statusMessage: "Import file is too large." });
15
+ let payload;
16
+ try {
17
+ payload = raw ? JSON.parse(raw) : void 0;
18
+ } catch {
19
+ throw createError({ statusCode: 400, statusMessage: "Import file must be valid JSON." });
20
+ }
21
+ const dryRun = Boolean(getQuery(event).dryRun);
22
+ const result = await useEponymeService().importContent(payload, { dryRun, actorId: user.id });
23
+ if ("errors" in result) {
24
+ throw createError({
25
+ statusCode: result.schemaMismatch?.length ? 409 : 400,
26
+ statusMessage: result.errors[0] ?? "This import could not be applied.",
27
+ data: { errors: result.errors, schemaMismatch: result.schemaMismatch ?? [] }
28
+ });
29
+ }
30
+ return result;
31
+ });
@@ -0,0 +1,4 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ purged: boolean;
3
+ }>>;
4
+ export default _default;
@@ -0,0 +1,16 @@
1
+ import { createError, defineEventHandler, getRequestURL } from "h3";
2
+ import { useEponymeService } from "../../services/eponyme-service.js";
3
+ import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
6
+ export default defineEventHandler(async (event) => {
7
+ assertEponymeMutationOrigin(event);
8
+ const user = await requireEponymeUser(event, { roles: ["owner"] });
9
+ const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-trash\//, ""));
10
+ const service = useEponymeService();
11
+ const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
12
+ if (!collection || !await service.purgeCollectionEntry(name))
13
+ throw createError({ statusCode: 404, statusMessage: "Eponyme trashed entry not found." });
14
+ await callEponymeHook("eponyme:entry:purged", { name, collection, userId: user.id });
15
+ return { purged: true };
16
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeCollectionPage<Record<string, unknown>>>>;
2
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { createError, defineEventHandler, getRequestURL } from "h3";
2
+ import { useEponymeService } from "../../services/eponyme-service.js";
3
+ import { requireEponymeUser } from "../../utils/auth.js";
4
+ export default defineEventHandler(async (event) => {
5
+ await requireEponymeUser(event, { roles: ["editor", "owner"] });
6
+ const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-trash\//, ""));
7
+ if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
8
+ const page = await useEponymeService().listCollectionTrash(name);
9
+ if (!page) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
10
+ return page;
11
+ });
@@ -0,0 +1,4 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ restored: boolean;
3
+ }>>;
4
+ export default _default;
@@ -0,0 +1,16 @@
1
+ import { createError, defineEventHandler, getRequestURL } from "h3";
2
+ import { useEponymeService } from "../../services/eponyme-service.js";
3
+ import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
6
+ export default defineEventHandler(async (event) => {
7
+ assertEponymeMutationOrigin(event);
8
+ const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
9
+ const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-trash\//, ""));
10
+ const service = useEponymeService();
11
+ const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
12
+ if (!collection || !await service.restoreCollectionEntry(name))
13
+ throw createError({ statusCode: 404, statusMessage: "Eponyme trashed entry not found." });
14
+ await callEponymeHook("eponyme:entry:untrashed", { name, collection, userId: user.id });
15
+ return { restored: true };
16
+ });
@@ -1,5 +1,6 @@
1
1
  import type { EponymeConfig, EponymeFormDefinitionBase } from '../../types/index.js';
2
2
  import { type ValidationErrors } from '../../utils/validate-eponyme-data.js';
3
+ /** Kept here rather than imported, so the store stays free of H3 imports. */
3
4
  type DateValue = Date | string;
4
5
  export interface EponymeFormSubmission {
5
6
  id: string;
@@ -16,7 +16,7 @@ export interface EponymeVersionAuthor {
16
16
  }
17
17
  export interface EponymeHistoryEntry {
18
18
  id: number;
19
- action: EponymeAction | 'restore';
19
+ action: EponymeAction | 'restore' | 'import';
20
20
  status: EponymeStatus;
21
21
  createdAt: string;
22
22
  user: EponymeVersionAuthor | null;
@@ -33,11 +33,47 @@ export interface EponymeCollectionEntry<Data extends Record<string, unknown> = R
33
33
  status: EponymeStatus;
34
34
  publishedAt: string | null;
35
35
  updatedAt: string | null;
36
+ /** Only set for entries read from the trash. */
37
+ deletedAt?: string | null;
36
38
  }
37
39
  export interface EponymeSitemapEntry {
38
40
  loc: string;
39
41
  lastmod?: string;
40
42
  }
43
+ /** One entry of an export file: the complete stored state, without the envelope. */
44
+ export interface EponymeExportEntry {
45
+ name: string;
46
+ /** Set for collection entries, so a reader can group them without re-parsing the name. */
47
+ collection?: string;
48
+ draft: Record<string, unknown>;
49
+ published: Record<string, unknown>;
50
+ status: EponymeStatus;
51
+ publishedAt: string | null;
52
+ }
53
+ export interface EponymeExportFile {
54
+ eponyme: {
55
+ format: 1;
56
+ exportedAt: string;
57
+ /** Schema fingerprint per singleton and per collection, checked before an import writes anything. */
58
+ schemas: Record<string, string>;
59
+ };
60
+ entries: EponymeExportEntry[];
61
+ }
62
+ export interface EponymeImportResult {
63
+ dryRun: boolean;
64
+ created: number;
65
+ updated: number;
66
+ skipped: Array<{
67
+ name: string;
68
+ reason: string;
69
+ }>;
70
+ }
71
+ /** The import was refused as a whole, before any write. */
72
+ export interface EponymeImportRejection {
73
+ errors: string[];
74
+ /** Names whose configured schema differs from the one the file was exported with. */
75
+ schemaMismatch?: string[];
76
+ }
41
77
  export type EponymeSortDirection = 'asc' | 'desc';
42
78
  export interface EponymeCollectionQuery {
43
79
  take?: number;
@@ -55,6 +91,11 @@ export type PrismaEponymeRow = {
55
91
  name: string;
56
92
  data: unknown;
57
93
  updatedAt?: Date | string;
94
+ deletedAt?: Date | string | null;
95
+ };
96
+ /** `null` keeps the live rows, `{ not: null }` the trashed ones. */
97
+ export type PrismaDeletedAtFilter = null | {
98
+ not: null;
58
99
  };
59
100
  export type PrismaEponymeVersionRow = {
60
101
  id: number;
@@ -96,9 +137,11 @@ export type PrismaEponymeClient = {
96
137
  where: {
97
138
  name: string;
98
139
  updatedAt?: Date | string;
140
+ deletedAt?: PrismaDeletedAtFilter;
99
141
  };
100
142
  data: {
101
- data: Record<string, unknown>;
143
+ data?: Record<string, unknown>;
144
+ deletedAt?: Date | null;
102
145
  };
103
146
  }): Promise<{
104
147
  count: number;
@@ -119,6 +162,7 @@ export type PrismaEponymeClient = {
119
162
  name: {
120
163
  startsWith: string;
121
164
  };
165
+ deletedAt?: PrismaDeletedAtFilter;
122
166
  };
123
167
  orderBy: Array<{
124
168
  updatedAt: 'desc';
@@ -197,13 +241,40 @@ export declare class EponymeService {
197
241
  * one query per collection rather than one query per entry.
198
242
  */
199
243
  getSitemapEntries(previewPaths: Record<string, string>): Promise<EponymeSitemapEntry[]>;
244
+ /**
245
+ * Every singleton and every live collection entry, as a portable file.
246
+ * Collections are read with one query each rather than one per entry.
247
+ */
248
+ exportContent(): Promise<EponymeExportFile>;
249
+ /**
250
+ * Applies an export file on top of the current content: every entry it carries is
251
+ * upserted, everything else is left alone. Nothing is ever deleted.
252
+ *
253
+ * The schema check runs first and refuses the whole file, so an import can never
254
+ * land half of its entries into a configuration that no longer matches.
255
+ */
256
+ importContent(file: unknown, options?: {
257
+ dryRun?: boolean;
258
+ actorId?: string;
259
+ }): Promise<EponymeImportResult | EponymeImportRejection>;
200
260
  createCollectionEntry(name: string, payload: unknown, actorId?: string): Promise<(EponymeResult & {
201
261
  slug: string;
202
262
  errors?: never;
203
263
  }) | {
204
264
  errors: ValidationErrors;
205
265
  } | undefined>;
266
+ /**
267
+ * A trashed entry keeps its row, so it keeps its slug: reusing one has to be
268
+ * refused with an error the editor can act on rather than the generic message.
269
+ */
270
+ private slugTakenError;
271
+ /** Moves an entry to the trash. Its history is kept, so it stays restorable. */
206
272
  deleteCollectionEntry(name: string): Promise<boolean>;
273
+ restoreCollectionEntry(name: string): Promise<boolean>;
274
+ /** Definitive: `onDelete: Cascade` takes the entry's whole history with it. */
275
+ purgeCollectionEntry(name: string): Promise<boolean>;
276
+ /** Trashed entries of a collection, most recently updated first. */
277
+ listCollectionTrash(name: string): Promise<EponymeCollectionPage | undefined>;
207
278
  patch(name: string, payload: unknown, action?: EponymeAction, actorId?: string): Promise<(EponymeResult & {
208
279
  errors?: never;
209
280
  conflict?: never;
@@ -214,4 +285,6 @@ export declare class EponymeService {
214
285
  history(name: string, limit?: number): Promise<EponymeHistoryEntry[] | undefined>;
215
286
  restore(name: string, versionId: number, actorId?: string): Promise<EponymeResult | EponymeConflict | undefined>;
216
287
  }
288
+ /** Stable hash of a schema's shape, embedded in an export and checked on import. */
289
+ export declare function schemaFingerprint(schema: EponymeSchema): string;
217
290
  export declare function isPrismaError(error: unknown, code: string): boolean;