Files
nanobot-runtime/skills/bookmark/scripts/bookmark.py
2026-06-10 06:39:52 +02:00

227 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Bookmark skill — CRUD for reading-list entries stored in SQLite."""
import argparse
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
DB_PATH = (
Path(__file__).resolve().parent.parent.parent.parent / "db" / "bookmark.sqlite"
)
EMPTY_TAGS_JSON = "[]"
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
read_at TEXT
);
"""
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
@contextmanager
def _connect() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
_init_db(conn)
try:
yield conn
finally:
conn.close()
def _parse_tags(raw: str) -> list[str]:
"""Parse comma-separated tags into a deduplicated sorted list."""
if not raw:
return []
tags = [t.strip() for t in raw.split(",") if t.strip()]
return sorted(set(tags))
def _tags_display(tags_json: str) -> str:
tags = json.loads(tags_json)
return ", ".join(tags) if tags else ""
def _domain(url: str) -> str:
"""Extract domain from URL (strip www. prefix)."""
try:
parsed = urlparse(url)
host = parsed.hostname or ""
return host.removeprefix("www.")
except (ValueError, AttributeError):
return url
def _print_bookmark(
row: sqlite3.Row, *, show_status: bool = False, show_read_date: bool = False
) -> None:
"""Format and print a single bookmark row."""
tags = json.loads(row["tags"])
tag_str = f" [{', '.join(tags)}]" if tags else ""
print(f"#{row['id']} {_domain(row['url'])}{tag_str}")
print(f" {row['description']}")
print(f" {row['url']}")
line = f" added: {row['created_at'][:10]}"
if show_status:
status = "read" if row["read_at"] else "unread"
line += f" status: {status}"
if show_read_date and row["read_at"]:
line += f" read: {row['read_at'][:10]}"
print(line)
def cmd_add(args: argparse.Namespace) -> None:
tags = _parse_tags(args.tags)
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),
)
conn.commit()
bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
tag_info = f" [{', '.join(tags)}]" if tags else ""
print(f"Added bookmark #{bid}: {args.url}{tag_info}")
def cmd_list(args: argparse.Namespace) -> None:
with _connect() as conn:
if args.tag:
rows = conn.execute(
"""SELECT * FROM bookmarks
WHERE read_at IS NULL AND EXISTS (
SELECT 1 FROM json_each(tags) WHERE json_each.value = ?
)
ORDER BY created_at DESC""",
(args.tag,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
).fetchall()
if not rows:
print(
"No bookmarks." if not args.tag else f"No bookmarks with tag '{args.tag}'."
)
return
for r in rows:
_print_bookmark(r)
print()
def cmd_read(args: argparse.Namespace) -> None:
with _connect() as conn:
now = datetime.now(timezone.utc).isoformat()
cur = conn.execute(
"UPDATE bookmarks SET read_at = ? WHERE id = ? AND read_at IS NULL",
(now, args.id),
)
affected = cur.rowcount
conn.commit()
if affected == 0:
print(f"Bookmark #{args.id} not found or already marked as read.")
else:
print(f"Marked bookmark #{args.id} as read.")
def cmd_unread(args: argparse.Namespace) -> None:
with _connect() as conn:
cur = conn.execute(
"UPDATE bookmarks SET read_at = NULL WHERE id = ? AND read_at IS NOT NULL",
(args.id,),
)
affected = cur.rowcount
conn.commit()
if affected == 0:
print(f"Bookmark #{args.id} not found or not marked as read.")
else:
print(f"Unmarked bookmark #{args.id}.")
def cmd_show(args: argparse.Namespace) -> None:
with _connect() as conn:
row = conn.execute(
"SELECT * FROM bookmarks WHERE id = ?", (args.id,)
).fetchone()
if not row:
print(f"Bookmark #{args.id} not found.")
return
_print_bookmark(row, show_status=True)
def cmd_history(args: argparse.Namespace) -> None:
with _connect() as conn:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
).fetchall()
if not rows:
print("No read bookmarks.")
return
for r in rows:
_print_bookmark(r, show_read_date=True)
print()
def main() -> None:
parser = argparse.ArgumentParser(description="Bookmark CRUD")
sub = parser.add_subparsers(dest="command", required=True)
# add
p_add = sub.add_parser("add", help="Add a bookmark")
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")
# list
p_list = sub.add_parser("list", help="List unread bookmarks")
p_list.add_argument("--tag", help="Filter by tag (exact match)")
# read (mark as read)
p_read = sub.add_parser("read", help="Mark bookmark as read")
p_read.add_argument("id", type=int, help="Bookmark ID")
# unread (unmark)
p_unread = sub.add_parser("unread", help="Unmark bookmark as read")
p_unread.add_argument("id", type=int, help="Bookmark ID")
# show (display details)
p_show = sub.add_parser("show", help="Show bookmark details")
p_show.add_argument("id", type=int, help="Bookmark ID")
# history (list read)
sub.add_parser("history", help="List read bookmarks")
dispatch = {
"add": cmd_add,
"list": cmd_list,
"read": cmd_read,
"unread": cmd_unread,
"show": cmd_show,
"history": cmd_history,
}
args = parser.parse_args()
dispatch[args.command](args)
if __name__ == "__main__":
main()