@k2b/cloud 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +97 -17
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +15 -7
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +56 -20
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +1 -0
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +23 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { decryptSecret } from "../secrets";
|
|
4
4
|
import { getNotificationChannel } from "./channels";
|
|
5
|
+
import { quietStateForEvent } from "./quiet";
|
|
5
6
|
|
|
6
7
|
const MAX_DELIVERY_ATTEMPTS = 5;
|
|
7
8
|
const BASE_RETRY_MS = 2_000;
|
|
@@ -19,12 +20,40 @@ type DeliveryRow = {
|
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export type DeliveryAttemptResult =
|
|
22
|
-
| { status: "skipped" | "delivered" | "failed" }
|
|
23
|
+
| { status: "skipped" | "delivered" | "suppressed" | "failed" }
|
|
23
24
|
| { status: "pending"; retryAfterMs: number }
|
|
24
25
|
| { status: "retry"; retryAfterMs: number; error: string };
|
|
25
26
|
|
|
26
27
|
const retryDelay = (attempt: number): number => Math.min(BASE_RETRY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_MS);
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Do not disturb and quiet hours hold back recommended browser notifications when they would go out. The
|
|
31
|
+
* notification is dropped, not postponed, so nothing arrives in a burst when the quiet time ends, and it is not
|
|
32
|
+
* rerouted: the event's waiting fallbacks end with it. Required deliveries are part of a protocol and still go.
|
|
33
|
+
*/
|
|
34
|
+
const holdBackForQuietTime = async (delivery: DeliveryRow): Promise<boolean> => {
|
|
35
|
+
if (delivery.channel !== "browser" || delivery.required) return false;
|
|
36
|
+
const quiet = await quietStateForEvent(delivery.event_id);
|
|
37
|
+
if (!quiet?.active) return false;
|
|
38
|
+
const code = quiet.reason === "doNotDisturb" ? "do_not_disturb" : "quiet_hours";
|
|
39
|
+
const message = quiet.reason === "doNotDisturb" ? "Held back by do not disturb." : "Held back during quiet hours.";
|
|
40
|
+
// One transaction: a crash in between must not leave fallbacks waiting, with their payload, behind a dropped push.
|
|
41
|
+
await sql.begin(async (tx) => {
|
|
42
|
+
await tx`
|
|
43
|
+
UPDATE notifications.deliveries
|
|
44
|
+
SET status = 'suppressed', attempt_count = attempt_count - 1, next_attempt_at = NULL,
|
|
45
|
+
error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
46
|
+
WHERE id = ${delivery.id}::uuid AND status = 'sending'
|
|
47
|
+
`;
|
|
48
|
+
await tx`
|
|
49
|
+
UPDATE notifications.deliveries
|
|
50
|
+
SET status = 'suppressed', error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
51
|
+
WHERE event_id = ${delivery.event_id}::uuid AND required = false AND status = 'deferred'
|
|
52
|
+
`;
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
56
|
+
|
|
28
57
|
const activateNextFallback = async (eventId: string): Promise<string[]> => {
|
|
29
58
|
const delivered = await sql<{ exists: boolean }[]>`
|
|
30
59
|
SELECT EXISTS (
|
|
@@ -85,6 +114,7 @@ export const processNotificationDelivery = async (
|
|
|
85
114
|
if (!delivery) return { status: "skipped" };
|
|
86
115
|
|
|
87
116
|
try {
|
|
117
|
+
if (await holdBackForQuietTime(delivery)) return { status: "suppressed" };
|
|
88
118
|
const driver = getNotificationChannel(delivery.channel);
|
|
89
119
|
if (!driver)
|
|
90
120
|
throw Object.assign(new Error(`Notification channel "${delivery.channel}" is unavailable`), { code: "channel_unavailable" });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { output, ZodType } from "zod";
|
|
3
|
+
import { isNotificationGroup, notificationGroupTag } from "../../contracts/notification-group";
|
|
3
4
|
import type {
|
|
4
5
|
BoundNotificationDefinition,
|
|
5
6
|
EmailNotificationPresentation,
|
|
@@ -15,6 +16,7 @@ import { encryptSecret } from "../secrets";
|
|
|
15
16
|
import { ensureNotificationDefinition } from "./catalog";
|
|
16
17
|
import { getNotificationChannel, type NotificationDestination, type ResolvedNotificationRecipient } from "./channels";
|
|
17
18
|
import { processNotificationDelivery } from "./dispatcher";
|
|
19
|
+
import { normalizeNotificationPreview } from "./preview";
|
|
18
20
|
import { enqueueNotificationDeliveries, enqueueNotificationDelivery } from "./runtime";
|
|
19
21
|
|
|
20
22
|
export type TypedNotificationDeliveryStatus = "deferred" | "pending" | "sending" | "delivered" | "suppressed" | "failed";
|
|
@@ -69,14 +71,38 @@ const preparationFailure = (input: { channel: string; required: boolean; routePr
|
|
|
69
71
|
errorMessage: "Notification delivery could not be prepared.",
|
|
70
72
|
});
|
|
71
73
|
|
|
72
|
-
const validatePresentation = (presentation: NotificationPresentation): NotificationPresentation => {
|
|
74
|
+
const validatePresentation = (presentation: NotificationPresentation, appId: string): NotificationPresentation => {
|
|
73
75
|
const title = presentation.title.trim();
|
|
74
76
|
const body = presentation.body?.trim();
|
|
77
|
+
if (presentation.preview !== undefined && typeof presentation.preview !== "string") {
|
|
78
|
+
throw new Error("Notification preview must be a string");
|
|
79
|
+
}
|
|
80
|
+
const preview = presentation.preview === undefined ? undefined : normalizeNotificationPreview(presentation.preview);
|
|
75
81
|
if (!title) throw new Error("Notification title is required");
|
|
76
82
|
if (title.length > 200) throw new Error("Notification title must not exceed 200 characters");
|
|
77
83
|
if (body && body.length > 4_000) throw new Error("Notification body must not exceed 4000 characters");
|
|
78
84
|
const targetHref = presentation.targetHref ? validateNotificationTargetHref(presentation.targetHref) : undefined;
|
|
79
|
-
|
|
85
|
+
if (presentation.group !== undefined && !isNotificationGroup(presentation.group)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Notification group must contain 1 to 128 characters using only letters, digits, dots, underscores, colons, or hyphens",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (presentation.group !== undefined && notificationGroupTag(appId, presentation.group) === null) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"Notification group requires an app ID that starts with a lowercase letter and contains only lowercase letters, digits, and hyphens",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (presentation.badge !== undefined && (!Number.isSafeInteger(presentation.badge) || presentation.badge < 0)) {
|
|
96
|
+
throw new Error("Notification badge must be a non-negative safe integer");
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
title,
|
|
100
|
+
...(body ? { body } : {}),
|
|
101
|
+
...(preview ? { preview } : {}),
|
|
102
|
+
...(targetHref ? { targetHref } : {}),
|
|
103
|
+
...(presentation.group !== undefined ? { group: presentation.group } : {}),
|
|
104
|
+
...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
|
|
105
|
+
};
|
|
80
106
|
};
|
|
81
107
|
|
|
82
108
|
const resolveRecipient = async (
|
|
@@ -172,6 +198,8 @@ const prepareChannel = async (input: {
|
|
|
172
198
|
|
|
173
199
|
try {
|
|
174
200
|
const emailPresentation = input.channel === "email" ? await input.emailPresentation?.() : undefined;
|
|
201
|
+
const { preview: _preview, ...otherPresentation } = input.presentation;
|
|
202
|
+
const presentation = input.channel === "browser" ? input.presentation : otherPresentation;
|
|
175
203
|
return await Promise.all(
|
|
176
204
|
destinations.map(async (destination) => ({
|
|
177
205
|
channel: input.channel,
|
|
@@ -179,7 +207,7 @@ const prepareChannel = async (input: {
|
|
|
179
207
|
destinationKey: destination.key,
|
|
180
208
|
destinationLabel: destination.label,
|
|
181
209
|
payloadEncrypted: await encryptSecret(
|
|
182
|
-
driver.createPayload({ presentation
|
|
210
|
+
driver.createPayload({ presentation, email: emailPresentation, destination, event: input.event }),
|
|
183
211
|
),
|
|
184
212
|
required: input.required,
|
|
185
213
|
routePriority: input.routePriority,
|
|
@@ -241,7 +269,7 @@ export const sendTypedNotification = async <
|
|
|
241
269
|
|
|
242
270
|
const data: output<S> = definition.data.parse(input.data);
|
|
243
271
|
const renderContext = { locale: normalizeLocale(input.locale) };
|
|
244
|
-
const presentation = validatePresentation(await definition.render(data, renderContext));
|
|
272
|
+
const presentation = validatePresentation(await definition.render(data, renderContext), definition.appId);
|
|
245
273
|
const resolved = await resolveRecipient(input.recipient);
|
|
246
274
|
const candidateEventId = crypto.randomUUID();
|
|
247
275
|
await ensureNotificationDefinition(definition);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const NOTIFICATION_PREVIEW_LIMIT = 200;
|
|
2
|
+
const PREVIEW_INPUT_LIMIT = 1_000;
|
|
3
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
4
|
+
|
|
5
|
+
export const normalizeNotificationPreview = (value: string): string | undefined => {
|
|
6
|
+
let prefix = value.slice(0, PREVIEW_INPUT_LIMIT);
|
|
7
|
+
const clipped = value.length > prefix.length;
|
|
8
|
+
if (clipped) {
|
|
9
|
+
// A boundary at or before the bound depends on at most one following code point (two UTF-16 units).
|
|
10
|
+
const last = segmenter.segment(value.slice(0, PREVIEW_INPUT_LIMIT + 2)).containing(PREVIEW_INPUT_LIMIT - 1);
|
|
11
|
+
if (last && last.index + last.segment.length > PREVIEW_INPUT_LIMIT) prefix = prefix.slice(0, last.index);
|
|
12
|
+
}
|
|
13
|
+
const text = prefix.replace(/[\s\p{Cc}]+/gu, " ").trim();
|
|
14
|
+
if (!text) return undefined;
|
|
15
|
+
if (!clipped && text.length <= NOTIFICATION_PREVIEW_LIMIT) return text;
|
|
16
|
+
|
|
17
|
+
let end = 0;
|
|
18
|
+
for (const { segment, index } of segmenter.segment(text)) {
|
|
19
|
+
if (index + segment.length > NOTIFICATION_PREVIEW_LIMIT - 1) break;
|
|
20
|
+
end = index + segment.length;
|
|
21
|
+
}
|
|
22
|
+
return `${text.slice(0, end).trimEnd()}…`;
|
|
23
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { dates } from "@k2b/stdlib";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import type { MutationResult } from "../../contracts/shared";
|
|
4
|
+
import {
|
|
5
|
+
NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS,
|
|
6
|
+
type NotificationQuietHours,
|
|
7
|
+
NotificationQuietHoursSchema,
|
|
8
|
+
type NotificationQuietSettings,
|
|
9
|
+
type NotificationQuietState,
|
|
10
|
+
type UpdateNotificationQuietSettings,
|
|
11
|
+
} from "../../contracts/user-notifications";
|
|
12
|
+
import { normalizeTimeZone } from "../../shared/time";
|
|
13
|
+
|
|
14
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
15
|
+
/** How far ahead `until` looks. A schedule that stays quiet for longer has no end worth naming. */
|
|
16
|
+
const LOOKAHEAD_DAYS = 8;
|
|
17
|
+
|
|
18
|
+
type QuietInput = { doNotDisturbUntil: Date | null; quietHours: NotificationQuietHours };
|
|
19
|
+
type Interval = { start: number; end: number };
|
|
20
|
+
|
|
21
|
+
const pad2 = (value: number): string => String(value).padStart(2, "0");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The quiet-hours intervals that touch `[from - 1 day, from + days]`, as instants. Each period belongs to the
|
|
25
|
+
* local day it starts on; wall-clock times are converted per date, so a period keeps its local times across
|
|
26
|
+
* daylight-saving changes. A time inside a skipped hour moves forward like the clock does. In a repeated hour a
|
|
27
|
+
* start takes the first occurrence and an end the second, so the period covers every instant the clock shows a
|
|
28
|
+
* time inside it.
|
|
29
|
+
*/
|
|
30
|
+
const quietHourIntervals = (hours: NotificationQuietHours, from: number, days: number): Interval[] => {
|
|
31
|
+
if (hours.periods.length === 0) return [];
|
|
32
|
+
const timeZone = normalizeTimeZone(hours.timeZone);
|
|
33
|
+
const [localDate = ""] = dates.instantToZonedInput(new Date(from), timeZone).split("T");
|
|
34
|
+
const [year, month, day] = localDate.split("-").map(Number) as [number, number, number];
|
|
35
|
+
const instant = (offset: number, time: string, disambiguation: "compatible" | "later"): number => {
|
|
36
|
+
const date = new Date(Date.UTC(year, month - 1, day + offset));
|
|
37
|
+
const wallClock = `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}T${time}`;
|
|
38
|
+
return Date.parse(dates.zonedDateTimeToInstant(wallClock, timeZone, { disambiguation }));
|
|
39
|
+
};
|
|
40
|
+
const intervals: Interval[] = [];
|
|
41
|
+
for (let offset = -1; offset <= days; offset += 1) {
|
|
42
|
+
const isoWeekday = new Date(Date.UTC(year, month - 1, day + offset)).getUTCDay() || 7;
|
|
43
|
+
for (const period of hours.periods) {
|
|
44
|
+
if (!period.days.includes(isoWeekday)) continue;
|
|
45
|
+
const start = instant(offset, period.start, "compatible");
|
|
46
|
+
const end = instant(period.end > period.start ? offset : offset + 1, period.end, "later");
|
|
47
|
+
if (end > start) intervals.push({ start, end });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return intervals;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Follow quiet hours from `from` across overlapping and adjoining periods. Returns `from` when it is not quiet
|
|
55
|
+
* and null when the quiet time does not end within the lookahead, for example with every day fully quiet.
|
|
56
|
+
*/
|
|
57
|
+
const quietHoursEnd = (hours: NotificationQuietHours, from: number): number | null => {
|
|
58
|
+
const intervals = quietHourIntervals(hours, from, LOOKAHEAD_DAYS);
|
|
59
|
+
const horizon = from + LOOKAHEAD_DAYS * DAY_MS;
|
|
60
|
+
let until = from;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const ends = intervals.filter((interval) => interval.start <= until && until < interval.end).map((interval) => interval.end);
|
|
63
|
+
if (ends.length === 0) return until;
|
|
64
|
+
until = Math.max(...ends);
|
|
65
|
+
if (until > horizon) return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether browser notifications are held back at `now`, why, and until when. Do not disturb names the reason
|
|
71
|
+
* while it lasts; `until` also covers quiet hours that continue when it ends.
|
|
72
|
+
*/
|
|
73
|
+
export const notificationQuietState = (input: QuietInput, now: Date = new Date()): NotificationQuietState => {
|
|
74
|
+
const at = now.getTime();
|
|
75
|
+
const iso = (instant: number | null) => (instant === null ? null : new Date(instant).toISOString());
|
|
76
|
+
const doNotDisturbUntil = input.doNotDisturbUntil?.getTime() ?? null;
|
|
77
|
+
if (doNotDisturbUntil !== null && doNotDisturbUntil > at) {
|
|
78
|
+
return { active: true, reason: "doNotDisturb", until: iso(quietHoursEnd(input.quietHours, doNotDisturbUntil)), nextStart: null };
|
|
79
|
+
}
|
|
80
|
+
const until = quietHoursEnd(input.quietHours, at);
|
|
81
|
+
if (until !== at) return { active: true, reason: "quietHours", until: iso(until), nextStart: null };
|
|
82
|
+
const starts = quietHourIntervals(input.quietHours, at, LOOKAHEAD_DAYS)
|
|
83
|
+
.map((interval) => interval.start)
|
|
84
|
+
.filter((start) => start > at && start <= at + LOOKAHEAD_DAYS * DAY_MS);
|
|
85
|
+
return { active: false, reason: null, until: null, nextStart: iso(starts.length > 0 ? Math.min(...starts) : null) };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type QuietRow = { do_not_disturb_until: Date | null; time_zone: string; periods: unknown };
|
|
89
|
+
|
|
90
|
+
/** Parse a stored row defensively: a schedule that no longer validates silences nothing. */
|
|
91
|
+
export const quietInputFromRow = (row: QuietRow | undefined, fallbackTimeZone = "UTC"): QuietInput => {
|
|
92
|
+
const parsed = row ? NotificationQuietHoursSchema.safeParse({ timeZone: row.time_zone, periods: row.periods }) : null;
|
|
93
|
+
return {
|
|
94
|
+
doNotDisturbUntil: row?.do_not_disturb_until ?? null,
|
|
95
|
+
quietHours: parsed?.success ? parsed.data : { timeZone: normalizeTimeZone(row?.time_zone ?? fallbackTimeZone), periods: [] },
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const toSettings = (input: QuietInput, now = new Date()): NotificationQuietSettings => ({
|
|
100
|
+
doNotDisturbUntil:
|
|
101
|
+
input.doNotDisturbUntil && input.doNotDisturbUntil.getTime() > now.getTime() ? input.doNotDisturbUntil.toISOString() : null,
|
|
102
|
+
quietHours: input.quietHours,
|
|
103
|
+
state: notificationQuietState(input, now),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** The person's quiet settings and current state. Without saved quiet hours, `fallbackTimeZone` is offered. */
|
|
107
|
+
const getQuietSettings = async (userId: string, fallbackTimeZone = "UTC"): Promise<NotificationQuietSettings> => {
|
|
108
|
+
const [row] = await sql<QuietRow[]>`
|
|
109
|
+
SELECT do_not_disturb_until, time_zone, periods FROM notifications.quiet_times WHERE user_id = ${userId}::uuid
|
|
110
|
+
`;
|
|
111
|
+
return toSettings(quietInputFromRow(row, fallbackTimeZone));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const updateQuietSettings = async (config: {
|
|
115
|
+
userId: string;
|
|
116
|
+
update: UpdateNotificationQuietSettings;
|
|
117
|
+
fallbackTimeZone?: string;
|
|
118
|
+
}): Promise<MutationResult<NotificationQuietSettings>> => {
|
|
119
|
+
const { doNotDisturbUntil, quietHours } = config.update;
|
|
120
|
+
const until = doNotDisturbUntil ? new Date(doNotDisturbUntil) : null;
|
|
121
|
+
if (until && until.getTime() <= Date.now()) {
|
|
122
|
+
return { ok: false, error: "Do not disturb must end in the future", status: 400 };
|
|
123
|
+
}
|
|
124
|
+
if (until && until.getTime() > Date.now() + NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS * DAY_MS) {
|
|
125
|
+
return { ok: false, error: "Do not disturb must end within a year", status: 400 };
|
|
126
|
+
}
|
|
127
|
+
const timeZone = normalizeTimeZone(quietHours?.timeZone ?? config.fallbackTimeZone);
|
|
128
|
+
const periods = quietHours ? JSON.stringify(quietHours.periods) : "[]";
|
|
129
|
+
await sql`
|
|
130
|
+
INSERT INTO notifications.quiet_times (user_id, do_not_disturb_until, time_zone, periods, updated_at)
|
|
131
|
+
VALUES (${config.userId}::uuid, ${until}::timestamptz, ${timeZone}, (${periods}::text)::jsonb, now())
|
|
132
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
133
|
+
do_not_disturb_until = CASE WHEN ${doNotDisturbUntil !== undefined} THEN EXCLUDED.do_not_disturb_until
|
|
134
|
+
ELSE notifications.quiet_times.do_not_disturb_until END,
|
|
135
|
+
time_zone = CASE WHEN ${quietHours !== undefined} THEN EXCLUDED.time_zone ELSE notifications.quiet_times.time_zone END,
|
|
136
|
+
periods = CASE WHEN ${quietHours !== undefined} THEN EXCLUDED.periods ELSE notifications.quiet_times.periods END,
|
|
137
|
+
updated_at = now()
|
|
138
|
+
`;
|
|
139
|
+
return { ok: true, data: await getQuietSettings(config.userId, config.fallbackTimeZone) };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The quiet state of an event's recipient, or null when the recipient is not a Cloud user. */
|
|
143
|
+
export const quietStateForEvent = async (eventId: string, now = new Date()): Promise<NotificationQuietState | null> => {
|
|
144
|
+
const [row] = await sql<QuietRow[]>`
|
|
145
|
+
SELECT q.do_not_disturb_until, q.time_zone, q.periods
|
|
146
|
+
FROM notifications.events e
|
|
147
|
+
JOIN notifications.quiet_times q ON q.user_id = e.recipient_user_id
|
|
148
|
+
WHERE e.id = ${eventId}::uuid
|
|
149
|
+
`;
|
|
150
|
+
return row ? notificationQuietState(quietInputFromRow(row), now) : null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const userQuietTimes = { get: getQuietSettings, update: updateQuietSettings } as const;
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import { normalizeLocale } from "../../shared/locale";
|
|
15
15
|
import { toPgTextArray } from "../postgres";
|
|
16
16
|
import { listNotificationChannels } from "./channels";
|
|
17
|
+
import { userQuietTimes } from "./quiet";
|
|
17
18
|
|
|
18
19
|
type PreferenceRow = {
|
|
19
20
|
id: string;
|
|
@@ -164,6 +165,10 @@ const publicDeliveryError = (code: string | null, status: NotificationDeliverySt
|
|
|
164
165
|
return { code, message: "This delivery channel is currently unavailable." };
|
|
165
166
|
case "no_endpoint":
|
|
166
167
|
return { code, message: "No registered destination is available for this channel." };
|
|
168
|
+
case "do_not_disturb":
|
|
169
|
+
return { code, message: "Held back because do not disturb was on." };
|
|
170
|
+
case "quiet_hours":
|
|
171
|
+
return { code, message: "Held back during your quiet hours." };
|
|
167
172
|
case "fallback_not_needed":
|
|
168
173
|
return { code, message: "Another preferred channel delivered this notification." };
|
|
169
174
|
case "lease_recovered":
|
|
@@ -245,4 +250,5 @@ const listHistory = async (config: {
|
|
|
245
250
|
export const userNotifications = {
|
|
246
251
|
preferences: { list: listPreferences, set: setPreference, reset: resetPreference },
|
|
247
252
|
history: { list: listHistory },
|
|
253
|
+
quiet: userQuietTimes,
|
|
248
254
|
} as const;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LiquidTemplateOptions, renderLiquidTemplate } from "../../shared/template-rendering";
|
|
1
|
+
import { LiquidTemplateError, type LiquidTemplateOptions, renderLiquidTemplate } from "../../shared/template-rendering";
|
|
2
2
|
import {
|
|
3
3
|
type GotenbergConfig,
|
|
4
4
|
GotenbergRenderError,
|
|
@@ -84,6 +84,7 @@ export const renderTemplatePdfPreview = async (
|
|
|
84
84
|
ok: false,
|
|
85
85
|
error: {
|
|
86
86
|
phase: "template",
|
|
87
|
+
...(error instanceof LiquidTemplateError ? { code: error.reason } : {}),
|
|
87
88
|
message: error instanceof Error ? error.message : "Template rendering failed.",
|
|
88
89
|
status: 400,
|
|
89
90
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { err, fail, ok, type PageParams, type Paginated } from "@k2b/stdlib";
|
|
11
11
|
import { paginateItems } from "../../server/services";
|
|
12
|
+
import { ImageMetadataError } from "../image-metadata";
|
|
12
13
|
import type { SettingEntry } from ".";
|
|
13
14
|
import * as settingsPrimitives from ".";
|
|
14
15
|
import { SETTINGS_MAP, validateSettingValue } from "./defaults";
|
|
@@ -66,6 +67,8 @@ export const settingsService = {
|
|
|
66
67
|
await settingsPrimitives.set(config.key, validated.value);
|
|
67
68
|
return ok(undefined);
|
|
68
69
|
} catch (error) {
|
|
70
|
+
// A Result cannot carry 422; the typed HTTPException keeps its status through Hono.
|
|
71
|
+
if (error instanceof ImageMetadataError) throw error;
|
|
69
72
|
const message = error instanceof Error ? error.message : String(error);
|
|
70
73
|
if (message.startsWith("Unknown setting:")) {
|
|
71
74
|
return fail(err.badInput(message));
|
|
@@ -17,6 +17,7 @@ import { sql } from "bun";
|
|
|
17
17
|
import { HTTPException } from "hono/http-exception";
|
|
18
18
|
import { hasRole, type User } from "../../contracts/shared";
|
|
19
19
|
import { claimCacheFill, completeCacheFill, MISSING_SETTING } from "../cache-fill";
|
|
20
|
+
import { stripImageDataUrlMetadata } from "../image-metadata";
|
|
20
21
|
import { toPgTextArray } from "../postgres";
|
|
21
22
|
import { requestCacheRedis } from "../request-cache-redis";
|
|
22
23
|
import { decryptValue, encryptValue } from "./crypto";
|
|
@@ -193,7 +194,9 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient): Pro
|
|
|
193
194
|
const validated = validateSettingValue(def, value);
|
|
194
195
|
if (!validated.ok) throw new Error(validated.error);
|
|
195
196
|
|
|
196
|
-
const
|
|
197
|
+
const storedValue =
|
|
198
|
+
def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
|
|
199
|
+
const encrypted = await encryptValue(storedValue);
|
|
197
200
|
await (db ?? sql)`
|
|
198
201
|
INSERT INTO settings.entries (key, value, updated_at)
|
|
199
202
|
VALUES (${key}, ${encrypted}, now())
|
|
@@ -36,7 +36,11 @@ const mapPlace = (place: GeoPlace): WeatherCity => ({
|
|
|
36
36
|
const getGeoBaseUrl = async (): Promise<Result<string>> => {
|
|
37
37
|
const geoUrl = (await settings.get<string>("weather.geo_url")).trim();
|
|
38
38
|
if (!geoUrl) {
|
|
39
|
-
return fail(
|
|
39
|
+
return fail({
|
|
40
|
+
code: "WEATHER_CITY_SEARCH_NOT_CONFIGURED",
|
|
41
|
+
message: "Geo API URL is not configured. Set weather.geo_url.",
|
|
42
|
+
status: 500,
|
|
43
|
+
});
|
|
40
44
|
}
|
|
41
45
|
return ok(geoUrl.replace(/\/$/, ""));
|
|
42
46
|
};
|
|
@@ -69,7 +73,7 @@ const list = async (config: {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
const baseUrlResult = await getGeoBaseUrl();
|
|
72
|
-
if (!baseUrlResult.ok) return
|
|
76
|
+
if (!baseUrlResult.ok) return baseUrlResult;
|
|
73
77
|
const geoUrl = baseUrlResult.data;
|
|
74
78
|
const result = await geoService.place.list({
|
|
75
79
|
baseUrl: geoUrl,
|
|
@@ -57,7 +57,7 @@ Most replies need no offer. Make at most one offer, in the last sentence of the
|
|
|
57
57
|
- The user corrected a result that their own Skill shaped: offer to add the correction to that Skill.
|
|
58
58
|
{%- endif %}
|
|
59
59
|
{%- if memoryToolEnabled %}
|
|
60
|
-
- The user corrected the tone of a result
|
|
60
|
+
- The user corrected the tone of a result, such as a mail that is too formal, without stating a lasting rule: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
|
|
61
61
|
{%- endif %}
|
|
62
62
|
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}scheduled task.
|
|
63
63
|
- A concrete next step clearly saves the user real work they may not know you can do: offer it, such as a manual follow-up the user mentioned, drafting the reply, or turning findings into Space tasks.
|
|
@@ -66,7 +66,7 @@ Offer only what the Skills, tool hints, and apps in this prompt support. Do not
|
|
|
66
66
|
Offer a new Skill only when no listed Skill{% if skillSearch %} or search_skills result{% endif %} already covers the approach; a single preference belongs in memory, not a Skill. Built-in and shared Skills change for everyone, so change one only when the user asks for that. After a yes to a Skill offer, load skill-creator and draft from this conversation; its create or update review is the confirmation.
|
|
67
67
|
{%- endif %}
|
|
68
68
|
Make no offer for a simple fact or small talk, while you ask a question or wait for approval, after a failure or blocker, when your previous reply already ended with an offer, when the user declined it in this chat, or when the user asked for no suggestions or only a short answer. A request to shorten a draft or text is a correction, not a request for no offers. Organization, Project, and user instructions about suggestions take precedence.
|
|
69
|
-
When the user asks what you can do, first take one quick look, where your tools allow it, at
|
|
69
|
+
When the user asks what you can do, lead with what they already work with: use the Recent work section when it is present; otherwise first take one quick look, where your tools allow it, at their Spaces, recent chats, or files. Give three to five concrete examples grounded in that work and in the Skills, tools, and apps available to you; leave out apps where they have no data, such as mail without a mailbox.
|
|
70
70
|
{%- endif %}
|
|
71
71
|
{%- if tools.size > 0 %}
|
|
72
72
|
|
package/src/shared/redirect.ts
CHANGED
|
@@ -9,7 +9,8 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
|
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const url = new URL(trimmed, REDIRECT_BASE);
|
|
12
|
-
|
|
12
|
+
// Dot segments can resolve to `//host`, which a browser reads as another host.
|
|
13
|
+
if (url.origin !== REDIRECT_BASE || url.pathname.startsWith("//")) return undefined;
|
|
13
14
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
14
15
|
} catch {
|
|
15
16
|
return undefined;
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AssertionError,
|
|
3
|
+
Context,
|
|
4
|
+
defaultOperators,
|
|
5
|
+
type Emitter,
|
|
6
|
+
type FilterImplOptions,
|
|
7
|
+
Liquid,
|
|
8
|
+
LiquidError,
|
|
9
|
+
type Operators,
|
|
10
|
+
toValue,
|
|
11
|
+
toValueSync,
|
|
12
|
+
} from "liquidjs";
|
|
2
13
|
|
|
3
14
|
const TEMPLATE_MAX_BYTES = 200_000;
|
|
4
15
|
const RENDER_MAX_BYTES = 300_000;
|
|
16
|
+
// Synchronous rendering blocks every request in the app process. Legitimate templates
|
|
17
|
+
// take milliseconds; keep this well below normal request latency budgets.
|
|
18
|
+
const RENDER_TIMEOUT_MS = 1_000;
|
|
5
19
|
|
|
6
20
|
type LiquidEngine = Liquid;
|
|
7
|
-
export type LiquidTemplateErrorReason = "render_too_large";
|
|
21
|
+
export type LiquidTemplateErrorReason = "render_too_large" | "render_timeout" | "render_memory_limit";
|
|
8
22
|
|
|
9
23
|
export class LiquidTemplateError extends Error {
|
|
10
24
|
constructor(
|
|
@@ -22,6 +36,7 @@ export type LiquidTemplateOptions = {
|
|
|
22
36
|
escapeOutput?: boolean | ((value: unknown) => string);
|
|
23
37
|
templateMaxBytes?: number;
|
|
24
38
|
renderMaxBytes?: number;
|
|
39
|
+
renderTimeoutMs?: number;
|
|
25
40
|
memoryLimit?: number;
|
|
26
41
|
};
|
|
27
42
|
|
|
@@ -52,6 +67,35 @@ const TEMPLATE_TAG_RE = /{%-?\s*([A-Za-z_][A-Za-z0-9_]*)\b/g;
|
|
|
52
67
|
|
|
53
68
|
const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
54
69
|
|
|
70
|
+
class BoundedEmitter implements Emitter {
|
|
71
|
+
buffer = "";
|
|
72
|
+
private bytes = 0;
|
|
73
|
+
private lastCodeUnit = 0;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly maxBytes: number) {}
|
|
76
|
+
|
|
77
|
+
write(input: unknown): void {
|
|
78
|
+
const value: unknown = toValue(input);
|
|
79
|
+
// Match LiquidJS's emitter: nil is empty, arrays concatenate their elements.
|
|
80
|
+
if (value === null || value === undefined) return;
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
for (const item of value) this.write(item);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const chunk = String(value);
|
|
86
|
+
if (!chunk) return;
|
|
87
|
+
const last = this.lastCodeUnit;
|
|
88
|
+
const first = chunk.charCodeAt(0);
|
|
89
|
+
// A surrogate pair split across writes encodes as four bytes, rather than six.
|
|
90
|
+
const paired = last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff;
|
|
91
|
+
const bytes = this.bytes + byteLength(chunk) - (paired ? 2 : 0);
|
|
92
|
+
if (bytes > this.maxBytes) throw new LiquidTemplateError("render_too_large", "Rendered template is too large");
|
|
93
|
+
this.bytes = bytes;
|
|
94
|
+
this.lastCodeUnit = chunk.charCodeAt(chunk.length - 1);
|
|
95
|
+
this.buffer += chunk;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
export const escapeTemplateOutput = (value: unknown): string =>
|
|
56
100
|
String(value).replace(/[&<>"'`=/]/g, (char) => {
|
|
57
101
|
switch (char) {
|
|
@@ -76,16 +120,34 @@ export const escapeTemplateOutput = (value: unknown): string =>
|
|
|
76
120
|
}
|
|
77
121
|
});
|
|
78
122
|
|
|
123
|
+
const expandedTextLength = (input: unknown): number => {
|
|
124
|
+
const value: unknown = toValue(input);
|
|
125
|
+
if (typeof value === "string") return value.length;
|
|
126
|
+
if (Array.isArray(value)) return value.reduce((length: number, item: unknown) => length + expandedTextLength(item), 0);
|
|
127
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).length;
|
|
128
|
+
return 0;
|
|
129
|
+
};
|
|
130
|
+
|
|
79
131
|
const createEngine = (options: LiquidTemplateOptions = {}) => {
|
|
80
132
|
const outputEscape =
|
|
81
133
|
typeof options.escapeOutput === "function" ? options.escapeOutput : options.escapeOutput === false ? undefined : escapeTemplateOutput;
|
|
134
|
+
const operators: Operators = {};
|
|
135
|
+
for (const [name, handler] of Object.entries(defaultOperators)) {
|
|
136
|
+
operators[name] = (...args: [unknown, Context] | [unknown, unknown, Context]): boolean => {
|
|
137
|
+
const result: boolean = Reflect.apply(handler, undefined, args);
|
|
138
|
+
const context = args[args.length - 1];
|
|
139
|
+
if (context instanceof Context) context.renderLimit.check(performance.now());
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
82
143
|
const engine = new Liquid({
|
|
144
|
+
operators,
|
|
83
145
|
strictVariables: true,
|
|
84
146
|
strictFilters: true,
|
|
85
147
|
ownPropertyOnly: true,
|
|
86
148
|
...(outputEscape ? { outputEscape } : {}),
|
|
87
149
|
parseLimit: options.templateMaxBytes ?? TEMPLATE_MAX_BYTES,
|
|
88
|
-
renderLimit: options.
|
|
150
|
+
renderLimit: options.renderTimeoutMs ?? RENDER_TIMEOUT_MS,
|
|
89
151
|
memoryLimit: options.memoryLimit ?? 2_000_000,
|
|
90
152
|
cache: false,
|
|
91
153
|
dynamicPartials: false,
|
|
@@ -94,7 +156,42 @@ const createEngine = (options: LiquidTemplateOptions = {}) => {
|
|
|
94
156
|
partials: [],
|
|
95
157
|
});
|
|
96
158
|
|
|
159
|
+
const renderTemplates = engine.renderer.renderTemplates.bind(engine.renderer);
|
|
160
|
+
engine.renderer.renderTemplates = function* (templates, context, emitter) {
|
|
161
|
+
// Capture renders without an emitter; bound its intermediate buffer as well.
|
|
162
|
+
const result = yield* renderTemplates(
|
|
163
|
+
templates,
|
|
164
|
+
context,
|
|
165
|
+
emitter ?? new BoundedEmitter(Math.max(options.renderMaxBytes ?? RENDER_MAX_BYTES, RENDER_MAX_BYTES)),
|
|
166
|
+
);
|
|
167
|
+
// LiquidJS checks before templates and every loop body (even an empty one).
|
|
168
|
+
// Check after them too, so a slow final filter cannot return over budget.
|
|
169
|
+
context.renderLimit.check(performance.now());
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (const name of ["push", "concat", "unshift", "map"]) {
|
|
174
|
+
const filter = engine.filters[name];
|
|
175
|
+
if (!filter) continue;
|
|
176
|
+
const handler = typeof filter === "function" ? filter : filter.handler;
|
|
177
|
+
const bounded: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
|
|
178
|
+
const result: unknown = yield handler.apply(this, args);
|
|
179
|
+
// Charge added text only; charging the left array again would compound.
|
|
180
|
+
this.context.memoryLimit.use(expandedTextLength(name === "map" ? result : args[1]));
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
engine.registerFilter(name, typeof filter === "function" ? bounded : { ...filter, handler: bounded });
|
|
184
|
+
}
|
|
97
185
|
for (const [name, filter] of Object.entries(options.filters ?? {})) engine.registerFilter(name, filter);
|
|
186
|
+
for (const [name, filter] of Object.entries(engine.filters)) {
|
|
187
|
+
const handler = typeof filter === "function" ? filter : filter.handler;
|
|
188
|
+
const timed: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
|
|
189
|
+
const result: unknown = yield handler.apply(this, args);
|
|
190
|
+
this.context.renderLimit.check(performance.now());
|
|
191
|
+
return result;
|
|
192
|
+
};
|
|
193
|
+
engine.registerFilter(name, typeof filter === "function" ? timed : { ...filter, handler: timed });
|
|
194
|
+
}
|
|
98
195
|
return engine;
|
|
99
196
|
};
|
|
100
197
|
|
|
@@ -126,11 +223,27 @@ export const validateLiquidTemplate = (
|
|
|
126
223
|
export const renderLiquidTemplate = (template: string, data: Record<string, unknown>, options: LiquidTemplateOptions = {}): string => {
|
|
127
224
|
const valid = validateLiquidTemplate(template, options);
|
|
128
225
|
if (!valid.ok) throw new Error(valid.error);
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
226
|
+
const engine = engineFor(options);
|
|
227
|
+
const context = new Context(data, engine.options, { sync: true }, { liquid: engine });
|
|
228
|
+
const emitter = new BoundedEmitter(options.renderMaxBytes ?? RENDER_MAX_BYTES);
|
|
229
|
+
try {
|
|
230
|
+
toValueSync(engine.renderer.renderTemplates(engine.parse(template), context, emitter));
|
|
231
|
+
return emitter.buffer;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// LiquidJS wraps emitter and limiter failures in RenderError.originalError.
|
|
234
|
+
let original = error;
|
|
235
|
+
while (original instanceof LiquidError && original.originalError) original = original.originalError;
|
|
236
|
+
if (original instanceof LiquidTemplateError) throw original;
|
|
237
|
+
if (original instanceof AssertionError) {
|
|
238
|
+
if (original.message === "template render limit exceeded") {
|
|
239
|
+
throw new LiquidTemplateError("render_timeout", "Template rendering exceeded its time budget");
|
|
240
|
+
}
|
|
241
|
+
if (original.message === "memory alloc limit exceeded") {
|
|
242
|
+
throw new LiquidTemplateError("render_memory_limit", "Template rendering exceeded its memory limit");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
132
246
|
}
|
|
133
|
-
return rendered;
|
|
134
247
|
};
|
|
135
248
|
|
|
136
249
|
export const liquidTemplateVariables = (template: string, options: LiquidTemplateOptions = {}): string[] => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useLocale } from "@k2b/ui";
|
|
2
|
+
import { Show } from "solid-js";
|
|
3
|
+
import { appBadgeCount } from "./app-badges";
|
|
4
|
+
import { railMessages } from "./rail-messages";
|
|
5
|
+
|
|
6
|
+
/** What a screen reader hears after the app name; empty without a count. Says no more than the "99+" shown. */
|
|
7
|
+
export const useAppBadgeDescription = (endpoint: () => string | undefined) => {
|
|
8
|
+
const locale = useLocale();
|
|
9
|
+
return () => {
|
|
10
|
+
const count = appBadgeCount(endpoint());
|
|
11
|
+
const t = railMessages.resolve([locale()]).t;
|
|
12
|
+
return count > 99 ? t.badgeMany : count > 0 ? t.badge({ count }) : "";
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Count on the corner of an app icon. It is positioned over the icon and never
|
|
18
|
+
* takes layout space, so appearing, growing to "99+", or clearing moves
|
|
19
|
+
* nothing. Decorative: the link carries the description.
|
|
20
|
+
*/
|
|
21
|
+
export function AppBadge(props: { endpoint?: string }) {
|
|
22
|
+
const count = () => appBadgeCount(props.endpoint);
|
|
23
|
+
return (
|
|
24
|
+
<Show when={count() > 0}>
|
|
25
|
+
<span class="cloud-app-badge" aria-hidden="true">
|
|
26
|
+
{count() > 99 ? "99+" : count()}
|
|
27
|
+
</span>
|
|
28
|
+
</Show>
|
|
29
|
+
);
|
|
30
|
+
}
|