@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,529 @@
|
|
|
1
|
+
// TroveApiClient — the browser's view of the server API. Thin JSON calls with
|
|
2
|
+
// transient-error retry, plus the upload orchestrator, which is where the "handle
|
|
3
|
+
// large uploads well" requirement lives:
|
|
4
|
+
//
|
|
5
|
+
// • Chooses nothing itself — it follows the plan the server returns (single /
|
|
6
|
+
// presigned-multipart / direct-multipart), so an S3 deployment uploads parts
|
|
7
|
+
// straight to S3 and a filesystem deployment streams through the server, with
|
|
8
|
+
// the same client code.
|
|
9
|
+
// • Uploads parts concurrently (bounded), each with its own retry, and reports
|
|
10
|
+
// aggregate byte progress for a live progress bar.
|
|
11
|
+
// • Resumable: on retry it asks the server which parts already landed and skips
|
|
12
|
+
// them, so a dropped connection doesn't restart a 4 GB upload.
|
|
13
|
+
//
|
|
14
|
+
// Uploads use XMLHttpRequest because fetch can't report upload progress.
|
|
15
|
+
|
|
16
|
+
import { withRetry } from '@3sln/trove/core/retry.js';
|
|
17
|
+
import { TroveError } from '@3sln/trove/core/errors.js';
|
|
18
|
+
|
|
19
|
+
export class TroveApiClient {
|
|
20
|
+
/**
|
|
21
|
+
* @param {{baseUrl?: string, fetch?: Function, token?: string|(() => string|null)}} [opts]
|
|
22
|
+
* `token` is a bearer JWT, or a function returning one. Optional because the common
|
|
23
|
+
* deployment puts an authenticating proxy in front (Cloudflare Access sets its own
|
|
24
|
+
* header and the browser sends nothing) — but a deployment where the user HOLDS a
|
|
25
|
+
* token has no way to present it otherwise, which is why this exists.
|
|
26
|
+
*/
|
|
27
|
+
constructor({ baseUrl = '', fetch: f = globalThis.fetch.bind(globalThis), token = null } = {}) {
|
|
28
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
29
|
+
this._fetch = f;
|
|
30
|
+
this._token = token;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The bearer token to present, if any. Read per request so a refresh takes effect. */
|
|
34
|
+
token() {
|
|
35
|
+
const t = typeof this._token === 'function' ? this._token() : this._token;
|
|
36
|
+
return t || null;
|
|
37
|
+
}
|
|
38
|
+
/** Auth headers for a request, or nothing at all when there is no token to send. */
|
|
39
|
+
authHeaders() {
|
|
40
|
+
const t = this.token();
|
|
41
|
+
return t ? { authorization: `Bearer ${t}` } : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async request(method, path, { body, query, signal, raw } = {}) {
|
|
45
|
+
const url = this.baseUrl + path + (query ? '?' + new URLSearchParams(query) : '');
|
|
46
|
+
return withRetry(
|
|
47
|
+
async () => {
|
|
48
|
+
const res = await this._fetch(url, {
|
|
49
|
+
method,
|
|
50
|
+
headers: { ...this.authHeaders(), ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
51
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
52
|
+
signal,
|
|
53
|
+
});
|
|
54
|
+
if (res.status === 429 || res.status >= 500) {
|
|
55
|
+
// Read the structured error to decide retry: an explicitly non-retryable
|
|
56
|
+
// failure (e.g. a per-file size limit) must fail fast with its real message,
|
|
57
|
+
// not be retried 3× into a generic "Server 429". `raw` requests can't consume
|
|
58
|
+
// the body here, so they stay status-based.
|
|
59
|
+
if (!raw) {
|
|
60
|
+
let e;
|
|
61
|
+
try { const t = await res.text(); e = (t ? JSON.parse(t) : null)?.error; } catch { /* non-JSON body */ }
|
|
62
|
+
if (e && e.retryable === false) throw new TroveError(e.code, e.message, { retryable: false, details: e.details });
|
|
63
|
+
throw TroveError.transient(e?.message || `Server ${res.status}`);
|
|
64
|
+
}
|
|
65
|
+
throw TroveError.transient(`Server ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
if (raw) return res;
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
const json = text ? JSON.parse(text) : null;
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const e = json?.error || { code: 'internal', message: `Request failed (${res.status})` };
|
|
72
|
+
throw new TroveError(e.code, e.message, { retryable: e.retryable, details: e.details });
|
|
73
|
+
}
|
|
74
|
+
return json;
|
|
75
|
+
},
|
|
76
|
+
{ signal, retries: 3 },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- browse / mutate -------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
capabilities() {
|
|
83
|
+
return this.request('GET', '/api/capabilities');
|
|
84
|
+
}
|
|
85
|
+
/** Single-shot reachability probe (short timeout, no retries) — is the server
|
|
86
|
+
* actually reachable, vs. `navigator.onLine` merely reporting an up interface? */
|
|
87
|
+
async reachable(timeoutMs = 4000) {
|
|
88
|
+
try {
|
|
89
|
+
const signal = AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
90
|
+
const res = await this._fetch(this.baseUrl + '/api/capabilities', { method: 'GET', signal, headers: this.authHeaders() });
|
|
91
|
+
return res.ok;
|
|
92
|
+
} catch { return false; }
|
|
93
|
+
}
|
|
94
|
+
/** Every item in a collection. */
|
|
95
|
+
list(opts = {}) {
|
|
96
|
+
return this.request('GET', '/api/items', { query: opts });
|
|
97
|
+
}
|
|
98
|
+
/** Resolve an item by id, by `trove:` URI, or by name within a collection. */
|
|
99
|
+
stat(ref, opts = {}) {
|
|
100
|
+
const key = String(ref).startsWith('trove:') ? 'uri' : 'id';
|
|
101
|
+
return this.request('GET', '/api/items/resolve', { query: { [key]: ref, ...opts } });
|
|
102
|
+
}
|
|
103
|
+
/** What links to this item. */
|
|
104
|
+
backlinks(id, opts = {}) {
|
|
105
|
+
return this.request('GET', '/api/items/backlinks', { query: { id, ...opts } });
|
|
106
|
+
}
|
|
107
|
+
rename(id, newName) {
|
|
108
|
+
return this.request('POST', '/api/items/rename', { body: { id, newName } });
|
|
109
|
+
}
|
|
110
|
+
remove(id) {
|
|
111
|
+
return this.request('POST', '/api/items/delete', { body: { id } });
|
|
112
|
+
}
|
|
113
|
+
/** What's been deleted but not yet destroyed. */
|
|
114
|
+
trash(collection) {
|
|
115
|
+
return this.request('GET', '/api/trash', { query: collection ? { collection } : {} });
|
|
116
|
+
}
|
|
117
|
+
restore(id) {
|
|
118
|
+
return this.request('POST', '/api/trash/restore', { body: { id } });
|
|
119
|
+
}
|
|
120
|
+
/** Destroy for real — one item, or everything in a collection's trash. */
|
|
121
|
+
purgeTrash({ id, collection } = {}) {
|
|
122
|
+
return this.request('POST', '/api/trash/purge', { body: id ? { id } : { collection } });
|
|
123
|
+
}
|
|
124
|
+
search(q, opts = {}) {
|
|
125
|
+
return this.request('GET', '/api/search', { query: { q, ...opts } });
|
|
126
|
+
}
|
|
127
|
+
// Unified query: server transforms the raw string (parse/LLM) → runs it → returns
|
|
128
|
+
// { query, results, resolved }. `resolved` is what was actually searched.
|
|
129
|
+
query(q, opts = {}) {
|
|
130
|
+
return this.request('POST', '/api/query', { body: { q, ...opts } });
|
|
131
|
+
}
|
|
132
|
+
// Drive-wide tag/property filter (launcher #tag / #key:op:value).
|
|
133
|
+
tagSearch(filters, q, opts = {}) {
|
|
134
|
+
return this.request('POST', '/api/tags/search', { body: { filters, q, ...opts } });
|
|
135
|
+
}
|
|
136
|
+
indexers() {
|
|
137
|
+
return this.request('GET', '/api/indexers');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- background work + standing problems -----------------------------------
|
|
141
|
+
// Tasks are in-flight and ephemeral; issues are what a failure left behind and are
|
|
142
|
+
// durable. Ids can contain anything (an issue id embeds a node id), so every one of
|
|
143
|
+
// these encodes before it becomes a path segment.
|
|
144
|
+
tasks() {
|
|
145
|
+
return this.request('GET', '/api/tasks');
|
|
146
|
+
}
|
|
147
|
+
cancelTask(id) {
|
|
148
|
+
return this.request('POST', `/api/tasks/${encodeURIComponent(id)}/cancel`);
|
|
149
|
+
}
|
|
150
|
+
dismissTask(id) {
|
|
151
|
+
return this.request('DELETE', `/api/tasks/${encodeURIComponent(id)}`);
|
|
152
|
+
}
|
|
153
|
+
issues() {
|
|
154
|
+
return this.request('GET', '/api/issues');
|
|
155
|
+
}
|
|
156
|
+
retryIssue(id) {
|
|
157
|
+
return this.request('POST', `/api/issues/${encodeURIComponent(id)}/retry`);
|
|
158
|
+
}
|
|
159
|
+
dismissIssue(id) {
|
|
160
|
+
return this.request('DELETE', `/api/issues/${encodeURIComponent(id)}`);
|
|
161
|
+
}
|
|
162
|
+
reindex() {
|
|
163
|
+
return this.request('POST', '/api/reindex');
|
|
164
|
+
}
|
|
165
|
+
/** Reconcile a collection against its object store (changes made outside Trove). */
|
|
166
|
+
scanCollection(id) {
|
|
167
|
+
return this.request('POST', `/api/collections/${encodeURIComponent(id)}/scan`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- server plugin installs (account-scoped, synced across devices) ---------
|
|
171
|
+
/** Upload a package zip for account install; returns the server install record. */
|
|
172
|
+
async installPlugin(bytes, grants) {
|
|
173
|
+
const q = grants && grants.length ? '?grants=' + encodeURIComponent(grants.join(',')) : '';
|
|
174
|
+
const res = await this._fetch(this.baseUrl + '/api/plugins/install' + q, { method: 'POST', body: bytes, headers: this.authHeaders() });
|
|
175
|
+
const json = await res.json().catch(() => null);
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const e = json?.error || { code: 'internal', message: `Install failed (${res.status})` };
|
|
178
|
+
throw new TroveError(e.code, e.message, { details: e.details });
|
|
179
|
+
}
|
|
180
|
+
return json.install;
|
|
181
|
+
}
|
|
182
|
+
installedPlugins() {
|
|
183
|
+
return this.request('GET', '/api/plugins/installed');
|
|
184
|
+
}
|
|
185
|
+
async pluginPackage(id) {
|
|
186
|
+
const res = await this._fetch(`${this.baseUrl}/api/plugins/${encodeURIComponent(id)}/package`, { headers: this.authHeaders() });
|
|
187
|
+
if (!res.ok) throw new TroveError('not_found', `Package for "${id}" not found`);
|
|
188
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
189
|
+
}
|
|
190
|
+
uninstallPluginServer(id) {
|
|
191
|
+
return this.request('DELETE', `/api/plugins/${encodeURIComponent(id)}/install`);
|
|
192
|
+
}
|
|
193
|
+
// contribution: { semanticTexts?, tags?, metadata? } (legacy { documents, facet } ok).
|
|
194
|
+
pushIndex(indexerId, nodeId, contribution) {
|
|
195
|
+
return this.request('POST', `/api/index/${encodeURIComponent(indexerId)}`, { body: { nodeId, ...contribution } });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- identity --------------------------------------------------------------
|
|
199
|
+
me() {
|
|
200
|
+
return this.request('GET', '/api/me');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- collections -----------------------------------------------------------
|
|
204
|
+
collections() {
|
|
205
|
+
return this.request('GET', '/api/collections');
|
|
206
|
+
}
|
|
207
|
+
createCollection(body) {
|
|
208
|
+
return this.request('POST', '/api/collections', { body });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- conversations, tags, sidecar ------------------------------------------
|
|
212
|
+
sidecar(id) {
|
|
213
|
+
return this.request('GET', `/api/items/${encodeURIComponent(id)}/sidecar`);
|
|
214
|
+
}
|
|
215
|
+
addComment(id, { body, parentId, mentions } = {}) {
|
|
216
|
+
return this.request('POST', `/api/items/${encodeURIComponent(id)}/comments`, { body: { body, parentId, mentions } });
|
|
217
|
+
}
|
|
218
|
+
editComment(id, cid, body) {
|
|
219
|
+
return this.request('POST', `/api/items/${encodeURIComponent(id)}/comments/${encodeURIComponent(cid)}/edit`, { body: { body } });
|
|
220
|
+
}
|
|
221
|
+
deleteComment(id, cid) {
|
|
222
|
+
return this.request('DELETE', `/api/items/${encodeURIComponent(id)}/comments/${encodeURIComponent(cid)}`);
|
|
223
|
+
}
|
|
224
|
+
reactComment(id, cid, emoji, on) {
|
|
225
|
+
return this.request('POST', `/api/items/${encodeURIComponent(id)}/comments/${encodeURIComponent(cid)}/react`, { body: { emoji, on } });
|
|
226
|
+
}
|
|
227
|
+
setTag(id, name, value) {
|
|
228
|
+
return this.request('POST', `/api/items/${encodeURIComponent(id)}/tags`, { body: { name, value } });
|
|
229
|
+
}
|
|
230
|
+
removeTag(id, name) {
|
|
231
|
+
return this.request('DELETE', `/api/items/${encodeURIComponent(id)}/tags/${encodeURIComponent(name)}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- notifications & push --------------------------------------------------
|
|
235
|
+
notifications() {
|
|
236
|
+
return this.request('GET', '/api/notifications');
|
|
237
|
+
}
|
|
238
|
+
markNotificationsRead(ids) {
|
|
239
|
+
return this.request('POST', '/api/notifications/read', { body: { ids } });
|
|
240
|
+
}
|
|
241
|
+
vapidKey() {
|
|
242
|
+
return this.request('GET', '/api/push/vapid');
|
|
243
|
+
}
|
|
244
|
+
subscribePush(subscription) {
|
|
245
|
+
return this.request('POST', '/api/push/subscribe', { body: { subscription } });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** URL for GET-ing bytes (used by <img>/<audio>/<video> and downloads). */
|
|
249
|
+
downloadUrl(id, { attachment } = {}) {
|
|
250
|
+
return `${this.baseUrl}/api/items/download?id=${encodeURIComponent(id)}${attachment ? '&disposition=attachment' : ''}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Mint URLs that carry their own authorization, for the things that cannot send a
|
|
255
|
+
* header — an <img src>, a <video src>, cache.add(). Batched: a gallery asks for what
|
|
256
|
+
* it is about to draw, in one request. See platform/mediaUrls.js.
|
|
257
|
+
*/
|
|
258
|
+
mintUrls(ids, op = 'media') {
|
|
259
|
+
return this.request('POST', '/api/items/urls', { body: { ids, op } });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Start a download in the browser.
|
|
264
|
+
*
|
|
265
|
+
* With no bearer token this is a plain navigation to the download URL — the browser
|
|
266
|
+
* streams it, handles Range, and never buffers a 4 GB file in a tab. When a token IS
|
|
267
|
+
* in play that isn't possible: an `<a href>` cannot carry an Authorization header, so
|
|
268
|
+
* the navigation would arrive unauthenticated and 401. Rather than move the token into
|
|
269
|
+
* the query string (where it lands in logs, history, and referrers), fetch the bytes
|
|
270
|
+
* with the header and hand the browser a blob.
|
|
271
|
+
*
|
|
272
|
+
* The cost is honest and worth stating: the blob path holds the file in memory. It is
|
|
273
|
+
* the price of bearer auth without a token-bearing URL, and it does not apply to the
|
|
274
|
+
* common proxy-authenticated deployment, which takes the streaming path.
|
|
275
|
+
*/
|
|
276
|
+
async download(id, name, { attachment = true } = {}) {
|
|
277
|
+
const url = this.downloadUrl(id, { attachment });
|
|
278
|
+
if (!this.token()) return { url, streamed: true };
|
|
279
|
+
const res = await this._fetch(url, { headers: this.authHeaders() });
|
|
280
|
+
if (!res.ok) throw new TroveError('internal', `Download failed (${res.status})`);
|
|
281
|
+
return { url: URL.createObjectURL(await res.blob()), streamed: false, revoke: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Read a whole file as text/bytes (small files, indexers). */
|
|
285
|
+
async readBytes(id, { signal } = {}) {
|
|
286
|
+
const res = await this._fetch(this.downloadUrl(id), { signal, headers: this.authHeaders() });
|
|
287
|
+
if (!res.ok) throw new TroveError('internal', `Download failed (${res.status})`);
|
|
288
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
289
|
+
}
|
|
290
|
+
async readText(id, opts) {
|
|
291
|
+
return new TextDecoder().decode(await this.readBytes(id, opts));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Read at most `maxBytes` of a file as text.
|
|
296
|
+
*
|
|
297
|
+
* A viewer must never pull a whole file just to show the top of it. A drive holds
|
|
298
|
+
* logs, exports and dumps that are hundreds of megabytes; `readText` on one of those
|
|
299
|
+
* buffers the entire thing in the tab before a single character is drawn, and the
|
|
300
|
+
* browser is gone before the user learns anything. A Range request costs one extra
|
|
301
|
+
* byte over the wire and bounds the damage at the transfer rather than the render.
|
|
302
|
+
*
|
|
303
|
+
* The cut is at a BYTE boundary, which can land mid-character in UTF-8, so the
|
|
304
|
+
* decoder is told to tolerate it — a trailing replacement glyph beats throwing away
|
|
305
|
+
* the read. The last partial line is dropped for the same reason.
|
|
306
|
+
*
|
|
307
|
+
* @returns {Promise<{text: string, truncated: boolean, total: number|null}>}
|
|
308
|
+
*/
|
|
309
|
+
async readTextCapped(id, { maxBytes = 512 * 1024, size = null, signal } = {}) {
|
|
310
|
+
// Skip the Range when we already know the file fits. An empty file has no
|
|
311
|
+
// satisfiable range, so asking for one turned "open an empty note" into an error
|
|
312
|
+
// page; a 416 from any backend would do the same.
|
|
313
|
+
const ranged = size == null || size > maxBytes;
|
|
314
|
+
const res = await this._fetch(this.downloadUrl(id), {
|
|
315
|
+
signal,
|
|
316
|
+
headers: { ...this.authHeaders(), ...(ranged ? { range: `bytes=0-${maxBytes - 1}` } : {}) },
|
|
317
|
+
});
|
|
318
|
+
if (res.status === 416) return { text: '', truncated: false, total: 0 };
|
|
319
|
+
if (!res.ok && res.status !== 206) throw new TroveError('internal', `Download failed (${res.status})`);
|
|
320
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
321
|
+
// `Content-Range: bytes 0-N/TOTAL` is how we learn the real size; a server that
|
|
322
|
+
// ignored the Range header just sent everything, which is also fine.
|
|
323
|
+
const total = Number(/\/(\d+)$/.exec(res.headers.get('content-range') || '')?.[1]) || null;
|
|
324
|
+
const truncated = bytes.length >= maxBytes && (total == null || total > bytes.length);
|
|
325
|
+
let text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
326
|
+
if (truncated) {
|
|
327
|
+
const lastBreak = text.lastIndexOf('\n');
|
|
328
|
+
if (lastBreak > 0) text = text.slice(0, lastBreak);
|
|
329
|
+
}
|
|
330
|
+
return { text, truncated, total };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Upload a File/Blob with progress + resume.
|
|
335
|
+
* @param {Blob & {name?:string}} file
|
|
336
|
+
* @param {object} opts
|
|
337
|
+
* @param {string} opts.parentId
|
|
338
|
+
* @param {string} [opts.name]
|
|
339
|
+
* @param {(p:{loaded:number,total:number,ratio:number}) => void} [opts.onProgress]
|
|
340
|
+
* @param {AbortSignal} [opts.signal]
|
|
341
|
+
* @param {number} [opts.concurrency]
|
|
342
|
+
* @returns {Promise<object>} the created node
|
|
343
|
+
*/
|
|
344
|
+
async upload(file, opts) {
|
|
345
|
+
const name = opts.name || file.name || 'untitled';
|
|
346
|
+
const size = file.size;
|
|
347
|
+
const plan = await this.request('POST', '/api/uploads', {
|
|
348
|
+
body: { collection: opts.collection, name, size, contentType: file.type || undefined },
|
|
349
|
+
signal: opts.signal,
|
|
350
|
+
});
|
|
351
|
+
// Hand the caller the server upload id so a cancel/failure can abort the session
|
|
352
|
+
// (otherwise a multipart upload leaks server + storage state).
|
|
353
|
+
if (plan.uploadId) opts.onStart?.(plan.uploadId);
|
|
354
|
+
|
|
355
|
+
const progress = new ProgressAggregator(size, opts.onProgress);
|
|
356
|
+
const t = plan.transfer || {};
|
|
357
|
+
const completeUrl = plan.endpoints?.complete || `/api/uploads/${plan.uploadId}/complete`;
|
|
358
|
+
|
|
359
|
+
if (plan.strategy === 'single') {
|
|
360
|
+
// One presigned PUT straight to storage (bytes never touch our server).
|
|
361
|
+
await xhrPut(t.url || plan.url, file, { headers: t.requiredHeaders, signal: opts.signal, onProgress: (l) => progress.set('single', l) });
|
|
362
|
+
const done = await this.request('POST', completeUrl, { body: {}, signal: opts.signal });
|
|
363
|
+
return done.node;
|
|
364
|
+
}
|
|
365
|
+
if (plan.strategy === 'direct-single') {
|
|
366
|
+
await xhrPut(this.baseUrl + this.#partUrl(plan, 1), file, {
|
|
367
|
+
// Our own server, so it needs our bearer token; `t.authHeaders` lets the plan
|
|
368
|
+
// add its own and wins on a clash.
|
|
369
|
+
headers: { ...this.authHeaders(), ...t.authHeaders },
|
|
370
|
+
signal: opts.signal, onProgress: (l) => progress.set(1, l),
|
|
371
|
+
});
|
|
372
|
+
const done = await this.request('POST', completeUrl, { body: {}, signal: opts.signal });
|
|
373
|
+
return done.node;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Multipart (presign or direct).
|
|
377
|
+
const partSize = plan.partSize;
|
|
378
|
+
const partCount = plan.partCount ?? Math.ceil(size / partSize);
|
|
379
|
+
// Resume: which parts already exist?
|
|
380
|
+
let received = new Set();
|
|
381
|
+
try {
|
|
382
|
+
const statusUrl = plan.endpoints?.status || `/api/uploads/${plan.uploadId}/status`;
|
|
383
|
+
const status = await this.request('GET', statusUrl, { signal: opts.signal });
|
|
384
|
+
received = new Set(status.received || []);
|
|
385
|
+
} catch { /* fresh upload */ }
|
|
386
|
+
|
|
387
|
+
const parts = [];
|
|
388
|
+
const jobs = [];
|
|
389
|
+
for (let n = 1; n <= partCount; n++) {
|
|
390
|
+
const start = (n - 1) * partSize;
|
|
391
|
+
const blob = file.slice(start, Math.min(start + partSize, size));
|
|
392
|
+
jobs.push({ n, blob });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const results = new Array(partCount);
|
|
396
|
+
const concurrency = Math.min(opts.concurrency ?? 4, jobs.length);
|
|
397
|
+
let cursor = 0;
|
|
398
|
+
// When one part fails for good, abort the sibling workers' in-flight parts instead
|
|
399
|
+
// of letting them keep PUTting bytes whose result we're about to discard. The inner
|
|
400
|
+
// controller is chained to the caller's signal so a user cancel still propagates.
|
|
401
|
+
const inner = new AbortController();
|
|
402
|
+
const onOuterAbort = () => inner.abort();
|
|
403
|
+
if (opts.signal) {
|
|
404
|
+
if (opts.signal.aborted) inner.abort();
|
|
405
|
+
else opts.signal.addEventListener('abort', onOuterAbort, { once: true });
|
|
406
|
+
}
|
|
407
|
+
const worker = async () => {
|
|
408
|
+
while (cursor < jobs.length) {
|
|
409
|
+
if (inner.signal.aborted) return;
|
|
410
|
+
const { n, blob } = jobs[cursor++];
|
|
411
|
+
if (received.has(n)) {
|
|
412
|
+
progress.set(n, blob.size);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const etag = await withRetry(
|
|
416
|
+
() => this.#uploadPart(plan, n, blob, {
|
|
417
|
+
signal: inner.signal, onProgress: (l) => progress.set(n, l),
|
|
418
|
+
}),
|
|
419
|
+
{ signal: inner.signal, retries: 4 },
|
|
420
|
+
);
|
|
421
|
+
results[n - 1] = { partNumber: n, etag };
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
try {
|
|
425
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
426
|
+
} catch (err) {
|
|
427
|
+
inner.abort(); // stop the other workers before surfacing the failure
|
|
428
|
+
throw err;
|
|
429
|
+
} finally {
|
|
430
|
+
opts.signal?.removeEventListener('abort', onOuterAbort);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const reportedParts = results.filter(Boolean);
|
|
434
|
+
const done = await this.request('POST', completeUrl, {
|
|
435
|
+
body: { parts: reportedParts }, signal: opts.signal,
|
|
436
|
+
});
|
|
437
|
+
return done.node;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Expand an endpoint/transfer URL template's {partNumber} placeholder.
|
|
441
|
+
#partUrl(plan, n) {
|
|
442
|
+
const tmpl = plan.transfer?.partUrl || `/api/uploads/${plan.uploadId}/parts/{partNumber}`;
|
|
443
|
+
return tmpl.replace('{partNumber}', String(n));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async #uploadPart(plan, n, blob, { signal, onProgress }) {
|
|
447
|
+
const t = plan.transfer || {};
|
|
448
|
+
if (plan.strategy === 'presign') {
|
|
449
|
+
const part = (t.parts || plan.parts)?.find((p) => p.partNumber === n);
|
|
450
|
+
let url = part?.url;
|
|
451
|
+
if (!url) {
|
|
452
|
+
const signTmpl = plan.endpoints?.sign || `/api/uploads/${plan.uploadId}/parts/{partNumber}/sign`;
|
|
453
|
+
const r = await this.request('POST', signTmpl.replace('{partNumber}', String(n)), { signal });
|
|
454
|
+
url = r.url;
|
|
455
|
+
}
|
|
456
|
+
const res = await xhrPut(url, blob, { signal, onProgress, wantEtag: true });
|
|
457
|
+
const etag = res.etag;
|
|
458
|
+
const reportTmpl = plan.endpoints?.report || `/api/uploads/${plan.uploadId}/parts/{partNumber}/report`;
|
|
459
|
+
await this.request('POST', reportTmpl.replace('{partNumber}', String(n)), { body: { etag }, signal });
|
|
460
|
+
return etag;
|
|
461
|
+
}
|
|
462
|
+
// proxied: server records the etag; response body has it.
|
|
463
|
+
const res = await xhrPut(this.baseUrl + this.#partUrl(plan, n), blob, {
|
|
464
|
+
headers: { ...this.authHeaders(), ...t.authHeaders },
|
|
465
|
+
signal, onProgress, wantJson: true,
|
|
466
|
+
});
|
|
467
|
+
return res.json?.etag;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async abortUpload(uploadId) {
|
|
471
|
+
return this.request('DELETE', `/api/uploads/${uploadId}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
class ProgressAggregator {
|
|
476
|
+
constructor(total, cb) {
|
|
477
|
+
this.total = total;
|
|
478
|
+
this.cb = cb;
|
|
479
|
+
this.parts = new Map();
|
|
480
|
+
}
|
|
481
|
+
set(key, loaded) {
|
|
482
|
+
// Monotonic per part: a retried part restarts its XHR progress at 0, which would
|
|
483
|
+
// otherwise pull the aggregate bar backwards. Never let a part's reported bytes drop.
|
|
484
|
+
const prev = this.parts.get(key) || 0;
|
|
485
|
+
if (loaded < prev) return;
|
|
486
|
+
this.parts.set(key, loaded);
|
|
487
|
+
if (!this.cb) return;
|
|
488
|
+
let sum = 0;
|
|
489
|
+
for (const v of this.parts.values()) sum += v;
|
|
490
|
+
const loadedTotal = Math.min(sum, this.total);
|
|
491
|
+
this.cb({ loaded: loadedTotal, total: this.total, ratio: this.total ? loadedTotal / this.total : 1 });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// XHR PUT with upload progress + abort. Returns { status, etag?, json? }.
|
|
496
|
+
function xhrPut(url, body, { signal, onProgress, wantEtag, wantJson, headers } = {}) {
|
|
497
|
+
return new Promise((resolve, reject) => {
|
|
498
|
+
const xhr = new XMLHttpRequest();
|
|
499
|
+
xhr.open('PUT', url, true);
|
|
500
|
+
for (const [k, v] of Object.entries(headers || {})) { try { xhr.setRequestHeader(k, v); } catch { /* forbidden header */ } }
|
|
501
|
+
if (signal) {
|
|
502
|
+
if (signal.aborted) {
|
|
503
|
+
xhr.abort();
|
|
504
|
+
return reject(TroveError.aborted());
|
|
505
|
+
}
|
|
506
|
+
signal.addEventListener('abort', () => xhr.abort(), { once: true });
|
|
507
|
+
}
|
|
508
|
+
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
|
509
|
+
xhr.onload = () => {
|
|
510
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
511
|
+
const out = { status: xhr.status };
|
|
512
|
+
if (wantEtag) out.etag = xhr.getResponseHeader('ETag') || xhr.getResponseHeader('etag');
|
|
513
|
+
if (wantJson) {
|
|
514
|
+
try {
|
|
515
|
+
out.json = JSON.parse(xhr.responseText);
|
|
516
|
+
} catch { /* ignore */ }
|
|
517
|
+
}
|
|
518
|
+
resolve(out);
|
|
519
|
+
} else if (xhr.status === 429 || xhr.status >= 500) {
|
|
520
|
+
reject(TroveError.transient(`Upload part failed (${xhr.status})`));
|
|
521
|
+
} else {
|
|
522
|
+
reject(new TroveError('internal', `Upload failed (${xhr.status})`));
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
xhr.onerror = () => reject(TroveError.transient('Network error during upload'));
|
|
526
|
+
xhr.onabort = () => reject(TroveError.aborted());
|
|
527
|
+
xhr.send(body);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// CommandService — the one way anything happens in the workbench. Buttons,
|
|
2
|
+
// menu items, keybindings, the palette, and plugins all funnel through
|
|
3
|
+
// `execute(id, ...args)`. A command's metadata (title/category/icon/when/palette)
|
|
4
|
+
// lives in the contribution registry; its handler lives here (core handlers are
|
|
5
|
+
// plain functions, plugin handlers proxy over RPC). Errors surface as a
|
|
6
|
+
// notification instead of a silent console log — failure is always visible.
|
|
7
|
+
|
|
8
|
+
export class CommandService {
|
|
9
|
+
/**
|
|
10
|
+
* @param {import('./contributions.js').ContributionRegistry} contributions
|
|
11
|
+
* @param {import('./context.js').ContextKeyService} context
|
|
12
|
+
* @param {import('./notifications.js').NotificationService} notifications
|
|
13
|
+
*/
|
|
14
|
+
constructor(contributions, context, notifications) {
|
|
15
|
+
this.contributions = contributions;
|
|
16
|
+
this.context = context;
|
|
17
|
+
this.notifications = notifications;
|
|
18
|
+
this.handlers = new Map(); // id -> fn
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register a command. Accepts either register(id, handler) or a full spec
|
|
23
|
+
* object { id, title, handler, category, icon, when, palette }.
|
|
24
|
+
*/
|
|
25
|
+
register(idOrSpec, handler) {
|
|
26
|
+
const spec = typeof idOrSpec === 'string' ? { id: idOrSpec, handler } : idOrSpec;
|
|
27
|
+
if (spec.handler) this.handlers.set(spec.id, spec.handler);
|
|
28
|
+
const dispose = this.contributions.register(spec.id, {
|
|
29
|
+
type: 'command',
|
|
30
|
+
title: spec.title ?? spec.id,
|
|
31
|
+
category: spec.category,
|
|
32
|
+
icon: spec.icon,
|
|
33
|
+
when: spec.when,
|
|
34
|
+
palette: spec.palette ?? true,
|
|
35
|
+
// Provenance + offline capability, for availability filtering.
|
|
36
|
+
pluginId: spec.pluginId,
|
|
37
|
+
offline: spec.offline,
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
this.handlers.delete(spec.id);
|
|
41
|
+
dispose();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
has(id) {
|
|
46
|
+
return this.handlers.has(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
isEnabled(id) {
|
|
50
|
+
const cmd = this.contributions.get(id);
|
|
51
|
+
if (cmd?.when && !this.context.evaluate(cmd.when)) return false;
|
|
52
|
+
return this.isAvailable(cmd);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A command is available unless it's a plugin command that's currently offline/dead. */
|
|
56
|
+
isAvailable(cmd) {
|
|
57
|
+
if (!cmd) return true;
|
|
58
|
+
return this.availability ? this.availability(cmd) : true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async execute(id, ...args) {
|
|
62
|
+
const handler = this.handlers.get(id);
|
|
63
|
+
if (!handler) {
|
|
64
|
+
this.notifications.error(`Command not found: ${id}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const cmd = this.contributions.get(id);
|
|
68
|
+
if (!this.isAvailable(cmd)) {
|
|
69
|
+
this.notifications.warn(`“${cmd?.title || id}” isn’t available${this.availability ? ' offline' : ''} right now.`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!this.isEnabled(id)) return; // gated by when-clause
|
|
73
|
+
try {
|
|
74
|
+
return await handler(...args);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`Command ${id} failed`, err);
|
|
77
|
+
this.notifications.error(err?.message || `Command failed: ${id}`);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Commands eligible for the palette right now (respecting when-clauses). */
|
|
83
|
+
paletteCommands() {
|
|
84
|
+
return this.contributions
|
|
85
|
+
.ofType('command')
|
|
86
|
+
.filter((c) => c.palette !== false && (!c.when || this.context.evaluate(c.when)))
|
|
87
|
+
.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title));
|
|
88
|
+
}
|
|
89
|
+
}
|