Last updated: 2026-07-20 06:11 AM CDT
A Logseq plugin that imports your KOReader highlights, notes, and bookmarks into your Logseq graph β one Logseq page per book, with rich properties, journal-day backlinks, and an idempotent replace-on-change sync model.
Inspiration & credits. This plugin was inspired by two prior works:
isosphere/logseq-koreader-syncfor showing that KOReader sidecars can be parsed in-browser vialuaparseand the File System Access API. No code is reused; the layout, sync engine, and rendering pipeline here are written from scratch with a different design.readwiseio/logseq-readwise-official-pluginfor the per-book-page UX, the property-based metadata model, and the toolbar/index-page conventions that this plugin mirrors.Thank you to both projects.
AI-assisted development. This plugin was built with substantial help from Claude Code. If that matters to you, read the source before you load the plugin into your graph.
Use at your own risk. This software is provided "as is", without warranty of any kind, express or implied. The author accepts no liability for data loss, graph corruption, lost highlights, broken syncs, or any other damages arising from the use of this plugin. Always back up your Logseq graph before trying any new plugin, including this one. See the LICENSE for the full disclaimer.
- KOReader 2025.04 on Android (annotations and bookmarks
written to
metadata.*.luasidecars, propagated to the desktop via Syncthing or any other file-level sync). - Logseq 0.10.x on Linux (Asahi Fedora aarch64) and macOS. The plugin uses only Logseq's standard JS plugin API, so Windows and other Linux distros are expected to work too; they have not been exhaustively tested.
Public beta. Working end-to-end against a real Calibre + KOReader library. Available on the Logseq plugin marketplace as Sync KOReader Highlights β open Logseq β β― menu β Plugins β Marketplace β search for "KOReader". You can also load the repository as an unpacked plugin if you prefer (see Loading in Logseq below).
- Click the book icon in the Logseq toolbar to open the sync panel.
- In the panel, click Choose KOReader directory⦠and pick the
folder containing your KOReader sidecar
metadata.*.luafiles (typically your Calibre library, or whatever folder Syncthing pulls from your reader). - Click Sync now. The progress log streams in the panel; on completion a toast summarises new books and new highlights.
- Subsequent syncs reuse the remembered directory β no re-picking unless you click "Choose KOReader directoryβ¦" again.
- Each sync writes one Logseq page per book, updates the
[[KOReader]]index page, and adds journal-day backlinks so every highlight surfaces on the day it was made.
β οΈ Don't edit highlights in place. The plugin keeps each book's highlights in lockstep with KOReader by rebuilding the entireHighlights synced from β¦block on every change-bearing sync β so any edits you make inside that block (rewording a quote, tweaking adate::) are overwritten the next time that book's highlights change on the device. This is deliberate: it's how highlights you delete on the reader also disappear from Logseq. Anything you write outside that block β elsewhere on the book page, in child blocks of your own, on the journal day β is never touched. Put your own notes there.
Advanced knobs (Mustache templates for the book-page header, highlights heading, and per-highlight block; auto-sync interval; the directory-handle persistence toggle) live in the standard Logseq plugin settings dialog β reach it via the panel footer's Open plugin settingsβ¦ link or the regular Plugins β β flow.
The plugin writes to a single graph, chosen the first time you sync. Logseq runs every plugin against whichever graph is currently open, so without a guard a sync β or an automatic/launch sync β fired while a different graph was open would scatter KOReader book pages across unrelated graphs.
To prevent that, the plugin binds to one graph on the first sync and refuses to write anywhere else:
- The sync panel shows a Graph row with the bound graph's name and a Bind / Re-bind to this graph button.
- The first Sync now binds the currently-open graph automatically.
- Open a different graph and Sync now is disabled with a warning; background-interval and launch syncs silently skip.
- Click Re-bind to this graph to move the binding to the open graph. Because the per-book tracking state describes the previously-bound graph, re-binding resets that state β pages already written in the old graph are left untouched for you to remove manually (e.g. via reset and delete all book pages while that graph is open).
- The
Sync KOReader Highlights: unbind graphcommand clears the binding; the next sync re-binds to whatever graph is open.
A book page with KOReader-derived properties (author, tags, summary) and the synced highlights below:
A journal day, showing every highlight made on that date as a Linked
Reference β produced automatically by the date:: page-link on each
highlight block, with no plugin-authored blocks on the journal page:
The KOReader index page, where each sync writes a receipt and every
synced book backlinks via the [[KOReader]] mention in its highlights
heading:
-
One Logseq page per book. Page name comes from the KOReader sidecar's
doc_props.title, with the following sanitisation so the name doesn't break Logseq's wikilink / page-creation rules:[/]β(/):ββ(so titles likeThis Life: Secular Faith and Spiritual FreedombecomeThis Life β Secular Faith and Spiritual Freedomfor the page address; the colon-bearing original lives in thetitle::property and renders correctly in Logseq's UI).- Title collisions get disambiguated as
<title> β <authors>or<title> (n).
-
Page-level properties (defined by the customisable Book-page header template and written via Logseq's structured
createPage(name, properties, opts)API so Logseq escapes them natively). The default template produces:author::β each KOReader author rendered as its own[[wikilink]]. Names containingLast, Firstcommas are split only on KOReader's\nseparator, never on commas inside a name.full-title::β full original title, including any colons.series::β single[[wikilink]]fromdoc_props.serieswhen present.category:: #Booksβ a constant tag so every synced book is grouped under theBookspage.summary::β the fulldoc_props.description, with HTML tags stripped, named/numeric HTML entities decoded, and Lua escapes resolved. Not truncated.tags::β comma-joined[[wikilinks]], sourced fromdoc_props.keywords(ordoc_props.subjectas fallback). Split on;,,, and newlines so KOReader's multi-line keyword form (Philosophy\<newline>Life) yields two distinct tags.
All values are sanitised before being written: newlines collapsed, duplicate
::neutralised, whitespace trimmed. Empty values cause their property line to drop out entirely. Edit the Book-page header template setting to add, remove, or rename any of these. -
Highlights / notes / bookmarks (KOReader stores all three in the
annotationstable; the renderer disambiguates by which fields are populated):- Highlight (
textnon-empty) β block content> {text}, optional KOReader-side note attached as a child block. - Note alone (
textempty,notenon-empty) β the note text becomes the block body (no blockquote prefix, noBookmarkedplaceholder). - Page bookmark (both empty) β block content
> Bookmarked.
Each block carries structured properties β order:
date,date-updated,chapter,page. Thedatevalue is a[[<journal-day>]]page-link formatted with the user'spreferredDateFormat, so Logseq's native Linked References panel surfaces every highlight on its journal day automatically (no plugin-authored blocks on the journal side). Standalone notes intentionally skipdate-updated. - Highlight (
-
Highlights heading. A single block per book, content
Highlights synced from [[KOReader]]. The[[KOReader]]link gives you a backlink to every book on the index page. The heading intentionally omits a date because each highlight block already carries its owndate::page-link to the journal day it was made.
-
Idempotency state lives in
logseq.settings, not in graph block properties:bookIdsMap: { sidecarKey β { pageUuid, title } }wheresidecarKeyismd5:<partial_md5_checksum>(stable across file moves and Calibre id changes) with ameta:<authors>|||<title>fallback for ancient sidecars.highlightIdsMap: { sidecarKey β { highlightId: true } }β dedup set keyed by<datetime>|<pos0>|<pos1>|<notes>|<text>.lastHighlightDatetimeMap,lastSync.
-
Replace-on-change update model. When a sidecar's set of highlights differs from what's recorded in the dedup map (new ones added on the device, removed ones, or both), the existing
Highlights synced from β¦block and its children are removed and rewritten in full from the current sidecar. No-op syncs (zero added, zero removed) leave the page completely untouched. Highlights deleted on the device drop out of Logseq too β the trade-off is that user edits to highlight blocks get lost on the next change-bearing sync. Edits to anything outside theHighlights synced from β¦block are preserved. -
Existing-page protection. When a Logseq page or the
KOReaderindex page already exists at sync time, the plugin never overwrites user content:- On a book page, page-level properties are added only where they aren't already present, never updated.
- The
Highlights synced from β¦block lands at the bottom of the existing content; the user's prior blocks stay above. - On the
KOReaderindex page, the sync receipt is prepended so it always sits at the top, with everything else below untouched. Old receipt blocks (both## π Sync β¦and## π Synced on β¦shapes) are removed before the new one drops in.
-
Books with zero items skipped. Books whose sidecar has neither highlights nor notes nor bookmarks aren't synced at all β no Logseq page, no entry on the index page.
-
Stub-sidecar tolerance. Sidecars without a populated
doc_props(KOReader writes those for books opened-but-never- annotated, and re-collapses to that shape if you delete the last remaining annotation on the device) are silently skipped. -
Numeric-keyed-table handling. KOReader writes sequence- shaped values (annotations, multi-author/multi-tag fields) as Lua tables with explicit
["1"] = β¦, ["2"] = β¦numeric-string keys rather than implicit sequences. The parser detects 1..N integer-keyed shapes and converts them to JS arrays so downstream code can iterate them.
A single KOReader index page receives a ## π Synced on <date> at <time> block (rewritten on every sync that touches at
least one book) listing new books as [[wikilinks]] and existing
books that gained highlights with per-book counts.
- Plugins β Β·Β·Β· β Marketplace β search for "KOReader" β install Sync KOReader Highlights.
- A book icon appears in the toolbar.
- Click it to open the sync panel, then click Choose KOReader directory⦠and pick the folder containing your KOReader sidecars (typically your Calibre library or whatever folder Syncthing pulls from your reader).
- Click Sync now.
- Plugins β Β·Β·Β· β Load unpacked plugin β point at this
repository's root directory (after
npm run build). - Same toolbar-icon β panel β pick β sync flow as above.
In Logseq β Plugins β Sync KOReader Highlights β β:
- Remember Koreader directory (default on) β persists the picked directory handle to IndexedDB so the picker is skipped on subsequent syncs and across Logseq restarts.
- Auto-sync on Logseq launch (default off) β runs a sync shortly after Logseq starts. See Auto-sync limitations below.
- Auto-sync interval (minutes) (default 0 = disabled) β runs a sync every N minutes. Background ticks never prompt for the picker (they're a no-op when the directory hasn't been remembered yet), to avoid disrupting the user.
- Book page header template (Mustache) β defines the
page-level properties on each book page. Pre-filled with a
default that produces
author,full-title,series,category:: #Books,summary, andtags. Add, remove, or rename properties by editing the template; the rendered output is parsed line-by-line askey:: valuepairs and written through Logseq's structuredcreatePageproperties API for safe escaping (same path whether you keep the default or customise). Lines that don't matchkey:: valueare dropped, so empty Mustache sections ({{#series}}β¦{{/series}}) collapse cleanly. Variables:{{title}},{{authors}},{{authorsLinked}},{{series}},{{seriesLinked}},{{tags}},{{tagsLinked}},{{language}},{{summary}}(alias{{description}}),{{koreaderId}}. - Highlights section heading template (Mustache; default
Highlights synced from [[KOReader]]) β always rendered through Mustache. Variables:{{date}}(current sync time),{{kind}}("initial sync"or"sync"). Add them back to the template if you want a date-bearing heading. - Highlight block template (Mustache; default reproduces
the inline-properties shape) β when left at the default, the
renderer uses Logseq's structured-properties API (safer
escaping). When modified, the template owns the entire block
content, including any inline
key:: valuelines you write. Variables:{{text}},{{date}},{{dateUpdated}},{{chapter}},{{page}},{{note}}, plus boolean discriminators{{isHighlight}},{{isNote}},{{isBookmark}}. A custom heading or block template's output is written to the block verbatim β KOReader values are interpolated raw, with no HTML/markdown escaping, so a custom template owns any escaping its content needs. (The defaults never render raw: book properties and default highlight blocks go through Logseq's escaped structured-properties API.)
State maps (bookIdsMap, highlightIdsMap,
lastHighlightDatetimeMap, lastSync) plus the bound-graph
record (boundGraph) are stored in logseq.settings and not
exposed in the UI. The directory handle itself lives in IndexedDB
under sync-koreader-highlights:directoryHandle.
- Toolbar icon opens an in-plugin sync panel rendered inside the plugin's own iframe. The panel shows the currently-selected directory, the bound graph, the last-sync timestamp, a live progress log, and three buttons: Choose KOReader directoryβ¦, Bind / Re-bind to this graph, and Sync now (disabled when the open graph isn't the bound one β see Multiple graphs). Closing the panel (X / backdrop click / ESC) returns to the main Logseq window.
- The panel matches your active Logseq theme (background, accent,
text colours, font) by reading the host's
--ls-*CSS variables on every open β Awesome Styler customisations propagate automatically. - Toasts (
logseq.UI.showMsg) report the completion summary (Sync done β N new book(s), M new highlight(s), β¦) and any errors. - Command palette entries:
Sync KOReader Highlights: open panelSync KOReader Highlights: reset sync stateSync KOReader Highlights: reset and delete all book pagesSync KOReader Highlights: forget remembered directorySync KOReader Highlights: unbind graphSync KOReader Highlights: reset templates to defaults
- Mustache templates and auto-sync knobs live in the standard Logseq plugin settings panel β reach them from the panel's footer link ("Open plugin settingsβ¦") or the regular Plugins β β flow.
The Readwise plugin's auto-sync just works because Readwise is
an HTTPS pull and needs no permission story. This plugin uses
the File System Access API, which Chromium drops the read
permission for on every Logseq restart. To re-grant permission
the plugin needs to call requestPermission inside a user-
activation context (i.e. a click or keypress event). The
plugin tries to defer the launch sync to the next user
activation by listening for pointerdown / keydown, but
Chromium does not always propagate user activation from the
plugin's iframe. When that happens the auto-sync silently
fails; a click on the toolbar icon (always a proper user-
activation context) re-grants and runs the sync.
The interval setting has the same limitation: a background tick fires only when permission is currently granted in this session.
System Node 22+ (the project uses Vite 7 + TypeScript; vanilla DOM, no UI framework). On Asahi Fedora aarch64, an x86-64 nvm install can shadow the right binary, so:
PATH=/usr/bin:$PATH /usr/bin/npm install
PATH=/usr/bin:$PATH /usr/bin/npm run buildOutput lands in dist/ (one index.html + one bundled JS
asset, β180 KB).
The same gates CI runs, locally:
PATH=/usr/bin:$PATH /usr/bin/npm run typecheck # tsc --noEmit
PATH=/usr/bin:$PATH /usr/bin/npm test # vitest runTests cover the pure parse/render layer (src/sidecar.ts,
src/render.ts) via src/*.test.ts β no logseq global or DOM
needed. Vitest uses its own vitest.config.ts (not vite.config.ts,
whose vite-plugin-logseq only runs in the dev-server path).
CI also runs two security gates (.github/workflows/security.yml),
both in digest-pinned containers β reproduce them with any OCI runtime
(docker or podman):
# SAST β fails on any finding
podman run --rm -v "$PWD":/src:z -w /src semgrep/semgrep:1.169.0 \
semgrep scan --config p/security-audit --config p/javascript \
--config p/typescript --config p/owasp-top-ten --config p/github-actions \
--exclude node_modules --exclude dist --error
# Secrets across full git history + tree
podman run --rm -v "$PWD":/repo zricethezav/gitleaks:v8.30.1 git /repoThese complement GitHub's own secret scanning + push protection and the Dependabot alerts β each covers the others' blind spots (SAST finds code bugs, gitleaks finds committed secrets, Dependabot finds vulnerable deps).
src/
main.ts β Logseq plugin lifecycle, settings schema,
toolbar, command palette, picker (with IDB
persistence), launch-sync activation deferral,
and the single-graph binding gate.
panel.ts β The in-iframe sync panel: vanilla-DOM dialog,
host-theme (`--ls-*`) mirroring, and the
directory / graph / progress UI.
sync.ts β Sync engine: walk β parse β diff against state
maps β createPage / rebuildHighlightsSection /
writeIndexReceipt.
sidecar.ts β Async generator FSA walker; luaparse-based
metadata parser (numeric-keyed-table handling,
Lua-unescaped string values, multi-line
keyword/author splitting); highlight-id
derivation; sidecar key.
render.ts β Page-property and highlight-block builders;
Mustache template plumbing; HTML-entity
decoder; page-name and property-value
sanitisers; KOReader datetime parser.
storage.ts β Typed wrappers around `logseq.settings` for
the state maps and the bound-graph record.
Reset writes per-key nulls before the top-level
null overwrite, since Logseq's `updateSettings`
deep-merges and `{key: {}}` is a no-op.
Runtime deps: @logseq/libs, luaparse, mustache,
date-fns, idb-keyval. About 2300 lines of source across six
TypeScript modules; no UI framework (vanilla DOM).
The plugin reads files only from the directory you explicitly pick, runs entirely on your machine, and makes no network requests of any kind. The picked directory handle is persisted to your browser's IndexedDB, scoped to Logseq, and never transmitted anywhere. State maps live in your local Logseq settings file.
Issue reports and pull requests welcome on GitHub.
If you're filing a bug, please include:
- KOReader version (Settings β β β "About").
- Logseq version (top-right β― β About Logseq).
- The plugin version (from
package.json). - A redacted copy of the relevant
metadata.*.luasidecar if the bug involves a specific book. - Console output from Logseq's devtools (Ctrl+Shift+I β
Console) at the time of the failure. Lines starting with
sync-koreader-highlights:are particularly useful.
See CHANGELOG.md.
MIT.
A short note on intent for future maintainers: the design
choices here are documented at the level of "why" in the commit
log, especially for the parser fix (KOReader's numeric-keyed
tables), the indexer-crash mitigation (switching from inline
properties to structured properties), the replace-on-change
update model (so device-side deletions propagate), the settings-
reset bug (Logseq's deep-merge surprises empty-object clears),
and the multi-character page-name handling. git log is the
durable source of truth.


