From 56610fa836c3d0b81cced61f4bce05225fa5a9b6 Mon Sep 17 00:00:00 2001 From: kpa39l Date: Tue, 22 Sep 2026 17:39:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=E2=80=94=20=D0=B4=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B3=D0=BE=D0=B4=D0=B0=D0=BC/=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=8F=D1=86=D0=B0=D0=BC=20+=20=D0=BF=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20(openspec=20archive-navi?= =?UTF-8?q?gation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/archive-navigation/.openspec.yaml | 2 + openspec/changes/archive-navigation/design.md | 124 +++++++++++++ .../changes/archive-navigation/proposal.md | 40 +++++ .../specs/archive-navigation/spec.md | 97 ++++++++++ openspec/changes/archive-navigation/tasks.md | 12 ++ web/app.py | 100 ++++++++++- web/templates/published.html | 166 ++++++++++++++---- 7 files changed, 497 insertions(+), 44 deletions(-) create mode 100644 openspec/changes/archive-navigation/.openspec.yaml create mode 100644 openspec/changes/archive-navigation/design.md create mode 100644 openspec/changes/archive-navigation/proposal.md create mode 100644 openspec/changes/archive-navigation/specs/archive-navigation/spec.md create mode 100644 openspec/changes/archive-navigation/tasks.md 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 ▾
` + `