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).
This commit is contained in:
ken 2026-04-17 23:34:41 +08:00
commit 788961859f
9 changed files with 1570 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
.idea

21
LICENSE Normal file
View file

@ -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.

129
README.md Normal file
View file

@ -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 %>
<% } %>
---
```

129
README.zh-TW.md Normal file
View file

@ -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 %>
<% } %>
---
```

9
manifest.json Normal file
View file

@ -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
}

610
package-lock.json generated Normal file
View file

@ -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
}
}
}

22
package.json Normal file
View file

@ -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"
}
}

632
src/main.ts Normal file
View file

@ -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<string, LocaleStrings> = {
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<BookData[]> {
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<string, any>[] = [];
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<string, KoboHighlight[]>();
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<string>();
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)";
}
}
}

15
tsconfig.json Normal file
View file

@ -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"]
}