aboutsummaryrefslogblamecommitdiffstats
path: root/src/epy_reader/models.py
blob: db4701b73f9b8eacf91a579d6ad4f9472a8ddafe (plain) (tree)







































































































































































































































                                                                                           
import os
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any, Mapping, Optional, Tuple, Union


class Direction(Enum):
    FORWARD = "forward"
    BACKWARD = "backward"


@dataclass(frozen=True)
class BookMetadata:
    title: Optional[str] = None
    creator: Optional[str] = None
    description: Optional[str] = None
    publisher: Optional[str] = None
    date: Optional[str] = None
    language: Optional[str] = None
    format: Optional[str] = None
    identifier: Optional[str] = None
    source: Optional[str] = None


@dataclass(frozen=True)
class LibraryItem:
    last_read: datetime
    filepath: str
    title: Optional[str] = None
    author: Optional[str] = None
    reading_progress: Optional[float] = None

    def __str__(self) -> str:
        if self.reading_progress is None:
            reading_progress_str = "N/A"
        else:
            reading_progress_str = f"{int(self.reading_progress * 100)}%"
        reading_progress_str = reading_progress_str.rjust(4)

        book_name: str
        filename = self.filepath.replace(os.path.expanduser("~"), "~", 1)
        if self.title is not None and self.author is not None:
            book_name = f"{self.title} - {self.author} ({filename})"
        elif self.title is None and self.author:
            book_name = f"{filename} - {self.author}"
        else:
            book_name = filename

        last_read_str = self.last_read.strftime("%I:%M%p %b %d")

        return f"{reading_progress_str} {last_read_str}: {book_name}"


@dataclass(frozen=True)
class ReadingState:
    """
    Data model for reading state.

    `row` has to be explicitly assigned with value
    because Seamless feature needs it to adjust from
    relative (to book's content index) row to absolute
    (to book's entire content) row.

    `rel_pctg` and `section` default to None and if
    either of them is assigned with value, then it
    will be overriding the `row` value.
    """

    content_index: int
    textwidth: int
    row: int
    rel_pctg: Optional[float] = None
    section: Optional[str] = None


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


@dataclass(frozen=True)
class CharPos:
    """
    Describes character position in text.
    eg. ["Lorem ipsum dolor sit amet,",  # row=0
         "consectetur adipiscing elit."]  # row=1
             ^CharPos(row=1, col=3)
    """

    row: int
    col: int


@dataclass(frozen=True)
class TextMark:
    """
    Describes marking in text.
    eg. Interval [CharPos(row=0, col=3), CharPos(row=1, col=4)]
    notice the marking inclusive [] for both side instead of right exclusive [)
    """

    start: CharPos
    end: Optional[CharPos] = None

    def is_valid(self) -> bool:
        """
        Assert validity and check if the mark is unterminated
        eg. <div><i>This is italic text</div>
        Missing </i> tag
        """
        if self.end is not None:
            if self.start.row == self.end.row:
                return self.start.col <= self.end.col
            else:
                return self.start.row < self.end.row

        return False


@dataclass(frozen=True)
class TextSpan:
    """
    Like TextMark but using span of letters (n_letters)
    """

    start: CharPos
    n_letters: int


@dataclass(frozen=True)
class InlineStyle:
    """
    eg. InlineStyle(attr=curses.A_BOLD, row=3, cols=4, n_letters=3)
    """

    row: int
    col: int
    n_letters: int
    attr: int


@dataclass(frozen=True)
class TocEntry:
    label: str
    content_index: int
    section: Optional[str]


@dataclass(frozen=True)
class TextStructure:
    """
    Object that describes how the text
    should be displayed in screen.

    text_lines: ("list of lines", "of text", ...)
    image_maps: {line_num: path/to/image/in/ebook/zip}
    section_rows: {section_id: line_num}
    formatting: (InlineStyle, ...)
    """

    text_lines: Tuple[str, ...]
    image_maps: Mapping[int, str]
    section_rows: Mapping[str, int]
    formatting: Tuple[InlineStyle, ...]


@dataclass(frozen=True)
class NoUpdate:
    pass


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)


class AppData:
    @property
    def prefix(self) -> Optional[str]:
        """Return None if there exists no homedir | userdir"""
        prefix: Optional[str] = None

        # UNIX filesystem
        homedir = os.getenv("HOME")
        # WIN filesystem
        userdir = os.getenv("USERPROFILE")

        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")

        if prefix:
            os.makedirs(prefix, exist_ok=True)

        return prefix