@tiagojct/subsub 0.5.0 → 0.6.0

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/docs/Subsub.md CHANGED
@@ -52,6 +52,17 @@ Templates (type the name, then the argument):
52
52
  - Librarian: `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
53
53
  - Researcher: `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
54
54
 
55
+ ## Web view
56
+
57
+ 1. Type `subsub web`. Sub-Sub opens in the browser, with the same modes, profiles, tools and approvals. The approval dialog shows the server preview; the change runs only after Yes.
58
+ 2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
59
+ 3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
60
+ 4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
61
+ 5. The labels are in European Portuguese when the language setting is Portuguese.
62
+ 6. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
63
+
64
+ The log is in ~/.subsub/web.log.
65
+
55
66
  ## Profiles
56
67
 
57
68
  The profile sets how much Sub-Sub does. To see the profile, type `/profile`. To change it, type `/profile reader` (or scholar, author, editor). The change is saved.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.5.0",
4
- "description": "Sub-Sub: a Zotero librarian and research assistant in the terminal, built on pi",
3
+ "version": "0.6.0",
4
+ "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Tiago Jacinto",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "files": [
46
46
  "README.md",
47
+ "assets",
47
48
  "bin",
48
49
  "dist",
49
50
  "docs",
@@ -53,7 +54,8 @@
53
54
  "scripts",
54
55
  "src",
55
56
  "templates",
56
- "themes"
57
+ "themes",
58
+ "web"
57
59
  ],
58
60
  "devDependencies": {
59
61
  "@types/node": "^24.19.0",
package/src/cli.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  * - `subsub review preview|apply NOTE...` runs zotero-review (tag review notes,
13
13
  * several at a time) with Sub-Sub's server folder and settings.
14
14
  * - `subsub init` sets Sub-Sub up; `subsub doctor` checks the setup.
15
+ * - `subsub web` (or `subsub serve`) opens Sub-Sub in the browser;
16
+ * `subsub shortcut` adds a Sub-Sub shortcut that does the same.
15
17
  */
16
18
 
17
19
  import { spawnSync } from "node:child_process";
@@ -148,6 +150,16 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
148
150
  process.exitCode = await initMain(args.slice(1), env);
149
151
  return;
150
152
  }
153
+ if (args[0] === "web" || args[0] === "serve") {
154
+ const { webMain } = await import("./web.ts");
155
+ process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
156
+ return;
157
+ }
158
+ if (args[0] === "shortcut") {
159
+ const { shortcutMain } = await import("./shortcut.ts");
160
+ process.exitCode = shortcutMain(args.slice(1), env);
161
+ return;
162
+ }
151
163
  if (args[0] === "doctor") {
152
164
  const { doctorMain } = await import("./doctor.ts");
153
165
  process.exitCode = await doctorMain(env);
package/src/init.ts CHANGED
@@ -184,8 +184,8 @@ export async function runInit(
184
184
  ? "Zotero: reachable."
185
185
  : "Zotero: not reachable. Start Zotero 10, then in Zotero open Settings > Advanced and turn on \"Allow other applications on this computer to communicate with Zotero\".",
186
186
  );
187
- if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, choose a model provider with /login.");
188
- io.say("Next: type subsub. In Sub-Sub, type /login to connect a model provider. To check the setup, type subsub doctor.");
187
+ if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
188
+ io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
189
189
  return { configFile, envFile, notes, created, zotero };
190
190
  }
191
191
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `subsub shortcut`: a Sub-Sub shortcut that opens the web view, so students
3
+ * can start Sub-Sub without a terminal.
4
+ *
5
+ * - macOS: ~/Applications/Sub-Sub.app (a small app bundle that runs `subsub web`;
6
+ * no Dock icon, it stops by itself when the page has been closed for a while).
7
+ * - Windows: Sub-Sub in the Start menu and on the desktop.
8
+ * - Linux: ~/.local/share/applications/sub-sub.desktop.
9
+ *
10
+ * The shortcut runs this copy of Sub-Sub with this Node.js (absolute paths), so
11
+ * it works without PATH. Run `subsub shortcut` again after moving either.
12
+ * `subsub shortcut --remove` removes what this command created.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { PACKAGE_DIR, packageVersion } from "./cli.ts";
20
+
21
+ export interface ShortcutPlan {
22
+ platform: NodeJS.Platform;
23
+ node: string;
24
+ bin: string;
25
+ home: string;
26
+ packageDir: string;
27
+ desktop: boolean;
28
+ }
29
+
30
+ export function planFor(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, desktop = true): ShortcutPlan {
31
+ const home = platform === "win32" ? (env.USERPROFILE ?? homedir()) : (env.HOME ?? homedir());
32
+ return { platform, node: process.execPath, bin: join(PACKAGE_DIR, "bin", "subsub.js"), home, packageDir: PACKAGE_DIR, desktop };
33
+ }
34
+
35
+ /** Single-quote a string for sh. */
36
+ export function shQuote(s: string): string {
37
+ return `'${s.replace(/'/g, `'\\''`)}'`;
38
+ }
39
+
40
+ /** Quote an argument for the Exec line of a .desktop file. */
41
+ export function desktopQuote(s: string): string {
42
+ return `"${s.replace(/(["`$\\])/g, "\\$1")}"`;
43
+ }
44
+
45
+ /** Single-quote a string for PowerShell. */
46
+ export function psQuote(s: string): string {
47
+ return `'${s.replace(/'/g, "''")}'`;
48
+ }
49
+
50
+ export function macScript(p: ShortcutPlan): string {
51
+ return [
52
+ "#!/bin/sh",
53
+ "# Opens Sub-Sub in the browser. Written by: subsub shortcut",
54
+ 'cd "$HOME" || exit 1',
55
+ `exec ${shQuote(p.node)} ${shQuote(p.bin)} web >/dev/null 2>&1`,
56
+ "",
57
+ ].join("\n");
58
+ }
59
+
60
+ export function macPlist(version: string): string {
61
+ return `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <plist version="1.0">
64
+ <dict>
65
+ <key>CFBundleName</key><string>Sub-Sub</string>
66
+ <key>CFBundleDisplayName</key><string>Sub-Sub</string>
67
+ <key>CFBundleIdentifier</key><string>eu.tiagojacinto.subsub</string>
68
+ <key>CFBundleExecutable</key><string>Sub-Sub</string>
69
+ <key>CFBundleIconFile</key><string>icon</string>
70
+ <key>CFBundlePackageType</key><string>APPL</string>
71
+ <key>CFBundleShortVersionString</key><string>${version}</string>
72
+ <key>CFBundleVersion</key><string>${version}</string>
73
+ <key>LSUIElement</key><true/>
74
+ </dict>
75
+ </plist>
76
+ `;
77
+ }
78
+
79
+ export function desktopEntry(p: ShortcutPlan): string {
80
+ return [
81
+ "[Desktop Entry]",
82
+ "Type=Application",
83
+ "Name=Sub-Sub",
84
+ "Comment=Zotero librarian and research assistant",
85
+ `Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
86
+ `Path=${p.home}`,
87
+ `Icon=${join(p.packageDir, "assets", "icon.png")}`,
88
+ "Terminal=false",
89
+ "Categories=Education;Office;",
90
+ "",
91
+ ].join("\n");
92
+ }
93
+
94
+ export function windowsScript(p: ShortcutPlan): string {
95
+ const lines = [
96
+ "$ErrorActionPreference = 'Stop'",
97
+ "$shell = New-Object -ComObject WScript.Shell",
98
+ "$places = @([Environment]::GetFolderPath('Programs'))",
99
+ ...(p.desktop ? ["$places += [Environment]::GetFolderPath('Desktop')"] : []),
100
+ "foreach ($dir in $places) {",
101
+ " $s = $shell.CreateShortcut((Join-Path $dir 'Sub-Sub.lnk'))",
102
+ ` $s.TargetPath = ${psQuote(p.node)}`,
103
+ ` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
104
+ ` $s.WorkingDirectory = ${psQuote(p.home)}`,
105
+ ` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
106
+ " $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
107
+ " $s.WindowStyle = 7",
108
+ " $s.Save()",
109
+ " Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
110
+ "}",
111
+ ];
112
+ return lines.join("\n");
113
+ }
114
+
115
+ function runPowerShell(script: string): { ok: boolean; out: string } {
116
+ const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", windowsHide: true });
117
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error ? r.error.message : ""}`.trim() };
118
+ }
119
+
120
+ export function createShortcuts(p: ShortcutPlan): string[] {
121
+ if (p.platform === "darwin") {
122
+ const app = join(p.home, "Applications", "Sub-Sub.app");
123
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
124
+ mkdirSync(join(app, "Contents", "Resources"), { recursive: true });
125
+ writeFileSync(join(app, "Contents", "Info.plist"), macPlist(packageVersion(p.packageDir)));
126
+ const exe = join(app, "Contents", "MacOS", "Sub-Sub");
127
+ writeFileSync(exe, macScript(p));
128
+ chmodSync(exe, 0o755);
129
+ const icns = join(p.packageDir, "assets", "icon.icns");
130
+ if (existsSync(icns)) copyFileSync(icns, join(app, "Contents", "Resources", "icon.icns"));
131
+ return [app];
132
+ }
133
+ if (p.platform === "win32") {
134
+ const r = runPowerShell(windowsScript(p));
135
+ if (!r.ok) throw new Error(`could not create the shortcut: ${r.out}`);
136
+ return r.out.split(/\r?\n/).filter(Boolean);
137
+ }
138
+ const dir = join(p.home, ".local", "share", "applications");
139
+ mkdirSync(dir, { recursive: true });
140
+ const file = join(dir, "sub-sub.desktop");
141
+ writeFileSync(file, desktopEntry(p));
142
+ chmodSync(file, 0o755);
143
+ return [file];
144
+ }
145
+
146
+ export function removeShortcuts(p: ShortcutPlan): string[] {
147
+ if (p.platform === "win32") {
148
+ const script = [
149
+ "foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) {",
150
+ " $f = Join-Path $dir 'Sub-Sub.lnk'",
151
+ " if (Test-Path $f) { Remove-Item $f; Write-Output $f }",
152
+ "}",
153
+ ].join("\n");
154
+ const r = runPowerShell(script);
155
+ return r.out.split(/\r?\n/).filter(Boolean);
156
+ }
157
+ const target = p.platform === "darwin" ? join(p.home, "Applications", "Sub-Sub.app") : join(p.home, ".local", "share", "applications", "sub-sub.desktop");
158
+ if (!existsSync(target)) return [];
159
+ rmSync(target, { recursive: true, force: true });
160
+ return [target];
161
+ }
162
+
163
+ export function shortcutMain(args: string[], env: NodeJS.ProcessEnv = process.env): number {
164
+ const plan = planFor(env, process.platform, !args.includes("--no-desktop"));
165
+ try {
166
+ if (args.includes("--remove")) {
167
+ const gone = removeShortcuts(plan);
168
+ console.log(gone.length ? `Removed:\n${gone.join("\n")}` : "No Sub-Sub shortcut found.");
169
+ return 0;
170
+ }
171
+ const made = createShortcuts(plan);
172
+ const where =
173
+ plan.platform === "darwin"
174
+ ? "Open Sub-Sub from Applications, Launchpad or Spotlight."
175
+ : plan.platform === "win32"
176
+ ? "Open Sub-Sub from the Start menu or the desktop."
177
+ : "Open Sub-Sub from your applications menu.";
178
+ console.log(`Shortcut:\n${made.join("\n")}\n${where} It opens Sub-Sub in your browser.`);
179
+ return 0;
180
+ } catch (err) {
181
+ console.error(`Sub-Sub: ${(err as Error).message}`);
182
+ return 1;
183
+ }
184
+ }
package/src/subsub.ts CHANGED
@@ -108,6 +108,30 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
108
108
  const library: LibraryState = { zotero: "checking" };
109
109
  const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
110
110
  const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
111
+ /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
112
+ const webView = process.env.SUBSUB_WEB === "1";
113
+ let webCtx: ExtensionContext | undefined;
114
+ function emitWeb(): void {
115
+ const ctx = webCtx;
116
+ if (!webView || !ctx || ctx.mode !== "rpc") return;
117
+ const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
118
+ const state = {
119
+ mode,
120
+ profile,
121
+ profileLabel: profileSpec(profile).label,
122
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
123
+ down,
124
+ library,
125
+ language: cfg.language,
126
+ userName: cfg.userName ?? null,
127
+ vault: cfg.vault ?? null,
128
+ };
129
+ try {
130
+ ctx.ui.setStatus("subsub.web", JSON.stringify(state));
131
+ } catch {
132
+ /* the context is gone after shutdown */
133
+ }
134
+ }
111
135
 
112
136
  function applyTheme(ctx: ExtensionContext): void {
113
137
  if (!lookOn(ctx) || cfg.themes === false) return;
@@ -145,6 +169,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
145
169
  library.zotero = "down";
146
170
  }
147
171
  headerTui?.requestRender();
172
+ emitWeb();
148
173
  }
149
174
 
150
175
  function setHeader(ctx: ExtensionContext): void {
@@ -183,6 +208,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
183
208
  ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
184
209
  if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
185
210
  headerTui?.requestRender();
211
+ webCtx = ctx;
212
+ emitWeb();
186
213
  }
187
214
 
188
215
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
@@ -218,6 +245,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
218
245
  if (lookOn(ctx)) {
219
246
  setHeader(ctx);
220
247
  void refreshLibrary();
248
+ } else if (webView) {
249
+ void refreshLibrary();
221
250
  }
222
251
  for (const [name, err] of Object.entries(bridge.errors)) {
223
252
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
@@ -382,10 +411,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
382
411
  }
383
412
  }
384
413
  headerTui?.requestRender();
414
+ emitWeb();
385
415
  ctx.ui.notify(lines.join("\n"), "info");
386
416
  },
387
417
  });
388
418
 
419
+ pi.registerCommand("subsub-refresh", {
420
+ description: "Sub-Sub: refresh the library counts shown in the web view",
421
+ handler: async (_args, ctx) => {
422
+ webCtx = ctx;
423
+ await refreshLibrary();
424
+ },
425
+ });
426
+
389
427
  pi.registerCommand("history", {
390
428
  description: "Sub-Sub: recent library changes",
391
429
  handler: async (_args, ctx) => {