@acms-subir/core 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/sections.mjs +186 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +60 -1
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +4 -2
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +19 -171
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/editor/PostEditor.tsx +174 -10
- package/src/components/import/ImportExport.tsx +522 -0
- package/src/components/import/runner.ts +285 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +6 -1
- package/src/layouts/Site.astro +107 -19
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/content-service.ts +198 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +459 -0
- package/src/lib/import/bundle.ts +171 -0
- package/src/lib/import/export.ts +55 -0
- package/src/lib/import/ghost.ts +248 -0
- package/src/lib/import/lexical.ts +290 -0
- package/src/lib/import/migration.ts +27 -0
- package/src/lib/import/zipread.ts +72 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +19 -11
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +8 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +13 -0
- package/src/lib/repo/sql.ts +156 -9
- package/src/lib/repo/types.ts +26 -3
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +27 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +217 -0
- package/src/lib/theme.ts +16 -1
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +32 -0
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/import.astro +16 -0
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/settings.astro +11 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/export.ts +41 -0
- package/src/pages/api/import/asset.ts +32 -0
- package/src/pages/api/import/index.ts +47 -0
- package/src/pages/api/import/media.ts +31 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +69 -0
- package/src/styles/editor.css +21 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/sections.css +7 -1
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +326 -0
- package/src/theme-sdk/index.ts +10 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +1 -0
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { LiveStore } from '../live/store';
|
|
2
|
+
import { PushStore } from '../push/store';
|
|
3
|
+
import { EditorStore } from '../editor/store';
|
|
2
4
|
import { LiveConfig } from '../live/types';
|
|
3
5
|
import { ApiError } from '../api';
|
|
4
6
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
5
|
-
import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
|
|
6
|
-
import { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
7
|
+
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
8
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
7
9
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
8
10
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
9
11
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -43,6 +45,11 @@ function parseDraft(raw: unknown): Record<string, unknown> | null {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// translations column: tolerate old rows / junk (never break a page render)
|
|
49
|
+
function parseTranslations(v: unknown) {
|
|
50
|
+
const parsed = DocTranslations.safeParse(json(v, {}));
|
|
51
|
+
return parsed.success ? parsed.data : {};
|
|
52
|
+
}
|
|
46
53
|
function rowToPage(r: Row): Page {
|
|
47
54
|
return Page.parse({
|
|
48
55
|
id: r.id, slug: r.slug, title: r.title, status: r.status,
|
|
@@ -50,7 +57,7 @@ function rowToPage(r: Row): Page {
|
|
|
50
57
|
blocks: JSON.parse(String(r.blocks ?? '[]')),
|
|
51
58
|
bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
52
59
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
|
|
53
|
-
template: r.template ?? '', draft: parseDraft(r.draft),
|
|
60
|
+
template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
54
61
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
55
62
|
});
|
|
56
63
|
}
|
|
@@ -62,12 +69,26 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
|
|
|
62
69
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
|
|
63
70
|
featured: Boolean(r.featured), authorId: r.author_id ?? null,
|
|
64
71
|
authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
|
|
65
|
-
template: r.template ?? '', draft: parseDraft(r.draft),
|
|
72
|
+
template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
66
73
|
publishedAt: r.published_at === null ? null : Number(r.published_at),
|
|
67
74
|
scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
|
|
68
75
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
69
76
|
});
|
|
70
77
|
}
|
|
78
|
+
function rowToImportRun(r: Row): ImportRun {
|
|
79
|
+
let report: Record<string, unknown> = {};
|
|
80
|
+
try { report = JSON.parse(String(r.report ?? '{}')); } catch { /* keep empty */ }
|
|
81
|
+
return {
|
|
82
|
+
id: String(r.id), source: String(r.source), label: String(r.label ?? ''), status: String(r.status) as ImportRun['status'],
|
|
83
|
+
report, createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function rowToImportRecord(r: Row): ImportRecord {
|
|
87
|
+
return {
|
|
88
|
+
runId: String(r.run_id), kind: String(r.kind) as ImportRecordKind, sourceId: String(r.source_id), acmsId: String(r.acms_id),
|
|
89
|
+
action: String(r.action) as ImportRecord['action'], stamp: Number(r.stamp ?? 0),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
71
92
|
function rowToSnippet(r: Row): Snippet {
|
|
72
93
|
return Snippet.parse({
|
|
73
94
|
id: r.id, name: r.name, contentJson: r.content_json ?? '[]', createdAt: Number(r.created_at),
|
|
@@ -124,7 +145,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
124
145
|
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
|
|
125
146
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
126
147
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
|
|
127
|
-
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft),
|
|
148
|
+
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
128
149
|
publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
|
|
129
150
|
scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
|
|
130
151
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
@@ -219,10 +240,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
219
240
|
private ready: Promise<void>;
|
|
220
241
|
readonly email: EmailStore;
|
|
221
242
|
readonly live: LiveStore;
|
|
243
|
+
readonly push: PushStore;
|
|
244
|
+
readonly editor: EditorStore;
|
|
222
245
|
|
|
223
246
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
224
247
|
this.ready = this.init(driver);
|
|
225
248
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
249
|
+
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
250
|
+
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
226
251
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
227
252
|
}
|
|
228
253
|
|
|
@@ -452,6 +477,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
452
477
|
if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
|
|
453
478
|
if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
|
|
454
479
|
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
480
|
+
if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
|
|
455
481
|
if (data.blocks !== undefined) { sets.push('blocks = ?'); args.push(JSON.stringify(data.blocks)); }
|
|
456
482
|
if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
|
|
457
483
|
if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
|
|
@@ -584,6 +610,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
584
610
|
if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
|
|
585
611
|
if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
|
|
586
612
|
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
613
|
+
if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
|
|
587
614
|
// authorIds is the source of truth; author_id mirrors the first entry.
|
|
588
615
|
// Main row, join rows and webhook notifications share one transaction.
|
|
589
616
|
let authorJoin: string[] | null = null;
|
|
@@ -818,7 +845,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
818
845
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
819
846
|
}
|
|
820
847
|
const jsonCols: Record<string, string> = {
|
|
821
|
-
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
|
|
848
|
+
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
|
|
822
849
|
};
|
|
823
850
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
824
851
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -1577,7 +1604,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1577
1604
|
// Suppression and re-enabling share the restore transaction. A failed
|
|
1578
1605
|
// restore cannot leave notifications disabled or emit phantom deletes.
|
|
1579
1606
|
{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
|
|
1580
|
-
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests'].map((t) => ({
|
|
1607
|
+
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records'].map((t) => ({
|
|
1581
1608
|
sql: `DELETE FROM ${t}`,
|
|
1582
1609
|
})),
|
|
1583
1610
|
...(content.calendars ?? []).map((c) => ({
|
|
@@ -1619,6 +1646,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1619
1646
|
args: [p.id, aid, i] as unknown[],
|
|
1620
1647
|
}))
|
|
1621
1648
|
),
|
|
1649
|
+
// translations (v36): written after the rows so older backups need no change
|
|
1650
|
+
...[['pages', content.pages], ['posts', content.posts], ['events', content.events ?? []]].flatMap(([table, rows]) =>
|
|
1651
|
+
(rows as { id: string; translations?: Record<string, unknown> }[]).filter((r) => r.translations && Object.keys(r.translations).length)
|
|
1652
|
+
.map((r) => ({ sql: `UPDATE ${table} SET translations = ? WHERE id = ?`, args: [JSON.stringify(r.translations), r.id] as unknown[] }))),
|
|
1622
1653
|
...content.media.map((m) => ({
|
|
1623
1654
|
sql: 'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
1624
1655
|
args: [m.id, m.key, m.filename, m.mime, m.width, m.height, m.size, m.alt, JSON.stringify(m.variants), m.createdAt],
|
|
@@ -1656,6 +1687,106 @@ export class SqlRepository implements ContentRepository {
|
|
|
1656
1687
|
await this.reindexSearch();
|
|
1657
1688
|
}
|
|
1658
1689
|
|
|
1690
|
+
// ---- imports ----
|
|
1691
|
+
async createImportRun({ id, source, label }: Pick<ImportRun, 'id' | 'source' | 'label'>) {
|
|
1692
|
+
await this.ready;
|
|
1693
|
+
const now = Date.now();
|
|
1694
|
+
await this.driver.run('INSERT INTO import_runs (id, source, label, status, report, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, source, label, 'running', '{}', now, now]);
|
|
1695
|
+
}
|
|
1696
|
+
async updateImportRun(id: string, data: Partial<Pick<ImportRun, 'status' | 'report'>>) {
|
|
1697
|
+
await this.ready;
|
|
1698
|
+
const sets = ['updated_at = ?'];
|
|
1699
|
+
const args: unknown[] = [Date.now()];
|
|
1700
|
+
if (data.status !== undefined) { sets.push('status = ?'); args.push(data.status); }
|
|
1701
|
+
if (data.report !== undefined) { sets.push('report = ?'); args.push(JSON.stringify(data.report)); }
|
|
1702
|
+
await this.driver.run(`UPDATE import_runs SET ${sets.join(', ')} WHERE id = ?`, [...args, id]);
|
|
1703
|
+
}
|
|
1704
|
+
async listImportRuns() {
|
|
1705
|
+
await this.ready;
|
|
1706
|
+
return (await this.driver.all('SELECT * FROM import_runs ORDER BY created_at DESC')).map(rowToImportRun);
|
|
1707
|
+
}
|
|
1708
|
+
async getImportRun(id: string) {
|
|
1709
|
+
await this.ready;
|
|
1710
|
+
const r = await this.first('SELECT * FROM import_runs WHERE id = ?', [id]);
|
|
1711
|
+
return r ? rowToImportRun(r) : null;
|
|
1712
|
+
}
|
|
1713
|
+
async addImportRecords(records: ImportRecord[]) {
|
|
1714
|
+
await this.ready;
|
|
1715
|
+
const now = Date.now();
|
|
1716
|
+
await this.driver.batch(records.map((r) => ({
|
|
1717
|
+
sql: 'INSERT INTO import_records (run_id, kind, source_id, acms_id, action, stamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, kind, source_id) DO UPDATE SET acms_id = excluded.acms_id, stamp = excluded.stamp',
|
|
1718
|
+
args: [r.runId, r.kind, r.sourceId, r.acmsId, r.action, r.stamp, now],
|
|
1719
|
+
})));
|
|
1720
|
+
}
|
|
1721
|
+
async listImportRecords(runId: string) {
|
|
1722
|
+
await this.ready;
|
|
1723
|
+
return (await this.driver.all('SELECT * FROM import_records WHERE run_id = ? ORDER BY created_at ASC', [runId])).map(rowToImportRecord);
|
|
1724
|
+
}
|
|
1725
|
+
async findImportRecords(kind: ImportRecordKind, sourceIds: string[]) {
|
|
1726
|
+
await this.ready;
|
|
1727
|
+
if (!sourceIds.length) return [];
|
|
1728
|
+
const out: ImportRecord[] = [];
|
|
1729
|
+
// chunked to stay under SQLite's bound-parameter limit
|
|
1730
|
+
for (let i = 0; i < sourceIds.length; i += 200) {
|
|
1731
|
+
const chunk = sourceIds.slice(i, i + 200);
|
|
1732
|
+
const rows = await this.driver.all(
|
|
1733
|
+
`SELECT r.* FROM import_records r JOIN import_runs u ON u.id = r.run_id
|
|
1734
|
+
WHERE u.status != 'undone' AND r.kind = ? AND r.source_id IN (${chunk.map(() => '?').join(', ')})
|
|
1735
|
+
ORDER BY r.created_at ASC`,
|
|
1736
|
+
[kind, ...chunk]
|
|
1737
|
+
);
|
|
1738
|
+
out.push(...rows.map(rowToImportRecord));
|
|
1739
|
+
}
|
|
1740
|
+
// later runs win
|
|
1741
|
+
return [...new Map(out.map((r) => [r.sourceId, r])).values()];
|
|
1742
|
+
}
|
|
1743
|
+
async writeImported(data: { posts?: Post[]; pages?: Page[]; deletePosts?: string[]; deletePages?: string[] }) {
|
|
1744
|
+
await this.ready;
|
|
1745
|
+
const posts = data.posts ?? [], pages = data.pages ?? [];
|
|
1746
|
+
const stmts: SqlStatement[] = [{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' }];
|
|
1747
|
+
for (const p of posts) {
|
|
1748
|
+
stmts.push({
|
|
1749
|
+
sql: `INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, draft, published_at, scheduled_at, created_at, updated_at)
|
|
1750
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1751
|
+
ON CONFLICT(id) DO UPDATE SET collection = excluded.collection, slug = excluded.slug, title = excluded.title, body = excluded.body,
|
|
1752
|
+
body_json = excluded.body_json, body_html = excluded.body_html, excerpt = excluded.excerpt, tags = excluded.tags, cover = excluded.cover,
|
|
1753
|
+
cover_alt = excluded.cover_alt, seo_title = excluded.seo_title, seo_desc = excluded.seo_desc, status = excluded.status,
|
|
1754
|
+
featured = excluded.featured, author_id = excluded.author_id, template = excluded.template, draft = NULL,
|
|
1755
|
+
published_at = excluded.published_at, scheduled_at = excluded.scheduled_at, updated_at = excluded.updated_at`,
|
|
1756
|
+
args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? null, p.template, null, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
|
|
1757
|
+
});
|
|
1758
|
+
stmts.push({ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [p.id] });
|
|
1759
|
+
p.authorIds.forEach((aid, i) => stmts.push({ sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)', args: [p.id, aid, i] }));
|
|
1760
|
+
}
|
|
1761
|
+
for (const p of pages) {
|
|
1762
|
+
stmts.push({
|
|
1763
|
+
sql: `INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, draft, created_at, updated_at)
|
|
1764
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1765
|
+
ON CONFLICT(id) DO UPDATE SET slug = excluded.slug, title = excluded.title, status = excluded.status, locale = excluded.locale,
|
|
1766
|
+
blocks = excluded.blocks, body_json = excluded.body_json, body_html = excluded.body_html, seo_title = excluded.seo_title,
|
|
1767
|
+
seo_desc = excluded.seo_desc, template = excluded.template, draft = NULL, updated_at = excluded.updated_at`,
|
|
1768
|
+
args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, null, p.createdAt, p.updatedAt],
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
for (const id of data.deletePosts ?? []) {
|
|
1772
|
+
stmts.push(
|
|
1773
|
+
{ sql: 'DELETE FROM posts WHERE id = ?', args: [id] },
|
|
1774
|
+
{ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
|
|
1775
|
+
{ sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] },
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
for (const id of data.deletePages ?? []) {
|
|
1779
|
+
stmts.push(
|
|
1780
|
+
{ sql: 'DELETE FROM pages WHERE id = ?', args: [id] },
|
|
1781
|
+
{ sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] },
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
stmts.push({ sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' });
|
|
1785
|
+
await this.driver.batch(stmts);
|
|
1786
|
+
for (const p of posts) await this.syncPostIndex(p.id);
|
|
1787
|
+
for (const p of pages) await this.syncPageIndex(p.id);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1659
1790
|
// ---- redirects ----
|
|
1660
1791
|
async listRedirects() {
|
|
1661
1792
|
await this.ready;
|
|
@@ -1704,6 +1835,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1704
1835
|
timezone: r?.timezone ?? '', locale: r?.locale ?? '',
|
|
1705
1836
|
ai: JSON.parse(String(r?.ai ?? '{}')),
|
|
1706
1837
|
live: JSON.parse(String(r?.live ?? '{}')),
|
|
1838
|
+
push: JSON.parse(String(r?.push ?? '{}')),
|
|
1707
1839
|
agent: JSON.parse(String(r?.agent ?? '{}')),
|
|
1708
1840
|
draftsApiKey: r?.drafts_api_key ?? '',
|
|
1709
1841
|
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
@@ -1720,9 +1852,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1720
1852
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1721
1853
|
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1722
1854
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
1723
|
-
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
|
|
1855
|
+
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
|
|
1724
1856
|
};
|
|
1725
|
-
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
|
|
1857
|
+
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
|
|
1726
1858
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1727
1859
|
if (!entries.length) return;
|
|
1728
1860
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
|
|
@@ -1787,6 +1919,21 @@ export class SqlRepository implements ContentRepository {
|
|
|
1787
1919
|
} }));
|
|
1788
1920
|
await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
|
|
1789
1921
|
}
|
|
1922
|
+
async publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }) {
|
|
1923
|
+
const current = await this.getSettings();
|
|
1924
|
+
const liveNav = await this.listNavItems();
|
|
1925
|
+
const draft: Record<string, unknown> = { ...(current.draftDesign ?? {}) };
|
|
1926
|
+
if (staged) draft.sectionPages = staged; else delete draft.sectionPages;
|
|
1927
|
+
// a draft with nothing left to publish goes away entirely
|
|
1928
|
+
const remaining = Object.keys(pickDesignDraft(draft)).length > 0 || Array.isArray(draft.navItems);
|
|
1929
|
+
const statements: SqlStatement[] = [
|
|
1930
|
+
{ sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
|
|
1931
|
+
{ sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
|
|
1932
|
+
{ sql: 'UPDATE settings SET section_pages=?,draft_design=? WHERE id=1', args: [JSON.stringify(live), remaining ? JSON.stringify({ ...draft, savedAt: Date.now() }) : null] },
|
|
1933
|
+
webhookStatement('design.published', { design: { revision: current.designRevision + 1, activeTheme: current.activeTheme, navigationChanged: false, sectionPageIds: Object.keys(live) } }),
|
|
1934
|
+
];
|
|
1935
|
+
await this.designWrite(opts.expectedRevision, opts.author ?? 'admin', 'publish', statements);
|
|
1936
|
+
}
|
|
1790
1937
|
async discardDesignDraft() { await this.setDesignDraft(null); }
|
|
1791
1938
|
async listDesignVersions(limit = 20) {
|
|
1792
1939
|
await this.ready;
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { LiveStore } from '../live/store';
|
|
2
2
|
import type { BookingRequest } from '../backup/content';
|
|
3
3
|
import type { EmailStore } from '../email/store';
|
|
4
|
+
import type { PushStore } from '../push/store';
|
|
5
|
+
import type { EditorStore } from '../editor/store';
|
|
6
|
+
export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
|
|
7
|
+
export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
|
|
8
|
+
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
4
9
|
export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
|
|
5
10
|
|
|
6
11
|
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
|
|
@@ -34,7 +39,7 @@ export type EventUpdate = Partial<
|
|
|
34
39
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
35
40
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
36
41
|
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
|
|
37
|
-
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
|
|
42
|
+
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations'
|
|
38
43
|
| 'publishedAt' | 'scheduledAt'
|
|
39
44
|
>
|
|
40
45
|
>;
|
|
@@ -44,6 +49,8 @@ export type EventUpdate = Partial<
|
|
|
44
49
|
export interface ContentRepository {
|
|
45
50
|
readonly email: EmailStore;
|
|
46
51
|
readonly live: LiveStore;
|
|
52
|
+
readonly push: PushStore;
|
|
53
|
+
readonly editor: EditorStore;
|
|
47
54
|
// Bounded public catalog; fetch limit + 1 IDs without loading every body.
|
|
48
55
|
listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
|
|
49
56
|
exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
|
|
@@ -57,7 +64,7 @@ export interface ContentRepository {
|
|
|
57
64
|
createPage(data: { title: string; slug: string }): Promise<Page>;
|
|
58
65
|
updatePage(
|
|
59
66
|
id: string,
|
|
60
|
-
data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template'>> & {
|
|
67
|
+
data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template' | 'translations'>> & {
|
|
61
68
|
blocks?: WidgetInstance[];
|
|
62
69
|
}
|
|
63
70
|
): Promise<void>;
|
|
@@ -87,7 +94,7 @@ export interface ContentRepository {
|
|
|
87
94
|
| 'title' | 'slug' | 'collection' | 'body' | 'bodyJson' | 'bodyHtml' | 'excerpt'
|
|
88
95
|
| 'tags' | 'cover' | 'coverAlt' | 'seoTitle' | 'seoDesc'
|
|
89
96
|
| 'status' | 'featured' | 'authorId' | 'authorIds' | 'template'
|
|
90
|
-
| 'publishedAt' | 'scheduledAt'
|
|
97
|
+
| 'publishedAt' | 'scheduledAt' | 'translations'
|
|
91
98
|
>
|
|
92
99
|
>
|
|
93
100
|
): Promise<void>;
|
|
@@ -260,6 +267,20 @@ export interface ContentRepository {
|
|
|
260
267
|
bookingRequests?: BookingRequest[];
|
|
261
268
|
}): Promise<void>;
|
|
262
269
|
|
|
270
|
+
// imports (lib/import/apply.ts drives these). writeImported is the
|
|
271
|
+
// id-preserving bulk write for imported posts/pages: original timestamps
|
|
272
|
+
// are kept and publication webhooks stay silent (a migration of old posts
|
|
273
|
+
// is not news), in one transaction with the search index refreshed after.
|
|
274
|
+
createImportRun(run: Pick<ImportRun, 'id' | 'source' | 'label'>): Promise<void>;
|
|
275
|
+
updateImportRun(id: string, data: Partial<Pick<ImportRun, 'status' | 'report'>>): Promise<void>;
|
|
276
|
+
listImportRuns(): Promise<ImportRun[]>;
|
|
277
|
+
getImportRun(id: string): Promise<ImportRun | null>;
|
|
278
|
+
addImportRecords(records: ImportRecord[]): Promise<void>;
|
|
279
|
+
listImportRecords(runId: string): Promise<ImportRecord[]>;
|
|
280
|
+
// latest record per source id across runs that weren't undone
|
|
281
|
+
findImportRecords(kind: ImportRecordKind, sourceIds: string[]): Promise<ImportRecord[]>;
|
|
282
|
+
writeImported(data: { posts?: Post[]; pages?: Page[]; deletePosts?: string[]; deletePages?: string[] }): Promise<void>;
|
|
283
|
+
|
|
263
284
|
// redirects — exact-path forwards, consulted when a request would 404.
|
|
264
285
|
// createRedirect upserts on fromPath and prunes self/loop entries.
|
|
265
286
|
listRedirects(): Promise<Redirect[]>;
|
|
@@ -281,6 +302,8 @@ export interface ContentRepository {
|
|
|
281
302
|
// publish: snapshot the outgoing live design into design_versions (last 20
|
|
282
303
|
// kept), apply the draft's settings + staged navigation, clear the draft
|
|
283
304
|
applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
|
|
305
|
+
/** publish section layouts only (one section's edits); the rest of the draft stays staged */
|
|
306
|
+
publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }): Promise<void>;
|
|
284
307
|
discardDesignDraft(): Promise<void>;
|
|
285
308
|
listDesignVersions(limit?: number): Promise<DesignVersion[]>;
|
|
286
309
|
getDesignVersion(id: string): Promise<DesignVersion | null>;
|
package/src/lib/roles.ts
ADDED
package/src/lib/scheduler.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ContentRepository } from './repo/types';
|
|
|
3
3
|
import { processDueDeliveries } from './webhooks';
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
5
|
import { sendDueReminders } from './booking/service';
|
|
6
|
+
import { runPushJobs } from './push/service';
|
|
6
7
|
|
|
7
8
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
8
9
|
// this heartbeat: monitoring must prove the independent scheduler ran.
|
|
@@ -15,6 +16,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
15
16
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
16
17
|
email: () => dispatchEmail(repo, locals, 10),
|
|
17
18
|
live: () => syncDueLive(repo, locals),
|
|
19
|
+
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
20
|
+
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
18
21
|
};
|
|
19
22
|
const results: Record<string, { ok: boolean; count?: number }> = {};
|
|
20
23
|
for (const [name, run] of Object.entries(jobs)) {
|
package/src/lib/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LiveConfig } from './live/types';
|
|
2
|
+
import { PushSettings } from './push/types';
|
|
2
3
|
import { SectionPages } from '../theme-sdk/contract.mjs';
|
|
3
4
|
import { AiConfig, AgentConfig } from './ai-schema';
|
|
4
5
|
import { z } from 'zod';
|
|
@@ -64,6 +65,26 @@ export function parseConfig<T extends WidgetType>(type: T, raw: unknown): z.infe
|
|
|
64
65
|
export const Status = z.enum(['draft', 'scheduled', 'published', 'archived']);
|
|
65
66
|
export type Status = z.infer<typeof Status>;
|
|
66
67
|
|
|
68
|
+
// Per-language words for a post, page or event (the facts stay shared):
|
|
69
|
+
// translations: { es: { title, excerpt, bodyHtml, seoTitle, seoDesc, slug,
|
|
70
|
+
// locationName, ctaLabel, meta: { title: { state, src } } } }
|
|
71
|
+
// state 'ai' = machine draft (shown, flagged for review), 'reviewed' = a person
|
|
72
|
+
// wrote/approved it; src = hash of the main-language text it was made from.
|
|
73
|
+
export const DocTranslation = z.object({
|
|
74
|
+
title: z.string().max(1000).default(''),
|
|
75
|
+
excerpt: z.string().max(5000).default(''),
|
|
76
|
+
bodyHtml: z.string().max(2_000_000).default(''),
|
|
77
|
+
seoTitle: z.string().max(1000).default(''),
|
|
78
|
+
seoDesc: z.string().max(5000).default(''),
|
|
79
|
+
slug: z.string().max(300).default(''),
|
|
80
|
+
locationName: z.string().max(500).default(''),
|
|
81
|
+
ctaLabel: z.string().max(300).default(''),
|
|
82
|
+
meta: z.record(z.string().max(40), z.object({ state: z.enum(['ai', 'reviewed']), src: z.string().max(20) })).default({}),
|
|
83
|
+
});
|
|
84
|
+
export type DocTranslation = z.infer<typeof DocTranslation>;
|
|
85
|
+
export const DocTranslations = z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), DocTranslation).default({});
|
|
86
|
+
export type DocTranslations = z.infer<typeof DocTranslations>;
|
|
87
|
+
|
|
67
88
|
export const Page = z.object({
|
|
68
89
|
id: z.string(),
|
|
69
90
|
slug: z.string(),
|
|
@@ -83,6 +104,7 @@ export const Page = z.object({
|
|
|
83
104
|
// theme template name (templates/page-<name>.astro); empty = default layout
|
|
84
105
|
template: z.string().default(''),
|
|
85
106
|
// pending edits on a live page (see lib/drafts.ts); null = none staged
|
|
107
|
+
translations: DocTranslations,
|
|
86
108
|
draft: z.record(z.any()).nullable().default(null),
|
|
87
109
|
createdAt: z.number(),
|
|
88
110
|
updatedAt: z.number(),
|
|
@@ -117,6 +139,7 @@ export const Post = z.object({
|
|
|
117
139
|
// theme template name (templates/post-<name>.astro); empty = default layout
|
|
118
140
|
template: z.string().default(''),
|
|
119
141
|
// pending edits on a live post (see lib/drafts.ts); null = none staged
|
|
142
|
+
translations: DocTranslations,
|
|
120
143
|
draft: z.record(z.any()).nullable().default(null),
|
|
121
144
|
publishedAt: z.number().nullable(),
|
|
122
145
|
scheduledAt: z.number().nullable().default(null),
|
|
@@ -171,7 +194,7 @@ export const Snippet = z.object({
|
|
|
171
194
|
});
|
|
172
195
|
export type Snippet = z.infer<typeof Snippet>;
|
|
173
196
|
|
|
174
|
-
export const Role = z.enum(['
|
|
197
|
+
export const Role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
|
|
175
198
|
export type Role = z.infer<typeof Role>;
|
|
176
199
|
|
|
177
200
|
// Password hashes never leave the repo layer — User is the public shape.
|
|
@@ -334,6 +357,7 @@ export const CalEvent = z.object({
|
|
|
334
357
|
}).default({}),
|
|
335
358
|
startAt: z.number(),
|
|
336
359
|
endAt: z.number(),
|
|
360
|
+
translations: DocTranslations,
|
|
337
361
|
draft: z.record(z.any()).nullable().default(null),
|
|
338
362
|
publishedAt: z.number().nullable().default(null),
|
|
339
363
|
scheduledAt: z.number().nullable().default(null),
|
|
@@ -560,6 +584,8 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
560
584
|
|
|
561
585
|
export const Settings = z.object({
|
|
562
586
|
live: LiveConfig.default({}),
|
|
587
|
+
// push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
|
|
588
|
+
push: PushSettings.default({}),
|
|
563
589
|
siteTitle: z.string().default('My Site'),
|
|
564
590
|
tagline: z.string().default(''),
|
|
565
591
|
// site-wide footer, markdown allowed; empty falls back to "title — tagline"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import sanitizeHtml from 'sanitize-html';
|
|
3
|
+
import type { AiConfig } from './ai-schema';
|
|
4
|
+
import { completeText } from './ai';
|
|
5
|
+
import { ApiError } from './api';
|
|
6
|
+
|
|
7
|
+
// "Help me write" for section fields: rewrite one field's text with the
|
|
8
|
+
// site's configured AI provider. The owner reviews the suggestion before it
|
|
9
|
+
// replaces anything; nothing is saved here.
|
|
10
|
+
|
|
11
|
+
export const AssistInput = z.object({
|
|
12
|
+
action: z.enum(['shorter', 'friendlier', 'clearer', 'translate', 'draft']),
|
|
13
|
+
text: z.string().max(20_000).default(''),
|
|
14
|
+
label: z.string().max(100),
|
|
15
|
+
kind: z.enum(['text', 'textarea', 'richtext']).default('text'),
|
|
16
|
+
hint: z.string().max(300).optional(),
|
|
17
|
+
maxLength: z.number().int().min(1).max(20_000).optional(),
|
|
18
|
+
language: z.string().max(40).optional(),
|
|
19
|
+
// other filled-in fields of the same section, for context ("Heading: Join us")
|
|
20
|
+
context: z.string().max(4000).optional(),
|
|
21
|
+
}).strict();
|
|
22
|
+
|
|
23
|
+
const ACTIONS: Record<z.infer<typeof AssistInput>['action'], string> = {
|
|
24
|
+
shorter: 'Make it noticeably shorter while keeping every fact (names, days, times, places, prices).',
|
|
25
|
+
friendlier: 'Make it warmer and more welcoming, plain words, no hype or exclamation marks.',
|
|
26
|
+
clearer: 'Fix spelling and grammar and make it clearer. Change as little as possible.',
|
|
27
|
+
translate: 'Translate it into the requested language. Keep names of people and places as they are.',
|
|
28
|
+
draft: 'Write it from scratch for this field, using the other fields as context. Be concrete and plain.',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export async function assistField(config: AiConfig, raw: unknown): Promise<{ text: string }> {
|
|
32
|
+
const a = AssistInput.parse(raw);
|
|
33
|
+
if (a.action !== 'draft' && !a.text.trim()) throw new ApiError(400, 'Write something first, or use “Write a first draft”.');
|
|
34
|
+
if (a.action === 'translate' && !a.language) throw new ApiError(400, 'Choose a language.');
|
|
35
|
+
const rules = [
|
|
36
|
+
'You edit short text for a section of a website (for example a church, club or small business homepage).',
|
|
37
|
+
`The field is “${a.label}”.`,
|
|
38
|
+
ACTIONS[a.action],
|
|
39
|
+
a.action === 'translate' ? `Target language: ${a.language}.` : 'Keep the original language.',
|
|
40
|
+
a.hint ? `The site owner's guidance for this field: ${a.hint}` : '',
|
|
41
|
+
a.maxLength ? `Stay under ${a.maxLength} characters.` : '',
|
|
42
|
+
a.kind === 'richtext' ? 'Return simple HTML using only <p>, <strong>, <em>, <a href>, <ul>, <ol>, <li>. Keep existing links.' : 'Return plain text only.',
|
|
43
|
+
a.kind === 'text' ? 'One line, no line breaks.' : '',
|
|
44
|
+
'Return only the new text: no quotes, labels, notes or alternatives. Never invent facts, dates, prices or contact details.',
|
|
45
|
+
].filter(Boolean).join('\n');
|
|
46
|
+
const input = [a.context ? `Other fields in this section:\n${a.context}` : '', a.text ? `Current text:\n${a.text}` : 'The field is empty.'].filter(Boolean).join('\n\n');
|
|
47
|
+
let out = (await completeText(config, rules, input)).replace(/^```\w*\n?|\n?```$/g, '').trim();
|
|
48
|
+
if (/^["“].*["”]$/s.test(out)) out = out.slice(1, -1).trim();
|
|
49
|
+
out = a.kind === 'richtext'
|
|
50
|
+
? sanitizeHtml(out, { allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li'], allowedAttributes: { a: ['href'] }, allowedSchemes: ['http', 'https', 'mailto', 'tel'] })
|
|
51
|
+
: sanitizeHtml(out, { allowedTags: [], allowedAttributes: {} }).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
|
|
52
|
+
if (a.kind === 'text') out = out.replace(/\s*\n+\s*/g, ' ');
|
|
53
|
+
if (!out) throw new ApiError(502, 'The AI returned nothing useful. Try again.');
|
|
54
|
+
return { text: out };
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ContentRepository } from './repo/types';
|
|
2
|
+
import { CONTENT, collectionUrl } from './content';
|
|
3
|
+
import { I18N, extraLocales, localeName } from './i18n';
|
|
4
|
+
import { translationEngine } from './translate';
|
|
5
|
+
import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl } from './calendar/config';
|
|
6
|
+
|
|
7
|
+
const NAMES: Record<string, string> = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ko: 'Korean', zh: 'Chinese', ja: 'Japanese', tl: 'Tagalog', vi: 'Vietnamese', ar: 'Arabic', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish' };
|
|
8
|
+
// the site's own languages first, then common ones
|
|
9
|
+
const languageNames = () => [...new Set([...(I18N.enabled ? I18N.locales : []).map((l) => NAMES[l.split('-')[0]] ?? l), 'English', 'Spanish', 'French', 'Portuguese', 'Korean', 'Chinese'])];
|
|
10
|
+
|
|
11
|
+
// Choices for the Design screen's section fields: what a link field can point
|
|
12
|
+
// at (stored as a ref so it follows slug changes) and what a source field can
|
|
13
|
+
// show. Published/public things only — this is what visitors can reach.
|
|
14
|
+
|
|
15
|
+
export interface LinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' | 'Booking' }
|
|
16
|
+
export interface SectionTargets {
|
|
17
|
+
links: LinkTarget[];
|
|
18
|
+
calendars: { slug: string; name: string }[];
|
|
19
|
+
collections: { key: string; label: string }[];
|
|
20
|
+
tags: string[];
|
|
21
|
+
resources: { slug: string; name: string }[];
|
|
22
|
+
features: { events: boolean; bookings: boolean };
|
|
23
|
+
/** AI help for text fields: provider configured + languages to translate into */
|
|
24
|
+
ai: { enabled: boolean; languages: string[] };
|
|
25
|
+
/** extra site languages to translate into, and whether machine drafts are available */
|
|
26
|
+
i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
|
|
30
|
+
const bookings = bookingsEnabled();
|
|
31
|
+
const settings = await repo.getSettings();
|
|
32
|
+
const [pages, posts, events, calendars, tags, resources, links] = await Promise.all([
|
|
33
|
+
repo.listPages(),
|
|
34
|
+
repo.listPublishedPosts(),
|
|
35
|
+
CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
|
|
36
|
+
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
37
|
+
repo.listTags(),
|
|
38
|
+
bookings ? repo.listResources() : Promise.resolve([]),
|
|
39
|
+
bookings ? repo.listBookingLinks() : Promise.resolve([]),
|
|
40
|
+
]);
|
|
41
|
+
const publicResources = resources.filter((r) => r.visibility === 'public');
|
|
42
|
+
const bySlug = new Map(resources.map((r) => [r.id, r]));
|
|
43
|
+
const out: LinkTarget[] = [
|
|
44
|
+
...pages.filter((p) => p.status === 'published').map((p): LinkTarget => ({ ref: `page:${p.id}`, label: p.title || p.slug, url: p.isHome ? '/' : `/${p.slug}`, group: 'Pages' })),
|
|
45
|
+
...posts.slice(0, 200).map((p): LinkTarget => ({ ref: `post:${p.id}`, label: p.title, url: collectionUrl(p.collection, p.slug), group: 'Posts' })),
|
|
46
|
+
...events.slice(0, 200).map((e): LinkTarget => ({ ref: `event:${e.id}`, label: e.title, url: eventUrl(e.slug), group: 'Events' })),
|
|
47
|
+
...links.filter((l) => l.active && bySlug.has(l.resourceId)).map((l): LinkTarget => {
|
|
48
|
+
const r = bySlug.get(l.resourceId)!;
|
|
49
|
+
return { ref: `booking:${l.id}`, label: `${r.name} · ${l.name}`, url: bookingLinkUrl(r.slug, l.slug), group: 'Booking' };
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
return {
|
|
53
|
+
links: out,
|
|
54
|
+
calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
|
|
55
|
+
collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
|
|
56
|
+
tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
|
|
57
|
+
resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
|
|
58
|
+
features: { events: CALENDAR.enabled, bookings },
|
|
59
|
+
ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
|
|
60
|
+
i18n: { defaultLocale: I18N.defaultLocale, locales: extraLocales().map((code) => ({ code, name: localeName(code, true) })), drafts: translationEngine(settings.ai, locals) !== null },
|
|
61
|
+
};
|
|
62
|
+
}
|