@tiagojct/subsub 0.5.0 → 0.6.1
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 +17 -2
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/dist/cli.js +12 -0
- package/dist/config.js +22 -17
- package/dist/init.js +11 -10
- package/dist/shortcut.js +165 -0
- package/dist/subsub.js +40 -0
- package/dist/web.js +702 -0
- package/docs/Subsub.md +13 -0
- package/package.json +5 -3
- package/src/cli.ts +12 -0
- package/src/config.ts +22 -17
- package/src/init.ts +11 -9
- package/src/shortcut.ts +184 -0
- package/src/subsub.ts +38 -0
- package/src/web.ts +710 -0
- package/web/app.css +256 -0
- package/web/app.js +1033 -0
- package/web/fonts/IBMPlexMono.woff2 +0 -0
- package/web/fonts/IBMPlexSans.woff2 +0 -0
- package/web/fonts/IBMPlexSerif.woff2 +0 -0
- package/web/glauca.css +106 -0
- package/web/i18n.js +287 -0
- package/web/icon.svg +1 -0
- package/web/index.html +122 -0
- package/web/vendor/marked.esm.js +78 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sub-Sub
|
|
2
2
|
|
|
3
|
-
A Zotero librarian and research assistant that runs in your terminal. It is built on the [pi](https://pi.dev) agent and works with your own Zotero library through Zotero's local API.
|
|
3
|
+
A Zotero librarian and research assistant that runs on your computer, in your browser or in your terminal. It is built on the [pi](https://pi.dev) agent and works with your own Zotero library through Zotero's local API.
|
|
4
4
|
|
|
5
5
|
> "This mere painstaking burrower and grub-worm of a poor devil of a Sub-Sub appears to have gone through the long Vaticans and street-stalls of the earth, picking up whatever random allusions to whales he could anyways find in any book whatsoever." (Moby-Dick, Extracts)
|
|
6
6
|
|
|
@@ -34,10 +34,13 @@ A profile is your own choice, not a lock.
|
|
|
34
34
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
37
|
+
The installers on [subsub.tiagojacinto.eu](https://subsub.tiagojacinto.eu/install/) do all of this, including Node.js and uv. By hand:
|
|
38
|
+
|
|
37
39
|
1. Type `npm install -g @tiagojct/subsub`. The command is `subsub`.
|
|
38
40
|
2. Type `subsub init`. Answer the questions. Press Enter to keep a default.
|
|
39
41
|
3. Type `subsub doctor`. Fix each line marked FIX.
|
|
40
|
-
4. Type `subsub
|
|
42
|
+
4. Type `subsub shortcut` to add a Sub-Sub shortcut (Applications on macOS, the Start menu and the desktop on Windows, the applications menu on Linux).
|
|
43
|
+
5. Open Sub-Sub with the shortcut, or type `subsub web`. Select the model button and connect a provider with an API key (or, in the terminal, type `subsub`, then `/login`).
|
|
41
44
|
|
|
42
45
|
`subsub init` creates a notes folder with Inbox/ and Systems/, a starter tag list (health sciences, health informatics, or any field) and a file with the note formats (Systems/Zotero agent.md). You can edit both files. It never replaces a file that exists.
|
|
43
46
|
|
|
@@ -45,6 +48,18 @@ If you already use pi, you can also add Sub-Sub to plain pi: `pi install npm:@ti
|
|
|
45
48
|
|
|
46
49
|
The Zotero servers (zotero-local-mcp) run from PyPI through uv. To run them from a copy of the repository, add `"serverDir": "/path/to/zotero-local-mcp"` to the settings file.
|
|
47
50
|
|
|
51
|
+
## Web view
|
|
52
|
+
|
|
53
|
+
`subsub web` (or `subsub serve`) opens Sub-Sub in your browser. It has the same modes, profiles, tools and approvals as the terminal:
|
|
54
|
+
|
|
55
|
+
- pi runs in RPC mode with the Sub-Sub extension. Sub-Sub's approval dialog (the server preview) appears in the page, and the change runs only after you select Yes.
|
|
56
|
+
- The server listens only on 127.0.0.1. The address that opens the page carries a random key, which becomes an HttpOnly, SameSite=Strict cookie; other requests need that cookie, the right Host header and a same-origin JSON request. The page has a strict content security policy and shows model output without raw HTML.
|
|
57
|
+
- One server per user: a second `subsub web` opens the running one. Without an open page for 10 minutes (and with nothing waiting for approval), the server stops. `--stay` keeps it running; `--idle N` sets the minutes; `--no-open` does not open a browser; `--librarian` starts in the librarian mode.
|
|
58
|
+
- Labels are in European Portuguese when the language setting is Portuguese, otherwise in English. A theme button in the header toggles light, dark, and system themes.
|
|
59
|
+
- Fast approval shortcuts (`y` to apply, `n` to decline), one-click copy buttons on code blocks and notes, conversation search, and export to Markdown.
|
|
60
|
+
- The model button lists the models you can use and saves an API key for a provider in the same file as `/login`. Sign-ins through a provider's website stay in the terminal (`/login`).
|
|
61
|
+
- A log is kept in `~/.subsub/web.log`.
|
|
62
|
+
|
|
48
63
|
## Use
|
|
49
64
|
|
|
50
65
|
- `subsub` starts in the researcher mode. `subsub --librarian` starts in the librarian mode. `/librarian` and `/researcher` change the mode.
|
package/assets/icon.icns
ADDED
|
Binary file
|
package/assets/icon.ico
ADDED
|
Binary file
|
package/assets/icon.png
ADDED
|
Binary file
|
package/dist/cli.js
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
|
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
17
19
|
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
@@ -156,6 +158,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
156
158
|
process.exitCode = await initMain(args.slice(1), env);
|
|
157
159
|
return;
|
|
158
160
|
}
|
|
161
|
+
if (args[0] === "web" || args[0] === "serve") {
|
|
162
|
+
const { webMain } = await import("./web.js");
|
|
163
|
+
process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (args[0] === "shortcut") {
|
|
167
|
+
const { shortcutMain } = await import("./shortcut.js");
|
|
168
|
+
process.exitCode = shortcutMain(args.slice(1), env);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
159
171
|
if (args[0] === "doctor") {
|
|
160
172
|
const { doctorMain } = await import("./doctor.js");
|
|
161
173
|
process.exitCode = await doctorMain(env);
|
package/dist/config.js
CHANGED
|
@@ -19,8 +19,9 @@ import { dirname, join, resolve } from "node:path";
|
|
|
19
19
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
20
20
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
21
21
|
export const SERVER_VERSION = "0.4.1";
|
|
22
|
-
export function expand(p) {
|
|
23
|
-
|
|
22
|
+
export function expand(p, home) {
|
|
23
|
+
const base = home ?? process.env.HOME ?? homedir();
|
|
24
|
+
return resolve(p.replace(/^~(?=$|\/)/, base));
|
|
24
25
|
}
|
|
25
26
|
export function parseEnvFile(path) {
|
|
26
27
|
const out = {};
|
|
@@ -41,7 +42,7 @@ export function parseEnvFile(path) {
|
|
|
41
42
|
return out;
|
|
42
43
|
}
|
|
43
44
|
export function configPath(env = process.env) {
|
|
44
|
-
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
|
|
45
|
+
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
|
|
45
46
|
}
|
|
46
47
|
export function readConfigFile(file) {
|
|
47
48
|
if (!existsSync(file))
|
|
@@ -68,6 +69,7 @@ export function isProfile(x) {
|
|
|
68
69
|
return typeof x === "string" && PROFILES.includes(x);
|
|
69
70
|
}
|
|
70
71
|
export function loadConfig(env = process.env) {
|
|
72
|
+
const home = env.HOME;
|
|
71
73
|
const file = configPath(env);
|
|
72
74
|
let user = {};
|
|
73
75
|
if (existsSync(file)) {
|
|
@@ -78,16 +80,18 @@ export function loadConfig(env = process.env) {
|
|
|
78
80
|
throw new Error(`Sub-Sub: cannot read ${file}: ${err.message}`);
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
|
|
84
|
+
const envFile = explicitEnv
|
|
85
|
+
? expand(explicitEnv, home)
|
|
86
|
+
: [
|
|
87
|
+
"~/.config/zotero-local-mcp/env",
|
|
88
|
+
"~/.config/opencode/zotero.env",
|
|
89
|
+
]
|
|
90
|
+
.map((p) => expand(p, home))
|
|
91
|
+
.find((p) => existsSync(p));
|
|
92
|
+
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
89
93
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
90
|
-
const vault = vaultRaw ? expand(vaultRaw) : undefined;
|
|
94
|
+
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
91
95
|
const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
|
|
92
96
|
return {
|
|
93
97
|
configFile: file,
|
|
@@ -96,7 +100,7 @@ export function loadConfig(env = process.env) {
|
|
|
96
100
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
97
101
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
|
98
102
|
setup: user.setup,
|
|
99
|
-
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
|
|
103
|
+
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
100
104
|
envFile,
|
|
101
105
|
vault,
|
|
102
106
|
models: user.models ?? {
|
|
@@ -104,14 +108,14 @@ export function loadConfig(env = process.env) {
|
|
|
104
108
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
105
109
|
},
|
|
106
110
|
defaultMode: mode,
|
|
107
|
-
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
|
|
111
|
+
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
|
|
108
112
|
sharedRules: user.sharedRules
|
|
109
|
-
? expand(user.sharedRules)
|
|
113
|
+
? expand(user.sharedRules, home)
|
|
110
114
|
: vault
|
|
111
115
|
? join(vault, "Systems", "Zotero agent.md")
|
|
112
116
|
: undefined,
|
|
113
117
|
startTimeout: user.startTimeout ?? 45,
|
|
114
|
-
uvPath: user.uvPath ? expand(user.uvPath) : undefined,
|
|
118
|
+
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
|
115
119
|
look: user.look,
|
|
116
120
|
quotes: user.quotes,
|
|
117
121
|
themes: user.themes,
|
|
@@ -119,11 +123,12 @@ export function loadConfig(env = process.env) {
|
|
|
119
123
|
}
|
|
120
124
|
/** uv is often missing from PATH when pi is started outside a login shell. */
|
|
121
125
|
export function findUv(cfg, env = process.env, platform = process.platform) {
|
|
126
|
+
const home = env.HOME;
|
|
122
127
|
const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
|
|
123
128
|
const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
|
|
124
129
|
const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
|
|
125
130
|
.filter((x) => Boolean(x))
|
|
126
|
-
.map(expand);
|
|
131
|
+
.map((p) => expand(p, home));
|
|
127
132
|
return candidates.find((p) => existsSync(p)) ?? "uv";
|
|
128
133
|
}
|
|
129
134
|
/**
|
package/dist/init.js
CHANGED
|
@@ -63,11 +63,12 @@ export async function zoteroReachable(fetchFn = fetch) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
66
|
-
const home = deps.home ?? homedir();
|
|
67
|
-
const
|
|
66
|
+
const home = deps.home ?? env.HOME ?? homedir();
|
|
67
|
+
const initEnv = { ...env, HOME: home };
|
|
68
|
+
const configFile = configPath(initEnv);
|
|
68
69
|
const existing = readConfigFile(configFile);
|
|
69
|
-
const current = loadConfig(
|
|
70
|
-
const envFile = current.envFile ?? expand(
|
|
70
|
+
const current = loadConfig(initEnv);
|
|
71
|
+
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
71
72
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
72
73
|
const created = [];
|
|
73
74
|
io.say(existsSync(configFile) ? `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.` : "Sub-Sub setup. Press Enter to take the value in brackets.");
|
|
@@ -79,10 +80,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
79
80
|
const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
80
81
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
81
82
|
const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
|
|
82
|
-
const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim());
|
|
83
|
+
const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(), home);
|
|
83
84
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
84
85
|
const profile = await io.choose("Profile (change it later with /profile)", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
85
|
-
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
|
|
86
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
|
|
86
87
|
let tags = "keep";
|
|
87
88
|
if (existsSync(vocab)) {
|
|
88
89
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
@@ -96,7 +97,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
96
97
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
97
98
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
98
99
|
];
|
|
99
|
-
const agentDir =
|
|
100
|
+
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
100
101
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
101
102
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
102
103
|
// ---- write
|
|
@@ -132,7 +133,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
132
133
|
patch.models = {};
|
|
133
134
|
if (models === "opencode-go")
|
|
134
135
|
patch.models = TESTED_MODELS;
|
|
135
|
-
saveConfig(patch,
|
|
136
|
+
saveConfig(patch, initEnv);
|
|
136
137
|
const zotero = await zoteroReachable(deps.fetch);
|
|
137
138
|
io.say("");
|
|
138
139
|
io.say(`Saved: ${configFile}`);
|
|
@@ -143,8 +144,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
143
144
|
? "Zotero: reachable."
|
|
144
145
|
: "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\".");
|
|
145
146
|
if (fmup)
|
|
146
|
-
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then,
|
|
147
|
-
io.say("Next: type subsub
|
|
147
|
+
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
148
|
+
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.");
|
|
148
149
|
return { configFile, envFile, notes, created, zotero };
|
|
149
150
|
}
|
|
150
151
|
export function parseFlags(args) {
|
package/dist/shortcut.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
19
|
+
export function planFor(env = process.env, platform = process.platform, desktop = true) {
|
|
20
|
+
const home = platform === "win32" ? (env.USERPROFILE ?? homedir()) : (env.HOME ?? homedir());
|
|
21
|
+
return { platform, node: process.execPath, bin: join(PACKAGE_DIR, "bin", "subsub.js"), home, packageDir: PACKAGE_DIR, desktop };
|
|
22
|
+
}
|
|
23
|
+
/** Single-quote a string for sh. */
|
|
24
|
+
export function shQuote(s) {
|
|
25
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
26
|
+
}
|
|
27
|
+
/** Quote an argument for the Exec line of a .desktop file. */
|
|
28
|
+
export function desktopQuote(s) {
|
|
29
|
+
return `"${s.replace(/(["`$\\])/g, "\\$1")}"`;
|
|
30
|
+
}
|
|
31
|
+
/** Single-quote a string for PowerShell. */
|
|
32
|
+
export function psQuote(s) {
|
|
33
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
34
|
+
}
|
|
35
|
+
export function macScript(p) {
|
|
36
|
+
return [
|
|
37
|
+
"#!/bin/sh",
|
|
38
|
+
"# Opens Sub-Sub in the browser. Written by: subsub shortcut",
|
|
39
|
+
'cd "$HOME" || exit 1',
|
|
40
|
+
`exec ${shQuote(p.node)} ${shQuote(p.bin)} web >/dev/null 2>&1`,
|
|
41
|
+
"",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
export function macPlist(version) {
|
|
45
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
46
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
47
|
+
<plist version="1.0">
|
|
48
|
+
<dict>
|
|
49
|
+
<key>CFBundleName</key><string>Sub-Sub</string>
|
|
50
|
+
<key>CFBundleDisplayName</key><string>Sub-Sub</string>
|
|
51
|
+
<key>CFBundleIdentifier</key><string>eu.tiagojacinto.subsub</string>
|
|
52
|
+
<key>CFBundleExecutable</key><string>Sub-Sub</string>
|
|
53
|
+
<key>CFBundleIconFile</key><string>icon</string>
|
|
54
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
55
|
+
<key>CFBundleShortVersionString</key><string>${version}</string>
|
|
56
|
+
<key>CFBundleVersion</key><string>${version}</string>
|
|
57
|
+
<key>LSUIElement</key><true/>
|
|
58
|
+
</dict>
|
|
59
|
+
</plist>
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
export function desktopEntry(p) {
|
|
63
|
+
return [
|
|
64
|
+
"[Desktop Entry]",
|
|
65
|
+
"Type=Application",
|
|
66
|
+
"Name=Sub-Sub",
|
|
67
|
+
"Comment=Zotero librarian and research assistant",
|
|
68
|
+
`Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
|
|
69
|
+
`Path=${p.home}`,
|
|
70
|
+
`Icon=${join(p.packageDir, "assets", "icon.png")}`,
|
|
71
|
+
"Terminal=false",
|
|
72
|
+
"Categories=Education;Office;",
|
|
73
|
+
"",
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
export function windowsScript(p) {
|
|
77
|
+
const lines = [
|
|
78
|
+
"$ErrorActionPreference = 'Stop'",
|
|
79
|
+
"$shell = New-Object -ComObject WScript.Shell",
|
|
80
|
+
"$places = @([Environment]::GetFolderPath('Programs'))",
|
|
81
|
+
...(p.desktop ? ["$places += [Environment]::GetFolderPath('Desktop')"] : []),
|
|
82
|
+
"foreach ($dir in $places) {",
|
|
83
|
+
" $s = $shell.CreateShortcut((Join-Path $dir 'Sub-Sub.lnk'))",
|
|
84
|
+
` $s.TargetPath = ${psQuote(p.node)}`,
|
|
85
|
+
` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
|
|
86
|
+
` $s.WorkingDirectory = ${psQuote(p.home)}`,
|
|
87
|
+
` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
|
|
88
|
+
" $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
|
|
89
|
+
" $s.WindowStyle = 7",
|
|
90
|
+
" $s.Save()",
|
|
91
|
+
" Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
|
|
92
|
+
"}",
|
|
93
|
+
];
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
function runPowerShell(script) {
|
|
97
|
+
const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", windowsHide: true });
|
|
98
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error ? r.error.message : ""}`.trim() };
|
|
99
|
+
}
|
|
100
|
+
export function createShortcuts(p) {
|
|
101
|
+
if (p.platform === "darwin") {
|
|
102
|
+
const app = join(p.home, "Applications", "Sub-Sub.app");
|
|
103
|
+
mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
|
|
104
|
+
mkdirSync(join(app, "Contents", "Resources"), { recursive: true });
|
|
105
|
+
writeFileSync(join(app, "Contents", "Info.plist"), macPlist(packageVersion(p.packageDir)));
|
|
106
|
+
const exe = join(app, "Contents", "MacOS", "Sub-Sub");
|
|
107
|
+
writeFileSync(exe, macScript(p));
|
|
108
|
+
chmodSync(exe, 0o755);
|
|
109
|
+
const icns = join(p.packageDir, "assets", "icon.icns");
|
|
110
|
+
if (existsSync(icns))
|
|
111
|
+
copyFileSync(icns, join(app, "Contents", "Resources", "icon.icns"));
|
|
112
|
+
return [app];
|
|
113
|
+
}
|
|
114
|
+
if (p.platform === "win32") {
|
|
115
|
+
const r = runPowerShell(windowsScript(p));
|
|
116
|
+
if (!r.ok)
|
|
117
|
+
throw new Error(`could not create the shortcut: ${r.out}`);
|
|
118
|
+
return r.out.split(/\r?\n/).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
const dir = join(p.home, ".local", "share", "applications");
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
const file = join(dir, "sub-sub.desktop");
|
|
123
|
+
writeFileSync(file, desktopEntry(p));
|
|
124
|
+
chmodSync(file, 0o755);
|
|
125
|
+
return [file];
|
|
126
|
+
}
|
|
127
|
+
export function removeShortcuts(p) {
|
|
128
|
+
if (p.platform === "win32") {
|
|
129
|
+
const script = [
|
|
130
|
+
"foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) {",
|
|
131
|
+
" $f = Join-Path $dir 'Sub-Sub.lnk'",
|
|
132
|
+
" if (Test-Path $f) { Remove-Item $f; Write-Output $f }",
|
|
133
|
+
"}",
|
|
134
|
+
].join("\n");
|
|
135
|
+
const r = runPowerShell(script);
|
|
136
|
+
return r.out.split(/\r?\n/).filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
const target = p.platform === "darwin" ? join(p.home, "Applications", "Sub-Sub.app") : join(p.home, ".local", "share", "applications", "sub-sub.desktop");
|
|
139
|
+
if (!existsSync(target))
|
|
140
|
+
return [];
|
|
141
|
+
rmSync(target, { recursive: true, force: true });
|
|
142
|
+
return [target];
|
|
143
|
+
}
|
|
144
|
+
export function shortcutMain(args, env = process.env) {
|
|
145
|
+
const plan = planFor(env, process.platform, !args.includes("--no-desktop"));
|
|
146
|
+
try {
|
|
147
|
+
if (args.includes("--remove")) {
|
|
148
|
+
const gone = removeShortcuts(plan);
|
|
149
|
+
console.log(gone.length ? `Removed:\n${gone.join("\n")}` : "No Sub-Sub shortcut found.");
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
const made = createShortcuts(plan);
|
|
153
|
+
const where = plan.platform === "darwin"
|
|
154
|
+
? "Open Sub-Sub from Applications, Launchpad or Spotlight."
|
|
155
|
+
: plan.platform === "win32"
|
|
156
|
+
? "Open Sub-Sub from the Start menu or the desktop."
|
|
157
|
+
: "Open Sub-Sub from your applications menu.";
|
|
158
|
+
console.log(`Shortcut:\n${made.join("\n")}\n${where} It opens Sub-Sub in your browser.`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.error(`Sub-Sub: ${err.message}`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
package/dist/subsub.js
CHANGED
|
@@ -93,6 +93,32 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
93
93
|
const library = { zotero: "checking" };
|
|
94
94
|
const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
|
95
95
|
const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
96
|
+
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
97
|
+
const webView = process.env.SUBSUB_WEB === "1";
|
|
98
|
+
let webCtx;
|
|
99
|
+
function emitWeb() {
|
|
100
|
+
const ctx = webCtx;
|
|
101
|
+
if (!webView || !ctx || ctx.mode !== "rpc")
|
|
102
|
+
return;
|
|
103
|
+
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
104
|
+
const state = {
|
|
105
|
+
mode,
|
|
106
|
+
profile,
|
|
107
|
+
profileLabel: profileSpec(profile).label,
|
|
108
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
109
|
+
down,
|
|
110
|
+
library,
|
|
111
|
+
language: cfg.language,
|
|
112
|
+
userName: cfg.userName ?? null,
|
|
113
|
+
vault: cfg.vault ?? null,
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
ctx.ui.setStatus("subsub.web", JSON.stringify(state));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* the context is gone after shutdown */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
96
122
|
function applyTheme(ctx) {
|
|
97
123
|
if (!lookOn(ctx) || cfg.themes === false)
|
|
98
124
|
return;
|
|
@@ -125,6 +151,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
125
151
|
library.zotero = "down";
|
|
126
152
|
}
|
|
127
153
|
headerTui?.requestRender();
|
|
154
|
+
emitWeb();
|
|
128
155
|
}
|
|
129
156
|
function setHeader(ctx) {
|
|
130
157
|
if (!lookOn(ctx))
|
|
@@ -163,6 +190,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
163
190
|
if (standalone)
|
|
164
191
|
ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
165
192
|
headerTui?.requestRender();
|
|
193
|
+
webCtx = ctx;
|
|
194
|
+
emitWeb();
|
|
166
195
|
}
|
|
167
196
|
async function applyMode(ctx, next, remember) {
|
|
168
197
|
mode = next;
|
|
@@ -201,6 +230,9 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
201
230
|
setHeader(ctx);
|
|
202
231
|
void refreshLibrary();
|
|
203
232
|
}
|
|
233
|
+
else if (webView) {
|
|
234
|
+
void refreshLibrary();
|
|
235
|
+
}
|
|
204
236
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
205
237
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
206
238
|
}
|
|
@@ -362,9 +394,17 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
362
394
|
}
|
|
363
395
|
}
|
|
364
396
|
headerTui?.requestRender();
|
|
397
|
+
emitWeb();
|
|
365
398
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
366
399
|
},
|
|
367
400
|
});
|
|
401
|
+
pi.registerCommand("subsub-refresh", {
|
|
402
|
+
description: "Sub-Sub: refresh the library counts shown in the web view",
|
|
403
|
+
handler: async (_args, ctx) => {
|
|
404
|
+
webCtx = ctx;
|
|
405
|
+
await refreshLibrary();
|
|
406
|
+
},
|
|
407
|
+
});
|
|
368
408
|
pi.registerCommand("history", {
|
|
369
409
|
description: "Sub-Sub: recent library changes",
|
|
370
410
|
handler: async (_args, ctx) => {
|