@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.
- package/CHANGELOG.md +14 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +2 -2
- package/package.json +1 -1
- package/src/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +44 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -0
- package/src/lib/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-words.ts +62 -0
- package/src/styles/mobile-app.css +7 -0
package/src/lib/push/store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/lib/push/types.ts
CHANGED
|
@@ -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 {
|
|
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.
|
package/src/lib/search-i18n.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
// UI strings for the search modal and /search page
|
|
2
|
-
//
|
|
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
|
|