Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
dist
dist-firefox
.vite
.DS_Store
*.log
Expand Down
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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/
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
39 changes: 39 additions & 0 deletions scripts/build-firefox.mjs
Original file line number Diff line number Diff line change
@@ -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)}`);
9 changes: 7 additions & 2 deletions src/api/TheBrainLocalClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
CreateThoughtRequest,
CreateThoughtResponse,
Thought,
ThoughtReference,
} from "./types";
import { AcType, AttachmentType, LinkRelation, ThoughtKind } from "./types";

Expand Down Expand Up @@ -99,6 +100,10 @@ export class TheBrainLocalClient {
return this.request<Thought>("GET", `/api/thoughts/${brainId}/${thoughtId}`);
}

getPinnedThoughts(brainId: string): Promise<Thought[]> {
return this.request<Thought[]>("GET", `/api/thoughts/${brainId}/pins`);
}

findAttachmentsByLocation(
brainId: string,
location: string,
Expand All @@ -116,14 +121,14 @@ export class TheBrainLocalClient {

createChildThought(
brainId: string,
parentThoughtId: string,
parentThought: ThoughtReference,
name: string,
label: string,
): Promise<CreateThoughtResponse> {
const body: CreateThoughtRequest = {
name,
label: label.length > 0 ? label : null,
sourceThoughtId: parentThoughtId,
sourceThoughtId: parentThought.id,
relation: LinkRelation.Child,
kind: ThoughtKind.Normal,
typeId: null,
Expand Down
5 changes: 5 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export interface Thought {
forgottenDateTime: string | null;
}

export interface ThoughtReference {
id: string;
name: string;
}

export interface Attachment {
id: string;
brainId: string;
Expand Down
70 changes: 70 additions & 0 deletions src/lib/sendToBrain.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
15 changes: 8 additions & 7 deletions src/lib/sendToBrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,6 +19,7 @@ export interface SendInput {
tabTitle: string;
tabUrl: string;
mode: SendMode;
targetThought: ThoughtReference;
activateAfterSend: boolean;
}

Expand All @@ -43,10 +45,10 @@ export type SendOutcome =
};

export async function sendToBrain(input: SendInput): Promise<SendOutcome> {
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();
}

Expand All @@ -71,7 +73,7 @@ export async function sendToBrain(input: SendInput): Promise<SendOutcome> {
if(mode === "createChild") {
const created = await client.createChildThought(
state.currentBrainId,
state.activeThoughtId,
targetThought,
effectiveName,
label,
);
Expand All @@ -93,18 +95,17 @@ export async function sendToBrain(input: SendInput): Promise<SendOutcome> {
};
}

// 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.
Expand Down
9 changes: 9 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +35,7 @@ const DEFAULTS: Settings = {
trimQueryParams: false,
trimQueryParamsExceptions: [...DEFAULT_TRIM_EXCEPTIONS],
autoProceed: false,
thoughtTargetIndex: 0,
};

const KEYS: (keyof Settings)[] = [
Expand All @@ -44,6 +46,7 @@ const KEYS: (keyof Settings)[] = [
"trimQueryParams",
"trimQueryParamsExceptions",
"autoProceed",
"thoughtTargetIndex",
];

export async function getSettings(): Promise<Settings> {
Expand Down Expand Up @@ -72,6 +75,12 @@ export async function getSettings(): Promise<Settings> {
typeof stored.autoProceed === "boolean"
? stored.autoProceed
: DEFAULTS.autoProceed,
thoughtTargetIndex:
typeof stored.thoughtTargetIndex === "number" &&
Number.isInteger(stored.thoughtTargetIndex) &&
stored.thoughtTargetIndex >= 0
? stored.thoughtTargetIndex
: DEFAULTS.thoughtTargetIndex,
};
}

Expand Down
Loading