diff --git a/.gitignore b/.gitignore index 8cdaa07..160fc4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +dist-firefox .vite .DS_Store *.log diff --git a/README.md b/README.md index 7597bd3..27b75c9 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,17 @@ npm run build # produces the finished extension in a new "dist" folder The first command can take a minute or two the first time — it's fetching everything the project needs. When both finish without errors, you'll have -a `dist/` folder inside your project folder. That's the extension. +a `dist/` folder inside your project folder. That's the Chrome / Edge / +Brave extension. + +For Firefox, run this instead of `npm run build`: + +```bash +npm run build:firefox +``` + +That creates a separate `dist-firefox/` folder with a Firefox-compatible +manifest. > The toolbar icon PNGs are committed in `public/icons/`, so you don't need > to regenerate them. If you change `public/logo.svg` and want to refresh @@ -123,8 +133,12 @@ a `dist/` folder inside your project folder. That's the extension. 2. Turn on **Developer mode** (toggle in the top-right). 3. Click **Load unpacked** and choose the `dist/` folder from step 3. -**Firefox:** see [Porting to Firefox / Safari](#porting-to-firefox--safari) -below. +**Firefox:** + +1. Build with `npm run build:firefox`. +2. Open `about:debugging#/runtime/this-firefox`. +3. Click **Load Temporary Add-on...**. +4. Choose `dist-firefox/manifest.json`. You'll see the "Send to TheBrain" icon appear in your browser toolbar. Click it and the popup will walk you through the one-time connection setup @@ -192,8 +206,10 @@ so the extension rebuilds on save. ## Porting to Firefox / Safari -- **Firefox:** `manifest.json` is already MV3-compatible. Run `npx web-ext run - -s dist/` after building. +- **Firefox:** run `npm run build:firefox`, then load + `dist-firefox/manifest.json` from `about:debugging`. Firefox MV3 uses + `background.scripts`, so the Firefox build rewrites the generated Chrome + `background.service_worker` entry accordingly. - **Safari:** Use Apple's converter: ``` xcrun safari-web-extension-converter dist/ diff --git a/package.json b/package.json index 7ba90b3..84d2ac7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "dev": "vite", "icons": "node scripts/generate-icons.mjs", "build": "tsc --noEmit && vite build", + "build:chrome": "npm run build", + "build:firefox": "npm run build && node scripts/build-firefox.mjs", "test": "vitest run", "test:watch": "vitest" }, diff --git a/scripts/build-firefox.mjs b/scripts/build-firefox.mjs new file mode 100644 index 0000000..0097b48 --- /dev/null +++ b/scripts/build-firefox.mjs @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const chromeDist = path.join(root, "dist"); +const firefoxDist = path.join(root, "dist-firefox"); +const chromeManifestPath = path.join(chromeDist, "manifest.json"); +const firefoxManifestPath = path.join(firefoxDist, "manifest.json"); + +if(!fs.existsSync(chromeManifestPath)) { + throw new Error("Run the Vite build before creating the Firefox package."); +} + +fs.rmSync(firefoxDist, { recursive: true, force: true }); +fs.cpSync(chromeDist, firefoxDist, { recursive: true }); + +const manifest = JSON.parse(fs.readFileSync(chromeManifestPath, "utf8")); +const serviceWorker = manifest.background?.service_worker; + +if(!serviceWorker) { + throw new Error("Expected the Chrome build to emit background.service_worker."); +} + +manifest.background = { + scripts: [serviceWorker], + type: manifest.background?.type ?? "module", +}; + +manifest.browser_specific_settings = { + gecko: { + id: "send-to-thebrain@thebrain.com", + strict_min_version: "109.0", + }, +}; + +fs.writeFileSync(firefoxManifestPath, `${JSON.stringify(manifest, null, "\t")}\n`); + +console.log(`Firefox extension written to ${path.relative(root, firefoxDist)}`); diff --git a/src/api/TheBrainLocalClient.ts b/src/api/TheBrainLocalClient.ts index 182d111..c089f5d 100644 --- a/src/api/TheBrainLocalClient.ts +++ b/src/api/TheBrainLocalClient.ts @@ -12,6 +12,7 @@ import type { CreateThoughtRequest, CreateThoughtResponse, Thought, + ThoughtReference, } from "./types"; import { AcType, AttachmentType, LinkRelation, ThoughtKind } from "./types"; @@ -99,6 +100,10 @@ export class TheBrainLocalClient { return this.request("GET", `/api/thoughts/${brainId}/${thoughtId}`); } + getPinnedThoughts(brainId: string): Promise { + return this.request("GET", `/api/thoughts/${brainId}/pins`); + } + findAttachmentsByLocation( brainId: string, location: string, @@ -116,14 +121,14 @@ export class TheBrainLocalClient { createChildThought( brainId: string, - parentThoughtId: string, + parentThought: ThoughtReference, name: string, label: string, ): Promise { const body: CreateThoughtRequest = { name, label: label.length > 0 ? label : null, - sourceThoughtId: parentThoughtId, + sourceThoughtId: parentThought.id, relation: LinkRelation.Child, kind: ThoughtKind.Normal, typeId: null, diff --git a/src/api/types.ts b/src/api/types.ts index da82423..bc9e471 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -41,6 +41,11 @@ export interface Thought { forgottenDateTime: string | null; } +export interface ThoughtReference { + id: string; + name: string; +} + export interface Attachment { id: string; brainId: string; diff --git a/src/lib/sendToBrain.test.ts b/src/lib/sendToBrain.test.ts new file mode 100644 index 0000000..efef251 --- /dev/null +++ b/src/lib/sendToBrain.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TheBrainLocalClient } from "../api/TheBrainLocalClient"; +import { sendToBrain } from "./sendToBrain"; + +const appState = { + currentBrainId: "brain-1", + currentBrainName: "Brain", + activeThoughtId: "active-1", + activeThoughtName: "Active", + isLoggedIn: true, + userId: "user-1", + tabs: [], +}; + +function createClient(): TheBrainLocalClient { + return { + getAppState: vi.fn().mockResolvedValue(appState), + findAttachmentsByLocation: vi.fn().mockResolvedValue([]), + createChildThought: vi.fn().mockResolvedValue({ id: "created-1" }), + attachUrl: vi.fn().mockResolvedValue(undefined), + activateThought: vi.fn().mockResolvedValue(undefined), + } as unknown as TheBrainLocalClient; +} + +describe("sendToBrain", () => { + it("creates a child under the selected target thought", async () => { + const client = createClient(); + + await sendToBrain({ + client, + tabTitle: "Example | Site", + tabUrl: "https://example.com", + mode: "createChild", + targetThought: { id: "pin-1", name: "Pinned Project" }, + activateAfterSend: false, + }); + + expect(client.createChildThought).toHaveBeenCalledWith( + "brain-1", + { id: "pin-1", name: "Pinned Project" }, + "Example", + "Site", + ); + }); + + it("attaches the URL to the selected target thought", async () => { + const client = createClient(); + + const outcome = await sendToBrain({ + client, + tabTitle: "Example", + tabUrl: "https://example.com", + mode: "attachToActive", + targetThought: { id: "pin-1", name: "Pinned Project" }, + activateAfterSend: false, + }); + + expect(client.attachUrl).toHaveBeenCalledWith( + "brain-1", + "pin-1", + "https://example.com", + "Example", + ); + expect(outcome).toMatchObject({ + kind: "attached", + thoughtId: "pin-1", + thoughtName: "Pinned Project", + }); + }); +}); diff --git a/src/lib/sendToBrain.ts b/src/lib/sendToBrain.ts index a794fdb..7329503 100644 --- a/src/lib/sendToBrain.ts +++ b/src/lib/sendToBrain.ts @@ -10,6 +10,7 @@ import { TheBrainError, } from "../api/errors"; import type { AppState } from "../api/types"; +import type { ThoughtReference } from "../api/types"; import type { SendMode } from "./settings"; import { splitTitle } from "./titleSplit"; @@ -18,6 +19,7 @@ export interface SendInput { tabTitle: string; tabUrl: string; mode: SendMode; + targetThought: ThoughtReference; activateAfterSend: boolean; } @@ -43,10 +45,10 @@ export type SendOutcome = }; export async function sendToBrain(input: SendInput): Promise { - const { client, tabTitle, tabUrl, mode, activateAfterSend } = input; + const { client, tabTitle, tabUrl, mode, targetThought, activateAfterSend } = input; const state = await client.getAppState(); - if(!state.currentBrainId || !state.activeThoughtId) { + if(!state.currentBrainId) { throw new NoBrainOpenError(); } @@ -71,7 +73,7 @@ export async function sendToBrain(input: SendInput): Promise { if(mode === "createChild") { const created = await client.createChildThought( state.currentBrainId, - state.activeThoughtId, + targetThought, effectiveName, label, ); @@ -93,18 +95,17 @@ export async function sendToBrain(input: SendInput): Promise { }; } - // attachToActive await client.attachUrl( state.currentBrainId, - state.activeThoughtId, + targetThought.id, tabUrl, attachmentName, ); return { kind: "attached", brainId: state.currentBrainId, - thoughtId: state.activeThoughtId, - thoughtName: state.activeThoughtName ?? "active thought", + thoughtId: targetThought.id, + thoughtName: targetThought.name, }; } catch(error) { // Auth and user-mismatch have already been filtered out in the client. diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 91e52f6..58ba0e9 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -17,6 +17,7 @@ export interface Settings { // YouTube's ?v=VIDEO_ID). Entries are bare hostnames; subdomains match. trimQueryParamsExceptions: string[]; autoProceed: boolean; + thoughtTargetIndex: number; } export const AUTO_PROCEED_MS = 3000; @@ -34,6 +35,7 @@ const DEFAULTS: Settings = { trimQueryParams: false, trimQueryParamsExceptions: [...DEFAULT_TRIM_EXCEPTIONS], autoProceed: false, + thoughtTargetIndex: 0, }; const KEYS: (keyof Settings)[] = [ @@ -44,6 +46,7 @@ const KEYS: (keyof Settings)[] = [ "trimQueryParams", "trimQueryParamsExceptions", "autoProceed", + "thoughtTargetIndex", ]; export async function getSettings(): Promise { @@ -72,6 +75,12 @@ export async function getSettings(): Promise { typeof stored.autoProceed === "boolean" ? stored.autoProceed : DEFAULTS.autoProceed, + thoughtTargetIndex: + typeof stored.thoughtTargetIndex === "number" && + Number.isInteger(stored.thoughtTargetIndex) && + stored.thoughtTargetIndex >= 0 + ? stored.thoughtTargetIndex + : DEFAULTS.thoughtTargetIndex, }; } diff --git a/src/popup/PopupApp.tsx b/src/popup/PopupApp.tsx index e1a5c4d..7d12989 100644 --- a/src/popup/PopupApp.tsx +++ b/src/popup/PopupApp.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { TheBrainLocalClient } from "../api/TheBrainLocalClient"; import { TheBrainError, NoBrainOpenError } from "../api/errors"; +import type { ThoughtReference } from "../api/types"; import { Alert } from "../components/Alert"; import { Button } from "../components/Button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../components/Card"; @@ -26,6 +27,10 @@ interface ActiveThought { brainName: string | null; } +interface ThoughtTarget extends ThoughtReference { + source: "active" | "pin"; +} + const AUTO_CLOSE_MS = 3000; type ViewState = @@ -41,6 +46,8 @@ export function PopupApp() { const [settings, setSettings] = useState(null); const [tab, setTab] = useState(null); const [activeThought, setActiveThought] = useState(null); + const [thoughtTargets, setThoughtTargets] = useState([]); + const [selectedTargetIndex, setSelectedTargetIndex] = useState(0); const [view, setView] = useState({ kind: "loading" }); const [autoProceedActive, setAutoProceedActive] = useState(false); const [successAutoCloseActive, setSuccessAutoCloseActive] = useState(true); @@ -61,9 +68,17 @@ export function PopupApp() { name: state.activeThoughtName ?? "active thought", brainName: state.currentBrainName, }); + const targets = await getThoughtTargets(client, { + id: state.activeThoughtId, + name: state.activeThoughtName ?? "active thought", + }, state.currentBrainId); + setThoughtTargets(targets); + setSelectedTargetIndex(clampThoughtTargetIndex(s.thoughtTargetIndex, targets)); setView({ kind: "ready" }); } catch(error) { setActiveThought(null); + setThoughtTargets([]); + setSelectedTargetIndex(0); const message = error instanceof TheBrainError ? error.message @@ -104,6 +119,15 @@ export function PopupApp() { const handleSend = useCallback(async () => { if(!tab || !settings) return; + const targetThought = thoughtTargets[selectedTargetIndex]; + if(!targetThought) { + setView({ + kind: "error", + message: "No thought selected as the destination.", + recoverable: true, + }); + return; + } setView({ kind: "sending" }); const client = new TheBrainLocalClient({ apiKey: settings.apiKey, @@ -119,6 +143,7 @@ export function PopupApp() { tabTitle: tab.title, tabUrl: effectiveUrl, mode: settings.mode, + targetThought, activateAfterSend: settings.activateAfterSend, }); setView({ kind: "success", outcome, client }); @@ -131,7 +156,7 @@ export function PopupApp() { : "Something went wrong."; setView({ kind: "error", message, recoverable: true }); } - }, [tab, settings]); + }, [tab, settings, thoughtTargets, selectedTargetIndex]); // Arm the countdown when we first reach the ready view, if the user // opted in. Cancelling (user interaction) sets autoProceedActive to @@ -173,6 +198,12 @@ export function PopupApp() { setSettings((prev) => (prev ? { ...prev, mode } : prev)); }, []); + const handleTargetChange = useCallback(async (thoughtTargetIndex: number) => { + await updateSettings({ thoughtTargetIndex }); + setSettings((prev) => (prev ? { ...prev, thoughtTargetIndex } : prev)); + setSelectedTargetIndex(thoughtTargetIndex); + }, []); + // Arm the auto-close every time we enter the success view. useEffect(() => { if(view.kind === "success") { @@ -239,6 +270,9 @@ export function PopupApp() { { + const activeTarget: ThoughtTarget = { + ...activeThought, + source: "active", + }; + let pins; + try { + pins = await client.getPinnedThoughts(brainId); + } catch(error) { + if(error instanceof TheBrainError) { + console.warn("[Send to TheBrain] pinned-thought lookup failed:", error); + return [activeTarget]; + } + throw error; + } + const pinTargets = pins + .filter((pin) => pin.id !== activeThought.id) + .map((pin): ThoughtTarget => ({ + id: pin.id, + name: pin.name || "unnamed thought", + source: "pin", + })); + return [activeTarget, ...pinTargets]; +} + +function clampThoughtTargetIndex(index: number, targets: ThoughtTarget[]): number { + if(targets.length === 0) { + return 0; + } + return Math.min(Math.max(index, 0), targets.length - 1); +} + function Header() { return (
@@ -308,6 +378,9 @@ function Header() { function ReadyCard({ tab, activeThought, + thoughtTargets, + selectedTargetIndex, + onTargetChange, mode, onModeChange, trimQueryParams, @@ -318,6 +391,9 @@ function ReadyCard({ }: { tab: ActiveTab; activeThought: ActiveThought; + thoughtTargets: ThoughtTarget[]; + selectedTargetIndex: number; + onTargetChange: (thoughtTargetIndex: number) => void; mode: SendMode; onModeChange: (mode: SendMode) => void; trimQueryParams: boolean; @@ -342,10 +418,16 @@ function ReadyCard({ const isException = isTrimException(tab.url, trimExceptions); const showTrimOption = hasQueryOrHash(tab.url) && !isException; const previewUrl = showTrimOption && trimQueryParams ? stripQueryAndHash(tab.url) : tab.url; + const selectedTarget = + thoughtTargets[clampThoughtTargetIndex(selectedTargetIndex, thoughtTargets)] ?? { + id: activeThought.id, + name: activeThought.name, + source: "active" as const, + }; const sendLabel = mode === "createChild" - ? `Create child of "${activeThought.name}"` - : `Attach to "${activeThought.name}"`; + ? `Create child of "${selectedTarget.name}"` + : `Attach to "${selectedTarget.name}"`; return ( @@ -360,6 +442,11 @@ function ReadyCard({ · {activeThought.brainName} )}
+ {showTrimOption && (