cquarry 1.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cquarry/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ VERSION = "1.9.0"
2
+ __version__ = VERSION
cquarry/analytics.py ADDED
@@ -0,0 +1,142 @@
1
+ """Library analytics derivations, promoted from CalibreQuarry's frontend.
2
+
3
+ Reading pace, per-author stats, and wing overlap were computed inside
4
+ CalibreQuarry's ``--analytics`` mode from pure database data; Hermitage's
5
+ stats views and Carrel's collection-pace views would each have re-derived
6
+ them. This module is the shared derivation layer — the frontend keeps
7
+ formatting. Anything an existing cquarry API already serves
8
+ (:meth:`get_format_stats`, :meth:`get_entities`, :meth:`get_tag_counts`)
9
+ deliberately does not appear here.
10
+
11
+ Same shape as :mod:`cquarry.integrity`: pure functions over the cached rows,
12
+ no SQL of their own.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import itertools
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from cquarry.helpers import calibre_rating_to_stars, normalize_author_display
21
+
22
+ if TYPE_CHECKING:
23
+ from cquarry.db import CalibreDB
24
+
25
+ __all__ = [
26
+ "addition_timeline",
27
+ "author_stats",
28
+ "rating_distribution",
29
+ "vl_overlap",
30
+ ]
31
+
32
+
33
+ def addition_timeline(db: CalibreDB, granularity: str = "month") -> dict[str, int]:
34
+ """Books added per calendar bucket, chronological.
35
+
36
+ ``granularity="month"`` (the default) keys ``"YYYY-MM"``; ``"year"``
37
+ keys ``"YYYY"``. Books with no timestamp are skipped — they have no
38
+ bucket to land in. Anything else raises ValueError.
39
+ """
40
+ if granularity not in ("month", "year"):
41
+ raise ValueError(f"granularity must be 'month' or 'year', not {granularity!r}")
42
+ width = 7 if granularity == "month" else 4
43
+ out: dict[str, int] = {}
44
+ for b in db.get_all_books():
45
+ ts = b["timestamp"] or ""
46
+ if not ts:
47
+ continue
48
+ key = ts[:width]
49
+ if not key[0].isdigit():
50
+ continue
51
+ out[key] = out.get(key, 0) + 1
52
+ return dict(sorted(out.items()))
53
+
54
+
55
+ def author_stats(db: CalibreDB) -> list[dict[str, Any]]:
56
+ """Per-primary-author rollups, count-descending then name.
57
+
58
+ Each entry: ``author`` (primary author via
59
+ :func:`cquarry.helpers.normalize_author_display`'s ``primary_only``
60
+ mode), ``book_count``, ``avg_rating`` (star-scale average over rated
61
+ books only; ``0.0`` when the author has none), ``rated_count`` (how many
62
+ of the author's books carry a rating — renderers need it for
63
+ "(N rated)" and the average alone cannot give it back), and ``formats``
64
+ (sorted distinct format names across the author's books). Authorless
65
+ books are skipped — they have no author to roll up into.
66
+ """
67
+ data: dict[str, dict[str, Any]] = {}
68
+ for b in db.get_all_books():
69
+ if not b["authors"]:
70
+ continue
71
+ author = normalize_author_display(b["authors"], primary_only=True)
72
+ ad = data.setdefault(author, {"count": 0, "ratings": [], "formats": set()})
73
+ ad["count"] += 1
74
+ stars = calibre_rating_to_stars(b["rating"])
75
+ if stars is not None:
76
+ ad["ratings"].append(stars)
77
+ if b["formats"]:
78
+ ad["formats"].update(f.strip() for f in b["formats"])
79
+
80
+ out = []
81
+ for author, ad in sorted(data.items(), key=lambda x: (-x[1]["count"], x[0])):
82
+ ratings = ad["ratings"]
83
+ out.append(
84
+ {
85
+ "author": author,
86
+ "book_count": ad["count"],
87
+ "avg_rating": sum(ratings) / len(ratings) if ratings else 0.0,
88
+ "rated_count": len(ratings),
89
+ "formats": sorted(ad["formats"]),
90
+ }
91
+ )
92
+ return out
93
+
94
+
95
+ def rating_distribution(db: CalibreDB) -> dict[float | str, int]:
96
+ """How many books sit at each star rating, ascending, ``"unrated"`` last.
97
+
98
+ Keys are star floats on the half-step scale Calibre's 0-10 integers map
99
+ to (``normalize_rating``); books with no rating (``None``/``0``) count
100
+ under the ``"unrated"`` string key.
101
+ """
102
+ out: dict[float | str, int] = {}
103
+ for b in db.get_all_books():
104
+ stars = calibre_rating_to_stars(b["rating"])
105
+ key: float | str = stars if stars is not None else "unrated"
106
+ out[key] = out.get(key, 0) + 1
107
+ floats = sorted(k for k in out if isinstance(k, float))
108
+ ordered: dict[float | str, int] = {k: out[k] for k in floats}
109
+ if "unrated" in out:
110
+ ordered["unrated"] = out["unrated"]
111
+ return ordered
112
+
113
+
114
+ def vl_overlap(
115
+ db: CalibreDB, names: list[str] | tuple[str, ...] | None = None
116
+ ) -> dict[tuple[str, ...], list[int]]:
117
+ """Books shared by two or more virtual libraries (wings).
118
+
119
+ Returns ``{(wing, wing, ...): [ids]}`` for every combination of two or
120
+ more wings that actually overlaps, wing names sorted inside each key.
121
+ ``names`` restricts the wings considered (unknown names raise through
122
+ :meth:`CalibreDB.resolve_vl`); None means every virtual library. Books
123
+ in exactly one wing appear nowhere — single-wing membership is not
124
+ overlap.
125
+ """
126
+ if names is None:
127
+ names = sorted(db.get_virtual_libraries())
128
+ ids_by_wing: dict[str, set[int]] = {n: db.resolve_vl(n) for n in names}
129
+
130
+ wings_of: dict[int, list[str]] = {}
131
+ for wing, ids in ids_by_wing.items():
132
+ for book_id in ids:
133
+ wings_of.setdefault(book_id, []).append(wing)
134
+
135
+ out: dict[tuple[str, ...], list[int]] = {}
136
+ for book_id, wings in wings_of.items():
137
+ if len(wings) < 2:
138
+ continue
139
+ for r in range(2, len(wings) + 1):
140
+ for combo in itertools.combinations(sorted(wings), r):
141
+ out.setdefault(combo, []).append(book_id)
142
+ return {k: sorted(v) for k, v in sorted(out.items())}
cquarry/config.py ADDED
@@ -0,0 +1,40 @@
1
+ import json
2
+ import os
3
+
4
+ VERSION = "1.9.0"
5
+
6
+ DEFAULT_DB_PATHS = [
7
+ "metadata.db",
8
+ os.path.expanduser("~/Calibre Library/metadata.db"),
9
+ os.path.expanduser("~/calibre/metadata.db"),
10
+ ]
11
+
12
+ CALIBRE_RATING_SCALE = 2 # Calibre stores rating * 2 (so 5 stars = 10)
13
+
14
+ CONFIG_FILE = os.path.expanduser("~/.config/cquarry/config.json")
15
+
16
+
17
+ def load_config() -> dict:
18
+ if os.path.exists(CONFIG_FILE):
19
+ try:
20
+ with open(CONFIG_FILE, encoding="utf-8") as f:
21
+ return json.load(f)
22
+ except Exception:
23
+ pass
24
+ return {}
25
+
26
+
27
+ def save_config(config: dict) -> None:
28
+ os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
29
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
30
+ json.dump(config, f, indent=4)
31
+
32
+
33
+ def get_db_path() -> str | None:
34
+ return load_config().get("db_path")
35
+
36
+
37
+ def set_db_path(path: str) -> None:
38
+ config = load_config()
39
+ config["db_path"] = os.path.abspath(os.path.expanduser(path))
40
+ save_config(config)