From 788961859f3222ca541c5a30391fc3aaa3a3c2da Mon Sep 17 00:00:00 2001 From: ken Date: Fri, 17 Apr 2026 23:34:41 +0800 Subject: [PATCH] feat: initial release of Kobo Note Sync Import Kobo highlights and annotations into Obsidian with customizable Eta.js templates, cross-platform support (macOS/Windows), append-only sync, and i18n (en/zh-TW). --- .gitignore | 3 + LICENSE | 21 ++ README.md | 129 ++++++++++ README.zh-TW.md | 129 ++++++++++ manifest.json | 9 + package-lock.json | 610 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 22 ++ src/main.ts | 632 ++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 15 ++ 9 files changed, 1570 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh-TW.md create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/main.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9612a9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f57885b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kenkyou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..100a572 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Kobo Note Sync + +[繁體中文](README.zh-TW.md) + +An Obsidian plugin that imports highlights and annotations from your Kobo e-reader into Obsidian notes. + +## Features + +- Import highlights and annotations from Kobo Desktop App's SQLite database +- Customizable note and highlight templates using [Eta.js](https://eta.js.org/) syntax +- Append-only sync — existing highlights in Obsidian are preserved even if deleted on Kobo +- Rich metadata support (23 book variables + 5 highlight variables) +- Cross-platform: macOS and Windows +- i18n: English and Traditional Chinese (follows Obsidian language setting) + +## How It Works + +``` +Kobo Device / App → Kobo Cloud → Kobo Desktop App → Kobo.sqlite → This Plugin → Obsidian Notes +``` + +The plugin reads the local `Kobo.sqlite` database created by the Kobo Desktop App and generates Markdown notes in your vault. + +## Installation + +### Manual Installation + +1. Download `main.js`, `manifest.json`, and `sql-wasm.wasm` from the [latest release](https://github.com/KennKyou/obsidian-kobo-note-sync/releases) +2. Create a folder `kobo-note-sync` in your vault's `.obsidian/plugins/` directory +3. Copy the downloaded files into that folder +4. Restart Obsidian and enable the plugin in Settings → Community Plugins + +### Prerequisites + +- [Kobo Desktop App](https://www.kobo.com/desktop) installed and signed in with your Kobo account +- Sync your library in Kobo Desktop App so that `Kobo.sqlite` is up to date + +## Usage + +1. Open Command Palette (`Cmd/Ctrl + P`) and run **Kobo Note Sync** +2. Or click the book icon in the left sidebar + +Highlights will be created as Markdown files in the configured output folder (default: `Kobo Note Sync/`). + +## Settings + +| Setting | Description | +|---------|-------------| +| **Kobo database path** | Path to `Kobo.sqlite`. Auto-detected based on your OS. | +| **Output folder** | Folder in your vault for highlight notes | +| **Note template** | Eta.js template for each book note | +| **Highlight template** | Eta.js template for each highlight | + +### Default Database Paths + +| OS | Path | +|----|------| +| macOS | `~/Library/Application Support/Kobo/Kobo Desktop Edition/Kobo.sqlite` | +| Windows | `%LOCALAPPDATA%\Kobo\Kobo Desktop Edition\Kobo.sqlite` | + +## Templates + +Templates use [Eta.js](https://eta.js.org/) syntax: `<%= it.variableName %>` for output, `<% if (it.condition) { %>` for logic. + +### Note Template Variables + +| Variable | Description | +|----------|-------------| +| `it.bookTitle` | Book title | +| `it.bookAuthor` | Author | +| `it.publisher` | Publisher | +| `it.isbn` | ISBN | +| `it.series` | Series name | +| `it.seriesNumber` | Series number | +| `it.subtitle` | Subtitle | +| `it.language` | Language | +| `it.description` | Book description (HTML stripped) | +| `it.progress` | Reading progress % | +| `it.status` | Reading status (`unread` / `reading` / `finished`) | +| `it.numPages` | Number of pages | +| `it.wordCount` | Word count | +| `it.averageRating` | Average rating on Kobo | +| `it.ratingCount` | Rating count | +| `it.timeSpentReading` | Time spent reading (seconds) | +| `it.timesStartedReading` | Times started reading | +| `it.dateAdded` | Date added to library | +| `it.dateCreated` | Book creation date | +| `it.lastRead` | Last read date | +| `it.lastFinished` | Finished reading date | +| `it.highlightCount` | Number of highlights | +| `it.highlights` | Rendered highlights content | + +### Highlight Template Variables + +| Variable | Description | +|----------|-------------| +| `it.highlightText` | Highlighted text | +| `it.annotation` | Your note/annotation | +| `it.dateCreated` | Highlight date | +| `it.chapter` | Chapter title | +| `it.chapterProgress` | Progress within chapter (0-1) | + +### Example Templates + +**Note template:** + +``` +--- +title: "<%= it.bookTitle %>" +author: "<%= it.bookAuthor %>" +publisher: "<%= it.publisher %>" +last_read: <%= it.lastRead %> +highlight_count: <%= it.highlightCount %> +source: Kobo +--- +<%= it.highlights %> +``` + +**Highlight template:** + +``` +*<%= it.dateCreated %>*<% if (it.chapter) { %> | <%= it.chapter %><% } %> + +> <%= it.highlightText %> +<% if (it.annotation) { %> +**Note:** <%= it.annotation %> +<% } %> +--- +``` \ No newline at end of file diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000..7a0cc5c --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,129 @@ +# Kobo Note Sync + +[English](README.md) + +一款 Obsidian 插件,將 Kobo 電子書的畫線與筆記匯入 Obsidian。 + +## 功能 + +- 從 Kobo Desktop App 的 SQLite 資料庫匯入畫線與筆記 +- 使用 [Eta.js](https://eta.js.org/) 語法自訂筆記與畫線模板 +- 僅新增同步 — 即使在 Kobo 上刪除畫線,Obsidian 中的紀錄仍會保留 +- 豐富的 metadata 支援(23 個書籍變數 + 5 個畫線變數) +- 跨平台:macOS 與 Windows +- 多語系:英文與繁體中文(自動跟隨 Obsidian 語言設定) + +## 運作原理 + +``` +Kobo 閱讀器 / App → Kobo 雲端 → Kobo Desktop App → Kobo.sqlite → 本插件 → Obsidian 筆記 +``` + +插件會讀取 Kobo Desktop App 建立的本機 `Kobo.sqlite` 資料庫,將畫線生成為 Markdown 筆記。 + +## 安裝 + +### 手動安裝 + +1. 從 [最新版本](https://github.com/KennKyou/obsidian-kobo-note-sync/releases) 下載 `main.js`、`manifest.json` 和 `sql-wasm.wasm` +2. 在 Vault 的 `.obsidian/plugins/` 目錄下建立 `kobo-note-sync` 資料夾 +3. 將下載的檔案複製到該資料夾 +4. 重新啟動 Obsidian,在 設定 → 社群插件 中啟用 + +### 前置需求 + +- 安裝 [Kobo Desktop App](https://www.kobo.com/desktop) 並登入你的 Kobo 帳號 +- 在 Kobo Desktop App 中同步書庫,確保 `Kobo.sqlite` 為最新狀態 + +## 使用方式 + +1. 開啟命令面板(`Cmd/Ctrl + P`)執行 **Kobo Note Sync** +2. 或點擊左側工具列的書本圖示 + +畫線會以 Markdown 檔案儲存在設定的輸出資料夾中(預設:`Kobo Note Sync/`)。 + +## 設定 + +| 設定項目 | 說明 | +|---------|------| +| **Kobo 資料庫路徑** | `Kobo.sqlite` 的路徑,會根據作業系統自動偵測 | +| **輸出資料夾** | Vault 中儲存畫線筆記的資料夾 | +| **筆記模板** | 每本書的 Eta.js 模板 | +| **畫線模板** | 每條畫線的 Eta.js 模板 | + +### 預設資料庫路徑 + +| 作業系統 | 路徑 | +|---------|------| +| macOS | `~/Library/Application Support/Kobo/Kobo Desktop Edition/Kobo.sqlite` | +| Windows | `%LOCALAPPDATA%\Kobo\Kobo Desktop Edition\Kobo.sqlite` | + +## 模板 + +模板使用 [Eta.js](https://eta.js.org/) 語法:`<%= it.variableName %>` 輸出變數,`<% if (it.condition) { %>` 做條件判斷。 + +### 筆記模板變數 + +| 變數 | 說明 | +|------|------| +| `it.bookTitle` | 書名 | +| `it.bookAuthor` | 作者 | +| `it.publisher` | 出版社 | +| `it.isbn` | ISBN | +| `it.series` | 系列名稱 | +| `it.seriesNumber` | 系列編號 | +| `it.subtitle` | 副標題 | +| `it.language` | 語言 | +| `it.description` | 書籍簡介(已去除 HTML 標籤) | +| `it.progress` | 閱讀進度 % | +| `it.status` | 閱讀狀態(`unread` / `reading` / `finished`) | +| `it.numPages` | 頁數 | +| `it.wordCount` | 字數 | +| `it.averageRating` | Kobo 平均評分 | +| `it.ratingCount` | 評分數量 | +| `it.timeSpentReading` | 閱讀花費時間(秒) | +| `it.timesStartedReading` | 開始閱讀次數 | +| `it.dateAdded` | 加入書庫日期 | +| `it.dateCreated` | 書籍建立日期 | +| `it.lastRead` | 最後閱讀日期 | +| `it.lastFinished` | 完成閱讀日期 | +| `it.highlightCount` | 畫線數量 | +| `it.highlights` | 渲染後的畫線內容 | + +### 畫線模板變數 + +| 變數 | 說明 | +|------|------| +| `it.highlightText` | 畫線原文 | +| `it.annotation` | 你的筆記備註 | +| `it.dateCreated` | 畫線日期 | +| `it.chapter` | 章節名稱 | +| `it.chapterProgress` | 章節內進度(0-1) | + +### 模板範例 + +**筆記模板:** + +``` +--- +title: "<%= it.bookTitle %>" +author: "<%= it.bookAuthor %>" +publisher: "<%= it.publisher %>" +last_read: <%= it.lastRead %> +highlight_count: <%= it.highlightCount %> +source: Kobo +--- +<%= it.highlights %> +``` + +**畫線模板:** + +``` +*<%= it.dateCreated %>*<% if (it.chapter) { %> | <%= it.chapter %><% } %> + +> <%= it.highlightText %> +<% if (it.annotation) { %> +**Note:** <%= it.annotation %> +<% } %> +--- +``` \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7e257e0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "kobo-note-sync", + "name": "Kobo Note Sync", + "version": "1.0.0", + "minAppVersion": "1.0.0", + "description": "Import highlights and annotations from Kobo e-reader into Obsidian notes.", + "author": "kenkyou", + "isDesktopOnly": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c42c8a8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,610 @@ +{ + "name": "kobo-note-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kobo-note-sync", + "version": "1.0.0", + "dependencies": { + "eta": "^4.5.1", + "sql.js": "^1.14.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.20.0", + "obsidian": "^1.5.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/eta": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.5.1.tgz", + "integrity": "sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..31cfc42 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "kobo-note-sync", + "version": "1.0.0", + "description": "Import Kobo highlights and annotations into Obsidian notes", + "main": "main.js", + "scripts": { + "build": "esbuild src/main.ts --bundle --outfile=dist/main.js --external:obsidian --external:electron --format=cjs --platform=node --target=es2020 && npm run copy-assets", + "dev": "esbuild src/main.ts --bundle --outfile=dist/main.js --external:obsidian --external:electron --format=cjs --platform=node --target=es2020 --watch", + "copy-assets": "cp manifest.json node_modules/sql.js/dist/sql-wasm.wasm dist/", + "deploy": "npm run build && cp dist/main.js dist/manifest.json dist/sql-wasm.wasm \"$OBSIDIAN_PLUGINS/kobo-note-sync/\"" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.20.0", + "obsidian": "^1.5.0", + "typescript": "^5.4.0" + }, + "dependencies": { + "eta": "^4.5.1", + "sql.js": "^1.14.1" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f830d8d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,632 @@ +import { App, Notice, Plugin, PluginSettingTab, Setting, normalizePath } from "obsidian"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { Eta } from "eta"; +import initSqlJs from "sql.js"; + +const eta = new Eta({ autoEscape: false, rmWhitespace: false, autoTrim: [false, false] }); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface KoboHighlight { + highlightText: string; + annotation: string; + dateCreated: string; + chapter: string; + chapterProgress: number; +} + +interface BookMeta { + bookTitle: string; + bookAuthor: string; + publisher: string; + isbn: string; + series: string; + seriesNumber: string; + subtitle: string; + language: string; + description: string; + progress: number; + status: string; + numPages: number; + wordCount: number; + averageRating: number; + ratingCount: number; + timeSpentReading: number; + timesStartedReading: number; + dateAdded: string; + dateCreated: string; + lastRead: string; + lastFinished: string; +} + +interface BookData { + meta: BookMeta; + highlights: KoboHighlight[]; +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +interface KoboHighlighterSettings { + dbPath: string; + outputFolder: string; + noteTemplate: string; + highlightTemplate: string; +} + +const DEFAULT_NOTE_TEMPLATE = `--- +title: "<%= it.bookTitle %>" +author: "<%= it.bookAuthor %>" +publisher: "<%= it.publisher %>" +last_read: <%= it.lastRead %> +highlight_count: <%= it.highlightCount %> +source: Kobo +--- +<%= it.highlights %>`; + +const DEFAULT_HIGHLIGHT_TEMPLATE = `*<%= it.dateCreated %>*<% if (it.chapter) { %> | <%= it.chapter %><% } %> + +> <%= it.highlightText %> +<% if (it.annotation) { %> +**Note:** <%= it.annotation %> +<% } %> +--- +`; + +function getDefaultDbPath(): string { + const platform = os.platform(); + if (platform === "win32") { + return path.join(os.homedir(), "AppData", "Local", "Kobo", "Kobo Desktop Edition", "Kobo.sqlite"); + } + // macOS and Linux + return "~/Library/Application Support/Kobo/Kobo Desktop Edition/Kobo.sqlite"; +} + +const DEFAULT_SETTINGS: KoboHighlighterSettings = { + dbPath: getDefaultDbPath(), + outputFolder: "Kobo Note Sync", + noteTemplate: DEFAULT_NOTE_TEMPLATE, + highlightTemplate: DEFAULT_HIGHLIGHT_TEMPLATE, +}; + +// --------------------------------------------------------------------------- +// i18n +// --------------------------------------------------------------------------- + +type LocaleStrings = { + settingsTitle: string; + dbPath: string; + dbPathDesc: string; + outputFolder: string; + outputFolderDesc: string; + noteTemplate: string; + noteTemplateDesc: string; + highlightTemplate: string; + highlightTemplateDesc: string; + noteVarsTitle: string; + highlightVarsTitle: string; + syncing: string; + noHighlights: string; + syncDone: (highlights: number, books: number) => string; + error: (msg: string) => string; +}; + +const locales: Record = { + en: { + settingsTitle: "Kobo Note Sync Settings", + dbPath: "Kobo database path", + dbPathDesc: "Path to Kobo.sqlite (supports ~ for home directory)", + outputFolder: "Output folder", + outputFolderDesc: "Folder in your vault to save highlight notes", + noteTemplate: "Note template", + noteTemplateDesc: "Eta.js template for each book note. Use <%= it.varName %> for variables.", + highlightTemplate: "Highlight template", + highlightTemplateDesc: "Eta.js template for each highlight. Use <%= it.varName %> for variables.", + noteVarsTitle: "Note template variables", + highlightVarsTitle: "Highlight template variables", + syncing: "Kobo Note Sync: Syncing...", + noHighlights: "Kobo Note Sync: No highlights found.", + syncDone: (h, b) => `Kobo Note Sync: Synced ${h} highlights from ${b} book(s).`, + error: (msg) => `Kobo Note Sync: Error — ${msg}`, + }, + "zh-TW": { + settingsTitle: "Kobo Note Sync 設定", + dbPath: "Kobo 資料庫路徑", + dbPathDesc: "Kobo.sqlite 的路徑(支援 ~ 代表家目錄)", + outputFolder: "輸出資料夾", + outputFolderDesc: "在 Vault 中儲存畫線筆記的資料夾", + noteTemplate: "筆記模板", + noteTemplateDesc: "每本書的 Eta.js 模板。使用 <%= it.varName %> 插入變數。", + highlightTemplate: "畫線模板", + highlightTemplateDesc: "每條畫線的 Eta.js 模板。使用 <%= it.varName %> 插入變數。", + noteVarsTitle: "筆記模板可用變數", + highlightVarsTitle: "畫線模板可用變數", + syncing: "Kobo Note Sync:同步中…", + noHighlights: "Kobo Note Sync:未找到任何畫線。", + syncDone: (h, b) => `Kobo Note Sync:已同步 ${b} 本書的 ${h} 條畫線。`, + error: (msg) => `Kobo Note Sync:錯誤 — ${msg}`, + }, +}; + +function getLocale(): LocaleStrings { + const lang = window.localStorage.getItem("language") || "en"; + return locales[lang] || locales["en"]; +} + +// --------------------------------------------------------------------------- +// Variable reference (for settings UI) +// --------------------------------------------------------------------------- + +interface VarInfo { + name: string; + en: string; + zhTW: string; +} + +const NOTE_VARS: VarInfo[] = [ + { name: "bookTitle", en: "Book title", zhTW: "書名" }, + { name: "bookAuthor", en: "Author", zhTW: "作者" }, + { name: "publisher", en: "Publisher", zhTW: "出版社" }, + { name: "isbn", en: "ISBN", zhTW: "ISBN" }, + { name: "series", en: "Series name", zhTW: "系列名稱" }, + { name: "seriesNumber", en: "Series number", zhTW: "系列編號" }, + { name: "subtitle", en: "Subtitle", zhTW: "副標題" }, + { name: "language", en: "Language", zhTW: "語言" }, + { name: "description", en: "Book description (HTML stripped)", zhTW: "書籍簡介(已去 HTML)" }, + { name: "progress", en: "Reading progress %", zhTW: "閱讀進度 %" }, + { name: "status", en: "Reading status (unread/reading/finished)", zhTW: "閱讀狀態(unread/reading/finished)" }, + { name: "numPages", en: "Number of pages", zhTW: "頁數" }, + { name: "wordCount", en: "Word count", zhTW: "字數" }, + { name: "averageRating", en: "Average rating on Kobo", zhTW: "Kobo 平均評分" }, + { name: "ratingCount", en: "Rating count", zhTW: "評分數量" }, + { name: "timeSpentReading", en: "Time spent reading (seconds)", zhTW: "閱讀花費時間(秒)" }, + { name: "timesStartedReading", en: "Times started reading", zhTW: "開始閱讀次數" }, + { name: "dateAdded", en: "Date added to library", zhTW: "加入書庫日期" }, + { name: "dateCreated", en: "Book creation date", zhTW: "書籍建立日期" }, + { name: "lastRead", en: "Last read date", zhTW: "最後閱讀日期" }, + { name: "lastFinished", en: "Finished reading date", zhTW: "完成閱讀日期" }, + { name: "highlightCount", en: "Number of highlights", zhTW: "畫線數量" }, + { name: "highlights", en: "Rendered highlights content", zhTW: "渲染後的畫線內容" }, +]; + +const HIGHLIGHT_VARS: VarInfo[] = [ + { name: "highlightText", en: "Highlighted text", zhTW: "畫線原文" }, + { name: "annotation", en: "Your note/annotation", zhTW: "你的筆記備註" }, + { name: "dateCreated", en: "Highlight date", zhTW: "畫線日期" }, + { name: "chapter", en: "Chapter title", zhTW: "章節名稱" }, + { name: "chapterProgress", en: "Progress within chapter (0-1)", zhTW: "章節內進度(0-1)" }, +]; + +// --------------------------------------------------------------------------- +// Database (cross-platform via sql.js WASM) +// --------------------------------------------------------------------------- + +function expandHome(p: string): string { + if (p.startsWith("~/") || p === "~") { + return p.replace("~", os.homedir()); + } + return p; +} + +function readStatusToString(status: number): string { + switch (status) { + case 2: return "finished"; + case 1: return "reading"; + default: return "unread"; + } +} + +function parseDate(d: unknown): string { + if (!d || typeof d !== "string" || d.startsWith("0000")) return ""; + return d.split("T")[0]; +} + +function strVal(v: unknown): string { + if (v == null) return ""; + return String(v); +} + +function numVal(v: unknown, fallback = 0): number { + if (v == null) return fallback; + const n = Number(v); + return isNaN(n) ? fallback : n; +} + +async function readKoboDb(dbPath: string, pluginDir: string): Promise { + const resolvedPath = expandHome(dbPath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Database not found: ${resolvedPath}`); + } + + // Copy to temp to avoid locking issues + const tmpDir = process.env.TMPDIR || os.tmpdir(); + const tmpPath = path.join(tmpDir, `kobo-highlighter-${Date.now()}.sqlite`); + fs.copyFileSync(resolvedPath, tmpPath); + + const wasmPath = path.join(pluginDir, "sql-wasm.wasm"); + const wasmBinary = fs.readFileSync(wasmPath); + const SQL = await initSqlJs({ wasmBinary }); + const buffer = fs.readFileSync(tmpPath); + const db = new SQL.Database(buffer); + + try { + // Query book metadata + const metaStmt = db.prepare(` + SELECT + c.ContentID, + c.Title, + c.Attribution, + c.Publisher, + c.ISBN, + c.Series, + c.SeriesNumber, + c.Subtitle, + c.Language, + c.Description, + c.ReadStatus, + c.___PercentRead, + c.___NumPages, + c.WordCount, + c.AverageRating, + c.RatingCount, + c.TimeSpentReading, + c.TimesStartedReading, + c.DateCreated, + c.DateLastRead, + c.DateAdded, + c.LastTimeFinishedReading + FROM content c + WHERE c.ContentType = 6 + AND c.ContentID IN (SELECT DISTINCT VolumeID FROM Bookmark WHERE Text IS NOT NULL AND Text <> '') + ORDER BY c.Title + `); + + const metaRows: Record[] = []; + while (metaStmt.step()) { + const row = metaStmt.getAsObject(); + metaRows.push(row); + } + metaStmt.free(); + + if (metaRows.length === 0) return []; + + // Query highlights with chapter info + const hlStmt = db.prepare(` + SELECT + b.VolumeID, + b.Text, + b.Annotation, + b.DateCreated, + b.ChapterProgress, + b.ContentID, + (SELECT c2.Title FROM content c2 WHERE c2.ContentID = b.ContentID || '-1') AS ChapterTitle + FROM Bookmark b + WHERE b.Text IS NOT NULL AND b.Text <> '' + ORDER BY b.DateCreated + `); + + const highlightMap = new Map(); + while (hlStmt.step()) { + const row = hlStmt.getAsObject(); + const volumeId = strVal(row.VolumeID); + if (!highlightMap.has(volumeId)) { + highlightMap.set(volumeId, []); + } + highlightMap.get(volumeId)!.push({ + highlightText: strVal(row.Text), + annotation: strVal(row.Annotation), + dateCreated: parseDate(row.DateCreated), + chapter: strVal(row.ChapterTitle), + chapterProgress: numVal(row.ChapterProgress), + }); + } + hlStmt.free(); + + // Build BookData + const books: BookData[] = []; + for (const row of metaRows) { + const contentId = strVal(row.ContentID); + const highlights = highlightMap.get(contentId) || []; + if (highlights.length === 0) continue; + + const desc = strVal(row.Description).replace(/<[^>]*>/g, "").trim(); + + books.push({ + meta: { + bookTitle: strVal(row.Title) || "Unknown", + bookAuthor: strVal(row.Attribution) || "Unknown", + publisher: strVal(row.Publisher), + isbn: strVal(row.ISBN), + series: strVal(row.Series), + seriesNumber: strVal(row.SeriesNumber), + subtitle: strVal(row.Subtitle), + language: strVal(row.Language), + description: desc, + progress: numVal(row.___PercentRead), + status: readStatusToString(numVal(row.ReadStatus)), + numPages: numVal(row.___NumPages, -1), + wordCount: numVal(row.WordCount, -1), + averageRating: numVal(row.AverageRating), + ratingCount: numVal(row.RatingCount), + timeSpentReading: numVal(row.TimeSpentReading), + timesStartedReading: numVal(row.TimesStartedReading), + dateAdded: parseDate(row.DateAdded), + dateCreated: parseDate(row.DateCreated), + lastRead: parseDate(row.DateLastRead), + lastFinished: parseDate(row.LastTimeFinishedReading), + }, + highlights, + }); + } + + return books; + } finally { + db.close(); + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } +} + +// --------------------------------------------------------------------------- +// Filename sanitizer +// --------------------------------------------------------------------------- + +function sanitizeFilename(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, "_").trim(); +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +export default class KoboHighlighterPlugin extends Plugin { + settings: KoboHighlighterSettings = DEFAULT_SETTINGS; + + async onload() { + await this.loadSettings(); + + this.addCommand({ + id: "sync-kobo-highlights", + name: "Kobo Note Sync", + callback: () => this.syncHighlights(), + }); + + this.addRibbonIcon("book-open", "Kobo Note Sync", () => { + this.syncHighlights(); + }); + + this.addSettingTab(new KoboHighlighterSettingTab(this.app, this)); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + async syncHighlights() { + const t = getLocale(); + try { + new Notice(t.syncing); + + const pluginDir = (this.app.vault.adapter as any).getBasePath() + + path.sep + this.manifest.dir; + const books = await readKoboDb(this.settings.dbPath, pluginDir); + if (books.length === 0) { + new Notice(t.noHighlights); + return; + } + + const folder = normalizePath(this.settings.outputFolder); + if (!this.app.vault.getAbstractFileByPath(folder)) { + await this.app.vault.createFolder(folder); + } + + let totalHighlights = 0; + + for (const book of books) { + const filename = sanitizeFilename(book.meta.bookTitle) + ".md"; + const filePath = normalizePath(`${folder}/${filename}`); + const existing = this.app.vault.getAbstractFileByPath(filePath); + + // Read existing highlights to avoid duplicates + let existingHighlightTexts = new Set(); + let existingBody = ""; + if (existing) { + const content = await this.app.vault.read(existing as any); + for (const line of content.split("\n")) { + if (line.startsWith("> ")) { + existingHighlightTexts.add(line.slice(2).trim()); + } + } + const fmEnd = content.indexOf("---", content.indexOf("---") + 1); + if (fmEnd !== -1) { + existingBody = content.slice(fmEnd + 3).trim(); + } + } + + // Render only new highlights + const newHighlights = book.highlights.filter( + (h) => !existingHighlightTexts.has(h.highlightText.trim()) + ); + + let newRendered = ""; + if (newHighlights.length > 0) { + newRendered = newHighlights + .map((h) => eta.renderString(this.settings.highlightTemplate, h)) + .join("\n"); + } + + // Combine: keep existing + append new + const combinedHighlights = existingBody + ? existingBody + (newRendered ? "\n\n" + newRendered : "") + : book.highlights + .map((h) => eta.renderString(this.settings.highlightTemplate, h)) + .join("\n"); + + const noteVars = { + ...book.meta, + highlightCount: book.highlights.length, + highlights: combinedHighlights, + }; + + const noteContent = eta.renderString(this.settings.noteTemplate, noteVars); + + if (existing) { + await this.app.vault.modify(existing as any, noteContent); + } else { + await this.app.vault.create(filePath, noteContent); + } + + totalHighlights += book.highlights.length; + } + + new Notice(t.syncDone(totalHighlights, books.length)); + } catch (e: any) { + console.error("Kobo Note Sync error:", e); + new Notice(t.error(e.message)); + } + } +} + +// --------------------------------------------------------------------------- +// Settings Tab +// --------------------------------------------------------------------------- + +class KoboHighlighterSettingTab extends PluginSettingTab { + plugin: KoboHighlighterPlugin; + + constructor(app: App, plugin: KoboHighlighterPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + const t = getLocale(); + const isZh = (window.localStorage.getItem("language") || "").startsWith("zh"); + containerEl.empty(); + + containerEl.createEl("h2", { text: t.settingsTitle }); + + // Database path + new Setting(containerEl) + .setName(t.dbPath) + .setDesc(t.dbPathDesc) + .addText((text) => + text + .setPlaceholder(DEFAULT_SETTINGS.dbPath) + .setValue(this.plugin.settings.dbPath) + .onChange(async (value) => { + this.plugin.settings.dbPath = value; + await this.plugin.saveSettings(); + }) + ); + + // Output folder + new Setting(containerEl) + .setName(t.outputFolder) + .setDesc(t.outputFolderDesc) + .addText((text) => + text + .setPlaceholder(DEFAULT_SETTINGS.outputFolder) + .setValue(this.plugin.settings.outputFolder) + .onChange(async (value) => { + this.plugin.settings.outputFolder = value; + await this.plugin.saveSettings(); + }) + ); + + // Note template + new Setting(containerEl) + .setName(t.noteTemplate) + .setDesc(t.noteTemplateDesc) + .addTextArea((text) => { + text.inputEl.rows = 12; + text.inputEl.cols = 50; + text.inputEl.style.width = "100%"; + text.inputEl.style.fontFamily = "monospace"; + text.inputEl.style.fontSize = "13px"; + text + .setPlaceholder(DEFAULT_NOTE_TEMPLATE) + .setValue(this.plugin.settings.noteTemplate) + .onChange(async (value) => { + this.plugin.settings.noteTemplate = value; + await this.plugin.saveSettings(); + }); + }); + + // Note variables reference + this.renderVarTable(containerEl, t.noteVarsTitle, NOTE_VARS, isZh); + + // Highlight template + new Setting(containerEl) + .setName(t.highlightTemplate) + .setDesc(t.highlightTemplateDesc) + .addTextArea((text) => { + text.inputEl.rows = 8; + text.inputEl.cols = 50; + text.inputEl.style.width = "100%"; + text.inputEl.style.fontFamily = "monospace"; + text.inputEl.style.fontSize = "13px"; + text + .setPlaceholder(DEFAULT_HIGHLIGHT_TEMPLATE) + .setValue(this.plugin.settings.highlightTemplate) + .onChange(async (value) => { + this.plugin.settings.highlightTemplate = value; + await this.plugin.saveSettings(); + }); + }); + + // Highlight variables reference + this.renderVarTable(containerEl, t.highlightVarsTitle, HIGHLIGHT_VARS, isZh); + } + + private renderVarTable(container: HTMLElement, title: string, vars: VarInfo[], isZh: boolean): void { + const details = container.createEl("details"); + details.style.marginBottom = "1em"; + details.style.fontSize = "13px"; + + const summary = details.createEl("summary"); + summary.style.cursor = "pointer"; + summary.style.fontWeight = "bold"; + summary.style.color = "var(--text-muted)"; + summary.setText(title); + + const table = details.createEl("table"); + table.style.width = "100%"; + table.style.borderCollapse = "collapse"; + table.style.marginTop = "0.5em"; + + const thead = table.createEl("thead"); + const headerRow = thead.createEl("tr"); + for (const h of [isZh ? "變數" : "Variable", isZh ? "說明" : "Description"]) { + const th = headerRow.createEl("th", { text: h }); + th.style.textAlign = "left"; + th.style.padding = "4px 8px"; + th.style.borderBottom = "1px solid var(--background-modifier-border)"; + } + + const tbody = table.createEl("tbody"); + for (const v of vars) { + const row = tbody.createEl("tr"); + const codeCell = row.createEl("td"); + codeCell.style.padding = "3px 8px"; + codeCell.style.borderBottom = "1px solid var(--background-modifier-border)"; + const code = codeCell.createEl("code"); + code.setText(`it.${v.name}`); + code.style.fontSize = "12px"; + + const descCell = row.createEl("td", { text: isZh ? v.zhTW : v.en }); + descCell.style.padding = "3px 8px"; + descCell.style.borderBottom = "1px solid var(--background-modifier-border)"; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6ec5770 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM"], + "outDir": ".", + "sourceMap": false, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}