Update projektu
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user