From 8a1a72e708618ea5d6e3efbd175c326c629fbc97 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:24 +0000 Subject: [PATCH 01/21] feat(import): bookmarks from a file (IM-13, IM-05) through the import window and onboarding's step - Netscape bookmarks HTML (Chromium- and Firefox-style exports): folders, order, titles and addresses kept; a file with no bookmarks says "No bookmarks found in this file" and imports nothing. - One pinned folder, "Imported bookmarks", at the end of the target Space's pinned section, with closed sub-folders; tabs asleep, no favicon or request until opened; one undo step. - Large imports go in store updates of 250 items (2,000 bookmarks: about 0.15 s, longest main stall about 30 ms on a quiet machine). - The import window (IM-01): Back / Next / Cancel, the bookmark tree with Select all / none and a count (IM-04), the target Space (IM-06), progress and a summary with skipped items; Done shows the folder in the sidebar (IM-14). Dropping a file on the window or the sidebar starts it. - Onboarding's import step shows the same flow inline (OB-03): its import is added on Start browsing, and Skip setup adds nothing (D44). The Done screen says what was imported (OB-09). - Browser profiles are found through one function (importPlaces); tests set fixture places, and a test run without them searches nowhere and fails. Fixtures come from scripts/import-fixtures.ts. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 28 +- docs/feature-status.json | 9 +- scripts/import-fixtures.ts | 166 ++++ src/main/features.ts | 2 +- src/main/import/bookmarks.ts | 175 +++++ src/main/import/files.ts | 46 ++ src/main/import/index.ts | 143 +++- src/main/import/jobs.ts | 323 ++++++++ src/main/import/netscape.ts | 175 +++++ src/main/import/places.ts | 109 +++ src/main/import/window.ts | 136 ++++ src/renderer/src/import/BookmarkTree.tsx | 145 ++++ src/renderer/src/import/ImportApp.tsx | 50 ++ src/renderer/src/import/ImportFlow.tsx | 720 ++++++++++++++++++ src/renderer/src/import/Onboarding.tsx | 51 ++ src/renderer/src/import/Progress.tsx | 95 +++ src/renderer/src/import/boot.tsx | 20 + src/renderer/src/import/copy.ts | 32 + src/renderer/src/import/import.css | 567 ++++++++++++++ src/renderer/src/import/sidebar.ts | 51 ++ src/renderer/src/import/sources.tsx | 203 +++++ src/renderer/src/import/tree.ts | 39 + src/renderer/src/main.tsx | 2 + src/renderer/src/onboarding/OnboardingApp.tsx | 17 +- src/renderer/src/onboarding/steps.tsx | 47 +- .../src/shell/sidebar/SidebarItems.tsx | 5 +- src/shared/actions.ts | 2 + src/shared/commands/import.ts | 259 ++++++- src/shared/rpc.ts | 11 +- tests/e2e/import-file.spec.ts | 266 +++++++ tests/e2e/import-onboarding.spec.ts | 159 ++++ tests/e2e/import-support.ts | 125 +++ tests/e2e/onboarding.spec.ts | 13 +- tests/e2e/settings.spec.ts | 4 +- .../import/files/bookmarks-firefox.htm | 46 ++ tests/fixtures/import/files/bookmarks.htm | 35 + tests/fixtures/import/files/links.txt | 7 + tests/fixtures/import/files/malformed.htm | 3 + tests/unit/import-bookmarks.test.ts | 219 ++++++ tests/unit/import-netscape.test.ts | 202 +++++ tests/unit/import-places.test.ts | 55 ++ 41 files changed, 4706 insertions(+), 56 deletions(-) create mode 100644 scripts/import-fixtures.ts create mode 100644 src/main/import/bookmarks.ts create mode 100644 src/main/import/files.ts create mode 100644 src/main/import/jobs.ts create mode 100644 src/main/import/netscape.ts create mode 100644 src/main/import/places.ts create mode 100644 src/main/import/window.ts create mode 100644 src/renderer/src/import/BookmarkTree.tsx create mode 100644 src/renderer/src/import/ImportApp.tsx create mode 100644 src/renderer/src/import/ImportFlow.tsx create mode 100644 src/renderer/src/import/Onboarding.tsx create mode 100644 src/renderer/src/import/Progress.tsx create mode 100644 src/renderer/src/import/boot.tsx create mode 100644 src/renderer/src/import/copy.ts create mode 100644 src/renderer/src/import/import.css create mode 100644 src/renderer/src/import/sidebar.ts create mode 100644 src/renderer/src/import/sources.tsx create mode 100644 src/renderer/src/import/tree.ts create mode 100644 tests/e2e/import-file.spec.ts create mode 100644 tests/e2e/import-onboarding.spec.ts create mode 100644 tests/e2e/import-support.ts create mode 100644 tests/fixtures/import/files/bookmarks-firefox.htm create mode 100644 tests/fixtures/import/files/bookmarks.htm create mode 100644 tests/fixtures/import/files/links.txt create mode 100644 tests/fixtures/import/files/malformed.htm create mode 100644 tests/unit/import-bookmarks.test.ts create mode 100644 tests/unit/import-netscape.test.ts create mode 100644 tests/unit/import-places.test.ts diff --git a/FEATURES.md b/FEATURES.md index e9fdf860..bc0f1dea 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 174 built (72 partial) · 251 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 174 built (77 partial) · 260 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 111 | 136 | -| P1 | 221 | 54 | 104 | -| P2 | 80 | 9 | 11 | +| P1 | 221 | 54 | 111 | +| P2 | 80 | 9 | 13 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -563,23 +563,23 @@ Spec: `spec/import.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| IM-01 | Import entry points | P1 | – | ✅ e2e | – | | -| IM-02 | Choose the source browser and profile(s) | P1 | – | – | – | | +| IM-01 | Import entry points | P1 | 🟡 partial | ✅ e2e | – | the import window (a child of the browser window, in its Space's colours): Back / Next / Cancel, Cancel imports nothing, Enter is Next and Escape is Back, ⌘W closes it; onboarding shows the same flow inline (OB-03). The app menu, command bar and Settings entry points come with the rest of the flow | +| IM-02 | Choose the source browser and profile(s) | P1 | – | ✅ unit | – | | | IM-03 | Choose which data types to import | P1 | – | – | – | | -| IM-04 | Choose which bookmarks to import | P1 | – | – | – | | -| IM-05 | Mapping bookmarks into Orbit (pinned tabs and folders) | P1 | – | – | – | | -| IM-06 | Target Space and profile for imported data | P1 | – | – | – | | +| IM-04 | Choose which bookmarks to import | P1 | 🟡 partial | ✅ e2e+unit | – | the bookmark tree with a checkbox per folder and bookmark, everything chosen at first, Select all / none and a count; addresses that can't be pinned show unticked with the reason. Files only so far: browser sources come with IM-02 | +| IM-05 | Mapping bookmarks into Orbit (pinned tabs and folders) | P1 | 🟡 partial | ✅ e2e+unit | – | a file's bookmarks become one pinned folder at the end of the target Space's pinned section: top-level groups and nested folders kept, in order, as closed sub-folders (the folder itself closed above 1,000 items); each bookmark a pinned tab, asleep, with no favicon or request until opened; bookmarklets, browser-only and non-web addresses skipped and counted; no Favorites made; one undo step takes it all back. 2,000 bookmarks take about 0.1 s with the longest main-process stall about 110 ms. "Imported from " comes with IM-02 | +| IM-06 | Target Space and profile for imported data | P1 | – | ✅ e2e | – | | | IM-07 | History import | P1 | – | – | – | | | IM-08 | Password import | P1 | – | – | – | | | IM-09 | Extension import (Chromium-family sources) | P1 | – | – | – | | | IM-10 | Cookies / signed-in sessions import | P2 | – | – | – | | | IM-11 | Autofill addresses and payment cards import | P2 | – | – | – | | | IM-12 | Bringing data over from Arc (user-facing exports only) | P1 | – | – | – | | -| IM-13 | Import from files (standard formats) | P1 | – | – | – | | -| IM-14 | Progress, summary and errors | P1 | – | – | – | | -| IM-15 | Re-import and duplicates | P2 | – | – | – | | +| IM-13 | Import from files (standard formats) | P1 | 🟡 partial | ✅ e2e+unit | – | bookmarks HTML (the Netscape format; Chromium- and Firefox-style exports tested) from From a file… or dropped on the import window or the sidebar, mapped as IM-05 into "Imported bookmarks"; a file with no bookmarks says "No bookmarks found in this file" and imports nothing; a cut-off file imports what it holds; 64 MB at most. A .txt list of links opens the paste route (IM-12). No passwords CSV (D52) | +| IM-14 | Progress, summary and errors | P1 | 🟡 partial | ✅ e2e | – | progress per data type, then a summary with counts and the skipped items with reasons; Done shows the imported folder in the sidebar (its Space, the pinned section open, the folder in view); the browser window stays responsive meanwhile. Locked browser files come with IM-02 | +| IM-15 | Re-import and duplicates | P2 | – | ✅ e2e+unit | – | | | IM-16 | Starter suggestions from imported history | P2 | – | – | – | | -| IM-17 | Export Orbit's pinned items (anti-lock-in) | P2 | – | – | – | | +| IM-17 | Export Orbit's pinned items (anti-lock-in) | P2 | – | ✅ unit | – | | ## Onboarding (first run), tips and tutorials @@ -589,13 +589,13 @@ Spec: `spec/onboarding.md` |---|---|---|---|---|---|---| | OB-01 | Welcome screen | P1 | ✅ | ✅ e2e | – | a window of its own before any browser window on a first run (gated by settings.onboarded; e2e runs skip it unless ORBIT_ONBOARDING=1): Orbit's logo with the moon in motion (still with reduced motion), a welcome line, Get started; steps slide in (about 400 ms, none with reduced motion); Enter continues, Esc goes back; each step's title takes focus; progress dots are labelled. No sounds or music (optional), no haptics (Electron has no API), no first-window shimmer | | OB-02 | No account, no sign-in, ever | P0 | ✅ | ✅ e2e+unit | – | audit and e2e: no account UI anywhere, onboarding included; a first run sends nothing off the computer, with or without onboarding, except Chromium's spell-check dictionary download (see tests) | -| OB-03 | Import step | P1 | 🟡 partial | – | – | placeholder step that says import isn't available yet, because the import lane has no import.open; when it has, the step offers Import… (opens that flow in a browser window) and Do this later with the same weight. Not inline, no background import or counts, no link for people moving from another browser yet | +| OB-03 | Import step | P1 | 🟡 partial | ✅ e2e | – | the import flow inline (IM-01): a bookmarks file dropped or chosen, the paste route and the help for people moving from another browser, with Do this later weighing the same as Next. An import chosen there is read at once and added when Start browsing ends onboarding; Skip setup adds nothing (D44). Browser logos, profiles and data types come with IM-02 and IM-03 | | OB-04 | Pick a theme colour | P1 | ✅ | ✅ e2e | – | the Space theme picker (same ThemeEditor) for the first Space, applied live, with a preview of sidebar and frame, grain and intensity; Light / Dark / Match system (Match system preselected, Continue works at once); says it can be changed later. No haptics | | OB-05 | Pick favourite web apps (seed Favorites) | P1 | ✅ | ✅ e2e+unit | – | 16 web apps as letter tiles (no logos fetched), nothing preselected, at most 12; they become the first Space's profile's Favorites, unloaded until opened. No ordering by imported history (no import yet) | | OB-06 | Ad-blocking choice | P1 | – | – | – | there is no ad and tracker blocker (OT-15), so the step is left out of the flow | | OB-07 | Set as default browser | P1 | ✅ | ✅ e2e | – | explains what the default browser does; Make Orbit my default and Not now with the same weight; on Windows says what to pick in Default apps; status asked again when the window comes back to the front; no reminders later. Test runs use a stub | | OB-08 | Default search engine choice | P2 | ✅ | ✅ e2e | – | a Search with row on the Done screen: the Default profile's engine (ST-06's list), Google until changed | -| OB-09 | Done screen and the first browser window | P1 | 🟡 partial | ✅ e2e | – | Done screen sums up colours, Favorites, default browser and import; Start browsing (Back to browsing after a reset) opens the first window centred at about 86% of the screen with the colours and Favorites picked, command bar closed. No Orbit basics folder or local welcome tab (no local guide pages yet), no import counts | +| OB-09 | Done screen and the first browser window | P1 | 🟡 partial | ✅ e2e | – | Done screen sums up colours, Favorites, default browser and import (what will be added from where, e.g. "6 bookmarks from bookmarks.html", or that nothing was and where import lives); Start browsing (Back to browsing after a reset) opens the first window centred at about 86% of the screen with the colours and Favorites picked, command bar closed. No Orbit basics folder or local welcome tab (no local guide pages yet) | | OB-10 | Getting-started guide | P1 | – | – | – | | | OB-11 | Contextual tips and one-time hints | P1 | – | – | – | | | OB-12 | Essential keyboard shortcuts sheet | P1 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 8032c84d..4e734d57 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -12,14 +12,19 @@ "ST-10": {"built": "partial", "note": "tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet"}, "ST-12": {"built": "partial", "note": "privacy promise (Orbit sends nothing); Clear browsing data per profile (⌘⇧⌫ / Ctrl+Shift+Delete): history by time range; cookies and site data, and the cache, always from all time (Electron has no range), so they start ticked only for All time; Secure DNS (Automatic, Off, Cloudflare, Google, Quad9); graphics acceleration lives in Advanced. No HTTPS-first or Safe Browsing (not in Electron), no per-site clearing here"}, "OB-01": {"built": "yes", "note": "a window of its own before any browser window on a first run (gated by settings.onboarded; e2e runs skip it unless ORBIT_ONBOARDING=1): Orbit's logo with the moon in motion (still with reduced motion), a welcome line, Get started; steps slide in (about 400 ms, none with reduced motion); Enter continues, Esc goes back; each step's title takes focus; progress dots are labelled. No sounds or music (optional), no haptics (Electron has no API), no first-window shimmer"}, - "OB-03": {"built": "partial", "note": "placeholder step that says import isn't available yet, because the import lane has no import.open; when it has, the step offers Import… (opens that flow in a browser window) and Do this later with the same weight. Not inline, no background import or counts, no link for people moving from another browser yet"}, + "OB-03": {"built": "partial", "note": "the import flow inline (IM-01): a bookmarks file dropped or chosen, the paste route and the help for people moving from another browser, with Do this later weighing the same as Next. An import chosen there is read at once and added when Start browsing ends onboarding; Skip setup adds nothing (D44). Browser logos, profiles and data types come with IM-02 and IM-03"}, "OB-04": {"built": "yes", "note": "the Space theme picker (same ThemeEditor) for the first Space, applied live, with a preview of sidebar and frame, grain and intensity; Light / Dark / Match system (Match system preselected, Continue works at once); says it can be changed later. No haptics"}, "OB-05": {"built": "yes", "note": "16 web apps as letter tiles (no logos fetched), nothing preselected, at most 12; they become the first Space's profile's Favorites, unloaded until opened. No ordering by imported history (no import yet)"}, "OB-06": {"built": "not started", "note": "there is no ad and tracker blocker (OT-15), so the step is left out of the flow"}, "OB-07": {"built": "yes", "note": "explains what the default browser does; Make Orbit my default and Not now with the same weight; on Windows says what to pick in Default apps; status asked again when the window comes back to the front; no reminders later. Test runs use a stub"}, "OB-08": {"built": "yes", "note": "a Search with row on the Done screen: the Default profile's engine (ST-06's list), Google until changed"}, - "OB-09": {"built": "partial", "note": "Done screen sums up colours, Favorites, default browser and import; Start browsing (Back to browsing after a reset) opens the first window centred at about 86% of the screen with the colours and Favorites picked, command bar closed. No Orbit basics folder or local welcome tab (no local guide pages yet), no import counts"}, + "OB-09": {"built": "partial", "note": "Done screen sums up colours, Favorites, default browser and import (what will be added from where, e.g. \"6 bookmarks from bookmarks.html\", or that nothing was and where import lives); Start browsing (Back to browsing after a reset) opens the first window centred at about 86% of the screen with the colours and Favorites picked, command bar closed. No Orbit basics folder or local welcome tab (no local guide pages yet)"}, "OB-14": {"built": "yes", "note": "Skip setup on every step but the last gives the spec's default first window: the theme step's colours and mode are undone and no Favorites are added (D44). The step and the picks so far are saved, so a quit part-way resumes there with them; Reset Onboarding (command bar only) runs the flow again over the open windows and deletes nothing"}, + "IM-01": {"built": "partial", "note": "the import window (a child of the browser window, in its Space's colours): Back / Next / Cancel, Cancel imports nothing, Enter is Next and Escape is Back, ⌘W closes it; onboarding shows the same flow inline (OB-03). The app menu, command bar and Settings entry points come with the rest of the flow"}, + "IM-04": {"built": "partial", "note": "the bookmark tree with a checkbox per folder and bookmark, everything chosen at first, Select all / none and a count; addresses that can't be pinned show unticked with the reason. Files only so far: browser sources come with IM-02"}, + "IM-05": {"built": "partial", "note": "a file's bookmarks become one pinned folder at the end of the target Space's pinned section: top-level groups and nested folders kept, in order, as closed sub-folders (the folder itself closed above 1,000 items); each bookmark a pinned tab, asleep, with no favicon or request until opened; bookmarklets, browser-only and non-web addresses skipped and counted; no Favorites made; one undo step takes it all back. 2,000 bookmarks take about 0.1 s with the longest main-process stall about 110 ms. \"Imported from \" comes with IM-02"}, + "IM-13": {"built": "partial", "note": "bookmarks HTML (the Netscape format; Chromium- and Firefox-style exports tested) from From a file… or dropped on the import window or the sidebar, mapped as IM-05 into \"Imported bookmarks\"; a file with no bookmarks says \"No bookmarks found in this file\" and imports nothing; a cut-off file imports what it holds; 64 MB at most. A .txt list of links opens the paste route (IM-12). No passwords CSV (D52)"}, + "IM-14": {"built": "partial", "note": "progress per data type, then a summary with counts and the skipped items with reasons; Done shows the imported folder in the sidebar (its Space, the pinned section open, the folder in view); the browser window stays responsive meanwhile. Locked browser files come with IM-02"}, "ST-11": {"built": "partial", "note": "Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2)"}, "ST-17": {"built": "yes", "note": "Settings → About: logo, version (a click copies it), build date, Chromium and Electron versions, system, Check for updates, release notes, Copy build info. Licence and Open-source credits open credits.html, built with the app (D43): Orbit's and Electron's licences, a link to the LICENSES.chromium.html Electron ships, and the licence of each npm package whose code ships. Release notes and help need access to the private repository for now"}, "OB-13": {"built": "partial", "note": "command-bar Help, Report a Bug, What's New and Copy Build Info; macOS Help menu (Orbit Help, Report a Bug…, Release Notes, Troubleshooting → Copy Build Info). Help opens the project README; Report a Bug opens a new issue on the public tracker with the build info filled in. The shortcut sheet entry comes with OB-12 (keys lane)"}, diff --git a/scripts/import-fixtures.ts b/scripts/import-fixtures.ts new file mode 100644 index 00000000..2e32a245 --- /dev/null +++ b/scripts/import-fixtures.ts @@ -0,0 +1,166 @@ +/** + * Builds the import lane's test fixtures (IM-*) from made-up data, into `tests/fixtures/import/`: + * - `files/bookmarks.htm`, `files/bookmarks-firefox.htm`: bookmarks files as browsers export them + * (folders, a bookmarklet, a browser's own page, entities); + * - `files/links.txt`: a list of links, plain and "Title — address"; + * - `files/malformed.htm`: an HTML file with no bookmarks in it. + * HTML fixtures end in `.htm`, which the linter leaves alone: an export isn't meant to be valid HTML. + * + * Run it with `node scripts/import-fixtures.ts` after changing it, and commit what it writes. Tests + * also import its builders to make large files on the fly. Nothing here reads anything on this + * machine: every name, address and date is invented. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const FIXTURE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'tests', 'fixtures', 'import') + +/** A made-up bookmark (with `url`) or folder (with `children`). */ +export interface FixtureNode { + title: string + url?: string + children?: FixtureNode[] +} + +/** A fixed date for every fixture (2024-03-01), so the files don't change between runs. */ +const ADDED = 1709251200 + +const escapeHtml = (text: string) => + text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +/** + * A Netscape bookmark file. `style` follows one exporter's habits: Chromium's (bookmarks bar flag, + * `

` after lists), or Firefox's (`

` descriptions, `
`, unquoted-looking extras). + */ +export function netscapeHtml(nodes: readonly FixtureNode[], style: 'chromium' | 'firefox' = 'chromium'): string { + const out = [ + '', + '', + '', + style === 'firefox' ? '' : '', + 'Bookmarks', + style === 'firefox' ? '

Bookmarks Menu

' : '

Bookmarks

', + '

', + ] + const walk = (list: readonly FixtureNode[], depth: number, top: boolean) => { + const pad = ' '.repeat(depth) + list.forEach((node, i) => { + if (node.children) { + const flag = top && i === 0 ? ' PERSONAL_TOOLBAR_FOLDER="true"' : '' + out.push(`${pad}

${escapeHtml(node.title)}

`) + out.push(`${pad}

`) + walk(node.children, depth + 1, false) + out.push(`${pad}

`) + if (style === 'firefox' && top) out.push(`${pad}


`) + } else if (node.url) { + const icon = style === 'chromium' ? ' ICON="data:image/png;base64,iVBORw0KGgo="' : '' + out.push(`${pad}
${escapeHtml(node.title)}`) + if (style === 'firefox') out.push(`${pad}
A note about ${escapeHtml(node.title)}`) + } + }) + } + walk(nodes, 1, true) + out.push('

', '') + return out.filter((line) => line !== '').join('\n') +} + +/** + * The bookmarks of IM-05's example, served from `base`: a bookmarks bar with "Work" (3 links) and + * "News" (2 links), other bookmarks with a nested folder, a bookmarklet and a browser's own page. + */ +export function sampleTree(base = 'https://example.com'): FixtureNode[] { + return [ + { + title: 'Bookmarks bar', + children: [ + { + title: 'Work', + children: [ + { title: 'Team calendar', url: `${base}/page/Calendar` }, + { title: 'Design docs', url: `${base}/page/Docs` }, + { title: 'Issue tracker', url: `${base}/page/Issues` }, + ], + }, + { + title: 'News', + children: [ + { title: 'Morning paper', url: `${base}/page/Paper` }, + { title: 'Tech & Science ', url: `${base}/page/Weekly` }, + ], + }, + ], + }, + { + title: 'Other bookmarks', + children: [ + { + title: 'Recipes', + children: [{ title: 'Bread', children: [{ title: 'Sourdough', url: `${base}/page/Sourdough` }] }], + }, + { title: 'Make it dark', url: 'javascript:document.body.style.background="#000"' }, + { title: 'Browser settings', url: 'chrome://settings/' }, + ], + }, + ] +} + +/** `count` bookmarks served from `base`: 10 folders of sub-folders of 20, plus a few loose ones. */ +export function manyBookmarks(count: number, base: string): FixtureNode[] { + const top: FixtureNode[] = [] + let n = 0 + const next = (): FixtureNode => { + n++ + return { title: `Page ${n}`, url: `${base}/page/p${n}` } + } + const loose = Math.min(5, count) + for (let f = 0; n < count - loose; f++) { + const folder: FixtureNode = { title: `Folder ${f + 1}`, children: [] } + for (let s = 0; s < 10 && n < count - loose; s++) { + const sub: FixtureNode = { title: `Folder ${f + 1}.${s + 1}`, children: [] } + for (let i = 0; i < 20 && n < count - loose; i++) sub.children?.push(next()) + folder.children?.push(sub) + } + top.push(folder) + } + while (n < count) top.push(next()) + return top +} + +/** A list of links as someone might copy them (IM-12): plain, "Title — address", and noise. */ +export const LINKS_TXT = [ + 'https://example.com/page/One', + 'Two — https://example.com/page/Two', + 'example.org/three', + '', + 'not a link at all', + '- [Four](https://example.com/page/Four)', + 'Five | https://example.com/page/Five', + '', +].join('\n') + +/** An HTML file that holds no bookmarks: a page, not an export (IM-13). */ +export const MALFORMED_HTML = ` +Not bookmarks +

Recipes

Flour, water, salt.

Bread
Bake it.
+` + +function write(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, text) +} + +/** Writes every fixture under `root`. */ +export function buildFixtures(root = FIXTURE_ROOT): void { + write(join(root, 'files', 'bookmarks.htm'), netscapeHtml(sampleTree())) + write(join(root, 'files', 'bookmarks-firefox.htm'), netscapeHtml(sampleTree(), 'firefox')) + write(join(root, 'files', 'links.txt'), LINKS_TXT) + write(join(root, 'files', 'malformed.htm'), MALFORMED_HTML) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildFixtures() + console.log(`import fixtures written to ${FIXTURE_ROOT}`) +} diff --git a/src/main/features.ts b/src/main/features.ts index 9418f812..acae1413 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -32,6 +32,6 @@ export function registerFeatures(ctx: MainContext): void { registerExtensions(ctx) registerDownloads(ctx) registerSettings(ctx) - registerImport(ctx) + registerImport(ctx, history) registerUpdates(ctx) } diff --git a/src/main/import/bookmarks.ts b/src/main/import/bookmarks.ts new file mode 100644 index 00000000..4ac88258 --- /dev/null +++ b/src/main/import/bookmarks.ts @@ -0,0 +1,175 @@ +/** + * Imported bookmarks become pinned items (IM-05): one new pinned folder at the end of the target + * Space's pinned section, holding a pinned tab per bookmark and a sub-folder per bookmark folder, + * in their order. Pure state helpers (draft in, ids out), unit-tested directly. + */ +import { freeze } from 'immer' +import type { BookmarkNode } from '../../shared/commands/import' +import { newId } from '../../shared/ids' +import type { Folder, FolderId, OrbitState, SpaceId, Tab, TabId } from '../../shared/state' +import { insertItem } from '../../shared/tree' +import { fallbackTitle } from '../../shared/url' +import { makeTab } from '../tabs/model' + +/** An import holding more items than this shows its folder collapsed (IM-05). */ +export const COLLAPSE_OVER = 1000 + +export interface PlacedFolder { + /** The new top folder ('' when nothing was added). */ + folderId: FolderId + tabIds: TabId[] + folderIds: FolderId[] + /** Bookmarks left out, by reason. */ + skipped: Map +} + +/** The name, or "name (2)", "name (3)"… when a top-level pinned folder of the Space has it (IM-15). */ +export function freeFolderName(state: OrbitState, spaceId: SpaceId, base: string): string { + const taken = new Set((state.spaces[spaceId]?.pinned ?? []).map((id) => state.folders[id]?.name)) + if (!taken.has(base)) return base + for (let n = 2; ; n++) if (!taken.has(`${base} (${n})`)) return `${base} (${n})` +} + +/** One step of placing an import, in order: a folder or a pinned tab, under its parent's key. */ +export type PlaceStep = + | { kind: 'folder'; key: number; parent: number; name: string } + | { kind: 'tab'; parent: number; url: string; title: string } + +/** Where an import's bookmarks go, worked out before anything is added. */ +export interface Placement { + /** The new top folder (key 0, parent -1) first, then folders and tabs in the file's order. */ + steps: PlaceStep[] + tabs: number + folders: number + /** Bookmarks left out, by reason. */ + skipped: Map +} + +/** Plans folder `name` holding `nodes` (IM-05). Bookmarks that can't be pinned tabs are only counted. */ +export function planPlacement(name: string, nodes: readonly BookmarkNode[]): Placement { + const plan: Placement = { + steps: [{ kind: 'folder', key: 0, parent: -1, name }], + tabs: 0, + folders: 0, + skipped: new Map(), + } + let keys = 0 + const walk = (list: readonly BookmarkNode[], parent: number) => { + for (const node of list) { + if (node.children) { + const key = ++keys + plan.steps.push({ kind: 'folder', key, parent, name: node.title.trim().slice(0, 200) || 'Untitled folder' }) + plan.folders++ + walk(node.children, key) + } else if (node.url) { + if (node.skip) { + plan.skipped.set(node.skip, (plan.skipped.get(node.skip) ?? 0) + 1) + continue + } + plan.steps.push({ + kind: 'tab', + parent, + url: node.url, + title: node.title.trim().slice(0, 400) || fallbackTitle(node.url), + }) + plan.tabs++ + } + } + } + walk(nodes, 0) + return plan +} + +/** + * Adds steps `from` to `to` of a plan to the draft: the top folder at the end of the Space's pinned + * section, the rest at the end of their parent folder. Large imports go in several store updates, so + * no single one holds up the windows (IM-14). Tabs are asleep, have no favicon (it comes when the + * page first loads) and count as never used. Returns false once there is nowhere left to add to + * (the Space or the new folder was deleted part-way). Draft helper. + */ +export function applyPlacement( + d: OrbitState, + spaceId: SpaceId, + plan: Placement, + from: number, + to: number, + placed: PlacedFolder, + ids: Map, + now: number, +): boolean { + const space = d.spaces[spaceId] + if (!space || (placed.folderId && !d.folders[placed.folderId])) return false + for (const step of plan.steps.slice(from, to)) { + if (step.kind === 'folder' && step.parent === -1) { + // Sub-folders start closed; the top folder too when it holds more than COLLAPSE_OVER items. + const root: Folder = { + id: newId('f'), + name: step.name, + children: [], + collapsed: plan.tabs + plan.folders > COLLAPSE_OVER, + createdAt: now, + } + d.folders[root.id] = root + insertItem(d, { kind: 'pinned', spaceId }, space.pinned.length, root.id) + ids.set(0, root.id) + placed.folderId = root.id + placed.folderIds.push(root.id) + continue + } + // A folder deleted part-way: what was to go in it is left out. + const parent = d.folders[ids.get(step.parent) ?? ''] + if (!parent) continue + if (step.kind === 'folder') { + const folder: Folder = { id: newId('f'), name: step.name, children: [], collapsed: true, createdAt: now } + d.folders[folder.id] = folder + parent.children.push(folder.id) + ids.set(step.key, folder.id) + placed.folderIds.push(folder.id) + } else { + const tab: Tab = makeTab( + { + spaceId, + profileId: space.profileId, + url: step.url, + title: step.title, + pinnedUrl: step.url, + pinnedResolvedUrl: step.url, + pinnedTitle: step.title, + createdAt: now, + // Never used in Orbit: recent-tab lists and Ctrl+Tab don't fill up with them. + lastActiveAt: 0, + }, + now, + ) + // Frozen first, as Immer advises for data added in bulk: it then skips walking each new tab. + d.tabs[tab.id] = freeze(tab) + parent.children.push(tab.id) + placed.tabIds.push(tab.id) + } + } + return true +} + +/** + * Adds folder `name` with `nodes` to the end of the Space's pinned section in one go (IM-05). When + * every bookmark is left out, nothing is added, not even an empty folder. Draft helper. + */ +export function placeBookmarks( + d: OrbitState, + spaceId: SpaceId, + name: string, + nodes: readonly BookmarkNode[], + now: number, +): PlacedFolder | undefined { + if (!d.spaces[spaceId]) return undefined + const plan = planPlacement(name, nodes) + const placed: PlacedFolder = { folderId: '', tabIds: [], folderIds: [], skipped: plan.skipped } + if (plan.tabs === 0) return placed + applyPlacement(d, spaceId, plan, 0, plan.steps.length, placed, new Map(), now) + return placed +} + +/** Reasons for left-out bookmarks, as summary lines. */ +export function skippedLines(skipped: Map): { what: string; count: number; reason: string }[] { + return [...skipped].map(([reason, count]) => ({ what: count === 1 ? 'bookmark' : 'bookmarks', count, reason })) +} diff --git a/src/main/import/files.ts b/src/main/import/files.ts new file mode 100644 index 00000000..c11e199e --- /dev/null +++ b/src/main/import/files.ts @@ -0,0 +1,46 @@ +import type { BookmarkNode, ParsedFile } from '../../shared/commands/import' +import { parseLinkList } from '../../shared/commands/import' +import { fallbackTitle } from '../../shared/url' +import { bookmark, countBookmarks, NO_BOOKMARKS, parseBookmarksHtml } from './netscape' + +/** Files larger than this aren't bookmark files. */ +export const MAX_FILE_BYTES = 64 * 1024 * 1024 +/** A tree from a renderer is cut off beyond these. */ +const MAX_NODES = 200_000 +const MAX_DEPTH = 64 + +/** IM-13: the folder a bookmarks file goes into. */ +export const FILE_FOLDER_NAME = 'Imported bookmarks' + +/** Reads a dropped or chosen file: a bookmarks HTML file, or a plain list of links (IM-13). */ +export function parseFile(name: string, text: string): ParsedFile { + const looksHtml = /<(!doctype|dl|dt|a)[\s>]/i.test(text) + if (/\.txt$/i.test(name) || !looksHtml) { + const { links } = parseLinkList(text) + if (links.length > 0) return { kind: 'links', name, text, count: links.length } + return { kind: 'error', name, message: NO_BOOKMARKS } + } + const tree = parseBookmarksHtml(text) + if (!tree) return { kind: 'error', name, message: NO_BOOKMARKS } + return { kind: 'bookmarks', name, tree, count: countBookmarks(tree) } +} + +/** A bookmark tree sent back by the import window, checked again (addresses, size, depth). */ +export function cleanTree(nodes: unknown): BookmarkNode[] { + let budget = MAX_NODES + let seq = 0 + const walk = (list: unknown, depth: number): BookmarkNode[] => { + if (!Array.isArray(list) || depth > MAX_DEPTH) return [] + const out: BookmarkNode[] = [] + for (const raw of list) { + if (budget-- <= 0) break + const n = raw as Partial + const title = typeof n?.title === 'string' ? n.title : '' + if (Array.isArray(n?.children)) out.push({ id: `c${seq++}`, title, children: walk(n.children, depth + 1) }) + else if (typeof n?.url === 'string' && n.url) + out.push(bookmark(`c${seq++}`, title || fallbackTitle(n.url), n.url)) + } + return out + } + return walk(nodes, 0) +} diff --git a/src/main/import/index.ts b/src/main/import/index.ts index f287cd3f..b3bb7cf4 100644 --- a/src/main/import/index.ts +++ b/src/main/import/index.ts @@ -1,4 +1,143 @@ +import { readFile, stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { BrowserWindow, dialog } from 'electron' +import { ACTIONS_BY_ID } from '../../shared/actions' +import type { ImportPlan, ImportProgressItem, ImportSource, ParsedFile } from '../../shared/commands/import' +import type { WindowId } from '../../shared/state' import type { MainContext } from '../context' +import type { HistoryService } from '../history' +import type { CallContext } from '../registry' +import { cleanTree, FILE_FOLDER_NAME, MAX_FILE_BYTES, parseFile } from './files' +import { ImportJobs, type Task } from './jobs' +import { countBookmarks, NO_BOOKMARKS } from './netscape' +import { importPlaces } from './places' +import { IMPORT_WINDOW_ID, ImportWindow } from './window' -/** Import feature (main process). Owned by its build lane; see docs/ARCHITECTURE.md. */ -export function registerImport(_ctx: MainContext): void {} +export function registerImport(ctx: MainContext, _history: HistoryService): void { + const { commands, actions, store, windows } = ctx + const jobs = new ImportJobs(ctx) + const win = new ImportWindow(ctx, () => { + const windowId = win.browserWindow() + const spaceId = windowId ? store.state.windows[windowId]?.spaceId : store.state.spaceOrder[0] + return { + ...(spaceId ? { spaceId, profileId: store.state.spaces[spaceId]?.profileId } : {}), + job: jobs.status(), + } + }) + /** The browser window a call acts for (the import window and onboarding act for one). */ + const browserOf = (c: CallContext): WindowId | undefined => { + const id = c.windowId + return id && store.state.windows[id]?.kind === 'main' ? id : win.browserWindow() + } + + commands.register('import.open', ({ start }, c) => { + if (c.windowId === IMPORT_WINDOW_ID) win.open(undefined, start) + else win.open(c.windowId ?? windows.focusedWindowId(), start) + }) + commands.register('import.session', () => win.takeSession()) + commands.register('import.sources', async (): Promise => { + importPlaces(ctx.e2e) + return [] + }) + commands.register('import.parseFile', ({ name, text }): ParsedFile => { + const file = typeof name === 'string' ? basename(name) : 'file' + if (typeof text !== 'string' || text.length > MAX_FILE_BYTES) + return { kind: 'error', name: file, message: NO_BOOKMARKS } + return parseFile(file, text) + }) + commands.register('import.chooseFile', async (_args, c): Promise => { + const parent = (c.windowId === IMPORT_WINDOW_ID ? win.window : undefined) ?? BrowserWindow.getFocusedWindow() + const options: Electron.OpenDialogOptions = { + title: 'Import from a File', + properties: ['openFile'], + filters: [ + { name: 'Bookmarks or links', extensions: ['html', 'htm', 'txt'] }, + { name: 'All files', extensions: ['*'] }, + ], + } + const picked = parent ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options) + const path = picked.canceled ? undefined : picked.filePaths[0] + if (!path) return null + const name = basename(path) + try { + if ((await stat(path)).size > MAX_FILE_BYTES) return { kind: 'error', name, message: NO_BOOKMARKS } + return parseFile(name, await readFile(path, 'utf8')) + } catch { + return { kind: 'error', name, message: 'Orbit couldn’t read this file' } + } + }) + + commands.register('import.start', ({ plan }, c) => { + const p = plan as ImportPlan + const item = (type: ImportProgressItem['type'], total?: number): ImportProgressItem => ({ + type, + state: 'waiting', + done: 0, + ...(total !== undefined ? { total } : {}), + }) + if (p?.kind === 'file') { + const bookmarks = item('bookmarks') + const tasks: Task[] = [ + { + item: bookmarks, + // The tree comes back from the import window: checked again, a turn after the call returns. + read: async (job) => { + await new Promise((resolve) => setImmediate(resolve)) + const nodes = cleanTree(p.bookmarks) + bookmarks.total = countBookmarks(nodes) + job.bookmarks.set('', [{ title: '', nodes }]) + }, + add: async (job) => jobs.addBookmarks(job, bookmarks), + }, + ] + return jobs.start(p, basename(String(p.name ?? 'file')), FILE_FOLDER_NAME, tasks, { windowId: browserOf(c) }) + } + throw new Error('Unknown import') + }) + commands.register('import.status', ({ jobId }) => jobs.status(jobId)) + commands.register('import.cancel', ({ jobId }) => jobs.cancel(jobId)) + commands.register('import.commit', ({ jobId }) => jobs.commit(jobId)) + commands.register('import.close', () => win.close()) + commands.register('import.suggest', () => undefined) + commands.register('import.exportPinned', async () => null) + + // Done (IM-14): the browser window shows the imported folder, in its Space, pinned section open. + commands.register('import.reveal', async ({ jobId }, c) => { + const job = jobs.get(jobId) + const folder = job?.status.folders[0] + const windowId = (job?.opener && store.state.windows[job.opener] ? job.opener : undefined) ?? browserOf(c) + if (c.windowId === IMPORT_WINDOW_ID) win.close() + if (!folder || !windowId || !store.state.folders[folder.folderId]) return + const run = commands.run as unknown as (n: string, a: unknown, cc: CallContext) => Promise + if (store.state.windows[windowId]?.spaceId !== folder.spaceId) { + await run('spaces.switch', { spaceId: folder.spaceId }, { windowId }) + } + if (store.state.spaces[folder.spaceId]?.pinnedCollapsed) { + await run('tabs.setPinnedCollapsed', { spaceId: folder.spaceId, collapsed: false }, { windowId }) + } + const bw = windows.get(windowId) + if (bw && !bw.isDestroyed()) bw.focus() + windows.sendToWindow(windowId, 'sidebar:reveal', { id: folder.folderId }) + }) + + actions.register('app.import', ({ windowId }) => win.open(windowId)) + + // Onboarding's import is held until Start browsing commits it; ending another way adds nothing (OB-14). + store.subscribe((next, prev) => { + if (next.settings.onboarded && !prev.settings.onboarded) jobs.dropHeld() + }) + + // Keys pressed in the import window: ⌘W closes it; app and window actions act for its browser window. + actions.intercept((id, c) => { + if (c.windowId !== IMPORT_WINDOW_ID) return false + if (id === 'tab.close' || id === 'window.close') win.close() + else if (id === 'app.import') win.focus() + else if (ACTIONS_BY_ID[id]?.category === 'App' || ACTIONS_BY_ID[id]?.category === 'Window') { + const target = win.browserWindow() + const bw = target ? windows.get(target) : undefined + if (bw && !bw.isDestroyed()) bw.focus() + void actions.run(id, { windowId: target }) + } + return true + }) +} diff --git a/src/main/import/jobs.ts b/src/main/import/jobs.ts new file mode 100644 index 00000000..54ebd611 --- /dev/null +++ b/src/main/import/jobs.ts @@ -0,0 +1,323 @@ +import { monitorEventLoopDelay } from 'node:perf_hooks' +import type { + BookmarkNode, + ImportJob, + ImportPlan, + ImportProgressItem, + ImportSkip, + ImportTarget, +} from '../../shared/commands/import' +import { DEFAULT_PROFILE_ID } from '../../shared/defaults' +import { newId } from '../../shared/ids' +import type { FolderId, OrbitState, ProfileId, SpaceId, TabId, WindowId } from '../../shared/state' +import { containerItems, insertItem, locate, removeItem } from '../../shared/tree' +import { sidebarUndo } from '../archive/inbox' +import type { MainContext } from '../context' +import type { CallContext } from '../registry' +import { reselectAway } from '../tabs/model' +import { applyPlacement, freeFolderName, type PlacedFolder, planPlacement, skippedLines } from './bookmarks' + +/** Where one part of an import goes: bookmarks to the Space, the rest to its profile (IM-06). */ +export interface Destination { + spaceId: SpaceId + profileId: ProfileId +} + +/** One line of the progress view: read first (nothing added yet), then add. */ +export interface Task { + item: ImportProgressItem + read?: (job: Job) => Promise + add: (job: Job) => Promise +} + +/** A running import. Tasks read everything first, so Cancel before adding imports nothing (IM-01). */ +export interface Job { + status: ImportJob + plan: ImportPlan + opener: WindowId | undefined + cancelled: boolean + /** Bookmarks to add, per destination key (a source profile, or '' for a single target). */ + bookmarks: Map + destinations: Map + /** The root folder's base name, e.g. "Imported from Chrome" (IM-05) or "Imported bookmarks" (IM-13). */ + folderName: string + /** Source profiles' names by key: sub-folder names, and 'matching' destinations' names. */ + profileNames: Map + extra: Map +} + +export const IMPORT_UNDO_LABEL = 'Import Bookmarks' +const PROGRESS_EVERY_MS = 100 +/** Pinned items added per store update: a few milliseconds of work each. */ +const PLACE_CHUNK = 250 + +/** Runs imports one after another; tells every Orbit UI how they go (`import.progress`). */ +export class ImportJobs { + private jobs = new Map() + private latest: string | undefined + private queue: Promise = Promise.resolve() + private sent = 0 + private sendTimer: NodeJS.Timeout | undefined + private release = new Map void>() + + constructor(private readonly ctx: MainContext) {} + + get(jobId?: string): Job | undefined { + const id = jobId ?? this.latest + return id ? this.jobs.get(id) : undefined + } + + status(jobId?: string): ImportJob | null { + return this.get(jobId)?.status ?? null + } + + /** Starts an import made of `tasks`; returns its id. */ + start(plan: ImportPlan, source: string, folderName: string, tasks: Task[], c: CallContext): string { + const id = newId('x').replace(/^x-/, 'import-') + const job: Job = { + status: { id, phase: 'reading', source, items: tasks.map((t) => t.item), skipped: [], folders: [] }, + plan, + opener: c.windowId, + cancelled: false, + bookmarks: new Map(), + destinations: new Map(), + folderName, + profileNames: new Map(), + extra: new Map(), + } + this.jobs.set(id, job) + this.latest = id + const run = this.queue.then(() => this.run(job, tasks)) + this.queue = run.catch(() => undefined) + run.catch((err: unknown) => { + console.error('[import] failed:', err) + job.status.phase = 'failed' + job.status.error = err instanceof Error ? err.message : String(err) + this.changed(job, true) + }) + this.changed(job, true) + return id + } + + /** Cancel: only while nothing has been added (reading, or held for onboarding). */ + cancel(jobId: string): void { + const job = this.jobs.get(jobId) + if (!job || (job.status.phase !== 'reading' && job.status.phase !== 'held')) return + job.cancelled = true + job.status.phase = 'cancelled' + this.release.get(jobId)?.() + this.changed(job, true) + } + + /** Adds a held import (onboarding's Start browsing). */ + commit(jobId: string): void { + const job = this.jobs.get(jobId) + if (!job || job.cancelled) return + job.plan.hold = false + this.release.get(jobId)?.() + } + + /** Held imports nobody committed are dropped (onboarding ended with Skip setup, OB-14). */ + dropHeld(): void { + for (const job of this.jobs.values()) if (job.plan.hold && !job.cancelled) this.cancel(job.status.id) + } + + changed(job: Job, now = false): void { + if (job.status.id !== this.latest && !now) return + const send = () => { + this.sendTimer = undefined + this.sent = Date.now() + this.ctx.windows.broadcast('orbit:import.progress', job.status) + } + if (now || Date.now() - this.sent >= PROGRESS_EVERY_MS) { + clearTimeout(this.sendTimer) + send() + } else if (!this.sendTimer) { + this.sendTimer = setTimeout(send, PROGRESS_EVERY_MS) + } + } + + private async run(job: Job, tasks: Task[]): Promise { + const started = performance.now() + const lag = monitorEventLoopDelay({ resolution: 5 }) + lag.enable() + try { + for (const task of tasks) { + if (job.cancelled) return + if (!task.read) continue + task.item.state = 'running' + this.changed(job) + await task.read(job) + if (task.item.state === 'running') task.item.state = 'waiting' + } + if (job.cancelled) return + if (job.plan.hold) { + job.status.phase = 'held' + this.changed(job, true) + await new Promise((resolve) => this.release.set(job.status.id, resolve)) + this.release.delete(job.status.id) + if (job.cancelled) return + } + job.status.phase = 'adding' + this.changed(job, true) + await this.resolveDestinations(job) + for (const task of tasks) { + if (task.item.state === 'failed' || task.item.state === 'skipped') continue + task.item.state = 'running' + this.changed(job) + await task.add(job) + if (task.item.state === 'running') task.item.state = 'done' + this.changed(job) + } + job.status.phase = 'done' + } finally { + // One more turn of the event loop, so the histogram sees the last stretch of work end. + await new Promise((resolve) => setTimeout(resolve, 20)) + lag.disable() + job.status.stats = { ms: Math.round(performance.now() - started), longestTaskMs: Math.round(lag.max / 1e6) } + this.changed(job, true) + } + } + + /** Makes the Spaces and profiles the plan asks for (IM-06). */ + private async resolveDestinations(job: Job): Promise { + const target: ImportTarget = job.plan.target + const state = this.ctx.store.state + const keys = new Set([...job.bookmarks.keys(), ...job.destinations.keys()]) + if (keys.size === 0) keys.add('') + const run = this.ctx.commands.run as unknown as (n: string, a: unknown, c: CallContext) => Promise + const c: CallContext = { windowId: job.opener } + if (target.kind === 'matching') { + for (const key of keys) { + const name = job.profileNames.get(key) || 'Imported' + const profileId = (await run('profiles.create', { name }, c)) as ProfileId + const spaceId = (await run('spaces.create', { name, profileId, activate: false }, c)) as SpaceId + job.destinations.set(key, { spaceId, profileId }) + } + return + } + let dest: Destination | undefined + if (target.kind === 'new-space') { + const firstSpace = state.spaces[state.spaceOrder[0] ?? ''] + const profileId = state.profiles[target.profileId] + ? target.profileId + : (firstSpace?.profileId ?? DEFAULT_PROFILE_ID) + const spaceId = (await run( + 'spaces.create', + { name: target.name.trim() || 'Imported', profileId, activate: false }, + c, + )) as SpaceId + dest = { spaceId, profileId: this.ctx.store.state.spaces[spaceId]?.profileId ?? profileId } + } else { + const space = state.spaces[target.spaceId] ?? state.spaces[state.spaceOrder[0] ?? ''] + if (!space) throw new Error('No Space to import into') + dest = { spaceId: space.id, profileId: space.profileId } + } + for (const key of keys) job.destinations.set(key, dest) + } + + /** + * Adds the bookmarks read: one pinned folder per Space (IM-05), with a sub-folder per source profile + * when several go to the same Space. They go in store updates of PLACE_CHUNK items, with a turn of + * the event loop between, so the windows keep answering (IM-14). One undo step takes it all back. + */ + async addBookmarks(job: Job, item: ImportProgressItem): Promise { + const bySpace = new Map() + for (const [key, groups] of job.bookmarks) { + const dest = job.destinations.get(key) + if (!dest) continue + const list = bySpace.get(dest.spaceId) ?? [] + list.push({ key, groups }) + bySpace.set(dest.spaceId, list) + } + const now = Date.now() + const skipped = new Map() + let added = 0 + for (const [spaceId, perProfile] of bySpace) { + const asNodes = (groups: { title: string; nodes: BookmarkNode[] }[]): BookmarkNode[] => + groups.flatMap((g) => (g.title ? [{ id: g.title, title: g.title, children: g.nodes }] : g.nodes)) + const nodes: BookmarkNode[] = + perProfile.length === 1 + ? asNodes(perProfile[0]?.groups ?? []) + : perProfile.map(({ key, groups }, i) => ({ + id: `p${i}`, + title: job.profileNames.get(key) || `Profile ${i + 1}`, + children: asNodes(groups), + })) + const plan = planPlacement(freeFolderName(this.ctx.store.state, spaceId, job.folderName), nodes) + for (const [reason, n] of plan.skipped) skipped.set(reason, (skipped.get(reason) ?? 0) + n) + if (plan.tabs === 0) continue + const placed: PlacedFolder = { folderId: '', tabIds: [], folderIds: [], skipped: plan.skipped } + const ids = new Map() + for (let from = 0; from < plan.steps.length; from += PLACE_CHUNK) { + await new Promise((resolve) => setImmediate(resolve)) + let more = true + this.ctx.store.update((d) => { + more = applyPlacement(d, spaceId, plan, from, from + PLACE_CHUNK, placed, ids, now) + }) + item.done = added + placed.tabIds.length + this.changed(job) + if (!more) break + } + added += placed.tabIds.length + if (!placed.folderId || !this.ctx.store.state.folders[placed.folderId]) continue + job.status.folders.push({ folderId: placed.folderId, spaceId }) + this.recordUndo(placed.folderId) + } + item.done = added + job.status.skipped.push(...skippedLines(skipped)) + } + + addSkips(job: Job, skips: ImportSkip[]): void { + job.status.skipped.push(...skips.filter((s) => s.count > 0)) + } + + /** ⌘Z takes an import back: its folder goes, with every tab in it; ⌘⇧Z puts it back (TB-18). */ + private recordUndo(rootId: FolderId): void { + const stack = sidebarUndo() + if (!stack) return + const store = this.ctx.store + const undo = () => { + const state = store.state + const loc = locate(state, rootId) + if (!loc) return undefined + const snap = subtree(state, rootId) + const tabSet = new Set(snap.tabs.map((t) => t.id)) + store.update((d) => { + reselectAway(d, tabSet) + removeItem(d, rootId) + for (const f of snap.folders) delete d.folders[f.id] + for (const t of snap.tabs) delete d.tabs[t.id] + }) + for (const id of tabSet) this.ctx.tabs.destroy(id) + return () => { + store.update((d) => { + if (!containerItems(d, loc.container)) return + for (const f of snap.folders) d.folders[f.id] = structuredClone(f) + for (const t of snap.tabs) d.tabs[t.id] = { ...structuredClone(t), asleep: true, loading: false } + insertItem(d, loc.container, loc.index, rootId) + }) + this.recordUndo(rootId) + } + } + stack.push(IMPORT_UNDO_LABEL, undo, Date.now(), () => !store.state.folders[rootId]) + } +} + +/** A folder's own folders and tabs, copied (for undo). */ +function subtree(state: OrbitState, rootId: FolderId) { + const folders: OrbitState['folders'][string][] = [] + const tabs: OrbitState['tabs'][string][] = [] + const walk = (id: string) => { + const f = state.folders[id] + if (f) { + folders.push(structuredClone(f)) + for (const child of f.children) walk(child) + return + } + const t = state.tabs[id] + if (t) tabs.push(structuredClone(t)) + } + walk(rootId) + return { folders, tabs } +} diff --git a/src/main/import/netscape.ts b/src/main/import/netscape.ts new file mode 100644 index 00000000..fd7439c3 --- /dev/null +++ b/src/main/import/netscape.ts @@ -0,0 +1,175 @@ +/** + * The Netscape bookmark file (IM-13, IM-17): the HTML every browser exports and imports. + * Pure (no Electron), so it is unit-tested directly. + * + * Reading keeps folders (H3 + DL), their order, and each link's title and address; it ignores + * everything else (icons, dates, descriptions, tags). Writing produces the same format. + */ +import type { BookmarkNode } from '../../shared/commands/import' + +export const NO_BOOKMARKS = 'No bookmarks found in this file' + +const CLOSE_H3 = /<\/h3\s*>/gi +const CLOSE_A = /<\/a\s*>/gi + +const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' } + +export function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => { + if (code[0] === '#') { + const n = + code[1] === 'x' || code[1] === 'X' ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10) + return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole + } + return ENTITIES[code.toLowerCase()] ?? whole + }) +} + +function cleanText(html: string): string { + return decodeEntities(html.replace(/<[^>]*>/g, '')) + .replace(/\s+/g, ' ') + .trim() +} + +function attribute(attrs: string, name: string): string | undefined { + const m = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs) + if (!m) return undefined + return decodeEntities(m[1] ?? m[2] ?? m[3] ?? '').trim() +} + +/** + * Why a bookmark's address can't be a pinned tab, or undefined when it can. Web pages and local + * files are kept; a browser's own pages, bookmarklets and saved searches are not. + */ +export function skipReason(url: string): string | undefined { + if (url.length > 4096) return 'Address too long' + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1]?.toLowerCase() + if (!scheme) return 'Not a web address' + if (scheme === 'http' || scheme === 'https') { + try { + return new URL(url).host ? undefined : 'Not a web address' + } catch { + return 'Not a web address' + } + } + if (scheme === 'file') return undefined + if (scheme === 'javascript') return 'Bookmarklet (a script, not a page)' + if (scheme === 'place') return 'Saved search, not a page' + if (/^(chrome|edge|brave|vivaldi|opera|about|chrome-extension|moz-extension|extension|safari-resource)$/.test(scheme)) + return 'A page only that browser can open' + return 'Not a web address' +} + +/** A bookmark node for `url`, marked when it can't be imported. */ +export function bookmark(id: string, title: string, url: string): BookmarkNode { + const reason = skipReason(url) + return reason ? { id, title, url, skip: reason } : { id, title, url } +} + +/** Bookmarks that will become pinned tabs (not counting skipped ones). */ +export function countBookmarks(nodes: readonly BookmarkNode[]): number { + let n = 0 + for (const node of nodes) { + if (node.children) n += countBookmarks(node.children) + else if (node.url && !node.skip) n++ + } + return n +} + +/** Every bookmark, skipped ones included. */ +export function countAll(nodes: readonly BookmarkNode[]): number { + let n = 0 + for (const node of nodes) n += node.children ? countAll(node.children) : node.url ? 1 : 0 + return n +} + +/** + * Reads a bookmarks HTML file. Returns the top-level items (the file's root list), or undefined + * when it holds no bookmarks: not this format, empty, or too broken to read. + */ +export function parseBookmarksHtml(html: string): BookmarkNode[] | undefined { + if (!/]/i.test(html) && /]/i.test(html))) { + return undefined + } + const root: BookmarkNode[] = [] + const stack: BookmarkNode[][] = [root] + /** A folder whose H3 was just read: the next DL holds its children. */ + let opened: BookmarkNode | undefined + let seq = 0 + const tag = /<(\/?)(dl|h3|a)\b([^>]*)>/gi + for (let m = tag.exec(html); m; m = tag.exec(html)) { + const closing = m[1] === '/' + const name = (m[2] as string).toLowerCase() + const attrs = m[3] ?? '' + const level = stack[stack.length - 1] as BookmarkNode[] + if (name === 'dl') { + if (closing) { + if (stack.length > 1) stack.pop() + } else { + // A DL right after a folder's title is that folder's list; any other DL is the file's own. + stack.push(opened?.children ?? level) + } + opened = undefined + continue + } + if (closing) continue + const close = name === 'h3' ? CLOSE_H3 : CLOSE_A + close.lastIndex = tag.lastIndex + const end = close.exec(html)?.index ?? -1 + const inner = end >= 0 ? html.slice(tag.lastIndex, end) : '' + if (end >= 0) tag.lastIndex = end + if (name === 'h3') { + const folder: BookmarkNode = { id: `n${seq++}`, title: cleanText(inner) || 'Untitled folder', children: [] } + level.push(folder) + opened = folder + } else { + opened = undefined + const href = attribute(attrs, 'href') + if (!href) continue + level.push(bookmark(`n${seq++}`, cleanText(inner) || href, href)) + } + } + return countAll(root) > 0 ? root : undefined +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +/** One folder or link of an export (IM-17). */ +export interface ExportNode { + title: string + url?: string + children?: ExportNode[] + addDate?: number +} + +/** Writes a bookmarks HTML file that other browsers import (IM-17). */ +export function writeBookmarksHtml(nodes: readonly ExportNode[]): string { + const lines = [ + '', + '', + '', + 'Bookmarks', + '

Bookmarks

', + '

', + ] + const walk = (list: readonly ExportNode[], depth: number) => { + const pad = ' '.repeat(depth) + for (const node of list) { + const date = node.addDate ? ` ADD_DATE="${Math.floor(node.addDate / 1000)}"` : '' + if (node.children) { + lines.push(`${pad}

${escapeHtml(node.title)}`, `${pad}

`) + walk(node.children, depth + 1) + lines.push(`${pad}

`) + } else if (node.url) { + lines.push(`${pad}

${escapeHtml(node.title)}`) + } + } + } + walk(nodes, 1) + lines.push('

', '') + return lines.join('\n') +} diff --git a/src/main/import/places.ts b/src/main/import/places.ts new file mode 100644 index 00000000..0a31fd6c --- /dev/null +++ b/src/main/import/places.ts @@ -0,0 +1,109 @@ +import { homedir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import type { BrowserKind } from '../../shared/commands/import' + +/** A folder where a browser keeps its profiles. */ +export interface Place { + browser: BrowserKind + dir: string +} + +/** Fixture places for tests: JSON `[{ "browser": "chrome", "dir": "…" }]`. */ +export const PLACES_ENV = 'ORBIT_IMPORT_PLACES' + +const KINDS = new Set([ + 'chrome', + 'chromium', + 'brave', + 'edge', + 'vivaldi', + 'opera', + 'opera-gx', + 'firefox', + 'safari', +]) + +/** Where each browser keeps its profiles on this OS (paths only: nothing is read here). */ +export function standardPlaces(platform: NodeJS.Platform, home: string, env: NodeJS.ProcessEnv): Place[] { + if (platform === 'darwin') { + const lib = join(home, 'Library', 'Application Support') + return [ + { browser: 'chrome', dir: join(lib, 'Google', 'Chrome') }, + { browser: 'chromium', dir: join(lib, 'Chromium') }, + { browser: 'brave', dir: join(lib, 'BraveSoftware', 'Brave-Browser') }, + { browser: 'edge', dir: join(lib, 'Microsoft Edge') }, + { browser: 'vivaldi', dir: join(lib, 'Vivaldi') }, + { browser: 'opera', dir: join(lib, 'com.operasoftware.Opera') }, + { browser: 'opera-gx', dir: join(lib, 'com.operasoftware.OperaGX') }, + { browser: 'firefox', dir: join(lib, 'Firefox') }, + { browser: 'safari', dir: join(home, 'Library', 'Safari') }, + ] + } + if (platform === 'win32') { + const local = env.LOCALAPPDATA || join(home, 'AppData', 'Local') + const roaming = env.APPDATA || join(home, 'AppData', 'Roaming') + return [ + { browser: 'chrome', dir: join(local, 'Google', 'Chrome', 'User Data') }, + { browser: 'chromium', dir: join(local, 'Chromium', 'User Data') }, + { browser: 'brave', dir: join(local, 'BraveSoftware', 'Brave-Browser', 'User Data') }, + { browser: 'edge', dir: join(local, 'Microsoft', 'Edge', 'User Data') }, + { browser: 'vivaldi', dir: join(local, 'Vivaldi', 'User Data') }, + { browser: 'opera', dir: join(roaming, 'Opera Software', 'Opera Stable') }, + { browser: 'opera-gx', dir: join(roaming, 'Opera Software', 'Opera GX Stable') }, + { browser: 'firefox', dir: join(roaming, 'Mozilla', 'Firefox') }, + ] + } + const config = env.XDG_CONFIG_HOME || join(home, '.config') + return [ + { browser: 'chrome', dir: join(config, 'google-chrome') }, + { browser: 'chromium', dir: join(config, 'chromium') }, + { browser: 'brave', dir: join(config, 'BraveSoftware', 'Brave-Browser') }, + { browser: 'edge', dir: join(config, 'microsoft-edge') }, + { browser: 'vivaldi', dir: join(config, 'vivaldi') }, + { browser: 'opera', dir: join(config, 'opera') }, + { browser: 'firefox', dir: join(home, '.mozilla', 'firefox') }, + // Firefox installed as a Snap keeps its profiles in the Snap's home. + { browser: 'firefox', dir: join(home, 'snap', 'firefox', 'common', '.mozilla', 'firefox') }, + ] +} + +/** Reads fixture places (relative folders resolve against the working directory). */ +export function parsePlaces(json: string): Place[] { + let list: unknown + try { + list = JSON.parse(json) + } catch { + throw new Error(`${PLACES_ENV} is not JSON`) + } + if (!Array.isArray(list)) throw new Error(`${PLACES_ENV} must be a list`) + return list.flatMap((p) => { + const browser = (p as Place)?.browser + const dir = (p as Place)?.dir + if (!KINDS.has(browser) || typeof dir !== 'string' || !dir) return [] + return [{ browser, dir: isAbsolute(dir) ? dir : resolve(dir) }] + }) +} + +/** + * The one place Orbit learns where to look for browsers (IM-02). Tests set fixture places. In a + * test run without them Orbit looks nowhere and reports the mistake as an uncaught error, so the + * test fails (the quit check, D28) instead of ever reading someone's real profiles. + */ +export function importPlaces( + e2e: boolean, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + home: string = homedir(), + refuse: (message: string) => void = (message) => + setImmediate(() => { + throw new Error(message) + }), +): Place[] { + const fixture = env[PLACES_ENV] + if (fixture !== undefined) return parsePlaces(fixture) + if (e2e) { + refuse(`[import] ${PLACES_ENV} is not set in a test run, so no browser was searched`) + return [] + } + return standardPlaces(platform, home, env) +} diff --git a/src/main/import/window.ts b/src/main/import/window.ts new file mode 100644 index 00000000..729b8d58 --- /dev/null +++ b/src/main/import/window.ts @@ -0,0 +1,136 @@ +import { BrowserWindow, nativeTheme, type Rectangle, screen } from 'electron' +import type { ImportSession, ImportStart } from '../../shared/commands/import' +import type { WindowId } from '../../shared/state' +import { NEUTRAL_THEME, paletteFor } from '../../shared/theme' +import type { MainContext } from '../context' +import { guardOwnWindow } from '../settings/ui-window' +import { loadRenderer, uiWebPreferences } from '../windows' + +/** The import window's id for IPC, shortcuts and actions (not a browser window). */ +export const IMPORT_WINDOW_ID = 'w-import' + +const SIZE = { width: 680, height: 600 } +const MIN_SIZE = { width: 560, height: 480 } +const isMac = process.platform === 'darwin' + +/** + * The import window (IM-01): one at a time, over the browser window it was opened from. It is that + * window's child but not modal, so browsing goes on while an import runs (IM-14). + */ +export class ImportWindow { + private win: BrowserWindow | undefined + private openerId: WindowId | undefined + private start: ImportStart | undefined + private nonce = 0 + + constructor( + private readonly ctx: MainContext, + private readonly session: () => Omit, + ) { + nativeTheme.on('updated', () => this.window?.setBackgroundColor(this.background())) + } + + get window(): BrowserWindow | undefined { + return this.win && !this.win.isDestroyed() ? this.win : undefined + } + + /** The browser window the import acts for: its opener while open, else the last one used. */ + browserWindow(): WindowId | undefined { + const state = this.ctx.store.state + if (this.openerId && state.windows[this.openerId] && this.ctx.windows.get(this.openerId)) return this.openerId + const ids = this.ctx.windows.ids().filter((id) => state.windows[id]?.kind === 'main') + return ids[ids.length - 1] + } + + /** What the page shows: the start it was opened with, once. */ + takeSession(): ImportSession { + const start = this.start + this.start = undefined + return { ...this.session(), ...(start ? { start } : {}), nonce: this.nonce } + } + + open(from: WindowId | undefined, start?: ImportStart): void { + const state = this.ctx.store.state + if (from && state.windows[from]?.kind === 'main') this.openerId = from + this.start = start + this.nonce++ + const win = this.window + if (win) { + this.focus() + if (start && !win.webContents.isLoading()) win.webContents.send('orbit:import.session', this.takeSession()) + return + } + this.create() + } + + focus(): void { + const win = this.window + if (!win) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() + } + + close(): void { + this.window?.close() + } + + private create(): void { + const opener = this.openerId ? this.ctx.windows.get(this.openerId) : undefined + const win = new BrowserWindow({ + ...this.initialBounds(opener), + minWidth: MIN_SIZE.width, + minHeight: MIN_SIZE.height, + show: false, + title: 'Import', + fullscreenable: false, + minimizable: false, + ...(opener && !opener.isDestroyed() ? { parent: opener } : {}), + backgroundColor: this.background(), + ...(isMac + ? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 16 } } + : { autoHideMenuBar: true }), + webPreferences: uiWebPreferences(IMPORT_WINDOW_ID, 'import'), + }) + this.win = win + const contents = win.webContents + this.ctx.windows.registerUi(contents, IMPORT_WINDOW_ID) + this.ctx.windows.attachShortcuts(contents) + guardOwnWindow(contents, 'import') + win.once('ready-to-show', () => { + if (win.isDestroyed()) return + win.show() + win.focus() + }) + win.once('closed', () => { + if (this.win === win) this.win = undefined + // Back to the browser window it was opened over. + const back = this.browserWindow() + const target = back ? this.ctx.windows.get(back) : undefined + if (target && !target.isDestroyed() && !this.ctx.windows.quitting) target.focus() + }) + loadRenderer(contents, 'import') + } + + /** Centred over the browser window it was opened from. */ + private initialBounds(opener: BrowserWindow | undefined): Rectangle { + const around = opener && !opener.isDestroyed() ? opener.getBounds() : undefined + const area = (around ? screen.getDisplayMatching(around) : screen.getPrimaryDisplay()).workArea + const width = Math.min(SIZE.width, area.width) + const height = Math.min(SIZE.height, area.height) + const centre = around + ? { x: around.x + around.width / 2, y: around.y + around.height / 2 } + : { x: area.x + area.width / 2, y: area.y + area.height / 2 } + const x = Math.round(Math.min(Math.max(centre.x - width / 2, area.x), area.x + area.width - width)) + const y = Math.round(Math.min(Math.max(centre.y - height / 2, area.y), area.y + area.height - height)) + return { x, y, width, height } + } + + private background(): string { + const state = this.ctx.store.state + const windowId = this.browserWindow() + const spaceId = (windowId ? state.windows[windowId]?.spaceId : undefined) ?? state.spaceOrder[0] + const theme = (spaceId ? state.spaces[spaceId]?.theme : undefined) ?? NEUTRAL_THEME + return paletteFor(theme, nativeTheme.shouldUseDarkColors).surface + } +} diff --git a/src/renderer/src/import/BookmarkTree.tsx b/src/renderer/src/import/BookmarkTree.tsx new file mode 100644 index 00000000..953427d2 --- /dev/null +++ b/src/renderer/src/import/BookmarkTree.tsx @@ -0,0 +1,145 @@ +import { ChevronRight, Folder } from 'lucide-react' +import { memo, useMemo, useState } from 'react' +import type { BookmarkNode } from '../../../shared/commands/import' +import { leafKey, leafKeys, type TreeRoot } from './tree' + +function hostOf(url: string): string { + try { + return new URL(url).host.replace(/^www\./, '') + } catch { + return url + } +} + +/** + * A source's bookmark tree with checkboxes (IM-04). A folder's box sets every bookmark in it, and + * shows mixed when only some are chosen. Folders below the top level start closed, so a big tree + * draws only what is opened. + */ +export function BookmarkTree({ + roots, + selected, + onChange, +}: { + roots: TreeRoot[] + selected: ReadonlySet + onChange: (next: Set) => void +}) { + const leaves = useMemo(() => new Map(), []) + const leavesOf = (root: string, node: BookmarkNode) => { + let list = leaves.get(node) + if (!list) { + list = leafKeys(root, node.children ?? []) + leaves.set(node, list) + } + return list + } + const setMany = (keys: string[], on: boolean) => { + const next = new Set(selected) + for (const k of keys) { + if (on) next.add(k) + else next.delete(k) + } + onChange(next) + } + const ctx: RowContext = { selected, leavesOf, setMany } + return ( +

+ {roots.map((root) => + root.title ? ( + + ) : ( + root.nodes.map((n) => ) + ), + )} +
+ ) +} + +interface RowContext { + selected: ReadonlySet + leavesOf: (root: string, node: BookmarkNode) => string[] + setMany: (keys: string[], on: boolean) => void +} + +function Row({ root, node, depth, ctx }: { root: string; node: BookmarkNode; depth: number; ctx: RowContext }) { + if (node.children) return + if (!node.url) return null + const key = leafKey(root, node) + const on = !node.skip && ctx.selected.has(key) + return ( + + ) +} + +const FolderRow = memo(function FolderRow({ + root, + node, + depth, + ctx, +}: { + root: string + node: BookmarkNode + depth: number + ctx: RowContext +}) { + const [open, setOpen] = useState(depth === 0) + const keys = ctx.leavesOf(root, node) + let chosen = 0 + for (const k of keys) if (ctx.selected.has(k)) chosen++ + const all = keys.length > 0 && chosen === keys.length + const some = chosen > 0 && !all + return ( +
+
+ + { + if (el) el.indeterminate = some + }} + aria-label={`Import ${node.title}`} + onChange={() => ctx.setMany(keys, !all)} + /> +
+ {open && node.children?.map((n) => )} +
+ ) +}) diff --git a/src/renderer/src/import/ImportApp.tsx b/src/renderer/src/import/ImportApp.tsx new file mode 100644 index 00000000..5be13e00 --- /dev/null +++ b/src/renderer/src/import/ImportApp.tsx @@ -0,0 +1,50 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ImportSession } from '../../../shared/commands/import' +import { NEUTRAL_THEME } from '../../../shared/theme' +import { invoke, isMac } from '../api' +import { useOrbit } from '../store' +import { cachedPalette, paletteVars, prefersReducedMotion, useDark } from '../theme' +import { ImportFlow } from './ImportFlow' + +/** + * The import window (IM-01): the import flow in the colours of the Space it was opened from. Main + * sends a new session when it is asked again with a start (a dropped file, a paste in the sidebar). + */ +export function ImportApp() { + const [session, setSession] = useState() + useEffect(() => { + let latest = -1 + const apply = (s: ImportSession) => { + if (s.nonce < latest) return + latest = s.nonce + setSession(s) + } + void invoke('import.session', {}).then(apply) + return window.orbit.on('import.session', (payload) => apply(payload as ImportSession)) + }, []) + const spaceId = useOrbit((s) => session?.spaceId ?? s.spaceOrder[0]) + const theme = useOrbit((s) => (spaceId ? s.spaces[spaceId]?.theme : undefined)) ?? NEUTRAL_THEME + const dark = useDark() + const palette = cachedPalette(theme, dark) + const style = useMemo(() => paletteVars(palette), [palette]) + useThemeFade(dark) + return ( +
+ {isMac &&
} + {session && void invoke('import.close', {})} />} +
+ ) +} + +function useThemeFade(dark: boolean): void { + const last = useRef(dark) + useLayoutEffect(() => { + if (last.current === dark) return + last.current = dark + if (prefersReducedMotion()) return + const root = document.documentElement + root.dataset.themeFade = '' + const t = setTimeout(() => delete root.dataset.themeFade, 260) + return () => clearTimeout(t) + }, [dark]) +} diff --git a/src/renderer/src/import/ImportFlow.tsx b/src/renderer/src/import/ImportFlow.tsx new file mode 100644 index 00000000..fed51e5d --- /dev/null +++ b/src/renderer/src/import/ImportFlow.tsx @@ -0,0 +1,720 @@ +import { ArrowRight, ClipboardList, FileUp, HelpCircle } from 'lucide-react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { + ImportDataType, + ImportJob, + ImportPlan, + ImportSession, + ImportSource, + ImportStart, + ImportTarget, + Inspection, + ParsedFile, +} from '../../../shared/commands/import' +import { parseLinkList } from '../../../shared/commands/import' +import { invoke } from '../api' +import { useOrbit } from '../store' +import { BookmarkTree } from './BookmarkTree' +import { MOVING_HEADING, MOVING_INTRO, MOVING_ROUTES, PASTE_DEFAULT_NAME } from './copy' +import { Progress } from './Progress' +import { SourcePicker, TypesPicker } from './sources' +import { filterTree, leafKeys, type TreeRoot } from './tree' +import './import.css' + +type Route = 'browser' | 'file' | 'links' +type StepId = 'source' | 'profiles' | 'types' | 'bookmarks' | 'links' | 'target' | 'progress' + +const STEP_TITLES: Record = { + source: 'Bring your bookmarks along', + profiles: 'Choose profiles', + types: 'What to import', + bookmarks: 'Choose bookmarks', + links: 'Paste a list of links', + target: 'Where should it go?', + progress: 'Importing', +} + +interface Target { + spaceId: string + profileId: string + newName: string + matching: boolean +} + +/** + * The import flow (IM-01…IM-06, IM-12, IM-13): Back, Next and Cancel through the steps a route + * needs. Nothing is imported before the last step's Import, and Cancel imports nothing. The import + * window shows it, and so does onboarding's import step (`embedded`, OB-03), where the import is + * held until onboarding ends. + */ +export function ImportFlow({ + session, + embedded, + onClose, + onStarted, +}: { + session: ImportSession | undefined + embedded?: boolean + /** Cancel (the window closes; onboarding moves on: "Do this later"). */ + onClose: () => void + /** Embedded: the import was started (held). */ + onStarted?: (jobId: string) => void +}) { + const [step, setStep] = useState('source') + const [route, setRoute] = useState() + const [sources, setSources] = useState() + const [sourceId, setSourceId] = useState() + const [profileIds, setProfileIds] = useState([]) + const [inspection, setInspection] = useState() + const [types, setTypes] = useState([]) + const [file, setFile] = useState>() + const [fileError, setFileError] = useState() + const [linksText, setLinksText] = useState('') + const [folderName, setFolderName] = useState(PASTE_DEFAULT_NAME) + const [selected, setSelected] = useState>(new Set()) + const [target, setTarget] = useState() + const [job, setJob] = useState(null) + const [busy, setBusy] = useState(false) + const bodyRef = useRef(null) + + const source = sources?.find((s) => s.id === sourceId) + const roots: TreeRoot[] = useMemo(() => { + if (route === 'file') return file ? [{ key: '', nodes: file.tree }] : [] + if (route !== 'browser' || !inspection || inspection === 'loading') return [] + const withMarks = inspection.profiles.filter((p) => p.bookmarks) + return withMarks.map((p) => ({ + key: p.profileId, + ...(withMarks.length > 1 ? { title: p.name } : {}), + nodes: p.bookmarks?.tree ?? [], + })) + }, [route, file, inspection]) + const allKeys = useMemo(() => roots.flatMap((r) => leafKeys(r.key, r.nodes)), [roots]) + + const steps = useMemo((): StepId[] => { + if (route === 'file') return ['source', 'bookmarks', 'target', 'progress'] + if (route === 'links') return ['source', 'links', 'target', 'progress'] + const list: StepId[] = ['source'] + if (source && source.profiles.length > 1) list.push('profiles') + list.push('types') + if (types.includes('bookmarks')) list.push('bookmarks') + list.push('target', 'progress') + return list + }, [route, source, types]) + + const go = useCallback((to: StepId) => { + setStep(to) + requestAnimationFrame(() => bodyRef.current?.querySelector('h1')?.focus({ preventScroll: true })) + }, []) + + // A new tree (a file, a profile's bookmarks) starts with everything chosen (IM-04). + useEffect(() => setSelected(new Set(allKeys)), [allKeys]) + + // Browsers on this computer (IM-02). + useEffect(() => { + void invoke('import.sources', {}).then(setSources) + }, []) + + /** A dropped or chosen file (IM-13): its bookmarks to choose from, its links, or why it can't be read. */ + const openFile = (parsed: ParsedFile) => { + setFileError(undefined) + if (parsed.kind === 'error') { + setFileError(`${parsed.name}: ${parsed.message}`) + setRoute(undefined) + setStep('source') + return + } + if (parsed.kind === 'links') { + setRoute('links') + setLinksText(parsed.text) + go('links') + return + } + setRoute('file') + setFile(parsed) + go('bookmarks') + } + + // Where the window was asked to start (a dropped file, a paste in the sidebar), else a running import. + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening (the flow is keyed by it) + useEffect(() => { + const start: ImportStart | undefined = session?.start + if (start?.kind === 'links') { + setRoute('links') + setLinksText(start.text) + go('links') + } else if (start?.kind === 'file') { + openFile(start.file) + } else if (!embedded && (session?.job?.phase === 'reading' || session?.job?.phase === 'adding')) { + setJob(session.job) + setStep('progress') + } + }, []) + + useEffect( + () => + window.orbit.on('import.progress', (status) => { + setJob((current) => (current && current.id === (status as ImportJob).id ? (status as ImportJob) : current)) + }), + [], + ) + + const chooseFile = async () => { + const parsed = await invoke('import.chooseFile', {}) + if (parsed) openFile(parsed) + } + + // Inspect the chosen profiles when they change (IM-03). + const inspect = useCallback(async (id: string, profiles: string[]) => { + setInspection('loading') + const result = await invoke('import.inspect', { sourceId: id, profileIds: profiles }) + setInspection(result) + const offered = new Set() + for (const p of result.profiles) { + if (p.bookmarks) offered.add('bookmarks') + if (p.history) offered.add('history') + } + // Everything the source offers starts checked, except cookies (P2, off by default, IM-10). + setTypes([...offered]) + }, []) + + const pickSource = (id: string) => { + const s = sources?.find((x) => x.id === id) + if (!s || s.blocked) return + setRoute('browser') + setSourceId(id) + const firstProfile = s.profiles[0]?.id + setProfileIds(firstProfile ? [firstProfile] : []) + setInspection(undefined) + } + + const index = steps.indexOf(step) + const linkCount = useMemo(() => parseLinkList(linksText).links.length, [linksText]) + + const canNext = (() => { + switch (step) { + case 'source': + return route === 'browser' && !!source && !source.blocked + case 'profiles': + return profileIds.length > 0 + case 'types': + return types.length > 0 && inspection !== 'loading' && !!inspection + case 'bookmarks': + return selected.size > 0 || (route === 'browser' && types.length > 1) + case 'links': + return linkCount > 0 && folderName.trim().length > 0 + case 'target': + return !!target && (target.spaceId !== 'new' || target.newName.trim().length > 0) && !busy + default: + return false + } + })() + + const next = async () => { + if (!canNext) return + const to = steps[index + 1] + if (step === 'source' && route === 'browser' && sourceId && source && source.profiles.length <= 1) { + void inspect(sourceId, profileIds) + } + if (step === 'profiles' && sourceId) void inspect(sourceId, profileIds) + if (step === 'target') { + await start() + return + } + if (to) go(to) + } + + const back = () => { + const to = steps[index - 1] + if (!to) return + if (to === 'source') { + setRoute(undefined) + setFile(undefined) + } + go(to) + } + + const plan = (): ImportPlan | undefined => { + if (!target) return undefined + const t: ImportTarget = target.matching + ? { kind: 'matching' } + : target.spaceId === 'new' + ? { kind: 'new-space', name: target.newName.trim(), profileId: target.profileId } + : { kind: 'space', spaceId: target.spaceId } + const hold = embedded ? { hold: true } : {} + if (route === 'file' && file) { + return { kind: 'file', name: file.name, bookmarks: filterTree(file.tree, '', selected), target: t, ...hold } + } + if (route === 'links') return { kind: 'links', folderName: folderName.trim(), text: linksText, target: t, ...hold } + if (route === 'browser' && sourceId && inspection && inspection !== 'loading') { + return { + kind: 'browser', + sourceId, + profiles: inspection.profiles.map((p) => ({ + profileId: p.profileId, + types: types.filter((ty) => ty !== 'bookmarks' || !!p.bookmarks), + ...(types.includes('bookmarks') && p.bookmarks + ? { bookmarks: filterTree(p.bookmarks.tree, p.profileId, selected) } + : {}), + })), + target: t, + ...hold, + } + } + return undefined + } + + const start = async () => { + const p = plan() + if (!p || busy) return + setBusy(true) + try { + const jobId = await invoke('import.start', { plan: p }) + if (embedded) { + onStarted?.(jobId) + return + } + setJob(await invoke('import.status', { jobId })) + go('progress') + } finally { + setBusy(false) + } + } + + const cancel = () => { + if (job && (job.phase === 'reading' || job.phase === 'held')) void invoke('import.cancel', { jobId: job.id }) + onClose() + } + + // Dropping a bookmarks file or a list of links on the flow starts that route (IM-13). + const onDrop = async (e: React.DragEvent) => { + const f = e.dataTransfer.files[0] + if (!f || step === 'progress') return + e.preventDefault() + openFile(await invoke('import.parseFile', { name: f.name, text: await f.text() })) + } + + // Enter is Next and Escape is Back (the window: Escape on the first step cancels). In onboarding the + // first step leaves both keys to onboarding, so Enter moves on ("Do this later") as on every step. + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.defaultPrevented || e.nativeEvent.isComposing || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return + const el = e.target instanceof Element ? e.target : null + if (e.key === 'Enter' && !el?.closest('button, a, input, select, textarea, label, [role="radio"]')) { + if (step === 'source' && !canNext) return + e.preventDefault() + if (step !== 'progress') void next() + } else if (e.key === 'Escape' && !(el instanceof HTMLSelectElement)) { + if (index > 0 && step !== 'progress') { + e.preventDefault() + back() + } else if (!embedded) { + e.preventDefault() + if (step === 'progress') onClose() + else cancel() + } + } + } + + const nextLabel = step === 'target' ? 'Import' : 'Next' + const title = step === 'source' && embedded ? 'Bring your bookmarks along' : STEP_TITLES[step] + const running = job && (job.phase === 'reading' || job.phase === 'adding') + + return ( +
{ + if (e.dataTransfer.types.includes('Files') && step !== 'progress') { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + } + }} + onDrop={(e) => void onDrop(e)} + > +
+

+ {title} +

+ {step === 'source' && ( + <> +

+ Copy bookmarks and history from another browser on this computer, or bring them from a file. +

+ void next()} /> +
+ } + title="From a file…" + text="A bookmarks file (.html) or a list of links (.txt). You can also drop it here." + testId="import-from-file" + onClick={() => void chooseFile()} + /> + } + title="Paste a list of links" + text="One address per line, or “Title — address”." + testId="import-paste" + onClick={() => { + setRoute('links') + go('links') + }} + /> +
+ {fileError && ( +

+ {fileError} +

+ )} + + + )} + {step === 'profiles' && source && } + {step === 'types' && ( + sourceId && void inspect(sourceId, profileIds)} + /> + )} + {step === 'bookmarks' && ( + <> +
+ + {selected.size.toLocaleString()} of {allKeys.length.toLocaleString()} bookmarks selected + + + +
+ + + )} + {step === 'links' && ( + + )} + {step === 'target' && ( + + )} + {step === 'progress' && job && } +
+
+ {step === 'progress' ? ( + <> + {running && job?.phase === 'reading' ? ( + + ) : ( + + )} + {job?.phase === 'done' ? ( + + ) : ( + + )} + + ) : ( + <> + +
+ {index > 0 && ( + + )} + {/* In onboarding, "Do this later" weighs the same as Next (OB-03). */} + +
+ + )} +
+
+ ) +} + +export function Button({ + children, + onClick, + primary, + disabled, + testId, +}: { + children: ReactNode + onClick: () => void + primary?: boolean + disabled?: boolean + testId?: string +}) { + return ( + + ) +} + +function OtherWay({ + icon, + title, + text, + testId, + onClick, +}: { + icon: ReactNode + title: string + text: string + testId: string + onClick: () => void +}) { + return ( + + ) +} + +/** "Coming from …?": the routes in plain words (IM-12). Nothing is read from that browser. */ +function MovingHelp() { + return ( +
+ + +

{MOVING_INTRO}

+
    + {MOVING_ROUTES.map((r) => ( +
  • + {r.title}. {r.text} +
  • + ))} +
+
+ ) +} + +function ProfilesStep({ + source, + chosen, + onChange, +}: { + source: ImportSource + chosen: string[] + onChange: (ids: string[]) => void +}) { + return ( + <> +

{source.name} has several profiles. Choose one or more.

+
+ {source.profiles.map((p) => ( + + ))} +
+ + ) +} + +function LinksStep({ + text, + onText, + name, + onName, + count, +}: { + text: string + onText: (t: string) => void + name: string + onName: (n: string) => void + count: number +}) { + return ( + <> +

+ Paste links copied from another browser: one address per line, or “Title — address”. Each becomes a pinned tab, + in this order, in one new folder. +

+