linkmark 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkmark-1.0.0/LICENSE +9 -0
- linkmark-1.0.0/PKG-INFO +71 -0
- linkmark-1.0.0/README.md +60 -0
- linkmark-1.0.0/linkmark.egg-info/PKG-INFO +71 -0
- linkmark-1.0.0/linkmark.egg-info/SOURCES.txt +9 -0
- linkmark-1.0.0/linkmark.egg-info/dependency_links.txt +1 -0
- linkmark-1.0.0/linkmark.egg-info/entry_points.txt +2 -0
- linkmark-1.0.0/linkmark.egg-info/top_level.txt +1 -0
- linkmark-1.0.0/linkmark.py +490 -0
- linkmark-1.0.0/pyproject.toml +19 -0
- linkmark-1.0.0/setup.cfg +4 -0
linkmark-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Joye
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
linkmark-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkmark
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Annotated bookmark CLI — save links with mandatory context
|
|
5
|
+
Author: linkmark contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# linkmark — Annotated Bookmark CLI
|
|
13
|
+
|
|
14
|
+
Save links with mandatory context — because bookmarks are useless without the "why."
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Clone or copy the file
|
|
20
|
+
cp linkmark.py /usr/local/bin/linkmark
|
|
21
|
+
chmod +x /usr/local/bin/linkmark
|
|
22
|
+
|
|
23
|
+
# Or run directly
|
|
24
|
+
python3 linkmark.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Requirements:** Python 3.11+, zero external dependencies (stdlib only).
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Add a bookmark (note is mandatory)
|
|
33
|
+
linkmark add "https://docs.python.org/3/library/sqlite3.html" "Python sqlite3 docs — FTS5 reference" --tag python --tag docs
|
|
34
|
+
|
|
35
|
+
# List recent bookmarks
|
|
36
|
+
linkmark list
|
|
37
|
+
|
|
38
|
+
# Search
|
|
39
|
+
linkmark search "python"
|
|
40
|
+
|
|
41
|
+
# Export
|
|
42
|
+
linkmark export --format markdown > bookmarks.md
|
|
43
|
+
linkmark export --format json > bookmarks.json
|
|
44
|
+
|
|
45
|
+
# Stats
|
|
46
|
+
linkmark stats
|
|
47
|
+
|
|
48
|
+
# List tags
|
|
49
|
+
linkmark tags
|
|
50
|
+
|
|
51
|
+
# Delete
|
|
52
|
+
linkmark delete 1
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
| Command | Description |
|
|
58
|
+
|---------|-------------|
|
|
59
|
+
| `add <url> <note> [--tag TAG ...]` | Save a bookmark with mandatory annotation |
|
|
60
|
+
| `list [--page N] [--limit N]` | List recent bookmarks, newest first |
|
|
61
|
+
| `search <query> [--limit N]` | Full-text search (FTS5, falls back to LIKE) |
|
|
62
|
+
| `export [--format markdown\|json]` | Export all bookmarks |
|
|
63
|
+
| `stats` | Show total, this week, top tags |
|
|
64
|
+
| `delete <id>` | Remove a bookmark by ID |
|
|
65
|
+
| `tags` | List all tags with counts |
|
|
66
|
+
|
|
67
|
+
## Storage
|
|
68
|
+
|
|
69
|
+
- SQLite database at `~/.local/share/linkmark/bookmarks.db`
|
|
70
|
+
- FTS5 virtual table for full-text search (auto-created if available)
|
|
71
|
+
- Tags stored in normalized many-to-many tables
|
linkmark-1.0.0/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# linkmark — Annotated Bookmark CLI
|
|
2
|
+
|
|
3
|
+
Save links with mandatory context — because bookmarks are useless without the "why."
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Clone or copy the file
|
|
9
|
+
cp linkmark.py /usr/local/bin/linkmark
|
|
10
|
+
chmod +x /usr/local/bin/linkmark
|
|
11
|
+
|
|
12
|
+
# Or run directly
|
|
13
|
+
python3 linkmark.py
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Requirements:** Python 3.11+, zero external dependencies (stdlib only).
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Add a bookmark (note is mandatory)
|
|
22
|
+
linkmark add "https://docs.python.org/3/library/sqlite3.html" "Python sqlite3 docs — FTS5 reference" --tag python --tag docs
|
|
23
|
+
|
|
24
|
+
# List recent bookmarks
|
|
25
|
+
linkmark list
|
|
26
|
+
|
|
27
|
+
# Search
|
|
28
|
+
linkmark search "python"
|
|
29
|
+
|
|
30
|
+
# Export
|
|
31
|
+
linkmark export --format markdown > bookmarks.md
|
|
32
|
+
linkmark export --format json > bookmarks.json
|
|
33
|
+
|
|
34
|
+
# Stats
|
|
35
|
+
linkmark stats
|
|
36
|
+
|
|
37
|
+
# List tags
|
|
38
|
+
linkmark tags
|
|
39
|
+
|
|
40
|
+
# Delete
|
|
41
|
+
linkmark delete 1
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---------|-------------|
|
|
48
|
+
| `add <url> <note> [--tag TAG ...]` | Save a bookmark with mandatory annotation |
|
|
49
|
+
| `list [--page N] [--limit N]` | List recent bookmarks, newest first |
|
|
50
|
+
| `search <query> [--limit N]` | Full-text search (FTS5, falls back to LIKE) |
|
|
51
|
+
| `export [--format markdown\|json]` | Export all bookmarks |
|
|
52
|
+
| `stats` | Show total, this week, top tags |
|
|
53
|
+
| `delete <id>` | Remove a bookmark by ID |
|
|
54
|
+
| `tags` | List all tags with counts |
|
|
55
|
+
|
|
56
|
+
## Storage
|
|
57
|
+
|
|
58
|
+
- SQLite database at `~/.local/share/linkmark/bookmarks.db`
|
|
59
|
+
- FTS5 virtual table for full-text search (auto-created if available)
|
|
60
|
+
- Tags stored in normalized many-to-many tables
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkmark
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Annotated bookmark CLI — save links with mandatory context
|
|
5
|
+
Author: linkmark contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# linkmark — Annotated Bookmark CLI
|
|
13
|
+
|
|
14
|
+
Save links with mandatory context — because bookmarks are useless without the "why."
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Clone or copy the file
|
|
20
|
+
cp linkmark.py /usr/local/bin/linkmark
|
|
21
|
+
chmod +x /usr/local/bin/linkmark
|
|
22
|
+
|
|
23
|
+
# Or run directly
|
|
24
|
+
python3 linkmark.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Requirements:** Python 3.11+, zero external dependencies (stdlib only).
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Add a bookmark (note is mandatory)
|
|
33
|
+
linkmark add "https://docs.python.org/3/library/sqlite3.html" "Python sqlite3 docs — FTS5 reference" --tag python --tag docs
|
|
34
|
+
|
|
35
|
+
# List recent bookmarks
|
|
36
|
+
linkmark list
|
|
37
|
+
|
|
38
|
+
# Search
|
|
39
|
+
linkmark search "python"
|
|
40
|
+
|
|
41
|
+
# Export
|
|
42
|
+
linkmark export --format markdown > bookmarks.md
|
|
43
|
+
linkmark export --format json > bookmarks.json
|
|
44
|
+
|
|
45
|
+
# Stats
|
|
46
|
+
linkmark stats
|
|
47
|
+
|
|
48
|
+
# List tags
|
|
49
|
+
linkmark tags
|
|
50
|
+
|
|
51
|
+
# Delete
|
|
52
|
+
linkmark delete 1
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
| Command | Description |
|
|
58
|
+
|---------|-------------|
|
|
59
|
+
| `add <url> <note> [--tag TAG ...]` | Save a bookmark with mandatory annotation |
|
|
60
|
+
| `list [--page N] [--limit N]` | List recent bookmarks, newest first |
|
|
61
|
+
| `search <query> [--limit N]` | Full-text search (FTS5, falls back to LIKE) |
|
|
62
|
+
| `export [--format markdown\|json]` | Export all bookmarks |
|
|
63
|
+
| `stats` | Show total, this week, top tags |
|
|
64
|
+
| `delete <id>` | Remove a bookmark by ID |
|
|
65
|
+
| `tags` | List all tags with counts |
|
|
66
|
+
|
|
67
|
+
## Storage
|
|
68
|
+
|
|
69
|
+
- SQLite database at `~/.local/share/linkmark/bookmarks.db`
|
|
70
|
+
- FTS5 virtual table for full-text search (auto-created if available)
|
|
71
|
+
- Tags stored in normalized many-to-many tables
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linkmark
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""linkmark — Annotated Bookmark CLI for Developers & Researchers.
|
|
3
|
+
|
|
4
|
+
Save links with mandatory context — because bookmarks are useless without the "why."
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
linkmark add <url> <note> [--tag TAG ...]
|
|
8
|
+
linkmark list [--page N] [--limit N]
|
|
9
|
+
linkmark search <query> [--limit N]
|
|
10
|
+
linkmark export [--format markdown|json]
|
|
11
|
+
linkmark stats
|
|
12
|
+
linkmark delete <id>
|
|
13
|
+
linkmark tags
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sqlite3
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Database helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
DEFAULT_DB = Path.home() / ".local" / "share" / "linkmark" / "bookmarks.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_db_path() -> Path:
|
|
32
|
+
"""Return the database path, creating parent dirs if needed."""
|
|
33
|
+
db_path = DEFAULT_DB
|
|
34
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return db_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_connection(db_path: Path | None = None) -> sqlite3.Connection:
|
|
39
|
+
"""Open a connection and ensure schema exists."""
|
|
40
|
+
path = db_path or get_db_path()
|
|
41
|
+
conn = sqlite3.connect(str(path))
|
|
42
|
+
conn.row_factory = sqlite3.Row
|
|
43
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
44
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
45
|
+
_ensure_schema(conn)
|
|
46
|
+
_ensure_fts(conn)
|
|
47
|
+
return conn
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
51
|
+
"""Create tables if they don't exist."""
|
|
52
|
+
conn.executescript("""
|
|
53
|
+
CREATE TABLE IF NOT EXISTS bookmarks (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
url TEXT NOT NULL,
|
|
56
|
+
note TEXT NOT NULL DEFAULT '',
|
|
57
|
+
created_at TEXT NOT NULL,
|
|
58
|
+
updated_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
name TEXT NOT NULL UNIQUE
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS bookmark_tags (
|
|
67
|
+
bookmark_id INTEGER NOT NULL REFERENCES bookmarks(id) ON DELETE CASCADE,
|
|
68
|
+
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
69
|
+
PRIMARY KEY (bookmark_id, tag_id)
|
|
70
|
+
);
|
|
71
|
+
""")
|
|
72
|
+
conn.commit()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ensure_fts(conn: sqlite3.Connection) -> None:
|
|
76
|
+
"""Create FTS5 virtual table if FTS5 is available; silently skip otherwise."""
|
|
77
|
+
try:
|
|
78
|
+
conn.execute("""
|
|
79
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
|
|
80
|
+
url, note,
|
|
81
|
+
content='bookmarks',
|
|
82
|
+
content_rowid='id'
|
|
83
|
+
)
|
|
84
|
+
""")
|
|
85
|
+
# Triggers to keep FTS index in sync
|
|
86
|
+
conn.executescript("""
|
|
87
|
+
CREATE TRIGGER IF NOT EXISTS bookmarks_ai AFTER INSERT ON bookmarks BEGIN
|
|
88
|
+
INSERT INTO bookmarks_fts(rowid, url, note)
|
|
89
|
+
VALUES (new.id, new.url, new.note);
|
|
90
|
+
END;
|
|
91
|
+
CREATE TRIGGER IF NOT EXISTS bookmarks_ad AFTER DELETE ON bookmarks BEGIN
|
|
92
|
+
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, url, note)
|
|
93
|
+
VALUES('delete', old.id, old.url, old.note);
|
|
94
|
+
END;
|
|
95
|
+
CREATE TRIGGER IF NOT EXISTS bookmarks_au AFTER UPDATE ON bookmarks BEGIN
|
|
96
|
+
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, url, note)
|
|
97
|
+
VALUES('delete', old.id, old.url, old.note);
|
|
98
|
+
INSERT INTO bookmarks_fts(rowid, url, note)
|
|
99
|
+
VALUES (new.id, new.url, new.note);
|
|
100
|
+
END;
|
|
101
|
+
""")
|
|
102
|
+
conn.commit()
|
|
103
|
+
except sqlite3.OperationalError:
|
|
104
|
+
# FTS5 not available — silently fall back to LIKE search
|
|
105
|
+
conn.rollback()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fts_available(conn: sqlite3.Connection) -> bool:
|
|
109
|
+
"""Check whether the FTS table exists."""
|
|
110
|
+
row = conn.execute(
|
|
111
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='bookmarks_fts'"
|
|
112
|
+
).fetchone()
|
|
113
|
+
return row is not None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Time helpers
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _now() -> str:
|
|
121
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _ts(iso_str: str) -> datetime:
|
|
125
|
+
return datetime.fromisoformat(iso_str)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _human_time(iso_str: str) -> str:
|
|
129
|
+
"""Convert ISO timestamp to a human-readable relative string."""
|
|
130
|
+
dt = _ts(iso_str)
|
|
131
|
+
now = datetime.now(timezone.utc)
|
|
132
|
+
delta = now - dt
|
|
133
|
+
if delta < timedelta(minutes=1):
|
|
134
|
+
return "just now"
|
|
135
|
+
if delta < timedelta(hours=1):
|
|
136
|
+
m = delta.seconds // 60
|
|
137
|
+
return f"{m}m ago"
|
|
138
|
+
if delta < timedelta(days=1):
|
|
139
|
+
h = delta.seconds // 3600
|
|
140
|
+
return f"{h}h ago"
|
|
141
|
+
if delta < timedelta(days=7):
|
|
142
|
+
return f"{delta.days}d ago"
|
|
143
|
+
return dt.strftime("%Y-%m-%d")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Commands
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def cmd_add(args: argparse.Namespace) -> None:
|
|
151
|
+
"""Add a bookmark."""
|
|
152
|
+
conn = get_connection()
|
|
153
|
+
now = _now()
|
|
154
|
+
cursor = conn.execute(
|
|
155
|
+
"INSERT INTO bookmarks (url, note, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
156
|
+
(args.url, args.note, now, now),
|
|
157
|
+
)
|
|
158
|
+
bookmark_id = cursor.lastrowid
|
|
159
|
+
|
|
160
|
+
# Handle tags
|
|
161
|
+
if args.tag:
|
|
162
|
+
for tag_name in args.tag:
|
|
163
|
+
tag_name = tag_name.lower().strip()
|
|
164
|
+
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
|
|
165
|
+
tag_id = conn.execute(
|
|
166
|
+
"SELECT id FROM tags WHERE name = ?", (tag_name,)
|
|
167
|
+
).fetchone()["id"]
|
|
168
|
+
conn.execute(
|
|
169
|
+
"INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id) VALUES (?, ?)",
|
|
170
|
+
(bookmark_id, tag_id),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
conn.commit()
|
|
174
|
+
|
|
175
|
+
# Show tags in output
|
|
176
|
+
tags_display = ""
|
|
177
|
+
if args.tag:
|
|
178
|
+
tags_display = " [" + ", ".join(args.tag) + "]"
|
|
179
|
+
|
|
180
|
+
print(f" #{bookmark_id} {args.url}{tags_display}")
|
|
181
|
+
print(f" {args.note}")
|
|
182
|
+
conn.close()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
186
|
+
"""List recent bookmarks."""
|
|
187
|
+
conn = get_connection()
|
|
188
|
+
limit = args.limit or 20
|
|
189
|
+
offset = (args.page - 1) * limit if args.page > 1 else 0
|
|
190
|
+
|
|
191
|
+
rows = conn.execute(
|
|
192
|
+
"""
|
|
193
|
+
SELECT b.id, b.url, b.note, b.created_at,
|
|
194
|
+
(SELECT GROUP_CONCAT(t2.name, ', ')
|
|
195
|
+
FROM bookmark_tags bt2
|
|
196
|
+
JOIN tags t2 ON t2.id = bt2.tag_id
|
|
197
|
+
WHERE bt2.bookmark_id = b.id) AS tags
|
|
198
|
+
FROM bookmarks b
|
|
199
|
+
ORDER BY b.created_at DESC, b.id DESC
|
|
200
|
+
LIMIT ? OFFSET ?
|
|
201
|
+
""",
|
|
202
|
+
(limit, offset),
|
|
203
|
+
).fetchall()
|
|
204
|
+
|
|
205
|
+
if not rows:
|
|
206
|
+
print(" No bookmarks yet. linkmark add <url> \"your note\"")
|
|
207
|
+
conn.close()
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
total = conn.execute("SELECT COUNT(*) AS n FROM bookmarks").fetchone()["n"]
|
|
211
|
+
page_label = f"page {args.page}" if args.page > 1 else "latest"
|
|
212
|
+
|
|
213
|
+
print(f" Bookmarks ({page_label}, {total} total):")
|
|
214
|
+
print()
|
|
215
|
+
for row in rows:
|
|
216
|
+
tags_str = f" [{row['tags']}]" if row["tags"] else ""
|
|
217
|
+
note_preview = row["note"]
|
|
218
|
+
print(f" #{row['id']} {row['url']}{tags_str}")
|
|
219
|
+
print(f" {note_preview} ({_human_time(row['created_at'])})")
|
|
220
|
+
print()
|
|
221
|
+
|
|
222
|
+
conn.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_search(args: argparse.Namespace) -> None:
|
|
226
|
+
"""Search bookmarks by query."""
|
|
227
|
+
conn = get_connection()
|
|
228
|
+
|
|
229
|
+
if _fts_available(conn):
|
|
230
|
+
try:
|
|
231
|
+
rows = conn.execute(
|
|
232
|
+
"""
|
|
233
|
+
SELECT b.id, b.url, b.note, b.created_at,
|
|
234
|
+
(SELECT GROUP_CONCAT(t2.name, ', ')
|
|
235
|
+
FROM bookmark_tags bt2
|
|
236
|
+
JOIN tags t2 ON t2.id = bt2.tag_id
|
|
237
|
+
WHERE bt2.bookmark_id = b.id) AS tags,
|
|
238
|
+
rank
|
|
239
|
+
FROM bookmarks_fts fts
|
|
240
|
+
JOIN bookmarks b ON b.id = fts.rowid
|
|
241
|
+
WHERE bookmarks_fts MATCH ?
|
|
242
|
+
ORDER BY rank
|
|
243
|
+
LIMIT ?
|
|
244
|
+
""",
|
|
245
|
+
(args.query, args.limit or 20),
|
|
246
|
+
).fetchall()
|
|
247
|
+
except sqlite3.OperationalError:
|
|
248
|
+
rows = _like_search(conn, args.query, args.limit or 20)
|
|
249
|
+
else:
|
|
250
|
+
rows = _like_search(conn, args.query, args.limit or 20)
|
|
251
|
+
|
|
252
|
+
if not rows:
|
|
253
|
+
print(f" No results for \"{args.query}\"")
|
|
254
|
+
conn.close()
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
print(f" Search results for \"{args.query}\" ({len(rows)} found):")
|
|
258
|
+
print()
|
|
259
|
+
for row in rows:
|
|
260
|
+
tags_str = f" [{row['tags']}]" if row["tags"] else ""
|
|
261
|
+
print(f" #{row['id']} {row['url']}{tags_str}")
|
|
262
|
+
print(f" {row['note']} ({_human_time(row['created_at'])})")
|
|
263
|
+
print()
|
|
264
|
+
|
|
265
|
+
conn.close()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _like_search(conn: sqlite3.Connection, query: str, limit: int):
|
|
269
|
+
"""Fallback LIKE-based search."""
|
|
270
|
+
pattern = f"%{query}%"
|
|
271
|
+
return conn.execute(
|
|
272
|
+
"""
|
|
273
|
+
SELECT b.id, b.url, b.note, b.created_at,
|
|
274
|
+
(SELECT GROUP_CONCAT(t2.name, ', ')
|
|
275
|
+
FROM bookmark_tags bt2
|
|
276
|
+
JOIN tags t2 ON t2.id = bt2.tag_id
|
|
277
|
+
WHERE bt2.bookmark_id = b.id) AS tags
|
|
278
|
+
FROM bookmarks b
|
|
279
|
+
WHERE b.url LIKE ? OR b.note LIKE ?
|
|
280
|
+
ORDER BY b.created_at DESC, b.id DESC
|
|
281
|
+
LIMIT ?
|
|
282
|
+
""",
|
|
283
|
+
(pattern, pattern, limit),
|
|
284
|
+
).fetchall()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_export(args: argparse.Namespace) -> None:
|
|
288
|
+
"""Export bookmarks."""
|
|
289
|
+
conn = get_connection()
|
|
290
|
+
fmt = args.format or "markdown"
|
|
291
|
+
|
|
292
|
+
rows = conn.execute(
|
|
293
|
+
"""
|
|
294
|
+
SELECT b.id, b.url, b.note, b.created_at,
|
|
295
|
+
(SELECT GROUP_CONCAT(t2.name, ', ')
|
|
296
|
+
FROM bookmark_tags bt2
|
|
297
|
+
JOIN tags t2 ON t2.id = bt2.tag_id
|
|
298
|
+
WHERE bt2.bookmark_id = b.id) AS tags
|
|
299
|
+
FROM bookmarks b
|
|
300
|
+
ORDER BY b.created_at DESC, b.id DESC
|
|
301
|
+
""",
|
|
302
|
+
).fetchall()
|
|
303
|
+
|
|
304
|
+
if fmt == "json":
|
|
305
|
+
data = []
|
|
306
|
+
for row in rows:
|
|
307
|
+
data.append(
|
|
308
|
+
{
|
|
309
|
+
"id": row["id"],
|
|
310
|
+
"url": row["url"],
|
|
311
|
+
"note": row["note"],
|
|
312
|
+
"tags": row["tags"].split(", ") if row["tags"] else [],
|
|
313
|
+
"created_at": row["created_at"],
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
print(json.dumps(data, indent=2))
|
|
317
|
+
else:
|
|
318
|
+
# Markdown
|
|
319
|
+
print("# linkmark bookmarks\n")
|
|
320
|
+
print(f"_{len(rows)} bookmarks, exported {_now()}_\n")
|
|
321
|
+
for row in rows:
|
|
322
|
+
tags_str = ""
|
|
323
|
+
if row["tags"]:
|
|
324
|
+
tags_str = " " + " ".join(f"`{t}`" for t in row["tags"].split(", "))
|
|
325
|
+
print(f"- [{row['note']}]({row['url']}){tags_str}")
|
|
326
|
+
print(f" _saved {_human_time(row['created_at'])}_")
|
|
327
|
+
print()
|
|
328
|
+
|
|
329
|
+
conn.close()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def cmd_stats(args: argparse.Namespace) -> None:
|
|
333
|
+
"""Show bookmark statistics."""
|
|
334
|
+
conn = get_connection()
|
|
335
|
+
|
|
336
|
+
total = conn.execute("SELECT COUNT(*) AS n FROM bookmarks").fetchone()["n"]
|
|
337
|
+
|
|
338
|
+
# Bookmarks this week
|
|
339
|
+
week_ago = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
|
|
340
|
+
this_week = conn.execute(
|
|
341
|
+
"SELECT COUNT(*) AS n FROM bookmarks WHERE created_at >= ?", (week_ago,)
|
|
342
|
+
).fetchone()["n"]
|
|
343
|
+
|
|
344
|
+
# Top tags
|
|
345
|
+
top_tags = conn.execute(
|
|
346
|
+
"""
|
|
347
|
+
SELECT t.name, COUNT(bt.bookmark_id) AS count
|
|
348
|
+
FROM tags t
|
|
349
|
+
JOIN bookmark_tags bt ON bt.tag_id = t.id
|
|
350
|
+
GROUP BY t.id
|
|
351
|
+
ORDER BY count DESC
|
|
352
|
+
LIMIT 10
|
|
353
|
+
""",
|
|
354
|
+
).fetchall()
|
|
355
|
+
|
|
356
|
+
# Bookmarks this month
|
|
357
|
+
month_ago = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
|
358
|
+
this_month = conn.execute(
|
|
359
|
+
"SELECT COUNT(*) AS n FROM bookmarks WHERE created_at >= ?", (month_ago,)
|
|
360
|
+
).fetchone()["n"]
|
|
361
|
+
|
|
362
|
+
print(f" linkmark stats")
|
|
363
|
+
print(f" {'─' * 30}")
|
|
364
|
+
print(f" Total bookmarks: {total}")
|
|
365
|
+
print(f" This week: {this_week}")
|
|
366
|
+
print(f" This month: {this_month}")
|
|
367
|
+
print()
|
|
368
|
+
|
|
369
|
+
if top_tags:
|
|
370
|
+
print(" Top tags:")
|
|
371
|
+
for tag in top_tags:
|
|
372
|
+
bar = "█" * min(tag["count"], 20)
|
|
373
|
+
print(f" {tag['name']:20s} {tag['count']:4d} {bar}")
|
|
374
|
+
else:
|
|
375
|
+
print(" No tags yet. Use --tag when adding bookmarks.")
|
|
376
|
+
|
|
377
|
+
conn.close()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_delete(args: argparse.Namespace) -> None:
|
|
381
|
+
"""Delete a bookmark by ID."""
|
|
382
|
+
conn = get_connection()
|
|
383
|
+
row = conn.execute("SELECT url FROM bookmarks WHERE id = ?", (args.id,)).fetchone()
|
|
384
|
+
if not row:
|
|
385
|
+
print(f" Bookmark #{args.id} not found.")
|
|
386
|
+
conn.close()
|
|
387
|
+
sys.exit(1)
|
|
388
|
+
conn.execute("DELETE FROM bookmarks WHERE id = ?", (args.id,))
|
|
389
|
+
conn.commit()
|
|
390
|
+
print(f" Deleted #{args.id} {row['url']}")
|
|
391
|
+
conn.close()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def cmd_tags(args: argparse.Namespace) -> None:
|
|
395
|
+
"""List all tags with counts."""
|
|
396
|
+
conn = get_connection()
|
|
397
|
+
rows = conn.execute(
|
|
398
|
+
"""
|
|
399
|
+
SELECT t.name, COUNT(bt.bookmark_id) AS count
|
|
400
|
+
FROM tags t
|
|
401
|
+
JOIN bookmark_tags bt ON bt.tag_id = t.id
|
|
402
|
+
GROUP BY t.id
|
|
403
|
+
ORDER BY count DESC
|
|
404
|
+
""",
|
|
405
|
+
).fetchall()
|
|
406
|
+
if not rows:
|
|
407
|
+
print(" No tags yet.")
|
|
408
|
+
conn.close()
|
|
409
|
+
return
|
|
410
|
+
for row in rows:
|
|
411
|
+
print(f" {row['name']:25s} ({row['count']})")
|
|
412
|
+
conn.close()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# CLI entry point
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
420
|
+
parser = argparse.ArgumentParser(
|
|
421
|
+
prog="linkmark",
|
|
422
|
+
description="linkmark — Annotated Bookmark CLI. Save links with mandatory context.",
|
|
423
|
+
)
|
|
424
|
+
sub = parser.add_subparsers(dest="command")
|
|
425
|
+
|
|
426
|
+
# add
|
|
427
|
+
p_add = sub.add_parser("add", help="Save a bookmark with annotation")
|
|
428
|
+
p_add.add_argument("url", help="URL to bookmark")
|
|
429
|
+
p_add.add_argument("note", help="Why this matters (annotation)")
|
|
430
|
+
p_add.add_argument("--tag", action="append", default=[], help="Add a tag (repeatable)")
|
|
431
|
+
|
|
432
|
+
# list
|
|
433
|
+
p_list = sub.add_parser("list", help="List recent bookmarks")
|
|
434
|
+
p_list.add_argument("--page", type=int, default=1, help="Page number (default: 1)")
|
|
435
|
+
p_list.add_argument("--limit", type=int, default=20, help="Items per page (default: 20)")
|
|
436
|
+
|
|
437
|
+
# search
|
|
438
|
+
p_search = sub.add_parser("search", help="Search bookmarks")
|
|
439
|
+
p_search.add_argument("query", help="Search query")
|
|
440
|
+
p_search.add_argument("--limit", type=int, default=20, help="Max results (default: 20)")
|
|
441
|
+
|
|
442
|
+
# export
|
|
443
|
+
p_export = sub.add_parser("export", help="Export bookmarks")
|
|
444
|
+
p_export.add_argument(
|
|
445
|
+
"--format",
|
|
446
|
+
choices=["markdown", "json"],
|
|
447
|
+
default="markdown",
|
|
448
|
+
help="Export format (default: markdown)",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# stats
|
|
452
|
+
sub.add_parser("stats", help="Show bookmark statistics")
|
|
453
|
+
|
|
454
|
+
# delete
|
|
455
|
+
p_delete = sub.add_parser("delete", help="Delete a bookmark")
|
|
456
|
+
p_delete.add_argument("id", type=int, help="Bookmark ID")
|
|
457
|
+
|
|
458
|
+
# tags
|
|
459
|
+
sub.add_parser("tags", help="List all tags")
|
|
460
|
+
|
|
461
|
+
return parser
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def main() -> None:
|
|
465
|
+
parser = build_parser()
|
|
466
|
+
args = parser.parse_args()
|
|
467
|
+
|
|
468
|
+
if not args.command:
|
|
469
|
+
parser.print_help()
|
|
470
|
+
sys.exit(0)
|
|
471
|
+
|
|
472
|
+
dispatch = {
|
|
473
|
+
"add": cmd_add,
|
|
474
|
+
"list": cmd_list,
|
|
475
|
+
"search": cmd_search,
|
|
476
|
+
"export": cmd_export,
|
|
477
|
+
"stats": cmd_stats,
|
|
478
|
+
"delete": cmd_delete,
|
|
479
|
+
"tags": cmd_tags,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
handler = dispatch.get(args.command)
|
|
483
|
+
if handler:
|
|
484
|
+
handler(args)
|
|
485
|
+
else:
|
|
486
|
+
parser.print_help()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
if __name__ == "__main__":
|
|
490
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "linkmark"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Annotated bookmark CLI — save links with mandatory context"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{name = "linkmark contributors"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
linkmark = "linkmark:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
py-modules = ["linkmark"]
|
linkmark-1.0.0/setup.cfg
ADDED