Sync reading position from Moon Reader app to Bookhive atproto records
atproto bookhive ereader moonreader
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add read-only dashboard at /

Serves a single-user HTML view of the books waggle has records for,
styled after brad.quest's reading page: Currently Reading / Want to
Read / Finished in <year> / previous-years archive. Covers come from
a new /blob/{cid} proxy gated on CIDs we actually track (not an open
blob proxy). Profile + book cards link out to bookhive.buzz.

+1024
+1
pyproject.toml
··· 8 8 "uvicorn[standard]>=0.30", 9 9 "httpx>=0.27", 10 10 "python-dotenv>=1.0", 11 + "jinja2>=3.1", 11 12 ] 12 13 13 14 [project.optional-dependencies]
+5
src/waggle/adapters/web/__init__.py
··· 1 + """Single-user HTML dashboard over the same records the DAV adapter reads.""" 2 + 3 + from .router import make_router 4 + 5 + __all__ = ["make_router"]
+75
src/waggle/adapters/web/router.py
··· 1 + """HTML dashboard + cover-blob proxy. Single-user, read-only.""" 2 + 3 + from __future__ import annotations 4 + 5 + import logging 6 + from datetime import UTC, datetime 7 + 8 + from fastapi import APIRouter, HTTPException 9 + from fastapi.responses import HTMLResponse, Response 10 + from jinja2 import Environment, PackageLoader, select_autoescape 11 + 12 + from waggle.adapters.webdav.router import DAVContext 13 + from waggle.atproto import bookhive 14 + 15 + from . import view 16 + 17 + log = logging.getLogger(__name__) 18 + 19 + _env = Environment( 20 + loader=PackageLoader("waggle.adapters.web", "templates"), 21 + autoescape=select_autoescape(["html"]), 22 + trim_blocks=True, 23 + lstrip_blocks=True, 24 + ) 25 + 26 + 27 + def _stars_display(stars: int | None) -> str: 28 + """Half-star rendering — bookhive stores 1–10, we map to 1–5 with halves.""" 29 + if not stars: 30 + return "" 31 + out_of_five = stars / 2 32 + full = int(out_of_five) 33 + half = (out_of_five - full) >= 0.5 34 + return "★" * full + ("½" if half else "") 35 + 36 + 37 + _env.filters["stars"] = _stars_display 38 + 39 + 40 + def make_router(ctx: DAVContext) -> APIRouter: 41 + router = APIRouter() 42 + 43 + @router.get("/", include_in_schema=False, response_class=HTMLResponse) 44 + async def dashboard() -> HTMLResponse: 45 + records = await bookhive.list_records(ctx.client) 46 + books = view.build_books_view(records) 47 + current_year = datetime.now(UTC).year 48 + sections = view.partition(books, current_year) 49 + try: 50 + profile = await ctx.client.get_profile() 51 + except Exception as e: 52 + log.warning("Profile fetch failed: %s", e) 53 + profile = {"handle": "", "displayName": "", "avatar": ""} 54 + html = _env.get_template("dashboard.html").render( 55 + sections=sections, profile=profile, book_count=len(books), 56 + ) 57 + return HTMLResponse(html) 58 + 59 + @router.get("/blob/{cid}", include_in_schema=False) 60 + async def cover(cid: str) -> Response: 61 + # Only serve cids that appear as covers on records we own. Prevents 62 + # this endpoint from being a generic open blob proxy. 63 + records = await bookhive.list_records(ctx.client) 64 + if cid not in view.cover_cids(records): 65 + raise HTTPException(status_code=404) 66 + resp = await bookhive.fetch_blob(ctx.client, cid) 67 + if resp.status_code != 200: 68 + raise HTTPException(status_code=502, detail="blob fetch failed") 69 + return Response( 70 + content=resp.content, 71 + media_type=resp.headers.get("content-type", "image/jpeg"), 72 + headers={"Cache-Control": "private, max-age=3600"}, 73 + ) 74 + 75 + return router
+364
src/waggle/adapters/web/templates/dashboard.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>Reading · {{ profile.handle or "waggle" }}</title> 7 + <style> 8 + /* Gruvbox palette, lifted from brad.quest */ 9 + :root { 10 + --bg-main: #fbf1c7; 11 + --bg-muted: #f2e5bc; 12 + --bg-surface: #ebdbb2; 13 + --border-main: #d5c4a1; 14 + --text-main: #3c3836; 15 + --text-muted: #7c6f64; 16 + --link-color: #076678; 17 + --accent: #b57614; 18 + --gruvbox-yellow: #b57614; 19 + } 20 + @media (prefers-color-scheme: dark) { 21 + :root { 22 + --bg-main: #282828; 23 + --bg-muted: #3c3836; 24 + --bg-surface: #504945; 25 + --border-main: #504945; 26 + --text-main: #ebdbb2; 27 + --text-muted: #a89984; 28 + --link-color: #83a598; 29 + --accent: #fabd2f; 30 + --gruvbox-yellow: #fabd2f; 31 + } 32 + } 33 + 34 + * { box-sizing: border-box; } 35 + html { -webkit-text-size-adjust: 100%; } 36 + body { 37 + margin: 0; 38 + padding: 2rem 1rem 4rem; 39 + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 40 + "Inter", "Segoe UI", sans-serif; 41 + background: var(--bg-main); 42 + color: var(--text-main); 43 + line-height: 1.5; 44 + } 45 + .wrap { max-width: 880px; margin: 0 auto; } 46 + a { color: var(--link-color); } 47 + a:hover { text-decoration: underline; } 48 + 49 + .font-serif { 50 + font-family: ui-serif, "Newsreader", Georgia, "Times New Roman", serif; 51 + } 52 + 53 + /* --- Profile header --- */ 54 + header.profile { 55 + display: flex; align-items: center; gap: 1rem; 56 + padding-bottom: 1.25rem; margin-bottom: 2rem; 57 + border-bottom: 1px solid var(--border-main); 58 + } 59 + header.profile img { 60 + width: 56px; height: 56px; border-radius: 50%; 61 + background: var(--bg-muted); object-fit: cover; 62 + } 63 + header.profile .who { margin: 0; font-weight: 600; } 64 + header.profile .meta { color: var(--text-muted); font-size: 0.85rem; margin: 0.1rem 0 0; } 65 + .profile-link { 66 + display: flex; align-items: center; gap: 1rem; 67 + text-decoration: none; color: inherit; 68 + padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem; 69 + border-radius: 0.375rem; 70 + transition: background-color 0.15s ease; 71 + } 72 + .profile-link:hover { 73 + background-color: color-mix(in srgb, var(--accent) 12%, transparent); 74 + text-decoration: none; 75 + } 76 + 77 + /* --- Section headings --- */ 78 + section { margin-bottom: 2.5rem; } 79 + section > h1, section > h2, section > h3 { margin: 0 0 0.5rem; font-weight: 500; } 80 + section > h1 { font-size: 2rem; font-style: italic; } 81 + section > h2 { font-size: 1.5rem; } 82 + section > h3 { font-size: 1.25rem; } 83 + section > .subtitle { 84 + color: var(--text-muted); font-size: 0.85rem; margin: 0 0 1.25rem; 85 + } 86 + 87 + /* --- Currently Reading: largest tier --- */ 88 + .current-books { 89 + display: grid; 90 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 91 + gap: 1rem; 92 + } 93 + .current-book { 94 + display: flex; flex-direction: column; align-items: center; 95 + text-align: center; text-decoration: none; color: inherit; 96 + padding: 0.75rem; border-radius: 0.5rem; 97 + transition: background-color 0.15s ease; 98 + } 99 + .current-book:hover { 100 + background-color: color-mix(in srgb, var(--accent) 12%, transparent); 101 + text-decoration: none; 102 + } 103 + .current-cover, .current-cover-ph { 104 + width: 160px; height: 240px; 105 + border-radius: 0.375rem; 106 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); 107 + margin-bottom: 0.75rem; 108 + object-fit: cover; 109 + background: var(--bg-muted); 110 + } 111 + .current-cover-ph { 112 + display: flex; align-items: center; justify-content: center; 113 + color: var(--text-muted); font-size: 2rem; 114 + border: 1px solid var(--border-main); 115 + } 116 + .current-title { 117 + font-size: 1.05rem; font-weight: 600; margin: 0 0 0.15rem; 118 + } 119 + .current-author { 120 + font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; 121 + } 122 + .current-progress { 123 + width: 100%; font-size: 0.75rem; color: var(--text-muted); 124 + display: flex; align-items: center; gap: 0.4rem; 125 + } 126 + progress { 127 + flex: 1; height: 4px; 128 + -webkit-appearance: none; appearance: none; border: 0; 129 + } 130 + progress::-webkit-progress-bar { background: var(--bg-muted); border-radius: 2px; } 131 + progress::-webkit-progress-value { background: var(--accent); border-radius: 2px; } 132 + progress::-moz-progress-bar { background: var(--accent); } 133 + 134 + /* --- Want to Read: medium tier --- */ 135 + .want-books { 136 + display: grid; 137 + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 138 + gap: 0.75rem; 139 + } 140 + .want-book { 141 + display: flex; flex-direction: column; align-items: center; 142 + text-align: center; text-decoration: none; color: inherit; 143 + padding: 0.5rem; border-radius: 0.375rem; 144 + transition: background-color 0.15s ease; 145 + } 146 + .want-book:hover { 147 + background-color: color-mix(in srgb, var(--accent) 12%, transparent); 148 + } 149 + .want-cover, .want-cover-ph { 150 + width: 80px; height: 120px; 151 + border-radius: 0.25rem; 152 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); 153 + margin-bottom: 0.5rem; 154 + object-fit: cover; 155 + background: var(--bg-muted); 156 + } 157 + .want-cover-ph { 158 + display: flex; align-items: center; justify-content: center; 159 + color: var(--text-muted); 160 + border: 1px solid var(--border-main); 161 + } 162 + .want-title { font-size: 0.8rem; font-weight: 500; margin: 0; } 163 + .want-author { 164 + font-size: 0.7rem; color: var(--text-muted); margin: 0.1rem 0 0; 165 + } 166 + 167 + /* --- Finished list: dense rows --- */ 168 + .book-list { 169 + list-style: none; padding: 0; margin: 0; 170 + display: flex; flex-direction: column; gap: 0.25rem; 171 + } 172 + .book-item { border-radius: 0.375rem; transition: background-color 0.1s ease; } 173 + .book-item:hover { background-color: var(--bg-muted); } 174 + .book-link { 175 + display: flex; align-items: center; gap: 0.75rem; 176 + padding: 0.5rem 0.75rem; 177 + text-decoration: none; color: inherit; 178 + } 179 + .list-cover, .list-cover-ph { 180 + width: 48px; height: 72px; 181 + object-fit: cover; 182 + border-radius: 0.25rem; 183 + flex-shrink: 0; 184 + background: var(--bg-muted); 185 + } 186 + .list-cover-ph { 187 + display: flex; align-items: center; justify-content: center; 188 + color: var(--text-muted); font-size: 0.9rem; 189 + border: 1px solid var(--border-main); 190 + } 191 + .book-info { display: flex; flex-direction: column; min-width: 0; } 192 + .book-title { 193 + font-size: 0.875rem; font-weight: 500; color: var(--link-color); 194 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 195 + } 196 + .book-meta { font-size: 0.75rem; color: var(--text-muted); } 197 + .book-stars { color: var(--gruvbox-yellow); } 198 + 199 + /* --- Archive <details> --- */ 200 + details.archive { 201 + margin-top: 1.5rem; 202 + border-top: 1px dashed var(--border-main); 203 + padding-top: 1rem; 204 + } 205 + details.archive > summary { 206 + cursor: pointer; 207 + color: var(--link-color); 208 + font-size: 0.9rem; 209 + list-style: none; 210 + } 211 + details.archive > summary::-webkit-details-marker { display: none; } 212 + details.archive > summary::before { 213 + content: "▸ "; 214 + display: inline-block; 215 + transition: transform 0.15s ease; 216 + } 217 + details.archive[open] > summary::before { transform: rotate(90deg); } 218 + details.archive > summary:hover { text-decoration: underline; } 219 + details.archive .book-list { margin-top: 0.75rem; } 220 + 221 + .empty { 222 + color: var(--text-muted); text-align: center; 223 + padding: 3rem 0; font-style: italic; 224 + } 225 + 226 + @media (max-width: 480px) { 227 + body { padding: 1rem 0.75rem 3rem; } 228 + section > h1 { font-size: 1.5rem; } 229 + .current-cover, .current-cover-ph { width: 120px; height: 180px; } 230 + } 231 + </style> 232 + </head> 233 + <body> 234 + <div class="wrap"> 235 + {% set profile_url = "https://bookhive.buzz/profile/" ~ profile.handle if profile.handle else None %} 236 + <header class="profile"> 237 + {% if profile_url %}<a href="{{ profile_url }}" target="_blank" rel="noopener" class="profile-link">{% endif %} 238 + <img src="{{ profile.avatar or '' }}" alt=""> 239 + <div> 240 + <p class="who">{{ profile.displayName or profile.handle or "waggle" }}</p> 241 + <p class="meta"> 242 + {% if profile.handle %}@{{ profile.handle }} · {% endif %}{{ book_count }} book{{ '' if book_count == 1 else 's' }} on Bookhive 243 + </p> 244 + </div> 245 + {% if profile_url %}</a>{% endif %} 246 + </header> 247 + 248 + {% macro format_date(iso) -%} 249 + {%- if iso -%} 250 + {{- iso[:10] -}} 251 + {%- endif -%} 252 + {%- endmacro %} 253 + 254 + {% if sections.currently_reading %} 255 + <section> 256 + <h1 class="font-serif">Currently Reading</h1> 257 + <div class="current-books"> 258 + {% for book in sections.currently_reading %} 259 + {% set tag = 'a' if book.bookhive_url else 'div' %} 260 + <{{ tag }} class="current-book"{% if book.bookhive_url %} href="{{ book.bookhive_url }}" target="_blank" rel="noopener"{% endif %}> 261 + {% if book.cover_cid %} 262 + <img class="current-cover" src="/blob/{{ book.cover_cid }}" alt="" loading="lazy"> 263 + {% else %} 264 + <div class="current-cover-ph">📖</div> 265 + {% endif %} 266 + <p class="current-title">{{ book.title }}</p> 267 + <p class="current-author">{{ book.authors }}</p> 268 + {% if book.percent is not none %} 269 + <div class="current-progress"> 270 + <progress max="100" value="{{ book.percent }}"></progress> 271 + <span>{{ book.percent }}%</span> 272 + </div> 273 + {% endif %} 274 + </{{ tag }}> 275 + {% endfor %} 276 + </div> 277 + </section> 278 + {% endif %} 279 + 280 + {% if sections.want_to_read %} 281 + <section> 282 + <h2 class="font-serif">Want to Read</h2> 283 + <div class="want-books"> 284 + {% for book in sections.want_to_read %} 285 + {% set tag = 'a' if book.bookhive_url else 'div' %} 286 + <{{ tag }} class="want-book"{% if book.bookhive_url %} href="{{ book.bookhive_url }}" target="_blank" rel="noopener"{% endif %}> 287 + {% if book.cover_cid %} 288 + <img class="want-cover" src="/blob/{{ book.cover_cid }}" alt="" loading="lazy"> 289 + {% else %} 290 + <div class="want-cover-ph">📖</div> 291 + {% endif %} 292 + <p class="want-title">{{ book.title }}</p> 293 + <p class="want-author">{{ book.authors }}</p> 294 + </{{ tag }}> 295 + {% endfor %} 296 + </div> 297 + </section> 298 + {% endif %} 299 + 300 + {% if sections.finished_this_year %} 301 + <section> 302 + <h3 class="font-serif">Finished in {{ sections.year }}</h3> 303 + <ul class="book-list"> 304 + {% for book in sections.finished_this_year %} 305 + <li class="book-item"> 306 + {% set tag = 'a' if book.bookhive_url else 'div' %} 307 + <{{ tag }} class="book-link"{% if book.bookhive_url %} href="{{ book.bookhive_url }}" target="_blank" rel="noopener"{% endif %}> 308 + {% if book.cover_cid %} 309 + <img class="list-cover" src="/blob/{{ book.cover_cid }}" alt="" loading="lazy"> 310 + {% else %} 311 + <div class="list-cover-ph">📖</div> 312 + {% endif %} 313 + <div class="book-info"> 314 + <span class="book-title">{{ book.title }}</span> 315 + <span class="book-meta"> 316 + {{ book.authors }} 317 + {% if book.stars %} · <span class="book-stars">{{ book.stars | stars }}</span>{% endif %} 318 + {% if book.finished_at %} · {{ format_date(book.finished_at) }}{% endif %} 319 + </span> 320 + </div> 321 + </{{ tag }}> 322 + </li> 323 + {% endfor %} 324 + </ul> 325 + </section> 326 + {% endif %} 327 + 328 + {% if sections.finished_previous %} 329 + <section> 330 + <details class="archive"> 331 + <summary>{{ sections.finished_previous|length }} from previous years</summary> 332 + <ul class="book-list"> 333 + {% for book in sections.finished_previous %} 334 + <li class="book-item"> 335 + {% set tag = 'a' if book.bookhive_url else 'div' %} 336 + <{{ tag }} class="book-link"{% if book.bookhive_url %} href="{{ book.bookhive_url }}" target="_blank" rel="noopener"{% endif %}> 337 + {% if book.cover_cid %} 338 + <img class="list-cover" src="/blob/{{ book.cover_cid }}" alt="" loading="lazy"> 339 + {% else %} 340 + <div class="list-cover-ph">📖</div> 341 + {% endif %} 342 + <div class="book-info"> 343 + <span class="book-title">{{ book.title }}</span> 344 + <span class="book-meta"> 345 + {{ book.authors }} 346 + {% if book.stars %} · <span class="book-stars">{{ book.stars | stars }}</span>{% endif %} 347 + {% if book.finished_at %} · {{ format_date(book.finished_at) }}{% endif %} 348 + </span> 349 + </div> 350 + </{{ tag }}> 351 + </li> 352 + {% endfor %} 353 + </ul> 354 + </details> 355 + </section> 356 + {% endif %} 357 + 358 + {% if not sections.currently_reading and not sections.want_to_read 359 + and not sections.finished_this_year and not sections.finished_previous %} 360 + <p class="empty">No books tracked yet.</p> 361 + {% endif %} 362 + </div> 363 + </body> 364 + </html>
+154
src/waggle/adapters/web/view.py
··· 1 + """Pure transforms from bookhive records → dashboard view models. 2 + 3 + Kept separate from the router so it's trivially unit-testable. 4 + """ 5 + 6 + from __future__ import annotations 7 + 8 + from dataclasses import dataclass 9 + from datetime import datetime 10 + 11 + STATUS_LABELS = { 12 + "buzz.bookhive.defs#reading": ("Reading", "reading"), 13 + "buzz.bookhive.defs#finished": ("Finished", "finished"), 14 + "buzz.bookhive.defs#wantToRead": ("Want to read", "want"), 15 + "buzz.bookhive.defs#abandoned": ("Abandoned", "abandoned"), 16 + } 17 + 18 + 19 + @dataclass 20 + class BookView: 21 + title: str 22 + authors: str 23 + status_slug: str # "reading" / "finished" / "want" / "abandoned" / "unknown" 24 + status_label: str 25 + percent: int | None 26 + cover_cid: str | None 27 + finished_at: str | None 28 + started_at: str | None 29 + created_at: str | None 30 + updated_at: str | None 31 + stars: int | None # 1-10 per lexicon; half-star rendering handled at template time 32 + moon_filename: str | None 33 + bookhive_url: str | None # https://bookhive.buzz/books/<hiveId> when we have one 34 + 35 + @property 36 + def sort_date(self) -> str: 37 + """Best-available datetime for ordering (ISO strings sort lexicographically).""" 38 + return self.finished_at or self.started_at or self.updated_at or self.created_at or "" 39 + 40 + @property 41 + def finished_year(self) -> int | None: 42 + """Year to file this book under in 'Finished in YYYY'.""" 43 + src = self.finished_at or self.started_at or self.created_at 44 + if not src: 45 + return None 46 + try: 47 + return datetime.fromisoformat(src.replace("Z", "+00:00")).year 48 + except ValueError: 49 + return None 50 + 51 + 52 + def _cover_cid(value: dict) -> str | None: 53 + cover = value.get("cover") 54 + if not isinstance(cover, dict): 55 + return None 56 + ref = cover.get("ref") 57 + if isinstance(ref, dict): 58 + link = ref.get("$link") 59 + if isinstance(link, str): 60 + return link 61 + return None 62 + 63 + 64 + def _hive_id(value: dict) -> str | None: 65 + """Extract hiveId — check both the top-level field and the identifiers ref.""" 66 + hid = value.get("hiveId") 67 + if isinstance(hid, str) and hid: 68 + return hid 69 + idents = value.get("identifiers") 70 + if isinstance(idents, dict): 71 + hid = idents.get("hiveId") 72 + if isinstance(hid, str) and hid: 73 + return hid 74 + return None 75 + 76 + 77 + def _to_view(record: dict) -> BookView: 78 + v = record.get("value") or {} 79 + bp = v.get("bookProgress") or {} 80 + moon = bp.get("moonReader") or {} 81 + status_raw = v.get("status") or "" 82 + label, slug = STATUS_LABELS.get(status_raw, (status_raw or "Unknown", "unknown")) 83 + percent = bp.get("percent") 84 + stars = v.get("stars") 85 + hive_id = _hive_id(v) 86 + return BookView( 87 + title=v.get("title", "Untitled"), 88 + authors=v.get("authors", ""), 89 + status_slug=slug, 90 + status_label=label, 91 + percent=int(percent) if isinstance(percent, (int, float)) else None, 92 + cover_cid=_cover_cid(v), 93 + finished_at=v.get("finishedAt"), 94 + started_at=v.get("startedAt"), 95 + created_at=v.get("createdAt"), 96 + updated_at=bp.get("updatedAt"), 97 + stars=int(stars) if isinstance(stars, (int, float)) else None, 98 + moon_filename=moon.get("file"), 99 + bookhive_url=f"https://bookhive.buzz/books/{hive_id}" if hive_id else None, 100 + ) 101 + 102 + 103 + def build_books_view(records: list[dict]) -> list[BookView]: 104 + """Turn raw listRecords output into BookView objects. No sorting/filtering.""" 105 + return [_to_view(r) for r in records] 106 + 107 + 108 + def cover_cids(records: list[dict]) -> set[str]: 109 + """CIDs for covers we're willing to proxy — used to gate /blob/{cid}.""" 110 + out: set[str] = set() 111 + for r in records: 112 + cid = _cover_cid(r.get("value") or {}) 113 + if cid: 114 + out.add(cid) 115 + return out 116 + 117 + 118 + @dataclass 119 + class DashboardSections: 120 + currently_reading: list[BookView] 121 + want_to_read: list[BookView] 122 + finished_this_year: list[BookView] 123 + finished_previous: list[BookView] 124 + year: int 125 + 126 + 127 + def partition(books: list[BookView], current_year: int) -> DashboardSections: 128 + """Split books into the four dashboard sections. 129 + 130 + `currently_reading` is capped at 3 (matches brad.quest's reading page); 131 + overflow falls through to `finished_previous` only if the book is actually 132 + finished — active readers just disappear off the dashboard. 133 + """ 134 + reading = sorted( 135 + (b for b in books if b.status_slug == "reading"), 136 + key=lambda b: b.sort_date, reverse=True, 137 + )[:3] 138 + want = sorted( 139 + (b for b in books if b.status_slug == "want"), 140 + key=lambda b: b.sort_date, reverse=True, 141 + ) 142 + finished_all = sorted( 143 + (b for b in books if b.status_slug == "finished"), 144 + key=lambda b: b.sort_date, reverse=True, 145 + ) 146 + this_year = [b for b in finished_all if b.finished_year == current_year] 147 + previous = [b for b in finished_all if b.finished_year != current_year] 148 + return DashboardSections( 149 + currently_reading=reading, 150 + want_to_read=want, 151 + finished_this_year=this_year, 152 + finished_previous=previous, 153 + year=current_year, 154 + )
+13
src/waggle/atproto/bookhive.py
··· 281 281 return books[0] if books else None 282 282 283 283 284 + async def fetch_blob(client: ATProtoClient, cid: str) -> httpx.Response: 285 + """Fetch a blob from the authed user's repo by CID. 286 + 287 + Used by the dashboard's cover proxy. Callers should validate the cid 288 + belongs to a cover they serve before exposing this — `com.atproto.sync.getBlob` 289 + will happily return any blob the repo holds. 290 + """ 291 + did = await client.did() 292 + return await client.request( 293 + "GET", "com.atproto.sync.getBlob", params={"did": did, "cid": cid} 294 + ) 295 + 296 + 284 297 async def upload_cover(client: ATProtoClient, cover_url: str) -> dict | None: 285 298 """Download a cover image URL and upload as a PDS blob. Returns blob ref.""" 286 299 try:
+14
src/waggle/atproto/client.py
··· 49 49 sess = await self._ensure_session() 50 50 return sess.did 51 51 52 + async def get_profile(self) -> dict: 53 + """Fetch the authed user's bsky profile (handle, displayName, avatar). 54 + 55 + Hits the public bsky appview, not the user's PDS — PDSes don't serve 56 + `app.bsky.*` endpoints directly. Avatar is a CDN URL safe to render. 57 + """ 58 + did = await self.did() 59 + resp = await self._http.get( 60 + "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile", 61 + params={"actor": did}, 62 + ) 63 + resp.raise_for_status() 64 + return resp.json() 65 + 52 66 async def _ensure_session(self) -> Session: 53 67 if self._session is not None: 54 68 return self._session
+4
src/waggle/main.py
··· 9 9 from starlette.middleware.base import BaseHTTPMiddleware 10 10 11 11 from waggle import auth, config 12 + from waggle.adapters.web import make_router as make_web_router 12 13 from waggle.adapters.webdav.passthrough import Passthrough 13 14 from waggle.adapters.webdav.router import DAVContext, make_router 14 15 from waggle.atproto.client import ATProtoClient ··· 52 53 async def healthz() -> dict: 53 54 return {"ok": True} 54 55 56 + # Web dashboard is registered first so `GET /` and `/blob/{cid}` win over 57 + # the WebDAV catch-all on `/{path:path}`. 58 + app.include_router(make_web_router(ctx)) 55 59 app.include_router(make_router(ctx)) 56 60 return app 57 61
+168
tests/test_web_e2e.py
··· 1 + """End-to-end tests for the dashboard + cover proxy routes.""" 2 + 3 + from __future__ import annotations 4 + 5 + import tempfile 6 + 7 + import httpx 8 + import pytest 9 + import respx 10 + from fastapi.testclient import TestClient 11 + 12 + from waggle.atproto import bookhive 13 + 14 + 15 + @pytest.fixture 16 + def app(monkeypatch): 17 + bookhive.invalidate_cache() 18 + tmp = tempfile.mkdtemp(prefix="waggle-web-test-") 19 + monkeypatch.setenv("PDS", "pds.example") 20 + monkeypatch.setenv("BSKY_HANDLE", "tester.example") 21 + monkeypatch.setenv("BSKY_APP_PASSWORD", "app-pw") 22 + monkeypatch.setenv("DAV_USER", "u") 23 + monkeypatch.setenv("DAV_PASSWORD", "p") 24 + monkeypatch.setenv("PASSTHROUGH_ROOT", tmp) 25 + from waggle import main as main_mod 26 + return main_mod.create_app() 27 + 28 + 29 + @pytest.fixture 30 + def client(app): 31 + return TestClient(app) 32 + 33 + 34 + AUTH = ("u", "p") 35 + DID = "did:plc:tester" 36 + 37 + 38 + def _mock_session(respx_mock) -> None: 39 + respx_mock.post("https://pds.example/xrpc/com.atproto.server.createSession").mock( 40 + return_value=httpx.Response( 41 + 200, 42 + json={ 43 + "accessJwt": "access.jwt", 44 + "refreshJwt": "refresh.jwt", 45 + "did": DID, 46 + "handle": "tester.example", 47 + }, 48 + ) 49 + ) 50 + 51 + 52 + def _mock_profile(respx_mock) -> None: 53 + respx_mock.get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile").mock( 54 + return_value=httpx.Response(200, json={ 55 + "did": DID, 56 + "handle": "tester.example", 57 + "displayName": "Test User", 58 + "avatar": "https://cdn.bsky.app/avatar.jpg", 59 + }) 60 + ) 61 + 62 + 63 + def _records(books: list[dict]) -> dict: 64 + return {"records": books} 65 + 66 + 67 + def _book(rkey: str, title: str, *, percent: int | None = None, cover_cid: str | None = None, 68 + status: str = "buzz.bookhive.defs#reading", updated_at: str | None = None) -> dict: 69 + value: dict = { 70 + "$type": "buzz.bookhive.book", 71 + "title": title, 72 + "authors": "A. Author", 73 + "status": status, 74 + } 75 + if percent is not None: 76 + value["bookProgress"] = { 77 + "percent": percent, 78 + "updatedAt": updated_at or "2026-04-13T19:00:00.000Z", 79 + } 80 + if cover_cid: 81 + value["cover"] = { 82 + "$type": "blob", 83 + "ref": {"$link": cover_cid}, 84 + "mimeType": "image/jpeg", 85 + "size": 1000, 86 + } 87 + return {"uri": f"at://{DID}/buzz.bookhive.book/{rkey}", "cid": "cid", "value": value} 88 + 89 + 90 + def test_dashboard_requires_auth(client): 91 + assert client.get("/").status_code == 401 92 + 93 + 94 + @respx.mock 95 + def test_dashboard_renders_books(client): 96 + _mock_session(respx.mock) 97 + _mock_profile(respx.mock) 98 + respx.get("https://pds.example/xrpc/com.atproto.repo.listRecords").mock( 99 + return_value=httpx.Response(200, json=_records([ 100 + _book("rk1", "The Lesser Dead", percent=42), 101 + _book("rk2", "Another Book"), 102 + ])), 103 + ) 104 + 105 + r = client.get("/", auth=AUTH) 106 + assert r.status_code == 200 107 + assert r.headers["content-type"].startswith("text/html") 108 + assert "The Lesser Dead" in r.text 109 + assert "Another Book" in r.text 110 + assert "Test User" in r.text 111 + assert "tester.example" in r.text 112 + assert "42%" in r.text 113 + 114 + 115 + @respx.mock 116 + def test_dashboard_profile_failure_still_renders(client): 117 + _mock_session(respx.mock) 118 + respx.get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile").mock( 119 + return_value=httpx.Response(500, text="boom"), 120 + ) 121 + respx.get("https://pds.example/xrpc/com.atproto.repo.listRecords").mock( 122 + return_value=httpx.Response(200, json=_records([_book("rk1", "Solo Book")])), 123 + ) 124 + 125 + r = client.get("/", auth=AUTH) 126 + assert r.status_code == 200 127 + assert "Solo Book" in r.text 128 + 129 + 130 + @respx.mock 131 + def test_cover_proxy_serves_known_cid(client): 132 + _mock_session(respx.mock) 133 + respx.get("https://pds.example/xrpc/com.atproto.repo.listRecords").mock( 134 + return_value=httpx.Response(200, json=_records([ 135 + _book("rk1", "With Cover", cover_cid="bafycover"), 136 + ])), 137 + ) 138 + respx.get("https://pds.example/xrpc/com.atproto.sync.getBlob").mock( 139 + return_value=httpx.Response( 140 + 200, content=b"\xff\xd8\xff fake-jpeg", 141 + headers={"content-type": "image/jpeg"}, 142 + ), 143 + ) 144 + 145 + r = client.get("/blob/bafycover", auth=AUTH) 146 + assert r.status_code == 200 147 + assert r.content.startswith(b"\xff\xd8\xff") 148 + assert r.headers["content-type"] == "image/jpeg" 149 + 150 + 151 + @respx.mock 152 + def test_cover_proxy_rejects_unknown_cid(client): 153 + _mock_session(respx.mock) 154 + respx.get("https://pds.example/xrpc/com.atproto.repo.listRecords").mock( 155 + return_value=httpx.Response(200, json=_records([ 156 + _book("rk1", "No Cover"), 157 + ])), 158 + ) 159 + 160 + r = client.get("/blob/bafysneaky", auth=AUTH) 161 + assert r.status_code == 404 162 + 163 + 164 + def test_webdav_propfind_still_routes_on_root(client): 165 + # Sanity: mounting the web router must not swallow DAV methods on /. 166 + r = client.request("OPTIONS", "/", auth=AUTH) 167 + assert r.status_code == 200 168 + assert "PROPFIND" in r.headers["Allow"]
+138
tests/test_web_view.py
··· 1 + """Unit tests for view transforms and partitioning.""" 2 + 3 + from __future__ import annotations 4 + 5 + from waggle.adapters.web.view import build_books_view, cover_cids, partition 6 + 7 + 8 + def _rec(title: str, **overrides) -> dict: 9 + bp = overrides.pop("bookProgress", None) 10 + value = { 11 + "$type": "buzz.bookhive.book", 12 + "title": title, 13 + "authors": "Someone", 14 + "status": "buzz.bookhive.defs#reading", 15 + } 16 + if bp is not None: 17 + value["bookProgress"] = bp 18 + value.update(overrides) 19 + return {"uri": f"at://x/y/{title}", "cid": "cid", "value": value} 20 + 21 + 22 + def test_status_label_mapping(): 23 + records = [ 24 + _rec("A"), 25 + _rec("B", status="buzz.bookhive.defs#finished"), 26 + _rec("C", status="buzz.bookhive.defs#wantToRead"), 27 + ] 28 + by_title = {b.title: b for b in build_books_view(records)} 29 + assert by_title["A"].status_slug == "reading" 30 + assert by_title["A"].status_label == "Reading" 31 + assert by_title["B"].status_slug == "finished" 32 + assert by_title["C"].status_slug == "want" 33 + 34 + 35 + def test_percent_and_progress_extraction(): 36 + b = build_books_view([_rec("X", bookProgress={ 37 + "percent": 42, 38 + "updatedAt": "2026-04-13T00:00:00.000Z", 39 + "moonReader": {"file": "X.po", "position": "0*0@0#0:42%"}, 40 + })])[0] 41 + assert b.percent == 42 42 + assert b.moon_filename == "X.po" 43 + assert b.updated_at == "2026-04-13T00:00:00.000Z" 44 + 45 + 46 + def test_cover_cid_extraction(): 47 + r = _rec("X", cover={ 48 + "$type": "blob", 49 + "ref": {"$link": "bafyreiabc"}, 50 + "mimeType": "image/jpeg", 51 + "size": 1234, 52 + }) 53 + assert build_books_view([r])[0].cover_cid == "bafyreiabc" 54 + assert cover_cids([r]) == {"bafyreiabc"} 55 + 56 + 57 + def test_cover_cids_skips_missing_covers(): 58 + assert cover_cids([_rec("X")]) == set() 59 + 60 + 61 + def test_bookhive_url_from_top_level_hiveId(): 62 + b = build_books_view([_rec("X", hiveId="bk_abc123")])[0] 63 + assert b.bookhive_url == "https://bookhive.buzz/books/bk_abc123" 64 + 65 + 66 + def test_bookhive_url_from_identifiers_fallback(): 67 + b = build_books_view([_rec("X", identifiers={"hiveId": "bk_xyz"})])[0] 68 + assert b.bookhive_url == "https://bookhive.buzz/books/bk_xyz" 69 + 70 + 71 + def test_bookhive_url_none_when_no_hiveId(): 72 + assert build_books_view([_rec("X")])[0].bookhive_url is None 73 + 74 + 75 + def test_finished_year_prefers_finishedAt(): 76 + b = build_books_view([_rec( 77 + "X", 78 + status="buzz.bookhive.defs#finished", 79 + finishedAt="2024-11-01T00:00:00.000Z", 80 + startedAt="2023-01-01T00:00:00.000Z", 81 + )])[0] 82 + assert b.finished_year == 2024 83 + 84 + 85 + def test_finished_year_falls_back_to_startedAt(): 86 + b = build_books_view([_rec( 87 + "X", 88 + status="buzz.bookhive.defs#finished", 89 + startedAt="2022-06-01T00:00:00.000Z", 90 + )])[0] 91 + assert b.finished_year == 2022 92 + 93 + 94 + def test_finished_year_none_when_no_date(): 95 + b = build_books_view([_rec("X", status="buzz.bookhive.defs#finished")])[0] 96 + assert b.finished_year is None 97 + 98 + 99 + # --------------------------------------------------------------------------- 100 + # Partition into dashboard sections 101 + # --------------------------------------------------------------------------- 102 + 103 + def test_partition_splits_by_status_and_year(): 104 + finished = "buzz.bookhive.defs#finished" 105 + books = build_books_view([ 106 + _rec("R1", bookProgress={"percent": 20, "updatedAt": "2026-04-10T00:00:00.000Z"}), 107 + _rec("F-this-year", status=finished, finishedAt="2026-02-01T00:00:00.000Z"), 108 + _rec("F-last-year", status=finished, finishedAt="2025-11-01T00:00:00.000Z"), 109 + _rec("W1", status="buzz.bookhive.defs#wantToRead"), 110 + ]) 111 + s = partition(books, current_year=2026) 112 + assert [b.title for b in s.currently_reading] == ["R1"] 113 + assert [b.title for b in s.want_to_read] == ["W1"] 114 + assert [b.title for b in s.finished_this_year] == ["F-this-year"] 115 + assert [b.title for b in s.finished_previous] == ["F-last-year"] 116 + assert s.year == 2026 117 + 118 + 119 + def test_partition_caps_currently_reading_at_three(): 120 + books = build_books_view([ 121 + _rec(f"R{i}", bookProgress={ 122 + "percent": i * 10, 123 + "updatedAt": f"2026-04-{10 + i:02d}T00:00:00.000Z", 124 + }) 125 + for i in range(5) 126 + ]) 127 + s = partition(books, current_year=2026) 128 + # Newest (updatedAt desc) wins the 3 slots. 129 + assert [b.title for b in s.currently_reading] == ["R4", "R3", "R2"] 130 + 131 + 132 + def test_partition_finished_sorted_newest_first(): 133 + books = build_books_view([ 134 + _rec("old", status="buzz.bookhive.defs#finished", finishedAt="2026-01-01T00:00:00.000Z"), 135 + _rec("new", status="buzz.bookhive.defs#finished", finishedAt="2026-03-01T00:00:00.000Z"), 136 + ]) 137 + s = partition(books, current_year=2026) 138 + assert [b.title for b in s.finished_this_year] == ["new", "old"]
+88
uv.lock
··· 171 171 ] 172 172 173 173 [[package]] 174 + name = "jinja2" 175 + version = "3.1.6" 176 + source = { registry = "https://pypi.org/simple" } 177 + dependencies = [ 178 + { name = "markupsafe" }, 179 + ] 180 + sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } 181 + wheels = [ 182 + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, 183 + ] 184 + 185 + [[package]] 186 + name = "markupsafe" 187 + version = "3.0.3" 188 + source = { registry = "https://pypi.org/simple" } 189 + sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } 190 + wheels = [ 191 + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, 192 + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, 193 + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, 194 + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, 195 + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, 196 + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, 197 + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, 198 + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, 199 + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, 200 + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, 201 + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, 202 + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, 203 + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, 204 + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, 205 + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, 206 + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, 207 + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, 208 + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, 209 + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, 210 + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, 211 + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, 212 + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, 213 + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, 214 + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, 215 + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, 216 + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, 217 + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, 218 + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, 219 + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, 220 + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, 221 + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, 222 + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, 223 + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, 224 + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, 225 + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, 226 + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, 227 + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, 228 + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, 229 + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, 230 + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, 231 + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, 232 + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, 233 + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, 234 + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, 235 + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, 236 + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, 237 + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, 238 + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, 239 + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, 240 + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, 241 + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, 242 + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, 243 + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, 244 + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, 245 + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, 246 + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, 247 + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, 248 + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, 249 + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, 250 + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, 251 + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, 252 + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, 253 + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, 254 + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, 255 + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, 256 + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, 257 + ] 258 + 259 + [[package]] 174 260 name = "packaging" 175 261 version = "26.0" 176 262 source = { registry = "https://pypi.org/simple" } ··· 547 633 dependencies = [ 548 634 { name = "fastapi" }, 549 635 { name = "httpx" }, 636 + { name = "jinja2" }, 550 637 { name = "python-dotenv" }, 551 638 { name = "uvicorn", extra = ["standard"] }, 552 639 ] ··· 563 650 requires-dist = [ 564 651 { name = "fastapi", specifier = ">=0.115" }, 565 652 { name = "httpx", specifier = ">=0.27" }, 653 + { name = "jinja2", specifier = ">=3.1" }, 566 654 { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, 567 655 { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, 568 656 { name = "python-dotenv", specifier = ">=1.0" },