@3sln/trove 0.0.7 → 0.0.9

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 (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -0,0 +1,71 @@
1
+ // Ranking what someone typed against a list of things.
2
+ //
3
+ // There were two of these, for one question. The command palette scored per matched
4
+ // character and added 10 for a contiguous substring; the launcher's `!` mode returned
5
+ // `1000 - index` for a substring and a flat 1 otherwise. So typing the same three letters
6
+ // ranked the same commands differently depending on which way you had reached them — and
7
+ // the launcher's version could not distinguish two commands that both merely contained the
8
+ // letters, because every non-substring match scored exactly 1.
9
+ //
10
+ // Neither was wrong so much as unaware of the other. Ranking is a decision about the
11
+ // drive's own vocabulary, so it belongs here, once.
12
+
13
+ /**
14
+ * How well `hay` matches `term`. 0 means no match at all.
15
+ *
16
+ * Subsequence first — the letters must appear in order, which is what makes "cmp" find
17
+ * "Command Palette". Then three things push a better match up:
18
+ *
19
+ * - a contiguous substring beats scattered letters, by a lot
20
+ * - an earlier hit beats a later one, so typing "set" prefers "Settings" to "Reset"
21
+ * - adjacent letters beat gaps, which is the difference between "op" matching "Open" and
22
+ * matching "Only Photographs"
23
+ *
24
+ * @param {string} hay already lowercased
25
+ * @param {string} term already lowercased and trimmed
26
+ */
27
+ export function matchScore(hay, term) {
28
+ if (!term) return 1;
29
+ let score = 0;
30
+ let from = 0;
31
+ for (const ch of term) {
32
+ const at = hay.indexOf(ch, from);
33
+ if (at < 0) return 0; // not a subsequence — not a match
34
+ score += at === from ? 3 : 1; // adjacency is worth more than mere presence
35
+ from = at + 1;
36
+ }
37
+ const sub = hay.indexOf(term);
38
+ if (sub >= 0) {
39
+ // A contiguous run is the strongest signal there is, and the earlier it starts the
40
+ // more likely it is what was meant.
41
+ score += 100 - Math.min(50, sub);
42
+ }
43
+ return score;
44
+ }
45
+
46
+ /**
47
+ * Commands matching `term`, best first.
48
+ *
49
+ * The haystack is "category title" because someone typing "view" means the View category
50
+ * as readily as they mean a command with View in its name.
51
+ *
52
+ * @param {Array<{id: string, title: string, category?: string}>} commands
53
+ * @param {string} term what was typed, without any leading `!`
54
+ * @param {number} [limit]
55
+ */
56
+ export function rankCommands(commands, term, limit = 60) {
57
+ const q = (term || '').trim().toLowerCase();
58
+ const all = commands || [];
59
+ if (!q) return all.slice(0, limit);
60
+ const scored = [];
61
+ for (const c of all) {
62
+ const s = matchScore(`${c.category || ''} ${c.title}`.toLowerCase(), q);
63
+ if (s > 0) scored.push([s, c]);
64
+ }
65
+ // Ties break by title, so a list that cannot decide is at least stable rather than
66
+ // ordered by whatever registered first.
67
+ return scored
68
+ .sort((a, b) => b[0] - a[0] || String(a[1].title).localeCompare(String(b[1].title)))
69
+ .slice(0, limit)
70
+ .map(([, c]) => c);
71
+ }
@@ -0,0 +1,39 @@
1
+ // The `@someone` syntax in a comment body.
2
+ //
3
+ // A sibling of bl/tagQuery.js, which parses the `#tag` syntax — and it sat in the social
4
+ // COMPONENT instead, a regex splitting a text format mid-render. Parsing the drive's own
5
+ // notation is business-layer work wherever it happens to be displayed, and having half of
6
+ // it here and half of it in a view is how the two end up disagreeing about what a name
7
+ // may contain.
8
+ //
9
+ // Two spellings, because they arrive from different places. The resolved form
10
+ // `@[Display Name](user-id)` is what the server writes once it knows who was meant; the
11
+ // bare form `@handle` is what someone types before anything has resolved it.
12
+
13
+ const MENTION = /@\[([^\]]+)\]\(([^)]+)\)|(?:^|\s)@([a-zA-Z0-9._@-]{2,})/g;
14
+
15
+ /**
16
+ * Split a comment body into runs of plain text and mentions.
17
+ *
18
+ * @param {string} body
19
+ * @returns {Array<{type: 'text', value: string} | {type: 'mention', name: string, id: string|null}>}
20
+ */
21
+ export function parseMentions(body) {
22
+ const text = body || '';
23
+ const out = [];
24
+ const re = new RegExp(MENTION.source, 'g'); // own lastIndex — the module-level one is shared
25
+ let last = 0;
26
+ let m;
27
+ while ((m = re.exec(text))) {
28
+ // The bare form's match deliberately includes the space in front of the `@`, so that
29
+ // "a@b" is an address rather than a mention. That leading space belongs to the text
30
+ // run, not to the mention.
31
+ const bare = !!m[3];
32
+ const start = m.index + (bare && m[0].startsWith(' ') ? 1 : 0);
33
+ if (start > last) out.push({ type: 'text', value: text.slice(last, start) });
34
+ out.push({ type: 'mention', name: m[1] || m[3], id: m[2] || null });
35
+ last = re.lastIndex;
36
+ }
37
+ if (last < text.length) out.push({ type: 'text', value: text.slice(last) });
38
+ return out;
39
+ }
@@ -69,10 +69,6 @@ export class OfflineService {
69
69
  await this.platform.plugins?.setOnline?.(false);
70
70
  }
71
71
 
72
- isPinned(id) {
73
- return this.state.pins.some((p) => p.id === id);
74
- }
75
-
76
72
  // --- pinning ---------------------------------------------------------------
77
73
 
78
74
  async pin(node) {
@@ -7,12 +7,49 @@
7
7
  // Preferences live in settings under `openers.associations`: a map from a *type key*
8
8
  // (an extension like ".pdf", an exact mime, or a "type/*" wildcard) to an opener id.
9
9
 
10
+ import { selectorMatches } from '@3sln/trove/core/util.js';
10
11
  import { extOf } from './fileType.js';
11
12
 
12
- const ASSOC_KEY = 'openers.associations';
13
+ export const ASSOC_KEY = 'openers.associations';
13
14
 
14
15
  export { extOf };
15
16
 
17
+ /**
18
+ * One opener as the chooser and the switcher need to see it: what it is called, what it
19
+ * claims, and where it came from.
20
+ *
21
+ * `source` is resolved here because it needs the plugin host to answer, which a component
22
+ * has no business holding. `match` stays so the selector can be applied later, by
23
+ * `openersFor`, against a node the caller already has.
24
+ *
25
+ * No `component`, only because nothing on this path renders — both callers are picking
26
+ * between openers, not drawing with one. (A renderer would be fine to pass: it is a pure
27
+ * vnode builder. See the `views` query, which does exactly that.)
28
+ */
29
+ export function describeOpener(opener, plugins) {
30
+ return {
31
+ id: opener.id,
32
+ title: opener.title ?? opener.name ?? opener.id,
33
+ match: opener.match ?? null,
34
+ priority: opener.priority ?? 0,
35
+ pluginId: opener.pluginId ?? null,
36
+ source: opener.pluginId
37
+ ? (plugins?.plugins?.get(opener.pluginId)?.manifest?.displayName || opener.pluginId)
38
+ : 'Built-in',
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Which of these openers claim this node, best first.
44
+ *
45
+ * Pure, and deliberately so — see the `openers` query. Every reactive decision (when-clause,
46
+ * plugin health, priority order) has already been made; all that is left is running a
47
+ * selector over a file, which needs nothing but its two arguments.
48
+ */
49
+ export function openersFor(openers, node) {
50
+ return (openers || []).filter((o) => selectorMatches(o.match, node));
51
+ }
52
+
16
53
  /** The canonical type key we remember a choice under: prefer extension, else mime. */
17
54
  export function typeKeyFor(node) {
18
55
  return extOf(node) || node?.contentType || '';
@@ -26,46 +63,41 @@ export function typeLabelFor(node) {
26
63
  return mime ? `${mime} files` : 'this file type';
27
64
  }
28
65
 
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
66
+ /**
67
+ * All openers that can handle this node right now (selector + when + availability),
68
+ * best (highest priority) first — for an ACTION, which holds the resources to ask.
69
+ *
70
+ * The render layer gets the same answer the other way round: the `openers` query resolves
71
+ * when/availability, `openersFor` applies the selector. Both end at `describeOpener`, so
72
+ * the opener-chooser dialog looks the same whichever side opened it.
73
+ */
74
+ export function availableOpeners(r, node) {
75
+ return r.contributions
35
76
  .openersFor(node)
36
- .filter((o) => (!o.when || evaluate(o.when)) && isAvailable(o))
37
- .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
77
+ .filter((o) => (!o.when || r.context.evaluate(o.when)) && r.plugins.isAvailable(o))
78
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
79
+ .map((o) => describeOpener(o, r.plugins));
38
80
  }
39
81
 
40
82
  /** 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) || {};
83
+ export function rememberedOpenerId(r, node) {
84
+ const assoc = r.settings.get(ASSOC_KEY) || {};
43
85
  const ext = extOf(node);
44
86
  const mime = node?.contentType || '';
45
87
  const wild = mime.includes('/') ? `${mime.slice(0, mime.indexOf('/') + 1)}*` : '';
46
88
  return (ext && assoc[ext]) || (mime && assoc[mime]) || (wild && assoc[wild]) || null;
47
89
  }
48
90
 
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;
91
+ /**
92
+ * The associations with one type key changed, or removed with a null openerId.
93
+ *
94
+ * Returns the next map rather than writing it: the write is `RememberOpenerAction`'s, and
95
+ * keeping the arithmetic separate from the effect is what lets both the settings screen and
96
+ * the opener chooser reach it without either touching `settings`.
97
+ */
98
+ export function withAssociation(assoc, typeKey, openerId) {
99
+ const next = { ...(assoc || {}) };
100
+ if (openerId) next[typeKey] = openerId;
101
+ else delete next[typeKey];
102
+ return next;
71
103
  }
@@ -1,59 +1,80 @@
1
1
  // Install flow: turn an uploaded .zip or a URL into a parsed package, assess its
2
2
  // trust (signature + domain verification), and present the pre-install review so
3
3
  // the user can make an informed decision before any plugin code runs.
4
+ //
5
+ // Takes the four resources it actually needs rather than the whole `app`. That is what let
6
+ // the last two actions stop leasing `app` — and a lease that names notifications, plugins,
7
+ // workbench and social says what an install touches, where `app` said nothing at all.
4
8
 
5
9
  import { parsePackage, fetchPackage, reviewSummary, displayName } from '../platform/pluginPackage.js';
6
10
  import { pluginId } from '@3sln/trove/core/plugins/identity.js';
11
+ import { installPolicyFor } from './trust.js';
7
12
 
8
- export async function beginInstallFromFile(app, file) {
13
+ /** @typedef {{notifications: object, overlay: object, plugins: object, social: object, workbench: object}} InstallResources */
14
+
15
+ /** @param {InstallResources} r */
16
+ export async function beginInstallFromFile(r, file) {
9
17
  try {
10
18
  const bytes = new Uint8Array(await file.arrayBuffer());
11
- await review(app, parsePackage(bytes));
19
+ await review(r, parsePackage(bytes));
12
20
  } catch (err) {
13
- app.platform.notifications.error(`Couldn't read the plugin: ${err.message}`);
21
+ r.notifications.error(`Couldn't read the plugin: ${err.message}`);
14
22
  }
15
23
  }
16
24
 
17
- export async function beginInstallFromUrl(app, url) {
25
+ /** @param {InstallResources} r */
26
+ export async function beginInstallFromUrl(r, url) {
18
27
  try {
19
28
  const pkg = await fetchPackage(url, (u, o) => fetch(u, o));
20
- await review(app, pkg);
29
+ await review(r, pkg);
21
30
  } catch (err) {
22
- app.platform.notifications.error(`Couldn't fetch the plugin: ${err.message}`);
31
+ r.notifications.error(`Couldn't fetch the plugin: ${err.message}`);
23
32
  }
24
33
  }
25
34
 
26
- async function review(app, pkg) {
35
+ async function review(r, pkg) {
27
36
  const label = displayName(pkg.manifest);
28
- if (app.platform.plugins.plugins.has(pluginId(pkg.manifest))) {
29
- app.platform.notifications.warn(`“${label}” is already installed.`);
37
+ if (r.plugins.plugins.has(pluginId(pkg.manifest))) {
38
+ r.notifications.warn(`“${label}” is already installed.`);
30
39
  return;
31
40
  }
32
41
  // Trust assessment can hit the network (domain assetlinks); do it before review.
33
42
  let trust = { status: 'unverified' };
34
43
  try {
35
- trust = await app.platform.plugins.assessTrust(pkg);
44
+ trust = await r.plugins.assessTrust(pkg);
36
45
  } catch { /* offline / unreachable */ }
46
+
47
+ // A tampered package is refused here rather than presented with a warning. The review
48
+ // dialog exists to let someone weigh what a plugin asks for against who is asking; a
49
+ // signature that does not match its contents means the "who" is unknown and the contents
50
+ // are not what was signed, so there is nothing to weigh. See installPolicyFor.
51
+ const policy = installPolicyFor(trust);
52
+ if (!policy.allowed) {
53
+ r.notifications.error(`“${label}” was not installed. ${policy.detail}`, { sticky: true });
54
+ return;
55
+ }
56
+
37
57
  const summary = reviewSummary(pkg, trust);
38
- app.platform.workbench.showDialog({
58
+ r.overlay.set({ dialog: {
39
59
  kind: 'plugin-review',
40
60
  summary,
41
- isAdmin: !!app.social.state.admin,
61
+ policy,
62
+ isAdmin: !!r.social.state.admin,
42
63
  onInstall: async (grants) => {
43
- app.platform.workbench.closeDialog();
64
+ r.overlay.set({ dialog: null });
44
65
  // Account installs upload the package + run a handshake that can take a while —
45
66
  // switch to the plugins view (which shows the plugin's loading state) and hold a
46
67
  // 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 });
68
+ r.workbench.set({ activity: 'plugins', sidebarVisible: true });
69
+ const pending = r.notifications.info(`Installing “${label}”…`, { sticky: true });
49
70
  try {
50
- await app.platform.plugins.install(pkg, { grants, trust });
51
- app.platform.notifications.dismiss(pending);
52
- app.platform.notifications.success(`Installed “${label}”`);
71
+ await r.plugins.install(pkg, { grants, trust });
72
+ r.notifications.dismiss(pending);
73
+ r.notifications.success(`Installed “${label}”`);
53
74
  } catch (err) {
54
- app.platform.notifications.dismiss(pending);
55
- app.platform.notifications.error(`Install failed: ${err.message}`);
75
+ r.notifications.dismiss(pending);
76
+ r.notifications.error(`Install failed: ${err.message}`);
56
77
  }
57
78
  },
58
- });
79
+ } });
59
80
  }