Update projektu
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
43
skills/bookmark/scripts/html_to_markdown.py
Normal file
43
skills/bookmark/scripts/html_to_markdown.py
Normal 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())
|
||||
180
skills/bookmark/tests/test_bookmark.py
Normal file
180
skills/bookmark/tests/test_bookmark.py
Normal 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
|
||||
Reference in New Issue
Block a user