Update projektu

This commit is contained in:
lachtan
2026-07-22 12:32:02 +02:00
parent 19014ed3d9
commit 8e66d6b92a
22 changed files with 1995 additions and 503 deletions

View File

@@ -4,6 +4,7 @@
import argparse
import json
import sqlite3
import sys
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
@@ -22,7 +23,9 @@ CREATE TABLE IF NOT EXISTS bookmarks (
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
read_at TEXT
read_at TEXT,
content TEXT,
deleted_at TEXT
);
"""
@@ -30,6 +33,16 @@ CREATE TABLE IF NOT EXISTS bookmarks (
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
_migrate(conn)
def _migrate(conn: sqlite3.Connection) -> None:
"""Add columns missing from a pre-existing DB (CREATE IF NOT EXISTS won't)."""
columns = {row["name"] for row in conn.execute("PRAGMA table_info(bookmarks)")}
if "content" not in columns:
conn.execute("ALTER TABLE bookmarks ADD COLUMN content TEXT")
if "deleted_at" not in columns:
conn.execute("ALTER TABLE bookmarks ADD COLUMN deleted_at TEXT")
@contextmanager
@@ -49,15 +62,16 @@ def _ordered_ids(conn: sqlite3.Connection, *, read: bool) -> list[int]:
Unread (`read=False`) is what `list` shows, read (`read=True`) what `history`
shows. Display IDs are 1-based positions here, computed on the fly — never
stored — so they renumber whenever the set changes.
stored — so they renumber whenever the set changes. Soft-deleted rows
(`deleted_at` set) are excluded from both sets.
"""
if read:
rows = conn.execute(
"SELECT id FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
"SELECT id FROM bookmarks WHERE read_at IS NOT NULL AND deleted_at IS NULL ORDER BY read_at DESC"
).fetchall()
else:
rows = conn.execute(
"SELECT id FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
"SELECT id FROM bookmarks WHERE read_at IS NULL AND deleted_at IS NULL ORDER BY created_at DESC"
).fetchall()
return [row["id"] for row in rows]
@@ -94,13 +108,27 @@ def _domain(url: str) -> str:
return url
def _read_content(content_file: str | None) -> str | None:
"""Read cleaned article markdown from a file path, or stdin when path is '-'."""
if content_file is None:
return None
text = sys.stdin.read() if content_file == "-" else Path(content_file).read_text(encoding="utf-8")
return text.strip() or None
def _print_bookmark(
row: sqlite3.Row, display_id: int, *, show_status: bool = False, show_read_date: bool = False
row: sqlite3.Row,
display_id: int,
*,
show_status: bool = False,
show_read_date: bool = False,
has_content: bool = False,
) -> None:
"""Format and print a single bookmark row under its display ID."""
tags = json.loads(row["tags"])
tag_str = f" [{', '.join(tags)}]" if tags else ""
print(f"#{display_id} {_domain(row['url'])}{tag_str}")
content_marker = " 📄" if has_content else ""
print(f"#{display_id} {_domain(row['url'])}{tag_str}{content_marker}")
print(f" {row['description']}")
print(f" {row['url']}")
line = f" added: {row['created_at'][:10]}"
@@ -114,25 +142,29 @@ def _print_bookmark(
def cmd_add(args: argparse.Namespace) -> None:
tags = _parse_tags(args.tags)
content = _read_content(args.content_file)
with _connect() as conn:
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO bookmarks (url, description, tags, created_at) VALUES (?, ?, ?, ?)",
(args.url, args.description, json.dumps(tags, ensure_ascii=False), now),
"INSERT INTO bookmarks (url, description, tags, created_at, content) VALUES (?, ?, ?, ?, ?)",
(args.url, args.description, json.dumps(tags, ensure_ascii=False), now, content),
)
conn.commit()
tag_info = f" [{', '.join(tags)}]" if tags else ""
content_info = " (article content stored)" if content else ""
# Newest unread sorts first, so a fresh bookmark is always display #1.
print(f"Added bookmark #1: {args.url}{tag_info}")
print(f"Added bookmark #1: {args.url}{tag_info}{content_info}")
def cmd_list(args: argparse.Namespace) -> None:
# Skip the (potentially large) content blob here — only whether it exists.
columns = "id, url, description, tags, created_at, read_at, content IS NOT NULL AS has_content"
with _connect() as conn:
display_by_id = {nid: i + 1 for i, nid in enumerate(_ordered_ids(conn, read=False))}
if args.tag:
rows = conn.execute(
"""SELECT * FROM bookmarks
WHERE read_at IS NULL AND EXISTS (
f"""SELECT {columns} FROM bookmarks
WHERE read_at IS NULL AND deleted_at IS NULL AND EXISTS (
SELECT 1 FROM json_each(tags) WHERE json_each.value = ?
)
ORDER BY created_at DESC""",
@@ -140,7 +172,7 @@ def cmd_list(args: argparse.Namespace) -> None:
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
f"SELECT {columns} FROM bookmarks WHERE read_at IS NULL AND deleted_at IS NULL ORDER BY created_at DESC"
).fetchall()
if not rows:
@@ -152,7 +184,7 @@ def cmd_list(args: argparse.Namespace) -> None:
# Display IDs come from the full unread set so a tag-filtered list keeps the
# same numbers `read`/`show` resolve against (gaps are expected when filtered).
for r in rows:
_print_bookmark(r, display_by_id[r["id"]])
_print_bookmark(r, display_by_id[r["id"]], has_content=bool(r["has_content"]))
print()
@@ -186,13 +218,41 @@ def cmd_show(args: argparse.Namespace) -> None:
print(f"No unread bookmark #{args.id}.")
return
row = conn.execute("SELECT * FROM bookmarks WHERE id = ?", (internal_id,)).fetchone()
_print_bookmark(row, args.id, show_status=True)
_print_bookmark(row, args.id, show_status=True, has_content=row["content"] is not None)
def cmd_content(args: argparse.Namespace) -> None:
with _connect() as conn:
internal_id = _resolve_display_id(conn, args.id, read=False)
if internal_id is None:
print(f"No unread bookmark #{args.id}.")
return
row = conn.execute("SELECT content FROM bookmarks WHERE id = ?", (internal_id,)).fetchone()
if not row["content"]:
print(f"Bookmark #{args.id} has no stored article content.")
return
print(row["content"])
def cmd_delete(args: argparse.Namespace) -> None:
# Soft delete: set deleted_at so the row drops out of list/history but stays
# in the DB. The agent confirms with the user before calling this (see SKILL.md).
with _connect() as conn:
internal_id = _resolve_display_id(conn, args.id, read=False)
if internal_id is None:
print(f"No unread bookmark #{args.id}.")
return
now = datetime.now(timezone.utc).isoformat()
conn.execute("UPDATE bookmarks SET deleted_at = ? WHERE id = ?", (now, internal_id))
conn.commit()
print(f"Deleted bookmark #{args.id}.")
def cmd_history(args: argparse.Namespace) -> None:
columns = "id, url, description, tags, created_at, read_at, content IS NOT NULL AS has_content"
with _connect() as conn:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
f"SELECT {columns} FROM bookmarks WHERE read_at IS NOT NULL AND deleted_at IS NULL ORDER BY read_at DESC"
).fetchall()
if not rows:
@@ -200,7 +260,7 @@ def cmd_history(args: argparse.Namespace) -> None:
return
for display_id, r in enumerate(rows, start=1):
_print_bookmark(r, display_id, show_read_date=True)
_print_bookmark(r, display_id, show_read_date=True, has_content=bool(r["has_content"]))
print()
@@ -213,6 +273,12 @@ def main() -> None:
p_add.add_argument("url", help="URL to bookmark")
p_add.add_argument("description", help="Short description")
p_add.add_argument("--tags", default="", help="Comma-separated tags")
p_add.add_argument(
"--content-file",
dest="content_file",
default=None,
help="Path to cleaned article markdown; '-' reads it from stdin",
)
# list
p_list = sub.add_parser("list", help="List unread bookmarks")
@@ -230,6 +296,14 @@ def main() -> None:
p_show = sub.add_parser("show", help="Show bookmark details")
p_show.add_argument("id", type=int, help="Display ID from `list`")
# content (print stored article markdown)
p_content = sub.add_parser("content", help="Print stored article markdown")
p_content.add_argument("id", type=int, help="Display ID from `list`")
# delete (soft delete)
p_delete = sub.add_parser("delete", help="Soft-delete a bookmark (hidden, kept in DB)")
p_delete.add_argument("id", type=int, help="Display ID from `list`")
# history (list read)
sub.add_parser("history", help="List read bookmarks")
@@ -239,6 +313,8 @@ def main() -> None:
"read": cmd_read,
"unread": cmd_unread,
"show": cmd_show,
"content": cmd_content,
"delete": cmd_delete,
"history": cmd_history,
}
args = parser.parse_args()