fms-odata-js 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/LICENSE +22 -0
- package/README.md +381 -0
- package/dist/batch.d.ts +154 -0
- package/dist/batch.d.ts.map +1 -0
- package/dist/client.d.ts +160 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/containers.d.ts +190 -0
- package/dist/containers.d.ts.map +1 -0
- package/dist/entity.d.ts +149 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/errors.d.ts +69 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fms-odata.esm.js +2201 -0
- package/dist/fms-odata.esm.min.js +6 -0
- package/dist/fms-odata.iife.min.js +6 -0
- package/dist/http.d.ts +51 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/metadata.d.ts +85 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/query.d.ts +198 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/scripts.d.ts +114 -0
- package/dist/scripts.d.ts.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/url.d.ts +49 -0
- package/dist/url.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/batch.ts +477 -0
- package/src/client.ts +268 -0
- package/src/containers.ts +457 -0
- package/src/entity.ts +334 -0
- package/src/errors.ts +134 -0
- package/src/http.ts +190 -0
- package/src/index.ts +81 -0
- package/src/metadata.ts +354 -0
- package/src/query.ts +442 -0
- package/src/scripts.ts +279 -0
- package/src/types.ts +30 -0
- package/src/url.ts +98 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileMaker container field I/O.
|
|
3
|
+
*
|
|
4
|
+
* FMS exposes three container operations over OData v4 — see
|
|
5
|
+
* `docs/filemaker-odata-container-guide.md` for the full reference. The
|
|
6
|
+
* library maps to them as follows:
|
|
7
|
+
*
|
|
8
|
+
* GET /<EntitySet>(<key>)/<field>/$value → ContainerRef#get / getStream
|
|
9
|
+
* PATCH /<EntitySet>(<key>)/<field> → ContainerRef#upload({ encoding: 'binary' }) — DEFAULT
|
|
10
|
+
* PATCH /<EntitySet>(<key>) (JSON body) → ContainerRef#upload({ encoding: 'base64' })
|
|
11
|
+
* POST /<EntitySet> (JSON body) → Query#create({ … }) with container annotation keys
|
|
12
|
+
*
|
|
13
|
+
* Supported MIME types per the Claris guide:
|
|
14
|
+
* image/png, image/jpeg, image/gif, image/tiff, application/pdf
|
|
15
|
+
*
|
|
16
|
+
* Clearing a container is not in the Claris guide. The library PATCHes the
|
|
17
|
+
* record with `{ <field>: null }`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { EntityRef } from './entity.js'
|
|
21
|
+
import { executeRequest } from './http.js'
|
|
22
|
+
import type { RequestOptions } from './types.js'
|
|
23
|
+
import { encodePathSegment } from './url.js'
|
|
24
|
+
|
|
25
|
+
/** Result of a successful container download. */
|
|
26
|
+
export interface ContainerDownload {
|
|
27
|
+
/** Binary contents as a `Blob` (works in browsers, Web Viewer, and Node 18+). */
|
|
28
|
+
blob: Blob
|
|
29
|
+
/** MIME type reported by FMS (e.g. `image/png`); empty string if absent. */
|
|
30
|
+
contentType: string
|
|
31
|
+
/** Filename parsed from `Content-Disposition`, when present. */
|
|
32
|
+
filename?: string
|
|
33
|
+
/** Byte length. `0` indicates an empty container. */
|
|
34
|
+
size: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Input accepted by `ContainerRef#upload`. */
|
|
38
|
+
export interface ContainerUploadInput {
|
|
39
|
+
/** Binary payload. `fetch` accepts all three forms natively. */
|
|
40
|
+
data: Blob | ArrayBuffer | Uint8Array
|
|
41
|
+
/**
|
|
42
|
+
* Content-Type to send with the upload. Optional: when omitted, the library
|
|
43
|
+
* sniffs the MIME from the payload's magic bytes (PNG, JPEG, GIF, TIFF, PDF).
|
|
44
|
+
* Throws if it cannot detect a supported type.
|
|
45
|
+
*/
|
|
46
|
+
contentType?: string
|
|
47
|
+
/**
|
|
48
|
+
* Optional filename to store in the container. Surfaced on download via
|
|
49
|
+
* `Content-Disposition: attachment; filename="…"`. When omitted, FMS stores
|
|
50
|
+
* (and surfaces back) the auto-generated name `Untitled.png`.
|
|
51
|
+
*/
|
|
52
|
+
filename?: string
|
|
53
|
+
/**
|
|
54
|
+
* Wire format:
|
|
55
|
+
*
|
|
56
|
+
* - `'binary'` (default): PATCH `…/<field>` with raw bytes. Restricted to
|
|
57
|
+
* PNG, JPEG, GIF, TIFF, and PDF.
|
|
58
|
+
* - `'base64'`: PATCH `…/<EntitySet>(<key>)` with a JSON body containing
|
|
59
|
+
* the file as a base64 string plus `@com.filemaker.odata.…` annotations.
|
|
60
|
+
* Use this when you need to update multiple container fields (or mix
|
|
61
|
+
* container and regular field updates) in a single request.
|
|
62
|
+
*/
|
|
63
|
+
encoding?: 'binary' | 'base64'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* MIME types FMS accepts for container fields. Other types (audio, video,
|
|
68
|
+
* zip, docx, …) may upload but won't be classified as a media type and won't
|
|
69
|
+
* preview correctly in FileMaker.
|
|
70
|
+
*/
|
|
71
|
+
export const FM_CONTAINER_SUPPORTED_MIME_TYPES = [
|
|
72
|
+
'image/png',
|
|
73
|
+
'image/jpeg',
|
|
74
|
+
'image/gif',
|
|
75
|
+
'image/tiff',
|
|
76
|
+
'application/pdf',
|
|
77
|
+
] as const
|
|
78
|
+
|
|
79
|
+
export type FMContainerMimeType = (typeof FM_CONTAINER_SUPPORTED_MIME_TYPES)[number]
|
|
80
|
+
|
|
81
|
+
/** @internal — strict mime check (case-insensitive, ignores parameters). */
|
|
82
|
+
function normalizeMime(value: string): string {
|
|
83
|
+
return (value.split(';')[0] ?? '').trim().toLowerCase()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isSupportedContainerMime(value: string): boolean {
|
|
87
|
+
const normalized = normalizeMime(value)
|
|
88
|
+
return (FM_CONTAINER_SUPPORTED_MIME_TYPES as readonly string[]).includes(normalized)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sniff the MIME type from the magic bytes of an uploaded payload. Returns
|
|
93
|
+
* `undefined` when none of the supported signatures match.
|
|
94
|
+
*
|
|
95
|
+
* Signatures:
|
|
96
|
+
* PNG — 89 50 4E 47 0D 0A 1A 0A
|
|
97
|
+
* JPEG — FF D8 FF
|
|
98
|
+
* GIF — 47 49 46 38 (`GIF8`)
|
|
99
|
+
* TIFF — 49 49 2A 00 (little-endian) | 4D 4D 00 2A (big-endian)
|
|
100
|
+
* PDF — 25 50 44 46 (`%PDF`)
|
|
101
|
+
*
|
|
102
|
+
* @internal
|
|
103
|
+
*/
|
|
104
|
+
export function sniffContainerMime(bytes: Uint8Array): FMContainerMimeType | undefined {
|
|
105
|
+
if (bytes.length >= 8 &&
|
|
106
|
+
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
|
|
107
|
+
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
|
|
108
|
+
return 'image/png'
|
|
109
|
+
}
|
|
110
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
111
|
+
return 'image/jpeg'
|
|
112
|
+
}
|
|
113
|
+
if (bytes.length >= 4 &&
|
|
114
|
+
bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
115
|
+
return 'image/gif'
|
|
116
|
+
}
|
|
117
|
+
if (bytes.length >= 4 &&
|
|
118
|
+
((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
|
|
119
|
+
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a))) {
|
|
120
|
+
return 'image/tiff'
|
|
121
|
+
}
|
|
122
|
+
if (bytes.length >= 4 &&
|
|
123
|
+
bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
|
|
124
|
+
return 'application/pdf'
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Handle to a single FileMaker container field on a specific record. Created
|
|
131
|
+
* via `EntityRef#container(fieldName)`.
|
|
132
|
+
*/
|
|
133
|
+
export class ContainerRef {
|
|
134
|
+
/** @internal */ readonly _entity: EntityRef<unknown>
|
|
135
|
+
readonly fieldName: string
|
|
136
|
+
|
|
137
|
+
constructor(entity: EntityRef<unknown>, fieldName: string) {
|
|
138
|
+
if (!fieldName) {
|
|
139
|
+
throw new TypeError('ContainerRef: fieldName is required')
|
|
140
|
+
}
|
|
141
|
+
this._entity = entity
|
|
142
|
+
this.fieldName = fieldName
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Absolute URL of the container field itself
|
|
147
|
+
* (`…/<EntitySet>(<key>)/<fieldName>`). This is the URL used by binary
|
|
148
|
+
* `upload()`. Append `/$value` to download.
|
|
149
|
+
*/
|
|
150
|
+
url(): string {
|
|
151
|
+
return `${this._entity.toURL()}/${encodePathSegment(this.fieldName)}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @internal — `…/<field>/$value` for downloads. */
|
|
155
|
+
private _valueUrl(): string {
|
|
156
|
+
return `${this.url()}/$value`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Download the container's contents and buffer them into a `Blob`. For
|
|
161
|
+
* very large payloads prefer `getStream()` to avoid buffering in memory.
|
|
162
|
+
*/
|
|
163
|
+
async get(opts: RequestOptions = {}): Promise<ContainerDownload> {
|
|
164
|
+
const res = await executeRequest(this._entity._client._ctx, this._valueUrl(), {
|
|
165
|
+
method: 'GET',
|
|
166
|
+
// FMS quirk: `Accept: application/octet-stream` makes `$value` return the
|
|
167
|
+
// stored filename string as `text/plain` instead of the binary. Use the
|
|
168
|
+
// wildcard so FMS returns the actual bytes with a sniffed Content-Type.
|
|
169
|
+
// See `docs/filemaker-quirks.md`.
|
|
170
|
+
accept: 'none',
|
|
171
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const contentType = res.headers.get('content-type') ?? ''
|
|
175
|
+
const disposition = res.headers.get('content-disposition')
|
|
176
|
+
const filename = disposition ? parseContentDispositionFilename(disposition) : undefined
|
|
177
|
+
|
|
178
|
+
const blob = await res.blob()
|
|
179
|
+
const out: ContainerDownload = {
|
|
180
|
+
blob,
|
|
181
|
+
contentType,
|
|
182
|
+
size: blob.size,
|
|
183
|
+
}
|
|
184
|
+
if (filename !== undefined) out.filename = filename
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stream the container's contents without buffering. Useful for large files
|
|
190
|
+
* (`pipeTo()` into a writable, forward to another `Response`, etc.).
|
|
191
|
+
*
|
|
192
|
+
* Throws if the underlying `Response` has no body.
|
|
193
|
+
*/
|
|
194
|
+
async getStream(opts: RequestOptions = {}): Promise<ReadableStream<Uint8Array>> {
|
|
195
|
+
const res = await executeRequest(this._entity._client._ctx, this._valueUrl(), {
|
|
196
|
+
method: 'GET',
|
|
197
|
+
// See note in `get()` — Accept: */* avoids the FMS `octet-stream` quirk.
|
|
198
|
+
accept: 'none',
|
|
199
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
200
|
+
})
|
|
201
|
+
if (!res.body) {
|
|
202
|
+
throw new TypeError('ContainerRef.getStream: response has no body')
|
|
203
|
+
}
|
|
204
|
+
return res.body
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Upload binary contents to the container. Replaces any existing value.
|
|
209
|
+
*
|
|
210
|
+
* Default `encoding: 'binary'` PATCHes `…/<field>` with raw bytes and a
|
|
211
|
+
* `Content-Type` header. Restricted to PNG / JPEG / GIF / TIFF / PDF.
|
|
212
|
+
*
|
|
213
|
+
* `encoding: 'base64'` PATCHes `…/<EntitySet>(<key>)` with a JSON body
|
|
214
|
+
* containing base64 data plus `@com.filemaker.odata.…` annotations. Use
|
|
215
|
+
* this when updating multiple container fields (or mixing container and
|
|
216
|
+
* regular fields) in a single round-trip.
|
|
217
|
+
*
|
|
218
|
+
* `contentType` is optional — when omitted, the library sniffs the MIME
|
|
219
|
+
* from the payload's magic bytes. Throws if no supported signature matches.
|
|
220
|
+
*/
|
|
221
|
+
async upload(input: ContainerUploadInput, opts: RequestOptions = {}): Promise<void> {
|
|
222
|
+
const encoding = input.encoding ?? 'binary'
|
|
223
|
+
|
|
224
|
+
// Resolve contentType: caller-supplied takes precedence; otherwise sniff.
|
|
225
|
+
const bytes = await toUint8Array(input.data)
|
|
226
|
+
let contentType = input.contentType
|
|
227
|
+
if (!contentType) {
|
|
228
|
+
const sniffed = sniffContainerMime(bytes)
|
|
229
|
+
if (!sniffed) {
|
|
230
|
+
throw new TypeError(
|
|
231
|
+
'ContainerRef.upload: contentType is required and could not be sniffed from the payload. ' +
|
|
232
|
+
`Pass a contentType explicitly (one of ${FM_CONTAINER_SUPPORTED_MIME_TYPES.join(', ')}).`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
contentType = sniffed
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (encoding === 'binary') {
|
|
239
|
+
if (!isSupportedContainerMime(contentType)) {
|
|
240
|
+
throw new TypeError(
|
|
241
|
+
`ContainerRef.upload (binary): contentType "${contentType}" is not a FileMaker-supported container type. ` +
|
|
242
|
+
`Use one of ${FM_CONTAINER_SUPPORTED_MIME_TYPES.join(', ')}, or switch to { encoding: 'base64' }.`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
const headers: Record<string, string> = {
|
|
246
|
+
'Content-Type': contentType,
|
|
247
|
+
}
|
|
248
|
+
if (input.filename) {
|
|
249
|
+
headers['Content-Disposition'] = formatContentDisposition(input.filename)
|
|
250
|
+
}
|
|
251
|
+
// Normalize to a fresh ArrayBuffer so every fetch implementation (undici,
|
|
252
|
+
// browser, FM Web Viewer) sends raw bytes rather than relying on
|
|
253
|
+
// Uint8Array serialization quirks. Copying also rules out SharedArrayBuffer.
|
|
254
|
+
const body = new ArrayBuffer(bytes.byteLength)
|
|
255
|
+
new Uint8Array(body).set(bytes)
|
|
256
|
+
await executeRequest(this._entity._client._ctx, this.url(), {
|
|
257
|
+
method: 'PATCH',
|
|
258
|
+
headers,
|
|
259
|
+
body,
|
|
260
|
+
accept: 'none',
|
|
261
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
262
|
+
})
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// base64 mode: PATCH the record with FileMaker annotations.
|
|
267
|
+
const body = buildContainerJsonBody({
|
|
268
|
+
[this.fieldName]: {
|
|
269
|
+
data: toBase64(bytes),
|
|
270
|
+
contentType,
|
|
271
|
+
...(input.filename ? { filename: input.filename } : {}),
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
await executeRequest(this._entity._client._ctx, this._entity.toURL(), {
|
|
275
|
+
method: 'PATCH',
|
|
276
|
+
headers: {
|
|
277
|
+
'Content-Type': 'application/json',
|
|
278
|
+
Prefer: 'return=minimal',
|
|
279
|
+
},
|
|
280
|
+
body: JSON.stringify(body),
|
|
281
|
+
accept: 'none',
|
|
282
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Clear the container value. FMS has no documented per-field DELETE for
|
|
288
|
+
* record-level data, so the supported path is to PATCH the record with
|
|
289
|
+
* `{ <fieldName>: null }`.
|
|
290
|
+
*/
|
|
291
|
+
async delete(opts: RequestOptions = {}): Promise<void> {
|
|
292
|
+
const body: Record<string, unknown> = { [this.fieldName]: null }
|
|
293
|
+
await executeRequest(this._entity._client._ctx, this._entity.toURL(), {
|
|
294
|
+
method: 'PATCH',
|
|
295
|
+
headers: {
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
Prefer: 'return=minimal',
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify(body),
|
|
300
|
+
accept: 'none',
|
|
301
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// JSON-body annotation helpers (shared with multi-container PATCH and POST)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One container value to embed in a JSON body, in raw byte form. Used by
|
|
312
|
+
* `buildContainerJsonBody` and `EntityRef#patchContainers`.
|
|
313
|
+
*/
|
|
314
|
+
export interface ContainerJsonValue {
|
|
315
|
+
/** Base64-encoded bytes (without `data:` prefix or whitespace). */
|
|
316
|
+
data: string
|
|
317
|
+
/** REQUIRED MIME type. Stored as `@com.filemaker.odata.ContentType`. */
|
|
318
|
+
contentType: string
|
|
319
|
+
/** Optional filename. Stored as `@com.filemaker.odata.Filename`. */
|
|
320
|
+
filename?: string
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build a JSON body for a POST/PATCH that updates one or more container
|
|
325
|
+
* fields (and optionally regular fields). Inserts the FileMaker annotation
|
|
326
|
+
* keys for every container value.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* buildContainerJsonBody(
|
|
330
|
+
* {
|
|
331
|
+
* photo: { data: '<base64>', contentType: 'image/png', filename: 'p.png' },
|
|
332
|
+
* contract: { data: '<base64>', contentType: 'application/pdf' },
|
|
333
|
+
* },
|
|
334
|
+
* { website: 'https://example.com' },
|
|
335
|
+
* )
|
|
336
|
+
*/
|
|
337
|
+
export function buildContainerJsonBody(
|
|
338
|
+
containers: Record<string, ContainerJsonValue>,
|
|
339
|
+
regularFields: Record<string, unknown> = {},
|
|
340
|
+
): Record<string, unknown> {
|
|
341
|
+
const body: Record<string, unknown> = { ...regularFields }
|
|
342
|
+
for (const [field, value] of Object.entries(containers)) {
|
|
343
|
+
if (!value.contentType) {
|
|
344
|
+
throw new TypeError(`buildContainerJsonBody: "${field}".contentType is required`)
|
|
345
|
+
}
|
|
346
|
+
body[field] = value.data
|
|
347
|
+
body[`${field}@com.filemaker.odata.ContentType`] = value.contentType
|
|
348
|
+
if (value.filename) {
|
|
349
|
+
body[`${field}@com.filemaker.odata.Filename`] = value.filename
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return body
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Content-Disposition helpers
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parse the `filename` (or RFC 5987 `filename*`) from a `Content-Disposition`
|
|
361
|
+
* header. Returns `undefined` when no filename parameter is present.
|
|
362
|
+
*
|
|
363
|
+
* Per RFC 6266 §4.3, `filename*` (with explicit charset) takes precedence
|
|
364
|
+
* over `filename` when both are supplied.
|
|
365
|
+
*
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
368
|
+
export function parseContentDispositionFilename(value: string): string | undefined {
|
|
369
|
+
// RFC 5987: filename*=charset'lang'percent-encoded-value
|
|
370
|
+
const ext = value.match(/filename\*\s*=\s*([^']+)'[^']*'([^;]+)/i)
|
|
371
|
+
if (ext) {
|
|
372
|
+
const charset = ext[1]!.trim().toLowerCase()
|
|
373
|
+
const encoded = ext[2]!.trim()
|
|
374
|
+
try {
|
|
375
|
+
const decoded = decodeURIComponent(encoded)
|
|
376
|
+
// We only honour utf-8; for any other charset return the percent-decoded
|
|
377
|
+
// bytes verbatim (best effort — non-utf8 in headers is exceptionally rare).
|
|
378
|
+
return charset === 'utf-8' || charset === 'utf8' ? decoded : decoded
|
|
379
|
+
} catch {
|
|
380
|
+
// fall through to the plain `filename`
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Plain `filename="..."` or `filename=...` (unquoted, up to `;` or end).
|
|
385
|
+
const plain = value.match(/filename\s*=\s*("([^"\\]*(?:\\.[^"\\]*)*)"|([^;]+))/i)
|
|
386
|
+
if (plain) {
|
|
387
|
+
const quoted = plain[2]
|
|
388
|
+
if (quoted !== undefined) return quoted.replace(/\\(.)/g, '$1').trim()
|
|
389
|
+
const unquoted = plain[3]
|
|
390
|
+
if (unquoted !== undefined) return unquoted.trim()
|
|
391
|
+
}
|
|
392
|
+
return undefined
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build a `Content-Disposition` value for an upload. The disposition-type is
|
|
397
|
+
* `inline` to match the example in the Claris OData docs.
|
|
398
|
+
*
|
|
399
|
+
* The Claris doc example uses the **unquoted** form
|
|
400
|
+
* (`filename=ALFKI.png`); FMS has been observed to mis-parse the RFC 6266
|
|
401
|
+
* quoted form on some deployments. So when the filename is composed of
|
|
402
|
+
* RFC 7230 token characters this function emits the unquoted form, falls
|
|
403
|
+
* back to a quoted form when the name contains spaces or other separator
|
|
404
|
+
* characters, and additionally emits an RFC 5987 `filename*=UTF-8''…`
|
|
405
|
+
* parameter for non-ASCII names.
|
|
406
|
+
*
|
|
407
|
+
* @internal
|
|
408
|
+
*/
|
|
409
|
+
export function formatContentDisposition(filename: string): string {
|
|
410
|
+
// RFC 7230 token = 1*tchar; tchar excludes separators and CTLs.
|
|
411
|
+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
412
|
+
// eslint-disable-next-line no-control-regex
|
|
413
|
+
const needsRfc5987 = /[^\x00-\x7F]/.test(filename)
|
|
414
|
+
|
|
415
|
+
let base: string
|
|
416
|
+
if (TOKEN_RE.test(filename)) {
|
|
417
|
+
base = `inline; filename=${filename}`
|
|
418
|
+
} else {
|
|
419
|
+
const safeAscii = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '\\"')
|
|
420
|
+
base = `inline; filename="${safeAscii}"`
|
|
421
|
+
}
|
|
422
|
+
if (!needsRfc5987) return base
|
|
423
|
+
return `${base}; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Coerce a `Blob | ArrayBuffer | Uint8Array` to a `Uint8Array`. */
|
|
427
|
+
async function toUint8Array(
|
|
428
|
+
data: Blob | ArrayBuffer | Uint8Array,
|
|
429
|
+
): Promise<Uint8Array> {
|
|
430
|
+
if (data instanceof Uint8Array) return data
|
|
431
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
|
432
|
+
// Treat anything else with an `arrayBuffer()` method (e.g. Blob) generically.
|
|
433
|
+
const ab = await (data as Blob).arrayBuffer()
|
|
434
|
+
return new Uint8Array(ab)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Encode bytes to a base64 string. Uses Node's `Buffer` when available, else
|
|
439
|
+
* a chunked `btoa` fallback (avoids stack overflow on large arrays).
|
|
440
|
+
*
|
|
441
|
+
* @internal
|
|
442
|
+
*/
|
|
443
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
444
|
+
if (typeof Buffer !== 'undefined') {
|
|
445
|
+
return Buffer.from(bytes).toString('base64')
|
|
446
|
+
}
|
|
447
|
+
let bin = ''
|
|
448
|
+
const chunk = 0x8000
|
|
449
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
450
|
+
bin += String.fromCharCode.apply(
|
|
451
|
+
null,
|
|
452
|
+
Array.from(bytes.subarray(i, i + chunk)),
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
// eslint-disable-next-line no-restricted-globals
|
|
456
|
+
return btoa(bin)
|
|
457
|
+
}
|