satchel-reader 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritish Saini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: satchel-reader
3
+ Version: 0.1.0
4
+ Summary: A local-first reading queue: saves the real article text, not just the link, and makes it genuinely offline-searchable.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/satchel
7
+ Project-URL: Source, https://github.com/MaXiMo000/satchel
8
+ Project-URL: Issues, https://github.com/MaXiMo000/satchel/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/satchel/releases
10
+ Keywords: reading,offline,full-text-search,sqlite,local-first
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Text Processing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: trafilatura>=2.0
21
+ Dynamic: license-file
22
+
23
+ # satchel
24
+
25
+ **A tiny local archive of the actual things you read.**
26
+
27
+ [![ci](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml)
28
+
29
+ Most "read later" tools save a URL. The link rots, the site adds a
30
+ paywall, or you're offline — and the thing you saved is gone. `satchel`
31
+ fetches the page once, extracts the actual article text (not the nav, not
32
+ the related-links box, not the footer), and stores it in one local SQLite
33
+ file with real full-text search. No server, no account, no network needed
34
+ to search what you've already saved.
35
+
36
+ ```
37
+ $ satchel add https://example.com/some-article
38
+ saved #4: Why Local-First Software Is Worth the Extra Effort
39
+
40
+ $ satchel search "FTS5"
41
+ #4 Why Local-First Software Is Worth the Extra Effort (https://example.com/some-article)
42
+
43
+ $ satchel read 4
44
+ Why Local-First Software Is Worth the Extra Effort
45
+ by Jordan Rivers
46
+
47
+ Most reading tools save a link and call it done...
48
+ ```
49
+
50
+ Save with one click instead of a terminal: `satchel serve` runs a local
51
+ listener and prints a bookmarklet — click it on any page and that page is
52
+ saved through the exact same pipeline `add` uses. See "Capture" below.
53
+
54
+ ## How
55
+
56
+ - `fetch.py` — plain `urllib` GET. Stdlib, no dependency for the easy
57
+ part. Always rejects non-http(s) schemes; a URL that didn't come from
58
+ the person running satchel (see "Capture") is additionally checked
59
+ against loopback/private/link-local address space before and after
60
+ every redirect hop, not just the first one.
61
+ - `capture.py` — fetch → extract → store, in one place. Both `add` and the
62
+ capture listener call this; there is exactly one add pipeline.
63
+ - `extract.py` — wraps [trafilatura](https://github.com/adbar/trafilatura)
64
+ to pull title/author/main-text out of real HTML, correctly skipping
65
+ navigation, ads, and related-links boilerplate. Boilerplate-stripping is
66
+ exactly the kind of thing that looks fine on a hand-rolled test page and
67
+ breaks on the next real site's markup — not worth reimplementing.
68
+ - `db.py` — one SQLite file, an FTS5 virtual table kept in sync with the
69
+ real table via triggers (external-content FTS5: the searchable index
70
+ isn't a second copy of the truth that can drift from the first).
71
+ - `serve.py` — the local capture listener (below).
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install satchel-reader # the command it installs is `satchel`
77
+ ```
78
+
79
+ Or from a checkout, for development:
80
+
81
+ ```bash
82
+ pip install -e .
83
+ ```
84
+
85
+ ## Use
86
+
87
+ ```bash
88
+ satchel add <url> # fetch, extract, save
89
+ satchel list # everything saved
90
+ satchel search <query> # full-text search
91
+ satchel read <id> # print an article's full text
92
+ satchel serve # one-click capture -- see below
93
+ ```
94
+
95
+ All commands take `--db path/to/file.db`. The default, if you don't pass
96
+ one, is `~/.local/share/satchel/satchel.db` (respecting `XDG_DATA_HOME`) —
97
+ one stable location regardless of which directory you happen to run the
98
+ command from, not `./satchel.db` in the current directory. Run
99
+ `satchel --help` to see the exact resolved path on your machine.
100
+
101
+ Saving the same article twice — via a tracking link, a shortener, or a
102
+ plain `http://` URL the site itself upgrades to `https://` — is one
103
+ duplicate, not two. `add` normalizes the URL it's given, follows
104
+ redirects, and normalizes the *actual* address the server served before
105
+ checking for a duplicate: it defers to what the server says, rather than
106
+ guessing at a scheme policy.
107
+
108
+ ## Capture
109
+
110
+ ```bash
111
+ $ satchel serve
112
+ satchel capture listening on http://127.0.0.1:8765 (db: ~/.local/share/satchel/satchel.db)
113
+
114
+ Drag this to your bookmarks bar, then click it on any page to save it:
115
+
116
+ javascript:fetch('http://127.0.0.1:8765/add?token=...&url='+encodeURIComponent(location.href))...
117
+
118
+ ^C to stop.
119
+ ```
120
+
121
+ Drag the printed link to your bookmarks bar. Click it on any page while
122
+ `serve` is running, and that page goes through the same fetch/extract/save
123
+ pipeline as `satchel add` — no terminal required for the thing you do most
124
+ often.
125
+
126
+ **This changes the threat model, and it's handled, not ignored.** While
127
+ `serve` is running, any tab open in your browser can send it a request —
128
+ a browser's same-origin policy stops a page from *reading* a
129
+ cross-origin response it wasn't granted, but not from *sending* the
130
+ request in the first place. Without a shared secret, that would be an
131
+ open invitation for any open tab to make satchel fetch an arbitrary URL.
132
+ So: the listener only binds to `127.0.0.1`, a fresh token is generated
133
+ every time you run `serve` and only ever appears in the bookmarklet you
134
+ just dragged, and every captured URL is checked against loopback,
135
+ private (RFC1918), link-local (this is what closes off
136
+ `169.254.169.254`-style cloud metadata endpoints), reserved, and
137
+ multicast address space — before the first request, and again on every
138
+ redirect hop, since checking only the first hop would let a URL redirect
139
+ somewhere private after passing the initial check.
140
+
141
+ What that guard does *not* claim: it validates a hostname's DNS answer at
142
+ the moment it's checked, it doesn't pin the connection to that exact
143
+ resolved address. A DNS-rebinding attacker with a fast-expiring record
144
+ could in principle still slip a private address past the check and have
145
+ the actual connection re-resolve to it. Closing that fully means
146
+ connecting to a pinned IP rather than by hostname — real added complexity
147
+ for a personal tool's local listener. This is a deliberate, documented
148
+ partial mitigation, not a claim that it's unbreakable.
149
+
150
+ Direct `satchel add <url>` from your own terminal is **not** restricted
151
+ this way — typing your own local dev server's URL to save a draft you're
152
+ writing is a legitimate thing to do, and you are not a threat to
153
+ yourself.
154
+
155
+ ## Test
156
+
157
+ ```bash
158
+ python tests/test_satchel.py
159
+ ```
160
+
161
+ Extraction is tested against a fixture HTML file (`tests/fixtures/`), not
162
+ the network — a real bug was caught this way during development: an
163
+ ambiguous byline (`"By Jordan Rivers · September 2026"` in one text node)
164
+ made trafilatura fold part of the date into the author field. Fixed by
165
+ making the fixture look like well-structured real markup (byline and date
166
+ as separate elements) rather than loosening the assertion.
167
+
168
+ The capture listener is tested the same way it's actually used: a real
169
+ `http.server.HTTPServer` runs in a background thread and gets real HTTP
170
+ requests, including SSRF attempts against loopback and link-local
171
+ addresses — checked with the *correct* token, since the interesting
172
+ question is whether the guard holds once someone's past the door, not
173
+ whether the door itself works.
174
+
175
+ ## What's deliberately not here yet
176
+
177
+ No tagging, no folders, no read/unread state — a flat list plus full-text
178
+ search covers the actual workflow (encounter → capture → search → read);
179
+ add these when a flat list genuinely stops being enough, not before. No
180
+ multi-device sync — one local file is the whole pitch, and sync is a
181
+ separate, harder problem this project isn't trying to solve. No AI
182
+ summarization, no embeddings, no recommendation engine: this is an
183
+ archive of what you actually read, not a platform.
184
+
185
+ MIT licensed.
@@ -0,0 +1,163 @@
1
+ # satchel
2
+
3
+ **A tiny local archive of the actual things you read.**
4
+
5
+ [![ci](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml)
6
+
7
+ Most "read later" tools save a URL. The link rots, the site adds a
8
+ paywall, or you're offline — and the thing you saved is gone. `satchel`
9
+ fetches the page once, extracts the actual article text (not the nav, not
10
+ the related-links box, not the footer), and stores it in one local SQLite
11
+ file with real full-text search. No server, no account, no network needed
12
+ to search what you've already saved.
13
+
14
+ ```
15
+ $ satchel add https://example.com/some-article
16
+ saved #4: Why Local-First Software Is Worth the Extra Effort
17
+
18
+ $ satchel search "FTS5"
19
+ #4 Why Local-First Software Is Worth the Extra Effort (https://example.com/some-article)
20
+
21
+ $ satchel read 4
22
+ Why Local-First Software Is Worth the Extra Effort
23
+ by Jordan Rivers
24
+
25
+ Most reading tools save a link and call it done...
26
+ ```
27
+
28
+ Save with one click instead of a terminal: `satchel serve` runs a local
29
+ listener and prints a bookmarklet — click it on any page and that page is
30
+ saved through the exact same pipeline `add` uses. See "Capture" below.
31
+
32
+ ## How
33
+
34
+ - `fetch.py` — plain `urllib` GET. Stdlib, no dependency for the easy
35
+ part. Always rejects non-http(s) schemes; a URL that didn't come from
36
+ the person running satchel (see "Capture") is additionally checked
37
+ against loopback/private/link-local address space before and after
38
+ every redirect hop, not just the first one.
39
+ - `capture.py` — fetch → extract → store, in one place. Both `add` and the
40
+ capture listener call this; there is exactly one add pipeline.
41
+ - `extract.py` — wraps [trafilatura](https://github.com/adbar/trafilatura)
42
+ to pull title/author/main-text out of real HTML, correctly skipping
43
+ navigation, ads, and related-links boilerplate. Boilerplate-stripping is
44
+ exactly the kind of thing that looks fine on a hand-rolled test page and
45
+ breaks on the next real site's markup — not worth reimplementing.
46
+ - `db.py` — one SQLite file, an FTS5 virtual table kept in sync with the
47
+ real table via triggers (external-content FTS5: the searchable index
48
+ isn't a second copy of the truth that can drift from the first).
49
+ - `serve.py` — the local capture listener (below).
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install satchel-reader # the command it installs is `satchel`
55
+ ```
56
+
57
+ Or from a checkout, for development:
58
+
59
+ ```bash
60
+ pip install -e .
61
+ ```
62
+
63
+ ## Use
64
+
65
+ ```bash
66
+ satchel add <url> # fetch, extract, save
67
+ satchel list # everything saved
68
+ satchel search <query> # full-text search
69
+ satchel read <id> # print an article's full text
70
+ satchel serve # one-click capture -- see below
71
+ ```
72
+
73
+ All commands take `--db path/to/file.db`. The default, if you don't pass
74
+ one, is `~/.local/share/satchel/satchel.db` (respecting `XDG_DATA_HOME`) —
75
+ one stable location regardless of which directory you happen to run the
76
+ command from, not `./satchel.db` in the current directory. Run
77
+ `satchel --help` to see the exact resolved path on your machine.
78
+
79
+ Saving the same article twice — via a tracking link, a shortener, or a
80
+ plain `http://` URL the site itself upgrades to `https://` — is one
81
+ duplicate, not two. `add` normalizes the URL it's given, follows
82
+ redirects, and normalizes the *actual* address the server served before
83
+ checking for a duplicate: it defers to what the server says, rather than
84
+ guessing at a scheme policy.
85
+
86
+ ## Capture
87
+
88
+ ```bash
89
+ $ satchel serve
90
+ satchel capture listening on http://127.0.0.1:8765 (db: ~/.local/share/satchel/satchel.db)
91
+
92
+ Drag this to your bookmarks bar, then click it on any page to save it:
93
+
94
+ javascript:fetch('http://127.0.0.1:8765/add?token=...&url='+encodeURIComponent(location.href))...
95
+
96
+ ^C to stop.
97
+ ```
98
+
99
+ Drag the printed link to your bookmarks bar. Click it on any page while
100
+ `serve` is running, and that page goes through the same fetch/extract/save
101
+ pipeline as `satchel add` — no terminal required for the thing you do most
102
+ often.
103
+
104
+ **This changes the threat model, and it's handled, not ignored.** While
105
+ `serve` is running, any tab open in your browser can send it a request —
106
+ a browser's same-origin policy stops a page from *reading* a
107
+ cross-origin response it wasn't granted, but not from *sending* the
108
+ request in the first place. Without a shared secret, that would be an
109
+ open invitation for any open tab to make satchel fetch an arbitrary URL.
110
+ So: the listener only binds to `127.0.0.1`, a fresh token is generated
111
+ every time you run `serve` and only ever appears in the bookmarklet you
112
+ just dragged, and every captured URL is checked against loopback,
113
+ private (RFC1918), link-local (this is what closes off
114
+ `169.254.169.254`-style cloud metadata endpoints), reserved, and
115
+ multicast address space — before the first request, and again on every
116
+ redirect hop, since checking only the first hop would let a URL redirect
117
+ somewhere private after passing the initial check.
118
+
119
+ What that guard does *not* claim: it validates a hostname's DNS answer at
120
+ the moment it's checked, it doesn't pin the connection to that exact
121
+ resolved address. A DNS-rebinding attacker with a fast-expiring record
122
+ could in principle still slip a private address past the check and have
123
+ the actual connection re-resolve to it. Closing that fully means
124
+ connecting to a pinned IP rather than by hostname — real added complexity
125
+ for a personal tool's local listener. This is a deliberate, documented
126
+ partial mitigation, not a claim that it's unbreakable.
127
+
128
+ Direct `satchel add <url>` from your own terminal is **not** restricted
129
+ this way — typing your own local dev server's URL to save a draft you're
130
+ writing is a legitimate thing to do, and you are not a threat to
131
+ yourself.
132
+
133
+ ## Test
134
+
135
+ ```bash
136
+ python tests/test_satchel.py
137
+ ```
138
+
139
+ Extraction is tested against a fixture HTML file (`tests/fixtures/`), not
140
+ the network — a real bug was caught this way during development: an
141
+ ambiguous byline (`"By Jordan Rivers · September 2026"` in one text node)
142
+ made trafilatura fold part of the date into the author field. Fixed by
143
+ making the fixture look like well-structured real markup (byline and date
144
+ as separate elements) rather than loosening the assertion.
145
+
146
+ The capture listener is tested the same way it's actually used: a real
147
+ `http.server.HTTPServer` runs in a background thread and gets real HTTP
148
+ requests, including SSRF attempts against loopback and link-local
149
+ addresses — checked with the *correct* token, since the interesting
150
+ question is whether the guard holds once someone's past the door, not
151
+ whether the door itself works.
152
+
153
+ ## What's deliberately not here yet
154
+
155
+ No tagging, no folders, no read/unread state — a flat list plus full-text
156
+ search covers the actual workflow (encounter → capture → search → read);
157
+ add these when a flat list genuinely stops being enough, not before. No
158
+ multi-device sync — one local file is the whole pitch, and sync is a
159
+ separate, harder problem this project isn't trying to solve. No AI
160
+ summarization, no embeddings, no recommendation engine: this is an
161
+ archive of what you actually read, not a platform.
162
+
163
+ MIT licensed.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ # The command stays `satchel`; the distribution cannot -- that name is
3
+ # already taken on PyPI by an unrelated, abandoned package (a single empty
4
+ # "1.0" release from 2018, "Development Status :: 1 - Planning"). A
5
+ # distribution name differing from the command it installs is ordinary
6
+ # (python-dateutil installs `dateutil`).
7
+ name = "satchel-reader"
8
+ version = "0.1.0"
9
+ description = "A local-first reading queue: saves the real article text, not just the link, and makes it genuinely offline-searchable."
10
+ requires-python = ">=3.10"
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ keywords = ["reading", "offline", "full-text-search", "sqlite", "local-first"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Text Processing",
22
+ ]
23
+ # trafilatura: boilerplate-stripping (nav/ads/footers) is exactly the kind
24
+ # of thing that looks fine on one test page and breaks on the next real
25
+ # site's markup -- not worth reimplementing. Fetching itself stays stdlib
26
+ # (urllib) since a plain GET doesn't need a dependency.
27
+ dependencies = ["trafilatura>=2.0"]
28
+
29
+ urls.Homepage = "https://github.com/MaXiMo000/satchel"
30
+ urls.Source = "https://github.com/MaXiMo000/satchel"
31
+ urls.Issues = "https://github.com/MaXiMo000/satchel/issues"
32
+ urls.Changelog = "https://github.com/MaXiMo000/satchel/releases"
33
+
34
+ [project.scripts]
35
+ satchel = "satchel.cli:main"
36
+
37
+ [build-system]
38
+ # 77 is the floor for PEP 639 (`license = "MIT"` as an SPDX expression). On
39
+ # 68 a clean build environment can reject it -- learned the hard way in a
40
+ # sibling project's own release workflow, fixed here before it bites this one.
41
+ requires = ["setuptools>=77"]
42
+ build-backend = "setuptools.build_meta"
43
+
44
+ [tool.setuptools.packages.find]
45
+ include = ["satchel*"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,44 @@
1
+ """The one place "fetch, extract, store" happens -- used by both the CLI's
2
+ `add` command and the local capture listener (`serve.py`), so there is
3
+ exactly one add pipeline, not two that can drift apart.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import sqlite3
8
+
9
+ from . import db
10
+ from .extract import extract
11
+ from .fetch import fetch, normalize_url
12
+
13
+
14
+ def add_article(conn: sqlite3.Connection, raw_url: str, *, restrict_private_network: bool = False) -> dict:
15
+ """Fetch, extract, and store one article.
16
+
17
+ Returns {"ok": bool, "message": str, "id": int | None}.
18
+
19
+ restrict_private_network is False for direct CLI use (a human typing a
20
+ URL into their own terminal isn't a threat to themselves) and True for
21
+ the capture listener (see serve.py) -- there, the URL comes from
22
+ whatever page happened to be open in the browser, not from the person
23
+ running satchel, and that's exactly the boundary an SSRF guard exists
24
+ for.
25
+ """
26
+ url = normalize_url(raw_url)
27
+ try:
28
+ html, final_url = fetch(url, restrict_private_network=restrict_private_network)
29
+ except Exception as exc: # noqa: BLE001 - a bad/unsafe fetch is a clear result, not a crash
30
+ return {"ok": False, "message": f"could not fetch {url}: {exc}", "id": None}
31
+ # Normalize again after following redirects -- the URL actually served
32
+ # (past a shortener, or an http->https upgrade) is the real dedup key.
33
+ url = normalize_url(final_url)
34
+
35
+ article = extract(html, url=url)
36
+ if article is None:
37
+ return {"ok": False, "message": f"could not extract article text from {url}", "id": None}
38
+
39
+ try:
40
+ article_id = db.add(conn, url, article["title"], article["author"], article["text"])
41
+ except sqlite3.IntegrityError:
42
+ return {"ok": False, "message": f"already saved: {url}", "id": None}
43
+
44
+ return {"ok": True, "message": f"saved #{article_id}: {article['title'] or url}", "id": article_id}
@@ -0,0 +1,99 @@
1
+ """satchel add <url> | search <query> | list | read <id> | serve"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sqlite3
6
+ import sys
7
+
8
+ from . import db
9
+ from .capture import add_article
10
+ from .serve import DEFAULT_PORT, serve
11
+
12
+
13
+ def _do_add(args) -> int:
14
+ conn = db.connect(args.db)
15
+ result = add_article(conn, args.url)
16
+ print(result["message"], file=sys.stdout if result["ok"] else sys.stderr)
17
+ return 0 if result["ok"] else 1
18
+
19
+
20
+ def _do_search(args) -> int:
21
+ conn = db.connect(args.db)
22
+ try:
23
+ rows = db.search(conn, args.query)
24
+ except sqlite3.OperationalError:
25
+ # FTS5's MATCH syntax (quotes, AND/OR/NOT, prefix *, column filters)
26
+ # is real query syntax a user can get wrong -- an unbalanced quote or
27
+ # a bare operator shouldn't surface as a Python traceback.
28
+ print(f"error: couldn't parse that search query: {args.query!r}", file=sys.stderr)
29
+ print("tip: quotes must be balanced; AND/OR/NOT/* are reserved words in FTS5 syntax", file=sys.stderr)
30
+ return 1
31
+ if not rows:
32
+ print("no matches")
33
+ return 0
34
+ for row in rows:
35
+ print(f"#{row['id']:<4} {row['title'] or row['url']} ({row['url']})")
36
+ return 0
37
+
38
+
39
+ def _do_list(args) -> int:
40
+ conn = db.connect(args.db)
41
+ rows = db.list_all(conn)
42
+ if not rows:
43
+ print(f"nothing saved yet — try: satchel add <url> (db: {args.db})")
44
+ return 0
45
+ for row in rows:
46
+ print(f"#{row['id']:<4} {row['title'] or row['url']} ({row['added_at']})")
47
+ return 0
48
+
49
+
50
+ def _do_read(args) -> int:
51
+ conn = db.connect(args.db)
52
+ row = db.get(conn, args.id)
53
+ if row is None:
54
+ print(f"error: no article #{args.id}", file=sys.stderr)
55
+ return 1
56
+ print(row["title"] or row["url"])
57
+ if row["author"]:
58
+ print(f"by {row['author']}")
59
+ print()
60
+ print(row["text"])
61
+ return 0
62
+
63
+
64
+ def _do_serve(args) -> int:
65
+ return serve(args.db, port=args.port)
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ parser = argparse.ArgumentParser(prog="satchel")
70
+ parser.add_argument("--db", default=db.default_db_path(),
71
+ help=f"path to the sqlite db (default: {db.default_db_path()})")
72
+ sub = parser.add_subparsers(dest="command", required=True)
73
+
74
+ add_p = sub.add_parser("add", help="fetch a URL, extract the article, save it")
75
+ add_p.add_argument("url")
76
+ add_p.set_defaults(func=_do_add)
77
+
78
+ search_p = sub.add_parser("search", help="full-text search saved articles")
79
+ search_p.add_argument("query")
80
+ search_p.set_defaults(func=_do_search)
81
+
82
+ list_p = sub.add_parser("list", help="list everything saved")
83
+ list_p.set_defaults(func=_do_list)
84
+
85
+ read_p = sub.add_parser("read", help="print a saved article's full text")
86
+ read_p.add_argument("id", type=int)
87
+ read_p.set_defaults(func=_do_read)
88
+
89
+ serve_p = sub.add_parser("serve", help="run a local listener + bookmarklet for one-click capture")
90
+ serve_p.add_argument("--port", type=int, default=DEFAULT_PORT,
91
+ help=f"port to listen on (default: {DEFAULT_PORT})")
92
+ serve_p.set_defaults(func=_do_serve)
93
+
94
+ args = parser.parse_args(argv)
95
+ return args.func(args)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -0,0 +1,101 @@
1
+ """SQLite storage with full-text search (FTS5), local-first: one file, no
2
+ server, no network dependency to search what you've already saved.
3
+
4
+ The FTS5 table is external-content (`content='articles'`): the searchable
5
+ copy of the text isn't duplicated as the source of truth, and triggers keep
6
+ it in sync on insert/update/delete so the two can't drift apart.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import pathlib
12
+ import sqlite3
13
+ import time
14
+
15
+
16
+ def default_db_path() -> str:
17
+ """Where satchel.db lives if --db isn't given: one stable, per-user
18
+ location instead of "whatever directory you happened to run the
19
+ command from" -- the latter means `satchel add` from ~/Downloads and
20
+ `satchel list` from ~ silently look at two different, disconnected
21
+ databases, which is indistinguishable from data loss to a new user.
22
+ Respects XDG_DATA_HOME; falls back to the XDG default location.
23
+ """
24
+ data_home = os.environ.get("XDG_DATA_HOME") or str(pathlib.Path.home() / ".local" / "share")
25
+ return str(pathlib.Path(data_home) / "satchel" / "satchel.db")
26
+
27
+
28
+ SCHEMA = """
29
+ CREATE TABLE IF NOT EXISTS articles (
30
+ id INTEGER PRIMARY KEY,
31
+ url TEXT UNIQUE NOT NULL,
32
+ title TEXT,
33
+ author TEXT,
34
+ text TEXT NOT NULL,
35
+ added_at TEXT NOT NULL
36
+ );
37
+
38
+ CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
39
+ title, author, text, content='articles', content_rowid='id'
40
+ );
41
+
42
+ CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
43
+ INSERT INTO articles_fts(rowid, title, author, text)
44
+ VALUES (new.id, new.title, new.author, new.text);
45
+ END;
46
+
47
+ CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
48
+ INSERT INTO articles_fts(articles_fts, rowid, title, author, text)
49
+ VALUES ('delete', old.id, old.title, old.author, old.text);
50
+ END;
51
+
52
+ CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
53
+ INSERT INTO articles_fts(articles_fts, rowid, title, author, text)
54
+ VALUES ('delete', old.id, old.title, old.author, old.text);
55
+ INSERT INTO articles_fts(rowid, title, author, text)
56
+ VALUES (new.id, new.title, new.author, new.text);
57
+ END;
58
+ """
59
+
60
+
61
+ def connect(path: str) -> sqlite3.Connection:
62
+ parent = pathlib.Path(path).parent
63
+ if str(parent) not in ("", "."):
64
+ parent.mkdir(parents=True, exist_ok=True)
65
+ conn = sqlite3.connect(path)
66
+ conn.row_factory = sqlite3.Row
67
+ conn.executescript(SCHEMA)
68
+ return conn
69
+
70
+
71
+ def add(conn: sqlite3.Connection, url: str, title: str | None, author: str | None, text: str) -> int:
72
+ """Returns the article's id. Raises sqlite3.IntegrityError if the url
73
+ is already saved -- the caller decides what "already have this" means
74
+ to them (skip, re-fetch, update), this layer doesn't guess."""
75
+ cur = conn.execute(
76
+ "INSERT INTO articles (url, title, author, text, added_at) VALUES (?, ?, ?, ?, ?)",
77
+ (url, title, author, text, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
78
+ )
79
+ conn.commit()
80
+ return cur.lastrowid
81
+
82
+
83
+ def get(conn: sqlite3.Connection, article_id: int) -> sqlite3.Row | None:
84
+ return conn.execute("SELECT * FROM articles WHERE id = ?", (article_id,)).fetchone()
85
+
86
+
87
+ def list_all(conn: sqlite3.Connection) -> list[sqlite3.Row]:
88
+ return conn.execute("SELECT id, url, title, author, added_at FROM articles ORDER BY added_at DESC").fetchall()
89
+
90
+
91
+ def search(conn: sqlite3.Connection, query: str) -> list[sqlite3.Row]:
92
+ return conn.execute(
93
+ """
94
+ SELECT articles.id, articles.url, articles.title, articles.author, articles.added_at
95
+ FROM articles_fts
96
+ JOIN articles ON articles.id = articles_fts.rowid
97
+ WHERE articles_fts MATCH ?
98
+ ORDER BY rank
99
+ """,
100
+ (query,),
101
+ ).fetchall()