@classic-homes/files-core 0.0.0-stage → 0.1.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/README.md +28 -2
- package/dist/index.d.ts +335 -0
- package/dist/index.js +741 -0
- package/package.json +46 -4
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @classic-homes/files-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-agnostic network-drive browsing for Classic Theme:
|
|
4
|
+
|
|
5
|
+
- `FilesSource`: the interface every file browser binding reads from
|
|
6
|
+
- `createChapiFilesSource()`: CHAPI's `/v2/files` API (the on-prem SMB gateway), read-only
|
|
7
|
+
- `createMemoryFilesSource()`: an in-memory drive for demos and tests
|
|
8
|
+
- `FilesError` with typed codes, `describeFilesError()` for user-facing copy
|
|
9
|
+
- Path, sort, filter, size/date formatting and file-kind helpers
|
|
10
|
+
|
|
11
|
+
No runtime dependencies. Svelte components: [`@classic-homes/files-svelte`](../files-svelte).
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createChapiFilesSource } from '@classic-homes/files-core';
|
|
15
|
+
import { getAccessToken, refreshSession } from '@classic-homes/auth/core';
|
|
16
|
+
|
|
17
|
+
const files = createChapiFilesSource({
|
|
18
|
+
baseUrl: 'https://api.classichomes.com',
|
|
19
|
+
getAccessToken,
|
|
20
|
+
onUnauthorized: async () => (await refreshSession()) === 'refreshed',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const roots = await files.listRoots();
|
|
24
|
+
const page = await files.list(roots[0].id, '', { limit: 200 });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`canWrite` is true only when the caller's access is `write` **and** the service is not read-only; there are no write endpoints yet, so the adapter has no write methods.
|
|
28
|
+
|
|
29
|
+
Docs: `/docs/utilities/files-source` in the Svelte demo app.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data model shared by every files source (CHAPI, in-memory, custom) and
|
|
3
|
+
* every framework binding. Paths are always root-relative, `/` separated, with
|
|
4
|
+
* `''` for the root itself.
|
|
5
|
+
*/
|
|
6
|
+
/** `link` = symlink / junction / shortcut: shown in listings, never opened. */
|
|
7
|
+
type FileEntryType = 'file' | 'dir' | 'link';
|
|
8
|
+
interface FileEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
/** Root-relative, `/` separated; `''` is the root. Treat the server's value as the truth. */
|
|
11
|
+
path: string;
|
|
12
|
+
type: FileEntryType;
|
|
13
|
+
/** Bytes; 0 for directories. */
|
|
14
|
+
size: number;
|
|
15
|
+
/** ISO 8601 timestamp. */
|
|
16
|
+
modified: string;
|
|
17
|
+
/** Already quoted, e.g. `"\"18a8…-10366\""`. Kept for `If-Match` on future writes. */
|
|
18
|
+
etag?: string;
|
|
19
|
+
hidden: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface FileRoot {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
description?: string | null;
|
|
25
|
+
/** True only when the caller may write AND the service accepts writes. */
|
|
26
|
+
canWrite: boolean;
|
|
27
|
+
}
|
|
28
|
+
interface FileListPage {
|
|
29
|
+
entries: FileEntry[];
|
|
30
|
+
/** Entries in the whole folder, when the source knows. */
|
|
31
|
+
total?: number;
|
|
32
|
+
/** Pass back as `cursor` for the next page; `null`/absent when there are no more. */
|
|
33
|
+
nextCursor?: string | null;
|
|
34
|
+
}
|
|
35
|
+
interface ListOptions {
|
|
36
|
+
cursor?: string;
|
|
37
|
+
/** Page size, 1–1000. The CHAPI default is 200. */
|
|
38
|
+
limit?: number;
|
|
39
|
+
includeHidden?: boolean;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
interface ReadOptions {
|
|
43
|
+
/** Ask for `Content-Disposition: attachment`. */
|
|
44
|
+
download?: boolean;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
interface SearchOptions extends ListOptions {
|
|
48
|
+
/** Folder to search under; `''` (the default) searches the whole root. */
|
|
49
|
+
path?: string;
|
|
50
|
+
}
|
|
51
|
+
/** Options for a future upload. */
|
|
52
|
+
interface UploadOptions {
|
|
53
|
+
/** Overwrite only when the current entry still has this etag (`If-Match`). */
|
|
54
|
+
ifMatch?: string;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
/** Options for future move/remove calls. */
|
|
58
|
+
interface WriteOptions {
|
|
59
|
+
ifMatch?: string;
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Where a file browser gets its data. Components never call `fetch`
|
|
64
|
+
* themselves: pass `createChapiFilesSource()` in apps and
|
|
65
|
+
* `createMemoryFilesSource()` in demos and tests, or write your own.
|
|
66
|
+
*/
|
|
67
|
+
interface FilesSource {
|
|
68
|
+
/** Roots the caller may read. */
|
|
69
|
+
listRoots(signal?: AbortSignal): Promise<FileRoot[]>;
|
|
70
|
+
/** One page of a folder: directories first, then names, case-insensitively. */
|
|
71
|
+
list(rootId: string, path: string, opts?: ListOptions): Promise<FileListPage>;
|
|
72
|
+
info(rootId: string, path: string, signal?: AbortSignal): Promise<FileEntry>;
|
|
73
|
+
/** Bytes for preview or download. Must send auth, so it returns a Blob, not a URL. */
|
|
74
|
+
read(rootId: string, path: string, opts?: ReadOptions): Promise<Blob>;
|
|
75
|
+
/**
|
|
76
|
+
* Server-side name search. Not offered by CHAPI yet; when a source has it,
|
|
77
|
+
* FileBrowser's filter box searches the whole root instead of the loaded page.
|
|
78
|
+
*/
|
|
79
|
+
search?(rootId: string, query: string, opts?: SearchOptions): Promise<FileListPage>;
|
|
80
|
+
upload?(rootId: string, path: string, file: Blob, opts?: UploadOptions): Promise<FileEntry>;
|
|
81
|
+
mkdir?(rootId: string, path: string, signal?: AbortSignal): Promise<FileEntry>;
|
|
82
|
+
move?(rootId: string, from: string, to: string, opts?: WriteOptions): Promise<FileEntry>;
|
|
83
|
+
remove?(rootId: string, path: string, opts?: WriteOptions): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
/** Which write actions a root offers through a source. All false while a root is read-only. */
|
|
86
|
+
interface WriteCapabilities {
|
|
87
|
+
upload: boolean;
|
|
88
|
+
mkdir: boolean;
|
|
89
|
+
move: boolean;
|
|
90
|
+
remove: boolean;
|
|
91
|
+
/** Any of the above. */
|
|
92
|
+
any: boolean;
|
|
93
|
+
}
|
|
94
|
+
declare function writeCapabilities(source: FilesSource, root: FileRoot | undefined | null): WriteCapabilities;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Typed errors for files sources, so a UI can render the right state without
|
|
98
|
+
* parsing messages. CHAPI puts the gateway's code in `error.details.reason`.
|
|
99
|
+
*/
|
|
100
|
+
type FilesErrorCode = 'invalid_path' | 'not_a_directory' | 'is_directory' | 'invalid_cursor' | 'unauthorized' | 'link_not_followed' | 'access_denied' | 'not_found' | 'locked' | 'unavailable' | 'timeout' | 'file_server_error' | 'network' | 'unknown';
|
|
101
|
+
declare const BAD_REQUEST_REASONS: readonly ["invalid_path", "not_a_directory", "is_directory", "invalid_cursor"];
|
|
102
|
+
interface FilesErrorInit {
|
|
103
|
+
status?: number;
|
|
104
|
+
/** Seconds to wait before retrying (from `Retry-After`). */
|
|
105
|
+
retryAfter?: number;
|
|
106
|
+
/** The raw reason / code the server sent, when it differs from `code`. */
|
|
107
|
+
reason?: string;
|
|
108
|
+
cause?: unknown;
|
|
109
|
+
}
|
|
110
|
+
declare class FilesError extends Error {
|
|
111
|
+
readonly name = "FilesError";
|
|
112
|
+
readonly code: FilesErrorCode;
|
|
113
|
+
/** HTTP status, 0 when the request never got a response. */
|
|
114
|
+
readonly status: number;
|
|
115
|
+
readonly retryAfter?: number;
|
|
116
|
+
readonly reason?: string;
|
|
117
|
+
constructor(code: FilesErrorCode, message: string, init?: FilesErrorInit);
|
|
118
|
+
}
|
|
119
|
+
declare function isFilesError(value: unknown): value is FilesError;
|
|
120
|
+
/** A cancelled request (our own `AbortController`), which a UI should ignore. */
|
|
121
|
+
declare function isAbortError(value: unknown): boolean;
|
|
122
|
+
/** Map an HTTP status plus the server's reason/code to a {@link FilesErrorCode}. */
|
|
123
|
+
declare function filesErrorCode(status: number, reason?: string): FilesErrorCode;
|
|
124
|
+
/** What the UI should offer next to an error. */
|
|
125
|
+
type FilesErrorAction = 'retry' | 'root' | 'none';
|
|
126
|
+
interface FilesErrorDescription {
|
|
127
|
+
title: string;
|
|
128
|
+
description: string;
|
|
129
|
+
action: FilesErrorAction;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* User-facing copy for an error. Staff never see server names, status codes
|
|
133
|
+
* or the gateway's raw messages.
|
|
134
|
+
*/
|
|
135
|
+
declare function describeFilesError(error: unknown): FilesErrorDescription;
|
|
136
|
+
/** Seconds from a `Retry-After` header (delta-seconds or HTTP date). */
|
|
137
|
+
declare function parseRetryAfter(header: string | null | undefined, now?: number): number | undefined;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Normalise a root-relative path: backslashes become `/`, empty and `.`
|
|
141
|
+
* segments are dropped. Throws `FilesError('invalid_path')` for `..`.
|
|
142
|
+
*/
|
|
143
|
+
declare function normalizePath(path: string | null | undefined): string;
|
|
144
|
+
/** Join path parts: `joinPath('Lots', '14/Plans')` → `'Lots/14/Plans'`. */
|
|
145
|
+
declare function joinPath(...parts: (string | null | undefined)[]): string;
|
|
146
|
+
/** Parent folder; the root's parent is the root. */
|
|
147
|
+
declare function parentPath(path: string): string;
|
|
148
|
+
/** Last segment (`''` for the root). */
|
|
149
|
+
declare function baseName(path: string): string;
|
|
150
|
+
interface PathSegment {
|
|
151
|
+
name: string;
|
|
152
|
+
/** Path of this folder, from the root. */
|
|
153
|
+
path: string;
|
|
154
|
+
}
|
|
155
|
+
/** Each folder from the root down: `'A/B'` → `[{ name: 'A', path: 'A' }, { name: 'B', path: 'A/B' }]`. */
|
|
156
|
+
declare function pathSegments(path: string): PathSegment[];
|
|
157
|
+
/** True when `path` is `ancestor` or inside it. */
|
|
158
|
+
declare function isWithin(path: string, ancestor: string): boolean;
|
|
159
|
+
/** Root label plus path, for pasting into chat (never a UNC path). */
|
|
160
|
+
declare function displayPath(rootLabel: string, path: string): string;
|
|
161
|
+
|
|
162
|
+
/** Locale-aware display helpers for sizes and dates. */
|
|
163
|
+
interface FormatBytesOptions {
|
|
164
|
+
locale?: string | string[];
|
|
165
|
+
/** Maximum fraction digits for KB and up. Default 1. */
|
|
166
|
+
decimals?: number;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Human-readable size in binary units, labelled as Windows Explorer does:
|
|
170
|
+
* `1536` → `"1.5 KB"`. Negative or non-finite input returns `''`.
|
|
171
|
+
*/
|
|
172
|
+
declare function formatBytes(bytes: number, options?: FormatBytesOptions): string;
|
|
173
|
+
/** Absolute date and time in the viewer's locale and time zone, e.g. for a tooltip. */
|
|
174
|
+
declare function formatDateTime(value: string | number | Date, locale?: string | string[], options?: Intl.DateTimeFormatOptions): string;
|
|
175
|
+
interface FormatRelativeOptions {
|
|
176
|
+
locale?: string | string[];
|
|
177
|
+
/** Reference time. Default now. */
|
|
178
|
+
now?: number | Date;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Relative time, e.g. `"3 days ago"`, `"yesterday"`, `"just now"`. Older
|
|
182
|
+
* than a year shows the date instead, which reads better in a file list.
|
|
183
|
+
*/
|
|
184
|
+
declare function formatRelativeTime(value: string | number | Date, options?: FormatRelativeOptions): string;
|
|
185
|
+
|
|
186
|
+
/** File kinds by extension, for icons and preview routing. */
|
|
187
|
+
|
|
188
|
+
type FileKind = 'folder' | 'link' | 'pdf' | 'word' | 'excel' | 'csv' | 'cad' | 'image' | 'archive' | 'text' | 'generic';
|
|
189
|
+
/** Lower-cased extension without the dot (`''` when there is none). */
|
|
190
|
+
declare function fileExtension(name: string): string;
|
|
191
|
+
/** Kind of a file from its name alone. */
|
|
192
|
+
declare function kindForName(name: string): FileKind;
|
|
193
|
+
/** Kind of an entry: folders and links first, then the name's extension. */
|
|
194
|
+
declare function kindForEntry(entry: Pick<FileEntry, 'name' | 'type'>): FileKind;
|
|
195
|
+
/** Short label for a kind, e.g. for a details card. */
|
|
196
|
+
declare const KIND_LABELS: Record<FileKind, string>;
|
|
197
|
+
/** Document viewer formats (matches `@classic-homes/viewer-core` RenderableKind). */
|
|
198
|
+
type DocumentPreviewKind = 'pdf' | 'docx' | 'xlsx' | 'csv' | 'dxf' | 'dwg';
|
|
199
|
+
/**
|
|
200
|
+
* Never rendered inline, whatever the content: the gateway serves these as an
|
|
201
|
+
* attachment under a sandbox CSP.
|
|
202
|
+
*/
|
|
203
|
+
declare const NEVER_INLINE_EXTENSIONS: readonly string[];
|
|
204
|
+
type PreviewRoute = {
|
|
205
|
+
kind: 'document';
|
|
206
|
+
format: DocumentPreviewKind;
|
|
207
|
+
} | {
|
|
208
|
+
kind: 'image';
|
|
209
|
+
} | {
|
|
210
|
+
kind: 'none';
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* How a file can be previewed. Pass the formats the app registered viewers
|
|
214
|
+
* for; anything else (and every never-inline type) gets `{ kind: 'none' }`,
|
|
215
|
+
* i.e. a details card with a Download button.
|
|
216
|
+
*/
|
|
217
|
+
declare function previewRoute(entry: Pick<FileEntry, 'name' | 'type'>, documentFormats?: readonly string[]): PreviewRoute;
|
|
218
|
+
|
|
219
|
+
/** Client-side sort and filter over the entries already loaded. */
|
|
220
|
+
|
|
221
|
+
type FileSortKey = 'name' | 'modified' | 'size' | 'type';
|
|
222
|
+
type SortDirection = 'asc' | 'desc';
|
|
223
|
+
interface FileSort {
|
|
224
|
+
key: FileSortKey;
|
|
225
|
+
direction: SortDirection;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The server's order: directories first, then names case-insensitively.
|
|
229
|
+
* Useful for sources that build listings themselves.
|
|
230
|
+
*/
|
|
231
|
+
declare function compareEntries(a: FileEntry, b: FileEntry): number;
|
|
232
|
+
/**
|
|
233
|
+
* Sort a copy of `entries` by a column. Folders stay above files in either
|
|
234
|
+
* direction, as in Explorer. Ties fall back to the name.
|
|
235
|
+
*/
|
|
236
|
+
declare function sortEntries(entries: readonly FileEntry[], sort: FileSort): FileEntry[];
|
|
237
|
+
/** Entries whose name contains `query` (case- and accent-insensitive). */
|
|
238
|
+
declare function filterEntries(entries: readonly FileEntry[], query: string): FileEntry[];
|
|
239
|
+
|
|
240
|
+
/** Save a Blob as a file. Downloads need the bearer header, so a plain `<a href>` to the API won't do. */
|
|
241
|
+
declare function saveBlob(blob: Blob, filename: string): void;
|
|
242
|
+
|
|
243
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
244
|
+
interface ChapiFilesSourceOptions {
|
|
245
|
+
/** CHAPI origin, e.g. `https://api.classichomes.com`. A trailing `/v1` or `/v2` is ignored. */
|
|
246
|
+
baseUrl: string;
|
|
247
|
+
/** The app's CHAPI bearer token, sent on every request. */
|
|
248
|
+
getAccessToken?: () => MaybePromise<string | null | undefined>;
|
|
249
|
+
/** Extra headers per request (e.g. `X-Session-Token`). */
|
|
250
|
+
getHeaders?: () => MaybePromise<Record<string, string> | undefined>;
|
|
251
|
+
/**
|
|
252
|
+
* Called on a 401. Return `true` once the session is refreshed to retry the
|
|
253
|
+
* request once with the new token, e.g.
|
|
254
|
+
* `async () => (await refreshSession()) === 'refreshed'` with `@classic-homes/auth/core`.
|
|
255
|
+
*/
|
|
256
|
+
onUnauthorized?: () => MaybePromise<boolean>;
|
|
257
|
+
/** Defaults to the global `fetch`. */
|
|
258
|
+
fetch?: typeof fetch;
|
|
259
|
+
/** Time to wait for response headers. Default 30 s. File bodies may then take as long as they need. */
|
|
260
|
+
timeoutMs?: number;
|
|
261
|
+
}
|
|
262
|
+
/** Shape of a root in `GET /v2/files/roots`. */
|
|
263
|
+
interface ChapiRoot {
|
|
264
|
+
id: string;
|
|
265
|
+
label?: string;
|
|
266
|
+
description?: string | null;
|
|
267
|
+
access?: 'read' | 'write';
|
|
268
|
+
active?: boolean;
|
|
269
|
+
service?: {
|
|
270
|
+
configured?: boolean;
|
|
271
|
+
readOnly?: boolean;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
275
|
+
declare const DEFAULT_PAGE_SIZE = 200;
|
|
276
|
+
declare const MAX_PAGE_SIZE = 1000;
|
|
277
|
+
/** `https://api.example.com/v1/` → `https://api.example.com`. */
|
|
278
|
+
declare function chapiOrigin(baseUrl: string): string;
|
|
279
|
+
/**
|
|
280
|
+
* `canWrite` needs both: the caller's access is `write` AND the service is
|
|
281
|
+
* not read-only. Admins see `access: 'write'` on every root, so `access`
|
|
282
|
+
* alone is never enough.
|
|
283
|
+
*/
|
|
284
|
+
declare function rootFromChapi(root: ChapiRoot): FileRoot;
|
|
285
|
+
/** Defensive copy of a server entry (unknown types are treated as files). */
|
|
286
|
+
declare function entryFromChapi(raw: Partial<FileEntry>): FileEntry;
|
|
287
|
+
declare function createChapiFilesSource(options: ChapiFilesSourceOptions): FilesSource;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* An in-memory FilesSource for demos, tests and storybook-style pages. It
|
|
291
|
+
* behaves like CHAPI: paging with opaque cursors, hidden files, links that
|
|
292
|
+
* are listed but never followed, and the same typed errors.
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
type MemoryContent = Blob | string | (() => Blob | Promise<Blob>);
|
|
296
|
+
interface MemoryFile {
|
|
297
|
+
/** Root-relative path. Parent folders are created implicitly. */
|
|
298
|
+
path: string;
|
|
299
|
+
/** Default `'file'`. */
|
|
300
|
+
type?: FileEntryType;
|
|
301
|
+
/** Defaults to the content's size, or 0. */
|
|
302
|
+
size?: number;
|
|
303
|
+
modified?: string;
|
|
304
|
+
hidden?: boolean;
|
|
305
|
+
etag?: string;
|
|
306
|
+
content?: MemoryContent;
|
|
307
|
+
}
|
|
308
|
+
interface MemoryRoot extends FileRoot {
|
|
309
|
+
files: MemoryFile[];
|
|
310
|
+
}
|
|
311
|
+
type MemoryOperation = 'listRoots' | 'list' | 'info' | 'read' | 'search';
|
|
312
|
+
interface MemoryRequest {
|
|
313
|
+
op: MemoryOperation;
|
|
314
|
+
rootId?: string;
|
|
315
|
+
path?: string;
|
|
316
|
+
}
|
|
317
|
+
interface MemoryFilesSourceOptions {
|
|
318
|
+
roots: MemoryRoot[];
|
|
319
|
+
/** Simulated latency per call, in ms. Default 0. */
|
|
320
|
+
latency?: number;
|
|
321
|
+
/** Page size when `list()` is called without a limit. Default 200. */
|
|
322
|
+
defaultLimit?: number;
|
|
323
|
+
/**
|
|
324
|
+
* Called before every request; return (or throw) a FilesError to fail it.
|
|
325
|
+
* The demo uses this for its error toggles.
|
|
326
|
+
*/
|
|
327
|
+
intercept?: (request: MemoryRequest) => FilesError | void | undefined;
|
|
328
|
+
/** Offer `search()` (whole-root name search). Default false, like CHAPI today. */
|
|
329
|
+
search?: boolean;
|
|
330
|
+
/** Default modified time for entries without one. */
|
|
331
|
+
now?: string;
|
|
332
|
+
}
|
|
333
|
+
declare function createMemoryFilesSource(options: MemoryFilesSourceOptions): FilesSource;
|
|
334
|
+
|
|
335
|
+
export { BAD_REQUEST_REASONS, type ChapiFilesSourceOptions, DEFAULT_PAGE_SIZE, DEFAULT_TIMEOUT_MS, type DocumentPreviewKind, type FileEntry, type FileEntryType, type FileKind, type FileListPage, type FileRoot, type FileSort, type FileSortKey, FilesError, type FilesErrorAction, type FilesErrorCode, type FilesErrorDescription, type FilesErrorInit, type FilesSource, type FormatBytesOptions, type FormatRelativeOptions, KIND_LABELS, type ListOptions, MAX_PAGE_SIZE, type MemoryContent, type MemoryFile, type MemoryFilesSourceOptions, type MemoryOperation, type MemoryRequest, type MemoryRoot, NEVER_INLINE_EXTENSIONS, type PathSegment, type PreviewRoute, type ReadOptions, type SearchOptions, type SortDirection, type UploadOptions, type WriteCapabilities, type WriteOptions, baseName, chapiOrigin, compareEntries, createChapiFilesSource, createMemoryFilesSource, describeFilesError, displayPath, entryFromChapi, fileExtension, filesErrorCode, filterEntries, formatBytes, formatDateTime, formatRelativeTime, isAbortError, isFilesError, isWithin, joinPath, kindForEntry, kindForName, normalizePath, parentPath, parseRetryAfter, pathSegments, previewRoute, rootFromChapi, saveBlob, sortEntries, writeCapabilities };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
function writeCapabilities(source, root) {
|
|
3
|
+
const allowed = !!root?.canWrite;
|
|
4
|
+
const caps = {
|
|
5
|
+
upload: allowed && typeof source.upload === "function",
|
|
6
|
+
mkdir: allowed && typeof source.mkdir === "function",
|
|
7
|
+
move: allowed && typeof source.move === "function",
|
|
8
|
+
remove: allowed && typeof source.remove === "function"
|
|
9
|
+
};
|
|
10
|
+
return { ...caps, any: caps.upload || caps.mkdir || caps.move || caps.remove };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/errors.ts
|
|
14
|
+
var BAD_REQUEST_REASONS = [
|
|
15
|
+
"invalid_path",
|
|
16
|
+
"not_a_directory",
|
|
17
|
+
"is_directory",
|
|
18
|
+
"invalid_cursor"
|
|
19
|
+
];
|
|
20
|
+
var FilesError = class extends Error {
|
|
21
|
+
constructor(code, message, init = {}) {
|
|
22
|
+
super(message, init.cause === void 0 ? void 0 : { cause: init.cause });
|
|
23
|
+
this.name = "FilesError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.status = init.status ?? 0;
|
|
26
|
+
this.retryAfter = init.retryAfter;
|
|
27
|
+
this.reason = init.reason;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function isFilesError(value) {
|
|
31
|
+
return value instanceof FilesError || typeof value === "object" && value !== null && value.name === "FilesError" && typeof value.code === "string";
|
|
32
|
+
}
|
|
33
|
+
function isAbortError(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && value.name === "AbortError";
|
|
35
|
+
}
|
|
36
|
+
function filesErrorCode(status, reason) {
|
|
37
|
+
const r = reason?.toLowerCase();
|
|
38
|
+
switch (status) {
|
|
39
|
+
case 400:
|
|
40
|
+
return BAD_REQUEST_REASONS.includes(r ?? "") ? r : "invalid_path";
|
|
41
|
+
case 401:
|
|
42
|
+
return "unauthorized";
|
|
43
|
+
case 403:
|
|
44
|
+
return r === "link_not_followed" ? "link_not_followed" : "access_denied";
|
|
45
|
+
case 404:
|
|
46
|
+
return "not_found";
|
|
47
|
+
case 423:
|
|
48
|
+
return "locked";
|
|
49
|
+
case 503:
|
|
50
|
+
return "unavailable";
|
|
51
|
+
case 504:
|
|
52
|
+
return "timeout";
|
|
53
|
+
case 502:
|
|
54
|
+
return "file_server_error";
|
|
55
|
+
default:
|
|
56
|
+
return status >= 500 ? "file_server_error" : "unknown";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function describeFilesError(error) {
|
|
60
|
+
const code = isFilesError(error) ? error.code : "unknown";
|
|
61
|
+
switch (code) {
|
|
62
|
+
case "invalid_path":
|
|
63
|
+
case "not_a_directory":
|
|
64
|
+
case "is_directory":
|
|
65
|
+
case "invalid_cursor":
|
|
66
|
+
return {
|
|
67
|
+
title: "This location can't be opened",
|
|
68
|
+
description: "The address may be mistyped or out of date.",
|
|
69
|
+
action: "root"
|
|
70
|
+
};
|
|
71
|
+
case "unauthorized":
|
|
72
|
+
return {
|
|
73
|
+
title: "Your session has ended",
|
|
74
|
+
description: "Sign in again to keep browsing.",
|
|
75
|
+
action: "none"
|
|
76
|
+
};
|
|
77
|
+
case "link_not_followed":
|
|
78
|
+
return {
|
|
79
|
+
title: "This shortcut can't be opened here",
|
|
80
|
+
description: "This is a shortcut to another location and can't be opened here.",
|
|
81
|
+
action: "none"
|
|
82
|
+
};
|
|
83
|
+
case "access_denied":
|
|
84
|
+
return {
|
|
85
|
+
title: "You don't have access to this folder",
|
|
86
|
+
description: "Ask the folder owner or IT if you need it.",
|
|
87
|
+
action: "none"
|
|
88
|
+
};
|
|
89
|
+
case "not_found":
|
|
90
|
+
return {
|
|
91
|
+
title: "This folder no longer exists",
|
|
92
|
+
description: "It may have been moved, renamed or deleted.",
|
|
93
|
+
action: "root"
|
|
94
|
+
};
|
|
95
|
+
case "locked":
|
|
96
|
+
return {
|
|
97
|
+
title: "This file is in use",
|
|
98
|
+
description: "Someone has this file open (often in Office). Try again shortly.",
|
|
99
|
+
action: "retry"
|
|
100
|
+
};
|
|
101
|
+
case "unavailable":
|
|
102
|
+
return {
|
|
103
|
+
title: "The network drive is unavailable",
|
|
104
|
+
description: "It may be busy or down for maintenance.",
|
|
105
|
+
action: "retry"
|
|
106
|
+
};
|
|
107
|
+
case "timeout":
|
|
108
|
+
case "file_server_error":
|
|
109
|
+
case "network":
|
|
110
|
+
return {
|
|
111
|
+
title: "Couldn't reach the file server",
|
|
112
|
+
description: "Check your connection and try again.",
|
|
113
|
+
action: "retry"
|
|
114
|
+
};
|
|
115
|
+
default:
|
|
116
|
+
return {
|
|
117
|
+
title: "Something went wrong",
|
|
118
|
+
description: "Try again in a moment.",
|
|
119
|
+
action: "retry"
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function parseRetryAfter(header, now = Date.now()) {
|
|
124
|
+
if (!header) return void 0;
|
|
125
|
+
const trimmed = header.trim();
|
|
126
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
127
|
+
const date = Date.parse(trimmed);
|
|
128
|
+
if (Number.isNaN(date)) return void 0;
|
|
129
|
+
return Math.max(0, Math.ceil((date - now) / 1e3));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/path.ts
|
|
133
|
+
function normalizePath(path) {
|
|
134
|
+
if (!path) return "";
|
|
135
|
+
const segments = [];
|
|
136
|
+
for (const segment of path.replace(/\\/g, "/").split("/")) {
|
|
137
|
+
if (segment === "" || segment === ".") continue;
|
|
138
|
+
if (segment === "..") {
|
|
139
|
+
throw new FilesError("invalid_path", 'Paths may not contain "..".', { status: 400 });
|
|
140
|
+
}
|
|
141
|
+
segments.push(segment);
|
|
142
|
+
}
|
|
143
|
+
return segments.join("/");
|
|
144
|
+
}
|
|
145
|
+
function joinPath(...parts) {
|
|
146
|
+
return normalizePath(parts.filter((p) => p).join("/"));
|
|
147
|
+
}
|
|
148
|
+
function parentPath(path) {
|
|
149
|
+
const normal = normalizePath(path);
|
|
150
|
+
const slash = normal.lastIndexOf("/");
|
|
151
|
+
return slash < 0 ? "" : normal.slice(0, slash);
|
|
152
|
+
}
|
|
153
|
+
function baseName(path) {
|
|
154
|
+
const normal = normalizePath(path);
|
|
155
|
+
return normal.slice(normal.lastIndexOf("/") + 1);
|
|
156
|
+
}
|
|
157
|
+
function pathSegments(path) {
|
|
158
|
+
const normal = normalizePath(path);
|
|
159
|
+
if (!normal) return [];
|
|
160
|
+
const names = normal.split("/");
|
|
161
|
+
return names.map((name, i) => ({ name, path: names.slice(0, i + 1).join("/") }));
|
|
162
|
+
}
|
|
163
|
+
function isWithin(path, ancestor) {
|
|
164
|
+
const p = normalizePath(path);
|
|
165
|
+
const a = normalizePath(ancestor);
|
|
166
|
+
return a === "" || p === a || p.startsWith(`${a}/`);
|
|
167
|
+
}
|
|
168
|
+
function displayPath(rootLabel, path) {
|
|
169
|
+
const normal = normalizePath(path);
|
|
170
|
+
return normal ? `${rootLabel} / ${normal.split("/").join(" / ")}` : rootLabel;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/format.ts
|
|
174
|
+
var UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
175
|
+
function formatBytes(bytes, options = {}) {
|
|
176
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
177
|
+
const { locale, decimals = 1 } = options;
|
|
178
|
+
let value = bytes;
|
|
179
|
+
let unit = 0;
|
|
180
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
181
|
+
value /= 1024;
|
|
182
|
+
unit++;
|
|
183
|
+
}
|
|
184
|
+
const factor = 10 ** decimals;
|
|
185
|
+
if (unit > 0 && Math.round(value * factor) / factor >= 1024 && unit < UNITS.length - 1) {
|
|
186
|
+
value /= 1024;
|
|
187
|
+
unit++;
|
|
188
|
+
}
|
|
189
|
+
const number = new Intl.NumberFormat(locale, {
|
|
190
|
+
maximumFractionDigits: unit === 0 ? 0 : decimals
|
|
191
|
+
}).format(value);
|
|
192
|
+
return `${number} ${UNITS[unit]}`;
|
|
193
|
+
}
|
|
194
|
+
function toDate(value) {
|
|
195
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
196
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
197
|
+
}
|
|
198
|
+
function formatDateTime(value, locale, options = { dateStyle: "medium", timeStyle: "short" }) {
|
|
199
|
+
const date = toDate(value);
|
|
200
|
+
return date ? new Intl.DateTimeFormat(locale, options).format(date) : "";
|
|
201
|
+
}
|
|
202
|
+
var RELATIVE_STEPS = [
|
|
203
|
+
["second", 60],
|
|
204
|
+
["minute", 60],
|
|
205
|
+
["hour", 24],
|
|
206
|
+
["day", 7],
|
|
207
|
+
["week", 4.348],
|
|
208
|
+
["month", 12],
|
|
209
|
+
["year", Infinity]
|
|
210
|
+
];
|
|
211
|
+
function formatRelativeTime(value, options = {}) {
|
|
212
|
+
const date = toDate(value);
|
|
213
|
+
if (!date) return "";
|
|
214
|
+
const now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now();
|
|
215
|
+
let delta = (date.getTime() - now) / 1e3;
|
|
216
|
+
if (Math.abs(delta) < 45) {
|
|
217
|
+
return new Intl.RelativeTimeFormat(options.locale, { numeric: "auto" }).format(0, "second");
|
|
218
|
+
}
|
|
219
|
+
if (Math.abs(delta) >= 365 * 24 * 3600) {
|
|
220
|
+
return formatDateTime(date, options.locale, { dateStyle: "medium" });
|
|
221
|
+
}
|
|
222
|
+
const rtf = new Intl.RelativeTimeFormat(options.locale, { numeric: "auto" });
|
|
223
|
+
for (const [unit, size] of RELATIVE_STEPS) {
|
|
224
|
+
if (Math.abs(delta) < size) return rtf.format(Math.round(delta), unit);
|
|
225
|
+
delta /= size;
|
|
226
|
+
}
|
|
227
|
+
return formatDateTime(date, options.locale, { dateStyle: "medium" });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/kind.ts
|
|
231
|
+
var EXTENSIONS = {
|
|
232
|
+
pdf: ["pdf"],
|
|
233
|
+
word: ["doc", "docx", "docm", "dot", "dotx", "rtf", "odt"],
|
|
234
|
+
excel: ["xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "ods"],
|
|
235
|
+
csv: ["csv", "tsv"],
|
|
236
|
+
cad: ["dwg", "dxf", "dwf", "dwfx", "dgn", "rvt", "skp", "ifc"],
|
|
237
|
+
image: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "avif", "tif", "tiff", "heic", "svg"],
|
|
238
|
+
archive: ["zip", "7z", "rar", "tar", "gz", "tgz", "bz2", "xz"],
|
|
239
|
+
text: ["txt", "md", "log", "json", "xml", "html", "htm", "js", "ts", "css", "ini", "yml", "yaml"]
|
|
240
|
+
};
|
|
241
|
+
var BY_EXTENSION = new Map(
|
|
242
|
+
Object.entries(EXTENSIONS).flatMap(([kind, exts]) => exts.map((e) => [e, kind]))
|
|
243
|
+
);
|
|
244
|
+
function fileExtension(name) {
|
|
245
|
+
const base = name.slice(name.lastIndexOf("/") + 1);
|
|
246
|
+
const dot = base.lastIndexOf(".");
|
|
247
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
248
|
+
}
|
|
249
|
+
function kindForName(name) {
|
|
250
|
+
return BY_EXTENSION.get(fileExtension(name)) ?? "generic";
|
|
251
|
+
}
|
|
252
|
+
function kindForEntry(entry) {
|
|
253
|
+
if (entry.type === "dir") return "folder";
|
|
254
|
+
if (entry.type === "link") return "link";
|
|
255
|
+
return kindForName(entry.name);
|
|
256
|
+
}
|
|
257
|
+
var KIND_LABELS = {
|
|
258
|
+
folder: "Folder",
|
|
259
|
+
link: "Shortcut",
|
|
260
|
+
pdf: "PDF document",
|
|
261
|
+
word: "Word document",
|
|
262
|
+
excel: "Excel workbook",
|
|
263
|
+
csv: "CSV file",
|
|
264
|
+
cad: "CAD drawing",
|
|
265
|
+
image: "Image",
|
|
266
|
+
archive: "Archive",
|
|
267
|
+
text: "Text file",
|
|
268
|
+
generic: "File"
|
|
269
|
+
};
|
|
270
|
+
var DOCUMENT_PREVIEW = {
|
|
271
|
+
pdf: "pdf",
|
|
272
|
+
docx: "docx",
|
|
273
|
+
docm: "docx",
|
|
274
|
+
xlsx: "xlsx",
|
|
275
|
+
xlsm: "xlsx",
|
|
276
|
+
csv: "csv",
|
|
277
|
+
dxf: "dxf",
|
|
278
|
+
dwg: "dwg"
|
|
279
|
+
};
|
|
280
|
+
var PREVIEW_IMAGES = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "avif"]);
|
|
281
|
+
var NEVER_INLINE_EXTENSIONS = [
|
|
282
|
+
"html",
|
|
283
|
+
"htm",
|
|
284
|
+
"xhtml",
|
|
285
|
+
"svg",
|
|
286
|
+
"svgz",
|
|
287
|
+
"xml",
|
|
288
|
+
"xsl",
|
|
289
|
+
"js",
|
|
290
|
+
"mjs"
|
|
291
|
+
];
|
|
292
|
+
function previewRoute(entry, documentFormats = []) {
|
|
293
|
+
if (entry.type !== "file") return { kind: "none" };
|
|
294
|
+
const ext = fileExtension(entry.name);
|
|
295
|
+
if (NEVER_INLINE_EXTENSIONS.includes(ext)) return { kind: "none" };
|
|
296
|
+
if (PREVIEW_IMAGES.has(ext)) return { kind: "image" };
|
|
297
|
+
const format = DOCUMENT_PREVIEW[ext];
|
|
298
|
+
if (format && documentFormats.includes(format)) return { kind: "document", format };
|
|
299
|
+
return { kind: "none" };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/sort.ts
|
|
303
|
+
var collator = new Intl.Collator(void 0, { sensitivity: "base", numeric: true });
|
|
304
|
+
function groupOf(entry) {
|
|
305
|
+
return entry.type === "dir" ? 0 : entry.type === "link" ? 1 : 2;
|
|
306
|
+
}
|
|
307
|
+
function compareEntries(a, b) {
|
|
308
|
+
return (a.type === "dir" ? 0 : 1) - (b.type === "dir" ? 0 : 1) || a.name.toLowerCase().localeCompare(b.name.toLowerCase(), "en") || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
309
|
+
}
|
|
310
|
+
function sortEntries(entries, sort) {
|
|
311
|
+
const dir = sort.direction === "desc" ? -1 : 1;
|
|
312
|
+
const byName = (a, b) => collator.compare(a.name, b.name);
|
|
313
|
+
const byKey = (a, b) => {
|
|
314
|
+
switch (sort.key) {
|
|
315
|
+
case "modified":
|
|
316
|
+
return Date.parse(a.modified) - Date.parse(b.modified) || 0;
|
|
317
|
+
case "size":
|
|
318
|
+
return a.size - b.size;
|
|
319
|
+
case "type":
|
|
320
|
+
return collator.compare(extension(a), extension(b));
|
|
321
|
+
default:
|
|
322
|
+
return byName(a, b);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
return [...entries].sort(
|
|
326
|
+
(a, b) => groupOf(a) - groupOf(b) || dir * byKey(a, b) || dir * byName(a, b)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
function extension(entry) {
|
|
330
|
+
const dot = entry.name.lastIndexOf(".");
|
|
331
|
+
return entry.type === "file" && dot > 0 ? entry.name.slice(dot + 1) : "";
|
|
332
|
+
}
|
|
333
|
+
function filterEntries(entries, query) {
|
|
334
|
+
const needle = fold(query.trim());
|
|
335
|
+
if (!needle) return [...entries];
|
|
336
|
+
return entries.filter((e) => fold(e.name).includes(needle));
|
|
337
|
+
}
|
|
338
|
+
function fold(text) {
|
|
339
|
+
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/download.ts
|
|
343
|
+
function saveBlob(blob, filename) {
|
|
344
|
+
if (typeof document === "undefined") return;
|
|
345
|
+
const url = URL.createObjectURL(blob);
|
|
346
|
+
const a = document.createElement("a");
|
|
347
|
+
a.href = url;
|
|
348
|
+
a.download = filename;
|
|
349
|
+
a.rel = "noopener";
|
|
350
|
+
a.style.display = "none";
|
|
351
|
+
document.body.appendChild(a);
|
|
352
|
+
a.click();
|
|
353
|
+
a.remove();
|
|
354
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/chapi.ts
|
|
358
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
359
|
+
var DEFAULT_PAGE_SIZE = 200;
|
|
360
|
+
var MAX_PAGE_SIZE = 1e3;
|
|
361
|
+
function chapiOrigin(baseUrl) {
|
|
362
|
+
return baseUrl.trim().replace(/\/+$/, "").replace(/\/v[12]$/, "").replace(/\/+$/, "");
|
|
363
|
+
}
|
|
364
|
+
function rootFromChapi(root) {
|
|
365
|
+
return {
|
|
366
|
+
id: root.id,
|
|
367
|
+
label: root.label || root.id,
|
|
368
|
+
description: root.description ?? null,
|
|
369
|
+
canWrite: root.access === "write" && root.service?.readOnly === false
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
var ENTRY_TYPES = ["file", "dir", "link"];
|
|
373
|
+
function entryFromChapi(raw) {
|
|
374
|
+
const type = ENTRY_TYPES.includes(raw.type) ? raw.type : "file";
|
|
375
|
+
const entry = {
|
|
376
|
+
name: String(raw.name ?? ""),
|
|
377
|
+
path: String(raw.path ?? raw.name ?? ""),
|
|
378
|
+
type,
|
|
379
|
+
size: type === "dir" ? 0 : Number(raw.size) || 0,
|
|
380
|
+
modified: String(raw.modified ?? ""),
|
|
381
|
+
hidden: raw.hidden === true
|
|
382
|
+
};
|
|
383
|
+
if (raw.etag) entry.etag = raw.etag;
|
|
384
|
+
return entry;
|
|
385
|
+
}
|
|
386
|
+
var MESSAGES = {
|
|
387
|
+
unauthorized: "Your session has ended. Sign in again.",
|
|
388
|
+
network: "Could not reach the file server.",
|
|
389
|
+
timeout: "The file server did not respond in time."
|
|
390
|
+
};
|
|
391
|
+
async function errorFromResponse(res) {
|
|
392
|
+
let body = null;
|
|
393
|
+
try {
|
|
394
|
+
body = await res.json();
|
|
395
|
+
} catch {
|
|
396
|
+
body = null;
|
|
397
|
+
}
|
|
398
|
+
const reason = body?.error?.details?.reason ?? body?.error?.code;
|
|
399
|
+
const code = filesErrorCode(res.status, reason);
|
|
400
|
+
return new FilesError(
|
|
401
|
+
code,
|
|
402
|
+
body?.error?.message || MESSAGES[code] || `Request failed (${res.status})`,
|
|
403
|
+
{
|
|
404
|
+
status: res.status,
|
|
405
|
+
reason,
|
|
406
|
+
retryAfter: parseRetryAfter(res.headers.get("retry-after"))
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
function createChapiFilesSource(options) {
|
|
411
|
+
const origin = chapiOrigin(options.baseUrl);
|
|
412
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
413
|
+
const doFetch = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
|
|
414
|
+
function url(endpoint, query) {
|
|
415
|
+
const params = new URLSearchParams();
|
|
416
|
+
for (const [key, value] of Object.entries(query)) {
|
|
417
|
+
if (value !== void 0) params.set(key, String(value));
|
|
418
|
+
}
|
|
419
|
+
const qs = params.toString();
|
|
420
|
+
return `${origin}/v2/files${endpoint}${qs ? `?${qs}` : ""}`;
|
|
421
|
+
}
|
|
422
|
+
async function headers(accept) {
|
|
423
|
+
const out = { Accept: accept };
|
|
424
|
+
const extra = await options.getHeaders?.();
|
|
425
|
+
if (extra) Object.assign(out, extra);
|
|
426
|
+
const token = await options.getAccessToken?.();
|
|
427
|
+
if (token) out.Authorization = `Bearer ${token}`;
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
async function request(target, accept, signal, retried = false) {
|
|
431
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
let timedOut = false;
|
|
434
|
+
const timer = setTimeout(() => {
|
|
435
|
+
timedOut = true;
|
|
436
|
+
controller.abort();
|
|
437
|
+
}, timeoutMs);
|
|
438
|
+
const onAbort = () => controller.abort();
|
|
439
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
440
|
+
const release = () => clearTimeout(timer);
|
|
441
|
+
let res;
|
|
442
|
+
try {
|
|
443
|
+
const init = { headers: await headers(accept), signal: controller.signal };
|
|
444
|
+
if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
445
|
+
res = await doFetch(target, init);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
release();
|
|
448
|
+
signal?.removeEventListener("abort", onAbort);
|
|
449
|
+
if (signal?.aborted && isAbortError(err)) throw err;
|
|
450
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
451
|
+
if (timedOut) throw new FilesError("timeout", MESSAGES.timeout, { cause: err });
|
|
452
|
+
throw new FilesError("network", MESSAGES.network, { cause: err });
|
|
453
|
+
}
|
|
454
|
+
release();
|
|
455
|
+
if (res.status === 401 && !retried && options.onUnauthorized) {
|
|
456
|
+
await res.body?.cancel().catch(() => {
|
|
457
|
+
});
|
|
458
|
+
signal?.removeEventListener("abort", onAbort);
|
|
459
|
+
if (await options.onUnauthorized()) return request(target, accept, signal, true);
|
|
460
|
+
throw new FilesError("unauthorized", MESSAGES.unauthorized, { status: 401 });
|
|
461
|
+
}
|
|
462
|
+
if (!res.ok) {
|
|
463
|
+
signal?.removeEventListener("abort", onAbort);
|
|
464
|
+
throw await errorFromResponse(res);
|
|
465
|
+
}
|
|
466
|
+
return res;
|
|
467
|
+
}
|
|
468
|
+
async function json(target, signal) {
|
|
469
|
+
const res = await request(target, "application/json", signal);
|
|
470
|
+
try {
|
|
471
|
+
return await res.json();
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (signal?.aborted) throw err;
|
|
474
|
+
throw new FilesError("file_server_error", "The file server sent an unreadable reply.", {
|
|
475
|
+
status: res.status,
|
|
476
|
+
cause: err
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
async listRoots(signal) {
|
|
482
|
+
const body = await json(url("/roots", {}), signal);
|
|
483
|
+
return (body.data ?? []).filter((r) => r && r.active !== false).map(rootFromChapi);
|
|
484
|
+
},
|
|
485
|
+
async list(rootId, path, opts = {}) {
|
|
486
|
+
const limit = opts.limit === void 0 ? void 0 : Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(opts.limit)));
|
|
487
|
+
const body = await json(
|
|
488
|
+
url(`/roots/${encodeURIComponent(rootId)}/entries`, {
|
|
489
|
+
path: normalizePath(path),
|
|
490
|
+
limit,
|
|
491
|
+
cursor: opts.cursor || void 0,
|
|
492
|
+
includeHidden: opts.includeHidden ? true : void 0
|
|
493
|
+
}),
|
|
494
|
+
opts.signal
|
|
495
|
+
);
|
|
496
|
+
return {
|
|
497
|
+
entries: (body.data ?? []).map(entryFromChapi),
|
|
498
|
+
total: typeof body.meta?.total === "number" ? body.meta.total : void 0,
|
|
499
|
+
nextCursor: body.meta?.nextCursor || null
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
async info(rootId, path, signal) {
|
|
503
|
+
const body = await json(
|
|
504
|
+
url(`/roots/${encodeURIComponent(rootId)}/info`, { path: normalizePath(path) }),
|
|
505
|
+
signal
|
|
506
|
+
);
|
|
507
|
+
if (!body.data) {
|
|
508
|
+
throw new FilesError("file_server_error", "The file server sent an empty reply.");
|
|
509
|
+
}
|
|
510
|
+
return entryFromChapi(body.data);
|
|
511
|
+
},
|
|
512
|
+
async read(rootId, path, opts = {}) {
|
|
513
|
+
const res = await request(
|
|
514
|
+
url(`/roots/${encodeURIComponent(rootId)}/content`, {
|
|
515
|
+
path: normalizePath(path),
|
|
516
|
+
download: opts.download ? "true" : "false"
|
|
517
|
+
}),
|
|
518
|
+
"*/*",
|
|
519
|
+
opts.signal
|
|
520
|
+
);
|
|
521
|
+
try {
|
|
522
|
+
return await res.blob();
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (opts.signal?.aborted) throw err;
|
|
525
|
+
throw new FilesError("network", "The download was interrupted.", { cause: err });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/memory.ts
|
|
532
|
+
var ENCODER = typeof TextEncoder === "undefined" ? void 0 : new TextEncoder();
|
|
533
|
+
function contentSize(content) {
|
|
534
|
+
if (content === void 0 || typeof content === "function") return 0;
|
|
535
|
+
if (typeof content === "string") return ENCODER?.encode(content).length ?? content.length;
|
|
536
|
+
return content.size;
|
|
537
|
+
}
|
|
538
|
+
function encodeCursor(offset) {
|
|
539
|
+
return `c${offset.toString(36)}`;
|
|
540
|
+
}
|
|
541
|
+
function decodeCursor(cursor) {
|
|
542
|
+
const match = /^c([0-9a-z]+)$/.exec(cursor);
|
|
543
|
+
if (!match) throw new FilesError("invalid_cursor", "Invalid cursor.", { status: 400 });
|
|
544
|
+
return parseInt(match[1], 36);
|
|
545
|
+
}
|
|
546
|
+
function wait(ms, signal) {
|
|
547
|
+
return new Promise((resolve, reject) => {
|
|
548
|
+
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
|
|
549
|
+
if (ms <= 0) return resolve();
|
|
550
|
+
const timer = setTimeout(done, ms);
|
|
551
|
+
function done() {
|
|
552
|
+
signal?.removeEventListener("abort", abort);
|
|
553
|
+
resolve();
|
|
554
|
+
}
|
|
555
|
+
function abort() {
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
558
|
+
}
|
|
559
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
function createMemoryFilesSource(options) {
|
|
563
|
+
const defaultModified = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
564
|
+
const roots = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const { files, ...root } of options.roots) {
|
|
566
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
567
|
+
const addDir = (path) => {
|
|
568
|
+
if (!path || nodes.has(path)) return;
|
|
569
|
+
addDir(parentPath(path));
|
|
570
|
+
nodes.set(path, {
|
|
571
|
+
entry: {
|
|
572
|
+
name: path.slice(path.lastIndexOf("/") + 1),
|
|
573
|
+
path,
|
|
574
|
+
type: "dir",
|
|
575
|
+
size: 0,
|
|
576
|
+
modified: defaultModified,
|
|
577
|
+
hidden: false
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
for (const file of files) {
|
|
582
|
+
const path = normalizePath(file.path);
|
|
583
|
+
if (!path) continue;
|
|
584
|
+
addDir(parentPath(path));
|
|
585
|
+
const type = file.type ?? "file";
|
|
586
|
+
const entry = {
|
|
587
|
+
name: path.slice(path.lastIndexOf("/") + 1),
|
|
588
|
+
path,
|
|
589
|
+
type,
|
|
590
|
+
size: type === "dir" ? 0 : file.size ?? contentSize(file.content),
|
|
591
|
+
modified: file.modified ?? defaultModified,
|
|
592
|
+
hidden: file.hidden ?? false
|
|
593
|
+
};
|
|
594
|
+
if (file.etag) entry.etag = file.etag;
|
|
595
|
+
nodes.set(path, { entry, content: file.content });
|
|
596
|
+
}
|
|
597
|
+
const explicit = new Set(files.filter((f) => f.modified).map((f) => normalizePath(f.path)));
|
|
598
|
+
const newest = /* @__PURE__ */ new Map();
|
|
599
|
+
for (const { entry } of nodes.values()) {
|
|
600
|
+
if (entry.type === "dir" && !explicit.has(entry.path)) continue;
|
|
601
|
+
for (let p = parentPath(entry.path); p; p = parentPath(p)) {
|
|
602
|
+
const current = newest.get(p);
|
|
603
|
+
if (!current || Date.parse(entry.modified) > Date.parse(current)) {
|
|
604
|
+
newest.set(p, entry.modified);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
for (const [p, modified] of newest) {
|
|
609
|
+
const node = nodes.get(p);
|
|
610
|
+
if (node && node.entry.type === "dir" && !explicit.has(p)) node.entry.modified = modified;
|
|
611
|
+
}
|
|
612
|
+
roots.set(root.id, { root: { ...root }, nodes });
|
|
613
|
+
}
|
|
614
|
+
async function begin(request, signal) {
|
|
615
|
+
await wait(options.latency ?? 0, signal);
|
|
616
|
+
const failure = options.intercept?.(request);
|
|
617
|
+
if (failure) throw failure;
|
|
618
|
+
}
|
|
619
|
+
function rootOf(rootId) {
|
|
620
|
+
const root = roots.get(rootId);
|
|
621
|
+
if (!root) throw new FilesError("not_found", "Root not found.", { status: 404 });
|
|
622
|
+
return root;
|
|
623
|
+
}
|
|
624
|
+
function resolve(rootId, rawPath) {
|
|
625
|
+
const path = normalizePath(rawPath);
|
|
626
|
+
const { nodes } = rootOf(rootId);
|
|
627
|
+
const segments = path ? path.split("/") : [];
|
|
628
|
+
for (let i = 1; i < segments.length; i++) {
|
|
629
|
+
const ancestor = nodes.get(segments.slice(0, i).join("/"));
|
|
630
|
+
if (ancestor?.entry.type === "link") {
|
|
631
|
+
throw new FilesError("link_not_followed", "Links are not followed.", {
|
|
632
|
+
status: 403,
|
|
633
|
+
reason: "link_not_followed"
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return path ? nodes.get(path) : void 0;
|
|
638
|
+
}
|
|
639
|
+
function children(rootId, path, includeHidden) {
|
|
640
|
+
const { nodes } = rootOf(rootId);
|
|
641
|
+
const out = [];
|
|
642
|
+
for (const node of nodes.values()) {
|
|
643
|
+
if (parentPath(node.entry.path) !== path || node.entry.path === "") continue;
|
|
644
|
+
if (!includeHidden && node.entry.hidden) continue;
|
|
645
|
+
out.push({ ...node.entry });
|
|
646
|
+
}
|
|
647
|
+
return out.sort(compareEntries);
|
|
648
|
+
}
|
|
649
|
+
function page(all, opts) {
|
|
650
|
+
const limit = Math.min(1e3, Math.max(1, opts.limit ?? options.defaultLimit ?? 200));
|
|
651
|
+
const offset = opts.cursor ? decodeCursor(opts.cursor) : 0;
|
|
652
|
+
if (offset > all.length)
|
|
653
|
+
throw new FilesError("invalid_cursor", "Invalid cursor.", { status: 400 });
|
|
654
|
+
const entries = all.slice(offset, offset + limit);
|
|
655
|
+
const next = offset + limit;
|
|
656
|
+
return {
|
|
657
|
+
entries,
|
|
658
|
+
total: all.length,
|
|
659
|
+
nextCursor: next < all.length ? encodeCursor(next) : null
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const source = {
|
|
663
|
+
async listRoots(signal) {
|
|
664
|
+
await begin({ op: "listRoots" }, signal);
|
|
665
|
+
return [...roots.values()].map(({ root }) => ({ ...root }));
|
|
666
|
+
},
|
|
667
|
+
async list(rootId, rawPath, opts = {}) {
|
|
668
|
+
const path = normalizePath(rawPath);
|
|
669
|
+
await begin({ op: "list", rootId, path }, opts.signal);
|
|
670
|
+
const node = resolve(rootId, path);
|
|
671
|
+
if (path && !node) throw new FilesError("not_found", "Not found.", { status: 404 });
|
|
672
|
+
if (node?.entry.type === "link") {
|
|
673
|
+
throw new FilesError("link_not_followed", "Links are not followed.", {
|
|
674
|
+
status: 403,
|
|
675
|
+
reason: "link_not_followed"
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
if (node && node.entry.type !== "dir") {
|
|
679
|
+
throw new FilesError("not_a_directory", "Not a directory.", { status: 400 });
|
|
680
|
+
}
|
|
681
|
+
return page(children(rootId, path, !!opts.includeHidden), opts);
|
|
682
|
+
},
|
|
683
|
+
async info(rootId, rawPath, signal) {
|
|
684
|
+
const path = normalizePath(rawPath);
|
|
685
|
+
await begin({ op: "info", rootId, path }, signal);
|
|
686
|
+
if (!path) {
|
|
687
|
+
rootOf(rootId);
|
|
688
|
+
return {
|
|
689
|
+
name: "",
|
|
690
|
+
path: "",
|
|
691
|
+
type: "dir",
|
|
692
|
+
size: 0,
|
|
693
|
+
modified: defaultModified,
|
|
694
|
+
hidden: false
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const node = resolve(rootId, path);
|
|
698
|
+
if (!node) throw new FilesError("not_found", "Not found.", { status: 404 });
|
|
699
|
+
return { ...node.entry };
|
|
700
|
+
},
|
|
701
|
+
async read(rootId, rawPath, opts = {}) {
|
|
702
|
+
const path = normalizePath(rawPath);
|
|
703
|
+
await begin({ op: "read", rootId, path }, opts.signal);
|
|
704
|
+
const node = resolve(rootId, path);
|
|
705
|
+
if (!node) throw new FilesError("not_found", "Not found.", { status: 404 });
|
|
706
|
+
if (node.entry.type === "link") {
|
|
707
|
+
throw new FilesError("link_not_followed", "Links are not followed.", {
|
|
708
|
+
status: 403,
|
|
709
|
+
reason: "link_not_followed"
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
if (node.entry.type === "dir") {
|
|
713
|
+
throw new FilesError("is_directory", "Is a directory.", { status: 400 });
|
|
714
|
+
}
|
|
715
|
+
const content = node.content;
|
|
716
|
+
if (typeof content === "function") return await content();
|
|
717
|
+
if (content instanceof Blob) return content;
|
|
718
|
+
return new Blob([content ?? ""], { type: "application/octet-stream" });
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
if (options.search) {
|
|
722
|
+
source.search = async (rootId, query, opts = {}) => {
|
|
723
|
+
const under = normalizePath(opts.path);
|
|
724
|
+
await begin({ op: "search", rootId, path: under }, opts.signal);
|
|
725
|
+
const { nodes } = rootOf(rootId);
|
|
726
|
+
const all = filterEntries(
|
|
727
|
+
[...nodes.values()].map((n) => n.entry).filter(
|
|
728
|
+
(e) => (opts.includeHidden || !e.hidden) && (under === "" || e.path.startsWith(`${under}/`))
|
|
729
|
+
),
|
|
730
|
+
query
|
|
731
|
+
).sort(compareEntries);
|
|
732
|
+
return page(
|
|
733
|
+
all.map((e) => ({ ...e })),
|
|
734
|
+
opts
|
|
735
|
+
);
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return source;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export { BAD_REQUEST_REASONS, DEFAULT_PAGE_SIZE, DEFAULT_TIMEOUT_MS, FilesError, KIND_LABELS, MAX_PAGE_SIZE, NEVER_INLINE_EXTENSIONS, baseName, chapiOrigin, compareEntries, createChapiFilesSource, createMemoryFilesSource, describeFilesError, displayPath, entryFromChapi, fileExtension, filesErrorCode, filterEntries, formatBytes, formatDateTime, formatRelativeTime, isAbortError, isFilesError, isWithin, joinPath, kindForEntry, kindForName, normalizePath, parentPath, parseRetryAfter, pathSegments, previewRoute, rootFromChapi, saveBlob, sortEntries, writeCapabilities };
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classic-homes/files-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic network-drive file browsing for Classic Theme: the FilesSource interface, the CHAPI /v2/files adapter, an in-memory source, path, sort and format helpers, and typed errors",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"dev": "tsup --watch",
|
|
21
|
+
"clean": "node ../../scripts/rm.mjs dist",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"tsup": "^8.0.0",
|
|
29
|
+
"typescript": "^5.3.0",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"file-browser",
|
|
34
|
+
"network-drive",
|
|
35
|
+
"smb",
|
|
36
|
+
"chapi",
|
|
37
|
+
"classic-theme"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/Classic-Homes/classic-theme.git",
|
|
43
|
+
"directory": "packages/files-core"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|