Zalohovani vsech podstatnych souboru

This commit is contained in:
lachtan
2026-06-10 06:39:52 +02:00
parent 1e10891945
commit 67e29c8b88
69 changed files with 9115 additions and 0 deletions

91
skills/bookmark/SKILL.md Normal file
View File

@@ -0,0 +1,91 @@
---
name: bookmark
description: Manage a personal reading list. Use when the user wants to save, list, mark as read, or remove article URLs for later reading. Triggers on "bookmark", "save URL", "read later", "reading list", "bookmarks".
---
# Bookmark
Manage a personal reading list stored in SQLite (`db/bookmark.sqlite`).
## Commands
All commands run via:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py <command> [args]
```
### Add a bookmark
```bash
bookmark.py add <url> "<description>" [--tags tag1,tag2]
```
- `url` — the article URL
- `description` — short human-readable description (required)
- `--tags` — optional comma-separated tags
Example:
```bash
bookmark.py add "https://example.com/rust-async" "Async Rust patterns" --tags rust,async
```
### List unread bookmarks
```bash
bookmark.py list [--tag <tag>]
```
Shows ID, URL, tags, description, and date added for each unread bookmark. Use `--tag` to filter.
### Mark as read
```bash
bookmark.py read <id>
```
Marks bookmark as read (stores `read_at` timestamp). Does **not** delete — entry stays in DB.
### Unmark (mark as unread again)
```bash
bookmark.py unread <id>
```
### Show bookmark details
```bash
bookmark.py show <id>
```
Shows full URL, description, tags, status (read/unread), and dates. Does **not** change any state.
### List read bookmarks (history)
```bash
bookmark.py history
```
Shows all bookmarks marked as read, with both `added` and `read` dates.
## Output formatting
When presenting bookmark lists or details to the user, **always use markdown links** so URLs are clickable in WebUI and Telegram:
```
#3 [hackaday.com](https://hackaday.com/2026/06/02/linux-fu-taming-strace/) [linux, strace] — lepší strace
```
Format: `#<id> [<domain>](<url>) [<tags>] — <description>`
- Domain is clickable, pointing to the full URL
- Tags in brackets, comma-separated
- Description after em-dash
- **Never** strip URLs from the output or replace them with plain-text summaries
## Workflow
1. User shares a URL → `add` with description and optional tags
2. User wants to see what to read → `list`
3. User wants to see details of a bookmark → `show <id>`
4. User finishes an article → `read <id>`
5. User wants to revisit → `unread <id>` or `history`

View File

@@ -0,0 +1,226 @@
#!/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()