#!/usr/bin/env python3 # -*- coding:utf-8 -*- # # SPDX-License-Identifier: GPL-3.0-or-later # # Quick'n dirty migration of KOReader's reading status for *read* books to Nickel. # This allows seamless interaction with the Calibre Kobo Utilities plugin to keep track of reading status ;). # # Requires the Python 3 environment from KoboStuff: https://www.mobileread.com/forums/showthread.php?t=254214 # # $Id$ # ## from pathlib import Path from datetime import datetime, timedelta import sqlite3 from slpp import slpp as lua # Setup FBInk from _fbink import ffi, lib as FBInk fbink_cfg = ffi.new("FBInkConfig *") fbink_cfg.fontname = FBInk.SCIENTIFICA fbink_cfg.is_rpadded = True fbink_cfg.row = 0 fbfd = FBInk.fbink_open() FBInk.fbink_init(fbfd, fbink_cfg) print("Loaded FBInk {}".format(ffi.string(FBInk.fbink_version()).decode("ascii"))) # We'll need to know the amount of available rows, in order to not stomp on the progress bar... fbink_state = ffi.new("FBInkState *") FBInk.fbink_get_state(fbink_cfg, fbink_state) # Display msg on screen & in terminal def log(msg): print(msg) # NOTE: cFFI's char * is a bytes object in Python 3! And FBInk expects UTF-8 encoding. lines = FBInk.fbink_print(fbfd, bytes("* " + msg, "utf-8"), fbink_cfg) if lines > 0: fbink_cfg.row += lines # If we'd be stomping on the progress bar, add one more line to skip over it ;) if fbink_cfg.row % (fbink_state.max_rows - 1) == 0: fbink_cfg.row += 1 # Display a progress bar on the last row, without forgetting our current actual row def progressbar(value): cur_row = fbink_cfg.row fbink_cfg.row = -1 FBInk.fbink_print_progress_bar(fbfd, value, fbink_cfg) fbink_cfg.row = cur_row # Where KOReader lives KOREADER_DIR = Path("/mnt/onboard/.adds/koreader") KOREADER_HISTORY = Path(KOREADER_DIR / "history.lua") # Where Nickel's DB lives NICKEL_DB = Path("/mnt/onboard/.kobo/KoboReader.sqlite") # Given a book's Path, return its metadata sidecar file as a Path object def get_sidecar(path): # First, check that this book still exists if not path.exists(): print("File `{}` doesn't exist anymore!".format(path)) return None # Then, get the sidecar folder for this book (i.e., DocSettings:getSidecarDir ) sidecar = path.with_suffix(".sdr") # Build the metadata filename (i.e., DocSettings:getSidecarFile) ext = path.suffix # NOTE: Yeah, KOReader will happily generate a file named "metadata..lua" if the original file has no file extension o_O. sidefile = "metadata{}.lua".format(ext) # Read the Lua metadata for this book metadata = Path(sidecar / sidefile) if not metadata.exists(): print("No metadata file found for `{}` in `{}`!".format(path, metadata)) return None return metadata # Given a sidecar metadata Path, return its data as a Python object def get_metadata(path): lua_meta = path.read_text() # Make it a shiny Python dict # Strip inital comment if lua_meta.startswith("--"): next_line = lua_meta.index('\n') lua_meta = lua_meta[next_line+1:] # Strip the initial return if lua_meta.startswith("return"): lua_meta = lua_meta[7:] # NOTE: Or go the re way, like https://git.sr.ht/~harmtemolder/koreader-calibre-plugin/tree/main/action.py#L229 # Follow KOHighlight's lead on replacing '--' ... # Otherwise, the description & keywords fields in doc_props have a rather high probability of breaking SLPP's parser... lua_meta = lua_meta.replace("--", "—") return lua.decode(lua_meta) # Start by reading the history file... lua_history = KOREADER_HISTORY.read_text() # Strip the initial return if lua_history.startswith("return"): lua_history = lua_history[7:] # Make that a shiny Python dict history = lua.decode(lua_history) # And now loop over every book in the History book_i = 0 book_total = len(history) for book in history.values(): # Make the counter a percentage book_i += 1 spinner = int(book_i / book_total * 100) f = book["file"] print("[{:3d}%] Checking `{}`".format(spinner, f)) # And update the progressbar... progressbar(spinner) # Get metadata sidecar file... sidecar = get_sidecar(Path(f)) if sidecar is None: continue # Get metadata data... meta = get_metadata(sidecar) if meta is None: print("Empty metadata!") continue if not "summary" in meta.keys(): print("No summary!") continue status = meta["summary"]["status"] if status != "complete": continue # Okay! if "authors" in meta["doc_props"].keys(): author = meta["doc_props"]["authors"] if author and author != "": # Handle multiple authors properly if "\\\n" in author: author = author.replace("\\\n", " & ") log("Marking `{}` by `{}` as read".format(meta["doc_props"]["title"], author)) else: log("Marking `{}` as read".format(meta["doc_props"]["title"])) # Convert the last open date into a mostly-accurate representation of the SQL format... ko_date_opened = book["time"] topen = datetime.utcfromtimestamp(ko_date_opened) nickel_date_opened = topen.isoformat() + 'Z' # Do the same for the last *closed* date (based on the metadata file's mtime) ko_date_closed = sidecar.stat().st_mtime tclose = datetime.utcfromtimestamp(ko_date_closed) nickel_date_closed = tclose.isoformat() + 'Z' print("Last opened on {}".format(nickel_date_opened)) print("Last closed on {}".format(nickel_date_closed)) # We usually use the date at which it was last closed read_date = nickel_date_closed delta = tclose - topen # But if it was last closed more than 15 days after the last open, use the last open'ed time instead... if delta.days > 15: read_date = nickel_date_opened print("Large delta between the two, using the last opened date!") # SQL magic... # Update ReadStatus (INT) (0 = Unread; 1 = Reading; 2 = Finished) # Update ___PercentRead (INT) ([0, 100]) # Update DateLastRead w/ history TS (TEXT) # Update LastTimeFinishedReading (TEXT) con = sqlite3.connect(NICKEL_DB) try: with con: con.execute("UPDATE content SET ReadStatus = :status, ___PercentRead = :percent, DateLastRead = :date, LastTimeFinishedReading = :date WHERE ContentID = :id AND ContentType = '6';", {"status": 2, "percent": 100, "date":read_date, "id":"file://{}".format(f)}) except: print("SQL update failed!") log("Done!") FBInk.fbink_close(fbfd)