@acms-subir/core 0.3.34 → 0.3.35

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.
@@ -96,6 +96,10 @@ export class PushStore {
96
96
  async receiptFailed(counts: Map<string, { failed: number; removed: number }>) {
97
97
  for (const [id, c] of counts) await this.run('UPDATE push_notifications SET ok=MAX(ok-?,0), failed=failed+?, removed=removed+? WHERE id=?', [c.failed, c.failed, c.removed, id]);
98
98
  }
99
+ /** Notifications from one source that went out since `since`, newest first. */
100
+ async sentSince(source: PushNotification['source'], since: number) {
101
+ return (await this.all(`SELECT * FROM push_notifications WHERE source=? AND created_at>=? AND status!='failed' ORDER BY created_at DESC LIMIT 100`, [source, since])).map(notification);
102
+ }
99
103
  async listNotifications(limit = 50) { return (await this.all('SELECT * FROM push_notifications ORDER BY created_at DESC LIMIT ?', [limit])).map(notification); }
100
104
  async getNotification(id: string) { const r = await this.all('SELECT * FROM push_notifications WHERE id=?', [id]); return r[0] ? notification(r[0]) : null; }
101
105
 
@@ -120,17 +124,39 @@ export class PushStore {
120
124
  }
121
125
 
122
126
  // ---- automatic notification queue ----
123
- /** Claims up to `limit` pending rows (status → sending); rows queued before `staleBefore` are skipped instead. */
127
+ /**
128
+ * Claims up to `limit` due pending rows (status → sending); rows queued before
129
+ * `staleBefore` are skipped instead. A row with `due_at` waits until then and
130
+ * goes stale from then, so an event still being edited never counts as late.
131
+ */
124
132
  async claimPending(now: number, staleBefore: number, limit: number): Promise<PushQueueRow[]> {
125
- await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND created_at<?`, [now, staleBefore]);
126
- const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' ORDER BY created_at LIMIT ?`, [limit]);
133
+ await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND COALESCE(due_at,created_at)<?`, [now, staleBefore]);
134
+ const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' AND (due_at IS NULL OR due_at<=?) ORDER BY created_at LIMIT ?`, [now, limit]);
127
135
  const out: PushQueueRow[] = [];
128
136
  for (const { key } of due) {
129
- const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' RETURNING *`, [now, key]);
130
- if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence), status: 'sending', createdAt: Number(r[0].created_at) });
137
+ const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' AND (due_at IS NULL OR due_at<=?) RETURNING *`, [now, key, now]);
138
+ if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence),
139
+ status: 'sending', createdAt: Number(r[0].created_at), payload: r[0].payload == null ? null : String(r[0].payload), dueAt: r[0].due_at == null ? null : Number(r[0].due_at) });
131
140
  }
132
141
  return out;
133
142
  }
143
+ /**
144
+ * Debounces an event's change notice (key change:<id>) in one statement, so
145
+ * concurrent saves can't lose an edit: a pending row keeps its snapshot (what
146
+ * people last knew) and waits until `dueAt` again; any other row (sent,
147
+ * skipped, failed, or being sent) starts over with this snapshot.
148
+ */
149
+ async queueChange(key: string, docId: string, payload: string, now: number, dueAt: number) {
150
+ await this.run(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at,payload,due_at)
151
+ VALUES (?,'change',?,NULL,'pending',NULL,?,?,?,?) ON CONFLICT(key) DO UPDATE SET
152
+ payload=CASE WHEN push_queue.status='pending' THEN push_queue.payload ELSE excluded.payload END,
153
+ created_at=CASE WHEN push_queue.status='pending' THEN push_queue.created_at ELSE excluded.created_at END,
154
+ status='pending', notification_id=NULL, due_at=excluded.due_at, updated_at=excluded.updated_at`, [key, docId, now, now, payload, dueAt]);
155
+ }
156
+ async queuedChange(key: string): Promise<{ status: string; payload: string | null } | null> {
157
+ const r = (await this.all('SELECT status, payload FROM push_queue WHERE key=?', [key]))[0];
158
+ return r ? { status: String(r.status), payload: r.payload == null ? null : String(r.payload) } : null;
159
+ }
134
160
  /** Inserts a new key as `sending`; false when it already exists (dedupe for "starting soon"). */
135
161
  async claimKey(key: string, kind: PushQueueRow['kind'], docId: string, occurrence: number | null, now: number): Promise<boolean> {
136
162
  const r = await this.all(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
@@ -138,7 +164,8 @@ export class PushStore {
138
164
  return r.length > 0;
139
165
  }
140
166
  async markQueue(key: string, status: 'sent' | 'skipped' | 'failed', notificationId: string | null, now: number) {
141
- await this.run('UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=?', [status, notificationId, now, key]);
167
+ // a change row an edit queued again while it was sending stays pending
168
+ await this.run(`UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=? AND (kind!='change' OR status='sending')`, [status, notificationId, now, key]);
142
169
  }
143
170
  async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
144
171
  /** When a notice of this kind last went out (0 = never). */
@@ -148,13 +175,13 @@ export class PushStore {
148
175
  async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
149
176
 
150
177
  // ---- retention ----
151
- /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence and broadcast keys. */
178
+ /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence, broadcast and change keys. */
152
179
  async purge(now: number, devicesBefore: number): Promise<number> {
153
180
  const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
154
181
  await (await this.driver()).batch([
155
182
  { sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
156
183
  { sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
157
- { sql: `DELETE FROM push_queue WHERE kind IN ('soon','live') AND updated_at<?`, args: [now - 30 * 86400000] },
184
+ { sql: `DELETE FROM push_queue WHERE kind IN ('soon','live','change') AND updated_at<?`, args: [now - 30 * 86400000] },
158
185
  ]);
159
186
  return gone.length;
160
187
  }
@@ -11,11 +11,26 @@ export const PushAuto = z.object({
11
11
  startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
12
12
  /** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
13
13
  live: z.object({ enabled: z.boolean().default(false) }).default({}),
14
+ /** a published event's new date, time or place, or a cancellation, once its edits settle */
15
+ eventChanges: z.object({
16
+ calendars: keys.default([]),
17
+ /** only events with one of these tags (any case); [] = every event in the calendars */
18
+ tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
19
+ /** sent to these topics instead of the event's own (`events` and its calendars) */
20
+ topics: z.array(z.string().min(1).max(200)).max(10).default([]),
21
+ }).default({}),
14
22
  });
15
23
  export type PushAuto = z.infer<typeof PushAuto>;
16
24
  export const PushSettings = z.object({ auto: PushAuto.default({}) });
17
25
  export type PushSettings = z.infer<typeof PushSettings>;
18
26
 
27
+ /** An event's public schedule and place, as people last knew it (the payload of a queued change notice) */
28
+ export const EventSnapshot = z.object({
29
+ start: z.string(), end: z.string(), allDay: z.boolean(), timezone: z.string(), rrule: z.string(), exdates: z.array(z.string()),
30
+ cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
31
+ });
32
+ export type EventSnapshot = z.infer<typeof EventSnapshot>;
33
+
19
34
  export type PushPlatform = 'ios' | 'android';
20
35
  export interface PushDevice {
21
36
  id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
@@ -23,7 +38,7 @@ export interface PushDevice {
23
38
  channels: string[];
24
39
  createdAt: number; updatedAt: number; lastSeenAt: number;
25
40
  }
26
- export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement';
41
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change';
27
42
  export interface PushNotification {
28
43
  id: string; title: string; body: string; url: string;
29
44
  /** ['*'] = everyone */
@@ -35,7 +50,13 @@ export interface PushNotification {
35
50
  removed: number;
36
51
  error: string; author: string; createdAt: number; finishedAt: number | null;
37
52
  }
38
- export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live' | 'announcement'; docId: string; occurrence: number | null; status: string; createdAt: number }
53
+ export interface PushQueueRow {
54
+ key: string; kind: 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change'; docId: string; occurrence: number | null; status: string; createdAt: number;
55
+ /** change rows: the EventSnapshot JSON from before the first edit */
56
+ payload: string | null;
57
+ /** change rows: not sent before this (null = as soon as possible) */
58
+ dueAt: number | null;
59
+ }
39
60
  export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' | 'site'; description?: string }
40
61
  export interface PushStats {
41
62
  devices: number;
@@ -679,6 +679,12 @@ export const MIGRATIONS: Migration[] = [
679
679
  ] },
680
680
  // announcements show on public pages, the banner and the app API
681
681
  { version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers('announcements', ANNOUNCEMENT_VISIBLE_COLUMNS)] },
682
+ // event-change notices (PushAuto.eventChanges) wait in push_queue until the
683
+ // edits settle: payload = the schedule people last knew, due_at = send time
684
+ { version: 59, statements: [
685
+ `ALTER TABLE push_queue ADD COLUMN payload TEXT`,
686
+ `ALTER TABLE push_queue ADD COLUMN due_at INTEGER`,
687
+ ] },
682
688
  ];
683
689
 
684
690
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -1,7 +1,5 @@
1
- // UI strings for the search modal and /search page. There is no site-wide
2
- // translation catalog yet — search is the first public surface with
3
- // per-locale chrome, so the dict lives here until one exists. Locales fall
4
- // back to English.
1
+ // UI strings for the search modal and /search page, in the same languages as
2
+ // the rest of the layout (site-words.ts). Locales fall back to English.
5
3
  export interface SearchStrings {
6
4
  label: string;
7
5
  placeholder: string;
@@ -43,6 +41,45 @@ const STRINGS: Record<string, SearchStrings> = {
43
41
  results: 'resultados',
44
42
  button: 'Buscar',
45
43
  },
44
+ fr: {
45
+ label: 'Rechercher',
46
+ placeholder: 'Rechercher des articles, pages, étiquettes et auteurs',
47
+ posts: 'Articles',
48
+ pages: 'Pages',
49
+ events: 'Événements',
50
+ tags: 'Étiquettes',
51
+ authors: 'Auteurs',
52
+ empty: 'Aucun résultat pour',
53
+ result: 'résultat',
54
+ results: 'résultats',
55
+ button: 'Rechercher',
56
+ },
57
+ de: {
58
+ label: 'Suchen',
59
+ placeholder: 'Beiträge, Seiten, Schlagwörter und Autoren durchsuchen',
60
+ posts: 'Beiträge',
61
+ pages: 'Seiten',
62
+ events: 'Veranstaltungen',
63
+ tags: 'Schlagwörter',
64
+ authors: 'Autoren',
65
+ empty: 'Nichts gefunden für',
66
+ result: 'Ergebnis',
67
+ results: 'Ergebnisse',
68
+ button: 'Suchen',
69
+ },
70
+ pt: {
71
+ label: 'Buscar',
72
+ placeholder: 'Busque posts, páginas, tags e autores',
73
+ posts: 'Posts',
74
+ pages: 'Páginas',
75
+ events: 'Eventos',
76
+ tags: 'Tags',
77
+ authors: 'Autores',
78
+ empty: 'Nada encontrado para',
79
+ result: 'resultado',
80
+ results: 'resultados',
81
+ button: 'Buscar',
82
+ },
46
83
  };
47
84
 
48
85
  export const searchStrings = (locale: string): SearchStrings => STRINGS[locale] ?? STRINGS.en;
@@ -0,0 +1,62 @@
1
+ // Words the public layout (Site.astro) shows around every page: the skip
2
+ // link, header and phone-menu controls, the footer's built-in columns and
3
+ // links. Owner-written labels (menus, collections) keep their own words;
4
+ // search, announcements and live video have their own lists. Other languages
5
+ // fall back to English.
6
+ export interface SiteStrings {
7
+ skip: string;
8
+ /** landmark labels */
9
+ main: string;
10
+ menu: string;
11
+ language: string;
12
+ social: string;
13
+ openMenu: string;
14
+ closeMenu: string;
15
+ /** the light/dark button */
16
+ scheme: string;
17
+ /** footer column headings (columns footer) */
18
+ read: string;
19
+ site: string;
20
+ /** built-in legal links, added when the site has no menu item for them */
21
+ privacy: string;
22
+ terms: string;
23
+ /** the header's automatic list, when there's no header menu */
24
+ home: string;
25
+ links: string;
26
+ /** code block copy button */
27
+ copy: string;
28
+ copied: string;
29
+ /** gallery lightbox */
30
+ close: string;
31
+ }
32
+
33
+ const STRINGS: Record<string, SiteStrings> = {
34
+ en: {
35
+ skip: 'Skip to content', main: 'Main', menu: 'Menu', language: 'Language', social: 'Social', openMenu: 'Open menu', closeMenu: 'Close menu',
36
+ scheme: 'Toggle dark mode', read: 'Read', site: 'Site', privacy: 'Privacy', terms: 'Terms', home: 'Home', links: 'Links',
37
+ copy: 'Copy', copied: 'Copied', close: 'Close',
38
+ },
39
+ es: {
40
+ skip: 'Saltar al contenido', main: 'Principal', menu: 'Menú', language: 'Idioma', social: 'Redes sociales', openMenu: 'Abrir menú', closeMenu: 'Cerrar menú',
41
+ scheme: 'Cambiar entre modo claro y oscuro', read: 'Leer', site: 'Sitio', privacy: 'Privacidad', terms: 'Términos', home: 'Inicio', links: 'Enlaces',
42
+ copy: 'Copiar', copied: 'Copiado', close: 'Cerrar',
43
+ },
44
+ fr: {
45
+ skip: 'Aller au contenu', main: 'Principal', menu: 'Menu', language: 'Langue', social: 'Réseaux sociaux', openMenu: 'Ouvrir le menu', closeMenu: 'Fermer le menu',
46
+ scheme: 'Basculer entre mode clair et sombre', read: 'Lire', site: 'Site', privacy: 'Confidentialité', terms: 'Conditions', home: 'Accueil', links: 'Liens',
47
+ copy: 'Copier', copied: 'Copié', close: 'Fermer',
48
+ },
49
+ de: {
50
+ skip: 'Zum Inhalt springen', main: 'Hauptmenü', menu: 'Menü', language: 'Sprache', social: 'Soziale Medien', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen',
51
+ scheme: 'Hell- und Dunkelmodus umschalten', read: 'Lesen', site: 'Website', privacy: 'Datenschutz', terms: 'Nutzungsbedingungen', home: 'Startseite', links: 'Links',
52
+ copy: 'Kopieren', copied: 'Kopiert', close: 'Schließen',
53
+ },
54
+ pt: {
55
+ skip: 'Pular para o conteúdo', main: 'Principal', menu: 'Menu', language: 'Idioma', social: 'Redes sociais', openMenu: 'Abrir menu', closeMenu: 'Fechar menu',
56
+ scheme: 'Alternar entre modo claro e escuro', read: 'Ler', site: 'Site', privacy: 'Privacidade', terms: 'Termos', home: 'Início', links: 'Links',
57
+ copy: 'Copiar', copied: 'Copiado', close: 'Fechar',
58
+ },
59
+ };
60
+
61
+ export const siteStrings = (locale: string | null | undefined): SiteStrings =>
62
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
@@ -81,6 +81,13 @@
81
81
  .ma-check:has(input:disabled) { cursor: default; opacity: 0.6; }
82
82
  .ma-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: 1px; }
83
83
  .ma-lead { max-width: 240px; margin: 10px 0 0; }
84
+ .ma-fields { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0 16px; }
85
+ .ma-fields .ma-lead { flex: 1 1 200px; }
86
+ .ma-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 36px; padding: 4px 6px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-md); }
87
+ .ma-tags:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
88
+ .ma-tag { display: inline-flex; align-items: center; gap: 5px; background: var(--sel); color: var(--text-2); font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-full); }
89
+ .ma-tag button { background: none; border: 0; color: inherit; cursor: pointer; font-size: 10px; padding: 0; }
90
+ .ma-tags input { flex: 1; min-width: 70px; background: none !important; border: 0 !important; box-shadow: none !important; padding: 2px !important; }
84
91
  .ma .form-actions { margin-top: 6px; }
85
92
  .ma-saved { color: var(--green); font-size: 12.5px; font-weight: 500; }
86
93