@scaleflex/template-builder 0.1.1 → 0.4.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.
@@ -1,5 +1,7 @@
1
1
  import { LitElement, nothing, type PropertyValues } from 'lit';
2
- import { type BuilderContentData, type BuilderDirtyData, type BuilderErrorData, type BuilderSaveData, type BuilderTheme } from './protocol';
2
+ import { type CustomMetadataField, type BuilderContentData, type BuilderDirtyData, type BuilderErrorData, type BuilderSaveData, type BuilderTheme } from './protocol';
3
+ import { type StoredTemplate } from './dam-store';
4
+ export type { StoredTemplate, DamStoreAuth } from './dam-store';
3
5
  export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error';
4
6
  /**
5
7
  * `save` payload. Which variant arrives follows the mode the element was
@@ -7,9 +9,15 @@ export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error';
7
9
  * - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
8
10
  * reports the resulting `uuid`.
9
11
  * - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
10
- * the edited template for the host to persist.
12
+ * the edited template for the host to persist. With `dam-store` the element
13
+ * uploads a rendering copy to Filerobot first, and the detail additionally
14
+ * carries `stored` (the copy's uuid + CDN URL) — or `storeError` when that
15
+ * copy could not be made; the raw `content` arrives either way.
11
16
  */
12
- export type TemplateBuilderSaveDetail = BuilderSaveData | BuilderContentData | undefined;
17
+ export type TemplateBuilderSaveDetail = BuilderSaveData | (BuilderContentData & {
18
+ stored?: StoredTemplate;
19
+ storeError?: string;
20
+ }) | undefined;
13
21
  export interface TemplateBuilderEventMap {
14
22
  ready: CustomEvent<void>;
15
23
  open: CustomEvent<void>;
@@ -43,7 +51,9 @@ export interface TemplateBuilderEventMap {
43
51
  * template into the editor over postMessage and `save` returns the edited
44
52
  * document; nothing is stored on the Scaleflex side, and `template-id` is
45
53
  * just an opaque string echoed back. Rendering, fonts and asset browsing
46
- * still use the session's Filerobot tenant.
54
+ * still use the session's Filerobot tenant. To start a template from
55
+ * scratch, set `new-template` (or call `createNew()`) instead of `content`
56
+ * — the widget supplies the blank document.
47
57
  *
48
58
  * `brand-color` and `theme` restyle the editor chrome to match the host page.
49
59
  * They do not touch the rendered template — its colours live in the document.
@@ -74,7 +84,8 @@ export declare class SfxTemplateBuilder extends LitElement {
74
84
  mode: 'inline' | 'modal';
75
85
  /**
76
86
  * Hand the template in and take it back out instead of letting the app read
77
- * and write Filerobot. Requires `content`.
87
+ * and write Filerobot. Requires `content`, or `new-template` to start from
88
+ * scratch.
78
89
  */
79
90
  stateless: boolean;
80
91
  /**
@@ -83,6 +94,21 @@ export declare class SfxTemplateBuilder extends LitElement {
83
94
  * Assigning a different value while open loads it into the running editor.
84
95
  */
85
96
  content: string;
97
+ /**
98
+ * Stateless mode: open on a new, empty template rather than one of yours.
99
+ * The widget supplies the blank document ({@link BLANK_TEMPLATE_XML}), so
100
+ * nothing here needs to know the template format — the user picks the canvas
101
+ * size in the editor, and `save` hands back a complete document to store.
102
+ *
103
+ * `content` wins when both are set: a host with a real template to edit is
104
+ * not asking for a blank one.
105
+ *
106
+ * A flag rather than an inferred meaning for empty `content`, because empty
107
+ * already means "the host is still fetching" — the editor waits on a spinner
108
+ * for it, and blanking that case would flash an empty document in front of
109
+ * every host that mounts the builder before its request resolves.
110
+ */
111
+ newTemplate: boolean;
86
112
  /** Stateless mode: display name for the editor header. */
87
113
  templateName: string;
88
114
  /**
@@ -101,6 +127,65 @@ export declare class SfxTemplateBuilder extends LitElement {
101
127
  brandColor: string;
102
128
  /** Colour scheme for the editor chrome. Empty leaves the app's own default. */
103
129
  theme: BuilderTheme | '';
130
+ /**
131
+ * Metadata model to offer in the editor as the **Custom metadata** value
132
+ * source: `[{ key, title?, group? }]`. Authors then bind a text variable to
133
+ * one of your field names instead of to a bare slug.
134
+ *
135
+ * Names only — no values travel with the model, and the app resolves nothing
136
+ * against it. The bound key is stored in the template as `custom_ckey`, and
137
+ * the variable renders like any free-text one: your pipeline puts
138
+ * `$slug=value` in the render query. Read the key back from the saved `.fdt`
139
+ * to know which of your fields each variable expects.
140
+ *
141
+ * Leave it empty and the source is not offered at all, so hosts that send
142
+ * nothing see the editor they always had. Its main use is `sec-template` /
143
+ * stateless embeds, where the Hub project model — and with it the "File
144
+ * metadata" source — is unavailable.
145
+ *
146
+ * Settable as a property (an array) or as a `custom-metadata` attribute
147
+ * holding that array as JSON. Neither is trusted to be well-formed:
148
+ * unparseable JSON is warned about, a value that is not an array is treated as
149
+ * no model, and individual fields the editor cannot use are dropped there —
150
+ * a config typo costs the field, not the editor.
151
+ *
152
+ * Lit compares by identity: assign a new array to change the model, don't
153
+ * mutate the one you passed.
154
+ */
155
+ customMetadata: CustomMetadataField[];
156
+ /**
157
+ * Display name for the custom-metadata value source in the editor's UI.
158
+ * Empty means the editor's default ("Custom metadata"); a host can rename
159
+ * it after its own domain — e.g. "External metadata". Pure wording: the
160
+ * stored template is unaffected.
161
+ */
162
+ customMetadataLabel: string;
163
+ /**
164
+ * Stateless only: store each save in Filerobot too, so the CDN can render
165
+ * it. The `save` event then carries `stored: { uuid, url }` next to the raw
166
+ * `content` — or `storeError` when the copy failed (the raw data arrives
167
+ * either way; whether that fails the save is the host's call via the ack).
168
+ * Uses the element's own credentials; a security template needs a scope
169
+ * that allows uploads.
170
+ */
171
+ damStore: boolean;
172
+ /**
173
+ * Folder new templates are stored into when `dam-store` is on and the
174
+ * template id names no existing DAM file (an existing file's own folder
175
+ * always wins, so same name + folder versions it in place).
176
+ */
177
+ storeFolder: string;
178
+ /**
179
+ * `dam-store`: the uuid of the rendering copy this document already has —
180
+ * the `stored.uuid` a previous session's save reported, passed back in by
181
+ * the host alongside the content. Without it the element only remembers
182
+ * copies it made itself, so after a reload an unchanged re-save cannot find
183
+ * its own file and reports a spurious `storeError`, and a changed one starts
184
+ * a fresh file instead of versioning the existing one. Set it when reopening
185
+ * a stored template; it belongs to the document, so hosts that swap
186
+ * documents must swap (or clear) it too — `load()` does this for you.
187
+ */
188
+ storedUuid: string;
104
189
  /** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
105
190
  readyTimeout: number;
106
191
  private _status;
@@ -120,6 +205,21 @@ export declare class SfxTemplateBuilder extends LitElement {
120
205
  * back on save.
121
206
  */
122
207
  private _sentKey?;
208
+ /**
209
+ * Identity of the last SAVE the app handed back, in the same shape as
210
+ * `_sentKey`. A host echoing the full save detail into the props (content,
211
+ * name, templateQuery) matches this key rather than `_sentKey`, whose name
212
+ * and query still describe what the template was loaded with — without it
213
+ * the echo would post a HOST_LOAD that reloads the editor and wipes undo
214
+ * history on nearly every save (a save almost always changes the query).
215
+ */
216
+ private _sentSaveKey?;
217
+ /**
218
+ * Identity of the config already delivered, so re-renders don't re-post it.
219
+ * Undefined means the app has not been told anything yet — set back to that
220
+ * whenever a new app instance loads.
221
+ */
222
+ private _sentConfigKey?;
123
223
  /**
124
224
  * The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
125
225
  * on every update cycle, so without this a bad URL re-emits `error` forever —
@@ -149,11 +249,29 @@ export declare class SfxTemplateBuilder extends LitElement {
149
249
  * Stateless mode: load a template, opening the editor if needed. Equivalent
150
250
  * to assigning `templateId` / `content` / `templateName` and calling `open()`.
151
251
  */
152
- load({ content, templateId, name, templateQuery, }: {
252
+ load({ content, templateId, name, templateQuery, storedUuid, }: {
153
253
  content: string;
154
254
  templateId?: string;
155
255
  name?: string;
156
256
  templateQuery?: string;
257
+ storedUuid?: string;
258
+ }): void;
259
+ /**
260
+ * Stateless mode: open the editor on a new, empty template, opening it if
261
+ * needed. Equivalent to setting `new-template` and calling `open()`.
262
+ *
263
+ * The user chooses the canvas size from the editor's empty state; `save`
264
+ * then hands back a complete `.fdt` document — the first one you store for
265
+ * this record. Pass `templateId` if you have already allocated one; leave it
266
+ * out and the `save` payload simply comes back without an id.
267
+ *
268
+ * Calling it again on an editor that is already showing the blank document
269
+ * does nothing — resending would discard whatever the user has built since.
270
+ * To genuinely start over, `close()` and reopen.
271
+ */
272
+ createNew({ templateId, name, }?: {
273
+ templateId?: string;
274
+ name?: string;
157
275
  }): void;
158
276
  /**
159
277
  * Stateless mode: report back whether a `save` was persisted on your side.
@@ -184,14 +302,91 @@ export declare class SfxTemplateBuilder extends LitElement {
184
302
  private _computeSrc;
185
303
  private get _appOrigin();
186
304
  private _onMessage;
305
+ /**
306
+ * Pending `dam-store` uploads, chained so saves emit in the order the app
307
+ * posted them. `_storeAndEmitSave` never rejects (its catch emits
308
+ * `storeError`), so the chain cannot wedge.
309
+ */
310
+ private _storeQueue;
311
+ /**
312
+ * Saves handed over by the app whose `save` event has not fired yet — the
313
+ * upload window. Insertion-ordered; membership is what makes a flush and a
314
+ * completing upload not double-emit the same save.
315
+ */
316
+ private _pendingSaves;
317
+ /**
318
+ * Identity (id, content, name — not query) of the last document posted to
319
+ * the app — updated on every ship, and on an accepted save echo (the echoed
320
+ * document IS the current one; leaving the pre-save key here would make a
321
+ * later content-request redelivery look like a new document and wipe the
322
+ * store memory below).
323
+ */
324
+ private _lastDocKey?;
325
+ /**
326
+ * Monotonic id of the current DOCUMENT. Bumped in exactly one place: when
327
+ * `_maybeSendContent` ships a genuinely different document (docKey change).
328
+ * Deliberately NOT bumped on iframe/src changes — a modal close, a theme
329
+ * swap or an in-place remount is the same document, and treating it as new
330
+ * forked the file on every such boundary.
331
+ */
332
+ private _docEpoch;
333
+ /**
334
+ * The copy this element last stored, tagged with the epoch of the document
335
+ * it belongs to. Written when an upload lands (with the SAVE's epoch, so a
336
+ * late-landing upload can never masquerade as another document's copy) and
337
+ * validated at read time — there is no eager reset to get wrong.
338
+ */
339
+ private _storeMemory?;
340
+ /**
341
+ * Emit every not-yet-emitted `dam-store` save immediately, raw content with
342
+ * `storeError` in place of the links. Called when waiting any longer risks
343
+ * the event finding no listener; a still-running upload for a flushed save
344
+ * is left to finish (the copy usually lands) but will not emit again.
345
+ *
346
+ * Public for framework wrappers: one that unsubscribes its listeners before
347
+ * unmounting must call this first, while they are still attached — the
348
+ * element's own disconnect-time flush fires only after the wrapper has
349
+ * stopped listening, and the raw save would be lost. The React wrapper does
350
+ * this; a vanilla host never needs to call it.
351
+ */
352
+ flushPendingSaves(reason?: string): void;
353
+ private _flushPendingSaves;
354
+ /**
355
+ * The `dam-store` save path: upload the edited template to Filerobot, then
356
+ * emit `save` with the stored copy's links on the detail. The upload is the
357
+ * render side of the save — the CDN renders only stored files — while the
358
+ * raw `content` stays the host's copy exactly as without the flag.
359
+ *
360
+ * A failed upload still emits `save` (the raw data must reach the host
361
+ * either way), with `storeError` in place of `stored`; whether a save
362
+ * without a rendering copy counts as saved is the host's decision, made
363
+ * where it always is — the save ack.
364
+ */
365
+ private _storeAndEmitSave;
187
366
  /**
188
367
  * Deliver `content` to the app once both sides are ready: it has asked, and
189
368
  * we have something new to give it. Skips a re-send of identical content so
190
369
  * an unrelated re-render can't discard the user's in-progress edits.
191
370
  */
192
371
  private _maybeSendContent;
372
+ /**
373
+ * Deliver host config to the app. Unlike content this is not requested — the
374
+ * app has no way to know a host means to send any — so it goes out on the
375
+ * ready signal and on every later change.
376
+ *
377
+ * Sending an empty model is meaningful: it is how a host clears one it set
378
+ * before. What is skipped is only a *repeat* of what the app already holds,
379
+ * and the very first send when there was never anything to say.
380
+ */
381
+ private _maybeSendConfig;
193
382
  /** Identity of a delivered template, as compared against `_sentKey`. */
194
383
  private _contentKey;
384
+ /**
385
+ * Identity of a save the app handed back — the same shape as
386
+ * `_contentKey`, but built from the save detail rather than the props, so
387
+ * a host echoing the detail (whose name/query the save changed) matches.
388
+ */
389
+ private _detailKey;
195
390
  /** Post into the iframe, targeted at the app origin. False if not mounted. */
196
391
  private _postToApp;
197
392
  private _startHandshakeTimer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scaleflex/template-builder",
3
- "version": "0.1.1",
3
+ "version": "0.4.0",
4
4
  "description": "Embeddable web component (<sfx-template-builder>) for the Filerobot design-templates builder",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -45,6 +45,8 @@
45
45
  "preview:demo": "vite preview --config vite.demo.config.ts",
46
46
  "build:all": "yarn build && yarn build:cdn",
47
47
  "release": "node scripts/release-cdn.mjs plugin",
48
+ "release:demo": "yarn build:demo && node scripts/upload-demo.mjs",
49
+ "release:demo:probe": "yarn build:demo && node scripts/upload-demo.mjs --probe",
48
50
  "typecheck": "tsc --noEmit",
49
51
  "test": "vitest run",
50
52
  "test:watch": "vitest"
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The `dam-store` save path: upload an edited template to Filerobot so the
3
+ * CDN can render it.
4
+ *
5
+ * A stateless save hands the host raw XML — but the CDN renders only stored
6
+ * files, so a host that wants render URLs (previews, production banners) needs
7
+ * a copy in the DAM too. With `dam-store` the element makes that copy itself,
8
+ * with the same multipart upload the DAM-backed editor uses, and the `save`
9
+ * event carries the stored file's links next to the raw data.
10
+ *
11
+ * The raw `content` remains the host's copy of record: nothing here changes
12
+ * what the save event has always carried.
13
+ */
14
+
15
+ import type { BuilderContentData } from './protocol'
16
+
17
+ export const FILEROBOT_API = 'https://api.filerobot.com'
18
+
19
+ /** Credentials the element already holds; one of sassKey / secTemplate. */
20
+ export interface DamStoreAuth {
21
+ token: string
22
+ sassKey?: string
23
+ secTemplate?: string
24
+ sessionUuid?: string
25
+ companyUuid?: string
26
+ projectUuid?: string
27
+ }
28
+
29
+ /** The stored copy's links, carried on the `save` event as `detail.stored`. */
30
+ export interface StoredTemplate {
31
+ /** DAM file uuid of the stored `.fdt`. */
32
+ uuid: string
33
+ /**
34
+ * CDN URL of the stored file, with its current `?vh=` cache key — append a
35
+ * template query to render it. Empty when the file record could not be read
36
+ * back after the upload (the file is stored regardless).
37
+ */
38
+ url: string
39
+ }
40
+
41
+ export interface FileRecord {
42
+ uuid?: string
43
+ name?: string
44
+ folder?: { name?: string }
45
+ url?: { cdn?: string; public?: string; path?: string }
46
+ }
47
+
48
+ /**
49
+ * A security template is not a key — it is exchanged for a short-lived sass
50
+ * key first, the template authenticating its own exchange. Same call the app
51
+ * and the asset picker make.
52
+ *
53
+ * Exported (with `apiHeaders` / `getFileRecord`) for the demo page, which
54
+ * plays the host half of the same API conversation — one implementation of
55
+ * the auth rules, not two drifting copies.
56
+ */
57
+ export async function resolveKey(auth: DamStoreAuth): Promise<string> {
58
+ if (!auth.secTemplate) return auth.sassKey ?? ''
59
+ const res = await fetch(
60
+ `${FILEROBOT_API}/${encodeURIComponent(auth.token)}/v5/key/${encodeURIComponent(auth.secTemplate)}`,
61
+ { headers: { 'X-Filerobot-Key': auth.secTemplate } },
62
+ )
63
+ const body = (await res.json().catch(() => ({}))) as { key?: string; msg?: string }
64
+ if (!res.ok || !body.key) {
65
+ throw new Error(body.msg ?? `key exchange failed: ${res.status}`)
66
+ }
67
+ return body.key
68
+ }
69
+
70
+ /**
71
+ * Session scope only: a minted key carries its own, and pairing it with a
72
+ * session's uuids would mix one mode's key with the other mode's scope.
73
+ */
74
+ export function apiHeaders(auth: DamStoreAuth, key: string): Record<string, string> {
75
+ const headers: Record<string, string> = { 'X-Filerobot-Key': key }
76
+ if (!auth.secTemplate) {
77
+ if (auth.sessionUuid) headers['X-Session-Token'] = auth.sessionUuid
78
+ if (auth.companyUuid) headers['X-Company-Token'] = auth.companyUuid
79
+ if (auth.projectUuid) headers['X-Project-Token'] = auth.projectUuid
80
+ }
81
+ return headers
82
+ }
83
+
84
+ /**
85
+ * Whether a template id plausibly names a DAM file (hex-and-dashes uuid).
86
+ * Opaque host ids ('demo-1', 'sample-spring-banner') never do — looking them
87
+ * up would waste a round trip per save and couple every save to whichever
88
+ * status the API happens to answer a malformed id with.
89
+ */
90
+ export function looksLikeDamFileUuid(id: string): boolean {
91
+ return /^[0-9a-f][0-9a-f-]{18,}$/i.test(id)
92
+ }
93
+
94
+ /**
95
+ * One file's record. `null` means the identifier names nothing — a 404/gone,
96
+ * a 4xx rejecting the id itself, or the API's not-found envelope — all normal
97
+ * answers here. What THROWS is a failure to answer (auth, rate limit, 5xx,
98
+ * network): collapsing those into null would make a transient blip read as
99
+ * "file gone", and the callers act on that — re-homing an existing template
100
+ * into the fallback folder as a duplicate, or failing an unchanged re-save.
101
+ */
102
+ export async function getFileRecord(
103
+ auth: DamStoreAuth,
104
+ key: string,
105
+ uuid: string,
106
+ ): Promise<FileRecord | null> {
107
+ let res: Response
108
+ try {
109
+ res = await fetch(
110
+ `${FILEROBOT_API}/${encodeURIComponent(auth.token)}/v5/files/${encodeURIComponent(uuid)}`,
111
+ { headers: apiHeaders(auth, key) },
112
+ )
113
+ } catch (err) {
114
+ throw new Error(
115
+ `file lookup failed: ${err instanceof Error ? err.message : String(err)}`,
116
+ )
117
+ }
118
+ const body = (await res.json().catch(() => ({}))) as {
119
+ status?: string
120
+ msg?: string
121
+ message?: string
122
+ file?: FileRecord
123
+ }
124
+ // "This identifier names nothing" — not an error worth failing a save for.
125
+ if ([400, 404, 410, 422].includes(res.status)) return null
126
+ const message = body.msg ?? body.message ?? ''
127
+ if (!res.ok || body.status === 'error') {
128
+ if (/not[ _-]?found|does not exist|no such file/i.test(message)) return null
129
+ throw new Error(message || `file lookup failed: ${res.status}`)
130
+ }
131
+ return body.file ?? null
132
+ }
133
+
134
+ /**
135
+ * The newest file with exactly this name in a folder, or null. Used as a last
136
+ * resort by the unchanged-content conflict path; best-effort by design.
137
+ */
138
+ async function findByName(
139
+ auth: DamStoreAuth,
140
+ key: string,
141
+ fileName: string,
142
+ folder: string,
143
+ ): Promise<FileRecord | null> {
144
+ const url = new URL(
145
+ `${FILEROBOT_API}/${encodeURIComponent(auth.token)}/v5/files`,
146
+ )
147
+ url.searchParams.set('q', `name:"${fileName}"`)
148
+ url.searchParams.set('folder', folder)
149
+ url.searchParams.set('sort', 'modified_at:desc')
150
+ url.searchParams.set('limit', '5')
151
+ const res = await fetch(url, { headers: apiHeaders(auth, key) })
152
+ const body = (await res.json().catch(() => ({}))) as {
153
+ status?: string
154
+ files?: FileRecord[]
155
+ }
156
+ if (!res.ok || body.status === 'error') return null
157
+ return body.files?.find((f) => f.name === fileName) ?? null
158
+ }
159
+
160
+ /**
161
+ * FNV-1a 32-bit of the host's template id — the deterministic filename
162
+ * suffix that keeps one document on one DAM file across page reloads.
163
+ */
164
+ function hashId(id: string): string {
165
+ let h = 0x811c9dc5
166
+ for (let i = 0; i < id.length; i++) {
167
+ h ^= id.charCodeAt(i)
168
+ h = Math.imul(h, 0x01000193)
169
+ }
170
+ return (h >>> 0).toString(36)
171
+ }
172
+
173
+ /** The folder half of a file's URL path (mirrors the app's folderPathFromFile). */
174
+ function folderFromPath(rawPath: string): string {
175
+ let path: string
176
+ try {
177
+ path = decodeURIComponent(rawPath)
178
+ } catch {
179
+ path = rawPath
180
+ }
181
+ const segments = path.split('/').filter(Boolean)
182
+ segments.pop()
183
+ return '/' + segments.join('/')
184
+ }
185
+
186
+ /**
187
+ * The `$slug=value` pairs of custom-metadata-bound variables, dropped from the
188
+ * query stored as the file's `template_query` metadata. The stored default has
189
+ * to be record-agnostic: the editor hands back whatever values the host opened
190
+ * it with, and storing those would pin one record's data as the template's own
191
+ * default. The binding lives in the XML (`custom_ckey`); the value belongs to
192
+ * the record and goes back in at render time.
193
+ */
194
+ function stripBoundValues(templateQuery: string, content: string): string {
195
+ if (!templateQuery) return templateQuery
196
+ let bound: NodeListOf<Element>
197
+ try {
198
+ const doc = new DOMParser().parseFromString(content, 'application/xml')
199
+ if (doc.querySelector('parsererror')) return templateQuery
200
+ bound = doc.querySelectorAll(
201
+ 'variable[custom_ckey][source="URL"][type="text_placeholder"]',
202
+ )
203
+ } catch {
204
+ return templateQuery
205
+ }
206
+ if (bound.length === 0) return templateQuery
207
+ const slugs = new Set([...bound].map((el) => el.getAttribute('name') ?? ''))
208
+ return templateQuery
209
+ .split('&')
210
+ .filter(Boolean)
211
+ .filter((part) => !slugs.has(part.split('=')[0].replace(/^(\$|%24)/, '')))
212
+ .join('&')
213
+ }
214
+
215
+ /**
216
+ * "Content unchanged" — the stored file already IS this save. Filerobot
217
+ * reports it inconsistently: with a 2xx or an error status alike, at the top
218
+ * level or inside a per-file `files.failed[]` entry.
219
+ */
220
+ /** Uniquifies first-save filenames within a page (Date.now can collide). */
221
+ let uploadSeq = 0
222
+
223
+ const UNCHANGED_UPLOAD_CODES = new Set([
224
+ 'ERROR_SHA1_CONFLICT',
225
+ 'SAME_ASSET_EXISTS_SKIP_UPLOAD',
226
+ ])
227
+
228
+ interface UploadResponseBody {
229
+ status?: string
230
+ code?: string
231
+ msg?: string
232
+ message?: string
233
+ file?: FileRecord
234
+ files?: {
235
+ uploaded?: FileRecord[]
236
+ failed?: Array<{ code?: string }>
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Store one save in the DAM and return the stored copy's links.
242
+ *
243
+ * Uploads into the folder the file this template is already stored as lives
244
+ * in — the host's `templateId` when it names a DAM file, else `knownUuid`
245
+ * (the copy a previous save in this session made; hosts persist `stored.uuid`
246
+ * rather than echoing it into `template-id`, which would reload the editor) —
247
+ * so same name + folder makes the backend version the template in place. New
248
+ * templates land in `fallbackFolder`. Unchanged content resolves to the
249
+ * already-stored file rather than failing. Throws with a human-readable
250
+ * message when the copy could not be made; the caller decides what a save
251
+ * without a stored copy means.
252
+ */
253
+ export async function storeTemplateInDam(
254
+ data: BuilderContentData,
255
+ auth: DamStoreAuth,
256
+ fallbackFolder: string,
257
+ knownUuid?: string,
258
+ ): Promise<StoredTemplate> {
259
+ if (!auth.token) throw new Error('dam-store needs a token')
260
+ const key = await resolveKey(auth)
261
+ if (!key) throw new Error('dam-store needs a sass key or a security template')
262
+
263
+ // Two candidate records with different jobs. The host's `templateId` file
264
+ // anchors the FOLDER (that is where the template lives) — looked up only
265
+ // when the id is actually uuid-shaped; an opaque host id names nothing in
266
+ // the DAM by definition. The copy this session last made (`knownUuid`) is
267
+ // the freshest row and is what an unchanged-content conflict must resolve
268
+ // to — under a VERSION policy the `templateId` row can be an older version
269
+ // whose uuid/`?vh=` would hand the host a regressive pointer.
270
+ const wantFromId = !!data.templateId && looksLikeDamFileUuid(data.templateId)
271
+ const wantOwnCopy = !!knownUuid && knownUuid !== data.templateId
272
+ // Independent lookups — in parallel, not a round trip each. The failure
273
+ // rules differ by role: the templateId anchor is required (proceeding on a
274
+ // transient failure would re-home the file into the fallback folder), while
275
+ // the knownUuid lookup is an optional freshness upgrade whose transient
276
+ // failure must not fail a save the other anchor can carry — unless it was
277
+ // the only tie to the existing file.
278
+ const [fromRes, ownRes] = await Promise.allSettled([
279
+ wantFromId
280
+ ? getFileRecord(auth, key, data.templateId as string)
281
+ : Promise.resolve<FileRecord | null>(null),
282
+ wantOwnCopy
283
+ ? getFileRecord(auth, key, knownUuid as string)
284
+ : Promise.resolve<FileRecord | null>(null),
285
+ ])
286
+ if (fromRes.status === 'rejected') throw fromRes.reason
287
+ const fromId = fromRes.value
288
+ let ownCopy: FileRecord | null = null
289
+ if (ownRes.status === 'fulfilled') ownCopy = ownRes.value
290
+ else if (!fromId) throw ownRes.reason
291
+
292
+ const anchor = fromId ?? ownCopy
293
+ const folder = anchor?.url?.path
294
+ ? folderFromPath(anchor.url.path)
295
+ : fallbackFolder || '/'
296
+
297
+ // The DAM filename. Versioning-in-place matches on name + folder, so the
298
+ // name must be STABLE per document and DISTINCT between documents:
299
+ // - a known copy's own filename wins — versioning keeps matching across
300
+ // renames and sessions;
301
+ // - else, with a host template id, the name carries a hash of that id —
302
+ // deterministic, so a reload without the stored-uuid seed still lands on
303
+ // the same file, while two "Untitled"s with different ids never collide;
304
+ // - else (no id at all) a per-page unique suffix — the one case where
305
+ // nothing identifies the document across sessions.
306
+ const display = (data.name ?? '').trim().replace(/\.fdt$/i, '') || 'template'
307
+ const fileName = anchor?.name
308
+ ? anchor.name
309
+ : data.templateId
310
+ ? `${display}_${hashId(data.templateId)}.fdt`
311
+ : `${display}_${Date.now().toString(36)}-${++uploadSeq}.fdt`
312
+
313
+ const body = new FormData()
314
+ // The same MIME the app's own template uploads declare
315
+ // (TEMPLATE_MIME_TYPE in the design-templates-app) — plain text/xml would
316
+ // risk the copy not being classified as a template_fdt asset.
317
+ body.append(
318
+ 'files[]',
319
+ new Blob([data.content], { type: 'text/xml+sfxtemplate' }),
320
+ fileName,
321
+ )
322
+ const storedQuery = stripBoundValues(data.templateQuery ?? '', data.content)
323
+ if (storedQuery) {
324
+ body.append(
325
+ 'info[files[]]',
326
+ JSON.stringify({ custom: { template_query: storedQuery } }),
327
+ )
328
+ }
329
+
330
+ const res = await fetch(
331
+ `${FILEROBOT_API}/${encodeURIComponent(auth.token)}/v4/files?folder=${encodeURIComponent(folder)}`,
332
+ {
333
+ method: 'POST',
334
+ headers: { ...apiHeaders(auth, key), 'X-Filerobot-Template': 'true' },
335
+ body,
336
+ },
337
+ )
338
+ const parsed = (await res.json().catch(() => ({}))) as UploadResponseBody
339
+
340
+ const code = parsed.code ?? parsed.files?.failed?.[0]?.code
341
+ if (code && UNCHANGED_UPLOAD_CODES.has(code)) {
342
+ let resolved = ownCopy ?? fromId
343
+ if (!resolved?.uuid) {
344
+ // No anchor in hand — typically the first save after a page reload
345
+ // without the stored-uuid seed. In that case the file this conflict
346
+ // points at is this document's own earlier copy, which carries exactly
347
+ // the (deterministic) filename we just tried to upload — find it.
348
+ resolved = await findByName(auth, key, fileName, folder).catch(() => null)
349
+ }
350
+ if (resolved?.uuid) {
351
+ return {
352
+ uuid: resolved.uuid,
353
+ url: resolved.url?.cdn ?? resolved.url?.public ?? '',
354
+ }
355
+ }
356
+ // A genuinely foreign copy: the project's dedupe policy already stores
357
+ // these exact bytes as some other document's file.
358
+ throw new Error(
359
+ `this project's deduplication policy already stores this exact content as another file (${code}) — ` +
360
+ 'edit the content, or pass that file\'s uuid as stored-uuid so the save can resolve to it',
361
+ )
362
+ }
363
+ if (!res.ok || parsed.status === 'error') {
364
+ throw new Error(parsed.msg ?? parsed.message ?? `upload failed: ${res.status}`)
365
+ }
366
+
367
+ const uuid = parsed.file?.uuid ?? parsed.files?.uploaded?.[0]?.uuid
368
+ if (!uuid) throw new Error('upload succeeded but no file uuid in the response')
369
+
370
+ // Read the record back rather than trusting the upload response, whose shape
371
+ // varies — this is also what yields the fresh `?vh=` cache key. Best-effort:
372
+ // the file IS stored by now, so a failed read must not turn the save into a
373
+ // storeError — the url just falls back to what the upload response carried.
374
+ let record: FileRecord | null = null
375
+ try {
376
+ record = await getFileRecord(auth, key, uuid)
377
+ } catch {
378
+ // Tolerated — see above.
379
+ }
380
+ return {
381
+ uuid,
382
+ url:
383
+ record?.url?.cdn ??
384
+ record?.url?.public ??
385
+ parsed.file?.url?.cdn ??
386
+ '',
387
+ }
388
+ }