@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.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. 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
+ }