let there be light

This commit is contained in:
Jurgis Sakalauskas
2026-04-28 11:48:29 +03:00
commit beb1294d86
15 changed files with 966 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
data/
__pycache__/
*.pyc
.venv/
venv/
.git/
.gitignore
plan.md
+23
View File
@@ -0,0 +1,23 @@
# app data
data/
testdata/
# python
__pycache__/
*.pyc
*.pyo
.venv/
venv/
*.egg-info/
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db
# editor swap files
*.swp
*.swo
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py storage.py fetcher.py epub_builder.py ./
COPY templates/ ./templates/
COPY static/ ./static/
ENV DATA_DIR=/data \
ARTICLES_PER_PAGE=8 \
PORT=8080 \
EPUB_SPLIT_THRESHOLD_BYTES=50000 \
EPUB_SPLIT_TARGET_BYTES=25000
VOLUME /data
EXPOSE 8080
CMD gunicorn --workers 1 --bind 0.0.0.0:${PORT} --timeout 120 app:app
+64
View File
@@ -0,0 +1,64 @@
# kob
Self-hosted article-to-EPUB service. Paste a URL, get an EPUB on your e-reader.
Designed to be browsed *from* the e-reader: HTML-only, no JavaScript, big fonts, full-width, paginated (no scrolling).
## How it works
1. You browse to the site from your e-reader.
2. Paste an article URL into the input.
3. The server fetches it via [Jina Reader](https://r.jina.ai/), converts to EPUB, and saves it.
4. The article appears in the list — tap to download.
Images are downloaded and embedded into the EPUB at build time, so the file is fully self-contained — no internet needed when reading. If a particular image fails to fetch, it's skipped and the URL is left in the `src` (so it works online but shows broken offline); a message is written to stderr.
Long articles are split into multiple xhtml files inside the EPUB so weak readers paginate faster (each "chapter" is loaded and laid out independently). Splitting prefers H2/H3 boundaries when present (TOC entries use the heading text) and falls back to "Part 1, Part 2, …" splits at paragraph boundaries when the article has no headings or its sections are larger than the threshold. Tune via `EPUB_SPLIT_THRESHOLD_BYTES` / `EPUB_SPLIT_TARGET_BYTES` (see below).
## Quick start (Docker)
```bash
docker build -t kob .
docker run -d \
--name kob \
-p 8080:8080 \
-v $(pwd)/data:/data \
-e ARTICLES_PER_PAGE=8 \
kob
```
Then point your e-reader's browser at `http://<server-ip>:8080`.
## Environment variables
| Var | Default | Purpose |
|---------------------|-----------|--------------------------------------------------------|
| `DATA_DIR` | `/data` | Where articles + `index.json` are stored. |
| `ARTICLES_PER_PAGE` | `8` | How many articles per page on the listing. |
| `JINA_API_KEY` | *(unset)* | Optional. Sent as `Authorization: Bearer ...` to Jina. |
| `PORT` | `8080` | HTTP port inside the container. |
| `EPUB_SPLIT_THRESHOLD_BYTES` | `50000` | If an article's source markdown is larger than this, it gets split into multiple xhtml files inside the EPUB. Helps weak readers paginate faster. |
| `EPUB_SPLIT_TARGET_BYTES` | `25000` | Target size for each chunk when splitting. |
## Volumes
| Path | Purpose |
|----------|---------------------------------------------------------------|
| `/data` | Article storage. Persist this — losing it loses your library. |
Layout inside the volume:
```
/data/
index.json
articles/
<id>/
article.epub
source.md
meta.json
```
## Security note
There is **no authentication**. Designed for a trusted home network. If you expose this beyond your LAN, put it behind a reverse proxy with auth (e.g. Caddy + basic auth, or Tailscale).
+112
View File
@@ -0,0 +1,112 @@
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from flask import Flask, abort, redirect, render_template, request, send_file, url_for
import epub_builder
import fetcher
from storage import Storage
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
ARTICLES_PER_PAGE = int(os.environ.get("ARTICLES_PER_PAGE", "8"))
JINA_API_KEY = os.environ.get("JINA_API_KEY") or None
app = Flask(__name__)
storage = Storage(DATA_DIR)
@app.route("/")
def index():
page = _parse_page(request.args.get("page"))
articles = storage.list_articles()
total_pages = max(1, (len(articles) + ARTICLES_PER_PAGE - 1) // ARTICLES_PER_PAGE)
page = min(page, total_pages)
start = (page - 1) * ARTICLES_PER_PAGE
page_articles = articles[start:start + ARTICLES_PER_PAGE]
return render_template(
"index.html",
articles=page_articles,
page=page,
total_pages=total_pages,
)
@app.route("/add", methods=["POST"])
def add():
url = (request.form.get("url") or "").strip()
if not url:
return render_template("error.html", message="No URL provided."), 400
if not (url.startswith("http://") or url.startswith("https://")):
return render_template("error.html", message="URL must start with http:// or https://"), 400
try:
title, source_md = fetcher.fetch(url, api_key=JINA_API_KEY)
except Exception as e:
return render_template("error.html", message=f"Failed to fetch: {e}"), 502
article_id = _make_id(title)
try:
epub_bytes = epub_builder.build(
title=title,
source_md=source_md,
identifier=article_id,
)
except Exception as e:
return render_template("error.html", message=f"Failed to build EPUB: {e}"), 500
storage.add_article(article_id, title, url, source_md, epub_bytes)
return redirect(url_for("index"))
@app.route("/download/<article_id>")
def download(article_id):
article = storage.get_article(article_id)
if not article:
abort(404)
path = storage.epub_path(article_id)
if not path.exists():
abort(404)
safe_name = _slugify(article["title"]) + ".epub"
return send_file(
path,
as_attachment=True,
download_name=safe_name,
mimetype="application/epub+zip",
)
@app.route("/delete/<article_id>", methods=["GET"])
def delete_confirm(article_id):
article = storage.get_article(article_id)
if not article:
abort(404)
page = _parse_page(request.args.get("page"))
return render_template("confirm_delete.html", article=article, page=page)
@app.route("/delete/<article_id>", methods=["POST"])
def delete_do(article_id):
page = _parse_page(request.form.get("page"))
storage.delete_article(article_id)
articles = storage.list_articles()
total_pages = max(1, (len(articles) + ARTICLES_PER_PAGE - 1) // ARTICLES_PER_PAGE)
page = min(page, total_pages)
return redirect(url_for("index", page=page))
def _parse_page(raw: str | None) -> int:
try:
return max(1, int(raw or "1"))
except ValueError:
return 1
def _slugify(text: str, max_len: int = 60) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = text.strip("-")
return text[:max_len] or "article"
def _make_id(title: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
return f"{ts}-{_slugify(title)}"
+299
View File
@@ -0,0 +1,299 @@
import hashlib
import io
import os
import re
import sys
import lxml.etree
import lxml.html
import markdown as md
import requests
from ebooklib import epub
EPUB_CSS = """
body { font-family: serif; line-height: 1.6; }
h1, h2, h3 { font-family: sans-serif; }
img { max-width: 100%; height: auto; }
pre { white-space: pre-wrap; word-break: break-word; }
"""
IMG_TAG = re.compile(r"<img\b([^>]*?)/?>", re.IGNORECASE)
SRC_ATTR = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
IMAGE_TIMEOUT = 20
IMAGE_UA = "Mozilla/5.0 (compatible; kob/1.0; +https://github.com/)"
EXT_BY_MIME = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
SPLIT_THRESHOLD = int(os.environ.get("EPUB_SPLIT_THRESHOLD_BYTES", "50000"))
SPLIT_TARGET = int(os.environ.get("EPUB_SPLIT_TARGET_BYTES", "25000"))
HEADING_RE = re.compile(r"^(##|###)\s+(.+)$")
def build(title: str, source_md: str, identifier: str) -> bytes:
book = epub.EpubBook()
book.set_identifier(identifier)
book.set_title(title)
book.set_language("en")
body_md = _strip_jina_header(source_md)
chunks = _plan_chunks(body_md, title)
style = epub.EpubItem(
uid="style",
file_name="style.css",
media_type="text/css",
content=EPUB_CSS,
)
book.add_item(style)
embedder = _ImageEmbedder(book)
chapters: list[epub.EpubHtml] = []
for i, chunk in enumerate(chunks, 1):
html = md.markdown(chunk["body"], extensions=["extra", "sane_lists"])
html = embedder.embed(html)
if i == 1:
content = f"<h1>{_escape(title)}</h1>\n{html}"
else:
content = html
content = _to_xhtml(content)
file_name = "article.xhtml" if len(chunks) == 1 else f"article_{i:02d}.xhtml"
ch = epub.EpubHtml(title=chunk["title"], file_name=file_name, lang="en")
ch.content = content
ch.add_item(style)
book.add_item(ch)
chapters.append(ch)
book.toc = tuple(chapters)
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
book.spine = ["nav", *chapters]
buf = io.BytesIO()
epub.write_epub(buf, book)
return buf.getvalue()
def _plan_chunks(body_md: str, article_title: str) -> list[dict]:
"""Decide how to split. Returns list of {title, body} chunks.
- Below SPLIT_THRESHOLD: one chunk titled with the article title.
- Otherwise prefer cutting at H2/H3 boundaries; each chunk titled with its
first heading. Used only if every resulting chunk fits under threshold.
- Fallback: paragraph-boundary "Part N" splits.
"""
if len(body_md) <= SPLIT_THRESHOLD:
return [{"title": article_title, "body": body_md}]
blocks = _tokenize_md_blocks(body_md)
heading_chunks = _split_at_headings(blocks, SPLIT_TARGET, article_title)
if heading_chunks and all(
len(c["body"]) <= SPLIT_THRESHOLD for c in heading_chunks
):
return heading_chunks
return _split_into_parts(blocks, SPLIT_TARGET)
def _tokenize_md_blocks(body: str) -> list[str]:
"""Split markdown into top-level blocks (blank-line separated, fence-aware)."""
blocks: list[str] = []
cur: list[str] = []
in_fence = False
fence_marker = ""
for line in body.splitlines():
stripped = line.strip()
if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")):
in_fence = True
fence_marker = stripped[:3]
cur.append(line)
continue
if in_fence:
if stripped.startswith(fence_marker):
in_fence = False
fence_marker = ""
cur.append(line)
continue
if stripped == "":
if cur:
blocks.append("\n".join(cur))
cur = []
else:
cur.append(line)
if cur:
blocks.append("\n".join(cur))
return blocks
def _block_heading(block: str) -> str | None:
"""If the block starts with an H2/H3 heading, return its plain text."""
if not block.strip():
return None
first_line = block.lstrip().splitlines()[0]
m = HEADING_RE.match(first_line)
if m:
return _strip_inline_md(m.group(2))
return None
def _strip_inline_md(text: str) -> str:
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_`]+", "", text)
return text.strip()
def _split_at_headings(
blocks: list[str], target: int, article_title: str
) -> list[dict]:
"""Walk blocks, cutting before an H2/H3 once accumulated size exceeds target.
Returns [] if there are no H2/H3 headings at all.
"""
if not any(_block_heading(b) for b in blocks):
return []
chunks: list[dict] = []
cur_blocks: list[str] = []
cur_size = 0
cur_title: str | None = None
for b in blocks:
h = _block_heading(b)
if cur_size >= target and h and cur_blocks:
chunks.append({
"title": cur_title or article_title,
"body": "\n\n".join(cur_blocks),
})
cur_blocks = []
cur_size = 0
cur_title = None
if cur_title is None and h:
cur_title = h
cur_blocks.append(b)
cur_size += len(b) + 2
if cur_blocks:
chunks.append({
"title": cur_title or article_title,
"body": "\n\n".join(cur_blocks),
})
return chunks
def _split_into_parts(blocks: list[str], target: int) -> list[dict]:
parts: list[list[str]] = []
cur: list[str] = []
cur_size = 0
for b in blocks:
cur.append(b)
cur_size += len(b) + 2
if cur_size >= target:
parts.append(cur)
cur = []
cur_size = 0
if cur:
parts.append(cur)
if not parts:
parts = [[""]]
return [
{"title": f"Part {i}", "body": "\n\n".join(p)}
for i, p in enumerate(parts, 1)
]
class _ImageEmbedder:
def __init__(self, book: epub.EpubBook):
self.book = book
self.cache: dict[str, str | None] = {}
def embed(self, html: str) -> str:
def replace(match: re.Match[str]) -> str:
attrs = match.group(1)
src_match = SRC_ATTR.search(attrs)
if not src_match:
return match.group(0)
url = src_match.group(1)
if not url.startswith(("http://", "https://")):
return match.group(0)
if url not in self.cache:
self.cache[url] = _download_and_register(url, self.book)
local = self.cache[url]
if local is None:
return match.group(0)
new_attrs = attrs[: src_match.start(1)] + local + attrs[src_match.end(1):]
return f"<img{new_attrs}/>"
return IMG_TAG.sub(replace, html)
def _download_and_register(url: str, book: epub.EpubBook) -> str | None:
try:
resp = requests.get(
url,
timeout=IMAGE_TIMEOUT,
headers={"User-Agent": IMAGE_UA},
)
resp.raise_for_status()
except Exception as e:
print(f"[kob] image fetch failed: {url}: {e}", file=sys.stderr)
return None
mime = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
if not mime.startswith("image/"):
print(f"[kob] not an image ({mime}): {url}", file=sys.stderr)
return None
ext = EXT_BY_MIME.get(mime, ".bin")
h = hashlib.sha1(url.encode("utf-8")).hexdigest()[:12]
file_name = f"images/img_{h}{ext}"
item = epub.EpubItem(
uid=f"img_{h}",
file_name=file_name,
media_type=mime,
content=resp.content,
)
book.add_item(item)
return file_name
def _to_xhtml(html_fragment: str) -> str:
"""Parse an HTML fragment forgivingly and re-serialize as valid XHTML.
Fixes void-element self-closing, bare ampersands, unclosed tags, and other
quirks that strict XHTML parsers (libxml2) reject.
"""
if not html_fragment.strip():
return html_fragment
wrapper = lxml.html.fragment_fromstring(html_fragment, create_parent="div")
serialized = lxml.etree.tostring(
wrapper,
method="xml",
encoding="unicode",
with_tail=False,
)
if serialized in ("<div/>", "<div></div>"):
return ""
if serialized.startswith("<div>") and serialized.endswith("</div>"):
return serialized[len("<div>"):-len("</div>")]
return serialized
def _strip_jina_header(body: str) -> str:
marker = "Markdown Content:"
idx = body.find(marker)
if idx >= 0:
return body[idx + len(marker):].lstrip("\n")
return body
def _escape(s: str) -> str:
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
+29
View File
@@ -0,0 +1,29 @@
import re
from urllib.parse import urlparse
import requests
JINA_BASE = "https://r.jina.ai/"
def fetch(url: str, api_key: str | None = None, timeout: int = 60) -> tuple[str, str]:
"""Fetch an article via Jina Reader. Returns (title, markdown_body)."""
headers = {"Accept": "text/markdown"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp = requests.get(JINA_BASE + url, headers=headers, timeout=timeout)
resp.raise_for_status()
body = resp.text
title = _extract_title(body) or _hostname_fallback(url)
return title, body
def _extract_title(body: str) -> str | None:
m = re.match(r"^Title:\s*(.+)$", body, re.MULTILINE)
if m:
return m.group(1).strip()
return None
def _hostname_fallback(url: str) -> str:
return urlparse(url).hostname or "Untitled"
+154
View File
@@ -0,0 +1,154 @@
# kob — article-to-EPUB for e-reader
A tiny self-hosted web app: paste an article URL → it's fetched via Jina Reader, packaged as EPUB, and listed for download. Designed to be browsed from an e-reader: HTML-only, no JS, big fonts, full-width, paginated (no scrolling).
## Decisions (locked)
- **Stack**: Python + Flask, `ebooklib` for EPUB, `requests` for HTTP, `markdown` for md→html.
- **Hosting**: home server, Docker. README explains env vars and volumes.
- **Fetcher**: `https://r.jina.ai/<url>`. Optional `JINA_API_KEY` env var, sent as `Authorization: Bearer ...` if set.
- **Articles per page**: configurable via `ARTICLES_PER_PAGE`, default 8.
- **Delete**: button per article → confirmation page (no JS) → POST to actually delete.
- **Storage**: filesystem + `index.json`. Keep source URL and original markdown alongside the EPUB.
- **Title**: taken from Jina response, used as EPUB title and slug for filename.
- **No auth**: single user on home network. Document in README that this should not be exposed publicly without putting a reverse proxy + auth in front.
## Storage layout
Mount one volume at `/data`:
```
/data/
index.json # ordered list, newest first
articles/
<id>/ # id = timestamp + slug, e.g. 20260428-153012-how-to-x
article.epub
source.md # original markdown from Jina
meta.json # { id, url, title, fetched_at }
```
`index.json` is the source of truth for ordering and listing. Format:
```json
{
"articles": [
{ "id": "20260428-153012-how-to-x", "title": "How to X", "url": "https://...", "fetched_at": "2026-04-28T15:30:12Z" }
]
}
```
On delete: remove from `index.json` first, then `rm -rf` the article folder. (Order matters: if rm fails, the index is still consistent — orphan folders are harmless and can be GC'd later if ever needed.)
## Routes
| Method | Path | Purpose |
|--------|----------------------------|----------------------------------------------------|
| GET | `/` | Main page: URL input + paginated list (`?page=N`). |
| POST | `/add` | Fetch URL → build EPUB → save → redirect to `/`. |
| GET | `/download/<id>` | Stream the `.epub` file. |
| GET | `/delete/<id>?page=N` | Confirmation page ("Delete '<title>'? Yes / No"). |
| POST | `/delete/<id>` (form: `page=N`) | Actually delete; redirect to `/?page=N`. |
Pagination uses big "← Prev" / "Next →" buttons at the bottom. No page-number list (keeps things simple and reader-friendly).
**Page preservation across delete**: each "Delete" link on `/` carries the current `?page=N`. The confirmation page passes it through as a hidden form field; the POST handler redirects back to `/?page=N` so you stay where you were. Edge case: if the page you were on no longer exists after the delete (e.g. you deleted the only article on the last page), clamp `page` to the new max page before redirecting.
## Page layouts (HTML only)
All pages share the same minimal CSS:
- `body { max-width: 100%; font-size: 1.6rem; line-height: 1.5; padding: 1rem; }`
- Buttons / links styled as large blocks (~3rem tall, full-width on small screens).
- No images, no JS, no external assets.
- `<meta name="viewport" content="width=device-width, initial-scale=1">`.
**`/` (main)** — fits one screen, no scrolling:
1. URL input + "Add" button (POST to `/add`).
2. List of up to N=8 article rows. Each row: title + "Download" link + "Delete" link.
3. Prev / Next pagination buttons (disabled state when at edge).
**`/delete/<id>`** — confirmation:
- "Delete '<title>'?"
- Two big buttons: "Yes, delete" (POST) and "Cancel" (link back to `/`).
**`/add` result**: on success, redirect to `/`. On error, render a simple page with the error message and a "Back" link.
## EPUB generation
1. POST `/add` receives URL.
2. GET `https://r.jina.ai/<url>` (with `Authorization` header if `JINA_API_KEY` set). Jina returns markdown with a leading `Title: ...` block — parse it for the title; fall back to URL host if missing.
3. Save markdown to `source.md`.
4. Convert markdown → HTML with the `markdown` library.
5. Build EPUB with `ebooklib`:
- Single chapter, the converted HTML.
- Title = parsed title, language = `en`, identifier = id.
- Minimal embedded CSS for big-font reading (independent of the web UI CSS).
6. Write `article.epub`, `meta.json`. Prepend entry to `index.json`.
If any step fails, clean up partial files; do not modify `index.json`.
## Configuration (env vars)
| Var | Default | Purpose |
|----------------------|-------------|------------------------------------------|
| `DATA_DIR` | `/data` | Where articles + index.json live. |
| `ARTICLES_PER_PAGE` | `8` | Pagination size on `/`. |
| `JINA_API_KEY` | *(unset)* | Optional; sent as bearer token to Jina. |
| `PORT` | `8080` | HTTP port inside the container. |
## Project layout
```
kob/
app.py # Flask app: routes, glue
storage.py # index.json + folder ops (add, list, delete)
fetcher.py # Jina call + title parsing
epub_builder.py # markdown → EPUB
templates/
base.html
index.html
confirm_delete.html
error.html
static/
style.css
requirements.txt
Dockerfile
README.md
plan.md # this file
```
## Dockerfile
- Base: `python:3.12-slim`.
- Install deps from `requirements.txt`.
- Copy app, expose `$PORT`, run with `gunicorn` (single worker — single user, no contention).
- Declare `VOLUME /data`.
## README outline
1. What it does (1 paragraph).
2. Quick start: `docker build`, `docker run` example with `-v ./data:/data -p 8080:8080 -e JINA_API_KEY=... -e ARTICLES_PER_PAGE=8`.
3. Env vars table (same as above).
4. Volumes table: `/data` → article storage (persist this).
5. Security note: no auth — put behind a reverse proxy / Tailscale if not on a trusted LAN.
6. Where to point your e-reader's browser.
## Build order
1. Skeleton: Flask app, `requirements.txt`, render an empty `/` template.
2. Storage module: read/write `index.json`, list/add/delete article folders.
3. Fetcher: Jina call + title extraction; manual test with one URL.
4. EPUB builder: markdown → epub; verify the file opens on the e-reader.
5. Wire `/add` end-to-end.
6. Pagination + listing on `/`.
7. Delete flow with confirmation page.
8. CSS pass for big-font / full-width / no-scroll layout.
9. Dockerfile + README.
10. Smoke test in container with a real volume mount.
## Open / deferred
- Auto-prune of old articles: not in v1; user can delete manually.
- Cover images / multi-chapter EPUBs: not in v1.
- Search: explicitly out of scope.
- Auth: out of scope; documented in README.
+5
View File
@@ -0,0 +1,5 @@
Flask==3.0.3
gunicorn==23.0.0
requests==2.32.3
ebooklib==0.18
markdown==3.7
+110
View File
@@ -0,0 +1,110 @@
* { box-sizing: border-box; }
body {
font-family: serif;
font-size: 1.6rem;
line-height: 1.5;
margin: 0;
padding: 1rem;
max-width: 100%;
}
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.add-form input[type="url"] {
flex: 1;
font-size: 1.6rem;
padding: 0.75rem;
border: 2px solid #000;
min-width: 0;
}
.add-form button,
.page-btn,
button {
font-size: 1.6rem;
padding: 0.75rem 1.25rem;
border: 2px solid #000;
background: #fff;
color: #000;
text-decoration: none;
display: inline-block;
cursor: pointer;
}
.page-btn.disabled {
opacity: 0.4;
}
.articles {
list-style: none;
padding: 0;
margin: 0 0 1.5rem 0;
}
.articles li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid #999;
}
.articles .article-title {
flex: 1;
color: #000;
text-decoration: none;
word-break: break-word;
}
.articles .delete {
font-size: 1.4rem;
padding: 0.5rem 0.75rem;
border: 2px solid #000;
text-decoration: none;
color: #000;
}
.empty {
font-style: italic;
color: #666;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.page-info {
font-size: 1.4rem;
}
.confirm {
text-align: center;
}
.confirm .article-title {
font-weight: bold;
margin: 1rem 0 2rem 0;
display: block;
}
.confirm form {
margin-bottom: 1rem;
}
button.danger {
border-color: #c00;
color: #c00;
}
.error p {
margin-bottom: 1.5rem;
}
+77
View File
@@ -0,0 +1,77 @@
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
class Storage:
def __init__(self, data_dir: Path):
self.data_dir = Path(data_dir)
self.articles_dir = self.data_dir / "articles"
self.index_file = self.data_dir / "index.json"
self.articles_dir.mkdir(parents=True, exist_ok=True)
if not self.index_file.exists():
self._write_index({"articles": []})
def _read_index(self) -> dict:
with open(self.index_file) as f:
return json.load(f)
def _write_index(self, data: dict) -> None:
tmp = self.index_file.with_suffix(".json.tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
tmp.replace(self.index_file)
def list_articles(self) -> list[dict]:
return self._read_index()["articles"]
def get_article(self, article_id: str) -> Optional[dict]:
for a in self.list_articles():
if a["id"] == article_id:
return a
return None
def add_article(
self,
article_id: str,
title: str,
url: str,
markdown: str,
epub_bytes: bytes,
) -> None:
folder = self.articles_dir / article_id
folder.mkdir(parents=True, exist_ok=False)
(folder / "source.md").write_text(markdown, encoding="utf-8")
(folder / "article.epub").write_bytes(epub_bytes)
meta = {
"id": article_id,
"url": url,
"title": title,
"fetched_at": _now(),
}
(folder / "meta.json").write_text(json.dumps(meta, indent=2))
index = self._read_index()
index["articles"].insert(0, meta)
self._write_index(index)
def delete_article(self, article_id: str) -> bool:
index = self._read_index()
before = len(index["articles"])
index["articles"] = [a for a in index["articles"] if a["id"] != article_id]
if len(index["articles"]) == before:
return False
self._write_index(index)
folder = self.articles_dir / article_id
if folder.exists():
shutil.rmtree(folder)
return True
def epub_path(self, article_id: str) -> Path:
return self.articles_dir / article_id / "article.epub"
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}kob{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div class="confirm">
<p>Delete this article?</p>
<p class="article-title">{{ article.title }}</p>
<form method="post" action="{{ url_for('delete_do', article_id=article.id) }}">
<input type="hidden" name="page" value="{{ page }}">
<button type="submit" class="danger">Yes, delete</button>
</form>
<a class="page-btn" href="{{ url_for('index', page=page) }}">Cancel</a>
</div>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<div class="error">
<p>{{ message }}</p>
<a class="page-btn" href="{{ url_for('index') }}">Back</a>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<form method="post" action="{{ url_for('add') }}" class="add-form">
<input type="url" name="url" placeholder="https://..." required autofocus>
<button type="submit">Add</button>
</form>
<ul class="articles">
{% for a in articles %}
<li>
<a class="article-title" href="{{ url_for('download', article_id=a.id) }}">{{ a.title }}</a>
<a class="delete" href="{{ url_for('delete_confirm', article_id=a.id, page=page) }}">Delete</a>
</li>
{% else %}
<li class="empty">No articles yet.</li>
{% endfor %}
</ul>
<nav class="pagination">
{% if page > 1 %}
<a class="page-btn" href="{{ url_for('index', page=page-1) }}">&larr; Prev</a>
{% else %}
<span class="page-btn disabled">&larr; Prev</span>
{% endif %}
<span class="page-info">{{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<a class="page-btn" href="{{ url_for('index', page=page+1) }}">Next &rarr;</a>
{% else %}
<span class="page-btn disabled">Next &rarr;</span>
{% endif %}
</nav>
{% endblock %}