@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Assemble the ngin engine and the reactive services into one `app` context,
|
|
2
|
+
// register commands and built-in openers, and expose everything the UI needs.
|
|
3
|
+
// The engine holds a single `app` singleton provider so Actions can reach the
|
|
4
|
+
// platform and services; the app object also carries the engine so Actions can
|
|
5
|
+
// dispatch follow-up Actions (the choreographer pattern).
|
|
6
|
+
|
|
7
|
+
import { Engine, Provider } from '@3sln/ngin';
|
|
8
|
+
import { effect } from '../runtime.js';
|
|
9
|
+
import { ExplorerService, SearchClientService, TransfersService } from './services.js';
|
|
10
|
+
import { SocialService } from './social.js';
|
|
11
|
+
import { OfflineService } from './offline.js';
|
|
12
|
+
import { ActivityService } from './activity.js';
|
|
13
|
+
import { registerCommands } from './commands.js';
|
|
14
|
+
import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction } from './actions.js';
|
|
15
|
+
|
|
16
|
+
export function createApp(platform) {
|
|
17
|
+
const explorer = new ExplorerService(platform.settings);
|
|
18
|
+
const search = new SearchClientService();
|
|
19
|
+
// One place for "what's running" and "what's stuck", covering both sides of the wire.
|
|
20
|
+
const activity = new ActivityService(platform);
|
|
21
|
+
const transfers = new TransfersService(activity);
|
|
22
|
+
const social = new SocialService(platform);
|
|
23
|
+
const offline = new OfflineService(platform);
|
|
24
|
+
social.offline = offline; // social queues sidecar ops through offline when disconnected
|
|
25
|
+
|
|
26
|
+
const app = { platform, explorer, search, transfers, social, offline, activity, engine: null };
|
|
27
|
+
|
|
28
|
+
const engine = new Engine({
|
|
29
|
+
providers: { app: Provider.fromSingleton(app) },
|
|
30
|
+
});
|
|
31
|
+
app.engine = engine;
|
|
32
|
+
|
|
33
|
+
registerCommands(app);
|
|
34
|
+
|
|
35
|
+
// Wire the plugin panel opener hook to the workbench.
|
|
36
|
+
platform.openPluginPanel = (pluginId) => platform.workbench.openPluginPanel(pluginId);
|
|
37
|
+
|
|
38
|
+
// Project explorer state → context keys in ONE place. These drive when-clauses
|
|
39
|
+
// (e.g. the Delete keybinding needs `explorer.hasSelection`), and previously only
|
|
40
|
+
// NavigateAction set them — so selecting a file never flipped hasSelection true and
|
|
41
|
+
// Delete silently did nothing. Deriving them from the observable keeps them honest.
|
|
42
|
+
platform.context.setMany({ 'explorer.collectionId': 'default', 'explorer.hasSelection': false });
|
|
43
|
+
effect(explorer.observe(), (ex) => {
|
|
44
|
+
platform.context.setMany({
|
|
45
|
+
'explorer.collectionId': ex.collectionId || 'default',
|
|
46
|
+
'explorer.hasSelection': (ex.selection?.length || 0) > 0,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Load a file's conversation/tags whenever the active viewer panel changes (the
|
|
51
|
+
// panel stack lives in the navigation sub-service now).
|
|
52
|
+
let lastTab = null;
|
|
53
|
+
effect(platform.workbench.observeNav(), (nav) => {
|
|
54
|
+
if (nav.activeTabId !== lastTab) {
|
|
55
|
+
lastTab = nav.activeTabId;
|
|
56
|
+
// Clear the sidecar when no file is active (last tab closed) so a stale
|
|
57
|
+
// conversation from the previous file doesn't linger.
|
|
58
|
+
social.loadSidecar(nav.activeFile ? nav.activeFile.id : null);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
social.init();
|
|
63
|
+
offline.init();
|
|
64
|
+
activity.init();
|
|
65
|
+
engine.dispatch(new LoadCollectionsAction());
|
|
66
|
+
|
|
67
|
+
return { engine, app };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { NavigateAction };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Following a `trove:` link from a document.
|
|
2
|
+
//
|
|
3
|
+
// Resolution is deliberately lazy — at click time, not at render time. A document can
|
|
4
|
+
// hold dozens of links; resolving them all on every render would be a request per link,
|
|
5
|
+
// and the answer can change between renders anyway (the target may be created, renamed,
|
|
6
|
+
// or deleted while the document is open). So a link is rendered from its text alone, and
|
|
7
|
+
// only what the user actually follows costs a round trip.
|
|
8
|
+
//
|
|
9
|
+
// A broken link is a normal, expected state — names are the legible way to write a link,
|
|
10
|
+
// and renaming breaks them. It has to say so clearly, and say WHY, because "nothing
|
|
11
|
+
// happened" is the worst possible response to a click.
|
|
12
|
+
|
|
13
|
+
import { parseTroveUri } from '@3sln/trove/core/links.js';
|
|
14
|
+
import { OpenFileAction } from './actions.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open what a `trove:` URI points at.
|
|
18
|
+
* @param {object} ui the workbench ui helper
|
|
19
|
+
* @param {string} uri the link as written in the document
|
|
20
|
+
* @param {{from?: object}} [opts] the item the link was followed FROM, for messages
|
|
21
|
+
*/
|
|
22
|
+
export async function openTroveLink(ui, uri, { from } = {}) {
|
|
23
|
+
const ref = parseTroveUri(uri);
|
|
24
|
+
const notify = ui.platform.notifications;
|
|
25
|
+
if (!ref) {
|
|
26
|
+
notify.warn(`"${uri}" isn’t a valid Trove link.`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const res = await ui.platform.api.stat(uri);
|
|
31
|
+
if (!res?.node) throw new Error('not found');
|
|
32
|
+
ui.go(new OpenFileAction(res.node));
|
|
33
|
+
return res.node;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
notify.warn(describeBrokenLink(ref, err, from));
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Why a link didn't open, in terms of the link the user is looking at. A 403 is a
|
|
42
|
+
* different situation from a 404 and must not be reported as one: "no such item" when
|
|
43
|
+
* the item exists but isn't yours is both wrong and a small information leak in reverse.
|
|
44
|
+
*/
|
|
45
|
+
function describeBrokenLink(ref, err, from) {
|
|
46
|
+
const where = from?.name ? ` (linked from "${from.name}")` : '';
|
|
47
|
+
if (err?.status === 403 || err?.code === 'forbidden') {
|
|
48
|
+
return `You don’t have access to that item in "${ref.collection}"${where}.`;
|
|
49
|
+
}
|
|
50
|
+
if (ref.by === 'name') {
|
|
51
|
+
return `Nothing named "${ref.value}" in "${ref.collection}"${where} — it may have been renamed or deleted.`;
|
|
52
|
+
}
|
|
53
|
+
return `That item no longer exists in "${ref.collection}"${where}.`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// OfflineService — a limited but real offline mode.
|
|
2
|
+
// • Pin ("make available offline"): file bytes go into the SW's trove-files
|
|
3
|
+
// cache (so every opener reads them with no network), and — for text — the
|
|
4
|
+
// content is chunked, embedded with the same LocalHash model the server can
|
|
5
|
+
// use, and stored in IndexedDB for offline search.
|
|
6
|
+
// • Offline search: lexical (names/tags/content) blended with local
|
|
7
|
+
// semantic (cosine over the cached chunk vectors) — the same hybrid shape as
|
|
8
|
+
// the server, just over what you took offline.
|
|
9
|
+
// • Sidecar op queue: while offline, comment/tag mutations are queued and
|
|
10
|
+
// replayed on reconnect. Because the sidecar is a CRDT, replay merges cleanly
|
|
11
|
+
// with whatever changed server-side meanwhile.
|
|
12
|
+
// Reactive: the workbench watches `online`, the pinned set, and the queue depth.
|
|
13
|
+
|
|
14
|
+
import { cell } from '../runtime.js';
|
|
15
|
+
import { LocalHashEmbedding } from '@3sln/trove/core/search/embeddings.js';
|
|
16
|
+
// Chunking and tokenizing must MATCH the server's — offline results are compared
|
|
17
|
+
// against embeddings the server produced, so a local variant would quietly skew them.
|
|
18
|
+
import { chunkText } from '@3sln/trove/core/indexers/registry.js';
|
|
19
|
+
import { tokenize } from '@3sln/trove/core/search/keywordStore.js';
|
|
20
|
+
import { isTexty } from './fileType.js';
|
|
21
|
+
|
|
22
|
+
const DB = 'trove-offline';
|
|
23
|
+
const FILES_CACHE = 'trove-files-v1';
|
|
24
|
+
const embed = new LocalHashEmbedding({ dimensions: 256 });
|
|
25
|
+
|
|
26
|
+
export class OfflineService {
|
|
27
|
+
constructor(platform) {
|
|
28
|
+
this.platform = platform;
|
|
29
|
+
this.api = platform.api;
|
|
30
|
+
this.db = null;
|
|
31
|
+
this.state = { online: navigator.onLine, pins: [], queued: 0, syncing: false };
|
|
32
|
+
this.cell = cell(this.state);
|
|
33
|
+
}
|
|
34
|
+
observe() {
|
|
35
|
+
return this.cell;
|
|
36
|
+
}
|
|
37
|
+
#set(patch) {
|
|
38
|
+
this.state = { ...this.state, ...patch };
|
|
39
|
+
this.cell.setValue(this.state);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async init() {
|
|
43
|
+
this.db = await openDb();
|
|
44
|
+
await this.#refreshPins();
|
|
45
|
+
await this.#refreshQueue();
|
|
46
|
+
window.addEventListener('online', () => this.#onOnline());
|
|
47
|
+
window.addEventListener('offline', () => this.#onOffline());
|
|
48
|
+
// `navigator.onLine` only reflects that an interface is up — on a captive portal or
|
|
49
|
+
// dead uplink it's `true` while the server is unreachable, which would leave us
|
|
50
|
+
// falsely "online" with every request silently failing. Confirm real reachability.
|
|
51
|
+
if (this.state.online && !(await this.api.reachable())) this.#set({ online: false });
|
|
52
|
+
// Tell the plugin host our initial connectivity so it can probe plugins.
|
|
53
|
+
this.platform.plugins?.setOnline?.(this.state.online);
|
|
54
|
+
if (this.state.online) this.flushQueue();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #onOnline() {
|
|
58
|
+
// The interface came up, but verify the server is actually reachable before
|
|
59
|
+
// declaring online — a captive portal fires 'online' with no real connectivity.
|
|
60
|
+
if (!(await this.api.reachable())) { this.#set({ online: false }); await this.platform.plugins?.setOnline?.(false); return; }
|
|
61
|
+
this.#set({ online: true });
|
|
62
|
+
// Plugins re-announce what works now that we're back online.
|
|
63
|
+
await this.platform.plugins?.setOnline?.(true);
|
|
64
|
+
await this.flushQueue();
|
|
65
|
+
}
|
|
66
|
+
async #onOffline() {
|
|
67
|
+
this.#set({ online: false });
|
|
68
|
+
// Plugins re-announce; network-only features become unavailable.
|
|
69
|
+
await this.platform.plugins?.setOnline?.(false);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isPinned(id) {
|
|
73
|
+
return this.state.pins.some((p) => p.id === id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- pinning ---------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async pin(node) {
|
|
79
|
+
if (!node?.id) return;
|
|
80
|
+
try {
|
|
81
|
+
// Two URLs, deliberately. The KEY is the plain route URL — stable, so `unpin` and
|
|
82
|
+
// the service worker's cache-first lookup both find what `pin` stored. The FETCH
|
|
83
|
+
// may need a minted one, because `cache.add` sends no Authorization header and
|
|
84
|
+
// would 401 on a token-authenticated deployment. Keying on the minted URL instead
|
|
85
|
+
// would have worked exactly once: it carries a signature, so the next session's
|
|
86
|
+
// `unpin` looks for a string that was never there and the bytes are never reclaimed.
|
|
87
|
+
if ('caches' in window) {
|
|
88
|
+
const cache = await caches.open(FILES_CACHE);
|
|
89
|
+
const urls = this.platform.mediaUrls;
|
|
90
|
+
const key = urls.cacheKey(node.id);
|
|
91
|
+
const { url } = await urls.url(node.id, { op: 'download' });
|
|
92
|
+
const res = await fetch(url);
|
|
93
|
+
if (!res.ok) throw new Error(`Couldn't fetch the file (${res.status})`);
|
|
94
|
+
await cache.put(new Request(key), res);
|
|
95
|
+
}
|
|
96
|
+
// Index text content for offline search.
|
|
97
|
+
let chunks = [];
|
|
98
|
+
let text = '';
|
|
99
|
+
if (isTexty(node)) {
|
|
100
|
+
try {
|
|
101
|
+
text = await this.api.readText(node.id);
|
|
102
|
+
const parts = chunkText(text, 1000, 150);
|
|
103
|
+
const vectors = await embed.embed(parts.length ? parts : [node.name]);
|
|
104
|
+
chunks = (parts.length ? parts : [node.name]).map((t, i) => ({ text: t, vector: vectors[i] }));
|
|
105
|
+
} catch { /* keep name-only */ }
|
|
106
|
+
}
|
|
107
|
+
const nameVec = (await embed.embed([node.name]))[0];
|
|
108
|
+
await idbPut(this.db, 'pins', node.id, { node, text, chunks, nameVec, pinnedAt: Date.now() });
|
|
109
|
+
await this.#refreshPins();
|
|
110
|
+
this.platform.notifications.success(`“${node.name}” is available offline`);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
this.platform.notifications.error(`Couldn't make available offline: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async unpin(id) {
|
|
117
|
+
const pin = this.state.pins.find((p) => p.id === id);
|
|
118
|
+
if (pin && 'caches' in window) {
|
|
119
|
+
const cache = await caches.open(FILES_CACHE);
|
|
120
|
+
// The same stable key `pin` stored under — never a freshly minted URL.
|
|
121
|
+
await cache.delete(this.platform.mediaUrls.cacheKey(id), { ignoreVary: true }).catch(() => {});
|
|
122
|
+
}
|
|
123
|
+
await idbDelete(this.db, 'pins', id);
|
|
124
|
+
await this.#refreshPins();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #refreshPins() {
|
|
128
|
+
const all = await idbAll(this.db, 'pins');
|
|
129
|
+
this.#set({ pins: all.map((r) => ({ id: r.node.id, name: r.node.name, contentType: r.node.contentType, collectionId: r.node.collectionId, pinnedAt: r.pinnedAt })) });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- offline search --------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async searchOffline(query, { limit = 40 } = {}) {
|
|
135
|
+
const rows = await idbAll(this.db, 'pins');
|
|
136
|
+
if (!rows.length) return [];
|
|
137
|
+
const qTokens = tokenize(query);
|
|
138
|
+
const qv = (await embed.embed([query]))[0];
|
|
139
|
+
const results = [];
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
const hay = tokenize(`${r.node.name} ${r.text || ''}`);
|
|
142
|
+
const lex = lexicalScore(qTokens, new Set(hay));
|
|
143
|
+
let dense = cosine(qv, r.nameVec);
|
|
144
|
+
for (const c of r.chunks || []) dense = Math.max(dense, cosine(qv, c.vector));
|
|
145
|
+
const score = 0.6 * dense + 0.4 * lex;
|
|
146
|
+
if (score > 0.02) results.push({ nodeId: r.node.id, node: r.node, score, snippet: snippet(r.text, qTokens), indexerId: 'offline' });
|
|
147
|
+
}
|
|
148
|
+
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- offline sidecar op queue ---------------------------------------------
|
|
152
|
+
|
|
153
|
+
async queueOp(op) {
|
|
154
|
+
await idbAdd(this.db, 'queue', { ...op, at: Date.now() });
|
|
155
|
+
await this.#refreshQueue();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async #refreshQueue() {
|
|
159
|
+
const q = await idbAll(this.db, 'queue');
|
|
160
|
+
this.#set({ queued: q.length });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async flushQueue() {
|
|
164
|
+
if (!this.state.online || this.state.syncing) return;
|
|
165
|
+
// Claim the sync BEFORE any await — two 'online' events (or init + an event) can
|
|
166
|
+
// fire concurrently, and a check-then-act guard after an await let both pass and
|
|
167
|
+
// replay the queue twice (double-posting). Setting syncing synchronously here makes
|
|
168
|
+
// the second caller bail at the guard above.
|
|
169
|
+
this.#set({ syncing: true });
|
|
170
|
+
let synced = 0;
|
|
171
|
+
let dropped = 0;
|
|
172
|
+
try {
|
|
173
|
+
const q = await idbAllWithKeys(this.db, 'queue');
|
|
174
|
+
for (const { key, value } of q) {
|
|
175
|
+
try {
|
|
176
|
+
await this.api.request(value.method, value.path, { body: value.body });
|
|
177
|
+
await idbDelete(this.db, 'queue', key);
|
|
178
|
+
synced++;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// Anything that could succeed later stays queued. `unauthorized` is the case
|
|
181
|
+
// that mattered: a session expiring while offline is not a permanent failure,
|
|
182
|
+
// and dropping the entry destroyed a comment the user had written and told
|
|
183
|
+
// them it "couldn't be applied" — when signing in again would have applied it.
|
|
184
|
+
if (['transient', 'timeout', 'unauthorized', 'quota'].includes(err.code)) break;
|
|
185
|
+
// A permanent failure (e.g. the file was deleted) — drop it so we don't loop,
|
|
186
|
+
// but don't pretend it synced.
|
|
187
|
+
await idbDelete(this.db, 'queue', key);
|
|
188
|
+
dropped++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
await this.#refreshQueue();
|
|
193
|
+
this.#set({ syncing: false });
|
|
194
|
+
// Be honest: only claim success for what actually applied, and surface drops.
|
|
195
|
+
if (dropped) this.platform.notifications.warn(`${dropped} offline change${dropped > 1 ? 's' : ''} couldn't be applied and ${dropped > 1 ? 'were' : 'was'} discarded.`);
|
|
196
|
+
if (synced && this.state.queued === 0) this.platform.notifications.info(`${synced} offline change${synced > 1 ? 's' : ''} synced.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- IndexedDB helpers ------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
function openDb() {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const req = indexedDB.open(DB, 1);
|
|
206
|
+
req.onupgradeneeded = () => {
|
|
207
|
+
const db = req.result;
|
|
208
|
+
if (!db.objectStoreNames.contains('pins')) db.createObjectStore('pins');
|
|
209
|
+
if (!db.objectStoreNames.contains('queue')) db.createObjectStore('queue', { autoIncrement: true });
|
|
210
|
+
};
|
|
211
|
+
req.onsuccess = () => resolve(req.result);
|
|
212
|
+
req.onerror = () => reject(req.error);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function tx(db, store, mode, fn) {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const t = db.transaction(store, mode);
|
|
218
|
+
const s = t.objectStore(store);
|
|
219
|
+
let out;
|
|
220
|
+
Promise.resolve(fn(s)).then((r) => (out = r));
|
|
221
|
+
t.oncomplete = () => resolve(out);
|
|
222
|
+
t.onerror = () => reject(t.error);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const reqP = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
|
226
|
+
const idbPut = (db, store, key, val) => tx(db, store, 'readwrite', (s) => s.put(val, key));
|
|
227
|
+
const idbAdd = (db, store, val) => tx(db, store, 'readwrite', (s) => s.add(val));
|
|
228
|
+
const idbDelete = (db, store, key) => tx(db, store, 'readwrite', (s) => s.delete(key));
|
|
229
|
+
const idbAll = (db, store) => tx(db, store, 'readonly', (s) => reqP(s.getAll()));
|
|
230
|
+
function idbAllWithKeys(db, store) {
|
|
231
|
+
return tx(db, store, 'readonly', (s) =>
|
|
232
|
+
new Promise((resolve, reject) => {
|
|
233
|
+
const out = [];
|
|
234
|
+
const req = s.openCursor();
|
|
235
|
+
req.onsuccess = () => {
|
|
236
|
+
const cur = req.result;
|
|
237
|
+
if (!cur) return resolve(out);
|
|
238
|
+
out.push({ key: cur.key, value: cur.value });
|
|
239
|
+
cur.continue();
|
|
240
|
+
};
|
|
241
|
+
req.onerror = () => reject(req.error);
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- search math (mirrors the server's LocalHash hybrid) --------------------
|
|
247
|
+
|
|
248
|
+
function lexicalScore(q, docTokens) {
|
|
249
|
+
if (!q.length || !docTokens.size) return 0;
|
|
250
|
+
let hit = 0;
|
|
251
|
+
for (const t of q) if (docTokens.has(t)) hit++;
|
|
252
|
+
return hit / q.length;
|
|
253
|
+
}
|
|
254
|
+
function cosine(a, b) {
|
|
255
|
+
if (!a || !b) return 0;
|
|
256
|
+
let s = 0;
|
|
257
|
+
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
|
258
|
+
return s; // both L2-normalised
|
|
259
|
+
}
|
|
260
|
+
function snippet(text, qTokens) {
|
|
261
|
+
if (!text) return null;
|
|
262
|
+
const lower = text.toLowerCase();
|
|
263
|
+
let at = -1;
|
|
264
|
+
for (const t of qTokens) { const i = lower.indexOf(t); if (i >= 0) { at = i; break; } }
|
|
265
|
+
if (at < 0) return text.slice(0, 160).trim();
|
|
266
|
+
const start = Math.max(0, at - 60);
|
|
267
|
+
return (start > 0 ? '…' : '') + text.slice(start, start + 200).trim() + '…';
|
|
268
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Opener choice: which viewer opens a file when several can. Built-in and plugin
|
|
2
|
+
// openers register the same way (a selector + priority), so a file can match more
|
|
3
|
+
// than one — e.g. a plugin PDF viewer alongside a built-in, or a rich player vs the
|
|
4
|
+
// plain audio player for an .m4a. When that happens and the user hasn't expressed a
|
|
5
|
+
// preference, we ask; their choice can be remembered per file type.
|
|
6
|
+
//
|
|
7
|
+
// Preferences live in settings under `openers.associations`: a map from a *type key*
|
|
8
|
+
// (an extension like ".pdf", an exact mime, or a "type/*" wildcard) to an opener id.
|
|
9
|
+
|
|
10
|
+
import { extOf } from './fileType.js';
|
|
11
|
+
|
|
12
|
+
const ASSOC_KEY = 'openers.associations';
|
|
13
|
+
|
|
14
|
+
export { extOf };
|
|
15
|
+
|
|
16
|
+
/** The canonical type key we remember a choice under: prefer extension, else mime. */
|
|
17
|
+
export function typeKeyFor(node) {
|
|
18
|
+
return extOf(node) || node?.contentType || '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A human label for the type, for "Always use … for {label}". */
|
|
22
|
+
export function typeLabelFor(node) {
|
|
23
|
+
const ext = extOf(node);
|
|
24
|
+
if (ext) return `${ext} files`;
|
|
25
|
+
const mime = node?.contentType || '';
|
|
26
|
+
return mime ? `${mime} files` : 'this file type';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** All openers that can handle this node right now (selector + when + availability),
|
|
30
|
+
* best (highest priority) first. */
|
|
31
|
+
export function availableOpeners(platform, node) {
|
|
32
|
+
const evaluate = (w) => platform.context.evaluate(w);
|
|
33
|
+
const isAvailable = (o) => platform.plugins.isAvailable(o);
|
|
34
|
+
return platform.contributions
|
|
35
|
+
.openersFor(node)
|
|
36
|
+
.filter((o) => (!o.when || evaluate(o.when)) && isAvailable(o))
|
|
37
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The remembered opener id for a node's type (ext → exact mime → type/* ), or null. */
|
|
41
|
+
export function rememberedOpenerId(platform, node) {
|
|
42
|
+
const assoc = platform.settings.get(ASSOC_KEY) || {};
|
|
43
|
+
const ext = extOf(node);
|
|
44
|
+
const mime = node?.contentType || '';
|
|
45
|
+
const wild = mime.includes('/') ? `${mime.slice(0, mime.indexOf('/') + 1)}*` : '';
|
|
46
|
+
return (ext && assoc[ext]) || (mime && assoc[mime]) || (wild && assoc[wild]) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Remember (or, with a null openerId, forget) the opener for a type key. */
|
|
50
|
+
export function rememberOpener(platform, typeKey, openerId) {
|
|
51
|
+
if (!typeKey) return;
|
|
52
|
+
const assoc = { ...(platform.settings.get(ASSOC_KEY) || {}) };
|
|
53
|
+
if (openerId) assoc[typeKey] = openerId;
|
|
54
|
+
else delete assoc[typeKey];
|
|
55
|
+
platform.settings.set(ASSOC_KEY, assoc);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The saved associations as a list, for the Settings UI. */
|
|
59
|
+
export function listAssociations(platform) {
|
|
60
|
+
const assoc = platform.settings.get(ASSOC_KEY) || {};
|
|
61
|
+
return Object.entries(assoc).map(([typeKey, openerId]) => {
|
|
62
|
+
const opener = platform.contributions.get(openerId);
|
|
63
|
+
return { typeKey, openerId, openerTitle: opener?.title || openerId, missing: !opener };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A short source label for an opener ("Built-in" or the plugin's name). */
|
|
68
|
+
export function openerSource(platform, opener) {
|
|
69
|
+
if (!opener?.pluginId) return 'Built-in';
|
|
70
|
+
return platform.plugins.plugins.get(opener.pluginId)?.manifest?.displayName || opener.pluginId;
|
|
71
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Install flow: turn an uploaded .zip or a URL into a parsed package, assess its
|
|
2
|
+
// trust (signature + domain verification), and present the pre-install review so
|
|
3
|
+
// the user can make an informed decision before any plugin code runs.
|
|
4
|
+
|
|
5
|
+
import { parsePackage, fetchPackage, reviewSummary, displayName } from '../platform/pluginPackage.js';
|
|
6
|
+
import { pluginId } from '@3sln/trove/core/plugins/identity.js';
|
|
7
|
+
|
|
8
|
+
export async function beginInstallFromFile(app, file) {
|
|
9
|
+
try {
|
|
10
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
11
|
+
await review(app, parsePackage(bytes));
|
|
12
|
+
} catch (err) {
|
|
13
|
+
app.platform.notifications.error(`Couldn't read the plugin: ${err.message}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function beginInstallFromUrl(app, url) {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = await fetchPackage(url, (u, o) => fetch(u, o));
|
|
20
|
+
await review(app, pkg);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
app.platform.notifications.error(`Couldn't fetch the plugin: ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function review(app, pkg) {
|
|
27
|
+
const label = displayName(pkg.manifest);
|
|
28
|
+
if (app.platform.plugins.plugins.has(pluginId(pkg.manifest))) {
|
|
29
|
+
app.platform.notifications.warn(`“${label}” is already installed.`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Trust assessment can hit the network (domain assetlinks); do it before review.
|
|
33
|
+
let trust = { status: 'unverified' };
|
|
34
|
+
try {
|
|
35
|
+
trust = await app.platform.plugins.assessTrust(pkg);
|
|
36
|
+
} catch { /* offline / unreachable */ }
|
|
37
|
+
const summary = reviewSummary(pkg, trust);
|
|
38
|
+
app.platform.workbench.showDialog({
|
|
39
|
+
kind: 'plugin-review',
|
|
40
|
+
summary,
|
|
41
|
+
isAdmin: !!app.social.state.admin,
|
|
42
|
+
onInstall: async (grants) => {
|
|
43
|
+
app.platform.workbench.closeDialog();
|
|
44
|
+
// Account installs upload the package + run a handshake that can take a while —
|
|
45
|
+
// switch to the plugins view (which shows the plugin's loading state) and hold a
|
|
46
|
+
// sticky "Installing…" toast so the user isn't left staring at nothing.
|
|
47
|
+
app.platform.workbench.setActivity('plugins');
|
|
48
|
+
const pending = app.platform.notifications.info(`Installing “${label}”…`, { sticky: true });
|
|
49
|
+
try {
|
|
50
|
+
await app.platform.plugins.install(pkg, { grants, trust });
|
|
51
|
+
app.platform.notifications.dismiss(pending);
|
|
52
|
+
app.platform.notifications.success(`Installed “${label}”`);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
app.platform.notifications.dismiss(pending);
|
|
55
|
+
app.platform.notifications.error(`Install failed: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Reactive data services the workbench renders from. These hold *data* state
|
|
2
|
+
// (the current collection's items, search results, in-flight transfers) as
|
|
3
|
+
// opposed to the shell's UI state (WorkbenchService). ngin Actions mutate them;
|
|
4
|
+
// the UI `watch`es them. Each is a plain cell wrapper so the render layer stays
|
|
5
|
+
// declarative.
|
|
6
|
+
|
|
7
|
+
import { cell } from '../runtime.js';
|
|
8
|
+
|
|
9
|
+
export class ExplorerService {
|
|
10
|
+
constructor(settings) {
|
|
11
|
+
this.settings = settings;
|
|
12
|
+
this.state = {
|
|
13
|
+
items: [], loading: false, error: null,
|
|
14
|
+
selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
|
|
15
|
+
collectionId: 'default', collections: [], canCreateCollection: false,
|
|
16
|
+
// `stats` is the whole collection; `items` is the page on screen. Keeping both
|
|
17
|
+
// is what lets the UI say "500 of 3,006" instead of quietly claiming 500.
|
|
18
|
+
stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
|
|
19
|
+
};
|
|
20
|
+
this.cell = cell(this.state);
|
|
21
|
+
}
|
|
22
|
+
observe() {
|
|
23
|
+
return this.cell;
|
|
24
|
+
}
|
|
25
|
+
set(patch) {
|
|
26
|
+
this.state = { ...this.state, ...patch };
|
|
27
|
+
this.cell.setValue(this.state);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @param {string[]} ids
|
|
31
|
+
* @param {{additive?: boolean, nodes?: object[]}} [opts] `nodes` is the caller's own
|
|
32
|
+
* copy of what it selected — pass it whenever you have it (see selectedNodes).
|
|
33
|
+
*/
|
|
34
|
+
select(ids, { additive = false, nodes = null } = {}) {
|
|
35
|
+
const next = additive ? Array.from(new Set([...this.state.selection, ...ids])) : ids;
|
|
36
|
+
// Selecting what is already selected must not emit. The launcher syncs the
|
|
37
|
+
// highlighted row into here on every mouseenter, and a state push per mouse move
|
|
38
|
+
// would re-render the list under the pointer.
|
|
39
|
+
const same = next.length === this.state.selection.length
|
|
40
|
+
&& next.every((id, i) => id === this.state.selection[i]);
|
|
41
|
+
if (same) return;
|
|
42
|
+
this.set({ selection: next, selectionNodes: nodes && !additive ? nodes : null });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The nodes the selection refers to.
|
|
46
|
+
*
|
|
47
|
+
* Resolving ids against `items` only finds rows on the LOADED PAGE of the CURRENT
|
|
48
|
+
* collection — and the launcher's rows come from search (which the server scopes to
|
|
49
|
+
* every readable collection) and from recents (which survive a collection switch).
|
|
50
|
+
* So every row reached by searching resolved to nothing, and rename / move-to-trash /
|
|
51
|
+
* copy-link returned silently while `explorer.hasSelection` said there was a
|
|
52
|
+
* selection. Preferring the nodes the selecting caller already held fixes the whole
|
|
53
|
+
* class; the `items` lookup stays for callers that only have ids.
|
|
54
|
+
*/
|
|
55
|
+
selectedNodes() {
|
|
56
|
+
const held = this.state.selectionNodes;
|
|
57
|
+
if (held?.length) return held.filter((n) => this.state.selection.includes(n.id));
|
|
58
|
+
return this.state.items.filter((i) => this.state.selection.includes(i.id));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class SearchClientService {
|
|
63
|
+
constructor() {
|
|
64
|
+
this.state = { query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false, paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null };
|
|
65
|
+
this.cell = cell(this.state);
|
|
66
|
+
}
|
|
67
|
+
observe() {
|
|
68
|
+
return this.cell;
|
|
69
|
+
}
|
|
70
|
+
set(patch) {
|
|
71
|
+
this.state = { ...this.state, ...patch };
|
|
72
|
+
this.cell.setValue(this.state);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Uploads in flight.
|
|
78
|
+
*
|
|
79
|
+
* This owns the DETAIL an upload needs and a generic task doesn't — bytes moved, an
|
|
80
|
+
* AbortController, per-file retry. It also projects each transfer into ActivityService
|
|
81
|
+
* so the drive has ONE list of "what is happening", covering work on both sides of the
|
|
82
|
+
* wire: an upload running in this browser and a reindex running on the server show up
|
|
83
|
+
* together, because a user doesn't care which machine is busy.
|
|
84
|
+
*
|
|
85
|
+
* The projection is one-way and this stays the owner. Two writable copies of the same
|
|
86
|
+
* fact is how they drift.
|
|
87
|
+
*/
|
|
88
|
+
export class TransfersService {
|
|
89
|
+
/** @param {import('./activity.js').ActivityService} [activity] */
|
|
90
|
+
constructor(activity = null) {
|
|
91
|
+
this.state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error }
|
|
92
|
+
this.cell = cell(this.state);
|
|
93
|
+
this._controllers = new Map();
|
|
94
|
+
this.activity = activity;
|
|
95
|
+
this._tasks = new Map(); // transfer id -> activity task handle
|
|
96
|
+
}
|
|
97
|
+
observe() {
|
|
98
|
+
return this.cell;
|
|
99
|
+
}
|
|
100
|
+
#emit() {
|
|
101
|
+
this.cell.setValue(this.state);
|
|
102
|
+
}
|
|
103
|
+
start(id, name, total, controller) {
|
|
104
|
+
this._controllers.set(id, controller);
|
|
105
|
+
this.state = { items: [...this.state.items, { id, name, direction: 'up', ratio: 0, loaded: 0, total, status: 'active', error: null }] };
|
|
106
|
+
this.#emit();
|
|
107
|
+
const task = this.activity?.start({
|
|
108
|
+
kind: 'transfer', title: `Uploading ${name}`, total: total || null, unit: 'bytes',
|
|
109
|
+
onCancel: () => this.cancel(id),
|
|
110
|
+
});
|
|
111
|
+
if (task) this._tasks.set(id, task);
|
|
112
|
+
}
|
|
113
|
+
progress(id, { loaded, total, ratio }) {
|
|
114
|
+
this.state = { items: this.state.items.map((t) => (t.id === id ? { ...t, loaded, total, ratio } : t)) };
|
|
115
|
+
this.#emit();
|
|
116
|
+
this._tasks.get(id)?.progress({ done: loaded, total: total || null });
|
|
117
|
+
}
|
|
118
|
+
finish(id, status = 'done', error = null) {
|
|
119
|
+
this.state = { items: this.state.items.map((t) => (t.id === id ? { ...t, status, error, ratio: status === 'done' ? 1 : t.ratio } : t)) };
|
|
120
|
+
this._controllers.delete(id);
|
|
121
|
+
this.#emit();
|
|
122
|
+
const task = this._tasks.get(id);
|
|
123
|
+
if (task) {
|
|
124
|
+
if (status === 'done') task.succeed();
|
|
125
|
+
else if (status === 'cancelled') task.cancel();
|
|
126
|
+
else task.fail(error || new Error('Upload failed'));
|
|
127
|
+
this._tasks.delete(id);
|
|
128
|
+
}
|
|
129
|
+
if (status === 'done') setTimeout(() => this.dismiss(id), 4000);
|
|
130
|
+
}
|
|
131
|
+
cancel(id) {
|
|
132
|
+
this._controllers.get(id)?.abort();
|
|
133
|
+
this.finish(id, 'cancelled');
|
|
134
|
+
}
|
|
135
|
+
dismiss(id) {
|
|
136
|
+
this.state = { items: this.state.items.filter((t) => t.id !== id) };
|
|
137
|
+
this.#emit();
|
|
138
|
+
}
|
|
139
|
+
clearDone() {
|
|
140
|
+
this.state = { items: this.state.items.filter((t) => t.status === 'active') };
|
|
141
|
+
this.#emit();
|
|
142
|
+
}
|
|
143
|
+
}
|