@scaleflex/template-builder 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/integrate-template-builder/SKILL.md +27 -21
- package/CHANGELOG.md +178 -4
- package/README.md +207 -52
- package/dist/dam-store.d.ts +92 -0
- package/dist/define.cjs +1 -1
- package/dist/define.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -8
- package/dist/protocol.d.ts +60 -2
- package/dist/react.cjs +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +34 -1
- package/dist/react.js +43 -28
- package/dist/react.js.map +1 -1
- package/dist/template-builder-B9Cwo_Q-.js +651 -0
- package/dist/template-builder-B9Cwo_Q-.js.map +1 -0
- package/dist/template-builder-Byqg1q93.cjs +53 -0
- package/dist/template-builder-Byqg1q93.cjs.map +1 -0
- package/dist/template-builder.d.ts +164 -4
- package/package.json +1 -1
- package/src/dam-store.ts +388 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +64 -2
- package/src/react.ts +111 -27
- package/src/template-builder.ts +405 -7
- package/dist/template-builder-CK2Zlo7E.cjs +0 -53
- package/dist/template-builder-CK2Zlo7E.cjs.map +0 -1
- package/dist/template-builder-De0hRO4s.js +0 -380
- package/dist/template-builder-De0hRO4s.js.map +0 -1
|
@@ -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
|
|
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>;
|
|
@@ -119,6 +127,65 @@ export declare class SfxTemplateBuilder extends LitElement {
|
|
|
119
127
|
brandColor: string;
|
|
120
128
|
/** Colour scheme for the editor chrome. Empty leaves the app's own default. */
|
|
121
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;
|
|
122
189
|
/** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
|
|
123
190
|
readyTimeout: number;
|
|
124
191
|
private _status;
|
|
@@ -138,6 +205,21 @@ export declare class SfxTemplateBuilder extends LitElement {
|
|
|
138
205
|
* back on save.
|
|
139
206
|
*/
|
|
140
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?;
|
|
141
223
|
/**
|
|
142
224
|
* The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
|
|
143
225
|
* on every update cycle, so without this a bad URL re-emits `error` forever —
|
|
@@ -167,11 +249,12 @@ export declare class SfxTemplateBuilder extends LitElement {
|
|
|
167
249
|
* Stateless mode: load a template, opening the editor if needed. Equivalent
|
|
168
250
|
* to assigning `templateId` / `content` / `templateName` and calling `open()`.
|
|
169
251
|
*/
|
|
170
|
-
load({ content, templateId, name, templateQuery, }: {
|
|
252
|
+
load({ content, templateId, name, templateQuery, storedUuid, }: {
|
|
171
253
|
content: string;
|
|
172
254
|
templateId?: string;
|
|
173
255
|
name?: string;
|
|
174
256
|
templateQuery?: string;
|
|
257
|
+
storedUuid?: string;
|
|
175
258
|
}): void;
|
|
176
259
|
/**
|
|
177
260
|
* Stateless mode: open the editor on a new, empty template, opening it if
|
|
@@ -219,14 +302,91 @@ export declare class SfxTemplateBuilder extends LitElement {
|
|
|
219
302
|
private _computeSrc;
|
|
220
303
|
private get _appOrigin();
|
|
221
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;
|
|
222
366
|
/**
|
|
223
367
|
* Deliver `content` to the app once both sides are ready: it has asked, and
|
|
224
368
|
* we have something new to give it. Skips a re-send of identical content so
|
|
225
369
|
* an unrelated re-render can't discard the user's in-progress edits.
|
|
226
370
|
*/
|
|
227
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;
|
|
228
382
|
/** Identity of a delivered template, as compared against `_sentKey`. */
|
|
229
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;
|
|
230
390
|
/** Post into the iframe, targeted at the app origin. False if not mounted. */
|
|
231
391
|
private _postToApp;
|
|
232
392
|
private _startHandshakeTimer;
|
package/package.json
CHANGED
package/src/dam-store.ts
ADDED
|
@@ -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
|
+
}
|