From 81d5f15050c0e01389540a81a8163610d3d7f26b Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:39:07 -0700 Subject: [PATCH 1/4] Add Web Viewer container uploads - Document container read and write workflows - Add upload limits, timeout, and script configuration --- .changeset/webviewer-container-upload.md | 5 + .../content/docs/webviewer/containers.mdx | 360 ++++++++++++++++++ apps/docs/content/docs/webviewer/meta.json | 1 + .../docs/webviewer/runtime-under-the-hood.mdx | 2 +- .../specs/container-upload-script.md | 171 +++++++++ packages/webviewer/src/adapter.ts | 150 +++++++- packages/webviewer/tests/adapter.test.ts | 158 ++++++++ 7 files changed, 844 insertions(+), 3 deletions(-) create mode 100644 .changeset/webviewer-container-upload.md create mode 100644 apps/docs/content/docs/webviewer/containers.mdx create mode 100644 packages/webviewer/specs/container-upload-script.md diff --git a/.changeset/webviewer-container-upload.md b/.changeset/webviewer-container-upload.md new file mode 100644 index 00000000..009f312d --- /dev/null +++ b/.changeset/webviewer-container-upload.md @@ -0,0 +1,5 @@ +--- +"@proofkit/webviewer": minor +--- + +Implement `containerUpload` in `WebViewerAdapter`. Files are Base64-encoded and written by the add-on's `PK_container_upload` FileMaker script, which navigates by record ID with `Go to List of Records` in a new window so the Web Viewer's layout stays current. Adds a `container` adapter option for `scriptName`, `timeoutMs`, and `maxFileBytes`. Requires FileMaker Pro 22.0 or later and an add-on that includes the script; older add-ons fail with a timeout that tells you to update. Container field repetitions above 1 are rejected for now. diff --git a/apps/docs/content/docs/webviewer/containers.mdx b/apps/docs/content/docs/webviewer/containers.mdx new file mode 100644 index 00000000..33a866d0 --- /dev/null +++ b/apps/docs/content/docs/webviewer/containers.mdx @@ -0,0 +1,360 @@ +--- +title: Container Fields +description: Reading, displaying, and writing FileMaker container data from a Web Viewer app. +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Steps, Step } from "fumadocs-ui/components/steps"; + +The Web Viewer bridge moves JSON strings, not binary. Container fields hold binary. So every container workflow in a Web Viewer app comes down to one of two choices: encode the bytes as Base64 and pass them through a FileMaker script, or keep the bytes in FileMaker entirely and drive a native script step from the web app. + +## Why the Data API container value is not enough + +Data API responses represent a container field as a URL, not as file content. Those URLs are tied to a Data API session, so putting one straight into `` inside a Web Viewer usually fails to render. + +## Uploading with `containerUpload` + +`WebViewerAdapter` implements `containerUpload` by Base64-encoding the file and handing it to a FileMaker script that decodes it back into the container field. The call looks the same as it does in a browser-hosted app: + +```ts +await client.containerUpload({ + containerFieldName: "Photo", + file, // a File from an + recordId: 3, +}); +``` + +This never goes through the Data API script and never batches, because the Data API uploads containers through a separate multipart endpoint that the `Execute FileMaker Data API` script step does not expose. + + + Needs FileMaker Pro 22.0 or later and a ProofKit add-on that includes the + `PK_container_upload` script. On an older add-on the script never calls back, + so the adapter times out after 60 seconds with a message telling you to update. + + +Pass a `File`, not a bare `Blob`. FileMaker's `Base64Decode` needs a file name with an extension, and a `Blob` does not carry one: + +```ts +// Blob from a canvas, fetch, or clipboard +const file = new File([blob], "signature.png", { type: blob.type }); +``` + +### Options + +```ts +export const client = DataApi({ + adapter: new WebViewerAdapter({ + scriptName: "PK_execute_data_api", + container: { + scriptName: "PK_container_upload", + timeoutMs: 60_000, + maxFileBytes: 20 * 1024 * 1024, + }, + }), + layout: "API_Assets", +}); +``` + +| Option | Default | Notes | +| -------------- | ---------------------- | ------------------------------------------------------------------------------- | +| `scriptName` | `"PK_container_upload"` | The add-on's container script. Override if your solution renamed it. | +| `timeoutMs` | `60000` | Turns a missing script into an actionable error. `0` waits indefinitely. | +| `maxFileBytes` | `20971520` (20 MB) | Checked before encoding, so oversized files fail fast. `0` disables the check. | + +### Current limits + +- **Repetitions above 1 are rejected** client-side until the script supports them. Upload to repetition 1, or write the field with your own script. +- Errors come back as `FileMakerError` with the real FileMaker code: `101` for a missing record, `102` for a field not on the layout, `105` for a missing layout, `306` for a stale `modId`. + +The rest of this page covers reading containers, and the script patterns to use when you need behavior beyond a straight field write. + +## Reading a container + +Have a FileMaker script Base64-encode the container and return it with its file name. + + + + ### FileMaker script + + ```FileMaker title="Get Container" + # Required properties + Set Variable [ $json ; Value: Get ( ScriptParameter ) ] + Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ] + Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ] + Set Variable [ $webViewerName ; Value: "web" ] + + Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] + + # Do the record work in a new window so the Web Viewer layout stays current + New Window [ Style: Card ; Using layout: "API_Customers" (Customers) ] + Set Error Capture [ On ] + Enter Find Mode [ Pause: Off ] + Set Field [ Customers::id ; $recordId ] + Perform Find [] + + If [ Get ( FoundCount ) = 0 or IsEmpty ( Customers::Photo ) ] + Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "found" ; False ; JSONBoolean ] ) ] + Else + Set Variable [ $result ; Value: JSONSetElement ( "" ; + [ "found" ; True ; JSONBoolean ] ; + [ "fileName" ; GetContainerAttribute ( Customers::Photo ; "filename" ) ; JSONString ] ; + [ "base64" ; Base64EncodeRFC ( 4648 ; Customers::Photo ) ; JSONString ] + ) ] + End If + + # Close the window before calling back + Close Window [ Current Window ] + + Set Variable [ $callback ; Value: JSONSetElement ( $callback ; [ "result" ; $result ; JSONObject ] ; [ "webViewerName" ; $webViewerName ; JSONString ] ) ] + Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ] + ``` + + + `SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach + a Web Viewer on the layout that is current when the step runs. A plain + `Go to Layout` navigates away and the callback silently fails, leaving the + `fmFetch` promise pending forever. + + Do record work in a `New Window`, then `Close Window` before sending the + callback. Script variables survive the window closing, so build `$result` + inside the window and use it after. A Card window is the simplest choice; an + off-screen document window works too if you do not want the window to flash. + + + + `Base64Encode` inserts line breaks every 76 characters. Those line breaks + make the string invalid inside a `data:` URL. `Base64EncodeRFC ( 4648 ; field )` + returns an unbroken string. + + + + + ### Web app + + ```ts title="containers.ts" + import { fmFetch } from "@proofkit/webviewer"; + + type ContainerPayload = + | { found: false } + | { found: true; fileName: string; base64: string }; + + const MIME_BY_EXTENSION: Record = { + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + pdf: "application/pdf", + png: "image/png", + webp: "image/webp", + }; + + function mimeFromFileName(fileName: string) { + const extension = fileName.split(".").pop()?.toLowerCase() ?? ""; + return MIME_BY_EXTENSION[extension] ?? "application/octet-stream"; + } + + export async function getCustomerPhoto(recordId: string) { + const result = await fmFetch("Get Container", { recordId }); + if (!result.found) { + return null; + } + + return { + dataUrl: `data:${mimeFromFileName(result.fileName)};base64,${result.base64}`, + fileName: result.fileName, + }; + } + ``` + + + The type passed to `fmFetch` is not validated against what the script + actually returns. Validate with [zod](https://zod.dev) if the script and the + app change independently. + + + + + +### Rendering the result + +A data URL works directly as an image source or download target. + +```tsx title="CustomerPhoto.tsx" +import { useQuery } from "@tanstack/react-query"; +import { getCustomerPhoto } from "./containers"; + +export function CustomerPhoto({ recordId }: { recordId: string }) { + const { data } = useQuery({ + queryFn: () => getCustomerPhoto(recordId), + queryKey: ["customer-photo", recordId], + }); + + if (!data) { + return

No photo on file.

; + } + + return {`Photo; +} +``` + +For anything large, convert to a blob URL instead. A data URL keeps the whole Base64 string in the DOM; a blob URL keeps one reference and lets the browser stream from memory. + +```ts title="blob-url.ts" +export function base64ToBlobUrl(base64: string, mimeType: string) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return URL.createObjectURL(new Blob([bytes], { type: mimeType })); +} +``` + +Call `URL.revokeObjectURL(url)` when the component unmounts, or the blob stays alive for the lifetime of the Web Viewer session. + +## Writing a container with your own script + +`containerUpload` covers a plain write to a container field. Use your own script when the write needs more than that: validation, related-record creation, an audit trail, or writing several fields in one transaction. + +The mechanics are the same as what the adapter does internally. Read the file in the browser, Base64-encode it, and let a FileMaker script decode it back into the container field. + +```ts title="upload.ts" +import { fmFetch } from "@proofkit/webviewer"; + +const CHUNK_SIZE = 0x8000; + +async function fileToBase64(file: File) { + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ""; + for (let index = 0; index < bytes.length; index += CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(index, index + CHUNK_SIZE)); + } + return btoa(binary); +} + +export async function uploadCustomerPhoto(recordId: string, file: File) { + return await fmFetch<{ ok: boolean; error?: number | string }>("Set Container", { + base64: await fileToBase64(file), + fileName: file.name, + recordId, + }); +} +``` + +Chunking through `subarray` matters: `String.fromCharCode(...bytes)` on a multi-megabyte array blows the argument limit and throws. + +```FileMaker title="Set Container" +Set Variable [ $json ; Value: Get ( ScriptParameter ) ] +Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ] +Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ] +Set Variable [ $webViewerName ; Value: "web" ] + +Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] +Set Variable [ $fileName ; Value: JSONGetElement ( $data ; "fileName" ) ] +Set Variable [ $base64 ; Value: JSONGetElement ( $data ; "base64" ) ] + +# Do the record work in a new window so the Web Viewer layout stays current +New Window [ Style: Card ; Using layout: "API_Customers" (Customers) ] +Set Error Capture [ On ] +Enter Find Mode [ Pause: Off ] +Set Field [ Customers::id ; $recordId ] +Perform Find [] + +If [ Get ( FoundCount ) = 0 ] + Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; False ; JSONBoolean ] ; [ "error" ; "Record not found" ; JSONString ] ) ] +Else + Set Field [ Customers::Photo ; Base64Decode ( $base64 ; $fileName ) ] + Commit Records/Requests [ With dialog: Off ] + Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; Get ( LastError ) = 0 ; JSONBoolean ] ; [ "error" ; Get ( LastError ) ; JSONNumber ] ) ] +End If + +# Close the window before calling back +Close Window [ Current Window ] + +Set Variable [ $callback ; Value: JSONSetElement ( $callback ; [ "result" ; $result ; JSONObject ] ; [ "webViewerName" ; $webViewerName ; JSONString ] ) ] +Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ] +``` + + + `Base64Decode ( text ; fileNameWithExtension )` stores the result as a named + file with the right extension. Without the second argument FileMaker stores an + untitled `.dat` file, and the container will not preview or export correctly. + + +After a successful write, invalidate the query that reads the container so the UI picks up the new file. See [Runtime Under the Hood](/docs/webviewer/runtime-under-the-hood) for the caching model. + +## Skipping the bridge entirely + +Base64 inflates payloads by roughly a third, and every byte crosses the bridge as a string. When files are large, or when the user is already sitting in FileMaker, it is often better to let FileMaker handle the bytes and only send a signal across. + +Use [`callFMScript`](/docs/webviewer/callFmScript) to trigger a script that runs `Insert File`, `Insert Picture`, or `Insert from URL` with FileMaker's own dialog, then refetch the record when the script reports back: + +```ts +import { callFMScript } from "@proofkit/webviewer"; + +callFMScript("Attach File To Customer", { recordId }); +``` + +The script can call back into the app with a [Web Viewer command](/docs/webviewer/commands) once the user finishes, which keeps a multi-megabyte file out of the JSON payload completely. + +Other cases worth pushing to FileMaker: + +- Exporting a container to disk with `Export Field Contents`. +- Fetching a remote file directly into a container with `Insert from URL`. +- Generating a PDF from a FileMaker layout instead of rendering it in the browser. + +### Uploading directly with OttoFMS + +If the file is hosted on a server running [OttoFMS](https://docs.ottomatic.cloud/docs/ottofms/guides/webhooks/webhook-file-uploads), the web app can `POST` the file to the server as multipart form data instead of routing it through a script. No Base64, no bridge, and no FileMaker dialog for the user. + +Register the webhook with the **File Receiver** option enabled, then post to the file receiver endpoint: + +```ts title="otto-upload.ts" +export async function uploadViaOttoFMS(recordId: string, file: File) { + const form = new FormData(); + form.append("file", file); + form.append("recordId", recordId); + + const response = await fetch( + "https://your.server.host/otto/filereceiver/YourFile.fmp12/customer-photos", + { + body: form, + headers: { Authorization: `Bearer ${OTTO_DATA_API_KEY}` }, + method: "POST", + } + ); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } +} +``` + +OttoFMS saves the upload under the server's `Documents/otto/{uuid}/` folder and runs your `OttoReceiver` script with an `uploaded_files` array in the payload. Each entry carries `originalname`, `mimetype`, `size`, and `path`. The script uses `path` to pull the file into a container with `Insert File` or `Insert PDF`. Uploads are deleted after 24 hours, so move the file into the solution on receipt. + + + Anything the Web Viewer holds is readable by anyone who can open the file, so + treat this key as public. An OttoFMS Data API key inherits the privilege set of + the FileMaker account it was created with, which makes the privilege set the + only thing standing between a copied key and your data. + + Create a dedicated account for it and lock the privilege set down as close to + write-only as FileMaker allows: create-only access on the one target table, no + access to any other table, and no script or layout access beyond what the + receiver needs. Never reuse a full-access or admin key here. + + +Test the request from inside a real Web Viewer before committing to this path. A Web Viewer is not an ordinary browser page, and cross-origin requests do not always behave the same way there as they do in local development. + +## Sizing guidance + +- Return thumbnails in list views and fetch the full-size container only when a detail view opens. +- Keep single transfers small. Multi-megabyte Base64 strings are slow to build in FileMaker, slow to parse in JavaScript, and hold up the bridge while they move. +- Keep container fields off broad list layouts used by Execute Data API queries. See [Batching Data API Requests](/docs/webviewer/batching) for page-size tuning on container-heavy layouts. +- Cache aggressively. A container rarely changes between renders, so a long `staleTime` in TanStack Query avoids repeat transfers. + +## Full web apps + +Outside a Web Viewer, containers go over HTTP: + +- [@proofkit/fmdapi](/docs/fmdapi) exposes `client.containerUpload({ containerFieldName, file, recordId })` for uploads. +- [@proofkit/fmodata](/docs/fmodata) models containers with `containerField()`. Container fields cannot be included in `.select()`; read them with `.getSingleField()`. diff --git a/apps/docs/content/docs/webviewer/meta.json b/apps/docs/content/docs/webviewer/meta.json index 23432e9f..1698e6c4 100644 --- a/apps/docs/content/docs/webviewer/meta.json +++ b/apps/docs/content/docs/webviewer/meta.json @@ -12,6 +12,7 @@ "runtime-under-the-hood", "data-access", "filemaker-scripts-as-backend", + "containers", "commands", "initial-props", "routing", diff --git a/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx b/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx index ebf29134..a2cface2 100644 --- a/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx +++ b/apps/docs/content/docs/webviewer/runtime-under-the-hood.mdx @@ -58,7 +58,7 @@ Common strategies include: ## Container data -Data passed through the Web Viewer bridge is JSON. Container data usually needs to be Base64-encoded before it is included in a JSON payload. +Data passed through the Web Viewer bridge is JSON. Container data usually needs to be Base64-encoded before it is included in a JSON payload. See [Container Fields](/docs/webviewer/containers) for the read, write, and skip-the-bridge patterns. ## Printing and PDFs diff --git a/packages/webviewer/specs/container-upload-script.md b/packages/webviewer/specs/container-upload-script.md new file mode 100644 index 00000000..917ea32a --- /dev/null +++ b/packages/webviewer/specs/container-upload-script.md @@ -0,0 +1,171 @@ +# Spec: `PK_container_upload` FileMaker script + +Contract between the ProofKit add-on and `WebViewerAdapter.containerUpload()` in `@proofkit/webviewer`. + +Status: draft, for implementation of the FileMaker side. The TypeScript side will be built against this document. + +## Why a script is needed + +The Data API uploads containers through a separate multipart endpoint, not through its JSON action set. The `Execute FileMaker Data API` script step only exposes the JSON actions, so container upload cannot ride the existing `PK_execute_data_api` script. It needs its own script that decodes Base64 and writes the container field directly. + +## Why a separate script rather than a new action on `PK_execute_data_api` + +- Container upload is not a Data API action; overloading that script's contract muddies both. +- It must never participate in request batching. +- Payloads are far larger than a typical Data API call, so the two have different performance characteristics. + +## Requirements + +- **FileMaker Pro 22.0 (2025) or later**, for the `Go to List of Records` script step. +- The `SendCallBack` script already shipped with the add-on. + +## Script name + +`PK_container_upload` + +Matches the existing `PK_execute_data_api` convention. The adapter will expose a `containerScriptName` option that defaults to this, so a solution can rename it. + +## Input + +The script is called by `fmFetch`, so `Get ( ScriptParameter )` is the standard envelope: + +```json +{ + "data": { + "layout": "API_Assets", + "recordId": 3, + "containerFieldName": "Photo", + "repetition": 1, + "fileName": "document.pdf", + "base64": "JVBERi0xLjQK...", + "modId": 7 + }, + "callback": { + "fetchId": "0f1c...", + "fn": "handleFmWVFetchCallback", + "webViewerName": "web" + } +} +``` + +| `data` key | Type | Required | Notes | +| -------------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------- | +| `layout` | string | yes | Layout name the record is reached through. | +| `recordId` | number | yes | FileMaker internal record ID, same value the Data API returns. | +| `containerFieldName` | string | yes | Field name only, no table occurrence prefix. Build the full name from `Get ( LayoutTableName )`. | +| `repetition` | number | no | Defaults to `1`. | +| `fileName` | string | yes | Original file name including extension. Must be passed to `Base64Decode`. | +| `base64` | string | yes | Unpadded-safe RFC 4648 Base64, no line breaks. | +| `modId` | number | no | When present, reject the write if the record's modification count no longer matches. | + +Take `webViewerName` from `callback.webViewerName`. Fall back to the add-on's default Web Viewer name when it is empty. + +## Output + +Return the same envelope shape as `PK_execute_data_api` so the adapter can reuse its existing error handling and `FileMakerError` type. + +Success: + +```json +{ + "messages": [{ "code": "0", "message": "OK" }], + "response": {} +} +``` + +Failure: + +```json +{ + "messages": [{ "code": "102", "message": "Field is missing" }], + "response": {} +} +``` + +`code` must be a **string**, matching the Data API convention. Use the real FileMaker error code from `Get ( LastError )` wherever one exists. + +### Error codes to return + +| Situation | Code | Message | +| ---------------------------------------- | ----- | ------------------------------------------ | +| Record ID not found | `101` | `Record is missing` | +| Container field not on layout / not found | `102` | `Field is missing` | +| Layout not found | `105` | `Layout is missing` | +| `modId` mismatch | `306` | `Record modification ID does not match` | +| Base64 decode produced an empty container | `500` | `Could not decode file data` | +| Any other captured error | actual `Get ( LastError )` | FileMaker's text | + +## Behavior + +``` +Set Error Capture [ On ] + +# 1. Parse the envelope +Set Variable [ $json ; Value: Get ( ScriptParameter ) ] +Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ] +Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ] +Set Variable [ $webViewerName ; Value: JSONGetElement ( $callback ; "webViewerName" ) ] +Set Variable [ $layout ; Value: JSONGetElement ( $data ; "layout" ) ] +Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] +Set Variable [ $fieldName ; Value: JSONGetElement ( $data ; "containerFieldName" ) ] +Set Variable [ $fileName ; Value: JSONGetElement ( $data ; "fileName" ) ] +Set Variable [ $base64 ; Value: JSONGetElement ( $data ; "base64" ) ] + +# 2. Navigate by record ID, in a new window +Go to List of Records [ List of record IDs: $recordId ; Using layout: $layout ; Show in new window: On ; Animation: None ] + +# 3. Verify, then write +Set Variable [ $fullFieldName ; Value: Get ( LayoutTableName ) & "::" & $fieldName ] +Set Field By Name [ $fullFieldName ; Base64Decode ( $base64 ; $fileName ) ] +Commit Records/Requests [ With dialog: Off ] + +# 4. Close the window, then call back +Close Window [ Current Window ] +Set Variable [ $callback ; Value: JSONSetElement ( $callback ; + [ "result" ; $result ; JSONObject ] ; + [ "webViewerName" ; $webViewerName ; JSONString ] ) ] +Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ] +``` + +### Critical: never send the callback from another layout + +`SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach a Web Viewer on the layout that is current when the step runs. If the script sends the callback while sitting on `$layout`, the call silently does nothing and the `fmFetch` promise on the JavaScript side never settles. + +`Go to List of Records` has a **Show in new window** parameter, so navigation and window creation are one step. Close that window before calling `SendCallBack`. Script variables survive the window closing, so build `$result` inside the window and use it afterward. + +### Window cleanup on every path + +Every exit path must close the window it opened, including error paths. If the window is left open, the user is stranded on a utility layout. Suggested approach: capture `Get ( WindowName )` before the step and only close if a new window actually opened, so a navigation failure that never created a window does not close the user's window instead. + +### Found set + +`Go to List of Records` replaces the found set in the window it targets. Because that is a new window, the user's found set in the original window is untouched. This is the main reason for the new-window approach beyond the callback constraint. + +### Error detection after navigation + +`Go to List of Records` ignores record IDs it cannot find and returns `101` or `401`. Check both `Get ( LastError )` and `Get ( FoundCount ) = 0` — a missing ID yields an empty found set rather than a hard failure. + +## Open questions for the FileMaker side + +1. **Repetition targeting.** `Set Field By Name` takes a calculated field name. Confirm whether a repetition can be addressed as `Table::Field[2]` in that expression, or whether a `Set Field` with an explicit repetition target and a branch is needed. If repetitions cannot be supported cleanly, say so and the adapter will reject `repetition > 1` client-side with a clear error. +2. **`modId` check.** Confirm `Get ( RecordModificationCount )` is the right comparison for the Data API's `modId`. +3. **Practical payload ceiling.** How large a Base64 string can move through `Get ( ScriptParameter )` before it becomes unusable? The adapter will enforce a client-side size limit and should use a real number. +4. **Missing-script behavior.** If a solution has an older add-on without this script, what does `FileMaker.PerformScript` do — silent no-op, or user-facing error dialog? This determines whether the adapter needs a timeout to produce "update your ProofKit add-on" instead of a promise that hangs forever. See below. + +## Version detection + +Unlike the batching change, this is a brand-new script rather than a new key on an existing one, so the established `1708 / unknown key` detection in `adapter.ts` does not apply. An old add-on has no script to respond at all. + +Planned client-side handling, pending the answer to open question 4: the adapter applies a timeout to `containerUpload` and, on expiry, throws an error instructing the user to update the ProofKit add-on. If you would rather have positive detection, the script could accept `{ "action": "capabilities" }` and return a version number, which the adapter would call once per instance and cache. + +## Test cases + +- Valid upload to an empty container field. +- Valid upload overwriting an existing container. +- Non-existent `recordId` → `101`. +- Field name not present on the layout → `102`. +- Non-existent layout → `105`. +- Stale `modId` → `306`, and the container is unchanged. +- Malformed Base64 → non-zero code, container unchanged. +- The user's original window keeps its layout, found set, and current record in every case above. +- A `Get Container` round trip after upload returns the same bytes and the same file name. diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index e1251caa..cbd8d662 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -3,6 +3,7 @@ import { FileMakerError } from "@proofkit/fmdapi"; import type { Adapter, BaseRequest, + ContainerUploadOptions, CreateOptions, DeleteOptions, FindOptions, @@ -25,9 +26,31 @@ export interface WebViewerAdapterBatchOptions { maxSize?: number; } +export interface WebViewerAdapterContainerOptions { + /** + * Name of the FileMaker script that writes container data. + * @default "PK_container_upload" + */ + scriptName?: string; + /** + * How long to wait for the container script before failing. An add-on that + * predates the container script never calls back, so this is what turns a + * hung promise into an actionable error. Set to 0 to wait indefinitely. + * @default 60000 + */ + timeoutMs?: number; + /** + * Reject uploads larger than this before spending time encoding them. + * Set to 0 to disable the check. + * @default 20971520 + */ + maxFileBytes?: number; +} + export interface WebViewerAdapterOptions { scriptName: string; batch?: boolean | WebViewerAdapterBatchOptions; + container?: WebViewerAdapterContainerOptions; } interface ResolvedBatchOptions { @@ -58,6 +81,86 @@ const DEFAULT_BATCH_MAX_SIZE = 20; const BATCHING_DOCS_URL = "https://proofkit.dev/docs/webviewer/batching"; const LEGACY_BATCH_WARNING = `[ProofKit] ProofKit called the FileMaker script to execute Data API, but it did not support batching. Install the latest ProofKit add-on in your FileMaker file to get the updated script. Falling back to unbatched requests for this adapter. See ${BATCHING_DOCS_URL}`; +const DEFAULT_CONTAINER_SCRIPT_NAME = "PK_container_upload"; +const DEFAULT_CONTAINER_TIMEOUT_MS = 60_000; +const DEFAULT_CONTAINER_MAX_FILE_BYTES = 20 * 1024 * 1024; +const BASE64_CHUNK_SIZE = 0x80_00; +const CONTAINERS_DOCS_URL = "https://proofkit.dev/docs/webviewer/containers"; + +function containerTimeoutMessage(scriptName: string, timeoutMs: number): string { + return `[ProofKit] The FileMaker script "${scriptName}" did not respond within ${timeoutMs}ms. Install the latest ProofKit add-on in your FileMaker file to get the container upload script, or set the adapter's container.scriptName option if your script uses a different name. See ${CONTAINERS_DOCS_URL}`; +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (timeoutMs <= 0) { + return await promise; + } + + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} + +async function blobToBase64(blob: Blob): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(index, index + BASE64_CHUNK_SIZE)); + } + return btoa(binary); +} + +/** + * FileMaker's `Base64Decode` needs a file name with an extension, otherwise it + * stores an untitled `.dat` that will not preview or export correctly. + */ +function getUploadFileName(file: Blob): string { + const fileName = (file as File).name; + if (typeof fileName === "string" && fileName.trim() !== "") { + return fileName; + } + throw new Error( + 'Container upload requires a file name with an extension. Pass a `File` rather than a bare `Blob`, or wrap it: `new File([blob], "photo.png", { type: blob.type })`.', + ); +} + +function resolveContainerRepetition(repetition: string | number | undefined): number { + if (repetition === undefined) { + return 1; + } + + const value = typeof repetition === "string" ? Number(repetition) : repetition; + if (!Number.isFinite(value) || value < 1) { + throw new Error(`Container field repetition must be a positive number, received ${String(repetition)}`); + } + if (value > 1) { + throw new Error( + `Container field repetitions are not yet supported by the Web Viewer adapter (received repetition ${value}). Upload to repetition 1, or use a FileMaker script directly. See ${CONTAINERS_DOCS_URL}`, + ); + } + return 1; +} + +function resolveContainerOptions( + container: WebViewerAdapterContainerOptions | undefined, +): Required { + const scriptName = container?.scriptName?.trim(); + return { + maxFileBytes: Math.max(0, container?.maxFileBytes ?? DEFAULT_CONTAINER_MAX_FILE_BYTES), + scriptName: scriptName ? scriptName : DEFAULT_CONTAINER_SCRIPT_NAME, + timeoutMs: Math.max(0, container?.timeoutMs ?? DEFAULT_CONTAINER_TIMEOUT_MS), + }; +} + function normalizeBatchMaxSize(maxSize: number | undefined): number { if (maxSize === undefined || !Number.isFinite(maxSize)) { return DEFAULT_BATCH_MAX_SIZE; @@ -156,6 +259,7 @@ function isLegacyBatchUnsupportedResponse(resp: BatchScriptResponse): boolean { export class WebViewerAdapter implements Adapter { protected scriptName: string; + private readonly containerOptions: Required; private readonly batchOptions?: ResolvedBatchOptions; private batchDisabledByLegacyScript = false; private batchFlushInProgress = false; @@ -166,6 +270,7 @@ export class WebViewerAdapter implements Adapter { constructor(options: WebViewerAdapterOptions & { refreshToken?: boolean }) { this.scriptName = options.scriptName; this.batchOptions = resolveBatchOptions(options.batch); + this.containerOptions = resolveContainerOptions(options.container); } protected request = (params: { @@ -521,7 +626,48 @@ export class WebViewerAdapter implements Adapter { ); }; - containerUpload = (): Promise => { - throw new Error("Container upload is not supported in webviewer"); + /** + * Uploads a file into a container field through the ProofKit add-on's + * container script. + * + * The Data API uploads containers through a separate multipart endpoint that + * the `Execute FileMaker Data API` script step does not expose, so this never + * goes through the Data API script and never participates in batching. The + * file is Base64-encoded and decoded back into the container by FileMaker. + * + * Requires FileMaker Pro 22.0 or later on the FileMaker side. + */ + containerUpload = async (opts: ContainerUploadOptions): Promise => { + const { containerFieldName, file, modId, recordId, repetition } = opts.data; + const { maxFileBytes, scriptName, timeoutMs } = this.containerOptions; + + const fileName = getUploadFileName(file); + const resolvedRepetition = resolveContainerRepetition(repetition); + + if (maxFileBytes > 0 && file.size > maxFileBytes) { + throw new Error( + `Container upload is ${file.size} bytes, which exceeds the Web Viewer limit of ${maxFileBytes} bytes. Raise the adapter's container.maxFileBytes option, or move the file with a FileMaker script step instead. See ${CONTAINERS_DOCS_URL}`, + ); + } + + const payload: Record = { + base64: await blobToBase64(file), + containerFieldName: containerFieldName as string, + fileName, + layout: opts.layout, + recordId, + repetition: resolvedRepetition, + }; + if (modId !== undefined) { + payload.modId = modId; + } + + const resp = await withTimeout( + fmFetch(scriptName, payload), + timeoutMs, + containerTimeoutMessage(scriptName, timeoutMs), + ); + + this.handleDataApiResponse(resp); }; } diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index f17953fe..ea7f5e10 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -7,6 +7,12 @@ vi.mock("../src/main.js", () => ({ fmFetch: vi.fn(), })); +const FILEMAKER_ERROR_101 = /101/; +const MISSING_FILE_NAME_ERROR = /file name with an extension/; +const UNSUPPORTED_REPETITION_ERROR = /repetitions are not yet supported/; +const FILE_TOO_LARGE_ERROR = /exceeds the Web Viewer limit/; +const STALE_ADDON_ERROR = /Install the latest ProofKit add-on/; + describe("WebViewerAdapter", () => { beforeEach(() => { vi.useFakeTimers(); @@ -673,4 +679,156 @@ describe("WebViewerAdapter", () => { expect(payload.requests).toHaveLength(20); } }); + + describe("containerUpload", () => { + const makeFile = (contents: string, name = "photo.png") => new File([contents], name, { type: "image/png" }); + + const okResponse = () => Promise.resolve({ messages: [{ code: "0" }], response: {} }); + + it("sends a base64 payload to the container script without batching", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await adapter.containerUpload({ + data: { + containerFieldName: "Photo", + file: makeFile("hello"), + recordId: 3, + }, + layout: "API_Assets", + }); + + expect(fmFetch).toHaveBeenCalledTimes(1); + expect(fmFetch).toHaveBeenCalledWith("PK_container_upload", { + base64: btoa("hello"), + containerFieldName: "Photo", + fileName: "photo.png", + layout: "API_Assets", + recordId: 3, + repetition: 1, + }); + }); + + it("includes modId only when supplied", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await adapter.containerUpload({ + data: { + containerFieldName: "Photo", + file: makeFile("hello"), + modId: 7, + recordId: 3, + }, + layout: "API_Assets", + }); + + expect(vi.mocked(fmFetch).mock.calls[0]?.[1]).toMatchObject({ modId: 7 }); + }); + + it("honors a custom container script name", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ + container: { scriptName: "My Container Script" }, + scriptName: "execute_data_api", + }); + await adapter.containerUpload({ + data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 }, + layout: "API_Assets", + }); + + expect(fmFetch).toHaveBeenCalledWith("My Container Script", expect.anything()); + }); + + it("throws a FileMakerError when the script reports a failure", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockResolvedValue({ + messages: [{ code: "101", message: "Record is missing" }], + response: {}, + }); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await expect( + adapter.containerUpload({ + data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 999 }, + layout: "API_Assets", + }), + ).rejects.toThrow(FILEMAKER_ERROR_101); + }); + + it("rejects a Blob without a file name", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await expect( + adapter.containerUpload({ + data: { + containerFieldName: "Photo", + file: new Blob(["hi"], { type: "image/png" }), + recordId: 1, + }, + layout: "API_Assets", + }), + ).rejects.toThrow(MISSING_FILE_NAME_ERROR); + expect(fmFetch).not.toHaveBeenCalled(); + }); + + it("rejects repetitions above 1 until the script supports them", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await expect( + adapter.containerUpload({ + data: { + containerFieldName: "Photo", + file: makeFile("hi"), + recordId: 1, + repetition: 2, + }, + layout: "API_Assets", + }), + ).rejects.toThrow(UNSUPPORTED_REPETITION_ERROR); + expect(fmFetch).not.toHaveBeenCalled(); + }); + + it("rejects files larger than maxFileBytes before encoding", async () => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ + container: { maxFileBytes: 4 }, + scriptName: "execute_data_api", + }); + await expect( + adapter.containerUpload({ + data: { containerFieldName: "Photo", file: makeFile("way too long"), recordId: 1 }, + layout: "API_Assets", + }), + ).rejects.toThrow(FILE_TOO_LARGE_ERROR); + expect(fmFetch).not.toHaveBeenCalled(); + }); + + it("times out with an add-on upgrade hint when the script never calls back", async () => { + vi.mocked(fmFetch).mockImplementation(() => new Promise(() => undefined)); + + const adapter = new WebViewerAdapter({ + container: { timeoutMs: 500 }, + scriptName: "execute_data_api", + }); + const result = adapter.containerUpload({ + data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 }, + layout: "API_Assets", + }); + const assertion = expect(result).rejects.toThrow(STALE_ADDON_ERROR); + + await vi.advanceTimersByTimeAsync(500); + await assertion; + }); + }); }); From 38858b92365f253e6c2bcdc650f3021a93a84859 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:00:28 -0700 Subject: [PATCH 2/4] Clarify webviewer container upload timeouts - Report unknown outcomes with ContainerUploadTimeoutError - Tighten file-name validation and document retry behavior --- .changeset/webviewer-container-upload.md | 2 +- .../content/docs/webviewer/containers.mdx | 33 ++++++++- .../specs/container-upload-script.md | 73 ++++++++++++++++--- packages/webviewer/src/adapter.ts | 56 +++++++++++--- packages/webviewer/tests/adapter.test.ts | 46 +++++++++++- 5 files changed, 183 insertions(+), 27 deletions(-) diff --git a/.changeset/webviewer-container-upload.md b/.changeset/webviewer-container-upload.md index 009f312d..6766d440 100644 --- a/.changeset/webviewer-container-upload.md +++ b/.changeset/webviewer-container-upload.md @@ -2,4 +2,4 @@ "@proofkit/webviewer": minor --- -Implement `containerUpload` in `WebViewerAdapter`. Files are Base64-encoded and written by the add-on's `PK_container_upload` FileMaker script, which navigates by record ID with `Go to List of Records` in a new window so the Web Viewer's layout stays current. Adds a `container` adapter option for `scriptName`, `timeoutMs`, and `maxFileBytes`. Requires FileMaker Pro 22.0 or later and an add-on that includes the script; older add-ons fail with a timeout that tells you to update. Container field repetitions above 1 are rejected for now. +Implement `containerUpload` in `WebViewerAdapter`. Files are Base64-encoded and written by the add-on's `PK_container_upload` FileMaker script, which navigates by record ID with `Go to List of Records` in a new window so the Web Viewer's layout stays current. Adds a `container` adapter option for `scriptName`, `timeoutMs`, and `maxFileBytes`. Requires FileMaker Pro 22.0 or later and an add-on that includes the script. When no script answers in time the call rejects with the new exported `ContainerUploadTimeoutError`, which carries `outcome: "unknown"` because the timeout cannot stop a FileMaker script that may still commit the write. File names without an extension, and container field repetitions above 1, are rejected client-side. diff --git a/apps/docs/content/docs/webviewer/containers.mdx b/apps/docs/content/docs/webviewer/containers.mdx index 33a866d0..56610da0 100644 --- a/apps/docs/content/docs/webviewer/containers.mdx +++ b/apps/docs/content/docs/webviewer/containers.mdx @@ -58,9 +58,27 @@ export const client = DataApi({ | Option | Default | Notes | | -------------- | ---------------------- | ------------------------------------------------------------------------------- | | `scriptName` | `"PK_container_upload"` | The add-on's container script. Override if your solution renamed it. | -| `timeoutMs` | `60000` | Turns a missing script into an actionable error. `0` waits indefinitely. | +| `timeoutMs` | `60000` | Stops the promise hanging when no script answers. `0` waits indefinitely. | | `maxFileBytes` | `20971520` (20 MB) | Checked before encoding, so oversized files fail fast. `0` disables the check. | +### A timeout is an unknown outcome + +The timeout ends the JavaScript wait. It cannot stop a FileMaker script that is already running, so a slow upload can commit *after* the promise rejects. `ContainerUploadTimeoutError` carries `outcome: "unknown"` for that reason — do not treat it as a confirmed failure. + +```ts +import { ContainerUploadTimeoutError } from "@proofkit/webviewer/adapter"; + +try { + await client.containerUpload({ containerFieldName: "Photo", file, recordId: 3 }); +} catch (error) { + if (error instanceof ContainerUploadTimeoutError) { + // The write may have landed. Refetch the record before telling the user it failed. + } +} +``` + +Retrying is safe: the upload sets one field from a payload that fully determines the result, so sending the same file to the same record twice leaves the same end state. Refetch first anyway, so you do not upload again while the original script is still running. + ### Current limits - **Repetitions above 1 are rejected** client-side until the script supports them. Upload to repetition 1, or write the field with your own script. @@ -262,9 +280,17 @@ Perform Find [] If [ Get ( FoundCount ) = 0 ] Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; False ; JSONBoolean ] ; [ "error" ; "Record not found" ; JSONString ] ) ] Else + # Capture the error after each step; a later step resets Get ( LastError ) Set Field [ Customers::Photo ; Base64Decode ( $base64 ; $fileName ) ] + Set Variable [ $error ; Value: Get ( LastError ) ] Commit Records/Requests [ With dialog: Off ] - Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; Get ( LastError ) = 0 ; JSONBoolean ] ; [ "error" ; Get ( LastError ) ; JSONNumber ] ) ] + Set Variable [ $error ; Value: If ( $error = 0 ; Get ( LastError ) ; $error ) ] + + If [ $error = 0 ] + Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; True ; JSONBoolean ] ) ] + Else + Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; False ; JSONBoolean ] ; [ "error" ; $error ; JSONNumber ] ) ] + End If End If # Close the window before calling back @@ -309,6 +335,9 @@ If the file is hosted on a server running [OttoFMS](https://docs.ottomatic.cloud Register the webhook with the **File Receiver** option enabled, then post to the file receiver endpoint: ```ts title="otto-upload.ts" +// Bundled into the Web Viewer, so treat this value as public. See the warning below. +const OTTO_DATA_API_KEY = import.meta.env.VITE_OTTO_DATA_API_KEY; + export async function uploadViaOttoFMS(recordId: string, file: File) { const form = new FormData(); form.append("file", file); diff --git a/packages/webviewer/specs/container-upload-script.md b/packages/webviewer/specs/container-upload-script.md index 917ea32a..9ba8a2e0 100644 --- a/packages/webviewer/specs/container-upload-script.md +++ b/packages/webviewer/specs/container-upload-script.md @@ -97,7 +97,9 @@ Failure: ## Behavior -``` +Every path must end by assigning `$result` and calling `SendCallBack`. The adapter treats a missing callback as a timeout, and rejects anything whose `messages[0].code` is not `"0"`. + +```text Set Error Capture [ On ] # 1. Parse the envelope @@ -105,28 +107,66 @@ Set Variable [ $json ; Value: Get ( ScriptParameter ) ] Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ] Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ] Set Variable [ $webViewerName ; Value: JSONGetElement ( $callback ; "webViewerName" ) ] +If [ IsEmpty ( $webViewerName ) ] + Set Variable [ $webViewerName ; Value: "web" ] # the add-on's default object name +End If Set Variable [ $layout ; Value: JSONGetElement ( $data ; "layout" ) ] Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] Set Variable [ $fieldName ; Value: JSONGetElement ( $data ; "containerFieldName" ) ] Set Variable [ $fileName ; Value: JSONGetElement ( $data ; "fileName" ) ] Set Variable [ $base64 ; Value: JSONGetElement ( $data ; "base64" ) ] +Set Variable [ $repetition ; Value: Max ( 1 ; GetAsNumber ( JSONGetElement ( $data ; "repetition" ) ) ) ] +Set Variable [ $modId ; Value: JSONGetElement ( $data ; "modId" ) ] # empty when absent # 2. Navigate by record ID, in a new window +Set Variable [ $callerWindow ; Value: Get ( WindowName ) ] Go to List of Records [ List of record IDs: $recordId ; Using layout: $layout ; Show in new window: On ; Animation: None ] - -# 3. Verify, then write -Set Variable [ $fullFieldName ; Value: Get ( LayoutTableName ) & "::" & $fieldName ] -Set Field By Name [ $fullFieldName ; Base64Decode ( $base64 ; $fileName ) ] -Commit Records/Requests [ With dialog: Off ] - -# 4. Close the window, then call back -Close Window [ Current Window ] +Set Variable [ $error ; Value: Get ( LastError ) ] +Set Variable [ $openedWindow ; Value: Get ( WindowName ) ≠ $callerWindow ] + +If [ $error ≠ 0 or Get ( FoundCount ) = 0 ] + Set Variable [ $result ; Value: PK_error ( 101 ; "Record is missing" ) ] + +Else If [ IsEmpty ( JSONGetElement ( $data ; "modId" ) ) = False and Get ( RecordModificationCount ) ≠ GetAsNumber ( $modId ) ] + Set Variable [ $result ; Value: PK_error ( 306 ; "Record modification ID does not match" ) ] + +Else + # 3. Write, capturing the error after each step + Set Variable [ $fullFieldName ; Value: Get ( LayoutTableName ) & "::" & $fieldName ] + Set Field By Name [ $fullFieldName ; Base64Decode ( $base64 ; $fileName ) ] + Set Variable [ $error ; Value: Get ( LastError ) ] + Commit Records/Requests [ With dialog: Off ] + Set Variable [ $error ; Value: If ( $error = 0 ; Get ( LastError ) ; $error ) ] + + If [ $error = 0 ] + Set Variable [ $result ; Value: PK_ok ] + Else + Set Variable [ $result ; Value: PK_error ( $error ; "" ) ] + End If +End If + +# 4. Close the window on every path, then call back +If [ $openedWindow ] + Close Window [ Current Window ] +End If Set Variable [ $callback ; Value: JSONSetElement ( $callback ; [ "result" ; $result ; JSONObject ] ; [ "webViewerName" ; $webViewerName ; JSONString ] ) ] Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ] ``` +`PK_ok` and `PK_error ( code ; message )` above stand in for whatever you use to build the envelope. They must produce exactly: + +```json +{ "messages": [{ "code": "0", "message": "OK" }], "response": {} } +``` + +```json +{ "messages": [{ "code": "102", "message": "Field is missing" }], "response": {} } +``` + +Note `code` is a **string** in both. A numeric `0` will not match the adapter's check and a successful write would be reported as a failure. + ### Critical: never send the callback from another layout `SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach a Web Viewer on the layout that is current when the step runs. If the script sends the callback while sitting on `$layout`, the call silently does nothing and the `fmFetch` promise on the JavaScript side never settles. @@ -156,7 +196,18 @@ Every exit path must close the window it opened, including error paths. If the w Unlike the batching change, this is a brand-new script rather than a new key on an existing one, so the established `1708 / unknown key` detection in `adapter.ts` does not apply. An old add-on has no script to respond at all. -Planned client-side handling, pending the answer to open question 4: the adapter applies a timeout to `containerUpload` and, on expiry, throws an error instructing the user to update the ProofKit add-on. If you would rather have positive detection, the script could accept `{ "action": "capabilities" }` and return a version number, which the adapter would call once per instance and cache. +Current client-side handling, pending the answer to open question 4: the adapter applies a timeout to `containerUpload` and, on expiry, rejects with `ContainerUploadTimeoutError`. If you would rather have positive detection, the script could accept `{ "action": "capabilities" }` and return a version number, which the adapter would call once per instance and cache. + +### The timeout is an unknown outcome, not a failure + +`Promise.race` ends the JavaScript wait. It cannot stop a FileMaker script that is already running, so a slow upload can commit *after* `containerUpload()` has rejected. The adapter therefore reports the timeout as an unknown outcome (`ContainerUploadTimeoutError` carries `outcome: "unknown"`) rather than claiming the script never ran. + +What makes this tolerable today is that the write is **idempotent**: the script sets one container field on one record from a payload that fully determines the result. Uploading the same file to the same record and field twice leaves the same end state, so a retry after a timeout cannot corrupt anything. Two things must stay true for that to hold, and both are requirements on the script: + +- The script must not append, version, or create related records as a side effect of the upload. +- The script must not treat "container already populated" as an error. + +If a future version needs side effects, this contract needs a request identity: the adapter would send a client-generated `requestId`, the script would record it, and a repeated `requestId` would return the original result instead of re-running the write. Worth designing then, not now — but do not add side effects to the script without it. ## Test cases @@ -167,5 +218,7 @@ Planned client-side handling, pending the answer to open question 4: the adapter - Non-existent layout → `105`. - Stale `modId` → `306`, and the container is unchanged. - Malformed Base64 → non-zero code, container unchanged. +- Uploading the same file to the same record twice → same end state, no duplicate side effects. +- Every failure path closes the window it opened and still calls `SendCallBack`. - The user's original window keeps its layout, found set, and current record in every case above. - A `Get Container` round trip after upload returns the same bytes and the same file name. diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index cbd8d662..7d96fa2f 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -33,9 +33,15 @@ export interface WebViewerAdapterContainerOptions { */ scriptName?: string; /** - * How long to wait for the container script before failing. An add-on that - * predates the container script never calls back, so this is what turns a - * hung promise into an actionable error. Set to 0 to wait indefinitely. + * How long to wait for the container script to call back. An add-on that + * predates the container script never calls back at all, so this is what + * stops the promise hanging forever. + * + * Expiry rejects with a {@link ContainerUploadTimeoutError}. It ends the + * JavaScript wait only; FileMaker keeps running and may still commit the + * write, so treat the outcome as unknown rather than failed. + * + * Set to 0 to wait indefinitely. * @default 60000 */ timeoutMs?: number; @@ -87,11 +93,30 @@ const DEFAULT_CONTAINER_MAX_FILE_BYTES = 20 * 1024 * 1024; const BASE64_CHUNK_SIZE = 0x80_00; const CONTAINERS_DOCS_URL = "https://proofkit.dev/docs/webviewer/containers"; -function containerTimeoutMessage(scriptName: string, timeoutMs: number): string { - return `[ProofKit] The FileMaker script "${scriptName}" did not respond within ${timeoutMs}ms. Install the latest ProofKit add-on in your FileMaker file to get the container upload script, or set the adapter's container.scriptName option if your script uses a different name. See ${CONTAINERS_DOCS_URL}`; +/** + * Thrown when the container script does not call back in time. + * + * The timeout only ends the JavaScript wait; it cannot stop a FileMaker script + * that is already running. The upload may still commit afterwards, so this is + * an unknown outcome rather than a confirmed failure. + */ +export class ContainerUploadTimeoutError extends Error { + readonly scriptName: string; + readonly timeoutMs: number; + /** The write may still have completed in FileMaker. Verify before retrying. */ + readonly outcome = "unknown" as const; + + constructor(scriptName: string, timeoutMs: number) { + super( + `[ProofKit] The FileMaker script "${scriptName}" did not call back within ${timeoutMs}ms, so the outcome of this upload is unknown. The script may still be running and may still commit the container. Check the record before retrying. If every upload times out, the file's ProofKit add-on probably predates the container script, or the script has a different name than the adapter's container.scriptName option. See ${CONTAINERS_DOCS_URL}`, + ); + this.name = "ContainerUploadTimeoutError"; + this.scriptName = scriptName; + this.timeoutMs = timeoutMs; + } } -async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { +async function withTimeout(promise: Promise, timeoutMs: number, error: () => Error): Promise { if (timeoutMs <= 0) { return await promise; } @@ -99,7 +124,7 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { - reject(new Error(message)); + reject(error()); }, timeoutMs); }); @@ -125,11 +150,15 @@ async function blobToBase64(blob: Blob): Promise { */ function getUploadFileName(file: Blob): string { const fileName = (file as File).name; - if (typeof fileName === "string" && fileName.trim() !== "") { - return fileName; + const trimmedFileName = typeof fileName === "string" ? fileName.trim() : ""; + const extension = trimmedFileName.slice(trimmedFileName.lastIndexOf(".") + 1); + + if (trimmedFileName !== "" && trimmedFileName.includes(".") && extension !== "") { + return trimmedFileName; } + throw new Error( - 'Container upload requires a file name with an extension. Pass a `File` rather than a bare `Blob`, or wrap it: `new File([blob], "photo.png", { type: blob.type })`.', + `Container upload requires a file name with an extension, received ${JSON.stringify(trimmedFileName)}. Pass a \`File\` rather than a bare \`Blob\`, or wrap it: \`new File([blob], "photo.png", { type: blob.type })\`.`, ); } @@ -636,6 +665,11 @@ export class WebViewerAdapter implements Adapter { * file is Base64-encoded and decoded back into the container by FileMaker. * * Requires FileMaker Pro 22.0 or later on the FileMaker side. + * + * The write itself is idempotent: uploading the same file to the same record + * and field again produces the same result. A retry after a + * {@link ContainerUploadTimeoutError} is therefore safe, but check the record + * first, because the original script may still be running. */ containerUpload = async (opts: ContainerUploadOptions): Promise => { const { containerFieldName, file, modId, recordId, repetition } = opts.data; @@ -665,7 +699,7 @@ export class WebViewerAdapter implements Adapter { const resp = await withTimeout( fmFetch(scriptName, payload), timeoutMs, - containerTimeoutMessage(scriptName, timeoutMs), + () => new ContainerUploadTimeoutError(scriptName, timeoutMs), ); this.handleDataApiResponse(resp); diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index ea7f5e10..0366390f 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -11,7 +11,7 @@ const FILEMAKER_ERROR_101 = /101/; const MISSING_FILE_NAME_ERROR = /file name with an extension/; const UNSUPPORTED_REPETITION_ERROR = /repetitions are not yet supported/; const FILE_TOO_LARGE_ERROR = /exceeds the Web Viewer limit/; -const STALE_ADDON_ERROR = /Install the latest ProofKit add-on/; +const UNKNOWN_OUTCOME_ERROR = /outcome of this upload is unknown/; describe("WebViewerAdapter", () => { beforeEach(() => { @@ -778,6 +778,24 @@ describe("WebViewerAdapter", () => { expect(fmFetch).not.toHaveBeenCalled(); }); + it.each(["photo", "photo.", ".", ""])("rejects the file name %o for lacking an extension", async (name) => { + vi.useRealTimers(); + vi.mocked(fmFetch).mockImplementation(okResponse); + + const adapter = new WebViewerAdapter({ scriptName: "execute_data_api" }); + await expect( + adapter.containerUpload({ + data: { + containerFieldName: "Photo", + file: new File(["hi"], name, { type: "image/png" }), + recordId: 1, + }, + layout: "API_Assets", + }), + ).rejects.toThrow(MISSING_FILE_NAME_ERROR); + expect(fmFetch).not.toHaveBeenCalled(); + }); + it("rejects repetitions above 1 until the script supports them", async () => { vi.useRealTimers(); vi.mocked(fmFetch).mockImplementation(okResponse); @@ -814,7 +832,7 @@ describe("WebViewerAdapter", () => { expect(fmFetch).not.toHaveBeenCalled(); }); - it("times out with an add-on upgrade hint when the script never calls back", async () => { + it("reports an unknown outcome when the script never calls back", async () => { vi.mocked(fmFetch).mockImplementation(() => new Promise(() => undefined)); const adapter = new WebViewerAdapter({ @@ -825,7 +843,29 @@ describe("WebViewerAdapter", () => { data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 }, layout: "API_Assets", }); - const assertion = expect(result).rejects.toThrow(STALE_ADDON_ERROR); + const assertion = expect(result).rejects.toThrow(UNKNOWN_OUTCOME_ERROR); + + await vi.advanceTimersByTimeAsync(500); + await assertion; + }); + + it("marks a timeout as an unknown outcome rather than a failed write", async () => { + vi.mocked(fmFetch).mockImplementation(() => new Promise(() => undefined)); + + const adapter = new WebViewerAdapter({ + container: { timeoutMs: 500 }, + scriptName: "execute_data_api", + }); + const result = adapter.containerUpload({ + data: { containerFieldName: "Photo", file: makeFile("hi"), recordId: 1 }, + layout: "API_Assets", + }); + const assertion = expect(result).rejects.toMatchObject({ + name: "ContainerUploadTimeoutError", + outcome: "unknown", + scriptName: "PK_container_upload", + timeoutMs: 500, + }); await vi.advanceTimersByTimeAsync(500); await assertion; From f167648e21bff3688778975e8df663d6713c8cf7 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:07:02 -0700 Subject: [PATCH 3/4] Simplify webviewer container docs - Clarify built-in uploads and custom reads - Streamline FileMaker and browser examples - Document version requirements and container handling --- .../content/docs/webviewer/containers.mdx | 256 +++++++----------- 1 file changed, 91 insertions(+), 165 deletions(-) diff --git a/apps/docs/content/docs/webviewer/containers.mdx b/apps/docs/content/docs/webviewer/containers.mdx index 56610da0..02023a18 100644 --- a/apps/docs/content/docs/webviewer/containers.mdx +++ b/apps/docs/content/docs/webviewer/containers.mdx @@ -4,10 +4,11 @@ description: Reading, displaying, and writing FileMaker container data from a We --- import { Callout } from "fumadocs-ui/components/callout"; -import { Steps, Step } from "fumadocs-ui/components/steps"; The Web Viewer bridge moves JSON strings, not binary. Container fields hold binary. So every container workflow in a Web Viewer app comes down to one of two choices: encode the bytes as Base64 and pass them through a FileMaker script, or keep the bytes in FileMaker entirely and drive a native script step from the web app. +Uploads are handled for you by `containerUpload` and the add-on's script. Reads are not — you build those, so most of this page is about the FileMaker calcs involved. + ## Why the Data API container value is not enough Data API responses represent a container field as a URL, not as file content. Those URLs are tied to a Data API session, so putting one straight into `` inside a Web Viewer usually fails to render. @@ -26,10 +27,15 @@ await client.containerUpload({ This never goes through the Data API script and never batches, because the Data API uploads containers through a separate multipart endpoint that the `Execute FileMaker Data API` script step does not expose. - - Needs FileMaker Pro 22.0 or later and a ProofKit add-on that includes the - `PK_container_upload` script. On an older add-on the script never calls back, - so the adapter times out after 60 seconds with a message telling you to update. + + `containerUpload` needs all three of: + + - `@proofkit/webviewer` **3.3.0** or later. Earlier versions throw + `Container upload is not supported in webviewer`. + - **FileMaker Pro 22.0** or later, for the `Go to List of Records` script step + the upload script uses to reach a record by ID. + - A ProofKit add-on that includes the `PK_container_upload` script. On an older + add-on nothing calls back and the adapter times out after 60 seconds. Pass a `File`, not a bare `Blob`. FileMaker's `Base64Decode` needs a file name with an extension, and a `Blob` does not carry one: @@ -88,109 +94,73 @@ The rest of this page covers reading containers, and the script patterns to use ## Reading a container -Have a FileMaker script Base64-encode the container and return it with its file name. - - - - ### FileMaker script - - ```FileMaker title="Get Container" - # Required properties - Set Variable [ $json ; Value: Get ( ScriptParameter ) ] - Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ] - Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ] - Set Variable [ $webViewerName ; Value: "web" ] - - Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] - - # Do the record work in a new window so the Web Viewer layout stays current - New Window [ Style: Card ; Using layout: "API_Customers" (Customers) ] - Set Error Capture [ On ] - Enter Find Mode [ Pause: Off ] - Set Field [ Customers::id ; $recordId ] - Perform Find [] - - If [ Get ( FoundCount ) = 0 or IsEmpty ( Customers::Photo ) ] - Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "found" ; False ; JSONBoolean ] ) ] - Else - Set Variable [ $result ; Value: JSONSetElement ( "" ; - [ "found" ; True ; JSONBoolean ] ; - [ "fileName" ; GetContainerAttribute ( Customers::Photo ; "filename" ) ; JSONString ] ; - [ "base64" ; Base64EncodeRFC ( 4648 ; Customers::Photo ) ; JSONString ] - ) ] - End If - - # Close the window before calling back - Close Window [ Current Window ] - - Set Variable [ $callback ; Value: JSONSetElement ( $callback ; [ "result" ; $result ; JSONObject ] ; [ "webViewerName" ; $webViewerName ; JSONString ] ) ] - Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ] - ``` - - - `SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach - a Web Viewer on the layout that is current when the step runs. A plain - `Go to Layout` navigates away and the callback silently fails, leaving the - `fmFetch` promise pending forever. - - Do record work in a `New Window`, then `Close Window` before sending the - callback. Script variables survive the window closing, so build `$result` - inside the window and use it after. A Card window is the simplest choice; an - off-screen document window works too if you do not want the window to flash. - - - - `Base64Encode` inserts line breaks every 76 characters. Those line breaks - make the string invalid inside a `data:` URL. `Base64EncodeRFC ( 4648 ; field )` - returns an unbroken string. - - - - - ### Web app - - ```ts title="containers.ts" - import { fmFetch } from "@proofkit/webviewer"; - - type ContainerPayload = - | { found: false } - | { found: true; fileName: string; base64: string }; - - const MIME_BY_EXTENSION: Record = { - gif: "image/gif", - jpeg: "image/jpeg", - jpg: "image/jpeg", - pdf: "application/pdf", - png: "image/png", - webp: "image/webp", - }; - - function mimeFromFileName(fileName: string) { - const extension = fileName.split(".").pop()?.toLowerCase() ?? ""; - return MIME_BY_EXTENSION[extension] ?? "application/octet-stream"; - } +There is no `containerRead`. Reading is yours to build, because only you know which record to reach and what shape the screen needs. - export async function getCustomerPhoto(recordId: string) { - const result = await fmFetch("Get Container", { recordId }); - if (!result.found) { - return null; - } +The FileMaker side is one script that returns the file as Base64. Start from the add-on's `FETCH CALLBACK TEMPLATE`, which already parses the request and sends the callback, and replace its business-logic block with two calcs: - return { - dataUrl: `data:${mimeFromFileName(result.fileName)};base64,${result.base64}`, - fileName: result.fileName, - }; - } - ``` +```FileMaker title="The only two calcs you need" +Set Variable [ $result ; Value: JSONSetElement ( "" ; + [ "fileName" ; GetContainerAttribute ( Customers::Photo ; "filename" ) ; JSONString ] ; + [ "base64" ; Base64EncodeRFC ( 4648 ; Customers::Photo ) ; JSONString ] +) ] +``` + +`GetContainerAttribute` gives you the file name, which the browser needs to pick a MIME type and to name a download. `Base64EncodeRFC` turns the bytes into something that survives a JSON payload. + + + `Base64Encode` inserts line breaks every 76 characters, which makes the string + invalid inside a `data:` URL. `Base64EncodeRFC ( 4648 ; field )` returns an + unbroken string. + + + + `SendCallBack` uses `Perform JavaScript in Web Viewer`, which can only reach a + Web Viewer on the layout that is current when the step runs. If your script + uses `Go to Layout` to find the record, the callback silently fails and the + `fmFetch` promise never settles. + + Reach the record in a `New Window`, then `Close Window` before sending the + callback. Script variables survive the window closing, so build `$result` + inside the window and use it after. + - - The type passed to `fmFetch` is not validated against what the script - actually returns. Validate with [zod](https://zod.dev) if the script and the - app change independently. - +On the web side, turn the response into a data URL: - - +```ts title="containers.ts" +import { fmFetch } from "@proofkit/webviewer"; + +const MIME_BY_EXTENSION: Record = { + gif: "image/gif", + jpg: "image/jpeg", + pdf: "application/pdf", + png: "image/png", + webp: "image/webp", +}; + +function mimeFromFileName(fileName: string) { + const extension = fileName.split(".").pop()?.toLowerCase() ?? ""; + return MIME_BY_EXTENSION[extension] ?? "application/octet-stream"; +} + +export async function getCustomerPhoto(recordId: string) { + const { base64, fileName } = await fmFetch<{ base64: string; fileName: string }>( + "Get Container", + { recordId } + ); + + return { + dataUrl: `data:${mimeFromFileName(fileName)};base64,${base64}`, + fileName, + }; +} +``` + + + The type passed to `fmFetch` is not validated against what the script actually + returns. Validate with [zod](https://zod.dev) if the script and the app change + independently, and decide how an empty container should come back — an empty + `base64` string, or a `found` flag your script sets. + ### Rendering the result @@ -207,7 +177,7 @@ export function CustomerPhoto({ recordId }: { recordId: string }) { }); if (!data) { - return

No photo on file.

; + return

Loading photo…

; } return {`Photo; @@ -231,16 +201,26 @@ Call `URL.revokeObjectURL(url)` when the component unmounts, or the blob stays a ## Writing a container with your own script -`containerUpload` covers a plain write to a container field. Use your own script when the write needs more than that: validation, related-record creation, an audit trail, or writing several fields in one transaction. +`containerUpload` covers a plain write to a container field. Write your own script when the upload needs more than that: validation, related-record creation, an audit trail, or several fields in one transaction. -The mechanics are the same as what the adapter does internally. Read the file in the browser, Base64-encode it, and let a FileMaker script decode it back into the container field. +Copy the add-on's `PK_container_upload` script as a starting point rather than building the envelope from scratch. The calc that does the actual work is the mirror of the read: -```ts title="upload.ts" -import { fmFetch } from "@proofkit/webviewer"; +```FileMaker title="Base64 back into a container" +Set Field [ Customers::Photo ; Base64Decode ( $base64 ; $fileName ) ] +``` + + `Base64Decode ( text ; fileNameWithExtension )` stores the result as a named + file with the right extension. Without the second argument FileMaker stores an + untitled `.dat` file, and the container will not preview or export correctly. + + +To Base64-encode the file in the browser, chunk it. `String.fromCharCode(...bytes)` on a multi-megabyte array blows the argument limit and throws: + +```ts title="upload.ts" const CHUNK_SIZE = 0x8000; -async function fileToBase64(file: File) { +export async function fileToBase64(file: File) { const bytes = new Uint8Array(await file.arrayBuffer()); let binary = ""; for (let index = 0; index < bytes.length; index += CHUNK_SIZE) { @@ -248,63 +228,9 @@ async function fileToBase64(file: File) { } return btoa(binary); } - -export async function uploadCustomerPhoto(recordId: string, file: File) { - return await fmFetch<{ ok: boolean; error?: number | string }>("Set Container", { - base64: await fileToBase64(file), - fileName: file.name, - recordId, - }); -} -``` - -Chunking through `subarray` matters: `String.fromCharCode(...bytes)` on a multi-megabyte array blows the argument limit and throws. - -```FileMaker title="Set Container" -Set Variable [ $json ; Value: Get ( ScriptParameter ) ] -Set Variable [ $callback ; Value: JSONGetElement ( $json ; "callback" ) ] -Set Variable [ $data ; Value: JSONGetElement ( $json ; "data" ) ] -Set Variable [ $webViewerName ; Value: "web" ] - -Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] -Set Variable [ $fileName ; Value: JSONGetElement ( $data ; "fileName" ) ] -Set Variable [ $base64 ; Value: JSONGetElement ( $data ; "base64" ) ] - -# Do the record work in a new window so the Web Viewer layout stays current -New Window [ Style: Card ; Using layout: "API_Customers" (Customers) ] -Set Error Capture [ On ] -Enter Find Mode [ Pause: Off ] -Set Field [ Customers::id ; $recordId ] -Perform Find [] - -If [ Get ( FoundCount ) = 0 ] - Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; False ; JSONBoolean ] ; [ "error" ; "Record not found" ; JSONString ] ) ] -Else - # Capture the error after each step; a later step resets Get ( LastError ) - Set Field [ Customers::Photo ; Base64Decode ( $base64 ; $fileName ) ] - Set Variable [ $error ; Value: Get ( LastError ) ] - Commit Records/Requests [ With dialog: Off ] - Set Variable [ $error ; Value: If ( $error = 0 ; Get ( LastError ) ; $error ) ] - - If [ $error = 0 ] - Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; True ; JSONBoolean ] ) ] - Else - Set Variable [ $result ; Value: JSONSetElement ( "" ; [ "ok" ; False ; JSONBoolean ] ; [ "error" ; $error ; JSONNumber ] ) ] - End If -End If - -# Close the window before calling back -Close Window [ Current Window ] - -Set Variable [ $callback ; Value: JSONSetElement ( $callback ; [ "result" ; $result ; JSONObject ] ; [ "webViewerName" ; $webViewerName ; JSONString ] ) ] -Perform Script [ Specified: From list ; "SendCallBack" ; Parameter: $callback ] ``` - - `Base64Decode ( text ; fileNameWithExtension )` stores the result as a named - file with the right extension. Without the second argument FileMaker stores an - untitled `.dat` file, and the container will not preview or export correctly. - +The same two rules from reading apply: do the record work in a `New Window` and close it before the callback, and capture `Get ( LastError )` immediately after `Set Field` rather than after the commit, or a failed write reports success. After a successful write, invalidate the query that reads the container so the UI picks up the new file. See [Runtime Under the Hood](/docs/webviewer/runtime-under-the-hood) for the caching model. From b4dbd5b93313801d4dfe52f5f576bfe884516a9d Mon Sep 17 00:00:00 2001 From: Silas <37158449+eluce2@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:40:01 -0500 Subject: [PATCH 4/4] Improve webviewer container upload errors - Validate repetition and decode failures - Preserve FileMaker error details - Update container upload docs and tests --- .../content/docs/webviewer/containers.mdx | 20 +++--- .../specs/container-upload-script.md | 68 +++++++++++++------ packages/webviewer/src/adapter.ts | 18 ++--- packages/webviewer/tests/adapter.test.ts | 5 +- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/apps/docs/content/docs/webviewer/containers.mdx b/apps/docs/content/docs/webviewer/containers.mdx index 02023a18..acd9e571 100644 --- a/apps/docs/content/docs/webviewer/containers.mdx +++ b/apps/docs/content/docs/webviewer/containers.mdx @@ -35,7 +35,8 @@ This never goes through the Data API script and never batches, because the Data - **FileMaker Pro 22.0** or later, for the `Go to List of Records` script step the upload script uses to reach a record by ID. - A ProofKit add-on that includes the `PK_container_upload` script. On an older - add-on nothing calls back and the adapter times out after 60 seconds. + add-on the adapter rejects with `ContainerUploadTimeoutError` after the + configured timeout.
Pass a `File`, not a bare `Blob`. FileMaker's `Base64Decode` needs a file name with an extension, and a `Blob` does not carry one: @@ -61,11 +62,11 @@ export const client = DataApi({ }); ``` -| Option | Default | Notes | -| -------------- | ---------------------- | ------------------------------------------------------------------------------- | -| `scriptName` | `"PK_container_upload"` | The add-on's container script. Override if your solution renamed it. | -| `timeoutMs` | `60000` | Stops the promise hanging when no script answers. `0` waits indefinitely. | -| `maxFileBytes` | `20971520` (20 MB) | Checked before encoding, so oversized files fail fast. `0` disables the check. | +| Option | Default | Notes | +| ------------------------ | ----------------------- | ----------------------------------------------------------------------------- | +| `container.scriptName` | `"PK_container_upload"` | The add-on's container script. Override if your solution renamed it. | +| `container.timeoutMs` | `60000` | Stops the promise hanging when no script answers. `0` waits indefinitely. | +| `container.maxFileBytes` | `20971520` (20 MB) | Checked before encoding, so oversized files fail fast. `0` disables the check. | ### A timeout is an unknown outcome @@ -189,10 +190,7 @@ For anything large, convert to a blob URL instead. A data URL keeps the whole Ba ```ts title="blob-url.ts" export function base64ToBlobUrl(base64: string, mimeType: string) { const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); return URL.createObjectURL(new Blob([bytes], { type: mimeType })); } ``` @@ -284,7 +282,7 @@ export async function uploadViaOttoFMS(recordId: string, file: File) { } ``` -OttoFMS saves the upload under the server's `Documents/otto/{uuid}/` folder and runs your `OttoReceiver` script with an `uploaded_files` array in the payload. Each entry carries `originalname`, `mimetype`, `size`, and `path`. The script uses `path` to pull the file into a container with `Insert File` or `Insert PDF`. Uploads are deleted after 24 hours, so move the file into the solution on receipt. +OttoFMS saves the upload under the server's `Documents/otto/{uuid}/` folder and runs your `OttoReceiver` script with an `uploaded_files` array in the payload. Each entry carries `fieldname`, `originalname`, `mimetype`, `size`, `destination`, `filename`, and `path`. The script uses `path` to pull the file into a container with `Insert File` or `Insert PDF`. Uploads are deleted after 24 hours, so move the file into the solution on receipt. Anything the Web Viewer holds is readable by anyone who can open the file, so diff --git a/packages/webviewer/specs/container-upload-script.md b/packages/webviewer/specs/container-upload-script.md index 9ba8a2e0..a9da6f4d 100644 --- a/packages/webviewer/specs/container-upload-script.md +++ b/packages/webviewer/specs/container-upload-script.md @@ -115,33 +115,57 @@ Set Variable [ $recordId ; Value: JSONGetElement ( $data ; "recordId" ) ] Set Variable [ $fieldName ; Value: JSONGetElement ( $data ; "containerFieldName" ) ] Set Variable [ $fileName ; Value: JSONGetElement ( $data ; "fileName" ) ] Set Variable [ $base64 ; Value: JSONGetElement ( $data ; "base64" ) ] -Set Variable [ $repetition ; Value: Max ( 1 ; GetAsNumber ( JSONGetElement ( $data ; "repetition" ) ) ) ] +Set Variable [ $repetitionInput ; Value: JSONGetElement ( $data ; "repetition" ) ] +Set Variable [ $repetitionType ; Value: JSONGetElementType ( $data ; "repetition" ) ] +Set Variable [ $repetitionIsValid ; Value: ( IsEmpty ( $repetitionInput ) and IsEmpty ( $repetitionType ) ) or ( $repetitionType = JSONNumber and GetAsNumber ( $repetitionInput ) = 1 ) ] +Set Variable [ $repetition ; Value: 1 ] Set Variable [ $modId ; Value: JSONGetElement ( $data ; "modId" ) ] # empty when absent # 2. Navigate by record ID, in a new window -Set Variable [ $callerWindow ; Value: Get ( WindowName ) ] -Go to List of Records [ List of record IDs: $recordId ; Using layout: $layout ; Show in new window: On ; Animation: None ] -Set Variable [ $error ; Value: Get ( LastError ) ] -Set Variable [ $openedWindow ; Value: Get ( WindowName ) ≠ $callerWindow ] - -If [ $error ≠ 0 or Get ( FoundCount ) = 0 ] - Set Variable [ $result ; Value: PK_error ( 101 ; "Record is missing" ) ] - -Else If [ IsEmpty ( JSONGetElement ( $data ; "modId" ) ) = False and Get ( RecordModificationCount ) ≠ GetAsNumber ( $modId ) ] - Set Variable [ $result ; Value: PK_error ( 306 ; "Record modification ID does not match" ) ] - +If [ not $repetitionIsValid ] + Set Variable [ $result ; Value: PK_error ( 500 ; "Only container repetition 1 is supported" ) ] Else - # 3. Write, capturing the error after each step - Set Variable [ $fullFieldName ; Value: Get ( LayoutTableName ) & "::" & $fieldName ] - Set Field By Name [ $fullFieldName ; Base64Decode ( $base64 ; $fileName ) ] - Set Variable [ $error ; Value: Get ( LastError ) ] - Commit Records/Requests [ With dialog: Off ] - Set Variable [ $error ; Value: If ( $error = 0 ; Get ( LastError ) ; $error ) ] - - If [ $error = 0 ] - Set Variable [ $result ; Value: PK_ok ] + Set Variable [ $callerWindow ; Value: Get ( WindowName ) ] + Go to List of Records [ List of record IDs: $recordId ; Using layout: $layout ; Show in new window: On ; Animation: None ] + Set Variable [ $navigationError ; Value: JSONSetElement ( "{}" ; [ "code" ; Get ( LastError ) ; JSONNumber ] ; [ "message" ; Get ( LastErrorText ) ; JSONString ] ) ] + Set Variable [ $error ; Value: JSONGetElement ( $navigationError ; "code" ) ] + Set Variable [ $errorMessage ; Value: JSONGetElement ( $navigationError ; "message" ) ] + Set Variable [ $openedWindow ; Value: Get ( WindowName ) ≠ $callerWindow ] + + If [ $error = 105 ] + Set Variable [ $result ; Value: PK_error ( 105 ; "Layout is missing" ) ] + Else If [ $error = 101 ] + Set Variable [ $result ; Value: PK_error ( 101 ; "Record is missing" ) ] + Else If [ $error ≠ 0 ] + Set Variable [ $result ; Value: PK_error ( $error ; $errorMessage ) ] + Else If [ Get ( FoundCount ) = 0 ] + Set Variable [ $result ; Value: PK_error ( 101 ; "Record is missing" ) ] + Else If [ IsEmpty ( JSONGetElement ( $data ; "modId" ) ) = False and Get ( RecordModificationCount ) ≠ GetAsNumber ( $modId ) ] + Set Variable [ $result ; Value: PK_error ( 306 ; "Record modification ID does not match" ) ] Else - Set Variable [ $result ; Value: PK_error ( $error ; "" ) ] + # 3. Decode, then write valid data while capturing errors after each step + Set Variable [ $fullFieldName ; Value: Get ( LayoutTableName ) & "::" & $fieldName ] + Set Variable [ $decoded ; Value: Base64Decode ( $base64 ; $fileName ) ] + If [ IsEmpty ( $decoded ) or $decoded = "?" ] + Set Variable [ $result ; Value: PK_error ( 500 ; "Could not decode file data" ) ] + Else + Set Field By Name [ $fullFieldName ; $decoded ] + Set Variable [ $fieldError ; Value: JSONSetElement ( "{}" ; [ "code" ; Get ( LastError ) ; JSONNumber ] ; [ "message" ; Get ( LastErrorText ) ; JSONString ] ) ] + Set Variable [ $error ; Value: JSONGetElement ( $fieldError ; "code" ) ] + Set Variable [ $errorMessage ; Value: JSONGetElement ( $fieldError ; "message" ) ] + Commit Records/Requests [ With dialog: Off ] + Set Variable [ $commitError ; Value: JSONSetElement ( "{}" ; [ "code" ; Get ( LastError ) ; JSONNumber ] ; [ "message" ; Get ( LastErrorText ) ; JSONString ] ) ] + If [ JSONGetElement ( $commitError ; "code" ) ≠ 0 ] + Set Variable [ $error ; Value: JSONGetElement ( $commitError ; "code" ) ] + Set Variable [ $errorMessage ; Value: JSONGetElement ( $commitError ; "message" ) ] + End If + + If [ $error = 0 ] + Set Variable [ $result ; Value: PK_ok ] + Else + Set Variable [ $result ; Value: PK_error ( $error ; $errorMessage ) ] + End If + End If End If End If diff --git a/packages/webviewer/src/adapter.ts b/packages/webviewer/src/adapter.ts index 7d96fa2f..be503ebe 100644 --- a/packages/webviewer/src/adapter.ts +++ b/packages/webviewer/src/adapter.ts @@ -135,20 +135,20 @@ async function withTimeout(promise: Promise, timeoutMs: number, error: () } } -async function blobToBase64(blob: Blob): Promise { +const blobToBase64 = async (blob: Blob): Promise => { const bytes = new Uint8Array(await blob.arrayBuffer()); let binary = ""; for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) { binary += String.fromCharCode(...bytes.subarray(index, index + BASE64_CHUNK_SIZE)); } return btoa(binary); -} +}; /** * FileMaker's `Base64Decode` needs a file name with an extension, otherwise it * stores an untitled `.dat` that will not preview or export correctly. */ -function getUploadFileName(file: Blob): string { +const getUploadFileName = (file: Blob): string => { const fileName = (file as File).name; const trimmedFileName = typeof fileName === "string" ? fileName.trim() : ""; const extension = trimmedFileName.slice(trimmedFileName.lastIndexOf(".") + 1); @@ -160,9 +160,9 @@ function getUploadFileName(file: Blob): string { throw new Error( `Container upload requires a file name with an extension, received ${JSON.stringify(trimmedFileName)}. Pass a \`File\` rather than a bare \`Blob\`, or wrap it: \`new File([blob], "photo.png", { type: blob.type })\`.`, ); -} +}; -function resolveContainerRepetition(repetition: string | number | undefined): number { +const resolveContainerRepetition = (repetition: string | number | undefined): number => { if (repetition === undefined) { return 1; } @@ -177,18 +177,18 @@ function resolveContainerRepetition(repetition: string | number | undefined): nu ); } return 1; -} +}; -function resolveContainerOptions( +const resolveContainerOptions = ( container: WebViewerAdapterContainerOptions | undefined, -): Required { +): Required => { const scriptName = container?.scriptName?.trim(); return { maxFileBytes: Math.max(0, container?.maxFileBytes ?? DEFAULT_CONTAINER_MAX_FILE_BYTES), scriptName: scriptName ? scriptName : DEFAULT_CONTAINER_SCRIPT_NAME, timeoutMs: Math.max(0, container?.timeoutMs ?? DEFAULT_CONTAINER_TIMEOUT_MS), }; -} +}; function normalizeBatchMaxSize(maxSize: number | undefined): number { if (maxSize === undefined || !Number.isFinite(maxSize)) { diff --git a/packages/webviewer/tests/adapter.test.ts b/packages/webviewer/tests/adapter.test.ts index 0366390f..ebe1d7d6 100644 --- a/packages/webviewer/tests/adapter.test.ts +++ b/packages/webviewer/tests/adapter.test.ts @@ -834,9 +834,10 @@ describe("WebViewerAdapter", () => { it("reports an unknown outcome when the script never calls back", async () => { vi.mocked(fmFetch).mockImplementation(() => new Promise(() => undefined)); + const timeoutMs = 500; const adapter = new WebViewerAdapter({ - container: { timeoutMs: 500 }, + container: { timeoutMs }, scriptName: "execute_data_api", }); const result = adapter.containerUpload({ @@ -845,7 +846,7 @@ describe("WebViewerAdapter", () => { }); const assertion = expect(result).rejects.toThrow(UNKNOWN_OUTCOME_ERROR); - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(timeoutMs); await assertion; });