aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md12
-rwxr-xr-xepy.py3741
-rw-r--r--setup.py8
3 files changed, 2258 insertions, 1503 deletions
diff --git a/README.md b/README.md
index 0575d05..6b78cd5 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,18 @@ This is just a fork of my own [epr](https://github.com/wustho/epr) with these ex
- Text-to-Speech (with additional setup, read [below](#text-to-speech))
- [Double Spread](#double-spread)
+## Note on `v2021.10.23` and beyond
+
+There happens major refactoring for `epy` in version `v2021.10.23` which harness
+a lot of new stuffs in python standard libraries starting from `python>=3.7`, so
+`epy` won't be compatible with older python version and won't be backward compatible
+with older `epy` version. And if you decide to install this version you might lose
+your reading progress with older `epy`.
+
+There are no new features with this version but some bugfixes. The refactoring is
+just to keep `epy` up to date to recent python and making it easier
+for future contributors to read.
+
## Installation
- Via PyPI
diff --git a/epy.py b/epy.py
index 17a12b2..47d1142 100755
--- a/epy.py
+++ b/epy.py
@@ -14,7 +14,7 @@ Options:
"""
-__version__ = "2021.8.14"
+__version__ = "2021.10.23"
__license__ = "GPL-3.0"
__author__ = "Benawi Adha"
__email__ = "benawiadha@gmail.com"
@@ -23,113 +23,203 @@ __url__ = "https://github.com/wustho/epy"
import base64
import curses
-import zipfile
-import sys
-import re
-import os
-import textwrap
+import dataclasses
import json
-import tempfile
+import multiprocessing
+import os
+import re
import shutil
+import sqlite3
import subprocess
-import multiprocessing
+import sys
+import tempfile
+import textwrap
import xml.etree.ElementTree as ET
-from urllib.parse import unquote
-from html import unescape
-from html.parser import HTMLParser
+import zipfile
+
+from typing import Optional, Union, Tuple, List, Mapping, Any
+from dataclasses import dataclass
from difflib import SequenceMatcher as SM
+from enum import Enum
from functools import wraps
+from html import unescape
+from html.parser import HTMLParser
+from urllib.parse import unquote
try:
import mobi
- MOBISUPPORT = True
+
+ MOBI_SUPPORT = True
except ModuleNotFoundError:
- MOBISUPPORT = False
-
-if shutil.which("pico2wave") is None\
- or shutil.which("play") is None:
- TTSSUPPORT = False
-else:
- TTSSUPPORT = True
-SPEAKING = False
-
-# -1 is default terminal fg/bg colors
-CFG = {
- "DefaultViewer": "auto",
- "DictionaryClient": "auto",
- "ShowProgressIndicator": True,
- "PageScrollAnimation": True,
- "MouseSupport": False,
- "StartWithDoubleSpread": False,
- "TTSSpeed": 1,
- "DarkColorFG": 252,
- "DarkColorBG": 235,
- "LightColorFG": 238,
- "LightColorBG": 253,
- "Keys": {
- "ScrollUp": "k",
- "ScrollDown": "j",
- "PageUp": "h",
- "PageDown": "l",
- "HalfScreenUp": "^u",
- "HalfScreenDown": "C-d",
- "NextChapter": "n",
- "PrevChapter": "p",
- "BeginningOfCh": "g",
- "EndOfCh": "G",
- "Shrink": "-",
- "Enlarge": "+",
- "SetWidth": "=",
- "Metadata": "M",
- "DefineWord": "d",
- "TableOfContents": "t",
- "Follow": "f",
- "OpenImage": "o",
- "RegexSearch": "/",
- "ShowHideProgress": "s",
- "MarkPosition": "m",
- "JumpToPosition": "`",
- "AddBookmark": "b",
- "ShowBookmarks": "B",
- "Quit": "q",
- "Help": "?",
- "SwitchColor": "c",
- "TTSToggle": "!",
- "DoubleSpreadToggle": "D"
- }
-}
-STATE = {
- "LastRead": "",
- "States": {}
-}
-# default keys
-K = {
- "ScrollUp": {curses.KEY_UP},
- "ScrollDown": {curses.KEY_DOWN},
- "PageUp": {curses.KEY_PPAGE, curses.KEY_LEFT},
- "PageDown": {curses.KEY_NPAGE, ord(" "), curses.KEY_RIGHT},
- "BeginningOfCh": {curses.KEY_HOME},
- "EndOfCh": {curses.KEY_END},
- "TableOfContents": {9, ord("\t")},
- "Follow": {10},
- "Quit": {3, 27, 304}
-}
-WINKEYS = set()
-CFGFILE = ""
-STATEFILE = ""
-COLORSUPPORT = False
-SEARCHPATTERN = None
-VWR = None
-DICT = None
-SCREEN = None
-JUMPLIST = {}
-SHOWPROGRESS = CFG["ShowProgressIndicator"]
-MULTIPROC = False if multiprocessing.cpu_count() == 1 else True
-ALLPREVLETTERS = []
-SUMALLLETTERS = 0
-PROC_COUNTLETTERS = None
-ANIMATE = None
-SPREAD = 1
+ MOBI_SUPPORT = False
+
+
+# add image viewers here
+# sorted by most widely used
+VIEWER_PRESET_LIST = (
+ "feh",
+ "imv",
+ "gio",
+ "gnome-open",
+ "gvfs-open",
+ "xdg-open",
+ "kde-open",
+ "firefox",
+)
+
+
+class Direction(Enum):
+ FORWARD = "forward"
+ BACKWARD = "backward"
+
+
+@dataclass(frozen=True)
+class ReadingState:
+ content_index: int = 0
+ textwidth: int = 80
+ row: int = 0
+ rel_pctg: Optional[float] = None
+ section: str = ""
+
+
+@dataclass(frozen=True)
+class SearchData:
+ direction: Direction = Direction.FORWARD
+ value: str = ""
+
+
+@dataclass(frozen=True)
+class LettersCount:
+ """
+ all: total letters in book
+ cumulative: list of total letters for previous contents
+ eg. let's say cumulative = (0, 50, 89, ...) it means
+ 0 is total cumulative letters of book contents[-1] to contents[0]
+ 50 is total cumulative letters of book contents[0] to contents[1]
+ 89 is total cumulative letters of book contents[0] to contents[2]
+ """
+
+ all: int
+ cumulative: Tuple[int, ...]
+
+
+class Key:
+ """Because ord("k") chr(34) are confusing"""
+
+ def __init__(self, char_or_int: Union[str, int]):
+ self.value: int = char_or_int if isinstance(char_or_int, int) else ord(char_or_int)
+ self.char: str = char_or_int if isinstance(char_or_int, str) else chr(char_or_int)
+
+ def __eq__(self, other: Any) -> bool:
+ if isinstance(other, Key):
+ return self.value == other.value
+ return False
+
+ def __ne__(self, other: Any) -> bool:
+ return self.__eq__(other)
+
+ def __hash__(self) -> int:
+ return hash(self.value)
+
+
+@dataclass(frozen=True)
+class NoUpdate:
+ pass
+
+
+@dataclass(frozen=True)
+class Settings:
+ DefaultViewer: str = "auto"
+ DictionaryClient: str = "auto"
+ ShowProgressIndicator: bool = True
+ PageScrollAnimation: bool = True
+ MouseSupport: bool = False
+ StartWithDoubleSpread: bool = False
+ TTSSpeed: int = 1
+ # -1 is default terminal fg/bg colors
+ DarkColorFG: int = 252
+ DarkColorBG: int = 235
+ LightColorFG: int = 238
+ LightColorBG: int = 253
+
+
+@dataclass(frozen=True)
+class CfgDefaultKeymaps:
+ ScrollUp: str = "k"
+ ScrollDown: str = "j"
+ PageUp: str = "h"
+ PageDown: str = "l"
+ # HalfScreenUp: str = "h"
+ # HalfScreenDown: str
+ NextChapter: str = "n"
+ PrevChapter: str = "p"
+ BeginningOfCh: str = "g"
+ EndOfCh: str = "G"
+ Shrink: str = "-"
+ Enlarge: str = "+"
+ SetWidth: str = "="
+ Metadata: str = "M"
+ DefineWord: str = "d"
+ TableOfContents: str = "t"
+ Follow: str = "f"
+ OpenImage: str = "o"
+ RegexSearch: str = "/"
+ ShowHideProgress: str = "s"
+ MarkPosition: str = "m"
+ JumpToPosition: str = "`"
+ AddBookmark: str = "b"
+ ShowBookmarks: str = "B"
+ Quit: str = "q"
+ Help: str = "?"
+ SwitchColor: str = "c"
+ TTSToggle: str = "!"
+ DoubleSpreadToggle: str = "D"
+
+
+@dataclass(frozen=True)
+class CfgBuiltinKeymaps:
+ ScrollUp: Tuple[int, ...] = (curses.KEY_UP,)
+ ScrollDown: Tuple[int, ...] = (curses.KEY_DOWN,)
+ PageUp: Tuple[int, ...] = (curses.KEY_PPAGE, curses.KEY_LEFT)
+ PageDown: Tuple[int, ...] = (curses.KEY_NPAGE, ord(" "), curses.KEY_RIGHT)
+ BeginningOfCh: Tuple[int, ...] = (curses.KEY_HOME,)
+ EndOfCh: Tuple[int, ...] = (curses.KEY_END,)
+ TableOfContents: Tuple[int, ...] = (9, ord("\t"))
+ Follow: Tuple[int, ...] = (10,)
+ Quit: Tuple[int, ...] = (3, 27, 304)
+
+
+@dataclass(frozen=True)
+class Keymap:
+ # HalfScreenDown: Tuple[Key, ...]
+ # HalfScreenUp: Tuple[Key, ...]
+ AddBookmark: Tuple[Key, ...]
+ BeginningOfCh: Tuple[Key, ...]
+ DefineWord: Tuple[Key, ...]
+ DoubleSpreadToggle: Tuple[Key, ...]
+ EndOfCh: Tuple[Key, ...]
+ Enlarge: Tuple[Key, ...]
+ Follow: Tuple[Key, ...]
+ Help: Tuple[Key, ...]
+ JumpToPosition: Tuple[Key, ...]
+ MarkPosition: Tuple[Key, ...]
+ Metadata: Tuple[Key, ...]
+ NextChapter: Tuple[Key, ...]
+ OpenImage: Tuple[Key, ...]
+ PageDown: Tuple[Key, ...]
+ PageUp: Tuple[Key, ...]
+ PrevChapter: Tuple[Key, ...]
+ Quit: Tuple[Key, ...]
+ RegexSearch: Tuple[Key, ...]
+ ScrollDown: Tuple[Key, ...]
+ ScrollUp: Tuple[Key, ...]
+ SetWidth: Tuple[Key, ...]
+ ShowBookmarks: Tuple[Key, ...]
+ ShowHideProgress: Tuple[Key, ...]
+ Shrink: Tuple[Key, ...]
+ SwitchColor: Tuple[Key, ...]
+ TTSToggle: Tuple[Key, ...]
+ TableOfContents: Tuple[Key, ...]
class Epub:
@@ -138,46 +228,40 @@ class Epub:
"OPF": "http://www.idpf.org/2007/opf",
"CONT": "urn:oasis:names:tc:opendocument:xmlns:container",
"XHTML": "http://www.w3.org/1999/xhtml",
- "EPUB": "http://www.idpf.org/2007/ops"
+ "EPUB": "http://www.idpf.org/2007/ops",
}
- def __init__(self, fileepub):
- self.path = os.path.abspath(fileepub)
- self.file = zipfile.ZipFile(fileepub, "r")
+ def __init__(self, fileepub: str):
+ self.path: str = os.path.abspath(fileepub)
+ self.file: zipfile.ZipFile = zipfile.ZipFile(fileepub, "r")
- def get_meta(self):
+ def get_meta(self) -> Tuple[Tuple[str, str], ...]:
meta = []
# why self.file.read(self.rootfile) problematic
cont = ET.fromstring(self.file.open(self.rootfile).read())
for i in cont.findall("OPF:metadata/*", self.NS):
if i.text is not None:
- meta.append([re.sub("{.*?}", "", i.tag), i.text])
- return meta
+ meta.append((re.sub("{.*?}", "", i.tag), i.text))
+ return tuple(meta)
- def initialize(self):
+ def initialize(self) -> None:
cont = ET.parse(self.file.open("META-INF/container.xml"))
- self.rootfile = cont.find(
- "CONT:rootfiles/CONT:rootfile",
- self.NS
- ).attrib["full-path"]
- self.rootdir = os.path.dirname(self.rootfile)\
- + "/" if os.path.dirname(self.rootfile) != "" else ""
+ self.rootfile = cont.find("CONT:rootfiles/CONT:rootfile", self.NS).attrib["full-path"]
+ self.rootdir = (
+ os.path.dirname(self.rootfile) + "/" if os.path.dirname(self.rootfile) != "" else ""
+ )
cont = ET.parse(self.file.open(self.rootfile))
# EPUB3
self.version = cont.getroot().get("version")
if self.version == "2.0":
# "OPF:manifest/*[@id='ncx']"
- self.toc = self.rootdir\
- + cont.find(
- "OPF:manifest/*[@media-type='application/x-dtbncx+xml']",
- self.NS
- ).get("href")
+ self.toc = self.rootdir + cont.find(
+ "OPF:manifest/*[@media-type='application/x-dtbncx+xml']", self.NS
+ ).get("href")
elif self.version == "3.0":
- self.toc = self.rootdir\
- + cont.find(
- "OPF:manifest/*[@properties='nav']",
- self.NS
- ).get("href")
+ self.toc = self.rootdir + cont.find("OPF:manifest/*[@properties='nav']", self.NS).get(
+ "href"
+ )
self.contents = []
self.toc_entries = [[], [], []]
@@ -187,12 +271,8 @@ class Epub:
for i in cont.findall("OPF:manifest/*", self.NS):
# EPUB3
# if i.get("id") != "ncx" and i.get("properties") != "nav":
- if i.get("media-type") != "application/x-dtbncx+xml"\
- and i.get("properties") != "nav":
- manifest.append([
- i.get("id"),
- i.get("href")
- ])
+ if i.get("media-type") != "application/x-dtbncx+xml" and i.get("properties") != "nav":
+ manifest.append([i.get("id"), i.get("href")])
spine, contents = [], []
for i in cont.findall("OPF:spine/*", self.NS):
@@ -200,7 +280,7 @@ class Epub:
for i in spine:
for j in manifest:
if i == j[0]:
- self.contents.append(self.rootdir+unquote(j[1]))
+ self.contents.append(self.rootdir + unquote(j[1]))
contents.append(unquote(j[1]))
manifest.remove(j)
# TODO: test is break necessary
@@ -212,10 +292,7 @@ class Epub:
if self.version == "2.0":
navPoints = toc.findall("DAISY:navMap//DAISY:navPoint", self.NS)
elif self.version == "3.0":
- navPoints = toc.findall(
- "XHTML:body//XHTML:nav[@EPUB:type='toc']//XHTML:a",
- self.NS
- )
+ navPoints = toc.findall("XHTML:body//XHTML:nav[@EPUB:type='toc']//XHTML:a", self.NS)
for i in navPoints:
if self.version == "2.0":
src = i.find("DAISY:content", self.NS).get("src")
@@ -237,7 +314,7 @@ class Epub:
except AttributeError:
pass
- def get_raw_text(self, chpath):
+ def get_raw_text(self, chpath: str) -> str:
# using try-except block to catch
# zlib.error: Error -3 while decompressing data: invalid distance too far back
# caused by forking PROC_COUNTLETTERS
@@ -249,29 +326,29 @@ class Epub:
continue
return content.decode("utf-8")
- def get_img_bytestr(self, impath):
+ def get_img_bytestr(self, impath: str) -> Tuple[str, bytes]:
return impath, self.file.read(impath)
- def cleanup(self):
+ def cleanup(self) -> None:
return
class Mobi(Epub):
- def __init__(self, filemobi):
+ def __init__(self, filemobi: str):
self.path = os.path.abspath(filemobi)
self.file, _ = mobi.extract(filemobi)
- def get_meta(self):
+ def get_meta(self) -> Tuple[Tuple[str, str], ...]:
meta = []
# why self.file.read(self.rootfile) problematic
with open(os.path.join(self.rootdir, "content.opf")) as f:
cont = ET.parse(f).getroot()
for i in cont.findall("OPF:metadata/*", self.NS):
if i.text is not None:
- meta.append([re.sub("{.*?}", "", i.tag), i.text])
- return meta
+ meta.append((re.sub("{.*?}", "", i.tag), i.text))
+ return tuple(meta)
- def initialize(self):
+ def initialize(self) -> None:
self.rootdir = os.path.join(self.file, "mobi7")
self.toc = os.path.join(self.rootdir, "toc.ncx")
self.version = "2.0"
@@ -285,12 +362,8 @@ class Mobi(Epub):
for i in cont.findall("OPF:manifest/*", self.NS):
# EPUB3
# if i.get("id") != "ncx" and i.get("properties") != "nav":
- if i.get("media-type") != "application/x-dtbncx+xml"\
- and i.get("properties") != "nav":
- manifest.append([
- i.get("id"),
- i.get("href")
- ])
+ if i.get("media-type") != "application/x-dtbncx+xml" and i.get("properties") != "nav":
+ manifest.append([i.get("id"), i.get("href")])
spine, contents = [], []
for i in cont.findall("OPF:spine/*", self.NS):
@@ -310,10 +383,7 @@ class Mobi(Epub):
if self.version == "2.0":
navPoints = toc.findall("DAISY:navMap//DAISY:navPoint", self.NS)
elif self.version == "3.0":
- navPoints = toc.findall(
- "XHTML:body//XHTML:nav[@EPUB:type='toc']//XHTML:a",
- self.NS
- )
+ navPoints = toc.findall("XHTML:body//XHTML:nav[@EPUB:type='toc']//XHTML:a", self.NS)
for i in navPoints:
if self.version == "2.0":
src = i.find("DAISY:content", self.NS).get("src")
@@ -333,7 +403,7 @@ class Mobi(Epub):
elif len(src) == 1:
self.toc_entries[2].append("")
- def get_raw_text(self, chpath):
+ def get_raw_text(self, chpath: str) -> str:
# using try-except block to catch
# zlib.error: Error -3 while decompressing data: invalid distance too far back
# caused by forking PROC_COUNTLETTERS
@@ -347,14 +417,14 @@ class Mobi(Epub):
# return content.decode("utf-8")
return content
- def get_img_bytestr(self, impath):
+ def get_img_bytestr(self, impath: str) -> Tuple[str, bytes]:
# TODO: test on windows
# if impath "Images/asdf.png" is problematic
with open(os.path.join(self.rootdir, impath), "rb") as f:
src = f.read()
return impath, src
- def cleanup(self):
+ def cleanup(self) -> None:
shutil.rmtree(self.file)
return
@@ -365,26 +435,24 @@ class Azw3(Epub):
self.tmpdir, self.tmpepub = mobi.extract(fileepub)
self.file = zipfile.ZipFile(self.tmpepub, "r")
- def cleanup(self):
+ def cleanup(self) -> None:
shutil.rmtree(self.tmpdir)
return
class FictionBook:
- NS = {
- "FB2": "http://www.gribuser.ru/xml/fictionbook/2.0"
- }
+ NS = {"FB2": "http://www.gribuser.ru/xml/fictionbook/2.0"}
- def __init__(self, filefb):
+ def __init__(self, filefb: str):
self.path = os.path.abspath(filefb)
self.file = filefb
- def get_meta(self):
+ def get_meta(self) -> Tuple[Tuple[str, str], ...]:
desc = self.root.find("FB2:description", self.NS)
alltags = desc.findall("*/*")
- return [[re.sub("{.*?}", "", i.tag), " ".join(i.itertext())] for i in alltags]
+ return tuple((re.sub("{.*?}", "", i.tag), " ".join(i.itertext())) for i in alltags)
- def initialize(self):
+ def initialize(self) -> None:
cont = ET.parse(self.file)
self.root = cont.getroot()
@@ -400,18 +468,18 @@ class FictionBook:
self.toc_entries[1].append(n)
self.toc_entries[2].append("")
- def get_raw_text(self, node):
+ def get_raw_text(self, node) -> str:
ET.register_namespace("", "http://www.gribuser.ru/xml/fictionbook/2.0")
# sys.exit(ET.tostring(node, encoding="utf8", method="html").decode("utf-8").replace("ns1:",""))
- return ET.tostring(node, encoding="utf8", method="html").decode("utf-8").replace("ns1:","")
+ return ET.tostring(node, encoding="utf8", method="html").decode("utf-8").replace("ns1:", "")
- def get_img_bytestr(self, imgid):
+ def get_img_bytestr(self, imgid: str) -> Tuple[str, bytes]:
imgid = imgid.replace("#", "")
img = self.root.find("*[@id='{}']".format(imgid))
imgtype = img.get("content-type").split("/")[1]
- return imgid+"."+imgtype, base64.b64decode(img.text)
+ return imgid + "." + imgtype, base64.b64decode(img.text)
- def cleanup(self):
+ def cleanup(self) -> None:
return
@@ -463,23 +531,22 @@ class HTMLtoLines(HTMLParser):
# but in XHTML both need endtag
elif tag in {"img", "image"}:
for i in attrs:
- if (tag == "img" and i[0] == "src")\
- or (tag == "image" and i[0].endswith("href")):
+ if (tag == "img" and i[0] == "src") or (tag == "image" and i[0].endswith("href")):
self.text.append("[IMG:{}]".format(len(self.imgs)))
self.imgs.append(unquote(i[1]))
# formatting
elif tag in self.ital:
if len(self.initital) == 0 or len(self.initital[-1]) == 4:
- self.initital.append([len(self.text)-1, len(self.text[-1])])
+ self.initital.append([len(self.text) - 1, len(self.text[-1])])
elif tag in self.bold:
if len(self.initbold) == 0 or len(self.initbold[-1]) == 4:
- self.initbold.append([len(self.text)-1, len(self.text[-1])])
+ self.initbold.append([len(self.text) - 1, len(self.text[-1])])
if self.sects != {""}:
for i in attrs:
if i[0] == "id" and i[1] in self.sects:
# self.text[-1] += " (#" + i[1] + ") "
# self.sectsindex.append([len(self.text), i[1]])
- self.sectsindex[len(self.text)-1] = i[1]
+ self.sectsindex[len(self.text) - 1] = i[1]
def handle_startendtag(self, tag, attrs):
if tag == "br":
@@ -488,8 +555,7 @@ class HTMLtoLines(HTMLParser):
for i in attrs:
# if (tag == "img" and i[0] == "src")\
# or (tag == "image" and i[0] == "xlink:href"):
- if (tag == "img" and i[0] == "src")\
- or (tag == "image" and i[0].endswith("href")):
+ if (tag == "img" and i[0] == "src") or (tag == "image" and i[0].endswith("href")):
self.text.append("[IMG:{}]".format(len(self.imgs)))
self.imgs.append(unquote(i[1]))
self.text.append("")
@@ -499,7 +565,7 @@ class HTMLtoLines(HTMLParser):
for i in attrs:
if i[0] == "id" and i[1] in self.sects:
# self.text[-1] += " (#" + i[1] + ") "
- self.sectsindex[len(self.text)-1] = i[1]
+ self.sectsindex[len(self.text) - 1] = i[1]
def handle_endtag(self, tag):
if re.match("h[1-6]", tag) is not None:
@@ -529,14 +595,14 @@ class HTMLtoLines(HTMLParser):
# formatting
elif tag in self.ital:
if len(self.initital[-1]) == 2:
- self.initital[-1] += [len(self.text)-1, len(self.text[-1])]
+ self.initital[-1] += [len(self.text) - 1, len(self.text[-1])]
elif len(self.initital[-1]) == 4:
- self.initital[-1][2:4] = [len(self.text)-1, len(self.text[-1])]
+ self.initital[-1][2:4] = [len(self.text) - 1, len(self.text[-1])]
elif tag in self.bold:
if len(self.initbold[-1]) == 2:
- self.initbold[-1] += [len(self.text)-1, len(self.text[-1])]
+ self.initbold[-1] += [len(self.text) - 1, len(self.text[-1])]
elif len(self.initbold[-1]) == 4:
- self.initbold[-1][2:4] = [len(self.text)-1, len(self.text[-1])]
+ self.initbold[-1][2:4] = [len(self.text) - 1, len(self.text[-1])]
def handle_data(self, raw):
if raw and not self.ishidden:
@@ -550,46 +616,43 @@ class HTMLtoLines(HTMLParser):
line = unescape(re.sub(r"\s+", " ", tmp))
self.text[-1] += line
if self.ishead:
- self.idhead.add(len(self.text)-1)
+ self.idhead.add(len(self.text) - 1)
elif self.isbull:
- self.idbull.add(len(self.text)-1)
+ self.idbull.add(len(self.text) - 1)
elif self.isinde:
- self.idinde.add(len(self.text)-1)
+ self.idinde.add(len(self.text) - 1)
elif self.ispref:
- self.idpref.add(len(self.text)-1)
+ self.idpref.add(len(self.text) - 1)
def get_lines(self, width=0):
text, sect = [], {}
- formatting = {
- "italic": [],
- "bold": []
- }
+ formatting = {"italic": [], "bold": []}
tmpital = []
for i in self.initital:
# handle uneven markup
# like <i> but no </i>
if len(i) == 4:
if i[0] == i[2]:
- tmpital.append([i[0], i[1], i[3]-i[1]])
- elif i[0] == i[2]-1:
- tmpital.append([i[0], i[1], len(self.text[i[0]])-i[1]])
+ tmpital.append([i[0], i[1], i[3] - i[1]])
+ elif i[0] == i[2] - 1:
+ tmpital.append([i[0], i[1], len(self.text[i[0]]) - i[1]])
tmpital.append([i[2], 0, i[3]])
- elif i[2]-i[0] > 1:
- tmpital.append([i[0], i[1], len(self.text[i[0]])-i[1]])
- for j in range(i[0]+1, i[2]):
+ elif i[2] - i[0] > 1:
+ tmpital.append([i[0], i[1], len(self.text[i[0]]) - i[1]])
+ for j in range(i[0] + 1, i[2]):
tmpital.append([j, 0, len(self.text[j])])
tmpital.append([i[2], 0, i[3]])
tmpbold = []
for i in self.initbold:
if len(i) == 4:
if i[0] == i[2]:
- tmpbold.append([i[0], i[1], i[3]-i[1]])
- elif i[0] == i[2]-1:
- tmpbold.append([i[0], i[1], len(self.text[i[0]])-i[1]])
+ tmpbold.append([i[0], i[1], i[3] - i[1]])
+ elif i[0] == i[2] - 1:
+ tmpbold.append([i[0], i[1], len(self.text[i[0]]) - i[1]])
tmpbold.append([i[2], 0, i[3]])
- elif i[2]-i[0] > 1:
- tmpbold.append([i[0], i[1], len(self.text[i[0]])-i[1]])
- for j in range(i[0]+1, i[2]):
+ elif i[2] - i[0] > 1:
+ tmpbold.append([i[0], i[1], len(self.text[i[0]]) - i[1]])
+ for j in range(i[0] + 1, i[2]):
tmpbold.append([j, 0, len(self.text[j])])
tmpbold.append([i[2], 0, i[3]])
@@ -599,29 +662,25 @@ class HTMLtoLines(HTMLParser):
startline = len(text)
# findsect = re.search(r"(?<= \(#).*?(?=\) )", i)
# if findsect is not None and findsect.group() in self.sects:
- # i = i.replace(" (#" + findsect.group() + ") ", "")
- # # i = i.replace(" (#" + findsect.group() + ") ", " "*(5+len(findsect.group())))
- # sect[findsect.group()] = len(text)
+ # i = i.replace(" (#" + findsect.group() + ") ", "")
+ # # i = i.replace(" (#" + findsect.group() + ") ", " "*(5+len(findsect.group())))
+ # sect[findsect.group()] = len(text)
if n in self.sectsindex.keys():
sect[self.sectsindex[n]] = len(text)
if n in self.idhead:
- text += [i.rjust(width//2 + len(i)//2)] + [""]
+ text += [i.rjust(width // 2 + len(i) // 2)] + [""]
formatting["bold"] += [[j, 0, len(text[j])] for j in range(startline, len(text))]
elif n in self.idinde:
- text += [
- " "+j for j in textwrap.wrap(i, width - 3)
- ] + [""]
+ text += [" " + j for j in textwrap.wrap(i, width - 3)] + [""]
elif n in self.idbull:
tmp = textwrap.wrap(i, width - 3)
- text += [
- " - "+j if j == tmp[0] else " "+j for j in tmp
- ] + [""]
+ text += [" - " + j if j == tmp[0] else " " + j for j in tmp] + [""]
elif n in self.idpref:
tmp = i.splitlines()
wraptmp = []
for line in tmp:
wraptmp += [j for j in textwrap.wrap(line, width - 6)]
- text += [" "+j for j in wraptmp] + [""]
+ text += [" " + j for j in wraptmp] + [""]
else:
text += textwrap.wrap(i, width) + [""]
@@ -632,27 +691,31 @@ class HTMLtoLines(HTMLParser):
tmp_count = 0
# for k in text[startline:endline]:
for k in range(startline, endline):
- if n in self.idbull|self.idinde:
+ if n in self.idbull | self.idinde:
if tmp_count <= j[1]:
- tmp_start = [k, j[1]-tmp_count+3]
- if tmp_count <= j[1]+j[2]:
- tmp_end = [k, j[1]+j[2]-tmp_count+3]
+ tmp_start = [k, j[1] - tmp_count + 3]
+ if tmp_count <= j[1] + j[2]:
+ tmp_end = [k, j[1] + j[2] - tmp_count + 3]
tmp_count += len(text[k]) - 2
else:
if tmp_count <= j[1]:
- tmp_start = [k, j[1]-tmp_count]
- if tmp_count <= j[1]+j[2]:
- tmp_end = [k, j[1]+j[2]-tmp_count]
+ tmp_start = [k, j[1] - tmp_count]
+ if tmp_count <= j[1] + j[2]:
+ tmp_end = [k, j[1] + j[2] - tmp_count]
tmp_count += len(text[k]) + 1
if tmp_start[0] == tmp_end[0]:
- formatting["italic"].append(tmp_start + [tmp_end[1]-tmp_start[1]])
- elif tmp_start[0] == tmp_end[0]-1:
- formatting["italic"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1])
+ formatting["italic"].append(tmp_start + [tmp_end[1] - tmp_start[1]])
+ elif tmp_start[0] == tmp_end[0] - 1:
+ formatting["italic"].append(
+ tmp_start + [len(text[tmp_start[0]]) - tmp_start[1] + 1]
+ )
formatting["italic"].append([tmp_end[0], 0, tmp_end[1]])
# elif tmp_start[0]-tmp_end[1] > 1:
else:
- formatting["italic"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1])
- for l in range(tmp_start[0]+1, tmp_end[0]):
+ formatting["italic"].append(
+ tmp_start + [len(text[tmp_start[0]]) - tmp_start[1] + 1]
+ )
+ for l in range(tmp_start[0] + 1, tmp_end[0]):
formatting["italic"].append([l, 0, len(text[l])])
formatting["italic"].append([tmp_end[0], 0, tmp_end[1]])
tmp_filtered = [j for j in tmpbold if j[0] == n]
@@ -660,27 +723,31 @@ class HTMLtoLines(HTMLParser):
tmp_count = 0
# for k in text[startline:endline]:
for k in range(startline, endline):
- if n in self.idbull|self.idinde:
+ if n in self.idbull | self.idinde:
if tmp_count <= j[1]:
- tmp_start = [k, j[1]-tmp_count+3]
- if tmp_count <= j[1]+j[2]:
- tmp_end = [k, j[1]+j[2]-tmp_count+3]
+ tmp_start = [k, j[1] - tmp_count + 3]
+ if tmp_count <= j[1] + j[2]:
+ tmp_end = [k, j[1] + j[2] - tmp_count + 3]
tmp_count += len(text[k]) - 2
else:
if tmp_count <= j[1]:
- tmp_start = [k, j[1]-tmp_count]
- if tmp_count <= j[1]+j[2]:
- tmp_end = [k, j[1]+j[2]-tmp_count]
+ tmp_start = [k, j[1] - tmp_count]
+ if tmp_count <= j[1] + j[2]:
+ tmp_end = [k, j[1] + j[2] - tmp_count]
tmp_count += len(text[k]) + 1
if tmp_start[0] == tmp_end[0]:
- formatting["bold"].append(tmp_start + [tmp_end[1]-tmp_start[1]])
- elif tmp_start[0] == tmp_end[0]-1:
- formatting["bold"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1])
+ formatting["bold"].append(tmp_start + [tmp_end[1] - tmp_start[1]])
+ elif tmp_start[0] == tmp_end[0] - 1:
+ formatting["bold"].append(
+ tmp_start + [len(text[tmp_start[0]]) - tmp_start[1] + 1]
+ )
formatting["bold"].append([tmp_end[0], 0, tmp_end[1]])
# elif tmp_start[0]-tmp_end[1] > 1:
else:
- formatting["bold"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1])
- for l in range(tmp_start[0]+1, tmp_end[0]):
+ formatting["bold"].append(
+ tmp_start + [len(text[tmp_start[0]]) - tmp_start[1] + 1]
+ )
+ for l in range(tmp_start[0] + 1, tmp_end[0]):
formatting["bold"].append([l, 0, len(text[l])])
formatting["bold"].append([tmp_end[0], 0, tmp_end[1]])
@@ -688,12 +755,23 @@ class HTMLtoLines(HTMLParser):
class Board:
- MAXCHUNKS = 32000-2 # lines
-
- def __init__(self, totlines, width):
- self.chunks = [self.MAXCHUNKS*(i+1)-1 for i in range(totlines // self.MAXCHUNKS)]
- self.chunks += [] if totlines % self.MAXCHUNKS == 0 else [totlines % self.MAXCHUNKS + (0 if self.chunks == [] else self.chunks[-1])] # -1
- self.pad = curses.newpad(min([self.MAXCHUNKS+2, totlines]), width)
+ """
+ Wrapper to curses.newpad() because curses.pad has
+ line max lines 32767. If there is >=32768 lines
+ Exception will be raised.
+ """
+
+ MAXCHUNKS = 32000 - 2 # lines
+
+ def __init__(self, screen, totlines, width):
+ self.screen = screen
+ self.chunks = [self.MAXCHUNKS * (i + 1) - 1 for i in range(totlines // self.MAXCHUNKS)]
+ self.chunks += (
+ []
+ if totlines % self.MAXCHUNKS == 0
+ else [totlines % self.MAXCHUNKS + (0 if self.chunks == [] else self.chunks[-1])]
+ ) # -1
+ self.pad = curses.newpad(min([self.MAXCHUNKS + 2, totlines]), width)
self.pad.keypad(True)
# self.current_chunk = 0
self.y = 0
@@ -707,58 +785,70 @@ class Board:
def format(self):
chunkidx = self.find_chunkidx(self.y)
- start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx-1]+1
+ start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx - 1] + 1
end_chunk = self.chunks[chunkidx]
# if y in range(start_chunk, end_chunk+1):
- for i in [j for j in self.formatting["italic"] if start_chunk <= j[0] and j[0] <= end_chunk]:
+ for i in [
+ j for j in self.formatting["italic"] if start_chunk <= j[0] and j[0] <= end_chunk
+ ]:
try:
- self.pad.chgat(i[0] % self.MAXCHUNKS, i[1], i[2], SCREEN.getbkgd()|curses.A_ITALIC)
+ self.pad.chgat(
+ i[0] % self.MAXCHUNKS, i[1], i[2], self.screen.getbkgd() | curses.A_ITALIC
+ )
except:
pass
for i in [j for j in self.formatting["bold"] if start_chunk <= j[0] and j[0] <= end_chunk]:
try:
- self.pad.chgat(i[0] % self.MAXCHUNKS, i[1], i[2], SCREEN.getbkgd()|curses.A_BOLD)
+ self.pad.chgat(
+ i[0] % self.MAXCHUNKS, i[1], i[2], self.screen.getbkgd() | curses.A_BOLD
+ )
except:
pass
- def getch(self):
- return self.pad.getch()
+ def getch(self) -> Union[Key, NoUpdate]:
+ tmp = self.pad.getch()
+ # curses.screen.timeout(delay)
+ # if delay < 0 then getch() return -1
+ if tmp == -1:
+ return NoUpdate()
+ return Key(tmp)
- def bkgd(self, bg):
- self.pad.bkgd(SCREEN.getbkgd())
+ def bkgd(self) -> None:
+ self.pad.bkgd(self.screen.getbkgd())
- def find_chunkidx(self, y):
+ def find_chunkidx(self, y) -> Optional[int]:
for n, i in enumerate(self.chunks):
if y <= i:
return n
def paint_text(self, chunkidx=0):
self.pad.clear()
- start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx-1]+1
+ start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx - 1] + 1
end_chunk = self.chunks[chunkidx]
- for n, i in enumerate(self.text[start_chunk:end_chunk+1]):
+ for n, i in enumerate(self.text[start_chunk : end_chunk + 1]):
if re.search("\\[IMG:[0-9]+\\]", i):
- self.pad.addstr(n, self.width//2 - len(i)//2 + 1, i, curses.A_REVERSE)
+ self.pad.addstr(n, self.width // 2 - len(i) // 2 + 1, i, curses.A_REVERSE)
else:
self.pad.addstr(n, 0, i)
# chapter suffix
ch_suffix = "***" # "\u3064\u3065\u304f" つづく
try:
- self.pad.addstr(n+1, (self.width - len(ch_suffix))//2 + 1, ch_suffix)
+ self.pad.addstr(n + 1, (self.width - len(ch_suffix)) // 2 + 1, ch_suffix)
except curses.error:
pass
# if chunkidx < len(self.chunks)-1:
- # try:
- # self.pad.addstr(self.MAXCHUNKS+1, (self.width - len(ch_suffix))//2 + 1, ch_suffix)
- # except curses.error:
- # pass
+ # try:
+ # self.pad.addstr(self.MAXCHUNKS+1, (self.width - len(ch_suffix))//2 + 1, ch_suffix)
+ # except curses.error:
+ # pass
def chgat(self, y, x, n, attr):
chunkidx = self.find_chunkidx(y)
- start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx-1]+1
+ start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx - 1] + 1
end_chunk = self.chunks[chunkidx]
- if y in range(start_chunk, end_chunk+1):
+ if y in range(start_chunk, end_chunk + 1):
+ # TODO: error when searching for |c
self.pad.chgat(y % self.MAXCHUNKS, x, n, attr)
def getbkgd(self):
@@ -775,91 +865,436 @@ class Board:
self.y = y
-def text_win(textfunc):
- @wraps(textfunc)
- def wrapper(*args, **kwargs):
- rows, cols = SCREEN.getmaxyx()
- hi, wi = rows - 4, cols - 4
- Y, X = 2, 2
- textw = curses.newwin(hi, wi, Y, X)
- if COLORSUPPORT:
- textw.bkgd(SCREEN.getbkgd())
+class AppData:
+ @property
+ def prefix(self) -> Optional[str]:
+ """Return None if there exists no homedir | userdir"""
+ prefix: Optional[str] = None
- title, raw_texts, key = textfunc(*args, **kwargs)
+ # UNIX filesystem
+ homedir = os.getenv("HOME")
+ # WIN filesystem
+ userdir = os.getenv("USERPROFILE")
- if len(title) > cols-8:
- title = title[:cols-8]
+ if homedir:
+ if os.path.isdir(os.path.join(homedir, ".config")):
+ prefix = os.path.join(homedir, ".config", "epy")
+ else:
+ prefix = os.path.join(homedir, ".epy")
+ elif userdir:
+ prefix = os.path.join(userdir, ".epy")
- texts = []
- for i in raw_texts.splitlines():
- texts += textwrap.wrap(i, wi - 6, drop_whitespace=False)
+ if prefix:
+ os.makedirs(prefix, exist_ok=True)
- textw.box()
- textw.keypad(True)
- textw.addstr(1, 2, title)
- textw.addstr(2, 2, "-"*len(title))
- key_textw = 0
+ return prefix
- totlines = len(texts)
- pad = curses.newpad(totlines, wi - 2)
- if COLORSUPPORT:
- pad.bkgd(SCREEN.getbkgd())
+class Config(AppData):
+ def __init__(self):
+ setting_dict = dataclasses.asdict(Settings())
+ keymap_dict = dataclasses.asdict(CfgDefaultKeymaps())
+ keymap_builtin_dict = dataclasses.asdict(CfgBuiltinKeymaps())
- pad.keypad(True)
- for n, i in enumerate(texts):
- pad.addstr(n, 0, i)
- y = 0
- textw.refresh()
- pad.refresh(y, 0, Y+4, X+4, rows - 5, cols - 6)
- padhi = rows - 8 - Y
+ if os.path.isfile(self.filepath):
+ with open(self.filepath) as f:
+ cfg_user = json.load(f)
+ setting_dict = Config.update_dict(setting_dict, cfg_user["Setting"])
+ keymap_dict = Config.update_dict(keymap_dict, cfg_user["Keymap"])
+ else:
+ self.save({"Setting": setting_dict, "Keymap": keymap_dict})
- while key_textw not in K["Quit"]|key:
- if key_textw in K["ScrollUp"] and y > 0:
- y -= 1
- elif key_textw in K["ScrollDown"] and y < totlines - hi + 6:
- y += 1
- elif key_textw in K["PageUp"]:
- y = pgup(y, padhi)
- elif key_textw in K["PageDown"]:
- y = pgdn(y, totlines, padhi)
- elif key_textw in K["BeginningOfCh"]:
- y = 0
- elif key_textw in K["EndOfCh"]:
- y = pgend(totlines, padhi)
- elif key_textw in WINKEYS - key:
- textw.clear()
- textw.refresh()
- return key_textw
- pad.refresh(y, 0, 6, 5, rows - 5, cols - 5)
- key_textw = textw.getch()
+ keymap_dict_tuple = {k: tuple(v) for k, v in keymap_dict.items()}
+ keymap_updated = {
+ k: tuple([Key(i) for i in v])
+ for k, v in Config.update_keys_tuple(keymap_dict_tuple, keymap_builtin_dict).items()
+ }
- textw.clear()
- textw.refresh()
+ if sys.platform == "win32":
+ setting_dict["PageScrollAnimation"] = False
+
+ self.setting = Settings(**setting_dict)
+ self.keymap = Keymap(**keymap_updated)
+ # to build help menu text
+ self.keymap_user_dict = keymap_dict
+
+ @property
+ def filepath(self) -> str:
+ return os.path.join(self.prefix, "configuration.json") if self.prefix else os.devnull
+
+ def save(self, cfg_dict):
+ with open(self.filepath, "w") as file:
+ json.dump(cfg_dict, file, indent=2)
+
+ @staticmethod
+ def update_dict(
+ old_dict: Mapping[str, Union[str, int, bool]],
+ new_dict: Mapping[str, Union[str, int, bool]],
+ place_new=False,
+ ) -> Mapping[str, Union[str, int, bool]]:
+ """Returns a copy of `old_dict` after updating it with `new_dict`"""
+
+ result = {**old_dict}
+ for k, v in new_dict.items():
+ if k in result:
+ result[k] = new_dict[k]
+ elif place_new:
+ result[k] = new_dict[k]
+
+ return result
+
+ @staticmethod
+ def update_keys_tuple(
+ old_keys: Mapping[str, Tuple[str, ...]],
+ new_keys: Mapping[str, Tuple[str, ...]],
+ place_new: bool = False,
+ ) -> Mapping[str, Tuple[str, ...]]:
+ """Returns a copy of `old_keys` after updating it with `new_keys`
+ by appending the tuple value and removes duplicate"""
+
+ result = {**old_keys}
+ for k, v in new_keys.items():
+ if k in result:
+ result[k] = tuple(set(result[k] + new_keys[k]))
+ elif place_new:
+ result[k] = tuple(set(new_keys[k]))
+
+ return result
+
+
+class State(AppData):
+ """
+ Use sqlite3 instead of JSON (in older version)
+ to shift the weight from memory to process
+ """
+
+ def __init__(self):
+ if not os.path.isfile(self.filepath):
+ self.init_db()
+
+ @property
+ def filepath(self) -> str:
+ return os.path.join(self.prefix, "states.db") if self.prefix else os.devnull
+
+ def get_from_history(self) -> Tuple[str]:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ cur = conn.cursor()
+ cur.execute("SELECT filepath FROM reading_states")
+ results = cur.fetchall()
+ return tuple(i[0] for i in results)
+ finally:
+ conn.close()
+
+ def delete_from_history(self, filepath: str) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute("DELETE FROM reading_states WHERE filepath=?", (filepath,))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def get_last_read(self) -> Optional[str]:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ cur = conn.cursor()
+ cur.execute("SELECT filepath FROM last_read WHERE id=0")
+ res = cur.fetchone()
+ if res:
+ return res[0]
+ return None
+ finally:
+ conn.close()
+
+ def set_last_read(self, ebook: Union[Epub, Mobi, Azw3, FictionBook]) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute("INSERT OR REPLACE INTO last_read VALUES (0, ?)", (ebook.path,))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def get_last_reading_state(self, ebook: Union[Epub, Mobi, Azw3, FictionBook]) -> ReadingState:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.row_factory = sqlite3.Row
+ cur = conn.cursor()
+ cur.execute("SELECT * FROM reading_states WHERE filepath=?", (ebook.path,))
+ result = cur.fetchone()
+ if result:
+ result = dict(result)
+ del result["filepath"]
+ return ReadingState(**result)
+ return ReadingState()
+ finally:
+ conn.close()
+
+ def set_last_reading_state(
+ self, ebook: Union[Epub, Mobi, Azw3, FictionBook], reading_state: ReadingState
+ ) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute(
+ """
+ INSERT OR REPLACE INTO reading_states
+ VALUES (:filepath, :content_index, :textwidth, :row, :rel_pctg)
+ """,
+ {"filepath": ebook.path, **dataclasses.asdict(reading_state)},
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ def insert_bookmark(
+ self, ebook: Union[Epub, Mobi, Azw3, FictionBook], name: str, reading_state: ReadingState
+ ) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute(
+ """
+ INSERT INTO bookmarks
+ VALUES (:filepath, :name, :content_index, :textwidth, :row, :rel_pctg)
+ """,
+ {"filepath": ebook.path, "name": name, **dataclasses.asdict(reading_state)},
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ def delete_bookmark(self, ebook: Union[Epub, Mobi, Azw3, FictionBook], name: str) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute("DELETE FROM bookmarks WHERE filepath=? AND name=?", (ebook.path, name))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def delete_bookmarks_by_filepath(self, filepath: str) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute("DELETE FROM bookmarks WHERE filepath=?", (filepath,))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def get_bookmarks(
+ self, ebook: Union[Epub, Mobi, Azw3, FictionBook]
+ ) -> List[Tuple[str, ReadingState]]:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.row_factory = sqlite3.Row
+ cur = conn.cursor()
+ cur.execute("SELECT * FROM bookmarks WHERE filepath=?", (ebook.path,))
+ results = cur.fetchall()
+ bookmarks: List[Tuple[str, ReadingState]] = []
+ for result in results:
+ tmp_dict = dict(result)
+ name = tmp_dict["name"]
+ del tmp_dict["filepath"]
+ del tmp_dict["name"]
+ bookmarks.append((name, ReadingState(**tmp_dict)))
+ return bookmarks
+ finally:
+ conn.close()
+
+ def init_db(self) -> None:
+ try:
+ conn = sqlite3.connect(self.filepath)
+ conn.execute(
+ """
+ CREATE TABLE last_read (
+ id INTEGER PRIMARY KEY,
+ filepath TEXT
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE reading_states (
+ filepath TEXT PRIMARY KEY,
+ content_index INTEGER,
+ textwidth INTEGER,
+ row INTEGER,
+ rel_pctg REAL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE bookmarks (
+ filepath TEXT,
+ name TEXT PRIMARY KEY,
+ content_index INTEGER,
+ textwidth INTEGER,
+ row INTEGER,
+ rel_pctg REAL
+ )
+ """
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def get_ebook_obj(filepath: str) -> Union[Epub, Mobi, Azw3, FictionBook]:
+ file_ext = os.path.splitext(filepath)[1]
+ if file_ext == ".epub":
+ return Epub(filepath)
+ elif file_ext == ".fb2":
+ return FictionBook(filepath)
+ elif MOBI_SUPPORT and file_ext == ".mobi":
+ return Mobi(filepath)
+ elif MOBI_SUPPORT and file_ext == ".azw3":
+ return Azw3(filepath)
+ elif not MOBI_SUPPORT and file_ext in {".mobi", ".azw3"}:
+ sys.exit(
+ "ERROR: Format not supported. (Supported: epub, fb2). "
+ "To get mobi and azw3 support, install mobi module from pip. "
+ "$ pip install mobi"
+ )
+ else:
+ sys.exit("ERROR: Format not supported. (Supported: epub, fb2)")
+
+
+def tuple_subtract(tuple_one: Tuple[Any, ...], tuple_two: Tuple[Any, ...]) -> Tuple[Any, ...]:
+ """Returns tuple with members in tuple_one
+ but not in tuple_two"""
+ return tuple(i for i in tuple_one if i not in tuple_two)
+
+
+def pgup(current_row: int, window_height: int, counter: int = 1) -> int:
+ if current_row >= (window_height) * counter:
+ return current_row - (window_height) * counter
+ else:
+ return 0
+
+
+def pgdn(current_row: int, total_lines: int, window_height: int, counter: int = 1) -> int:
+ if current_row + (window_height * counter) <= total_lines - window_height:
+ return current_row + (window_height * counter)
+ else:
+ current_row = total_lines - window_height
+ if current_row < 0:
+ return 0
+ return current_row
+
+
+def pgend(total_lines: int, window_height: int) -> int:
+ if total_lines - window_height >= 0:
+ return total_lines - window_height
+ else:
+ return 0
+
+
+def truncate(teks: str, subtitution_text: str, maxlen: int, startsub: int = 0) -> str:
+ """
+ Truncate text
+
+ eg.
+ teks: 'This is long silly dummy text'
+ subtitution_text: '...'
+ maxlen: 12
+ startsub: 3
+ :return: 'This...ly dummy text'
+ """
+ if startsub > maxlen:
+ raise ValueError("Var startsub cannot be bigger than maxlen.")
+ elif len(teks) <= maxlen:
+ return teks
+ else:
+ lensu = len(subtitution_text)
+ beg = teks[:startsub]
+ mid = (
+ subtitution_text
+ if lensu <= maxlen - startsub
+ else subtitution_text[: maxlen - startsub]
+ )
+ end = teks[startsub + lensu - maxlen :] if lensu < maxlen - startsub else ""
+ return beg + mid + end
+
+
+def safe_curs_set(state):
+ try:
+ curses.curs_set(state)
+ except:
return
- return wrapper
+
+
+def dots_path(curr, tofi):
+ candir = curr.split("/")
+ tofi = tofi.split("/")
+ alld = tofi.count("..")
+ t = len(candir)
+ candir = candir[0 : t - alld - 1]
+ try:
+ while True:
+ tofi.remove("..")
+ except ValueError:
+ pass
+ return "/".join(candir + tofi)
+
+
+def find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y):
+ ntoc = 0
+ for n, (i, j) in enumerate(zip(toc_idx, toc_sect)):
+ if i <= index:
+ if y >= toc_secid.get(j, 0):
+ ntoc = n
+ else:
+ break
+ return ntoc
+
+
+def count_letters(ebook: Union[Epub, Mobi, Azw3, FictionBook]) -> LettersCount:
+ per_content_counts: List[int] = []
+ cumulative_counts: List[int] = []
+ for i in ebook.contents:
+ content = ebook.get_raw_text(i)
+ parser = HTMLtoLines()
+ # try:
+ parser.feed(content)
+ parser.close()
+ # except:
+ # pass
+ src_lines = parser.get_lines()
+ cumulative_counts.append(sum(per_content_counts))
+ per_content_counts.append(sum([len(re.sub("\s", "", j)) for j in src_lines]))
+
+ return LettersCount(all=sum(per_content_counts), cumulative=tuple(cumulative_counts))
+
+
+def count_letters_parallel(ebook: Union[Epub, Mobi, Azw3, FictionBook], child_conn) -> None:
+ child_conn.send(count_letters(ebook))
+ child_conn.close()
def choice_win(allowdel=False):
+ """
+ Conjure options window by wrapping a window function
+ which has a return type of tuple in the form of
+ (title, list_to_chose, initial_active_index, windows_key_to_toggle)
+ and return tuple of (returned_key, chosen_index, chosen_index_to_delete)
+ """
+
def inner_f(listgen):
@wraps(listgen)
- def wrapper(*args, **kwargs):
- rows, cols = SCREEN.getmaxyx()
+ def wrapper(self, *args, **kwargs):
+ rows, cols = self.screen.getmaxyx()
hi, wi = rows - 4, cols - 4
Y, X = 2, 2
chwin = curses.newwin(hi, wi, Y, X)
- if COLORSUPPORT:
- chwin.bkgd(SCREEN.getbkgd())
+ if self.is_color_supported:
+ chwin.bkgd(self.screen.getbkgd())
- title, ch_list, index, key = listgen(*args, **kwargs)
+ title, ch_list, index, key = listgen(self, *args, **kwargs)
- if len(title) > cols-8:
- title = title[:cols-8]
+ if len(title) > cols - 8:
+ title = title[: cols - 8]
chwin.box()
chwin.keypad(True)
chwin.addstr(1, 2, title)
- chwin.addstr(2, 2, "-"*len(title))
+ chwin.addstr(2, 2, "-" * len(title))
if allowdel:
chwin.addstr(3, 2, "HINT: Press 'd' to delete.")
key_chwin = 0
@@ -867,88 +1302,83 @@ def choice_win(allowdel=False):
totlines = len(ch_list)
chwin.refresh()
pad = curses.newpad(totlines, wi - 2)
- if COLORSUPPORT:
- pad.bkgd(SCREEN.getbkgd())
+ if self.is_color_supported:
+ pad.bkgd(self.screen.getbkgd())
pad.keypad(True)
padhi = rows - 5 - Y - 4 + 1 - (1 if allowdel else 0)
# padhi = rows - 5 - Y - 4 + 1 - 1
y = 0
- if index in range(padhi//2, totlines - padhi//2):
- y = index - padhi//2 + 1
+ if index in range(padhi // 2, totlines - padhi // 2):
+ y = index - padhi // 2 + 1
span = []
for n, i in enumerate(ch_list):
# strs = " " + str(n+1).rjust(d) + " " + i[0]
strs = " " + i
- strs = strs[0:wi-3]
+ strs = strs[0 : wi - 3]
pad.addstr(n, 0, strs)
span.append(len(strs))
countstring = ""
- while key_chwin not in K["Quit"]|key:
+ while key_chwin not in self.keymap.Quit + key:
if countstring == "":
count = 1
else:
count = int(countstring)
- if key_chwin in range(48, 58): # i.e., k is a numeral
- countstring = countstring + chr(key_chwin)
+ if key_chwin in tuple(Key(i) for i in range(48, 58)): # i.e., k is a numeral
+ countstring = countstring + key_chwin.char
else:
- if key_chwin in K["ScrollUp"] or key_chwin in K["PageUp"]:
+ if key_chwin in self.keymap.ScrollUp + self.keymap.PageUp:
index -= count
if index < 0:
index = 0
- elif key_chwin in K["ScrollDown"] or key_chwin in K["PageDown"]:
+ elif key_chwin in self.keymap.ScrollDown or key_chwin in self.keymap.PageDown:
index += count
if index + 1 >= totlines:
index = totlines - 1
- elif key_chwin in K["Follow"]:
+ elif key_chwin in self.keymap.Follow:
chwin.clear()
chwin.refresh()
return None, index, None
- # elif key_chwin in K["PageUp"]:
- # index -= 3
- # if index < 0:
- # index = 0
- # elif key_chwin in K["PageDown"]:
- # index += 3
- # if index >= totlines:
- # index = totlines - 1
- elif key_chwin in K["BeginningOfCh"]:
+ elif key_chwin in self.keymap.BeginningOfCh:
index = 0
- elif key_chwin in K["EndOfCh"]:
+ elif key_chwin in self.keymap.EndOfCh:
index = totlines - 1
- elif key_chwin == ord("D") and allowdel:
- return None, (0 if index == 0 else index-1), index
+ elif key_chwin == Key("D") and allowdel:
+ return None, (0 if index == 0 else index - 1), index
# chwin.redrawwin()
# chwin.refresh()
- elif key_chwin == ord("d") and allowdel:
- resk, resp, _ = choice_win()(
- lambda: ("Delete '{}'?".format(
- ch_list[index]
- ), ["(Y)es", "(N)o"], 0, {ord("n")})
- )()
+ elif key_chwin == Key("d") and allowdel:
+ resk, resp, _ = self.show_win_options(
+ "Delete '{}'?".format(ch_list[index]),
+ ["(Y)es", "(N)o"],
+ 0,
+ (Key("n"),),
+ )
if resk is not None:
key_chwin = resk
continue
elif resp == 0:
- return None, (0 if index == 0 else index-1), index
+ return None, (0 if index == 0 else index - 1), index
chwin.redrawwin()
chwin.refresh()
- elif key_chwin in {ord(i) for i in ["Y", "y", "N", "n"]}\
- and ch_list == ["(Y)es", "(N)o"]:
- if key_chwin in {ord("Y"), ord("y")}:
+ elif key_chwin in {Key(i) for i in ["Y", "y", "N", "n"]} and ch_list == [
+ "(Y)es",
+ "(N)o",
+ ]:
+ if key_chwin in {Key("Y"), Key("y")}:
return None, 0, None
else:
return None, 1, None
- elif key_chwin in WINKEYS - key:
+ elif key_chwin in tuple_subtract(self._win_keys, key):
chwin.clear()
chwin.refresh()
return key_chwin, index, None
countstring = ""
- while index not in range(y, y+padhi):
+ while index not in range(y, y + padhi):
if index < y:
y -= 1
else:
@@ -960,1216 +1390,1507 @@ def choice_win(allowdel=False):
pad.addstr(n, 0, pre)
pad.chgat(n, 0, span[n], pad.getbkgd() | att)
- pad.refresh(y, 0, Y+4+(1 if allowdel else 0), X+4, rows - 5, cols - 6)
+ pad.refresh(y, 0, Y + 4 + (1 if allowdel else 0), X + 4, rows - 5, cols - 6)
# pad.refresh(y, 0, Y+5, X+4, rows - 5, cols - 6)
- key_chwin = chwin.getch()
- if key_chwin == curses.KEY_MOUSE:
+ key_chwin = Key(chwin.getch())
+ if key_chwin == Key(curses.KEY_MOUSE):
mouse_event = curses.getmouse()
if mouse_event[4] == curses.BUTTON4_PRESSED:
- key_chwin = list(K["ScrollUp"])[0]
+ key_chwin = self.keymap.ScrollUp[0]
elif mouse_event[4] == 2097152:
- key_chwin = list(K["ScrollDown"])[0]
+ key_chwin = self.keymap.ScrollDown[0]
elif mouse_event[4] == curses.BUTTON1_DOUBLE_CLICKED:
- if mouse_event[2] >= 6 and mouse_event[2] < rows-4\
- and mouse_event[2] < 6+totlines:
- index = mouse_event[2]-6+y
- key_chwin = list(K["Follow"])[0]
- elif mouse_event[4] == curses.BUTTON1_CLICKED\
- and mouse_event[2] >= 6 and mouse_event[2] < rows-4\
- and mouse_event[2] < 6+totlines:
- if index == mouse_event[2]-6+y:
- key_chwin = list(K["Follow"])[0]
+ if (
+ mouse_event[2] >= 6
+ and mouse_event[2] < rows - 4
+ and mouse_event[2] < 6 + totlines
+ ):
+ index = mouse_event[2] - 6 + y
+ key_chwin = self.keymap.Follow[0]
+ elif (
+ mouse_event[4] == curses.BUTTON1_CLICKED
+ and mouse_event[2] >= 6
+ and mouse_event[2] < rows - 4
+ and mouse_event[2] < 6 + totlines
+ ):
+ if index == mouse_event[2] - 6 + y:
+ key_chwin = self.keymap.Follow[0]
continue
- index = mouse_event[2]-6+y
+ index = mouse_event[2] - 6 + y
elif mouse_event[4] == curses.BUTTON3_CLICKED:
- key_chwin = list(K["Quit"])[0]
+ key_chwin = self.keymap.Quit[0]
chwin.clear()
chwin.refresh()
return None, None, None
+
return wrapper
+
return inner_f
-def show_loader(scr):
- scr.clear()
- rows, cols = scr.getmaxyx()
- scr.addstr((rows-1)//2, (cols-1)//2, "\u231B")
- # scr.addstr(((rows-2)//2)+1, (cols-len(msg))//2, msg)
- scr.refresh()
+def text_win(textfunc):
+ @wraps(textfunc)
+ def wrapper(self, *args, **kwargs) -> Union[NoUpdate, Key]:
+ rows, cols = self.screen.getmaxyx()
+ hi, wi = rows - 4, cols - 4
+ Y, X = 2, 2
+ textw = curses.newwin(hi, wi, Y, X)
+ if self.is_color_supported:
+ textw.bkgd(self.screen.getbkgd())
+ title, raw_texts, key = textfunc(self, *args, **kwargs)
-def loadstate():
- global CFG, STATE, CFGFILE, STATEFILE
- prefix = ""
- if os.getenv("HOME") is not None:
- homedir = os.getenv("HOME")
- if os.path.isdir(os.path.join(homedir, ".config")):
- prefix = os.path.join(homedir, ".config", "epy")
- else:
- prefix = os.path.join(homedir, ".epy")
- elif os.getenv("USERPROFILE") is not None:
- prefix = os.path.join(os.getenv("USERPROFILE"), ".epy")
- else:
- CFGFILE = os.devnull
- STATEFILE = os.devnull
- os.makedirs(prefix, exist_ok=True)
- CFGFILE = os.path.join(prefix, "config.json")
- STATEFILE = os.path.join(prefix, "state.json")
+ if len(title) > cols - 8:
+ title = title[: cols - 8]
- try:
- cfg_tmp = CFG
- with open(CFGFILE) as f:
- cfg = json.load(f)
- for i in cfg_tmp:
- if i != "Keys" and i in cfg:
- cfg_tmp[i] = cfg[i]
- cfg_tmp["Keys"].update(cfg["Keys"])
- CFG = cfg_tmp
- with open(STATEFILE) as f:
- STATE = json.load(f)
- except FileNotFoundError:
- pass
+ texts = []
+ for i in raw_texts.splitlines():
+ texts += textwrap.wrap(i, wi - 6, drop_whitespace=False)
- if sys.platform == "win32":
- CFG["PageScrollAnimation"] = False
+ textw.box()
+ textw.keypad(True)
+ textw.addstr(1, 2, title)
+ textw.addstr(2, 2, "-" * len(title))
+ key_textw: Union[NoUpdate, Key] = NoUpdate()
+ totlines = len(texts)
-def parse_keys():
- global WINKEYS
- for i in CFG["Keys"].keys():
- parsedk = CFG["Keys"][i]
- if len(parsedk) == 1:
- parsedk = ord(parsedk)
- elif parsedk[:-1] in {"^", "C-"}:
- parsedk = ord(parsedk[-1]) - 96 # Reference: ASCII chars
- else:
- sys.exit("ERROR: Keybindings {}".format(i))
+ pad = curses.newpad(totlines, wi - 2)
+ if self.is_color_supported:
+ pad.bkgd(self.screen.getbkgd())
- try:
- K[i].add(parsedk)
- except KeyError:
- K[i] = {parsedk}
- WINKEYS = {curses.KEY_RESIZE}|K["Metadata"]|K["Help"]|\
- K["TableOfContents"]|K["ShowBookmarks"]
-
-
-def savestate(file, index, width, pos, pctg):
- with open(CFGFILE, "w") as f:
- json.dump(CFG, f, indent=2)
- STATE["LastRead"] = file
- STATE["States"][file]["index"] = index
- STATE["States"][file]["width"] = width
- STATE["States"][file]["pos"] = pos
- STATE["States"][file]["pctg"] = pctg
- with open(STATEFILE, "w") as f:
- json.dump(STATE, f, indent=4)
-
- if MULTIPROC:
- # PROC_COUNTLETTERS.terminate()
- # PROC_COUNTLETTERS.kill()
- # PROC_COUNTLETTERS.join()
- try:
- PROC_COUNTLETTERS.kill()
- except AttributeError:
- PROC_COUNTLETTERS.terminate()
+ pad.keypad(True)
+ for n, i in enumerate(texts):
+ pad.addstr(n, 0, i)
+ y = 0
+ textw.refresh()
+ pad.refresh(y, 0, Y + 4, X + 4, rows - 5, cols - 6)
+ padhi = rows - 8 - Y
+
+ while key_textw not in self.keymap.Quit + key:
+ if key_textw in self.keymap.ScrollUp and y > 0:
+ y -= 1
+ elif key_textw in self.keymap.ScrollDown and y < totlines - hi + 6:
+ y += 1
+ elif key_textw in self.keymap.PageUp:
+ y = pgup(y, padhi)
+ elif key_textw in self.keymap.PageDown:
+ y = pgdn(y, totlines, padhi)
+ elif key_textw in self.keymap.BeginningOfCh:
+ y = 0
+ elif key_textw in self.keymap.EndOfCh:
+ y = pgend(totlines, padhi)
+ elif key_textw in tuple_subtract(self._win_keys, key):
+ textw.clear()
+ textw.refresh()
+ return key_textw
+ pad.refresh(y, 0, 6, 5, rows - 5, cols - 5)
+ key_textw = Key(textw.getch())
+ textw.clear()
+ textw.refresh()
+ return NoUpdate()
-def pgup(pos, winhi, preservedline=0, c=1):
- if pos >= (winhi - preservedline) * c:
- return pos - (winhi + preservedline) * c
- else:
- return 0
+ return wrapper
-def pgdn(pos, tot, winhi, preservedline=0, c=1):
- if pos + (winhi * c) <= tot - winhi:
- return pos + (winhi * c)
- else:
- pos = tot - winhi
- if pos < 0:
- return 0
- return pos
+class Reader:
+ def __init__(
+ self, screen, ebook: Union[Epub, Mobi, Azw3, FictionBook], config: Config, state: State
+ ):
+
+ self.setting = config.setting
+ self.keymap = config.keymap
+ # to build help menu text
+ self.keymap_user_dict = config.keymap_user_dict
+
+ # keys that will make
+ # windows exit and return the said key
+ self._win_keys = (
+ # curses.KEY_RESIZE is a must
+ (Key(curses.KEY_RESIZE),)
+ + self.keymap.TableOfContents
+ + self.keymap.Metadata
+ + self.keymap.Help
+ )
+ # screen initialization
+ self.screen = screen
+ self.screen.keypad(True)
+ safe_curs_set(0)
+ if self.setting.MouseSupport:
+ curses.mousemask(-1)
+ # curses.mouseinterval(0)
+ self.screen.clear()
-def pgend(tot, winhi):
- if tot - winhi >= 0:
- return tot - winhi
- else:
- return 0
+ # screen color
+ self.is_color_supported: bool = False
+ try:
+ curses.use_default_colors()
+ curses.init_pair(1, -1, -1)
+ curses.init_pair(2, self.setting.DarkColorFG, self.setting.DarkColorBG)
+ curses.init_pair(3, self.setting.LightColorFG, self.setting.LightColorBG)
+ self.is_color_supported = True
+ except:
+ self.is_color_supported = False
+ # show loader and start heavy resources processes
+ self.show_loader()
-@choice_win()
-def toc(src, index):
- return "Table of Contents", src, index, K["TableOfContents"]
-
-
-@text_win
-def meta(ebook):
- mdata = "[File Info]\nPATH: {}\nSIZE: {} MB\n \n[Book Info]\n".format(
- ebook.path, round(os.path.getsize(ebook.path)/1024**2, 2)
- )
- for i in ebook.get_meta():
- data = re.sub("<[^>]*>", "", i[1])
- mdata += i[0].upper() + ": " + data + "\n"
- data = re.sub("\t", "", data)
- # mdata += textwrap.wrap(i[0].upper() + ": " + data, wi - 6)
- return "Metadata", mdata, K["Metadata"]
-
-
-@text_win
-def help():
- src = "Key Bindings:\n"
- dig = max([len(i) for i in CFG["Keys"].values()]) + 2
- for i in CFG["Keys"].keys():
- src += "{} {}\n".format(
- CFG["Keys"][i].rjust(dig),
- " ".join(re.findall("[A-Z][^A-Z]*", i))
- )
- return "Help", src, K["Help"]
-
-
-@text_win
-def errmsg(title, msg, key):
- return title, msg, key
-
-
-def bookmarks(ebookpath):
- idx = 0
- while True:
- bmarkslist = [
- i[0] for i in STATE["States"][ebookpath]["bmarks"]
- ]
- if bmarkslist == []:
- return list(K["ShowBookmarks"])[0], None
- retk, idx, todel = choice_win(True)(lambda:
- ("Bookmarks", bmarkslist, idx, {ord("B")})
- )()
- if todel is not None:
- del STATE["States"][ebookpath]["bmarks"][todel]
- else:
- return retk, idx
+ # main ebook object
+ self.ebook = ebook
+ try:
+ self.ebook.initialize()
+ except Exception as e:
+ sys.exit("ERROR: Badly-structured ebook.\n" + str(e))
+ # state
+ self.state = state
-def truncate(teks, subte, maxlen, startsub=0):
- if startsub > maxlen:
- raise ValueError("Var startsub cannot be bigger than maxlen.")
- elif len(teks) <= maxlen:
- return teks
- else:
- lensu = len(subte)
- beg = teks[:startsub]
- mid = subte if lensu <= maxlen - startsub else subte[:maxlen-startsub]
- end = teks[startsub+lensu-maxlen:] if lensu < maxlen - startsub else ""
- return beg+mid+end
+ # page scroll animation
+ self.page_animation: Optional[Direction] = None
-def safe_curs_set(state):
- try:
- curses.curs_set(state)
- except:
- return
+ # show reading progress
+ self.show_reading_progress = self.setting.ShowProgressIndicator
-def input_prompt(prompt):
- # prevent pad hole when prompting for input while
- # other window is active
- # pad.refresh(y, 0, 0, x, rows-2, x+width)
- rows, cols = SCREEN.getmaxyx()
- stat = curses.newwin(1, cols, rows-1, 0)
- if COLORSUPPORT:
- stat.bkgd(SCREEN.getbkgd())
- stat.keypad(True)
- curses.echo(1)
- safe_curs_set(1)
+ # search storage
+ self.search_data: Optional[SearchData] = None
- init_text = ""
+ # double spread
+ self.spread = 2 if self.setting.StartWithDoubleSpread else 1
- stat.addstr(0, 0, prompt, curses.A_REVERSE)
- stat.addstr(0, len(prompt), init_text)
- stat.refresh()
+ # jumps marker container
+ self.jump_list: Mapping[str, ReadingState] = dict()
- try:
- while True:
- # NOTE: getch() only handles ascii
- # to handle wide char like: é, use get_wch()
- # ipt = stat.getch()
- ipt = stat.get_wch()
- # get_wch() return ambiguous type
- # str for string input but int for function or special keys
- if type(ipt) == str:
- ipt = ord(ipt)
-
- if ipt == 27:
- stat.clear()
- stat.refresh()
- curses.echo(0)
- safe_curs_set(0)
- return
- elif ipt == 10:
- stat.clear()
- stat.refresh()
- curses.echo(0)
- safe_curs_set(0)
- return init_text
- elif ipt in {8, 127, curses.KEY_BACKSPACE}:
- init_text = init_text[:-1]
- elif ipt == curses.KEY_RESIZE:
- stat.clear()
- stat.refresh()
- curses.echo(0)
- safe_curs_set(0)
- return curses.KEY_RESIZE
- # elif len(init_text) <= maxlen:
- else:
- init_text += chr(ipt)
+ # TTS speaker utils
+ self._tts_support: bool = any([shutil.which("pico2wave"), shutil.which("play")])
+ self.is_speaking: bool = False
- stat.clear()
- stat.addstr(0, 0, prompt, curses.A_REVERSE)
- stat.addstr(
- 0, len(prompt),
- init_text if len(prompt+init_text) < cols else "..."+init_text[len(prompt)-cols+4:]
+ # multi process & progress percentage
+ self._multiprocess_support: bool = False if multiprocessing.cpu_count() == 1 else True
+ self._process_counting_letter: Optional[multiprocessing.Process] = None
+ self.letters_count: Optional[LettersCount] = None
+
+ def run_counting_letters(self):
+ if self._multiprocess_support:
+ try:
+ self._proc_parent, self._proc_child = multiprocessing.Pipe()
+ self._process_counting_letter = multiprocessing.Process(
+ name="epy-subprocess-counting-letters",
+ target=count_letters_parallel,
+ args=(self.ebook, self._proc_child),
)
- stat.refresh()
- except KeyboardInterrupt:
- stat.clear()
- stat.refresh()
- curses.echo(0)
- safe_curs_set(0)
- return
+ # forking will raise
+ # zlib.error: Error -3 while decompressing data: invalid distance too far back
+ self._process_counting_letter.start()
+ except:
+ self._multiprocess_support = False
+ if not self._multiprocess_support:
+ self.letters_count = count_letters(self.ebook)
+ @property
+ def screen_rows(self) -> int:
+ return self.screen.getmaxyx()[0]
-def det_ebook_cls(file):
- filext = os.path.splitext(file)[1]
- if filext == ".epub":
- return Epub(file)
- elif filext == ".fb2":
- return FictionBook(file)
- elif MOBISUPPORT and filext == ".mobi":
- return Mobi(file)
- elif MOBISUPPORT and filext == ".azw3":
- return Azw3(file)
- elif not MOBISUPPORT and filext in {".mobi", ".azw3"}:
- sys.exit("""ERROR: Format not supported. (Supported: epub, fb2).
-To get mobi and azw3 support, install mobi module from pip.
- $ pip install mobi""")
- else:
- sys.exit("ERROR: Format not supported. (Supported: epub, fb2)")
+ @property
+ def screen_cols(self) -> int:
+ return self.screen.getmaxyx()[1]
+ @property
+ def ext_dict_app(self) -> Optional[str]:
+ self._ext_dict_app: Optional[str] = None
+ dict_app_preset_list = ["sdcv", "dict"]
-def dots_path(curr, tofi):
- candir = curr.split("/")
- tofi = tofi.split("/")
- alld = tofi.count("..")
- t = len(candir)
- candir = candir[0:t-alld-1]
- try:
- while True:
- tofi.remove("..")
- except ValueError:
- pass
- return "/".join(candir+tofi)
+ if shutil.which(self.setting.DictionaryClient.split()[0]):
+ self._ext_dict_app = self.setting.DictionaryClient
+ else:
+ for i in dict_app_preset_list:
+ if shutil.which(i) is not None:
+ self._ext_dict_app = i
+ break
+ if self._ext_dict_app in {"sdcv"}:
+ self._ext_dict_app += " -n"
+ return self._ext_dict_app
-def find_dict_client():
- global DICT
- if shutil.which(CFG["DictionaryClient"].split()[0]) is not None:
- DICT = CFG["DictionaryClient"]
- else:
- DICT_LIST = [
- "sdcv",
- "dict"
- ]
- for i in DICT_LIST:
- if shutil.which(i) is not None:
- DICT = i
- break
- if DICT in {"sdcv"}:
- DICT += " -n"
-
-
-def find_media_viewer():
- global VWR
- if shutil.which(CFG["DefaultViewer"].split()[0]) is not None:
- VWR = CFG["DefaultViewer"]
- elif sys.platform == "win32":
- VWR = "start"
- elif sys.platform == "darwin":
- VWR = "open"
- else:
- VWR_LIST = [
- "feh",
- "gio",
- "gnome-open",
- "gvfs-open",
- "xdg-open",
- "kde-open",
- "firefox"
- ]
- for i in VWR_LIST:
- if shutil.which(i) is not None:
- VWR = i
- break
+ @property
+ def image_viewer(self) -> str:
+ self._image_viewer: Optional[str] = None
- if VWR in {"gio"}:
- VWR += " open"
+ if shutil.which(self.setting.DefaultViewer.split()[0]) is not None:
+ self._image_viewer = self.setting.DefaultViewer
+ elif sys.platform == "win32":
+ self._image_viewer = "start"
+ elif sys.platform == "darwin":
+ self._image_viewer = "open"
+ else:
+ for i in VIEWER_PRESET_LIST:
+ if shutil.which(i) is not None:
+ self._image_viewer = i
+ break
+ if self._image_viewer in {"gio"}:
+ self._image_viewer += " open"
-def open_media(scr, name, bstr):
- sfx = os.path.splitext(name)[1]
- fd, path = tempfile.mkstemp(suffix=sfx)
- try:
- with os.fdopen(fd, "wb") as tmp:
- # tmp.write(epub.file.read(src))
- tmp.write(bstr)
- # run(VWR + " " + path, shell=True)
- subprocess.call(
- VWR + " " + path,
+ return self._image_viewer
+
+ def open_image(self, pad, name, bstr):
+ sfx = os.path.splitext(name)[1]
+ fd, path = tempfile.mkstemp(suffix=sfx)
+ try:
+ with os.fdopen(fd, "wb") as tmp:
+ # tmp.write(epub.file.read(src))
+ tmp.write(bstr)
+ # run(VWR + " " + path, shell=True)
+ subprocess.call(
+ self.image_viewer + " " + path,
+ shell=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ k = pad.getch()
+ finally:
+ os.remove(path)
+ return k
+
+ def show_loader(self):
+ self.screen.clear()
+ rows, cols = self.screen.getmaxyx()
+ self.screen.addstr((rows - 1) // 2, (cols - 1) // 2, "\u231B")
+ # self.screen.addstr(((rows-2)//2)+1, (cols-len(msg))//2, msg)
+ self.screen.refresh()
+
+ @choice_win(True)
+ def show_win_options(self, title, options, active_index, key_set):
+ return title, options, active_index, key_set
+
+ @text_win
+ def show_win_error(self, title, msg, key):
+ return title, msg, key
+
+ @choice_win()
+ def toc(self, src, index):
+ return "Table of Contents", src, index, self.keymap.TableOfContents
+
+ @text_win
+ def show_win_metadata(self):
+ mdata = "[File Info]\nPATH: {}\nSIZE: {} MB\n \n[Book Info]\n".format(
+ self.ebook.path, round(os.path.getsize(self.ebook.path) / 1024 ** 2, 2)
+ )
+ for i in self.ebook.get_meta():
+ data = re.sub("<[^>]*>", "", i[1])
+ mdata += i[0].upper() + ": " + data + "\n"
+ # data = re.sub("\t", "", data)
+ # mdata += textwrap.wrap(i[0].upper() + ": " + data, wi - 6)
+ return "Metadata", mdata, self.keymap.Metadata
+
+ @text_win
+ def show_win_help(self):
+ src = "Key Bindings:\n"
+ dig = max([len(i) for i in self.keymap_user_dict.values()]) + 2
+ for i in self.keymap_user_dict.keys():
+ src += "{} {}\n".format(
+ self.keymap_user_dict[i].rjust(dig), " ".join(re.findall("[A-Z][^A-Z]*", i))
+ )
+ return "Help", src, self.keymap.Help
+
+ @text_win
+ def define_word(self, word):
+ rows, cols = self.screen.getmaxyx()
+ hi, wi = 5, 16
+ Y, X = (rows - hi) // 2, (cols - wi) // 2
+
+ p = subprocess.Popen(
+ "{} {}".format(self.ext_dict_app, word),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
shell=True,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL
)
- k = scr.getch()
- finally:
- os.remove(path)
- return k
+ dictwin = curses.newwin(hi, wi, Y, X)
+ dictwin.box()
+ dictwin.addstr((hi - 1) // 2, (wi - 10) // 2, "Loading...")
+ dictwin.refresh()
-@text_win
-def define_word(word):
- rows, cols = SCREEN.getmaxyx()
- hi, wi = 5, 16
- Y, X = (rows - hi)//2, (cols - wi)//2
+ out, err = p.communicate()
- p = subprocess.Popen(
- "{} {}".format(DICT, word),
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- shell=True
- )
+ dictwin.clear()
+ dictwin.refresh()
- dictwin = curses.newwin(hi, wi, Y, X)
- dictwin.box()
- dictwin.addstr((hi-1)//2, (wi-10)//2, "Loading...")
- dictwin.refresh()
+ if err == b"":
+ return "Definition: " + word.upper(), out.decode(), self.keymap.DefineWord
+ else:
+ return "Error: " + self.ext_dict_app, err.decode(), self.keymap.DefineWord
- out, err = p.communicate()
+ def show_win_choices_bookmarks(self):
+ idx = 0
+ while True:
+ bookmarks = [i[0] for i in self.state.get_bookmarks(self.ebook)]
+ if not bookmarks:
+ return self.keymap.ShowBookmarks[0], None
+ retk, idx, todel = self.show_win_options(
+ "Bookmarks", bookmarks, idx, self.keymap.ShowBookmarks
+ )
+ if todel is not None:
+ self.state.delete_bookmark(self.ebook, bookmarks[todel])
+ else:
+ return retk, idx
+
+ def input_prompt(self, prompt: str) -> Union[NoUpdate, Key, str]:
+ # prevent pad hole when prompting for input while
+ # other window is active
+ # pad.refresh(y, 0, 0, x, rows-2, x+width)
+ rows, cols = self.screen.getmaxyx()
+ stat = curses.newwin(1, cols, rows - 1, 0)
+ if self.is_color_supported:
+ stat.bkgd(self.screen.getbkgd())
+ stat.keypad(True)
+ curses.echo(1)
+ safe_curs_set(1)
+
+ init_text = ""
+
+ stat.addstr(0, 0, prompt, curses.A_REVERSE)
+ stat.addstr(0, len(prompt), init_text)
+ stat.refresh()
- dictwin.clear()
- dictwin.refresh()
+ try:
+ while True:
+ # NOTE: getch() only handles ascii
+ # to handle wide char like: é, use get_wch()
+ ipt = Key(stat.get_wch())
+ # get_wch() return ambiguous type
+ # str for string input but int for function or special keys
+ # if type(ipt) == str:
+ # ipt = ord(ipt)
+
+ if ipt == Key(27):
+ stat.clear()
+ stat.refresh()
+ curses.echo(0)
+ safe_curs_set(0)
+ return NoUpdate()
+ elif ipt == Key(10):
+ stat.clear()
+ stat.refresh()
+ curses.echo(0)
+ safe_curs_set(0)
+ return init_text
+ elif ipt in (Key(8), Key(127), Key(curses.KEY_BACKSPACE)):
+ init_text = init_text[:-1]
+ elif ipt == Key(curses.KEY_RESIZE):
+ stat.clear()
+ stat.refresh()
+ curses.echo(0)
+ safe_curs_set(0)
+ return Key(curses.KEY_RESIZE)
+ # elif len(init_text) <= maxlen:
+ else:
+ init_text += ipt.char
- if err == b"":
- return "Definition: " + word.upper(), out.decode(), K["DefineWord"]
- else:
- return "Error: " + DICT, err.decode(), K["DefineWord"]
+ stat.clear()
+ stat.addstr(0, 0, prompt, curses.A_REVERSE)
+ stat.addstr(
+ 0,
+ len(prompt),
+ init_text
+ if len(prompt + init_text) < cols
+ else "..." + init_text[len(prompt) - cols + 4 :],
+ )
+ stat.refresh()
+ except KeyboardInterrupt:
+ stat.clear()
+ stat.refresh()
+ curses.echo(0)
+ safe_curs_set(0)
+ return NoUpdate()
+
+ def searching(
+ self, pad, src, reading_state: ReadingState, tot
+ ) -> Union[NoUpdate, ReadingState, Key]:
+
+ rows, cols = self.screen.getmaxyx()
+ # unnecessary
+ # if self.spread == 2:
+ # reading_state = dataclasses.replace(reading_state, textwidth=(cols - 7) // 2)
+
+ x = (cols - reading_state.textwidth) // 2
+ if self.spread == 1:
+ x = (cols - reading_state.textwidth) // 2
+ else:
+ x = 2
+ if not self.search_data:
+ candidate_text = self.input_prompt(" Regex:")
+ if isinstance(candidate_text, str) and candidate_text:
+ self.search_data = SearchData(value=candidate_text)
+ else:
+ return candidate_text
-def searching(pad, src, width, y, ch, tot):
- global SEARCHPATTERN
- rows, cols = SCREEN.getmaxyx()
- if SPREAD == 2:
- width = (cols-7)//2
+ found = []
+ try:
+ pattern = re.compile(self.search_data.value, re.IGNORECASE)
+ except re.error as reerrmsg:
+ self.search_data = None
+ tmpk = self.show_win_error("!Regex Error", str(reerrmsg), tuple())
+ return tmpk
+
+ for n, i in enumerate(src):
+ for j in pattern.finditer(i):
+ found.append([n, j.span()[0], j.span()[1] - j.span()[0]])
+
+ if not found:
+ if (
+ self.search_data.direction == Direction.FORWARD
+ and reading_state.content_index + 1 < tot
+ ):
+ return ReadingState(
+ content_index=reading_state.content_index + 1, textwidth=reading_state.textwidth
+ )
+ elif (
+ self.search_data.direction == Direction.BACKWARD and reading_state.content_index > 0
+ ):
+ return ReadingState(
+ content_index=reading_state.content_index - 1, textwidth=reading_state.textwidth
+ )
+ else:
+ s = 0
+ while True:
+ if s in self.keymap.Quit:
+ self.search_data = None
+ self.screen.clear()
+ self.screen.refresh()
+ return reading_state
+ # TODO: maybe >= 0?
+ elif s == Key("n") and reading_state.content_index == 0:
+ self.search_data = dataclasses.replace(
+ self.search_data, direction=Direction.FORWARD
+ )
+ return ReadingState(
+ content_index=reading_state.content_index + 1,
+ textwidth=reading_state.textwidth,
+ )
+ elif s == Key("N") and reading_state.content_index + 1 == tot:
+ self.search_data = dataclasses.replace(
+ self.search_data, direction=Direction.BACKWARD
+ )
+ return ReadingState(
+ content_index=reading_state.content_index - 1,
+ textwidth=reading_state.textwidth,
+ )
- x = (cols - width) // 2
- if SPREAD == 1:
- x = (cols - width) // 2
- else:
- x = 2
-
- if SEARCHPATTERN is None:
- candtext = input_prompt(" Regex:")
- if candtext is None:
- return y
- elif isinstance(candtext, str):
- SEARCHPATTERN = "/" + candtext
- elif candtext == curses.KEY_RESIZE:
- return candtext
-
- if SEARCHPATTERN in {"?", "/"}:
- SEARCHPATTERN = None
- return y
-
- found = []
- try:
- pattern = re.compile(SEARCHPATTERN[1:], re.IGNORECASE)
- except re.error as reerrmsg:
- SEARCHPATTERN = None
- tmpk = errmsg("!Regex Error", str(reerrmsg), set())
- return tmpk
-
- for n, i in enumerate(src):
- for j in pattern.finditer(i):
- found.append([n, j.span()[0], j.span()[1] - j.span()[0]])
-
- if found == []:
- if SEARCHPATTERN[0] == "/" and ch + 1 < tot:
- return 1
- elif SEARCHPATTERN[0] == "?" and ch > 0:
- return -1
- else:
- s = 0
- while True:
- if s in K["Quit"]:
- SEARCHPATTERN = None
- SCREEN.clear()
- SCREEN.refresh()
- return y
- elif s == ord("n") and ch == 0:
- SEARCHPATTERN = "/"+SEARCHPATTERN[1:]
- return 1
- elif s == ord("N") and ch + 1 == tot:
- SEARCHPATTERN = "?"+SEARCHPATTERN[1:]
- return -1
-
- SCREEN.clear()
- SCREEN.addstr(
- rows-1, 0,
- " Finished searching: " + SEARCHPATTERN[1:cols-22] + " ",
- curses.A_REVERSE
+ self.screen.clear()
+ self.screen.addstr(
+ rows - 1,
+ 0,
+ " Finished searching: " + self.search_data.value[: cols - 22] + " ",
+ curses.A_REVERSE,
+ )
+ self.screen.refresh()
+ pad.refresh(reading_state.row, 0, 0, x, rows - 2, x + reading_state.textwidth)
+ if self.spread == 2:
+ if reading_state.row + rows < len(src):
+ pad.refresh(
+ reading_state.row + rows - 1,
+ 0,
+ 0,
+ cols - 2 - reading_state.textwidth,
+ rows - 2,
+ cols - 2,
+ )
+ s = pad.getch()
+
+ sidx = len(found) - 1
+ if self.search_data.direction == Direction.FORWARD:
+ if reading_state.row > found[-1][0]:
+ return ReadingState(
+ content_index=reading_state.content_index + 1, textwidth=reading_state.textwidth
)
- SCREEN.refresh()
- pad.refresh(y, 0, 0, x, rows-2, x+width)
- if SPREAD == 2:
- if y+rows < len(src):
- pad.refresh(y+rows-1, 0, 0, cols-2-width, rows-2, cols-2)
- s = pad.getch()
-
- sidx = len(found) - 1
- if SEARCHPATTERN[0] == "/":
- if y > found[-1][0]:
- return 1
- for n, i in enumerate(found):
- if i[0] >= y:
- sidx = n
- break
+ for n, i in enumerate(found):
+ if i[0] >= reading_state.row:
+ sidx = n
+ break
- s = 0
- msg = " Searching: "\
- + SEARCHPATTERN[1:]\
- + " --- Res {}/{} Ch {}/{} ".format(
- sidx + 1,
- len(found),
- ch+1, tot
+ s = 0
+ msg = (
+ " Searching: "
+ + self.search_data.value
+ + " --- Res {}/{} Ch {}/{} ".format(
+ sidx + 1, len(found), reading_state.content_index + 1, tot
+ )
)
- while True:
- if s in K["Quit"]:
- SEARCHPATTERN = None
- for i in found:
- pad.chgat(i[0], i[1], i[2], pad.getbkgd())
- pad.format()
- SCREEN.clear()
- SCREEN.refresh()
- return y
- elif s == ord("n"):
- SEARCHPATTERN = "/"+SEARCHPATTERN[1:]
- if sidx == len(found) - 1:
- if ch + 1 < tot:
- return 1
+ while True:
+ if s in self.keymap.Quit:
+ self.search_data = None
+ for i in found:
+ pad.chgat(i[0], i[1], i[2], pad.getbkgd())
+ pad.format()
+ self.screen.clear()
+ self.screen.refresh()
+ return reading_state
+ elif s == Key("n"):
+ self.search_data = dataclasses.replace(
+ self.search_data, direction=Direction.FORWARD
+ )
+ if sidx == len(found) - 1:
+ if reading_state.content_index + 1 < tot:
+ return ReadingState(
+ content_index=reading_state.content_index + 1,
+ textwidth=reading_state.textwidth,
+ )
+ else:
+ s = 0
+ msg = " Finished searching: " + self.search_data.value + " "
+ continue
else:
- s = 0
- msg = " Finished searching: " + SEARCHPATTERN[1:] + " "
- continue
- else:
- sidx += 1
- msg = " Searching: "\
- + SEARCHPATTERN[1:]\
- + " --- Res {}/{} Ch {}/{} ".format(
- sidx + 1,
- len(found),
- ch+1, tot
+ sidx += 1
+ msg = (
+ " Searching: "
+ + self.search_data.value
+ + " --- Res {}/{} Ch {}/{} ".format(
+ sidx + 1, len(found), reading_state.content_index + 1, tot
+ )
)
- elif s == ord("N"):
- SEARCHPATTERN = "?"+SEARCHPATTERN[1:]
- if sidx == 0:
- if ch > 0:
- return -1
+ elif s == Key("N"):
+ self.search_data = dataclasses.replace(
+ self.search_data, direction=Direction.BACKWARD
+ )
+ if sidx == 0:
+ if reading_state.content_index > 0:
+ return ReadingState(
+ content_index=reading_state.content_index - 1,
+ textwidth=reading_state.textwidth,
+ )
+ else:
+ s = 0
+ msg = " Finished searching: " + self.search_data.value + " "
+ continue
else:
- s = 0
- msg = " Finished searching: " + SEARCHPATTERN[1:] + " "
- continue
- else:
- sidx -= 1
- msg = " Searching: "\
- + SEARCHPATTERN[1:]\
- + " --- Res {}/{} Ch {}/{} ".format(
- sidx + 1,
- len(found),
- ch+1, tot
+ sidx -= 1
+ msg = (
+ " Searching: "
+ + self.search_data.value
+ + " --- Res {}/{} Ch {}/{} ".format(
+ sidx + 1, len(found), reading_state.content_index + 1, tot
+ )
)
- elif s == curses.KEY_RESIZE:
- return s
-
- # TODO
- if y+rows-1 > pad.chunks[pad.find_chunkidx(y)]:
- y = pad.chunks[pad.find_chunkidx(y)] + 1
+ elif s == Key(curses.KEY_RESIZE):
+ return Key(curses.KEY_RESIZE)
- while found[sidx][0] not in list(range(y, y+(rows-1)*SPREAD )):
- if found[sidx][0] > y:
- y += (rows - 1)*SPREAD
- else:
- y -= (rows - 1)*SPREAD
- if y < 0:
- y = 0
-
- for n, i in enumerate(found):
- attr = curses.A_REVERSE if n == sidx else curses.A_NORMAL
- pad.chgat(i[0], i[1], i[2], pad.getbkgd() | attr)
-
- SCREEN.clear()
- SCREEN.addstr(rows-1, 0, msg, curses.A_REVERSE)
- SCREEN.refresh()
- pad.refresh(y, 0, 0, x, rows-2, x+width)
- if SPREAD == 2:
- if y+rows < len(src):
- pad.refresh(y+rows-1, 0, 0, cols-2-width, rows-2, cols-2)
- s = pad.getch()
+ if reading_state.row + rows - 1 > pad.chunks[pad.find_chunkidx(reading_state.row)]:
+ reading_state = dataclasses.replace(
+ reading_state, row=pad.chunks[pad.find_chunkidx(reading_state.row)] + 1
+ )
+ while found[sidx][0] not in list(
+ range(reading_state.row, reading_state.row + (rows - 1) * self.spread)
+ ):
+ if found[sidx][0] > reading_state.row:
+ reading_state = dataclasses.replace(
+ reading_state, row=reading_state.row + ((rows - 1) * self.spread)
+ )
+ else:
+ reading_state = dataclasses.replace(
+ reading_state, row=reading_state.row - ((rows - 1) * self.spread)
+ )
+ if reading_state.row < 0:
+ reading_state = dataclasses.replace(reading_state, row=0)
+
+ for n, i in enumerate(found):
+ attr = curses.A_REVERSE if n == sidx else curses.A_NORMAL
+ pad.chgat(i[0], i[1], i[2], pad.getbkgd() | attr)
+
+ self.screen.clear()
+ self.screen.addstr(rows - 1, 0, msg, curses.A_REVERSE)
+ self.screen.refresh()
+ pad.refresh(reading_state.row, 0, 0, x, rows - 2, x + reading_state.textwidth)
+ if self.spread == 2:
+ if reading_state.row + rows < len(src):
+ pad.refresh(
+ reading_state.row + rows - 1,
+ 0,
+ 0,
+ cols - 2 - reading_state.textwidth,
+ rows - 2,
+ cols - 2,
+ )
+ s = pad.getch()
-def find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y):
- ntoc = 0
- for n, (i, j) in enumerate(zip(toc_idx, toc_sect)):
- if i <= index:
- if y >= toc_secid.get(j, 0):
- ntoc = n
+ def speaking(self, text):
+ self.is_speaking = True
+ self.screen.addstr(self.screen_rows - 1, 0, " Speaking! ", curses.A_REVERSE)
+ self.screen.refresh()
+ self.screen.timeout(1)
+ try:
+ _, path = tempfile.mkstemp(suffix=".wav")
+ subprocess.call(
+ ["pico2wave", "-w", path, text],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ speaker = subprocess.Popen(
+ ["play", path, "tempo", str(self.setting.TTSSpeed)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ while True:
+ if speaker.poll() is not None:
+ k = self.keymap.PageDown[0]
+ break
+ tmp = self.screen.getch()
+ k = NoUpdate() if tmp == -1 else Key(tmp)
+ if k == Key(curses.KEY_MOUSE):
+ mouse_event = curses.getmouse()
+ if mouse_event[4] == curses.BUTTON2_CLICKED:
+ k = self.keymap.Quit[0]
+ elif mouse_event[4] == curses.BUTTON1_CLICKED:
+ if mouse_event[1] < self.screen_cols // 2:
+ k = self.keymap.PageUp[0]
+ else:
+ k = self.keymap.PageDown[0]
+ elif mouse_event[4] == curses.BUTTON4_PRESSED:
+ k = self.keymap.ScrollUp[0]
+ elif mouse_event[4] == 2097152:
+ k = self.keymap.ScrollDown[0]
+ if (
+ k
+ in self.keymap.Quit
+ + self.keymap.PageUp
+ + self.keymap.PageDown
+ + self.keymap.ScrollUp
+ + self.keymap.ScrollDown
+ + (curses.KEY_RESIZE,)
+ ):
+ speaker.terminate()
+ # speaker.kill()
+ break
+ finally:
+ self.screen.timeout(-1)
+ os.remove(path)
+
+ if k in self.keymap.Quit:
+ self.is_speaking = False
+ k = NoUpdate()
+ return k
+
+ def savestate(self, reading_state: ReadingState) -> None:
+ self.state.set_last_read(self.ebook)
+ self.state.set_last_reading_state(self.ebook, reading_state)
+
+ def cleanup(self) -> None:
+ self.ebook.cleanup()
+
+ if isinstance(self._process_counting_letter, multiprocessing.Process):
+ if self._process_counting_letter.is_alive():
+ self._process_counting_letter.terminate()
+ # weird python multiprocessing issue, need to call .join() before .close()
+ # ValueError: Cannot close a process while it is still running.
+ # You should first call join() or terminate().
+ self._process_counting_letter.join()
+ self._process_counting_letter.close()
+
+ def read(self, reading_state: ReadingState) -> ReadingState:
+
+ # k = self.keymap.RegexSearch[0] if self.search_data else 0
+ k = self.keymap.RegexSearch[0] if self.search_data else NoUpdate()
+ rows, cols = self.screen.getmaxyx()
+
+ mincols_doublespr = 2 + 22 + 3 + 22 + 2
+ if cols < mincols_doublespr:
+ self.spread = 1
+ if self.spread == 2:
+ reading_state = dataclasses.replace(reading_state, textwidth=(cols - 7) // 2)
+
+ x = (cols - reading_state.textwidth) // 2
+ if self.spread == 1:
+ x = (cols - reading_state.textwidth) // 2
else:
- break
- return ntoc
-
-
-def count_pct_async(ebook, allprev, sumlet):
- perch = []
- for n, i in enumerate(ebook.contents):
- content = ebook.get_raw_text(i)
- parser = HTMLtoLines()
+ x = 2
+
+ contents = self.ebook.contents
+ toc_name = self.ebook.toc_entries[0]
+ toc_idx = self.ebook.toc_entries[1]
+ toc_sect = self.ebook.toc_entries[2]
+ toc_secid = {}
+ chpath = contents[reading_state.content_index]
+ content = self.ebook.get_raw_text(chpath)
+
+ parser = HTMLtoLines(set(toc_sect))
+ # parser = HTMLtoLines()
# try:
parser.feed(content)
parser.close()
# except:
# pass
- src_lines = parser.get_lines()
- allprev[n] = sum(perch)
- perch.append(sum([len(re.sub("\s", "", j)) for j in src_lines]))
- sumlet.value = sum(perch)
-
-
-def count_pct(ebook):
- perch = []
- allprev = []
- for i in ebook.contents:
- content = ebook.get_raw_text(i)
- parser = HTMLtoLines()
- # try:
- parser.feed(content)
- parser.close()
- # except:
- # pass
- src_lines = parser.get_lines()
- allprev.append(sum(perch))
- perch.append(sum([len(re.sub("\s", "", j)) for j in src_lines]))
- sumlet = sum(perch)
- return allprev, sumlet
+ src_lines, imgs, toc_secid, formatting = parser.get_lines(reading_state.textwidth)
+ totlines = len(src_lines) + 1 # 1 extra line for suffix
-def count_max_reading_pg(ebook):
- global ALLPREVLETTERS, SUMALLLETTERS, PROC_COUNTLETTERS, MULTIPROC
-
- if MULTIPROC:
- try:
- ALLPREVLETTERS = multiprocessing.Array("i", len(ebook.contents))
- SUMALLLETTERS = multiprocessing.Value("i", 0)
- PROC_COUNTLETTERS = multiprocessing.Process(
- target=count_pct_async, args=(
- ebook,
- ALLPREVLETTERS,
- SUMALLLETTERS
- )
- )
- # forking PROC_COUNTLETTERS will raise
- # zlib.error: Error -3 while decompressing data: invalid distance too far back
- PROC_COUNTLETTERS.start()
- except:
- MULTIPROC = False
- if not MULTIPROC:
- ALLPREVLETTERS, SUMALLLETTERS = count_pct(ebook)
-
-
-def speaking(text):
- global SPEAKING
+ if reading_state.row < 0 and totlines <= rows * self.spread:
+ reading_state = dataclasses.replace(reading_state, row=0)
+ elif reading_state.rel_pctg is not None:
+ reading_state = dataclasses.replace(
+ reading_state, row=round(reading_state.rel_pctg * totlines)
+ )
+ else:
+ reading_state = dataclasses.replace(reading_state, row=reading_state.row % totlines)
- SPEAKING = True
- rows, _ = SCREEN.getmaxyx()
- SCREEN.addstr(rows-1, 0, ' Speaking! ', curses.A_REVERSE)
- SCREEN.refresh()
- SCREEN.timeout(1)
- try:
- _, path = tempfile.mkstemp(suffix=".wav")
- subprocess.call(
- [ "pico2wave", "-w", path, text],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL
- )
- SPEAKER = subprocess.Popen(
- [ "play", path, "tempo", str(CFG["TTSSpeed"])],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL
- )
- while True:
- if SPEAKER.poll() is not None:
- k = ord("l")
- break
- k = SCREEN.getch()
- if k == curses.KEY_MOUSE:
- mouse_event = curses.getmouse()
- if mouse_event[4] == curses.BUTTON2_CLICKED:
- k = list(K["Quit"])[0]
- elif mouse_event[4] == curses.BUTTON1_CLICKED:
- if mouse_event[1] < SCREEN.getmaxyx()[1]//2:
- k = list(K["PageUp"])[0]
- else:
- k = list(K["PageDown"])[0]
- elif mouse_event[4] == curses.BUTTON4_PRESSED:
- k = list(K["ScrollUp"])[0]
- elif mouse_event[4] == 2097152:
- k = list(K["ScrollDown"])[0]
- # if k != -1:
- if k in K["Quit"]|K["PageUp"]|K["PageDown"]|K["ScrollUp"]|K["ScrollDown"]|{curses.KEY_RESIZE}:
- SPEAKER.terminate()
- # SPEAKER.kill()
- break
- finally:
- SCREEN.timeout(-1)
- os.remove(path)
+ pad = Board(self.screen, totlines, reading_state.textwidth)
+ pad.feed(src_lines)
+ pad.feed_format(formatting)
- if k in K["Quit"]:
- SPEAKING = False
- k = None
- return k
+ # this make curses.A_REVERSE not working
+ # put before paint_text
+ if self.is_color_supported:
+ pad.bkgd()
+ pad.paint_text(0)
+ pad.format()
-def reader(ebook, index, width, y, pctg, sect):
- global SHOWPROGRESS, SPEAKING, ANIMATE, SPREAD
+ LOCALPCTG = []
+ for i in src_lines:
+ LOCALPCTG.append(len(re.sub("\s", "", i)))
- k = 0 if SEARCHPATTERN is None else ord("/")
- rows, cols = SCREEN.getmaxyx()
+ self.screen.clear()
+ self.screen.refresh()
+ # try except to be more flexible on terminal resize
+ try:
+ pad.refresh(reading_state.row, 0, 0, x, rows - 1, x + reading_state.textwidth)
+ except curses.error:
+ pass
- mincols_doublespr = 2 + 22 + 3 + 22 + 2
- if cols < mincols_doublespr:
- SPREAD = 1
- if SPREAD == 2:
- width = (cols-7)//2
+ if reading_state.section != "":
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid.get(reading_state.section, 0)
+ )
- x = (cols - width) // 2
- if SPREAD == 1:
- x = (cols - width) // 2
- else:
- x = 2
-
- contents = ebook.contents
- toc_name = ebook.toc_entries[0]
- toc_idx = ebook.toc_entries[1]
- toc_sect = ebook.toc_entries[2]
- toc_secid = {}
- chpath = contents[index]
- content = ebook.get_raw_text(chpath)
-
- parser = HTMLtoLines(set(toc_sect))
- # parser = HTMLtoLines()
- # try:
- parser.feed(content)
- parser.close()
- # except:
- # pass
-
- src_lines, imgs, toc_secid, formatting = parser.get_lines(width)
- totlines = len(src_lines) + 1 # 1 extra line for suffix
-
- if y < 0 and totlines <= rows*SPREAD:
- y = 0
- elif pctg is not None:
- y = round(pctg*totlines)
- else:
- y = y % totlines
+ # checkpoint_row is container for visual helper
+ # when we scroll the page by more than 1 line
+ # so it's less disorienting
+ # eg. when we scroll down by 3 lines then:
+ #
+ # ...
+ # this line has been read
+ # this line has been read
+ # this line has been read
+ # this line has been read
+ # ------------------------------- <- the visual helper
+ # this line has not been read yet
+ # this line has not been read yet
+ # this line has not been read yet
+ checkpoint_row: Optional[int] = None
+
+ countstring = ""
- pad = Board(totlines, width)
- pad.feed(src_lines)
- pad.feed_format(formatting)
+ try:
+ while True:
+ if countstring == "":
+ count = 1
+ else:
+ count = int(countstring)
+ if k in tuple(Key(i) for i in range(48, 58)): # i.e., k is a numeral
+ countstring = countstring + k.char
+ else:
+ if k in self.keymap.Quit:
+ if k == Key(27) and countstring != "":
+ countstring = ""
+ else:
+ self.savestate(
+ dataclasses.replace(
+ reading_state, rel_pctg=reading_state.row / totlines
+ )
+ )
+ sys.exit()
+
+ elif k in self.keymap.TTSToggle and self._tts_support:
+ # tospeak = "\n".join(src_lines[y:y+rows-1])
+ tospeak = ""
+ for i in src_lines[
+ reading_state.row : reading_state.row + (rows * self.spread)
+ ]:
+ if re.match(r"^\s*$", i) is not None:
+ tospeak += "\n. \n"
+ else:
+ tospeak += re.sub(r"\[IMG:[0-9]+\]", "Image", i) + " "
+ k = self.speaking(tospeak)
+ if (
+ totlines - reading_state.row <= rows
+ and reading_state.content_index == len(contents) - 1
+ ):
+ self.is_speaking = False
+ continue
- # this make curses.A_REVERSE not working
- # put before paint_text
- if COLORSUPPORT:
- pad.bkgd(SCREEN.getbkgd())
+ elif k in self.keymap.DoubleSpreadToggle:
+ if cols < mincols_doublespr:
+ k = self.show_win_error(
+ "Screen is too small",
+ "Min: {} cols x {} rows".format(mincols_doublespr, 12),
+ (Key("D"),),
+ )
+ self.spread = (self.spread % 2) + 1
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=reading_state.textwidth,
+ rel_pctg=reading_state.row / totlines,
+ )
- pad.paint_text(0)
- pad.format()
+ elif k in self.keymap.ScrollUp:
+ if self.spread == 2:
+ k = self.keymap.PageUp[0]
+ continue
+ if count > 1:
+ checkpoint_row = reading_state.row - 1
+ if reading_state.row >= count:
+ reading_state = dataclasses.replace(
+ reading_state, row=reading_state.row - count
+ )
+ elif reading_state.row == 0 and reading_state.content_index != 0:
+ self.page_animation = Direction.BACKWARD
+ # return -1, width, -rows, None, ""
+ return ReadingState(
+ content_index=reading_state.content_index - 1,
+ textwidth=reading_state.textwidth,
+ row=-rows,
+ )
+ else:
+ reading_state = dataclasses.replace(reading_state, row=0)
+
+ elif k in self.keymap.PageUp:
+ if reading_state.row == 0 and reading_state.content_index != 0:
+ self.page_animation = Direction.BACKWARD
+ tmp_parser = HTMLtoLines()
+ tmp_parser.feed(
+ self.ebook.get_raw_text(contents[reading_state.content_index - 1])
+ )
+ tmp_parser.close()
+ return ReadingState(
+ content_index=reading_state.content_index - 1,
+ textwidth=reading_state.textwidth,
+ row=rows
+ * self.spread
+ * (
+ len(tmp_parser.get_lines(reading_state.textwidth)[0])
+ // (rows * self.spread)
+ ),
+ )
+ else:
+ if reading_state.row >= rows * self.spread * count:
+ self.page_animation = Direction.BACKWARD
+ reading_state = dataclasses.replace(
+ reading_state,
+ row=reading_state.row - (rows * self.spread * count),
+ )
+ else:
+ reading_state = dataclasses.replace(reading_state, row=0)
+
+ elif k in self.keymap.ScrollDown:
+ if self.spread == 2:
+ k = self.keymap.PageDown[0]
+ continue
+ if count > 1:
+ checkpoint_row = reading_state.row + rows - 1
+ if reading_state.row + count <= totlines - rows:
+ reading_state = dataclasses.replace(
+ reading_state, row=reading_state.row + count
+ )
+ elif (
+ reading_state.row >= totlines - rows
+ and reading_state.content_index != len(contents) - 1
+ ):
+ self.page_animation = Direction.FORWARD
+ return ReadingState(
+ content_index=reading_state.content_index + 1,
+ textwidth=reading_state.textwidth,
+ )
+ else:
+ reading_state = dataclasses.replace(reading_state, row=totlines - rows)
+
+ elif k in self.keymap.PageDown:
+ if totlines - reading_state.row > rows * self.spread:
+ self.page_animation = Direction.FORWARD
+ if (
+ reading_state.row + (rows * self.spread)
+ > pad.chunks[pad.find_chunkidx(reading_state.row)]
+ ):
+ reading_state = dataclasses.replace(
+ reading_state,
+ row=pad.chunks[pad.find_chunkidx(reading_state.row)] + 1,
+ )
+ else:
+ reading_state = dataclasses.replace(
+ reading_state, row=reading_state.row + (rows * self.spread)
+ )
+ # self.screen.clear()
+ # self.screen.refresh()
+ elif reading_state.content_index != len(contents) - 1:
+ self.page_animation = Direction.FORWARD
+ return ReadingState(
+ content_index=reading_state.content_index + 1,
+ textwidth=reading_state.textwidth,
+ )
+
+ # elif k in K["HalfScreenUp"] | K["HalfScreenDown"]:
+ # countstring = str(rows // 2)
+ # k = list(K["ScrollUp" if k in K["HalfScreenUp"] else "ScrollDown"])[0]
+ # continue
+
+ elif k in self.keymap.NextChapter:
+ ntoc = find_curr_toc_id(
+ toc_idx,
+ toc_sect,
+ toc_secid,
+ reading_state.content_index,
+ reading_state.row,
+ )
+ if ntoc < len(toc_idx) - 1:
+ if reading_state.content_index == toc_idx[ntoc + 1]:
+ try:
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid[toc_sect[ntoc + 1]]
+ )
+ except KeyError:
+ pass
+ else:
+ return ReadingState(
+ content_index=toc_idx[ntoc + 1],
+ textwidth=reading_state.textwidth,
+ section=toc_sect[ntoc + 1],
+ )
+
+ elif k in self.keymap.PrevChapter:
+ ntoc = find_curr_toc_id(
+ toc_idx,
+ toc_sect,
+ toc_secid,
+ reading_state.content_index,
+ reading_state.row,
+ )
+ if ntoc > 0:
+ if reading_state.content_index == toc_idx[ntoc - 1]:
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid.get(toc_sect[ntoc - 1], 0)
+ )
+ else:
+ return ReadingState(
+ content_index=toc_idx[ntoc - 1],
+ textwidth=reading_state.textwidth,
+ section=toc_sect[ntoc - 1],
+ )
+
+ elif k in self.keymap.BeginningOfCh:
+ ntoc = find_curr_toc_id(
+ toc_idx,
+ toc_sect,
+ toc_secid,
+ reading_state.content_index,
+ reading_state.row,
+ )
+ try:
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid[toc_sect[ntoc]]
+ )
+ except (KeyError, IndexError):
+ reading_state = dataclasses.replace(reading_state, row=0)
+
+ elif k in self.keymap.EndOfCh:
+ ntoc = find_curr_toc_id(
+ toc_idx,
+ toc_sect,
+ toc_secid,
+ reading_state.content_index,
+ reading_state.row,
+ )
+ try:
+ if toc_secid[toc_sect[ntoc + 1]] - rows >= 0:
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid[toc_sect[ntoc + 1]] - rows
+ )
+ else:
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid[toc_sect[ntoc]]
+ )
+ except (KeyError, IndexError):
+ reading_state = dataclasses.replace(
+ reading_state, row=pgend(totlines, rows)
+ )
+
+ elif k in self.keymap.TableOfContents:
+ if self.ebook.toc_entries == [[], [], []]:
+ k = self.show_win_error(
+ "Table of Contents",
+ "N/A: TableOfContents is unavailable for this book.",
+ self.keymap.TableOfContents,
+ )
+ continue
+ ntoc = find_curr_toc_id(
+ toc_idx,
+ toc_sect,
+ toc_secid,
+ reading_state.content_index,
+ reading_state.row,
+ )
+ rettock, fllwd, _ = self.toc(toc_name, ntoc)
+ if rettock is not None: # and rettock in WINKEYS:
+ k = rettock
+ continue
+ elif fllwd is not None:
+ if reading_state.content_index == toc_idx[fllwd]:
+ try:
+ reading_state = dataclasses.replace(
+ reading_state, row=toc_secid[toc_sect[fllwd]]
+ )
+ except KeyError:
+ reading_state = dataclasses.replace(reading_state, row=0)
+ else:
+ return ReadingState(
+ content_index=toc_idx[fllwd],
+ textwidth=reading_state.textwidth,
+ section=toc_sect[fllwd],
+ )
+
+ elif k in self.keymap.Metadata:
+ k = self.show_win_metadata()
+ if k in self._win_keys:
+ continue
- LOCALPCTG = []
- for i in src_lines:
- LOCALPCTG.append(len(re.sub("\s", "", i)))
+ elif k in self.keymap.Help:
+ k = self.show_win_help()
+ if k in self._win_keys:
+ continue
- SCREEN.clear()
- SCREEN.refresh()
- # try except to be more flexible on terminal resize
- try:
- pad.refresh(y, 0, 0, x, rows-1, x+width)
- except curses.error:
- pass
+ elif (
+ k in self.keymap.Enlarge
+ and (reading_state.textwidth + count) < cols - 4
+ and self.spread == 1
+ ):
+ reading_state = dataclasses.replace(
+ reading_state, textwidth=reading_state.textwidth + count
+ )
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=reading_state.textwidth,
+ rel_pctg=reading_state.row / totlines,
+ )
- if sect != "":
- y = toc_secid.get(sect, 0)
+ elif (
+ k in self.keymap.Shrink
+ and reading_state.textwidth >= 22
+ and self.spread == 1
+ ):
+ reading_state = dataclasses.replace(
+ reading_state, textwidth=reading_state.textwidth - count
+ )
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=reading_state.textwidth,
+ rel_pctg=reading_state.row / totlines,
+ )
- countstring = ""
- svline = "dontsave"
- try:
- while True:
- if countstring == "":
- count = 1
- else:
- count = int(countstring)
- if k in range(48, 58): # i.e., k is a numeral
- countstring = countstring + chr(k)
- else:
- if k in K["Quit"]:
- if k == 27 and countstring != "":
- countstring = ""
- else:
- savestate(ebook.path, index, width, y, y/totlines)
- sys.exit()
- elif k in K["TTSToggle"] and TTSSUPPORT:
- # tospeak = "\n".join(src_lines[y:y+rows-1])
- tospeak = ""
- for i in src_lines[y:y+(rows*SPREAD)]:
- if re.match(r"^\s*$", i) is not None:
- tospeak += "\n. \n"
- else:
- tospeak += re.sub(r"\[IMG:[0-9]+\]", "Image", i) + " "
- k = speaking(tospeak)
- if totlines-y <= rows and index == len(contents)-1:
- SPEAKING = False
- continue
- elif k in K["DoubleSpreadToggle"]:
- if cols < mincols_doublespr:
- k = text_win(lambda: (
- "Screen is too small",
- "Min: {} cols x {} rows".format(mincols_doublespr, 12),
- {ord("D")}
- ))()
- SPREAD = (SPREAD % 2) + 1
- return 0, width, 0, y/totlines, ""
- elif k in K["ScrollUp"]:
- if SPREAD == 2:
- k = list(K["PageUp"])[0]
- continue
- if count > 1:
- svline = y - 1
- if y >= count:
- y -= count
- elif y == 0 and index != 0:
- ANIMATE = "prev"
- return -1, width, -rows, None, ""
- else:
- y = 0
- elif k in K["PageUp"]:
- if y == 0 and index != 0:
- ANIMATE = "prev"
- tmp_parser = HTMLtoLines()
- tmp_parser.feed(ebook.get_raw_text(contents[index-1]))
- tmp_parser.close()
- return -1, width, rows*SPREAD*(len(tmp_parser.get_lines(width)[0])//(rows*SPREAD)), None, ""
- else:
- if y >= rows*SPREAD*count:
- ANIMATE = "prev"
- y -= rows*SPREAD*count
- else:
- y = 0
- elif k in K["ScrollDown"]:
- if SPREAD == 2:
- k = list(K["PageDown"])[0]
- continue
- if count > 1:
- svline = y + rows - 1
- if y + count <= totlines - rows:
- y += count
- elif y >= totlines - rows and index != len(contents)-1:
- ANIMATE = "next"
- return 1, width, 0, None, ""
- else:
- y = totlines - rows
- elif k in K["PageDown"]:
- if totlines - y > rows*SPREAD:
- ANIMATE = "next"
- if y+(rows*SPREAD) > pad.chunks[pad.find_chunkidx(y)]:
- y = pad.chunks[pad.find_chunkidx(y)] + 1
+ elif k in self.keymap.SetWidth and self.spread == 1:
+ if countstring == "":
+ # if called without a count, toggle between 80 cols and full width
+ if reading_state.textwidth != 80 and cols - 4 >= 80:
+ return ReadingState(
+ content_index=reading_state.content_index,
+ rel_pctg=reading_state.row / totlines,
+ )
+ else:
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=cols - 4,
+ rel_pctg=reading_state.row / totlines,
+ )
else:
- y += rows*SPREAD
- # SCREEN.clear()
- # SCREEN.refresh()
- elif index != len(contents)-1:
- ANIMATE = "next"
- return 1, width, 0, None, ""
- elif k in K["HalfScreenUp"]|K["HalfScreenDown"]:
- countstring = str(rows//2)
- k = list(K["ScrollUp" if k in K["HalfScreenUp"] else "ScrollDown"])[0]
- continue
- elif k in K["NextChapter"]:
- ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y)
- if ntoc < len(toc_idx) - 1:
- if index == toc_idx[ntoc+1]:
+ reading_state = dataclasses.replace(reading_state, textwidth=count)
+ if reading_state.textwidth < 20:
+ reading_state = dataclasses.replace(reading_state, textwidth=20)
+ elif reading_state.textwidth >= cols - 4:
+ reading_state = dataclasses.replace(reading_state, textwidth=cols - 4)
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=reading_state.textwidth,
+ rel_pctg=reading_state.row / totlines,
+ )
+
+ elif k in self.keymap.RegexSearch:
+ ret_object = self.searching(
+ pad,
+ src_lines,
+ reading_state,
+ len(contents),
+ )
+ if isinstance(ret_object, Key) or isinstance(ret_object, NoUpdate):
+ k = ret_object
+ # k = ret_object.value
+ continue
+ elif isinstance(ret_object, ReadingState) and self.search_data:
+ return ret_object
+ # else:
+ elif isinstance(ret_object, ReadingState):
+ # y = ret_object
+ reading_state = ret_object
+
+ elif k in self.keymap.OpenImage and self.image_viewer is not None:
+ gambar, idx = [], []
+ for n, i in enumerate(
+ src_lines[reading_state.row : reading_state.row + (rows * self.spread)]
+ ):
+ img = re.search("(?<=\\[IMG:)[0-9]+(?=\\])", i)
+ if img is not None:
+ gambar.append(img.group())
+ idx.append(n)
+
+ impath = ""
+ if len(gambar) == 1:
+ impath = imgs[int(gambar[0])]
+ elif len(gambar) > 1:
+ p: Union[NoUpdate, Key] = NoUpdate()
+ i = 0
+ while p not in self.keymap.Quit and p not in self.keymap.Follow:
+ self.screen.move(
+ idx[i] % rows,
+ (
+ x
+ if idx[i] // rows == 0
+ else cols - 2 - reading_state.textwidth
+ )
+ + reading_state.textwidth // 2
+ + len(gambar[i])
+ + 1,
+ )
+ self.screen.refresh()
+ safe_curs_set(1)
+ p = pad.getch()
+ if p in self.keymap.ScrollDown:
+ i += 1
+ elif p in self.keymap.ScrollUp:
+ i -= 1
+ i = i % len(gambar)
+
+ safe_curs_set(0)
+ if p in self.keymap.Follow:
+ impath = imgs[int(gambar[i])]
+
+ if impath != "":
try:
- y = toc_secid[toc_sect[ntoc+1]]
- except KeyError:
- pass
- else:
- return toc_idx[ntoc+1]-index, width, 0, None, toc_sect[ntoc+1]
- elif k in K["PrevChapter"]:
- ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y)
- if ntoc > 0:
- if index == toc_idx[ntoc-1]:
- y = toc_secid.get(toc_sect[ntoc-1], 0)
+ if self.ebook.__class__.__name__ in {"Epub", "Mobi", "Azw3"}:
+ impath = dots_path(chpath, impath)
+ imgnm, imgbstr = self.ebook.get_img_bytestr(impath)
+ k = self.open_image(pad, imgnm, imgbstr)
+ continue
+ except Exception as e:
+ self.show_win_error("Error Opening Image", str(e), tuple())
+
+ elif (
+ k in self.keymap.SwitchColor
+ and self.is_color_supported
+ and countstring in {"", "0", "1", "2"}
+ ):
+ if countstring == "":
+ count_color = curses.pair_number(self.screen.getbkgd())
+ if count_color not in {2, 3}:
+ count_color = 1
+ count_color = count_color % 3
else:
- return toc_idx[ntoc-1]-index, width, 0, None, toc_sect[ntoc-1]
- elif k in K["BeginningOfCh"]:
- ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y)
- try:
- y = toc_secid[toc_sect[ntoc]]
- except (KeyError, IndexError):
- y = 0
- elif k in K["EndOfCh"]:
- ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y)
- try:
- if toc_secid[toc_sect[ntoc+1]] - rows >= 0:
- y = toc_secid[toc_sect[ntoc+1]] - rows
- else:
- y = toc_secid[toc_sect[ntoc]]
- except (KeyError, IndexError):
- y = pgend(totlines, rows)
- elif k in K["TableOfContents"]:
- if ebook.toc_entries == [[], [], []]:
- k = errmsg(
- "Table of Contents",
- "N/A: TableOfContents is unavailable for this book.",
- K["TableOfContents"]
+ count_color = count
+ self.screen.bkgd(curses.color_pair(count_color + 1))
+ pad.format()
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=reading_state.textwidth,
+ row=reading_state.row,
)
- continue
- ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y)
- rettock, fllwd, _ = toc(toc_name, ntoc)
- if rettock is not None: # and rettock in WINKEYS:
- k = rettock
- continue
- elif fllwd is not None:
- if index == toc_idx[fllwd]:
+
+ elif k in self.keymap.AddBookmark:
+ bmname = self.input_prompt(" Add bookmark:")
+ if isinstance(bmname, str) and bmname:
try:
- y = toc_secid[toc_sect[fllwd]]
- except KeyError:
- y = 0
+ self.state.insert_bookmark(
+ self.ebook,
+ bmname,
+ dataclasses.replace(
+ reading_state, rel_pctg=reading_state.row / totlines
+ ),
+ )
+ except sqlite3.IntegrityError:
+ k = self.show_win_error(
+ "Error: Add Bookmarks",
+ f"Bookmark with name '{bmname}' already exists.",
+ (Key("B"),),
+ )
+ continue
else:
- return toc_idx[fllwd] - index, width, 0, None, toc_sect[fllwd]
- elif k in K["Metadata"]:
- k = meta(ebook)
- if k in WINKEYS:
- continue
- elif k in K["Help"]:
- k = help()
- if k in WINKEYS:
- continue
- elif k in K["Enlarge"] and (width + count) < cols - 4 and SPREAD == 1:
- width += count
- return 0, width, 0, y/totlines, ""
- elif k in K["Shrink"] and width >= 22 and SPREAD == 1:
- width -= count
- return 0, width, 0, y/totlines, ""
- elif k in K["SetWidth"] and SPREAD == 1:
- if countstring == "":
- # if called without a count, toggle between 80 cols and full width
- if width != 80 and cols - 4 >= 80:
- return 0, 80, 0, y/totlines, ""
+ k = bmname
+ continue
+
+ elif k in self.keymap.ShowBookmarks:
+ bookmarks = self.state.get_bookmarks(self.ebook)
+ if not bookmarks:
+ k = self.show_win_error(
+ "Bookmarks",
+ "N/A: Bookmarks are not found in this book.",
+ self.keymap.ShowBookmarks,
+ )
+ continue
else:
- return 0, cols - 4, 0, y/totlines, ""
- else:
- width = count
- if width < 20:
- width = 20
- elif width >= cols - 4:
- width = cols - 4
- return 0, width, 0, y/totlines, ""
- # elif k == ord("0"):
- # if width != 80 and cols - 2 >= 80:
- # return 0, 80, 0, y/totlines, ""
- # else:
- # return 0, cols - 2, 0, y/totlines, ""
- elif k in K["RegexSearch"]:
- fs = searching(
- pad,
- src_lines,
- width, y,
- index, len(contents)
- )
- if fs in WINKEYS or fs is None:
- k = fs
- continue
- elif SEARCHPATTERN is not None:
- return fs, width, 0, None, ""
- else:
- y = fs
- elif k in K["OpenImage"] and VWR is not None:
- gambar, idx = [], []
- for n, i in enumerate(src_lines[y:y+(rows*SPREAD)]):
- img = re.search("(?<=\\[IMG:)[0-9]+(?=\\])", i)
- if img is not None:
- gambar.append(img.group())
- idx.append(n)
-
- impath = ""
- if len(gambar) == 1:
- impath = imgs[int(gambar[0])]
- elif len(gambar) > 1:
- p, i = 0, 0
- while p not in K["Quit"] and p not in K["Follow"]:
- SCREEN.move(idx[i] % rows, (x if idx[i]//rows==0 else cols-2-width) + width//2 + len(gambar[i]) + 1)
- SCREEN.refresh()
- safe_curs_set(1)
- p = pad.getch()
- if p in K["ScrollDown"]:
- i += 1
- elif p in K["ScrollUp"]:
- i -= 1
- i = i % len(gambar)
-
- safe_curs_set(0)
- if p in K["Follow"]:
- impath = imgs[int(gambar[i])]
-
- if impath != "":
- try:
- if ebook.__class__.__name__ in {"Epub", "Azw3"}:
- impath = dots_path(chpath, impath)
- imgnm, imgbstr = ebook.get_img_bytestr(impath)
- k = open_media(pad, imgnm, imgbstr)
+ retk, idxchoice = self.show_win_choices_bookmarks()
+ if retk is not None:
+ k = retk
+ continue
+ elif idxchoice is not None:
+ bookmark_to_jump = self.state.get_bookmarks(self.ebook)[idxchoice][
+ 1
+ ]
+ return ReadingState(
+ content_index=bookmark_to_jump.content_index,
+ textwidth=reading_state.textwidth,
+ row=bookmark_to_jump.row,
+ rel_pctg=bookmark_to_jump.rel_pctg,
+ )
+
+ elif k in self.keymap.DefineWord and self.ext_dict_app:
+ word = self.input_prompt(" Define:")
+ if isinstance(word, str) and word:
+ defin = self.define_word(word)
+ if defin in self._win_keys:
+ k = defin
+ continue
+ else:
+ k = word
continue
- except Exception as e:
- errmsg("Error Opening Image", str(e), set())
- elif k in K["SwitchColor"] and COLORSUPPORT and countstring in {"", "0", "1", "2"}:
- if countstring == "":
- count_color = curses.pair_number(SCREEN.getbkgd())
- if count_color not in {2, 3}: count_color = 1
- count_color = count_color % 3
- else:
- count_color = count
- SCREEN.bkgd(curses.color_pair(count_color+1))
- pad.format()
- return 0, width, y, None, ""
- elif k in K["AddBookmark"]:
- defbmname_suffix = 1
- defbmname = "Bookmark " + str(defbmname_suffix)
- occupiedbmnames = [i[0] for i in STATE["States"][ebook.path]["bmarks"]]
- while defbmname in occupiedbmnames:
- defbmname_suffix += 1
- defbmname = "Bookmark " + str(defbmname_suffix)
- bmname = input_prompt(" Add bookmark ({}):".format(defbmname))
- if bmname is not None:
- if bmname.strip() == "":
- bmname = defbmname
- STATE["States"][ebook.path]["bmarks"].append(
- [bmname, index, y, y/totlines]
- )
- elif k in K["ShowBookmarks"]:
- if STATE["States"][ebook.path]["bmarks"] == []:
- k = text_win(lambda: (
- "Bookmarks",
- "N/A: Bookmarks are not found in this book.",
- {ord("B")}
- ))()
- continue
- else:
- retk, idxchoice = bookmarks(ebook.path)
- if retk is not None:
- k = retk
+
+ elif k in self.keymap.MarkPosition:
+ jumnum = pad.getch()
+ if jumnum in tuple(Key(i) for i in range(48, 58)):
+ self.jump_list[jumnum.char] = reading_state
+ else:
+ k = jumnum
continue
- elif idxchoice is not None:
- bmtojump = STATE["States"][ebook.path]["bmarks"][idxchoice]
- return bmtojump[1]-index, width, bmtojump[2], bmtojump[3], ""
- elif k in K["DefineWord"] and DICT is not None:
- word = input_prompt(" Define:")
- if word == curses.KEY_RESIZE:
- k = word
- continue
- elif word is not None:
- defin = define_word(word)
- if defin in WINKEYS:
- k = defin
+
+ elif k in self.keymap.JumpToPosition:
+ jumnum = pad.getch()
+ if (
+ jumnum in tuple(Key(i) for i in range(48, 58))
+ and jumnum.char in self.jump_list
+ ):
+ marked_reading_state = self.jump_list[jumnum.char]
+ return dataclasses.replace(
+ marked_reading_state,
+ textwidth=reading_state.textwidth,
+ rel_pctg=None
+ if marked_reading_state.textwidth == reading_state.textwidth
+ else marked_reading_state.rel_pctg,
+ section="",
+ )
+ else:
+ k = NoUpdate()
continue
- elif k in K["MarkPosition"]:
- jumnum = pad.getch()
- if jumnum in range(49, 58):
- JUMPLIST[chr(jumnum)] = [index, width, y, y/totlines]
- else:
- k = jumnum
- continue
- elif k in K["JumpToPosition"]:
- jumnum = pad.getch()
- if jumnum in range(49, 58) and chr(jumnum) in JUMPLIST.keys():
- tojumpidxdiff = JUMPLIST[chr(jumnum)][0]-index
- tojumpy = JUMPLIST[chr(jumnum)][2]
- tojumpctg = None if JUMPLIST[chr(jumnum)][1] == width else JUMPLIST[chr(jumnum)][3]
- return tojumpidxdiff, width, tojumpy, tojumpctg, ""
- else:
- k = jumnum
- continue
- elif k in K["ShowHideProgress"]:
- SHOWPROGRESS = not SHOWPROGRESS
- elif k == curses.KEY_RESIZE:
- savestate(ebook.path, index, width, y, y/totlines)
- # stated in pypi windows-curses page:
- # to call resize_term right after KEY_RESIZE
- if sys.platform == "win32":
- curses.resize_term(rows, cols)
- rows, cols = SCREEN.getmaxyx()
- else:
- rows, cols = SCREEN.getmaxyx()
- curses.resize_term(rows, cols)
- if cols < 22 or rows < 12:
- sys.exit("ERROR: Screen was too small (min 22cols x 12rows).")
- if cols <= width + 4:
- return 0, cols - 4, 0, y/totlines, ""
- else:
- return 0, width, y, None, ""
- countstring = ""
- if svline != "dontsave":
- pad.chgat(svline, 0, width, SCREEN.getbkgd()|curses.A_UNDERLINE)
+ elif k in self.keymap.ShowHideProgress:
+ self.show_reading_progress = not self.show_reading_progress
- try:
- # NOTE: clear() will delete everything but doesnt need refresh()
- # while refresh() id necessary whenever a char added to scr
- SCREEN.clear()
- SCREEN.addstr(0, 0, countstring)
- SCREEN.refresh()
- if CFG["PageScrollAnimation"] and ANIMATE is not None:
- for i in range(width+1):
- curses.napms(1)
- # to optimize performance
- if i == width:
- # to cleanup screen from animation residue
- # actually only problematic for "next" animation
- # but just to be safe
- SCREEN.clear()
- SCREEN.refresh()
- if ANIMATE == "next":
- pad.refresh(y, 0, 0, x+width-i, rows-1, x+width)
- if SPREAD == 2 and y+rows < totlines:
- pad.refresh(y+rows, 0, 0, cols-2-i, rows-1, cols-2)
- elif ANIMATE == "prev":
- pad.refresh(y, width-i-1, 0, x, rows-1, x+i)
- if SPREAD == 2 and y+rows < totlines:
- pad.refresh(y+rows, width-i-1, 0, cols-2-width, rows-1, cols-2-width+i)
- else:
- pad.refresh(y, 0, 0, x, rows-1, x+width)
- if SPREAD == 2 and y+rows < totlines:
- pad.refresh(y+rows, 0, 0, cols-2-width, rows-1, cols-2)
- ANIMATE = None
-
- LOCALSUMALLL = SUMALLLETTERS.value if MULTIPROC else SUMALLLETTERS
- if SHOWPROGRESS and (cols-width-2)//2 > 3 and LOCALSUMALLL != 0:
- PROGRESS = (ALLPREVLETTERS[index] + sum(LOCALPCTG[:y+rows-1])) / LOCALSUMALLL
- PROGRESSTR = "{}%".format(int(PROGRESS*100))
- SCREEN.addstr(0, cols-len(PROGRESSTR), PROGRESSTR)
- SCREEN.refresh()
- except curses.error:
- pass
- if SPEAKING:
- k = list(K["TTSToggle"])[0]
- continue
- k = pad.getch()
- if k == curses.KEY_MOUSE:
- mouse_event = curses.getmouse()
- if mouse_event[4] == curses.BUTTON1_CLICKED:
- if mouse_event[1] < cols//2:
- k = list(K["PageUp"])[0]
- else:
- k = list(K["PageDown"])[0]
- elif mouse_event[4] == curses.BUTTON3_CLICKED:
- k = list(K["TableOfContents"])[0]
- elif mouse_event[4] == curses.BUTTON4_PRESSED:
- k = list(K["ScrollUp"])[0]
- elif mouse_event[4] == 2097152:
- k = list(K["ScrollDown"])[0]
- elif mouse_event[4] == curses.BUTTON4_PRESSED+curses.BUTTON_CTRL:
- k = list(K["Enlarge"])[0]
- elif mouse_event[4] == 2097152+curses.BUTTON_CTRL:
- k = list(K["Shrink"])[0]
- elif mouse_event[4] == curses.BUTTON2_CLICKED:
- k = list(K["TTSToggle"])[0]
-
- if svline != "dontsave":
- pad.chgat(svline, 0, width, SCREEN.getbkgd()|curses.A_NORMAL)
- svline = "dontsave"
- except KeyboardInterrupt:
- savestate(ebook.path, index, width, y, y/totlines)
- sys.exit()
+ elif k == Key(curses.KEY_RESIZE):
+ self.savestate(
+ dataclasses.replace(
+ reading_state, rel_pctg=reading_state.row / totlines
+ )
+ )
+ # stated in pypi windows-curses page:
+ # to call resize_term right after KEY_RESIZE
+ if sys.platform == "win32":
+ curses.resize_term(rows, cols)
+ rows, cols = self.screen.getmaxyx()
+ else:
+ rows, cols = self.screen.getmaxyx()
+ curses.resize_term(rows, cols)
+ if cols < 22 or rows < 12:
+ sys.exit("ERROR: Screen was too small (min 22cols x 12rows).")
+ if cols <= reading_state.textwidth + 4:
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=cols - 4,
+ rel_pctg=reading_state.row / totlines,
+ )
+ else:
+ return ReadingState(
+ content_index=reading_state.content_index,
+ textwidth=reading_state.textwidth,
+ row=reading_state.row,
+ )
+ countstring = ""
-def preread(stdscr, file):
- global COLORSUPPORT, SHOWPROGRESS, SCREEN, SPREAD
+ if checkpoint_row:
+ pad.chgat(
+ checkpoint_row,
+ 0,
+ reading_state.textwidth,
+ self.screen.getbkgd() | curses.A_UNDERLINE,
+ )
- try:
- curses.use_default_colors()
- curses.init_pair(1, -1, -1)
- curses.init_pair(2, CFG["DarkColorFG"], CFG["DarkColorBG"])
- curses.init_pair(3, CFG["LightColorFG"], CFG["LightColorBG"])
- COLORSUPPORT = True
- except:
- COLORSUPPORT = False
+ try:
+ # NOTE: clear() will delete everything but doesnt need refresh()
+ # while refresh() id necessary whenever a char added to scr
+ self.screen.clear()
+ self.screen.addstr(0, 0, countstring)
+ self.screen.refresh()
+ if self.setting.PageScrollAnimation and self.page_animation:
+ for i in range(reading_state.textwidth + 1):
+ curses.napms(1)
+ # to optimize performance
+ if i == reading_state.textwidth:
+ # to cleanup screen from animation residue
+ # actually only problematic for "next" animation
+ # but just to be safe
+ self.screen.clear()
+ self.screen.refresh()
+ if self.page_animation == Direction.FORWARD:
+ pad.refresh(
+ reading_state.row,
+ 0,
+ 0,
+ x + reading_state.textwidth - i,
+ rows - 1,
+ x + reading_state.textwidth,
+ )
+ if self.spread == 2 and reading_state.row + rows < totlines:
+ pad.refresh(
+ reading_state.row + rows,
+ 0,
+ 0,
+ cols - 2 - i,
+ rows - 1,
+ cols - 2,
+ )
+ if self.page_animation == Direction.BACKWARD:
+ pad.refresh(
+ reading_state.row,
+ reading_state.textwidth - i - 1,
+ 0,
+ x,
+ rows - 1,
+ x + i,
+ )
+ if self.spread == 2 and reading_state.row + rows < totlines:
+ pad.refresh(
+ reading_state.row + rows,
+ reading_state.textwidth - i - 1,
+ 0,
+ cols - 2 - reading_state.textwidth,
+ rows - 1,
+ cols - 2 - reading_state.textwidth + i,
+ )
+ else:
+ pad.refresh(
+ reading_state.row, 0, 0, x, rows - 1, x + reading_state.textwidth
+ )
+ if self.spread == 2 and reading_state.row + rows < totlines:
+ pad.refresh(
+ reading_state.row + rows,
+ 0,
+ 0,
+ cols - 2 - reading_state.textwidth,
+ rows - 1,
+ cols - 2,
+ )
+ self.page_animation = None
+
+ # check self._process
+ if isinstance(self._process_counting_letter, multiprocessing.Process):
+ if self._process_counting_letter.exitcode == 0:
+ self.letters_count = self._proc_parent.recv()
+ self._proc_parent.close()
+ self._process_counting_letter.terminate()
+ self._process_counting_letter.close()
+ self._process_counting_letter = None
+
+ if (
+ self.show_reading_progress
+ and (cols - reading_state.textwidth - 2) // 2 > 3
+ and self.letters_count
+ ):
+ reading_progress = (
+ self.letters_count.cumulative[reading_state.content_index]
+ + sum(LOCALPCTG[: reading_state.row + rows - 1])
+ ) / self.letters_count.all
+ reading_progress_str = "{}%".format(int(reading_progress * 100))
+ self.screen.addstr(
+ 0, cols - len(reading_progress_str), reading_progress_str
+ )
- SCREEN = stdscr
+ self.screen.refresh()
+ except curses.error:
+ pass
+ if self.is_speaking:
+ k = self.keymap.TTSToggle[0]
+ continue
+ k = pad.getch()
+ if k == Key(curses.KEY_MOUSE):
+ mouse_event = curses.getmouse()
+ if mouse_event[4] == curses.BUTTON1_CLICKED:
+ if mouse_event[1] < cols // 2:
+ k = self.keymap.PageUp[0]
+ else:
+ k = self.keymap.PageDown[0]
+ elif mouse_event[4] == curses.BUTTON3_CLICKED:
+ k = self.keymap.TableOfContents[0]
+ elif mouse_event[4] == curses.BUTTON4_PRESSED:
+ k = self.keymap.ScrollUp[0]
+ elif mouse_event[4] == 2097152:
+ k = self.keymap.ScrollDown[0]
+ elif mouse_event[4] == curses.BUTTON4_PRESSED + curses.BUTTON_CTRL:
+ k = self.keymap.Enlarge[0]
+ elif mouse_event[4] == 2097152 + curses.BUTTON_CTRL:
+ k = self.keymap.Shrink[0]
+ elif mouse_event[4] == curses.BUTTON2_CLICKED:
+ k = self.keymap.TTSToggle[0]
+
+ if checkpoint_row:
+ pad.chgat(
+ checkpoint_row,
+ 0,
+ reading_state.textwidth,
+ self.screen.getbkgd() | curses.A_NORMAL,
+ )
+ checkpoint_row = None
+ except KeyboardInterrupt:
+ self.savestate(
+ dataclasses.replace(reading_state, rel_pctg=reading_state.row / totlines)
+ )
+ sys.exit()
+
+
+def preread(stdscr, filepath: str):
- SCREEN.keypad(True)
- safe_curs_set(0)
- if CFG["MouseSupport"]:
- curses.mousemask(-1)
- # curses.mouseinterval(0)
- SCREEN.clear()
- _, cols = SCREEN.getmaxyx()
- show_loader(SCREEN)
+ ebook = get_ebook_obj(filepath)
+ state = State()
+ config = Config()
- ebook = det_ebook_cls(file)
+ reader = Reader(screen=stdscr, ebook=ebook, config=config, state=state)
try:
- if ebook.path in STATE["States"]:
- idx = STATE["States"][ebook.path]["index"]
- width = STATE["States"][ebook.path]["width"]
- y = STATE["States"][ebook.path]["pos"]
+ reading_state = state.get_last_reading_state(reader.ebook)
+
+ if reader.screen_cols <= reading_state.textwidth + 4:
+ reading_state = dataclasses.replace(reading_state, textwidth=reader.screen_cols - 4)
else:
- STATE["States"][ebook.path] = {}
- STATE["States"][ebook.path]["bmarks"] = []
- idx = 0
- y = 0
- width = 80
- pctg = None
+ reading_state = dataclasses.replace(reading_state, rel_pctg=None)
- if cols <= width + 4:
- width = cols - 4
- pctg = STATE["States"][ebook.path].get("pctg", None)
+ reader.run_counting_letters()
- try:
- ebook.initialize()
- except Exception as e:
- sys.exit("ERROR: Badly-structured ebook.\n"+str(e))
- find_media_viewer()
- find_dict_client()
- parse_keys()
- SHOWPROGRESS = CFG["ShowProgressIndicator"]
- SPREAD = 2 if CFG["StartWithDoubleSpread"] else 1
- count_max_reading_pg(ebook)
-
- sec = ""
while True:
- incr, width, y, pctg, sec = reader(
- ebook, idx, width, y, pctg, sec
- )
- idx += incr
- show_loader(SCREEN)
+ reading_state = reader.read(reading_state)
+ reader.show_loader()
finally:
- ebook.cleanup()
+ reader.cleanup()
-def main():
+def parse_cli_args() -> str:
+ """
+ Try parsing cli args and return filepath of ebook to read
+ or quitting based on args and app state
+ """
termc, termr = shutil.get_terminal_size()
args = []
@@ -2180,87 +2901,107 @@ def main():
print(__doc__.rstrip())
sys.exit()
- loadstate()
-
if len({"-v", "--version", "-V"} & set(args)) != 0:
- print("Startup file loaded:")
- print(CFGFILE)
- print(STATEFILE)
- print()
print("v" + __version__)
print(__license__, "License")
print("Copyright (c) 2019", __author__)
print(__url__)
sys.exit()
+ app_state = State()
+
+ # trying finding file and keep it in candidate
+ # which has the form of candidate = (filepath, error_msg)
+ # if filepath is None or error_msg is None then
+ # the app failed and exit with error_msg
+ candidate: Tuple[Optional[str], Optional[str]]
+
+ last_read_in_history = app_state.get_last_read()
+
+ # clean up history from missing file
+ reading_history = app_state.get_from_history()
+ is_history_modified = False
+ for file in reading_history:
+ if not os.path.isfile(file):
+ app_state.delete_from_history(file)
+ app_state.delete_bookmarks_by_filepath(file)
+ is_history_modified = True
+ if is_history_modified:
+ reading_history = app_state.get_from_history()
+
if len({"-d"} & set(args)) != 0:
args.remove("-d")
dump = True
else:
dump = False
- if args == []:
- file = STATE["LastRead"]
- if not os.path.isfile(file):
- # print(__doc__)
+ if not args:
+ candidate = (last_read_in_history, None)
+ if not os.path.isfile(candidate[0]):
+ # instant fail
sys.exit("ERROR: Found no last read file.")
elif os.path.isfile(args[0]):
- file = args[0]
+ candidate = (args[0], None)
else:
- file = None
- todel = []
- xitmsg = 0
-
- val = 0
- for i in STATE["States"].keys():
- if not os.path.exists(i):
- todel.append(i)
- else:
- match_val = sum([
- j.size for j in SM(
- None, i.lower(), " ".join(args).lower()
- ).get_matching_blocks()
- ])
- if match_val >= val:
- val = match_val
- file = i
- if val == 0:
- xitmsg = "\nERROR: No matching file found in history."
-
- for i in todel:
- del STATE["States"][i]
- with open(STATEFILE, "w") as f:
- json.dump(STATE, f, indent=4)
+ candidate = (None, "ERROR: No matching file found in history.")
+ # find file from history with index number
if len(args) == 1 and re.match(r"[0-9]+", args[0]) is not None:
try:
- file = list(STATE["States"].keys())[int(args[0])-1]
- xitmsg = 0
+ # file = list(STATE["States"].keys())[int(args[0]) - 1]
+ candidate = (reading_history[int(args[0]) - 1], None)
except IndexError:
- xitmsg = "ERROR: No matching file found in history."
+ pass
+
+ # find file from history by string matching
+ if (not candidate[0]) or candidate[1]:
+ matching_value = 0
+ for file in reading_history:
+ this_file_match_value = sum(
+ [
+ i.size
+ for i in SM(
+ None, file.lower(), " ".join(args).lower()
+ ).get_matching_blocks()
+ ]
+ )
+ if this_file_match_value >= matching_value:
+ matching_value = this_file_match_value
+ candidate = (file, None)
+
+ if matching_value == 0:
+ candidate = (None, "\nERROR: No matching file found in history.")
- if xitmsg != 0 or "-r" in args:
+ if (not candidate[0]) or candidate[1] or "-r" in args:
print("Reading history:")
- dig = len(str(len(STATE["States"].keys())+1))
+ # dig = len(str(len(STATE["States"].keys()) + 1))
+ dig = len(str(len(reading_history) + 1))
tcols = termc - dig - 2
- for n, i in enumerate(STATE["States"].keys()):
- p = i.replace(os.getenv("HOME"), "~")
- print("{}{} {}".format(
- str(n+1).rjust(dig),
- "*" if i == STATE["LastRead"] else " ",
- truncate(p, "...", tcols, 7)
- ))
- sys.exit(xitmsg)
+ for n, i in enumerate(reading_history):
+ p = i.replace(os.getenv("HOME"), "~") if os.getenv("HOME") else i
+ print(
+ "{}{} {}".format(
+ str(n + 1).rjust(dig),
+ "*" if i == last_read_in_history else " ",
+ truncate(p, "...", tcols, 7),
+ )
+ )
+ if "-r" in args:
+ sys.exit()
+
+ filepath, error_msg = candidate
+ if (not filepath) or error_msg:
+ sys.exit(error_msg)
if dump:
- ebook = det_ebook_cls(file)
+ ebook = get_ebook_obj(filepath)
try:
try:
ebook.initialize()
except Exception as e:
- sys.exit("ERROR: Badly-structured ebook.\n"+str(e))
+ sys.exit("ERROR: Badly-structured ebook.\n" + str(e))
for i in ebook.contents:
content = ebook.get_raw_text(i)
parser = HTMLtoLines()
@@ -2272,7 +3013,7 @@ def main():
src_lines = parser.get_lines()
# sys.stdout.reconfigure(encoding="utf-8") # Python>=3.7
for j in src_lines:
- sys.stdout.buffer.write((j+"\n\n").encode("utf-8"))
+ sys.stdout.buffer.write((j + "\n\n").encode("utf-8"))
finally:
ebook.cleanup()
sys.exit()
@@ -2280,8 +3021,10 @@ def main():
else:
if termc < 22 or termr < 12:
sys.exit("ERROR: Screen was too small (min 22cols x 12rows).")
- curses.wrapper(preread, file)
+
+ return filepath
if __name__ == "__main__":
- main()
+ filepath = parse_cli_args()
+ curses.wrapper(preread, filepath)
diff --git a/setup.py b/setup.py
index 37ff7ca..9af22b7 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@ if sys.platform == "win32":
setup(
name="epy-reader",
- version="2021.8.14",
+ version="2021.10.23",
description="Terminal/CLI Ebook (epub, fb2, mobi, azw3) Reader",
long_description=long_description,
long_description_content_type="text/markdown",
@@ -20,12 +20,12 @@ setup(
license="GPL-3.0",
keywords=["epub", "epub3", "fb2", "mobi", "azw3", "CLI", "Terminal", "Reader"],
install_requires=requirements,
- python_requires="~=3.0",
+ python_requires="~=3.7",
py_modules=["epy"],
- entry_points={ "console_scripts": ["epy = epy:main"] },
+ entry_points={"console_scripts": ["epy = epy:main"]},
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
- ]
+ ],
)