diff --git a/openspec/changes/archive-navigation/.openspec.yaml b/openspec/changes/archive-navigation/.openspec.yaml new file mode 100644 index 0000000..b0da164 --- /dev/null +++ b/openspec/changes/archive-navigation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 \ No newline at end of file diff --git a/openspec/changes/archive-navigation/design.md b/openspec/changes/archive-navigation/design.md new file mode 100644 index 0000000..bca515c --- /dev/null +++ b/openspec/changes/archive-navigation/design.md @@ -0,0 +1,124 @@ +# Design — archive-navigation + +## Контекст + +- Роут: `GET /published` (`web/app.py:735`), шаблон `web/templates/published.html` (42 строки). +- Дата: `posts.published_at TEXT` — ISO 8601 с таймзоной (`2026-09-08T08:02:45+00:00`). +- Сейчас: `LIMIT 100` без пагинации, без фильтров. +- Фронтенд: локальный Bootstrap (`web/static/bootstrap.min.css`), без JS-фреймворков, + htmx запрещён (AGENT.MD). Паттерн фильтра по направлениям — query-параметры (`_cand_back`). + +## 1. Хелпер дерева навигации + +В `web/app.py`, рядом с `_db()`: + +```python +MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн", + "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек") + +def _archive_tree(conn): + """Дерево навигации архива: [{year, months:[{m, label, n}], n}] + {no_date:n}. + Строится только из существующих периодов; пустые месяцы отсутствуют.""" + rows = conn.execute( + """SELECT strftime('%Y', published_at) y, + strftime('%m', published_at) m, COUNT(*) n + FROM posts WHERE status='published' AND published_at IS NOT NULL + AND published_at != '' + GROUP BY y, m ORDER BY y DESC, m DESC""" + ).fetchall() + years, cur = [], None + for r in rows: + if cur is None or cur["year"] != r["y"]: + cur = {"year": r["y"], "months": [], "n": 0} + years.append(cur) + cur["months"].append({"m": r["m"], "label": f"{MONTH_NAMES_RU[int(r['m'])]} {r['y']}", + "n": r["n"]}) + cur["n"] += r["n"] + no_date = conn.execute( + """SELECT COUNT(*) FROM posts + WHERE status='published' AND (published_at IS NULL OR published_at='')""" + ).fetchone()[0] + return {"years": years, "no_date": no_date} +``` + +- Сортировка: годы и месяцы — по убыванию (свежие сверху, Q9). +- Пустой период исключается самой группировкой `GROUP BY` — ровно как Hugo-архивы + (`site.RegularPages | groupBy "Date.Year"`), где календарь не итерируется (Q8). + +## 2. Роут `GET /published` + +```python +ARCHIVE_PAGE_SIZE = 10 + +@app.get("/published", response_class=HTMLResponse) +def published(request: Request, year: str = "", month: str = "", page: int = 1): + _require_auth(request) + page = max(1, page) + conn = _db() + tree = _archive_tree(conn) + + where, params = ["p.status='published'"], [] + if year: + where.append("strftime('%Y', p.published_at) = ?"); params.append(year) + if month: + where.append("strftime('%m', p.published_at) = ?"); params.append(month) + # год без месяца = все посты за год; "Без даты" не попадает в year/month + if not year and not month: + where.append("(p.published_at IS NOT NULL AND p.published_at != '')") + + total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {' AND '.join(where)}", + params).fetchone()[0] + pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE) + page = min(page, pages) + + rows = conn.execute( + f"""SELECT p.*, s.name source_name, + pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs + FROM posts p + LEFT JOIN sources s ON s.id=p.source_id + LEFT JOIN published pub ON pub.post_id=p.id + WHERE {' AND '.join(where)} + ORDER BY p.published_at DESC LIMIT ? OFFSET ?""", + params + [ARCHIVE_PAGE_SIZE, (page - 1) * ARCHIVE_PAGE_SIZE] + ).fetchall() + conn.close() + + html = tpl.get_template("published.html").render( + posts=rows, tree=tree, year=year, month=month, + page=page, pages=pages, total=total, + ) + return HTMLResponse(html) +``` + +- Блок «Без даты» показывается как ссылка `/?year=&month=`? Нет — отдельный + фильтр: кнопка «Без даты» (query `nodate=1`), тоже с деревом и пагинацией. + (Добавлено в spec ниже — сценарий «Без даты»). + +## 3. Шаблон `published.html` + +- Обёртка: `row` → `col-lg-9` (список) + `col-lg-3` (дерево, `position-sticky top-2`). +- Дерево: + - ссылка «📄 Все посты» → `/published` (активна, если нет year/month/nodate). + - по годам: `
2026 ▾
` + `