sliftutils 1.4.9 → 1.4.10
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/index.d.ts +16 -0
- package/package.json +1 -1
- package/storage/FileFolderAPI.d.ts +16 -0
- package/storage/FileFolderAPI.tsx +92 -134
package/index.d.ts
CHANGED
|
@@ -1384,6 +1384,10 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1384
1384
|
]>;
|
|
1385
1385
|
};
|
|
1386
1386
|
export declare function setFileAPIKey(key: string): void;
|
|
1387
|
+
type RemoteConfig = {
|
|
1388
|
+
url: string;
|
|
1389
|
+
password: string;
|
|
1390
|
+
};
|
|
1387
1391
|
export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
1388
1392
|
private filePath;
|
|
1389
1393
|
constructor(filePath: string);
|
|
@@ -1430,6 +1434,18 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1430
1434
|
}
|
|
1431
1435
|
]>;
|
|
1432
1436
|
}
|
|
1437
|
+
type StorageChoice = {
|
|
1438
|
+
type: "local";
|
|
1439
|
+
handle: DirectoryWrapper;
|
|
1440
|
+
} | {
|
|
1441
|
+
type: "remote";
|
|
1442
|
+
config: RemoteConfig;
|
|
1443
|
+
};
|
|
1444
|
+
export declare const getStorageChoice: {
|
|
1445
|
+
(): Promise<StorageChoice>;
|
|
1446
|
+
reset(): void;
|
|
1447
|
+
set(newValue: Promise<StorageChoice>): void;
|
|
1448
|
+
};
|
|
1433
1449
|
export declare const getDirectoryHandle: {
|
|
1434
1450
|
(): Promise<DirectoryWrapper>;
|
|
1435
1451
|
reset(): void;
|
package/package.json
CHANGED
|
@@ -70,6 +70,10 @@ type DirectoryWrapper = {
|
|
|
70
70
|
]>;
|
|
71
71
|
};
|
|
72
72
|
export declare function setFileAPIKey(key: string): void;
|
|
73
|
+
type RemoteConfig = {
|
|
74
|
+
url: string;
|
|
75
|
+
password: string;
|
|
76
|
+
};
|
|
73
77
|
export declare class NodeJSFileHandleWrapper implements FileWrapper {
|
|
74
78
|
private filePath;
|
|
75
79
|
constructor(filePath: string);
|
|
@@ -116,6 +120,18 @@ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
116
120
|
}
|
|
117
121
|
]>;
|
|
118
122
|
}
|
|
123
|
+
type StorageChoice = {
|
|
124
|
+
type: "local";
|
|
125
|
+
handle: DirectoryWrapper;
|
|
126
|
+
} | {
|
|
127
|
+
type: "remote";
|
|
128
|
+
config: RemoteConfig;
|
|
129
|
+
};
|
|
130
|
+
export declare const getStorageChoice: {
|
|
131
|
+
(): Promise<StorageChoice>;
|
|
132
|
+
reset(): void;
|
|
133
|
+
set(newValue: Promise<StorageChoice>): void;
|
|
134
|
+
};
|
|
119
135
|
export declare const getDirectoryHandle: {
|
|
120
136
|
(): Promise<DirectoryWrapper>;
|
|
121
137
|
reset(): void;
|
|
@@ -86,20 +86,14 @@ type RemoteConfig = { url: string; password: string };
|
|
|
86
86
|
function remoteConfigKey() { return getFileAPIKey() + ":remote"; }
|
|
87
87
|
function loadRemoteConfig(): RemoteConfig | undefined {
|
|
88
88
|
try {
|
|
89
|
-
const
|
|
90
|
-
const s = localStorage.getItem(key);
|
|
91
|
-
console.log("[remotecfg] load", JSON.stringify(key), "->", s);
|
|
89
|
+
const s = localStorage.getItem(remoteConfigKey());
|
|
92
90
|
if (!s) return undefined;
|
|
93
91
|
const c = JSON.parse(s);
|
|
94
92
|
if (c && typeof c.url === "string" && typeof c.password === "string") return c;
|
|
95
|
-
} catch
|
|
93
|
+
} catch { /* ignore */ }
|
|
96
94
|
return undefined;
|
|
97
95
|
}
|
|
98
|
-
function saveRemoteConfig(c: RemoteConfig) {
|
|
99
|
-
const key = remoteConfigKey();
|
|
100
|
-
localStorage.setItem(key, JSON.stringify(c));
|
|
101
|
-
console.log("[remotecfg] saved", JSON.stringify(key), "readback ->", localStorage.getItem(key));
|
|
102
|
-
}
|
|
96
|
+
function saveRemoteConfig(c: RemoteConfig) { localStorage.setItem(remoteConfigKey(), JSON.stringify(c)); }
|
|
103
97
|
|
|
104
98
|
// The server is always HTTPS on the filehoster's default port, so the user only needs to type the host
|
|
105
99
|
// (e.g. "65.109.93.113"). We strip any scheme/path they include and default the port if omitted.
|
|
@@ -139,11 +133,12 @@ class DirectoryPrompter extends preact.Component {
|
|
|
139
133
|
}
|
|
140
134
|
}
|
|
141
135
|
|
|
142
|
-
// "Connect to a server" option for the directory prompt: collapses to a button, expands to
|
|
143
|
-
// password fields. On connect it
|
|
144
|
-
//
|
|
136
|
+
// "Connect to a server" option for the directory prompt: collapses to a button, expands to address +
|
|
137
|
+
// password fields. On connect it actually connects to the server; on success it persists the config and
|
|
138
|
+
// calls onConnected so the caller (getStorageChoice) resolves with the live remote storage — no reload.
|
|
139
|
+
// Connection failures are shown to the user (and logged, without the password), never swallowed.
|
|
145
140
|
@observer
|
|
146
|
-
class ServerConnectForm extends preact.Component {
|
|
141
|
+
class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteConfig) => void }> {
|
|
147
142
|
private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false, needsCert: false, showPassword: false });
|
|
148
143
|
private cleanUrl() { return normalizeServerUrl(this.obs.url); }
|
|
149
144
|
private connect = async () => {
|
|
@@ -153,22 +148,28 @@ class ServerConnectForm extends preact.Component {
|
|
|
153
148
|
s.connecting = true;
|
|
154
149
|
try {
|
|
155
150
|
const url = this.cleanUrl();
|
|
156
|
-
if (!url) { s.error = "Enter a server
|
|
151
|
+
if (!url) { s.error = "Enter a server address."; return; }
|
|
157
152
|
const result = await probeRemoteConnection(url, s.password.trim());
|
|
158
153
|
if (result.status === "ok") {
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
const config: RemoteConfig = { url, password: s.password.trim() };
|
|
155
|
+
saveRemoteConfig(config); // remember for next session
|
|
156
|
+
this.props.onConnected(config); // resolve the pending storage request now (no reload)
|
|
161
157
|
return;
|
|
162
158
|
}
|
|
163
159
|
if (result.status === "unauthorized") {
|
|
164
160
|
s.error = "The server rejected that password.";
|
|
161
|
+
console.error("Remote connect: unauthorized for", url);
|
|
165
162
|
} else {
|
|
166
|
-
//
|
|
163
|
+
// Got nothing back — usually the self-signed certificate isn't trusted yet, but show the
|
|
164
|
+
// actual error too so a wrong address / down server / CORS issue is diagnosable.
|
|
167
165
|
s.needsCert = true;
|
|
168
|
-
s.error = "Couldn't reach the server.
|
|
166
|
+
s.error = "Couldn't reach the server: " + result.error;
|
|
167
|
+
console.error("Remote connect: unreachable", url, "-", result.error);
|
|
169
168
|
}
|
|
170
169
|
} catch (e) {
|
|
171
|
-
|
|
170
|
+
// Should be rare (probeRemoteConnection handles its own errors), but never swallow it.
|
|
171
|
+
s.error = "Connection error: " + ((e as Error).message || String(e));
|
|
172
|
+
console.error("Remote connect threw:", e);
|
|
172
173
|
} finally {
|
|
173
174
|
s.connecting = false;
|
|
174
175
|
}
|
|
@@ -362,141 +363,100 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
362
363
|
}
|
|
363
364
|
|
|
364
365
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
// What storage the user chose: a local directory handle, or a remote server config.
|
|
367
|
+
type StorageChoice = { type: "local"; handle: DirectoryWrapper } | { type: "remote"; config: RemoteConfig };
|
|
368
|
+
|
|
369
|
+
// Resolves (once) what storage to use. If a remote server was connected in a previous session we use it
|
|
370
|
+
// straight away; otherwise we prompt (Pick directory / Connect to a server / Dismiss). The Connect path
|
|
371
|
+
// actually connects to the server and resolves with its config — so when this returns, the storage is
|
|
372
|
+
// ready. No page reload. Blocks until the user chooses (or dismisses, which rejects).
|
|
373
|
+
export const getStorageChoice = lazy(async function getStorageChoice(): Promise<StorageChoice> {
|
|
368
374
|
if (isNode()) {
|
|
369
|
-
return new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
|
|
375
|
+
return { type: "local", handle: new NodeJSDirectoryHandleWrapper(path.resolve("./data/")) };
|
|
370
376
|
}
|
|
377
|
+
const savedRemote = loadRemoteConfig();
|
|
378
|
+
if (savedRemote) return { type: "remote", config: savedRemote };
|
|
379
|
+
|
|
371
380
|
let root = document.createElement("div");
|
|
372
381
|
document.body.appendChild(root);
|
|
373
382
|
preact.render(<DirectoryPrompter />, root);
|
|
374
383
|
try {
|
|
384
|
+
let resolveChoice!: (c: StorageChoice) => void;
|
|
385
|
+
let rejectChoice!: (e: Error) => void;
|
|
386
|
+
const choicePromise = new Promise<StorageChoice>((res, rej) => { resolveChoice = res; rejectChoice = rej; });
|
|
375
387
|
|
|
376
|
-
|
|
388
|
+
const pickLocal = async () => {
|
|
389
|
+
try {
|
|
390
|
+
const handle = await window.showDirectoryPicker();
|
|
391
|
+
await handle.requestPermission({ mode: "readwrite" });
|
|
392
|
+
const storedId = await storeFileSystemPointer({ mode: "readwrite", handle });
|
|
393
|
+
localStorage.setItem(getFileAPIKey(), storedId);
|
|
394
|
+
resolveChoice({ type: "local", handle: handle as any });
|
|
395
|
+
} catch (e) {
|
|
396
|
+
console.error("Directory pick failed/cancelled:", e); // stay on the prompt
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
// The three options, rendered fresh each time (so reused-vnode issues can't arise).
|
|
400
|
+
const renderOptions = () => (
|
|
401
|
+
<>
|
|
402
|
+
<button className={css.fontSize(40).pad2(80, 40)} onClick={pickLocal}>Pick Data Directory</button>
|
|
403
|
+
<ServerConnectForm onConnected={config => resolveChoice({ type: "remote", config })} />
|
|
404
|
+
<button className={css.fontSize(40).pad2(80, 40)}
|
|
405
|
+
onClick={() => rejectChoice(new Error("User dismissed file system access prompt"))}>Dismiss</button>
|
|
406
|
+
</>
|
|
407
|
+
);
|
|
377
408
|
|
|
409
|
+
// A previously-picked local folder: try to restore its handle (may need a click for activation).
|
|
378
410
|
const storedId = localStorage.getItem(getFileAPIKey());
|
|
379
411
|
if (storedId) {
|
|
380
412
|
let doneLoad = false;
|
|
381
|
-
setTimeout(() => {
|
|
382
|
-
if (doneLoad) return;
|
|
383
|
-
console.log("Waiting for user to click");
|
|
384
|
-
displayData.ui = "Click anywhere to allow file system access";
|
|
385
|
-
}, 500);
|
|
413
|
+
setTimeout(() => { if (!doneLoad) displayData.ui = "Click anywhere to allow file system access"; }, 500);
|
|
386
414
|
try {
|
|
387
|
-
handle = await tryToLoadPointer(storedId);
|
|
415
|
+
const handle = await tryToLoadPointer(storedId);
|
|
416
|
+
doneLoad = true;
|
|
417
|
+
if (handle) return { type: "local", handle };
|
|
388
418
|
} catch (e) {
|
|
419
|
+
doneLoad = true;
|
|
389
420
|
console.error(e);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (errorMessage.includes("user activation") || errorMessage.includes("User activation")) {
|
|
393
|
-
doneLoad = true;
|
|
394
|
-
// Show UI to get user to click and retry
|
|
395
|
-
let retryCallback: (success: boolean) => void;
|
|
396
|
-
let retryReject: (err: Error) => void;
|
|
397
|
-
let retryPromise = new Promise<boolean>((resolve, reject) => {
|
|
398
|
-
retryCallback = resolve;
|
|
399
|
-
retryReject = reject;
|
|
400
|
-
});
|
|
421
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
422
|
+
if (msg.includes("user activation") || msg.includes("User activation")) {
|
|
401
423
|
displayData.ui = (
|
|
402
424
|
<div className={css.vbox(20).center}>
|
|
403
|
-
<button
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
} else {
|
|
413
|
-
retryCallback(false);
|
|
414
|
-
}
|
|
415
|
-
} catch (retryError) {
|
|
416
|
-
console.error("Retry failed:", retryError);
|
|
417
|
-
retryCallback(false);
|
|
418
|
-
}
|
|
419
|
-
}}
|
|
420
|
-
>
|
|
421
|
-
Click to restore file system access
|
|
422
|
-
</button>
|
|
423
|
-
<button
|
|
424
|
-
className={css.fontSize(40).pad2(80, 40)}
|
|
425
|
-
onClick={async () => {
|
|
426
|
-
console.log("Waiting for user to give permission");
|
|
427
|
-
const pickedHandle = await window.showDirectoryPicker();
|
|
428
|
-
await pickedHandle.requestPermission({ mode: "readwrite" });
|
|
429
|
-
let newStoredId = await storeFileSystemPointer({ mode: "readwrite", handle: pickedHandle });
|
|
430
|
-
localStorage.setItem(getFileAPIKey(), newStoredId);
|
|
431
|
-
handle = pickedHandle as any;
|
|
432
|
-
retryCallback(true);
|
|
433
|
-
}}
|
|
434
|
-
>
|
|
435
|
-
Pick Data Directory
|
|
436
|
-
</button>
|
|
437
|
-
<ServerConnectForm />
|
|
438
|
-
<button
|
|
439
|
-
className={css.fontSize(40).pad2(80, 40)}
|
|
440
|
-
onClick={() => {
|
|
441
|
-
retryReject(new Error("User dismissed file system access prompt"));
|
|
442
|
-
}}
|
|
443
|
-
>
|
|
444
|
-
Dismiss
|
|
445
|
-
</button>
|
|
425
|
+
<button className={css.fontSize(40).pad2(80, 40)} onClick={async () => {
|
|
426
|
+
displayData.ui = "Loading...";
|
|
427
|
+
try {
|
|
428
|
+
const h = await tryToLoadPointer(storedId);
|
|
429
|
+
if (h) { resolveChoice({ type: "local", handle: h }); return; }
|
|
430
|
+
} catch (retryError) { console.error("Retry failed:", retryError); }
|
|
431
|
+
displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
|
|
432
|
+
}}>Click to restore file system access</button>
|
|
433
|
+
{renderOptions()}
|
|
446
434
|
</div>
|
|
447
435
|
);
|
|
448
|
-
|
|
449
|
-
if (handle) {
|
|
450
|
-
return handle;
|
|
451
|
-
}
|
|
436
|
+
return await choicePromise;
|
|
452
437
|
}
|
|
453
438
|
}
|
|
454
|
-
doneLoad = true;
|
|
455
|
-
if (handle) {
|
|
456
|
-
return handle;
|
|
457
|
-
}
|
|
458
439
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
let promise = new Promise<DirectoryWrapper>((resolve, reject) => {
|
|
462
|
-
fileCallback = resolve;
|
|
463
|
-
fileReject = reject;
|
|
464
|
-
});
|
|
465
|
-
displayData.ui = (
|
|
466
|
-
<div className={css.vbox(20).center}>
|
|
467
|
-
<button
|
|
468
|
-
className={css.fontSize(40).pad2(80, 40)}
|
|
469
|
-
onClick={async () => {
|
|
470
|
-
console.log("Waiting for user to give permission");
|
|
471
|
-
const handle = await window.showDirectoryPicker();
|
|
472
|
-
await handle.requestPermission({ mode: "readwrite" });
|
|
473
|
-
let storedId = await storeFileSystemPointer({ mode: "readwrite", handle });
|
|
474
|
-
localStorage.setItem(getFileAPIKey(), storedId);
|
|
475
|
-
fileCallback(handle as any);
|
|
476
|
-
}}
|
|
477
|
-
>
|
|
478
|
-
Pick Data Directory
|
|
479
|
-
</button>
|
|
480
|
-
<ServerConnectForm />
|
|
481
|
-
<button
|
|
482
|
-
className={css.fontSize(40).pad2(80, 40)}
|
|
483
|
-
onClick={() => {
|
|
484
|
-
fileReject(new Error("User dismissed file system access prompt"));
|
|
485
|
-
}}
|
|
486
|
-
>
|
|
487
|
-
Dismiss
|
|
488
|
-
</button>
|
|
489
|
-
</div>
|
|
490
|
-
);
|
|
491
|
-
return await promise;
|
|
440
|
+
displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
|
|
441
|
+
return await choicePromise;
|
|
492
442
|
} finally {
|
|
493
443
|
preact.render(null, root);
|
|
494
444
|
root.remove();
|
|
495
445
|
}
|
|
496
446
|
});
|
|
497
447
|
|
|
448
|
+
// Local-only directory handle, for callers that don't support remote storage. Throws if the user has
|
|
449
|
+
// configured a remote server (rather than silently doing the wrong thing).
|
|
450
|
+
export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
|
|
451
|
+
const choice = await getStorageChoice();
|
|
452
|
+
if (choice.type === "remote") throw new Error("Storage is configured to use a remote server; a local directory handle isn't available here.");
|
|
453
|
+
return choice.handle;
|
|
454
|
+
});
|
|
455
|
+
|
|
498
456
|
export const getFileStorageNested = cache(async function getFileStorage(path: string): Promise<FileStorage> {
|
|
499
|
-
|
|
457
|
+
const choice = await getStorageChoice();
|
|
458
|
+
if (choice.type === "remote") return getRemoteFactory(choice.config)(path);
|
|
459
|
+
let base = choice.handle;
|
|
500
460
|
for (let part of path.split("/")) {
|
|
501
461
|
if (!part) continue;
|
|
502
462
|
base = await base.getDirectoryHandle(part, { create: true });
|
|
@@ -507,17 +467,15 @@ export const getFileStorageNested = cache(async function getFileStorage(path: st
|
|
|
507
467
|
export const getFileStorageNested2 = cache(async function getFileStorage(pathStr: string): Promise<FileStorage> {
|
|
508
468
|
let base: DirectoryWrapper;
|
|
509
469
|
pathStr = pathStr.replaceAll("\\", "/");
|
|
510
|
-
if (!isNode()) {
|
|
511
|
-
const remote = loadRemoteConfig();
|
|
512
|
-
if (remote) return getRemoteFactory(remote)(pathStr);
|
|
513
|
-
}
|
|
514
470
|
if (isNode()) {
|
|
515
471
|
if (path.isAbsolute(pathStr)) {
|
|
516
472
|
return wrapHandle(new NodeJSDirectoryHandleWrapper(pathStr));
|
|
517
473
|
}
|
|
518
474
|
base = new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
|
|
519
475
|
} else {
|
|
520
|
-
|
|
476
|
+
const choice = await getStorageChoice();
|
|
477
|
+
if (choice.type === "remote") return getRemoteFactory(choice.config)(pathStr);
|
|
478
|
+
base = choice.handle;
|
|
521
479
|
let dirs: string[] = [];
|
|
522
480
|
let alls: string[] = [];
|
|
523
481
|
for await (const [name, entry] of base) {
|
|
@@ -541,9 +499,9 @@ export const getFileStorage = lazy(async function getFileStorage(): Promise<File
|
|
|
541
499
|
if (USE_INDEXED_DB) {
|
|
542
500
|
return await getFileStorageIndexDB();
|
|
543
501
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return wrapHandle(handle);
|
|
502
|
+
const choice = await getStorageChoice();
|
|
503
|
+
if (choice.type === "remote") return getRemoteFactory(choice.config)("");
|
|
504
|
+
return wrapHandle(choice.handle);
|
|
547
505
|
});
|
|
548
506
|
export function resetStorageLocation() {
|
|
549
507
|
localStorage.removeItem(getFileAPIKey());
|