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 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.
@@ -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,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,9 @@
1
+ LICENSE
2
+ README.md
3
+ linkmark.py
4
+ pyproject.toml
5
+ linkmark.egg-info/PKG-INFO
6
+ linkmark.egg-info/SOURCES.txt
7
+ linkmark.egg-info/dependency_links.txt
8
+ linkmark.egg-info/entry_points.txt
9
+ linkmark.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ linkmark = linkmark:main
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+