@acms-subir/core 0.3.51 → 0.3.53

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 (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cli/migrations.generated.mjs +16 -1
  3. package/docs/calendar.md +24 -10
  4. package/docs/mobile.md +1 -1
  5. package/integration.mjs +5 -0
  6. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  7. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  8. package/package.json +1 -1
  9. package/src/components/ThemeSections.astro +2 -2
  10. package/src/components/display/ScreensManager.tsx +481 -0
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/layouts/Admin.astro +2 -0
  22. package/src/lib/backup/content.ts +10 -1
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/display/board.ts +4 -3
  25. package/src/lib/display/screens.ts +111 -0
  26. package/src/lib/display/store.ts +113 -0
  27. package/src/lib/display/words.ts +16 -6
  28. package/src/lib/dynamicBlocks.ts +4 -2
  29. package/src/lib/forms/fields.ts +14 -9
  30. package/src/lib/forms/localize.ts +68 -0
  31. package/src/lib/forms/render.ts +8 -2
  32. package/src/lib/forms/schema.ts +31 -2
  33. package/src/lib/forms/service.ts +9 -1
  34. package/src/lib/forms/validate.ts +16 -4
  35. package/src/lib/forms/words.ts +32 -0
  36. package/src/lib/mobile/forms.ts +45 -13
  37. package/src/lib/mobile/openapi.ts +3 -3
  38. package/src/lib/mobile/rsvp.ts +4 -2
  39. package/src/lib/mobile/schemas.ts +3 -1
  40. package/src/lib/repo/migrations.ts +3 -0
  41. package/src/lib/repo/sql.ts +5 -0
  42. package/src/lib/repo/types.ts +5 -0
  43. package/src/pages/[...slug].astro +1 -1
  44. package/src/pages/admin/calendar/screens/index.astro +24 -0
  45. package/src/pages/admin/calendar/screens/preview.astro +36 -0
  46. package/src/pages/admin/forms/[id].astro +6 -0
  47. package/src/pages/api/calendar/screens.ts +30 -0
  48. package/src/pages/blog/[slug].astro +1 -1
  49. package/src/pages/display/s/[key].astro +48 -0
  50. package/src/pages/events/[slug].astro +9 -3
  51. package/src/pages/forms/[id].astro +22 -4
  52. package/src/pages/index.astro +1 -1
  53. package/src/styles/display.css +2 -1
  54. package/src/styles/forms.css +8 -0
  55. package/src/styles/screens-admin.css +53 -0
@@ -1,10 +1,13 @@
1
1
  // Question types, defaults and small pure helpers shared by the builder panes.
2
- import { CHOICE_TYPES, FIELD_ID, type FormDefinition, type FormField } from '../../lib/forms/schema';
2
+ import { CHOICE_TYPES, FIELD_ID, type FieldWords, type FormDefinition, type FormField, type FormWords } from '../../lib/forms/schema';
3
+ import { choiceClash } from '../../lib/forms/localize';
3
4
 
4
5
  export type FieldType = FormField['type'];
5
6
  export type Group = 'Basics' | 'Contact' | 'Other';
6
7
  export type Selection = { kind: 'field'; id: string } | { kind: 'form' } | { kind: 'submit' } | { kind: 'thanks' } | { kind: 'system'; index: number } | null;
7
8
  export type SystemField = { label: string; type: FieldType; note?: string };
9
+ /** The language being written (code '' = the main one) and its name, the main one's, and every language's by code. */
10
+ export type Language = { code: string; name: string; main: string; names: Record<string, string> };
8
11
 
9
12
  export const META: Record<FieldType, { label: string; group: Group; desc: string; fresh: string }> = {
10
13
  text: { label: 'Short text', group: 'Basics', desc: 'A single line, like a name or a title.', fresh: 'Short answer' },
@@ -17,12 +20,13 @@ export const META: Record<FieldType, { label: string; group: Group; desc: string
17
20
  url: { label: 'Website', group: 'Contact', desc: 'A web address. “example.org” is accepted too.', fresh: 'Website' },
18
21
  number: { label: 'Number', group: 'Other', desc: 'Any number, like a count or an age.', fresh: 'Number' },
19
22
  date: { label: 'Date', group: 'Other', desc: 'A calendar date picker.', fresh: 'Date' },
23
+ time: { label: 'Time', group: 'Other', desc: 'A time of day, like 6:30 PM. Stored as 18:30.', fresh: 'Time' },
20
24
  consent: { label: 'Agree checkbox', group: 'Other', desc: 'One box to tick. When required, people must tick it to send.', fresh: 'I agree to the terms' },
21
25
  heading: { label: 'Section heading', group: 'Other', desc: 'A title and description that splits a long form into parts. Not a question.', fresh: 'New section' },
22
26
  checkbox: { label: 'Checkbox', group: 'Other', desc: 'One box to tick.', fresh: 'Checkbox' },
23
27
  };
24
28
  /** Insert menu order (`checkbox` stays valid for old data but isn't offered). */
25
- export const INSERT: FieldType[] = ['text', 'textarea', 'radio', 'checkboxes', 'select', 'email', 'phone', 'url', 'number', 'date', 'consent', 'heading'];
29
+ export const INSERT: FieldType[] = ['text', 'textarea', 'radio', 'checkboxes', 'select', 'email', 'phone', 'url', 'number', 'date', 'time', 'consent', 'heading'];
26
30
  export const TYPE_CHOICES: FieldType[] = [...INSERT];
27
31
 
28
32
  export const isChoice = (t: FieldType) => CHOICE_TYPES.includes(t);
@@ -46,21 +50,77 @@ export function clean(f: FormField): FormField {
46
50
  if (f.placeholder && takesPlaceholder(f)) out.placeholder = f.placeholder;
47
51
  if (f.width === 'half' && f.type !== 'heading') out.width = 'half';
48
52
  if (f.allowOther && canOther(f.type)) out.allowOther = true;
53
+ const translations = cleanFieldWords(out, f.translations);
54
+ if (translations) out.translations = translations;
49
55
  return out;
50
56
  }
51
57
 
58
+ const blank = (v: string | undefined) => !v || !v.trim();
59
+ // Another language's words for a question: blank ones (and languages left
60
+ // with none) are dropped, and so are words its type doesn't show. Choices
61
+ // line up with the main ones by position.
62
+ function cleanFieldWords(f: FormField, translations: FormField['translations']): FormField['translations'] {
63
+ const out: NonNullable<FormField['translations']> = {};
64
+ for (const [locale, t] of Object.entries(translations ?? {})) {
65
+ const w: FieldWords = {};
66
+ if (!blank(t.label)) w.label = t.label;
67
+ if (!blank(t.help)) w.help = t.help;
68
+ if (!blank(t.placeholder) && takesPlaceholder(f)) w.placeholder = t.placeholder;
69
+ if (isChoice(f.type) && t.options?.some(o => !blank(o))) {
70
+ const options = f.options.map((_, i) => t.options?.[i] ?? '');
71
+ while (blank(options[options.length - 1])) options.pop();
72
+ w.options = options;
73
+ }
74
+ if (Object.keys(w).length) out[locale] = w;
75
+ }
76
+ return Object.keys(out).length ? out : undefined;
77
+ }
78
+
79
+ /** The question with some of its words in `locale` changed. */
80
+ export const withFieldWords = (f: FormField, locale: string, next: FieldWords): FormField =>
81
+ clean({ ...f, translations: { ...f.translations, [locale]: { ...f.translations?.[locale], ...next } } });
82
+
83
+ /** The form's title, introduction, button or thank-you in `locale` changed (blank ones dropped). */
84
+ export function withFormWords(d: FormDefinition, locale: string, next: FormWords): Pick<FormDefinition, 'translations'> {
85
+ const all = { ...d.translations, [locale]: { ...d.translations?.[locale], ...next } };
86
+ const out: NonNullable<FormDefinition['translations']> = {};
87
+ for (const [l, t] of Object.entries(all)) {
88
+ const w = Object.fromEntries(Object.entries(t).filter(([, v]) => !blank(v)));
89
+ if (Object.keys(w).length) out[l] = w;
90
+ }
91
+ return { translations: Object.keys(out).length ? out : undefined };
92
+ }
93
+
94
+ /** Each language's choices follow their main choice when choices are added, removed or moved (map: new position → old one, -1 for a new choice). */
95
+ export function remapChoices(translations: FormField['translations'], map: number[]): FormField['translations'] {
96
+ if (!translations) return translations;
97
+ return Object.fromEntries(Object.entries(translations).map(([l, t]) => [l, t.options ? { ...t, options: map.map(i => (i >= 0 ? t.options?.[i] ?? '' : '')) } : t]));
98
+ }
99
+
52
100
  export function newField(type: FieldType, taken: string[]): FormField {
53
101
  const label = META[type].fresh;
54
102
  return clean({ id: keyFrom(label, taken), type, label, required: false, help: '', options: isChoice(type) ? ['Option 1', 'Option 2'] : [] });
55
103
  }
56
104
 
57
- /** Switching type keeps the label/help; choice lists survive between choice types. */
58
- export function switchType(f: FormField, type: FieldType, remembered?: string[]): FormField {
59
- const options = isChoice(type) ? (f.options.length ? f.options : remembered?.length ? remembered : ['Option 1', 'Option 2']) : [];
60
- return clean({ ...f, type, options });
105
+ /** Choices kept while a question is some other type: the main ones and each language's. */
106
+ export interface RememberedChoices { options: string[]; words: Record<string, string[]> }
107
+ export const rememberChoices = (f: FormField): RememberedChoices => ({
108
+ options: f.options, words: Object.fromEntries(Object.entries(f.translations ?? {}).flatMap(([l, t]) => (t.options?.length ? [[l, t.options]] : []))),
109
+ });
110
+
111
+ /** Switching type keeps the label/help; choice lists (and their translations) survive between choice types. */
112
+ export function switchType(f: FormField, type: FieldType, remembered?: RememberedChoices): FormField {
113
+ const restore = isChoice(type) && !f.options.length && !!remembered?.options.length;
114
+ const options = isChoice(type) ? (f.options.length ? f.options : restore ? remembered!.options : ['Option 1', 'Option 2']) : [];
115
+ const translations = restore
116
+ ? Object.fromEntries([...new Set([...Object.keys(f.translations ?? {}), ...Object.keys(remembered!.words)])]
117
+ .map((l) => [l, { ...(f.translations?.[l] ?? {}), ...(remembered!.words[l] ? { options: remembered!.words[l] } : {}) }]))
118
+ : f.translations;
119
+ return clean({ ...f, type, options, translations });
61
120
  }
62
121
 
63
- export function fieldIssue(f: FormField, all: FormField[]): string {
122
+ /** names: language names by locale, for a translated choice that reads the same as another one */
123
+ export function fieldIssue(f: FormField, all: FormField[], names: Record<string, string> = {}): string {
64
124
  if (!f.label.trim()) return 'Needs a label.';
65
125
  if (!FIELD_ID.test(f.id)) return 'Answer key must start with a letter and use letters, numbers, - or _.';
66
126
  if (all.filter(x => x.id === f.id).length > 1) return 'Answer key is used twice.';
@@ -68,12 +128,14 @@ export function fieldIssue(f: FormField, all: FormField[]): string {
68
128
  if (!f.options.length) return 'Add at least one choice.';
69
129
  if (f.options.some(o => !o.trim())) return 'A choice is empty.';
70
130
  if (new Set(f.options.map(o => o.trim())).size !== f.options.length) return 'Two choices are the same.';
131
+ const clash = choiceClash(f);
132
+ if (clash) return `In ${names[clash.locale] ?? clash.locale.toUpperCase()}, “${clash.text.trim()}” is used for two choices.`;
71
133
  }
72
134
  return '';
73
135
  }
74
136
 
75
- export function definitionIssue(d: FormDefinition): string {
76
- for (const f of d.fields) { const issue = fieldIssue(f, d.fields); if (issue) return `“${f.label || META[f.type].label}”: ${issue}`; }
137
+ export function definitionIssue(d: FormDefinition, names: Record<string, string> = {}): string {
138
+ for (const f of d.fields) { const issue = fieldIssue(f, d.fields, names); if (issue) return `“${f.label || META[f.type].label}”: ${issue}`; }
77
139
  return '';
78
140
  }
79
141
 
@@ -128,6 +128,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
128
128
  ...(CALENDAR.enabled && can('schedule.bookings')
129
129
  ? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
130
130
  : []),
131
+ // TV screens: saved display links (lib/display), with private details behind their key
132
+ ...(can('schedule.manage') ? [{ key: 'calendar:screens', label: 'Screens', href: '/admin/calendar/screens', icon: icons.monitor, count: null }] : []),
131
133
  ],
132
134
  }]
133
135
  : []),
@@ -9,6 +9,13 @@ import type { ContentRepository } from '../repo/types';
9
9
  const legacyApproval = (l: unknown) =>
10
10
  l && typeof l === 'object' && !('approval' in l) && (l as { autoConfirm?: boolean }).autoConfirm === false ? { ...l, approval: 'manual' } : l;
11
11
 
12
+ // a saved TV screen (lib/display/store.ts). Its key never goes into a
13
+ // backup: a restored screen gets a new one
14
+ export const ScreenRecord = z.object({
15
+ id: z.string().min(1), name: z.string().max(200).default(''), params: z.string().max(4000).default(''), locale: z.string().max(20).default(''),
16
+ privateDetails: z.boolean().default(false), key: z.string().default(''), lastSeenAt: z.number().nullable().default(null),
17
+ order: z.number().default(0), createdAt: z.number(), updatedAt: z.number(),
18
+ });
12
19
  export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
13
20
  export type BookingRequest = z.infer<typeof BookingRequest>;
14
21
  export const BackupContent = z.object({
@@ -21,6 +28,7 @@ export const BackupContent = z.object({
21
28
  forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
22
29
  // optional (no version bump): older cores restore such a backup and skip them
23
30
  announcements: z.array(AnnouncementRecord).optional(),
31
+ screens: z.array(ScreenRecord).optional(),
24
32
  }).superRefine((data, ctx) => {
25
33
  if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
26
34
  if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
@@ -39,12 +47,13 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
39
47
  repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
40
48
  repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
41
49
  ]);
42
- const announcements = await repo.announcements.list(-1);
50
+ const [announcements, screens] = await Promise.all([repo.announcements.list(-1), repo.screens.list()]);
43
51
  return {
44
52
  format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
45
53
  settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
46
54
  pages, posts, media, links, tags, navItems, users, redirects,
47
55
  calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
48
56
  resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements,
57
+ screens: screens.map(s => ({ ...s, key: '', lastSeenAt: null })),
49
58
  };
50
59
  }
@@ -6,6 +6,7 @@
6
6
  import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
+ import { localizeForm } from '../forms/localize';
9
10
 
10
11
  export interface EventJson {
11
12
  id: string;
@@ -90,7 +91,8 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
90
91
  movedDates: movedDatesToJson(e, opts.includePrivate),
91
92
  location: e.location,
92
93
  cta: e.cta,
93
- rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
94
+ // public: the RSVP form in the main language, without its translations (GET /events/{slug}/rsvp?locale= has it in a language)
95
+ rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => (rsvp.form ? { ...rsvp, form: localizeForm(rsvp.form, '') } : rsvp))(e.rsvp),
94
96
  image: e.cover ? abs(origin, e.cover) : '',
95
97
  imageAlt: e.coverAlt,
96
98
  tags: e.tags.filter((t) => !t.startsWith('#')),
@@ -15,7 +15,7 @@ import { addDays, dayOfWeek, monthWeeks, startOfWeek, type Iso } from '../calend
15
15
  import { siteLang, siteLocale } from '../dates';
16
16
  import { DEFAULT_HOURS, MIN_AUTO_HOURS, type DisplaySettings, type HourRange } from './settings';
17
17
  import { renderBoard, type DisplayItem, type DisplayModel, type DisplayRoom } from './render';
18
- import { displayStrings } from './words';
18
+ import { displayStrings, isBookedWord } from './words';
19
19
  import { qrSvg } from './http';
20
20
 
21
21
  export const GOOGLE_BOOKING_TAG = '#google-booking';
@@ -200,8 +200,9 @@ function displayItem(v: ViewEvent, i: number, c: ItemContext): DisplayItem {
200
200
  key: `${ev?.id ?? v.ref ?? 'x'}:${v.start}:${i}`,
201
201
  kind: ev ? 'event' : v.rooms ? 'booking' : 'feed',
202
202
  title: booked ? c.words.booked : v.title,
203
- // under "Booked", a private screen also names the appointment
204
- detail: booked && c.privateDetails ? v.title : '',
203
+ // under "Booked", a private screen also names the appointment (unless
204
+ // its title is just "Booked", as the Google sync makes them)
205
+ detail: booked && c.privateDetails && !isBookedWord(v.title) ? v.title : '',
205
206
  start: v.start, end: v.end, allDay: v.allDay,
206
207
  startDay, endDay,
207
208
  startMin: v.allDay ? 0 : s.min,
@@ -0,0 +1,111 @@
1
+ // Saved screens as the TV and the admin see them: opening one by its key
2
+ // (rate-limited for wrong keys), and the admin state and actions behind
3
+ // Admin → Calendar → Screens (/api/calendar/screens).
4
+ import { z } from 'zod';
5
+ import type { ContentRepository } from '../repo/types';
6
+ import { ApiError } from '../api';
7
+ import { sha256Hex } from '../api-keys';
8
+ import { I18N, localeName } from '../i18n';
9
+ import { siteLang } from '../dates';
10
+ import { roomColor } from '../schema';
11
+ import { safeColor } from '../calendar/events';
12
+ import { canonicalDisplayQuery } from './settings';
13
+ import type { Screen, ScreenInput } from './store';
14
+
15
+ // Wrong keys, per client address: past this many in the window, further
16
+ // wrong keys from that address are refused without a lookup. A right key
17
+ // always opens its screen, so nobody can lock a church's TVs out by sending
18
+ // bad keys "from" its address (proxies can claim any address). Keys are
19
+ // 256-bit, so guessing gets nowhere either way.
20
+ export const MISS_LIMIT = 60;
21
+ export const MISS_WINDOW = 15 * 60_000;
22
+ const missKey = async (address: string) => `display:miss:${await sha256Hex(address || 'unknown')}`;
23
+
24
+ export type OpenedScreen = { status: 'ok'; screen: Screen } | { status: 'missing' } | { status: 'limited' };
25
+
26
+ /** The screen at /display/s/<key>, counting a wrong key against the caller's address. */
27
+ export async function openScreen(repo: ContentRepository, key: string, address: string): Promise<OpenedScreen> {
28
+ const screen = await repo.screens.byKey(key);
29
+ if (screen) return { status: 'ok', screen };
30
+ const limitKey = await missKey(address);
31
+ if (await repo.email.rateLimited(limitKey, MISS_LIMIT)) return { status: 'limited' };
32
+ await repo.email.rateHit(limitKey, MISS_WINDOW);
33
+ return { status: 'missing' };
34
+ }
35
+
36
+ export const screenPath = (s: Pick<Screen, 'key'>) => `/display/s/${s.key}`;
37
+
38
+ // ---- admin -----------------------------------------------------------------
39
+
40
+ const siteLanguages = (mainLang: string) => {
41
+ const codes = I18N.enabled ? I18N.locales : [mainLang];
42
+ return codes.map((code) => ({ code, label: localeName(code, true), main: I18N.enabled ? code === I18N.defaultLocale : true }));
43
+ };
44
+
45
+ /**
46
+ * What the Screens editor needs: the screens, and what a screen can pick
47
+ * from. `bookings`: the person may see booker names (Manage bookings), which
48
+ * private details show.
49
+ */
50
+ export async function screensState(repo: ContentRepository, origin: string, opts: { bookings: boolean }) {
51
+ const [screens, site, calendars, resources, events] = await Promise.all([
52
+ repo.screens.list(), repo.getSettings(), repo.listCalendars(), repo.listResources(), repo.listPublishedEvents(),
53
+ ]);
54
+ const tags = [...new Set(events.flatMap((e) => e.tags.map((t) => t.trim()).filter(Boolean)))].sort((a, b) => a.localeCompare(b));
55
+ return {
56
+ now: Date.now(),
57
+ origin,
58
+ canPrivate: opts.bookings,
59
+ weekStart: site.calendar.weekStart,
60
+ timezone: site.timezone || 'UTC',
61
+ languages: siteLanguages(siteLang(site)),
62
+ screens: screens.map((s) => ({ ...s, url: s.key ? origin + screenPath(s) : '' })),
63
+ calendars: calendars.map((c) => ({ slug: c.slug, name: c.name, color: safeColor(c.color), visibility: c.visibility, kind: c.kind })),
64
+ // public: named on public events, so quick links can show and filter by it
65
+ rooms: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, color: roomColor(r), public: r.showOnEvents })),
66
+ tags,
67
+ };
68
+ }
69
+
70
+ const ScreenFields = {
71
+ name: z.string().trim().min(1, 'Give the screen a name.').max(120),
72
+ params: z.string().max(4000),
73
+ locale: z.string().max(20).default(''),
74
+ privateDetails: z.boolean().default(false),
75
+ };
76
+ export const ScreenAction = z.discriminatedUnion('action', [
77
+ z.object({ action: z.literal('create'), ...ScreenFields }),
78
+ z.object({ action: z.literal('save'), id: z.string().min(1), ...ScreenFields }),
79
+ z.object({ action: z.literal('rotate'), id: z.string().min(1) }),
80
+ z.object({ action: z.literal('delete'), id: z.string().min(1) }),
81
+ z.object({ action: z.literal('reorder'), ids: z.array(z.string()).max(500) }),
82
+ ]);
83
+ export type ScreenAction = z.infer<typeof ScreenAction>;
84
+
85
+ function screenInput(a: { name: string; params: string; locale: string; privateDetails: boolean }): ScreenInput {
86
+ // only a configured extra language; the main one is stored as ''
87
+ const locale = I18N.enabled && a.locale !== I18N.defaultLocale && I18N.locales.includes(a.locale) ? a.locale : '';
88
+ return { name: a.name, params: canonicalDisplayQuery(a.params.replace(/^\?/, '')), locale, privateDetails: a.privateDetails };
89
+ }
90
+
91
+ export async function screenAction(repo: ContentRepository, a: ScreenAction, opts: { bookings: boolean }): Promise<{ screen?: Screen; ok?: true }> {
92
+ // private details show booker names: only for people who may see bookings
93
+ if ((a.action === 'create' || a.action === 'save') && a.privateDetails && !opts.bookings) {
94
+ throw new ApiError(403, 'Showing private calendars and room bookings needs the “Manage bookings” permission.');
95
+ }
96
+ const found = (s: Screen | null) => {
97
+ if (!s) throw new ApiError(404, 'That screen was removed. Reload the page.');
98
+ return { screen: s };
99
+ };
100
+ switch (a.action) {
101
+ case 'create': return { screen: await repo.screens.create(screenInput(a)) };
102
+ case 'save': return found(await repo.screens.update(a.id, screenInput(a)));
103
+ case 'rotate': return found(await repo.screens.rotate(a.id));
104
+ case 'delete':
105
+ if (!(await repo.screens.delete(a.id))) throw new ApiError(404, 'That screen was removed. Reload the page.');
106
+ return { ok: true };
107
+ case 'reorder':
108
+ await repo.screens.reorder(a.ids);
109
+ return { ok: true };
110
+ }
111
+ }
@@ -0,0 +1,113 @@
1
+ // Saved TV screens (Admin → Calendar → Screens). Each one is a name, the
2
+ // display settings as their canonical query (lib/display/settings.ts, the
3
+ // same text a quick link carries), a language, the private-details switch and
4
+ // a long random key: the screen's whole address is /display/s/<key>. The key
5
+ // is kept as is, like a private calendar's feed token, so staff can copy the
6
+ // link again; rotating it stops the old one at once. last_seen_at is the last
7
+ // time the screen fetched its events. Shared by the D1 and libSQL adapters.
8
+ import type { SqlDriver, SqlStatement } from '../repo/sql';
9
+
10
+ export interface Screen {
11
+ id: string;
12
+ name: string;
13
+ /** the display settings, as their canonical query ("view=rooms&hours=8-21") */
14
+ params: string;
15
+ /** the display's language ('' = the site's main language) */
16
+ locale: string;
17
+ /** shows private calendars it names and room bookings (titles, rooms, hosts, the name on a booking) */
18
+ privateDetails: boolean;
19
+ /** the secret in the screen's address; '' = no link until it's rotated */
20
+ key: string;
21
+ /** when the screen last fetched its events (ms; null = never) */
22
+ lastSeenAt: number | null;
23
+ order: number;
24
+ createdAt: number;
25
+ updatedAt: number;
26
+ }
27
+
28
+ export type ScreenInput = Pick<Screen, 'name' | 'params' | 'locale' | 'privateDetails'>;
29
+
30
+ // v67: Admin → Calendar → Screens. Not public (each screen is reached by its
31
+ // own key, never cached), so no content-version trigger.
32
+ export const SCREENS_MIGRATION = {
33
+ version: 67,
34
+ statements: [
35
+ `CREATE TABLE display_screens (
36
+ id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', params TEXT NOT NULL DEFAULT '', locale TEXT NOT NULL DEFAULT '',
37
+ private_details INTEGER NOT NULL DEFAULT 0, screen_key TEXT NOT NULL DEFAULT '', last_seen_at INTEGER,
38
+ ord INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
39
+ )`,
40
+ `CREATE UNIQUE INDEX display_screens_key ON display_screens (screen_key) WHERE screen_key <> ''`,
41
+ ],
42
+ };
43
+
44
+ /** 32 random bytes, base64url (43 characters) */
45
+ export function screenKey(): string {
46
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
47
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
48
+ }
49
+ export const isScreenKey = (s: string) => /^[A-Za-z0-9_-]{43}$/.test(s);
50
+
51
+ type Row = Record<string, unknown>;
52
+ const screen = (r: Row): Screen => ({
53
+ id: String(r.id), name: String(r.name ?? ''), params: String(r.params ?? ''), locale: String(r.locale ?? ''),
54
+ privateDetails: Number(r.private_details) === 1, key: String(r.screen_key ?? ''),
55
+ lastSeenAt: r.last_seen_at == null ? null : Number(r.last_seen_at),
56
+ order: Number(r.ord ?? 0), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
57
+ });
58
+
59
+ /** The INSERT for one screen (create and backup restore). */
60
+ export function screenInsert(s: Screen): SqlStatement {
61
+ return {
62
+ sql: `INSERT INTO display_screens (id, name, params, locale, private_details, screen_key, last_seen_at, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
63
+ args: [s.id, s.name, s.params, s.locale, s.privateDetails ? 1 : 0, s.key, s.lastSeenAt, s.order, s.createdAt, s.updatedAt],
64
+ };
65
+ }
66
+
67
+ // a screen fetching every few minutes writes its check-in at most once a minute
68
+ const CHECK_IN_EVERY = 60_000;
69
+
70
+ export class ScreenStore {
71
+ constructor(private driver: () => Promise<SqlDriver>) {}
72
+ private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
73
+
74
+ async list(): Promise<Screen[]> {
75
+ return (await this.all('SELECT * FROM display_screens ORDER BY ord, name COLLATE NOCASE, created_at')).map(screen);
76
+ }
77
+ async get(id: string): Promise<Screen | null> {
78
+ const r = await this.all('SELECT * FROM display_screens WHERE id = ?', [id]);
79
+ return r[0] ? screen(r[0]) : null;
80
+ }
81
+ /** the screen with this key (null for a wrong, rotated or empty key) */
82
+ async byKey(key: string): Promise<Screen | null> {
83
+ if (!isScreenKey(key)) return null;
84
+ const r = await this.all('SELECT * FROM display_screens WHERE screen_key = ?', [key]);
85
+ return r[0] ? screen(r[0]) : null;
86
+ }
87
+ async create(input: ScreenInput, now = Date.now()): Promise<Screen> {
88
+ const next = await this.all('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM display_screens');
89
+ const s: Screen = { id: crypto.randomUUID(), ...input, key: screenKey(), lastSeenAt: null, order: Number(next[0]?.n ?? 0), createdAt: now, updatedAt: now };
90
+ const insert = screenInsert(s);
91
+ return screen((await this.all(`${insert.sql} RETURNING *`, insert.args))[0]);
92
+ }
93
+ async update(id: string, input: ScreenInput, now = Date.now()): Promise<Screen | null> {
94
+ const r = await this.all('UPDATE display_screens SET name = ?, params = ?, locale = ?, private_details = ?, updated_at = ? WHERE id = ? RETURNING *',
95
+ [input.name, input.params, input.locale, input.privateDetails ? 1 : 0, now, id]);
96
+ return r[0] ? screen(r[0]) : null;
97
+ }
98
+ /** a new key: the old address stops working at once */
99
+ async rotate(id: string, now = Date.now()): Promise<Screen | null> {
100
+ const r = await this.all('UPDATE display_screens SET screen_key = ?, updated_at = ? WHERE id = ? RETURNING *', [screenKey(), now, id]);
101
+ return r[0] ? screen(r[0]) : null;
102
+ }
103
+ async delete(id: string): Promise<boolean> {
104
+ return (await this.all('DELETE FROM display_screens WHERE id = ? RETURNING id', [id])).length > 0;
105
+ }
106
+ async reorder(ids: string[]): Promise<void> {
107
+ await (await this.driver()).batch(ids.map((id, i) => ({ sql: 'UPDATE display_screens SET ord = ? WHERE id = ?', args: [i, id] })));
108
+ }
109
+ /** the screen fetched its events (written at most once a minute) */
110
+ async checkIn(id: string, now = Date.now()): Promise<void> {
111
+ await (await this.driver()).run('UPDATE display_screens SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at <= ?)', [now, id, now - CHECK_IN_EVERY]);
112
+ }
113
+ }
@@ -1,6 +1,9 @@
1
1
  // Words of the TV display (/display/…). Dates, weekdays and times come from
2
2
  // Intl in the display's language; these are the rest. Plain strings with
3
- // {slots} filled by `fill`. Other languages fall back to English.
3
+ // {slots} filled by `fill`. Other languages fall back to English. "Moved"
4
+ // and "Cancelled" are the event pages' own words (event-words.ts), so a TV
5
+ // says what the site and its emails say.
6
+ import { eventStrings } from '../calendar/event-words';
4
7
  export interface DisplayStrings {
5
8
  thisWeek: string;
6
9
  /** a rolling week (start=today) or list: "Next 7 days" */
@@ -16,11 +19,13 @@ export interface DisplayStrings {
16
19
  nothingNext: string;
17
20
  noEvents: string;
18
21
  more: string;
22
+ /** from eventStrings: the event pages' word */
19
23
  moved: string;
20
24
  /** a moved date's old time */
21
25
  was: string;
22
26
  /** appointments from Google booking pages (#google-booking) */
23
27
  booked: string;
28
+ /** from eventStrings: the event pages' word */
24
29
  cancelled: string;
25
30
  scan: string;
26
31
  tomorrow: string;
@@ -28,25 +33,30 @@ export interface DisplayStrings {
28
33
  notFoundNote: string;
29
34
  }
30
35
 
31
- const STRINGS: Record<string, DisplayStrings> = {
36
+ const STRINGS: Record<string, Omit<DisplayStrings, 'moved' | 'cancelled'>> = {
32
37
  en: {
33
38
  thisWeek: 'This week', nextDays: 'Next {n} days', today: 'Today', upcoming: 'Coming up', allDay: 'All day',
34
39
  updated: 'Updated {time}', now: 'Now', next: 'Next', nothingNow: 'Nothing right now', nothingNext: 'Nothing else today',
35
- noEvents: 'No events', more: '+{n} more', moved: 'Moved', was: 'was {time}', booked: 'Booked', cancelled: 'Cancelled',
40
+ noEvents: 'No events', more: '+{n} more', was: 'was {time}', booked: 'Booked',
36
41
  scan: 'Scan for the calendar', tomorrow: 'Tomorrow',
37
42
  notFound: 'This screen isn’t available', notFoundNote: 'Its link may have changed. Copy the new one from Admin → Calendar → Screens.',
38
43
  },
39
44
  es: {
40
45
  thisWeek: 'Esta semana', nextDays: 'Próximos {n} días', today: 'Hoy', upcoming: 'Próximamente', allDay: 'Todo el día',
41
46
  updated: 'Actualizado {time}', now: 'Ahora', next: 'Después', nothingNow: 'Nada en este momento', nothingNext: 'Nada más hoy',
42
- noEvents: 'Sin eventos', more: '+{n} más', moved: 'Cambiado', was: 'antes {time}', booked: 'Reservado', cancelled: 'Cancelado',
47
+ noEvents: 'Sin eventos', more: '+{n} más', was: 'antes {time}', booked: 'Reservado',
43
48
  scan: 'Escanea para ver el calendario', tomorrow: 'Mañana',
44
49
  notFound: 'Esta pantalla no está disponible', notFoundNote: 'Puede que su enlace haya cambiado. Copia el nuevo en Admin → Calendario → Pantallas.',
45
50
  },
46
51
  };
47
52
 
48
- export const displayStrings = (lang: string | null | undefined): DisplayStrings =>
49
- STRINGS[(lang || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
53
+ /** "Booked" in every language: the Google sync titles its appointments with it */
54
+ export const isBookedWord = (title: string) => Object.values(STRINGS).some((s) => s.booked.toLowerCase() === title.trim().toLowerCase());
55
+
56
+ export const displayStrings = (lang: string | null | undefined): DisplayStrings => {
57
+ const ew = eventStrings(lang);
58
+ return { ...(STRINGS[(lang || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en), moved: ew.moved, cancelled: ew.cancelled };
59
+ };
50
60
 
51
61
  export const fill = (s: string, slots: Record<string, string | number>) =>
52
62
  s.replace(/\{(\w+)\}/g, (m, k: string) => (k in slots ? String(slots[k]) : m));
@@ -5,6 +5,7 @@ import { formatDate } from './dates';
5
5
  import { CONTENT, collectionUrl } from './content';
6
6
  import { renderCalendarBlock, isNativeSource } from './calendar/render';
7
7
  import { renderEventCards, type EventCardAttrs } from './calendar/cards';
8
+ import { localePath } from './i18n';
8
9
 
9
10
  // Dynamic blocks: bodyHtml is a save-time cache, so cards whose content must
10
11
  // stay fresh (post lists, calendars) serialize as placeholder divs and get
@@ -131,7 +132,8 @@ function eventCardAttrs(tag: string): EventCardAttrs {
131
132
  };
132
133
  }
133
134
 
134
- export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
135
+ // locale: the page's language; a form block opens the form in it (/es/forms/<id>)
136
+ export async function expandDynamicBlocks(html: string, repo: ContentRepository, locale = ''): Promise<string> {
135
137
  html = stampHeadingAnchors(html);
136
138
  const hasForms = html.includes('data-dyn="form"');
137
139
  const hasPostList = html.includes('data-dyn="post-list"');
@@ -168,7 +170,7 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
168
170
  // A same-origin frame gives each visitor an uncached form/receipt even
169
171
  // when the containing content page is served from a shared cache.
170
172
  calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
171
- ? `<iframe class="pc-form-embed" src="/forms/${encodeURIComponent(form.id)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
173
+ ? `<iframe class="pc-form-embed" src="${localePath(`/forms/${encodeURIComponent(form.id)}`, locale)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
172
174
  }));
173
175
  }
174
176
  const anchored = html;
@@ -1,13 +1,18 @@
1
1
  import { hasOther, isHalf, type FormDefinition, type FormField } from './schema';
2
2
  import { OTHER } from './validate';
3
+ import { formStrings, type FormStrings } from './words';
4
+ import { fill } from '../live/words';
3
5
  export const escapeForm = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
4
6
  const e = escapeForm;
5
- const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date' };
7
+ const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date', time: 'time' };
6
8
  // Websites use a text box so "example.org" can be typed; the server adds https://.
7
9
  const autocomplete: Partial<Record<FormField['type'], string>> = { email: 'email', phone: 'tel', url: 'url' };
10
+ // Questions arrive in the page's language already (localize.ts localizeForm);
11
+ // `w` is the form's own words around them (words.ts), English by default.
12
+ const english = formStrings('en');
8
13
 
9
14
  /** The answer control alone (input, select, textarea or option list). The builder canvas reuses it under editable labels. */
10
- export function renderControl(f: FormField, value: string, id: string): string {
15
+ export function renderControl(f: FormField, value: string, id: string, w: FormStrings = english): string {
11
16
  const describe = f.help ? ` aria-describedby="${e(id)}-help"` : '';
12
17
  const attrs = `id="${e(id)}" name="q_${e(f.id)}"${f.required ? ' required' : ''}${describe}`;
13
18
  const ph = f.placeholder ? ` placeholder="${e(f.placeholder)}"` : '';
@@ -17,31 +22,31 @@ export function renderControl(f: FormField, value: string, id: string): string {
17
22
  // A required checkbox group can't be enforced natively; the server checks it.
18
23
  const req = radio && f.required ? ' required' : '';
19
24
  const option = (o: string, i: number) => `<label class="pc-bk-option"><input type="${radio ? 'radio' : 'checkbox'}" id="${e(id)}-${i}" name="q_${e(f.id)}" value="${e(o)}"${req}${chosen.includes(o) ? ' checked' : ''}/><span>${e(o)}</span></label>`;
20
- const other = hasOther(f) ? `<label class="pc-bk-option pc-bk-other"><input type="${radio ? 'radio' : 'checkbox'}" name="q_${e(f.id)}" value="${OTHER}"${req}${extra ? ' checked' : ''}/><span>Other</span><input type="text" name="o_${e(f.id)}" maxlength="200" aria-label="Other answer for ${e(f.label)}" placeholder="${e(f.placeholder || 'Type your answer')}" value="${e(extra)}"/></label>` : '';
25
+ const other = hasOther(f) ? `<label class="pc-bk-option pc-bk-other"><input type="${radio ? 'radio' : 'checkbox'}" name="q_${e(f.id)}" value="${OTHER}"${req}${extra ? ' checked' : ''}/><span>${e(w.other)}</span><input type="text" name="o_${e(f.id)}" maxlength="200" aria-label="${e(fill(w.otherAnswer, { label: f.label }))}" placeholder="${e(f.placeholder || w.typeAnswer)}" value="${e(extra)}"/></label>` : '';
21
26
  return `<div class="pc-bk-options">${f.options.map(option).join('')}${other}</div>`;
22
27
  }
23
28
  if (f.type === 'textarea') return `<textarea ${attrs}${ph} maxlength="2000" rows="3">${e(value)}</textarea>`;
24
- if (f.type === 'select') return `<select ${attrs}><option value="">${e(f.placeholder || 'Choose…')}</option>${f.options.map(o => `<option value="${e(o)}" ${o === value ? 'selected' : ''}>${e(o)}</option>`).join('')}</select>`;
29
+ if (f.type === 'select') return `<select ${attrs}><option value="">${e(f.placeholder || w.choose)}</option>${f.options.map(o => `<option value="${e(o)}" ${o === value ? 'selected' : ''}>${e(o)}</option>`).join('')}</select>`;
25
30
  const ac = (autocomplete[f.type] ? ` autocomplete="${autocomplete[f.type]}"` : '') + (f.type === 'url' ? ' inputmode="url" spellcheck="false"' : '');
26
31
  return `<input ${attrs}${ph}${ac} type="${inputType[f.type] ?? 'text'}"${f.type === 'number' ? ' step="any"' : ''} maxlength="${f.type === 'phone' ? 40 : 2000}" value="${e(value)}"/>`;
27
32
  }
28
33
 
29
- export function renderField(f: FormField, value: string, prefix: string): string {
34
+ export function renderField(f: FormField, value: string, prefix: string, w: FormStrings = english): string {
30
35
  const id = `${prefix}-${f.id}`, half = isHalf(f) ? ' is-half' : '';
31
36
  const help = f.help ? `<small id="${e(id)}-help">${e(f.help)}</small>` : '';
32
37
  const label = `${e(f.label)}${f.required ? ' *' : ''}`;
33
38
  if (f.type === 'heading') return `<div class="pc-form-heading" id="${e(id)}"><h3>${e(f.label)}</h3>${f.help ? `<p>${e(f.help)}</p>` : ''}</div>`;
34
39
  if (f.type === 'checkbox' || f.type === 'consent') return `<label class="pc-bk-check${half}" for="${e(id)}"><input type="checkbox" id="${e(id)}" name="q_${e(f.id)}" ${f.required ? 'required' : ''} ${f.help ? `aria-describedby="${e(id)}-help"` : ''} value="yes" ${value === 'yes' ? 'checked' : ''}/><span>${label}${help}</span></label>`;
35
- if (f.type === 'radio' || f.type === 'checkboxes') return `<fieldset class="pc-bk-field pc-bk-choices${half}"${f.help ? ` aria-describedby="${e(id)}-help"` : ''}><legend>${label}</legend>${renderControl(f, value, id)}${help}</fieldset>`;
36
- return `<label class="pc-bk-field${half}" for="${e(id)}"><span>${label}</span>${renderControl(f, value, id)}${help}</label>`;
40
+ if (f.type === 'radio' || f.type === 'checkboxes') return `<fieldset class="pc-bk-field pc-bk-choices${half}"${f.help ? ` aria-describedby="${e(id)}-help"` : ''}><legend>${label}</legend>${renderControl(f, value, id, w)}${help}</fieldset>`;
41
+ return `<label class="pc-bk-field${half}" for="${e(id)}"><span>${label}</span>${renderControl(f, value, id, w)}${help}</label>`;
37
42
  }
38
43
 
39
44
  /** Two consecutive half-width questions share a `.pc-form-row` (side by side when there's room). */
40
- export function renderFields(definition: FormDefinition, values: Record<string, string> = {}, prefix = 'form'): string {
45
+ export function renderFields(definition: FormDefinition, values: Record<string, string> = {}, prefix = 'form', w: FormStrings = english): string {
41
46
  const out: string[] = []; let row: string[] = [];
42
47
  const flush = () => { if (row.length) out.push(`<div class="pc-form-row">${row.join('')}</div>`); row = []; };
43
48
  for (const f of definition.fields) {
44
- const html = renderField(f, values[f.id] ?? '', prefix);
49
+ const html = renderField(f, values[f.id] ?? '', prefix, w);
45
50
  if (isHalf(f)) { row.push(html); if (row.length === 2) flush(); }
46
51
  else { flush(); out.push(html); }
47
52
  }