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

@@ -1,6 +1,8 @@
---
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".
description: >
Use when the user wants to save a URL (with or without additional content)
to read later or search in. Triggers on "bookmark", "save URL".
---
# Bookmark
@@ -10,6 +12,7 @@ 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]
```
@@ -21,14 +24,47 @@ bookmark.py add <url> "<description>" [--tags tag1,tag2]
```
- `url` — the article URL
- `description` — short human-readable description (required)
- `description` — short human-readable description (required). If the user did not give one, generate a short (~1 sentence) description yourself — from the article text if you have it, otherwise from the URL.
- `--tags` — optional comma-separated tags
- `--content-file <path>` — optional; path to the cleaned article markdown to archive. `-` reads it from stdin. See "Saving an article's full text" below.
Example:
```bash
bookmark.py add "https://example.com/rust-async" "Async Rust patterns" --tags rust,async
```
### Saving an article's full text
When the user pastes a **large block of text** together with a URL (typically a whole page selected with Ctrl+A/Ctrl+C), treat that text as the **full article to archive**, not as the description. Pick the path by what the pasted content looks like:
**If the pasted content is raw HTML** (you see `<html>`, `<div>`, `<p>` tags, etc.), do **not** convert it yourself — pipe it through the `html_to_markdown.py` helper (it uses trafilatura to strip boilerplate and emit clean markdown) straight into `add`, so the converted text never passes through your context:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/html_to_markdown.py <<'HTML' \
| /home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py add "<url>" "<description>" [--tags a,b] --content-file -
<raw html here>
HTML
```
If `add` does **not** report "article content stored" (trafilatura extracted nothing → empty content), fall back to cleaning the text yourself and storing it as below.
**If the pasted content is plain text or already markdown**, store it directly with `--content-file -` via a heredoc (one call, no shell-escaping of the body). Only clean it yourself **if you see obvious boilerplate** (copied menus, "Share"/"Tweet", cookie banners, footers) — otherwise store it as-is:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py \
add "<url>" "<description>" [--tags a,b] --content-file - <<'ARTICLE'
<article text / markdown here>
ARTICLE
```
In both cases:
- **Generate a description** (~1 sentence) from the article, unless the user gave one.
- **Confirm and echo.** After saving, tell the user it was stored and quote a short **verbatim** slice of what was archived (the title and first line or two, exactly as written) — not a re-summary — so they can see it worked.
**URL without pasted text:** try to fetch the article yourself via the `web` tool (Jina Reader returns clean markdown), then store it the same way. If the page is behind a **paywall** or the fetched text is **garbage/incomplete**, do **not** store that text (never fabricate the article body) — instead **ask the user to copy the whole article (Ctrl+A/Ctrl+C) and paste it**, briefly saying why (paywall / the fetched text looks incomplete). If they paste it, follow the paths above. If they decline or don't reply, save the bookmark without content and mark it `⚠ paywall/incomplete`.
### List unread bookmarks
```bash
@@ -41,7 +77,7 @@ Shows display ID, URL, tags, description, and date added for each unread bookmar
The `#1`, `#2`, … shown by `list` and `history` are **display IDs** — sequential positions, computed on the fly, never the internal DB id. They renumber whenever the set changes, so run `list`/`history` first if unsure.
- `read <n>` and `show <n>` take the display ID from **`list`** (the unread set).
- `read <n>`, `show <n>`, `content <n>`, and `delete <n>` take the display ID from **`list`** (the unread set).
- `unread <n>` takes the display ID from **`history`** (the read set).
A freshly added bookmark is always display `#1` in `list` (newest first).
@@ -68,7 +104,25 @@ bookmark.py unread <display-id>
bookmark.py show <display-id>
```
`<display-id>` is the number from `list`. Shows full URL, description, tags, status, and dates. Does **not** change any state.
`<display-id>` is the number from `list`. Shows full URL, description, tags, status, and dates. A `📄` marker means an archived article body is stored (read it with `content`). Does **not** change any state.
### Read stored article content
```bash
bookmark.py content <display-id>
```
`<display-id>` is the number from `list`. Prints the archived article markdown to stdout — render it for the user. If no full text was stored for that bookmark, it says so. Does **not** change any state.
### Delete a bookmark
```bash
bookmark.py delete <display-id>
```
`<display-id>` is the number from `list` (the unread set). Soft delete: the bookmark drops out of `list`/`history` but the row stays in the DB (recoverable by hand if ever needed). There is no `restore` command.
**Always confirm before deleting.** Display IDs renumber whenever the set changes, so a stale number can point at the wrong bookmark. First run `list`/`show`, tell the user exactly which bookmark you are about to delete (URL + description), and **wait for their explicit confirmation** — only then run `delete`. To delete a bookmark that is already read, `unread` it first (delete works on the unread set only).
### List read bookmarks (history)
@@ -86,17 +140,27 @@ When presenting bookmark lists or details to the user, **always use markdown lin
#3 [hackaday.com](https://hackaday.com/2026/06/02/linux-fu-taming-strace/) [linux, strace] — lepší strace
```
Format: `#<display-id> [<domain>](<url>) [<tags>] — <description>`
Format: `#<display-id> [<domain>](<url>) — <description> [<tags>]`
- Domain is clickable, pointing to the full URL
- Tags in brackets, comma-separated
- Description after em-dash
- Description after em-dash (most important, always shown)
- Tags in brackets, comma-separated (secondary, after description)
- A `📄` in `list`/`history`/`show` marks a bookmark with an archived article body — offer to open it with `content <display-id>`
- **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 <display-id>` (from `list`)
4. User finishes an article`read <display-id>` (from `list`)
5. User wants to revisit → `unread <display-id>` (from `history`) or `history`
2. User pastes a URL **and the full article text** → clean it to markdown and `add … --content-file -` (see "Saving an article's full text")
3. User wants to see what to read → `list`
4. User wants to see details of a bookmark`show <display-id>` (from `list`)
5. User wants to read an archived article → `content <display-id>` (from `list`)
6. User finishes an article → `read <display-id>` (from `list`)
7. User wants to revisit → `unread <display-id>` (from `history`) or `history`
8. User wants to remove one (e.g. accidental duplicate) → confirm, then `delete <display-id>` (from `list`)
## Known limitations
- No `edit`/`update` command — to change a description or tags, delete and re-add
- `delete` is soft-delete only (row stays in DB); no `restore` command
- Sites behind Cloudflare bot protection (PCTuning.cz, vtm.zive.cz, zive.cz) cannot be auto-fetched; ask user to paste full HTML manually

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()

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["trafilatura"]
# ///
"""Extract an article from raw HTML and emit clean markdown (stdin -> stdout).
Used by the bookmark skill so the LLM does not have to convert/clean HTML itself:
html_to_markdown.py <<'HTML' | bookmark.py add "<url>" "<desc>" --content-file -
<raw html>
HTML
trafilatura strips boilerplate (navigation, ads, footers) and returns markdown.
Exit codes: 0 = markdown emitted, 1 = empty input, 2 = no article extracted.
"""
import sys
import trafilatura
EXIT_EMPTY_INPUT = 1
EXIT_NO_ARTICLE = 2
def main() -> int:
html = sys.stdin.read()
if not html.strip():
print("Empty input.", file=sys.stderr)
return EXIT_EMPTY_INPUT
markdown = trafilatura.extract(
html, output_format="markdown", include_links=True, include_images=True
)
if not markdown or not markdown.strip():
print("No article content extracted.", file=sys.stderr)
return EXIT_NO_ARTICLE
print(markdown)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,180 @@
"""Tests for bookmark.py — reading-list CRUD, article-content storage, migration."""
import io
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
import bookmark # noqa: E402
HTML_TO_MARKDOWN = SCRIPTS / "html_to_markdown.py"
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "db" / "bookmark.sqlite"
monkeypatch.setattr(bookmark, "DB_PATH", path)
return path
def _run(argv, stdin=None, monkeypatch=None):
monkeypatch.setattr(sys, "argv", ["bookmark.py"] + argv)
if stdin is not None:
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin))
bookmark.main()
def _capture(capsys):
return capsys.readouterr().out
def test_add_stores_content_from_stdin(db, monkeypatch, capsys):
_run(["add", "https://ex.com/a", "Test", "--content-file", "-"],
stdin="# Title\n\nBody paragraph.\n", monkeypatch=monkeypatch)
assert "article content stored" in _capture(capsys)
conn = sqlite3.connect(db)
content = conn.execute("SELECT content FROM bookmarks").fetchone()[0]
conn.close()
assert content == "# Title\n\nBody paragraph."
def test_add_without_content_leaves_null(db, monkeypatch, capsys):
_run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch)
conn = sqlite3.connect(db)
content = conn.execute("SELECT content FROM bookmarks").fetchone()[0]
conn.close()
assert content is None
def test_content_command_prints_stored_markdown(db, monkeypatch, capsys):
_run(["add", "https://ex.com/a", "Test", "--content-file", "-"],
stdin="# Title\n\nBody.\n", monkeypatch=monkeypatch)
capsys.readouterr()
_run(["content", "1"], monkeypatch=monkeypatch)
assert "# Title\n\nBody." in _capture(capsys)
def test_content_command_reports_empty(db, monkeypatch, capsys):
_run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch)
capsys.readouterr()
_run(["content", "1"], monkeypatch=monkeypatch)
assert "no stored article content" in _capture(capsys)
def test_list_marks_only_rows_with_content(db, monkeypatch, capsys):
_run(["add", "https://ex.com/plain", "Plain"], monkeypatch=monkeypatch)
_run(["add", "https://ex.com/full", "Full", "--content-file", "-"],
stdin="# Doc\n\ntext\n", monkeypatch=monkeypatch)
capsys.readouterr()
_run(["list"], monkeypatch=monkeypatch)
out = _capture(capsys)
# Newest first: #1 is the one WITH content (has 📄), #2 plain (no marker).
lines = [ln for ln in out.splitlines() if ln.startswith("#")]
assert "📄" in lines[0]
assert "📄" not in lines[1]
def test_migration_adds_columns_to_legacy_db(db, monkeypatch, capsys):
db.parent.mkdir(parents=True)
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE 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
);
INSERT INTO bookmarks (url, description, tags, created_at)
VALUES ('https://old.com', 'legacy', '[]', '2026-01-01T00:00:00+00:00');
"""
)
conn.commit()
conn.close()
_run(["list"], monkeypatch=monkeypatch) # triggers migration
assert "legacy" in _capture(capsys)
conn = sqlite3.connect(db)
cols = {row[1] for row in conn.execute("PRAGMA table_info(bookmarks)")}
count = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
conn.close()
assert "content" in cols
assert "deleted_at" in cols
assert count == 1
def test_delete_soft_hides_but_keeps_row(db, monkeypatch, capsys):
_run(["add", "https://ex.com/keep", "Keep"], monkeypatch=monkeypatch)
_run(["add", "https://ex.com/dup", "Dup"], monkeypatch=monkeypatch)
capsys.readouterr()
_run(["delete", "1"], monkeypatch=monkeypatch) # newest (dup) is #1
assert "Deleted bookmark #1" in _capture(capsys)
_run(["list"], monkeypatch=monkeypatch)
out = _capture(capsys)
assert "Keep" in out
assert "Dup" not in out
conn = sqlite3.connect(db)
total = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
deleted = conn.execute(
"SELECT COUNT(*) FROM bookmarks WHERE deleted_at IS NOT NULL"
).fetchone()[0]
conn.close()
assert total == 2 # row kept in DB
assert deleted == 1
def test_delete_out_of_range(db, monkeypatch, capsys):
_run(["add", "https://ex.com/a", "One"], monkeypatch=monkeypatch)
capsys.readouterr()
_run(["delete", "9"], monkeypatch=monkeypatch)
assert "No unread bookmark #9" in _capture(capsys)
ARTICLE_HTML = """<!DOCTYPE html><html><head><title>EFI Boot</title></head><body>
<header><nav><a href="/">Home</a> <a href="/login">Login</a></nav></header>
<aside class="ad">Buy our product now! Special offer!</aside>
<main><article>
<h1>Understanding EFI Boot</h1>
<p>The EFI system partition stores the bootloader and kernel images that the
firmware loads at startup, which is a crucial part of the modern boot process.</p>
<h2>How it works</h2>
<p>When the machine powers on, the firmware reads the EFI variables and locates
the boot entry, then hands control to the kernel without a traditional bootloader.</p>
</article></main>
<footer>Copyright 2026 Example Inc. Share Tweet Subscribe to our newsletter.</footer>
</body></html>"""
def _run_html(html_input: str) -> subprocess.CompletedProcess:
return subprocess.run(
["uv", "run", str(HTML_TO_MARKDOWN)],
input=html_input,
capture_output=True,
text=True,
)
def test_html_to_markdown_extracts_clean_article():
result = _run_html(ARTICLE_HTML)
assert result.returncode == 0, result.stderr
assert "# Understanding EFI Boot" in result.stdout
assert "## How it works" in result.stdout
# Boilerplate must be gone.
assert "Buy our product" not in result.stdout
assert "Subscribe to our newsletter" not in result.stdout
def test_html_to_markdown_empty_input():
result = _run_html("")
assert result.returncode == 1