Zalohovani vsech podstatnych souboru
This commit is contained in:
91
skills/bookmark/SKILL.md
Normal file
91
skills/bookmark/SKILL.md
Normal 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`
|
||||
226
skills/bookmark/scripts/bookmark.py
Normal file
226
skills/bookmark/scripts/bookmark.py
Normal 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()
|
||||
Reference in New Issue
Block a user