mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(kobo/koreader): two way page level sync between kobo/koreader and bookorbit (#250)
* feat(kobo): support two-way progress sync Implement two-way page-level reading progress synchronization between Kobo devices, KOReader, and BookOrbit. This ensures readers can switch between physical devices and the web app without losing their exact page-level reading position. Closes #249 * fix(settings): restore thumbnail click preference Restore the main branch thumbnail-click setting after the Kobo branch accidentally dropped it during the rebase. Keep the Kobo architecture allowlist aligned with the new identity service. * fix(kobo): harden two-way progress sync fallbacks Preserve Kobo bookmark metadata and tolerate null auth payloads/settings failures. Cover review-reported regressions with focused tests.
This commit is contained in:
@@ -6,6 +6,193 @@ import { makeStreamingLoader } from './streaming-loader.js'
|
||||
|
||||
const SEARCH_PREFIX = 'foliate-search:'
|
||||
|
||||
const toPercent = (fraction) => (typeof fraction === 'number' && Number.isFinite(fraction) ? Math.max(0, Math.min(100, fraction * 100)) : null)
|
||||
|
||||
const getElementForNode = (node) => {
|
||||
if (!node) return null
|
||||
if (node.nodeType === 1) return node
|
||||
return node.parentElement ?? null
|
||||
}
|
||||
|
||||
const getRangePoint = (range) => {
|
||||
if (!range) return null
|
||||
if (range.startContainer?.nodeType === 3) return { node: range.startContainer, offset: range.startOffset }
|
||||
if (range.endContainer?.nodeType === 3) return { node: range.endContainer, offset: range.endOffset }
|
||||
return { node: range.startContainer, offset: range.startOffset }
|
||||
}
|
||||
|
||||
export const getKoboSpanValue = (range) => {
|
||||
if (!range) return null
|
||||
const point = getRangePoint(range)
|
||||
const direct = getElementForNode(point?.node)?.closest?.('.koboSpan[id^="kobo."]')
|
||||
if (direct?.id) return direct.id
|
||||
|
||||
const doc = range.commonAncestorContainer?.ownerDocument ?? getElementForNode(range.commonAncestorContainer)?.ownerDocument
|
||||
const spans = doc?.querySelectorAll?.('.koboSpan[id^="kobo."]') ?? []
|
||||
for (const span of spans) {
|
||||
try {
|
||||
if (range.intersectsNode(span)) return span.id
|
||||
} catch {
|
||||
// Ignore nodes outside this range's document.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getXPathStep = (node) => {
|
||||
if (!node) return null
|
||||
if (node.nodeType === 3) return 'text()'
|
||||
if (node.nodeType === 1 && node.localName) return node.localName
|
||||
return null
|
||||
}
|
||||
|
||||
const getXPathIndex = (node) => {
|
||||
const step = getXPathStep(node)
|
||||
if (!step) return 1
|
||||
|
||||
const siblings = getEffectiveChildren(getEffectiveParent(node)).filter((item) => item.type === 'element' && getXPathStep(item.node) === step)
|
||||
const index = siblings.findIndex((item) => item.node === node)
|
||||
return index >= 0 ? index + 1 : 1
|
||||
}
|
||||
|
||||
const getXPathStepCount = (node) => {
|
||||
const step = getXPathStep(node)
|
||||
if (!step) return 1
|
||||
return getEffectiveChildren(getEffectiveParent(node)).filter((item) => item.type === 'element' && getXPathStep(item.node) === step).length || 1
|
||||
}
|
||||
|
||||
const getXPathSegment = (node) => {
|
||||
const step = getXPathStep(node)
|
||||
if (!step) return null
|
||||
return getXPathStepCount(node) > 1 ? `${step}[${getXPathIndex(node)}]` : step
|
||||
}
|
||||
|
||||
const isFoliateLayoutWrapper = (node) => node?.nodeType === 1 && (node.id === 'book-columns' || node.id === 'book-inner')
|
||||
|
||||
const isKoboSpanWrapper = (node) => node?.nodeType === 1 && node.classList?.contains('koboSpan') && /^kobo\./.test(node.id ?? '')
|
||||
|
||||
const isTransparentXPathWrapper = (node) => isFoliateLayoutWrapper(node) || isKoboSpanWrapper(node)
|
||||
|
||||
const getEffectiveParent = (node) => {
|
||||
let parent = node?.parentNode ?? null
|
||||
while (isTransparentXPathWrapper(parent)) parent = parent.parentNode
|
||||
return parent
|
||||
}
|
||||
|
||||
const appendEffectiveChild = (items, node) => {
|
||||
if (!node) return
|
||||
|
||||
if (isTransparentXPathWrapper(node)) {
|
||||
for (const child of node.childNodes) appendEffectiveChild(items, child)
|
||||
return
|
||||
}
|
||||
|
||||
if (node.nodeType === 3) {
|
||||
const last = items.at(-1)
|
||||
if (last?.type === 'text') {
|
||||
last.nodes.push(node)
|
||||
return
|
||||
}
|
||||
items.push({ type: 'text', nodes: [node] })
|
||||
return
|
||||
}
|
||||
|
||||
if (node.nodeType === 1) items.push({ type: 'element', node })
|
||||
}
|
||||
|
||||
const getEffectiveChildren = (parent) => {
|
||||
const items = []
|
||||
for (const child of parent?.childNodes ?? []) appendEffectiveChild(items, child)
|
||||
return items
|
||||
}
|
||||
|
||||
const getBodyXPath = (node) => {
|
||||
if (!node) return null
|
||||
|
||||
const doc = node.ownerDocument ?? (node.nodeType === 9 ? node : null)
|
||||
const body = doc?.body
|
||||
if (!body) return null
|
||||
|
||||
const parts = []
|
||||
let current = node
|
||||
while (current) {
|
||||
if (current === body) {
|
||||
parts.unshift('body')
|
||||
return `/${parts.join('/')}`
|
||||
}
|
||||
|
||||
if (!isTransparentXPathWrapper(current)) {
|
||||
const segment = getXPathSegment(current)
|
||||
if (!segment) return null
|
||||
parts.unshift(segment)
|
||||
}
|
||||
current = getEffectiveParent(current)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getKoreaderTextTarget = (point) => {
|
||||
if (point?.node?.nodeType !== 3) return null
|
||||
|
||||
const parent = getEffectiveParent(point.node)
|
||||
if (!parent) return null
|
||||
|
||||
const textItems = getEffectiveChildren(parent).filter((item) => item.type === 'text')
|
||||
for (const [itemIndex, item] of textItems.entries()) {
|
||||
let offset = 0
|
||||
for (const node of item.nodes) {
|
||||
if (node === point.node) {
|
||||
return {
|
||||
parent,
|
||||
index: itemIndex + 1,
|
||||
count: textItems.length,
|
||||
offset: offset + (Number.isFinite(point.offset) ? point.offset : 0),
|
||||
}
|
||||
}
|
||||
offset += node.nodeValue?.length ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getSectionPathBasename = (section) => {
|
||||
if (typeof section?.id !== 'string') return null
|
||||
const path = section.id.split(/[?#]/)[0]
|
||||
return path.split('/').pop() ?? null
|
||||
}
|
||||
|
||||
const isKepubifySyntheticSection = (section) => getSectionPathBasename(section) === 'kepubify-titlepage-dummy.xhtml'
|
||||
|
||||
export const getKoreaderDocFragmentIndex = (sections, index) => {
|
||||
if (!Array.isArray(sections) || typeof index !== 'number') return null
|
||||
const section = sections[index]
|
||||
if (!section || isKepubifySyntheticSection(section)) return null
|
||||
|
||||
let docFragmentIndex = 0
|
||||
for (let i = 0; i <= index && i < sections.length; i += 1) {
|
||||
if (!isKepubifySyntheticSection(sections[i])) docFragmentIndex += 1
|
||||
}
|
||||
return docFragmentIndex || null
|
||||
}
|
||||
|
||||
export const getKoreaderProgress = (docFragmentIndex, range) => {
|
||||
if (!range || typeof docFragmentIndex !== 'number') return null
|
||||
|
||||
const point = getRangePoint(range)
|
||||
if (!point) return null
|
||||
|
||||
const target = getKoreaderTextTarget(point)
|
||||
if (!target) return null
|
||||
|
||||
const xpath = getBodyXPath(target.parent)
|
||||
if (!xpath) return null
|
||||
|
||||
const textStep = target.count > 1 ? `text()[${target.index}]` : 'text()'
|
||||
return `/body/DocFragment[${docFragmentIndex}]${xpath}/${textStep}.${target.offset}`
|
||||
}
|
||||
|
||||
const isZip = async (file) => {
|
||||
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
|
||||
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
|
||||
@@ -397,7 +584,24 @@ export class View extends HTMLElement {
|
||||
const tocItem = this.#tocProgress?.getProgress(index, range)
|
||||
const pageItem = this.#pageProgress?.getProgress(index, range)
|
||||
const cfi = this.getCFI(index, range)
|
||||
this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }
|
||||
const source = this.book.sections[index]?.id ?? null
|
||||
const contentSourceProgressPercent = toPercent(fraction)
|
||||
const koboLocationValue = getKoboSpanValue(range)
|
||||
const koboLocationType = koboLocationValue ? 'KoboSpan' : null
|
||||
const koreaderDocFragmentIndex = getKoreaderDocFragmentIndex(this.book.sections, index)
|
||||
const koreaderProgress = getKoreaderProgress(koreaderDocFragmentIndex, range)
|
||||
this.lastLocation = {
|
||||
...progress,
|
||||
tocItem,
|
||||
pageItem,
|
||||
cfi,
|
||||
range,
|
||||
source,
|
||||
koboLocationType,
|
||||
koboLocationValue,
|
||||
contentSourceProgressPercent,
|
||||
koreaderProgress,
|
||||
}
|
||||
if (reason === 'snap' || reason === 'page' || reason === 'scroll') this.history.replaceState(cfi)
|
||||
this.#emit('relocate', this.lastLocation)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user