@cosmicdrift/kumiko-samples 0.377.0 → 0.378.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
- package/packages/bundled-features/src/config/web/i18n.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
- package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
- package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.378.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.378.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Stock caps are atomic under parallel writes",
|
|
6
|
+
"detail": "withStockCap now takes a transaction-scoped advisory lock per tenant and cap, so concurrent creates cannot both pass the check and exceed the cap."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.378.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "withStockCap no longer widens the handler's grants",
|
|
12
|
+
"detail": "withStockCap serialises through TenantDb.advisoryXactLock instead of ctx.db.unsafeRaw(), so wrapped handlers no longer receive an implicit unsafeRaw grant."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.370.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -24,6 +24,10 @@ export type CapLimitContext = {
|
|
|
24
24
|
readonly config?: ConfigAccessor;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// pg_advisory_xact_lock namespace (int4): 'scap' as ASCII, disjoint from the
|
|
28
|
+
// framework's other fixed advisory-lock keys.
|
|
29
|
+
const STOCK_CAP_LOCK_NAMESPACE = 0x73636170;
|
|
30
|
+
|
|
27
31
|
// What the cap guards need from a write handler's ctx.
|
|
28
32
|
export type CapGuardContext = CapLimitContext & { readonly db: TenantDb };
|
|
29
33
|
|
|
@@ -53,6 +57,7 @@ export function createStockCapGuard<TCaps>(
|
|
|
53
57
|
spec: StockCapSpec<TCaps>,
|
|
54
58
|
context: CapLimitContext = {},
|
|
55
59
|
): Promise<WriteFailure | null> {
|
|
60
|
+
// Lock-free on its own: a direct call outside withStockCap is not atomic.
|
|
56
61
|
const caps = await resolveTierCaps(db, context);
|
|
57
62
|
const current = await db.count(spec.table, { ...spec.where, tenantId: db.tenantId });
|
|
58
63
|
const { state, limit } = enforceStockCap({
|
|
@@ -73,6 +78,10 @@ export function createStockCapGuard<TCaps>(
|
|
|
73
78
|
return {
|
|
74
79
|
...handler,
|
|
75
80
|
handler: async (event, ctx) => {
|
|
81
|
+
// ctx.db is the handler's transaction, so the xact lock releases with its commit or
|
|
82
|
+
// rollback. A handler wrapped in several caps must list them in the same order
|
|
83
|
+
// everywhere; Postgres aborts one side of an inverse order with a deadlock error.
|
|
84
|
+
await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, spec.code);
|
|
76
85
|
const failure = await checkStockCap(ctx.db, spec, { config: ctx.config });
|
|
77
86
|
return failure ?? handler.handler(event, ctx);
|
|
78
87
|
},
|
|
@@ -34,6 +34,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
34
34
|
"config.settings.extensionSelectorHint":
|
|
35
35
|
"Choose a provider. Its settings appear below and are saved together with it.",
|
|
36
36
|
"config.settings.provider": "Provider",
|
|
37
|
+
"config.settings.provider.platformDefault": "Platform default",
|
|
37
38
|
"config.settings.audience.system":
|
|
38
39
|
"Applies to every tenant unless a tenant sets its own value.",
|
|
39
40
|
"config.settings.audience.tenant":
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Mailbox list accepts the screen's limit",
|
|
6
|
+
"detail": "account:list accepted an empty payload only, so the projectionList screen's limit was rejected. It now takes limit up to MAX_LIST_LIMIT and returns the visible mailboxes ordered by id."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.377.0",
|
|
4
10
|
"type": "improvement",
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts
CHANGED
|
@@ -11,13 +11,16 @@ import {
|
|
|
11
11
|
configuredPiiSubjectKms,
|
|
12
12
|
decryptPiiFieldValues,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
14
|
-
import type
|
|
14
|
+
import { MAX_LIST_LIMIT, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import * as z from "zod";
|
|
16
16
|
import { MAIL_ACCOUNT_PII_FIELDS } from "../entities.ts";
|
|
17
17
|
import { mailAccountsProjectionTable } from "../projection.ts";
|
|
18
18
|
import { isVisibleToCaller } from "./scope-visibility.ts";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// The projectionList screen always sends `limit`; unknown keys stay rejected.
|
|
21
|
+
const listAccountsSchema = z
|
|
22
|
+
.object({ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT) })
|
|
23
|
+
.strict();
|
|
21
24
|
|
|
22
25
|
export const listAccountsQuery: QueryHandlerDef = {
|
|
23
26
|
name: "account:list",
|
|
@@ -25,11 +28,15 @@ export const listAccountsQuery: QueryHandlerDef = {
|
|
|
25
28
|
access: { roles: ["SystemAdmin", "TenantAdmin", "User"] },
|
|
26
29
|
description:
|
|
27
30
|
"Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers; use it for the mail connect and settings views.",
|
|
28
|
-
handler: async (
|
|
31
|
+
handler: async (query, ctx) => {
|
|
32
|
+
const { limit } = listAccountsSchema.parse(query.payload);
|
|
29
33
|
const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
|
|
30
34
|
tenantId: ctx.user.tenantId,
|
|
31
35
|
});
|
|
32
|
-
const rows = allRows
|
|
36
|
+
const rows = allRows
|
|
37
|
+
.filter((row) => isVisibleToCaller(row, ctx.user))
|
|
38
|
+
.sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))
|
|
39
|
+
.slice(0, limit);
|
|
33
40
|
const piiKms = configuredPiiSubjectKms();
|
|
34
41
|
if (!piiKms) return { rows };
|
|
35
42
|
const decryptedRows = await Promise.all(
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Email sending offers a platform default and an own SMTP server",
|
|
6
|
+
"detail": "The provider field is labelled \"Send via\" with the options \"Platform sending (default)\" (empty, saved as \"\") and \"Own SMTP server\"; the SMTP fields show only while the own server is picked."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export const MAIL_FOUNDATION_I18N = {
|
|
2
2
|
"screen:mail-foundation-tenant.title": { en: "Email sending" },
|
|
3
3
|
"mail-foundation.settings": { en: "Email sending" },
|
|
4
|
-
"mail-foundation.provider": { en: "
|
|
4
|
+
"mail-foundation.provider": { en: "Send via" },
|
|
5
5
|
"mail-foundation.provider.description": {
|
|
6
|
-
en: "
|
|
6
|
+
en: "Choose who sends your emails. The platform default needs no settings of your own.",
|
|
7
7
|
},
|
|
8
|
+
"mail-foundation.provider.platformDefault": { en: "Platform sending (default)" },
|
|
8
9
|
} as const;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const MAIL_TRANSPORT_SMTP_I18N = {
|
|
2
2
|
// Also the option label of mail-foundation's provider select.
|
|
3
|
-
"mail-transport-smtp.settings": { en: "SMTP server" },
|
|
3
|
+
"mail-transport-smtp.settings": { en: "Own SMTP server" },
|
|
4
4
|
"mail-transport-smtp.settings.secrets": { en: "SMTP sign-in" },
|
|
5
5
|
"mail-transport-smtp.settings.description": {
|
|
6
6
|
en: "The connection details come from your mail provider, for example Brevo, Postmark, Amazon SES or your own server.",
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Document PDFs embed the images of a record",
|
|
6
|
+
"detail": "A template variable of type images (templateVariable.images()) holds FileRef ids of the record. The new Images block (div[data-images], PDF templates only, rejected for mail kinds) renders them as data-URI images in the real PDF render. Only files of the rendering tenant are read, only png, jpeg, gif and webp, at most 24 images and 20 MB per document; missing, foreign, oversized or non-image ids are dropped silently. Previews and draft PDFs never read files and show one placeholder box per block. An images variable never renders as inline text. i18n keys template-resolver.documentEditor.images, template-resolver.documentEditor.imagesVariable and templateResolver.errors.document.imagesBlockPdfOnly ship in de, es, fr and it.",
|
|
7
|
+
"migration": "Declare a template variable with type `images`, place the Images block, and pass FileRef ids of the record in `variables`. Images resolve only in the real PDF render, previews show placeholders. The ids must come from the record on the server, never from client input: a resolved id is read for the whole tenant without an entity access check."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.378.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Template editing is SystemAdmin-only unless the app unlocks it",
|
|
13
|
+
"detail": "Every tenant write handler, the overview and resource upload accept SystemAdmin only, plus the roles of the tenantEditable entries. A write on a template that is not unlocked fails with templateNotTenantEditable. The test send follows the same rule. For a non-SystemAdmin an unknown template id answers 403 like a locked one, so the response does not reveal whether it exists. Collections without an access rule default to SystemAdmin.",
|
|
14
|
+
"migration": "Where TenantAdmins must keep editing a template, pass tenantEditable to createTemplateResolverFeature, for example tenantEditable: [{ kind: \"mail\", slug: \"billing:notify:invoice-issued\", access: { roles: [\"TenantAdmin\"] } }]. Give template collections an explicit access rule if tenants should manage them."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.378.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "send-test-status query for the template test mail",
|
|
20
|
+
"detail": "template-resolver:query:send-test-status answers whether a test mail can be sent (canSend) and, if not, why (no_channel or no_address), so clients can disable the action instead of failing on send."
|
|
21
|
+
},
|
|
2
22
|
{
|
|
3
23
|
"version": "0.377.0",
|
|
4
24
|
"type": "improvement",
|
|
@@ -78,10 +78,15 @@ function valueExpression(
|
|
|
78
78
|
return `{{${path}}}`;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Never interpolated as text: its value is a list of file ids.
|
|
82
|
+
function isImagesVariable(def: TemplateVariableDef | undefined): boolean {
|
|
83
|
+
return def?.type === "images";
|
|
84
|
+
}
|
|
85
|
+
|
|
81
86
|
function rewriteVariable(element: ScanElement, state: CompileState): void {
|
|
82
87
|
const name = element.getAttribute("data-variable") ?? "";
|
|
83
88
|
const replacement = element.ownerDocument.createTextNode(
|
|
84
|
-
isDocumentReferenceName(name)
|
|
89
|
+
isDocumentReferenceName(name) && !isImagesVariable(pathAndDef(name, element, state)[1])
|
|
85
90
|
? marker(
|
|
86
91
|
state,
|
|
87
92
|
valueExpression(
|
|
@@ -131,6 +136,8 @@ function wrapVariableOnlyBlock(block: ScanElement, state: CompileState): void {
|
|
|
131
136
|
// skip: a line with static content or several placeholders stays as it is
|
|
132
137
|
if (name === null) return;
|
|
133
138
|
const [path, def] = pathAndDef(name, block, state);
|
|
139
|
+
// skip: images blocks are rewritten by rewriteImagesBlock
|
|
140
|
+
if (isImagesVariable(def)) return;
|
|
134
141
|
const host =
|
|
135
142
|
def?.type === "richText" && block.tagName === "P" ? richTextBlockHost(block, state) : block;
|
|
136
143
|
const document = host.ownerDocument;
|
|
@@ -158,8 +165,8 @@ function rewriteLink(element: ScanElement, state: CompileState): void {
|
|
|
158
165
|
if (isDocumentPlaceholderHref(href)) {
|
|
159
166
|
const name = href.slice(2, -2);
|
|
160
167
|
if (isDocumentReferenceName(name)) {
|
|
161
|
-
const [path] = pathAndDef(name, element, state);
|
|
162
|
-
element.setAttribute("href", marker(state, `{{link ${path}}}`));
|
|
168
|
+
const [path, def] = pathAndDef(name, element, state);
|
|
169
|
+
element.setAttribute("href", isImagesVariable(def) ? "#" : marker(state, `{{link ${path}}}`));
|
|
163
170
|
} else {
|
|
164
171
|
element.setAttribute("href", "#");
|
|
165
172
|
}
|
|
@@ -225,8 +232,14 @@ export type CompileDocumentBodyOptions = {
|
|
|
225
232
|
readonly headingAsInlineStyle?: boolean;
|
|
226
233
|
// Bar color of reached stages in a steps block; ignored unless it is a #rrggbb hex.
|
|
227
234
|
readonly stepsColor?: string;
|
|
235
|
+
// Data URIs of the record's images for the images block bound to `variable`.
|
|
236
|
+
readonly recordImagesSrc?: (variable: string) => readonly string[];
|
|
237
|
+
// Previews and drafts never read the record's files; each images block shows one neutral box.
|
|
238
|
+
readonly recordImagesPlaceholder?: boolean;
|
|
228
239
|
};
|
|
229
240
|
|
|
241
|
+
const IMAGES_PLACEHOLDER_STYLE = "height:96px;margin:8px 0;border-radius:4px;background:#e5e7eb";
|
|
242
|
+
|
|
230
243
|
const MAIL_BUTTON_STYLE =
|
|
231
244
|
"display:inline-block;padding:10px 20px;border-radius:6px;background-color:#1f5468;color:#ffffff;font-weight:600;text-decoration:none";
|
|
232
245
|
|
|
@@ -258,6 +271,48 @@ export function collectDocumentResourceKeys(html: string): readonly string[] {
|
|
|
258
271
|
});
|
|
259
272
|
}
|
|
260
273
|
|
|
274
|
+
// Unique valid names of the images blocks in document order.
|
|
275
|
+
export function collectDocumentImageVariables(html: string): readonly string[] {
|
|
276
|
+
return withParsedDocument(html, (root) => {
|
|
277
|
+
const names = new Set<string>();
|
|
278
|
+
for (const element of Array.from(root.querySelectorAll("div[data-images]"))) {
|
|
279
|
+
const name = element.getAttribute("data-images") ?? "";
|
|
280
|
+
if (isDocumentReferenceName(name)) names.add(name);
|
|
281
|
+
}
|
|
282
|
+
return [...names];
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function rewriteImagesBlock(
|
|
287
|
+
element: ScanElement,
|
|
288
|
+
state: CompileState,
|
|
289
|
+
options: CompileDocumentBodyOptions,
|
|
290
|
+
): void {
|
|
291
|
+
const name = element.getAttribute("data-images") ?? "";
|
|
292
|
+
const declared =
|
|
293
|
+
isDocumentReferenceName(name) && isImagesVariable(ownVariable(state.variables, name));
|
|
294
|
+
if (!declared) {
|
|
295
|
+
element.remove();
|
|
296
|
+
// skip: an images block bound to anything but a declared images variable renders nothing
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const document = element.ownerDocument;
|
|
300
|
+
element.removeAttribute("data-images");
|
|
301
|
+
if (options.recordImagesPlaceholder === true) {
|
|
302
|
+
element.setAttribute("class", "kumiko-doc-image-placeholder");
|
|
303
|
+
element.setAttribute("style", IMAGES_PLACEHOLDER_STYLE);
|
|
304
|
+
// skip: preview and drafts never resolve record images
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
element.setAttribute("class", "kumiko-doc-images");
|
|
308
|
+
for (const src of options.recordImagesSrc?.(name) ?? []) {
|
|
309
|
+
const image = document.createElement("img");
|
|
310
|
+
image.setAttribute("src", src);
|
|
311
|
+
image.setAttribute("alt", "");
|
|
312
|
+
element.append(image);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
261
316
|
export function toResourceDataUri(contentType: string, bytes: Uint8Array): string {
|
|
262
317
|
if (!isResourceImageContentType(contentType)) {
|
|
263
318
|
throw new Error(`resource content type "${contentType}" cannot be embedded as an image`);
|
|
@@ -374,6 +429,9 @@ export function compileDocumentBodyToHandlebars(
|
|
|
374
429
|
color: options.stepsColor,
|
|
375
430
|
});
|
|
376
431
|
}
|
|
432
|
+
for (const element of Array.from(root.querySelectorAll("div[data-images]"))) {
|
|
433
|
+
rewriteImagesBlock(element, state, options);
|
|
434
|
+
}
|
|
377
435
|
for (const block of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
|
|
378
436
|
rewriteConditionalBlock(block, state);
|
|
379
437
|
}
|
|
@@ -99,3 +99,6 @@ export { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
99
99
|
// Default-Locale wenn Tenant keinen eigenen Default konfiguriert. Resolver
|
|
100
100
|
// fällt darauf zurück wenn requested locale + tenant-default fehlen.
|
|
101
101
|
export const FALLBACK_LOCALE = "de";
|
|
102
|
+
|
|
103
|
+
// A collection mounted without `access` is maintained by the platform only; TenantAdmin access is an explicit mount decision.
|
|
104
|
+
export const DEFAULT_COLLECTION_ACCESS = { roles: ["SystemAdmin"] } as const;
|
|
@@ -7,7 +7,8 @@ export type DocumentToolbarBlock =
|
|
|
7
7
|
| "repeat"
|
|
8
8
|
| "pageBreak"
|
|
9
9
|
| "align"
|
|
10
|
-
| "steps"
|
|
10
|
+
| "steps"
|
|
11
|
+
| "images";
|
|
11
12
|
|
|
12
13
|
export const MAIL_DOCUMENT_BLOCKS = [
|
|
13
14
|
"image",
|
|
@@ -25,6 +26,7 @@ export const PDF_DOCUMENT_BLOCKS = [
|
|
|
25
26
|
"divider",
|
|
26
27
|
"repeat",
|
|
27
28
|
"steps",
|
|
29
|
+
"images",
|
|
28
30
|
"pageBreak",
|
|
29
31
|
] as const satisfies readonly DocumentToolbarBlock[];
|
|
30
32
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// The record's photos: a block bound to an `images` variable whose FileRef ids the
|
|
3
|
+
// app passes at render time. Universal like document-schema.ts.
|
|
4
|
+
import { Node } from "@tiptap/core";
|
|
5
|
+
|
|
6
|
+
export function createDocumentImagesNode(isReferenceName: (value: string) => boolean) {
|
|
7
|
+
return Node.create({
|
|
8
|
+
name: "images",
|
|
9
|
+
group: "block",
|
|
10
|
+
atom: true,
|
|
11
|
+
addAttributes() {
|
|
12
|
+
return { variable: { default: null } };
|
|
13
|
+
},
|
|
14
|
+
parseHTML() {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
tag: "div[data-images]",
|
|
18
|
+
getAttrs: (element) => {
|
|
19
|
+
const variable = element.getAttribute("data-images");
|
|
20
|
+
return variable !== null && isReferenceName(variable) ? { variable } : false;
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
},
|
|
25
|
+
renderHTML({ node }) {
|
|
26
|
+
return ["div", { "data-images": node.attrs["variable"] }];
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -26,6 +26,7 @@ export type DocumentViolationCode =
|
|
|
26
26
|
| "invalid_reference_name"
|
|
27
27
|
| "embedded_content"
|
|
28
28
|
| "unsafe_attribute_value"
|
|
29
|
+
| "images_block_pdf_only"
|
|
29
30
|
| "button_href_not_placeholder"
|
|
30
31
|
| "document_requires_html_format"
|
|
31
32
|
| "layout_requires_mjml_format"
|
|
@@ -143,6 +144,7 @@ const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => b
|
|
|
143
144
|
[DOCUMENT_CONDITION_ATTRIBUTE, isDocumentReferenceName],
|
|
144
145
|
["data-resource", isDocumentResourceKey],
|
|
145
146
|
["data-steps", isDocumentReferenceName],
|
|
147
|
+
["data-images", isDocumentReferenceName],
|
|
146
148
|
["data-step", isDocumentStepKey],
|
|
147
149
|
];
|
|
148
150
|
|
|
@@ -313,6 +315,36 @@ function requiredFormatViolation(
|
|
|
313
315
|
return null;
|
|
314
316
|
}
|
|
315
317
|
|
|
318
|
+
const IMAGES_BLOCK_KIND = "document-pdf";
|
|
319
|
+
|
|
320
|
+
// The node is part of the shared document model, so a mail body would keep it after the
|
|
321
|
+
// rebuild; only a PDF resolves the record's images.
|
|
322
|
+
function imagesBlockViolation(input: {
|
|
323
|
+
readonly kind: string;
|
|
324
|
+
readonly contentFormat: ContentFormat;
|
|
325
|
+
readonly content: string | null;
|
|
326
|
+
}): DocumentViolation | null {
|
|
327
|
+
if (
|
|
328
|
+
input.kind === IMAGES_BLOCK_KIND ||
|
|
329
|
+
!isSanitizedHtmlKind(input.kind) ||
|
|
330
|
+
input.contentFormat !== "html" ||
|
|
331
|
+
input.content === null
|
|
332
|
+
) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const hasImagesBlock = withParsedDocument(
|
|
336
|
+
input.content,
|
|
337
|
+
(body) => body.querySelector("div[data-images]") !== null,
|
|
338
|
+
);
|
|
339
|
+
return hasImagesBlock
|
|
340
|
+
? violation(
|
|
341
|
+
"images_block_pdf_only",
|
|
342
|
+
input.kind,
|
|
343
|
+
`Record images blocks are only allowed in templates of kind ${IMAGES_BLOCK_KIND}`,
|
|
344
|
+
)
|
|
345
|
+
: null;
|
|
346
|
+
}
|
|
347
|
+
|
|
316
348
|
export function sanitizeTemplateBody(input: {
|
|
317
349
|
readonly kind: string;
|
|
318
350
|
readonly contentFormat: ContentFormat;
|
|
@@ -320,6 +352,8 @@ export function sanitizeTemplateBody(input: {
|
|
|
320
352
|
}): SanitizeBodyResult {
|
|
321
353
|
const formatViolation = requiredFormatViolation(input.kind, input.contentFormat);
|
|
322
354
|
if (formatViolation !== null) return { ok: false, violations: [formatViolation] };
|
|
355
|
+
const imagesViolation = imagesBlockViolation(input);
|
|
356
|
+
if (imagesViolation !== null) return { ok: false, violations: [imagesViolation] };
|
|
323
357
|
if (
|
|
324
358
|
!isSanitizedHtmlKind(input.kind) ||
|
|
325
359
|
input.contentFormat !== "html" ||
|
|
@@ -340,6 +374,7 @@ const VIOLATION_I18N_KEYS: Readonly<Record<DocumentViolationCode, string>> = {
|
|
|
340
374
|
invalid_reference_name: "templateResolver.errors.document.invalidReferenceName",
|
|
341
375
|
embedded_content: "templateResolver.errors.document.embeddedContent",
|
|
342
376
|
unsafe_attribute_value: "templateResolver.errors.document.unsafeAttributeValue",
|
|
377
|
+
images_block_pdf_only: "templateResolver.errors.document.imagesBlockPdfOnly",
|
|
343
378
|
button_href_not_placeholder: "templateResolver.errors.document.buttonHrefNotPlaceholder",
|
|
344
379
|
document_requires_html_format: "templateResolver.errors.document.documentRequiresHtmlFormat",
|
|
345
380
|
layout_requires_mjml_format: "templateResolver.errors.document.layoutRequiresMjmlFormat",
|
|
@@ -8,6 +8,7 @@ import { Extension, type Extensions, Node } from "@tiptap/core";
|
|
|
8
8
|
import { Link } from "@tiptap/extension-link";
|
|
9
9
|
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
10
10
|
import StarterKit from "@tiptap/starter-kit";
|
|
11
|
+
import { createDocumentImagesNode } from "./document-images.ts";
|
|
11
12
|
import { createDocumentStepsNode } from "./document-steps.ts";
|
|
12
13
|
|
|
13
14
|
const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
|
|
@@ -73,6 +74,7 @@ export const DOCUMENT_NODE_NAMES = [
|
|
|
73
74
|
"divider",
|
|
74
75
|
"pageBreak",
|
|
75
76
|
"steps",
|
|
77
|
+
"images",
|
|
76
78
|
] as const;
|
|
77
79
|
|
|
78
80
|
export const DocumentVariable = Node.create({
|
|
@@ -184,6 +186,8 @@ export const DocumentPageBreak = Node.create({
|
|
|
184
186
|
|
|
185
187
|
export const DocumentSteps = createDocumentStepsNode(isDocumentReferenceName);
|
|
186
188
|
|
|
189
|
+
export const DocumentImages = createDocumentImagesNode(isDocumentReferenceName);
|
|
190
|
+
|
|
187
191
|
// A repeat row is a table row carrying `each`: prosemirror-tables requires
|
|
188
192
|
// the tableRow node type, so a separate node type is not an option.
|
|
189
193
|
export const DocumentTableRow = TableRow.extend({
|
|
@@ -378,6 +382,7 @@ export const documentExtensions: Extensions = [
|
|
|
378
382
|
DocumentDivider,
|
|
379
383
|
DocumentPageBreak,
|
|
380
384
|
DocumentSteps,
|
|
385
|
+
DocumentImages,
|
|
381
386
|
DocumentTextAlign,
|
|
382
387
|
DocumentTextSize,
|
|
383
388
|
DocumentCondition,
|