suken-cite 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.
Files changed (37) hide show
  1. suken_cite-0.1.0/LICENSE +21 -0
  2. suken_cite-0.1.0/PKG-INFO +50 -0
  3. suken_cite-0.1.0/README.md +32 -0
  4. suken_cite-0.1.0/THIRD_PARTY_NOTICES.md +18 -0
  5. suken_cite-0.1.0/pyproject.toml +31 -0
  6. suken_cite-0.1.0/setup.cfg +4 -0
  7. suken_cite-0.1.0/src/suken_cite/USAGE.md +153 -0
  8. suken_cite-0.1.0/src/suken_cite/__init__.py +1 -0
  9. suken_cite-0.1.0/src/suken_cite/__main__.py +3 -0
  10. suken_cite-0.1.0/src/suken_cite/cli.py +176 -0
  11. suken_cite-0.1.0/src/suken_cite/project.py +131 -0
  12. suken_cite-0.1.0/src/suken_cite/server.py +82 -0
  13. suken_cite-0.1.0/src/suken_cite/snapshot.py +59 -0
  14. suken_cite-0.1.0/src/suken_cite/static/c.html +50 -0
  15. suken_cite-0.1.0/src/suken_cite/static/dist/github.css +118 -0
  16. suken_cite-0.1.0/src/suken_cite/static/dist/inject.js +2 -0
  17. suken_cite-0.1.0/src/suken_cite/static/dist/inject.js.LEGAL.txt +11 -0
  18. suken_cite-0.1.0/src/suken_cite/static/dist/pdf.worker.min.mjs +28 -0
  19. suken_cite-0.1.0/src/suken_cite/static/dist/pdf_viewer.css +6463 -0
  20. suken_cite-0.1.0/src/suken_cite/static/dist/render-code.js +6 -0
  21. suken_cite-0.1.0/src/suken_cite/static/dist/render-csv.js +2 -0
  22. suken_cite-0.1.0/src/suken_cite/static/dist/render-docx.js +23 -0
  23. suken_cite-0.1.0/src/suken_cite/static/dist/render-docx.js.LEGAL.txt +22 -0
  24. suken_cite-0.1.0/src/suken_cite/static/dist/render-pdf.js +55 -0
  25. suken_cite-0.1.0/src/suken_cite/static/dist/text-annotator.css +1 -0
  26. suken_cite-0.1.0/src/suken_cite/static/index.html +23 -0
  27. suken_cite-0.1.0/src/suken_cite/static/inject.css +58 -0
  28. suken_cite-0.1.0/src/suken_cite/static/render/code.html +19 -0
  29. suken_cite-0.1.0/src/suken_cite/static/render/csv.html +18 -0
  30. suken_cite-0.1.0/src/suken_cite/static/render/docx.html +14 -0
  31. suken_cite-0.1.0/src/suken_cite/static/render/pdf.html +18 -0
  32. suken_cite-0.1.0/src/suken_cite/static/render/render.css +71 -0
  33. suken_cite-0.1.0/src/suken_cite.egg-info/PKG-INFO +50 -0
  34. suken_cite-0.1.0/src/suken_cite.egg-info/SOURCES.txt +35 -0
  35. suken_cite-0.1.0/src/suken_cite.egg-info/dependency_links.txt +1 -0
  36. suken_cite-0.1.0/src/suken_cite.egg-info/entry_points.txt +2 -0
  37. suken_cite-0.1.0/src/suken_cite.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 suken
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,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: suken-cite
3
+ Version: 0.1.0
4
+ Summary: Circle a quote in a web page, PDF, Word, CSV or code file, add a translation, and get a screenshot link to cite it.
5
+ Author: suken
6
+ License-Expression: MIT
7
+ Keywords: citation,screenshot,highlight,annotation,llm,claude-code
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Text Processing
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: THIRD_PARTY_NOTICES.md
17
+ Dynamic: license-file
18
+
19
+ # suken-cite
20
+
21
+ Back every claim with a picture of its source. `suken-cite` renders a web page, PDF, Word `.docx`,
22
+ CSV or code file so it looks like the original, **circles the quoted text in red** and shows your
23
+ note (for example a translation) right under it. An LLM agent such as Claude Code screenshots that
24
+ view with chrome-devtools, and the reader opens one short link per claim.
25
+
26
+ ```bash
27
+ pip install suken-cite
28
+ suken-cite init --dir ./cites
29
+ suken-cite serve --dir ./cites &
30
+ suken-cite add --dir ./cites --id price --file prices.csv --rows 3 --note "Pears cost 2.40 per kg."
31
+ # -> screenshot http://127.0.0.1:8123/render/csv.html?cite=price into ./cites/shots/price.png
32
+ # -> share http://127.0.0.1:8123/c.html?id=price
33
+ ```
34
+
35
+ The full guide, written for an LLM agent, ships with the package:
36
+
37
+ ```bash
38
+ suken-cite usage
39
+ ```
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.9+ (no other Python dependencies; the browser bundles are pre-built).
44
+ - Chrome, and Claude Code with the **chrome-devtools** MCP server, for taking screenshots.
45
+ - For web pages: that Chrome must expose its DevTools port; SingleFile CLI is downloaded on first use.
46
+ - Linux or macOS.
47
+
48
+ ## License
49
+
50
+ MIT. Bundled browser libraries and their licenses are listed in `THIRD_PARTY_NOTICES.md`.
@@ -0,0 +1,32 @@
1
+ # suken-cite
2
+
3
+ Back every claim with a picture of its source. `suken-cite` renders a web page, PDF, Word `.docx`,
4
+ CSV or code file so it looks like the original, **circles the quoted text in red** and shows your
5
+ note (for example a translation) right under it. An LLM agent such as Claude Code screenshots that
6
+ view with chrome-devtools, and the reader opens one short link per claim.
7
+
8
+ ```bash
9
+ pip install suken-cite
10
+ suken-cite init --dir ./cites
11
+ suken-cite serve --dir ./cites &
12
+ suken-cite add --dir ./cites --id price --file prices.csv --rows 3 --note "Pears cost 2.40 per kg."
13
+ # -> screenshot http://127.0.0.1:8123/render/csv.html?cite=price into ./cites/shots/price.png
14
+ # -> share http://127.0.0.1:8123/c.html?id=price
15
+ ```
16
+
17
+ The full guide, written for an LLM agent, ships with the package:
18
+
19
+ ```bash
20
+ suken-cite usage
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.9+ (no other Python dependencies; the browser bundles are pre-built).
26
+ - Chrome, and Claude Code with the **chrome-devtools** MCP server, for taking screenshots.
27
+ - For web pages: that Chrome must expose its DevTools port; SingleFile CLI is downloaded on first use.
28
+ - Linux or macOS.
29
+
30
+ ## License
31
+
32
+ MIT. Bundled browser libraries and their licenses are listed in `THIRD_PARTY_NOTICES.md`.
@@ -0,0 +1,18 @@
1
+ # Third-party notices
2
+
3
+ `suken-cite` ships pre-built browser bundles (`src/suken_cite/static/dist/`) that include the
4
+ following open-source libraries. License headers kept by the bundler are in `dist/*.LEGAL.txt`.
5
+
6
+ | Library | Version | License | Source |
7
+ | --- | --- | --- | --- |
8
+ | @recogito/text-annotator | 4.3.7 | BSD-3-Clause | https://github.com/recogito/text-annotator-js |
9
+ | pdfjs-dist (PDF.js) | 6.4.299 | Apache-2.0 | https://github.com/mozilla/pdf.js |
10
+ | docx-preview | 0.4.1 | Apache-2.0 | https://github.com/VolodymyrBaydalka/docxjs |
11
+ | JSZip (used by docx-preview) | 3.10.2 | MIT (dual MIT / GPL-3.0; used under MIT) | https://github.com/Stuk/jszip |
12
+ | highlight.js | 11.12.0 | BSD-3-Clause | https://github.com/highlightjs/highlight.js |
13
+
14
+ ## Not distributed
15
+
16
+ `suken-cite snapshot` downloads **SingleFile CLI** (AGPL-3.0,
17
+ https://github.com/gildas-lormeau/single-file-cli) from its GitHub releases into your user cache the
18
+ first time you run it. It is run as a separate program and is not included in this package.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "suken-cite"
7
+ version = "0.1.0"
8
+ description = "Circle a quote in a web page, PDF, Word, CSV or code file, add a translation, and get a screenshot link to cite it."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
13
+ authors = [{ name = "suken" }]
14
+ keywords = ["citation", "screenshot", "highlight", "annotation", "llm", "claude-code"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Operating System :: MacOS",
19
+ "Environment :: Console",
20
+ "Topic :: Text Processing",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.scripts]
25
+ suken-cite = "suken_cite.cli:main"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ suken_cite = ["USAGE.md", "static/*", "static/**/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,153 @@
1
+ # suken-cite — usage guide for an LLM agent (Claude Code)
2
+
3
+ Read this whole file before using the tool. The user gives it to you when they want their answers
4
+ backed by **circled, translated screenshots** instead of long quotes.
5
+
6
+ ## What it does
7
+
8
+ A *citation* is one quote in one source plus a short *note* (usually a translation). suken-cite
9
+ renders the source in Chrome so it looks like the original, circles the quote in red and shows the
10
+ note in a bubble right under it. **You** take the screenshot with chrome-devtools. The reader then
11
+ opens `http://<host>:<port>/c.html?id=<citation-id>` and sees that screenshot, with links to an
12
+ interactive version and to the original.
13
+
14
+ Use it whenever you explain something to the user: one link per claim, short text around it.
15
+
16
+ Supported sources out of the box:
17
+
18
+ | Source | Renderer | How the quote is located |
19
+ | --- | --- | --- |
20
+ | Web page (also behind a login) | full-page copy made by SingleFile | exact sentence (`--quote`) |
21
+ | Code / any text file (`.py`, `.json`, `.md`, `.yaml`, `.txt`, …) | syntax-highlighted file view | line range (`--lines`) |
22
+ | CSV / TSV | table view | row range (`--rows`, header = row 1) |
23
+ | PDF | PDF.js page view | page + exact text (`--page`, `--quote`) |
24
+ | Word `.docx` | Word-like page view | exact text (`--quote`) |
25
+
26
+ ## Requirements
27
+
28
+ - Python 3.9+ and `pip install suken-cite`.
29
+ - **chrome-devtools MCP server** connected in Claude Code. You take every screenshot through it.
30
+ - Web pages only: the Chrome that chrome-devtools uses must expose its DevTools port (default
31
+ `http://127.0.0.1:9222`). `suken-cite snapshot` drives that same Chrome, so pages behind a login
32
+ are captured with the user's session. SingleFile CLI is downloaded automatically on first use.
33
+ - The reader's browser must reach the server. If the server runs on a remote machine, the user
34
+ forwards the port (VS Code / Cursor "Ports" tab, or `ssh -L 8123:127.0.0.1:8123 host`).
35
+ - Optional: `pdftotext` (poppler) — lets `add` verify that a PDF quote is on the given page.
36
+
37
+ ## One-time setup in a project
38
+
39
+ ```bash
40
+ suken-cite init --dir ./cites
41
+ nohup suken-cite serve --dir ./cites > ./cites/server.log 2>&1 &
42
+ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8123/c.html # expect 200
43
+ ```
44
+
45
+ `./cites` then holds `citations.json`, `docs.json` (web snapshots), `docs/`, `sources/` (copies of
46
+ cited files) and `shots/` (the PNG screenshots). Check the server is still up before you give links.
47
+
48
+ ## Workflow for every citation
49
+
50
+ 1. **Add it.** `suken-cite add …` (see the next section). It validates the quote / line range and
51
+ prints three things: `screenshot this` URL, `save png to` path, `link for reader` URL.
52
+ 2. **Screenshot it with chrome-devtools:**
53
+ 1. `new_page` with the `screenshot this` URL and `background: true`. Remember the page id.
54
+ 2. `evaluate_script` on that page:
55
+ `async () => { for (let i = 0; i < 40 && document.body.dataset.ready !== '1'; i++) await new Promise(r => setTimeout(r, 250)); return document.body.dataset.ready; }`
56
+ It must return `"1"`. If not, the quote was not found — fix the citation and retry.
57
+ 3. `take_screenshot` with `filePath` = the `save png to` path (viewport, not full page).
58
+ 4. Look at the image once: the red ring must surround the quote and the bubble must not hide
59
+ what the reader needs. If the ring is wrong, adjust `--quote` / `--lines` and redo.
60
+ 5. `close_page` for the page id you opened. Never touch tabs you did not open.
61
+ 3. **Cite it** in your answer: `[📷 short label](http://127.0.0.1:8123/c.html?id=<id>)`.
62
+ 4. Before you finish, run `suken-cite check --dir ./cites`; every line must say `OK`.
63
+
64
+ `suken-cite urls --dir ./cites --missing` lists citations that still have no screenshot.
65
+
66
+ ## Adding citations by source type
67
+
68
+ ```bash
69
+ # code or any text file: line range
70
+ suken-cite add --dir ./cites --id tax-rule --file app/billing.py --lines 40-52 \
71
+ --section "app/billing.py · lines 40–52" --vi "Thuế 10% được cộng sau khi trừ giảm giá."
72
+
73
+ # CSV / TSV: row range, the header line is row 1; --cols keeps wide tables readable
74
+ suken-cite add --dir ./cites --id pear-price --file data/prices.csv --rows 3 --cols item,price \
75
+ --section "prices.csv · row 3" --note "Pears cost 2.40 per kg."
76
+
77
+ # PDF: page + exact text on that page
78
+ suken-cite add --dir ./cites --id restart-rule --file docs/manual.pdf --page 3 \
79
+ --quote "Restart the device after every firmware update." \
80
+ --section "manual.pdf · page 3" --note "A restart is required after each update."
81
+
82
+ # Word .docx: exact text (+ optional link to the online original)
83
+ suken-cite add --dir ./cites --id backup-days --file policy.docx \
84
+ --quote "Backups are kept for 30 days." \
85
+ --source-url "https://example.com/policy.docx" --section "policy.docx · Backups" --note "…"
86
+
87
+ # Web page: snapshot once per page, then cite sentences from it
88
+ suken-cite snapshot "https://docs.example.com/guide" --dir ./cites --id guide
89
+ suken-cite add --dir ./cites --id token-expiry --doc guide \
90
+ --quote "Tokens expire after one hour." --section "Guide · Authentication" --note "…"
91
+ ```
92
+
93
+ Rules:
94
+
95
+ - `--quote` must be copied **verbatim** from the source (whitespace and line breaks may differ). Keep
96
+ it to one sentence, one list item or one table row: the ring wraps exactly that text.
97
+ - `--section` is a short label of where the quote lives (file · page/lines · heading).
98
+ - `--vi` is a Vietnamese note shown with a 🇻🇳 flag; `--note` is a note in any language. Write it in
99
+ the reader's language: the translation plus, if useful, why it matters — one or two sentences.
100
+ - Files outside the project folder are copied into `sources/` (the server only serves the project).
101
+ - Adding an existing id replaces that citation — take its screenshot again.
102
+ - A web snapshot is a full copy of the page. Do not snapshot pages the user would not want stored.
103
+
104
+ ## Complete example
105
+
106
+ ```bash
107
+ mkdir demo && cd demo
108
+ printf 'def total(qty, price):\n return qty * price\n' > cost.py
109
+ printf 'item,unit,price\napple,kg,1.20\npear,kg,2.40\n' > prices.csv
110
+ suken-cite init --dir cites
111
+ nohup suken-cite serve --dir cites > cites/server.log 2>&1 &
112
+ suken-cite add --dir cites --id cost-formula --file cost.py --lines 1-2 \
113
+ --section "cost.py · lines 1–2" --note "Total is quantity times unit price."
114
+ suken-cite add --dir cites --id pear-price --file prices.csv --rows 3 \
115
+ --section "prices.csv · row 3" --note "Pears cost 2.40 per kg."
116
+ ```
117
+
118
+ `add` prints, for `cost-formula`:
119
+
120
+ ```
121
+ screenshot this : http://127.0.0.1:8123/render/code.html?cite=cost-formula
122
+ save png to : /…/demo/cites/shots/cost-formula.png
123
+ link for reader : http://127.0.0.1:8123/c.html?id=cost-formula
124
+ ```
125
+
126
+ Then with chrome-devtools: `new_page` (that URL, background) → `evaluate_script` (wait for ready) →
127
+ `take_screenshot` (filePath `/…/demo/cites/shots/cost-formula.png`) → `close_page`. Same for
128
+ `pear-price`. Your answer to the user then reads:
129
+
130
+ > The total is quantity × unit price [📷 formula](http://127.0.0.1:8123/c.html?id=cost-formula), and
131
+ > pears cost 2.40 per kg [📷 price](http://127.0.0.1:8123/c.html?id=pear-price).
132
+
133
+ `http://127.0.0.1:8123/` lists every citation of the project.
134
+
135
+ ## Adding a renderer for another file type
136
+
137
+ Files in the project folder override the packaged ones, so a project can add
138
+ `<project>/render/<type>.html` and cite with `--type <type>`. The page must: read `?cite=<id>`,
139
+ fetch `/citations.json`, load `/<cite.file>`, render it, draw the ring and bubble (elements with
140
+ classes `cite-ring` and `cite-bubble`, styled by `/render/render.css`), then set
141
+ `document.body.dataset.ready = '1'`. The smallest packaged example is `/render/csv.html` with
142
+ `/dist/render-csv.js`.
143
+
144
+ ## Troubleshooting
145
+
146
+ | Symptom | Fix |
147
+ | --- | --- |
148
+ | `c.html` says the citation does not exist | wrong id, or the server serves another `--dir` |
149
+ | `ready` never becomes `"1"` / no ring | quote or range not found: `suken-cite check --dir …` |
150
+ | `snapshot` fails | Chrome is not listening on `--browser`, or the page needs a login in that Chrome |
151
+ | SingleFile cannot be downloaded or is blocked (macOS) | install single-file-cli yourself, set `SUKEN_CITE_SINGLE_FILE=/path/to/single-file` |
152
+ | Port 8123 busy | add `--port 8124` to `serve`, `add` and `urls` |
153
+ | Reader sees a connection error | server stopped (restart it) or the port is not forwarded to the reader's machine |
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,176 @@
1
+ import argparse
2
+ import sys
3
+ from datetime import datetime, timezone
4
+ from importlib.resources import files
5
+
6
+ from . import __version__
7
+ from .project import CITATIONS, DOCS, Project, cite_url, shot_url, type_for
8
+ from .server import serve
9
+ from .snapshot import capture
10
+
11
+ DEFAULT_PORT = 8123
12
+ DEFAULT_BROWSER = "http://127.0.0.1:9222"
13
+
14
+
15
+ def span(text):
16
+ first, _, last = text.partition("-")
17
+ return [int(first), int(last or first)]
18
+
19
+
20
+ def base_url(args):
21
+ return f"http://{args.host}:{args.port}"
22
+
23
+
24
+ def cmd_init(args):
25
+ project = Project(args.dir)
26
+ project.init()
27
+ print(f"Initialised suken-cite project in {project.root}")
28
+
29
+
30
+ def cmd_snapshot(args):
31
+ project = Project(args.dir)
32
+ project.require()
33
+ rel = f"docs/{args.id}.html"
34
+ title = capture(args.url, project.root / rel, args.browser)
35
+ doc = {"id": args.id, "type": "html", "file": rel, "title": args.title or title, "sourceUrl": args.url,
36
+ "capturedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
37
+ if args.root:
38
+ doc["root"] = args.root
39
+ project.upsert(DOCS, doc)
40
+ print(f"Saved {rel} ({doc['title']}) and registered doc '{args.id}' in {DOCS}")
41
+
42
+
43
+ def build_cite(args, project):
44
+ cite = {"id": args.id}
45
+ if args.doc:
46
+ cite.update(doc=args.doc, type="web")
47
+ else:
48
+ cite["file"] = project.import_file(args.file)
49
+ cite["type"] = args.type or type_for(cite["file"])
50
+ if args.lines:
51
+ cite["lines"] = span(args.lines)
52
+ if args.rows:
53
+ cite["rows"] = span(args.rows)
54
+ if args.cols:
55
+ cite["cols"] = [int(c) if c.strip().isdigit() else c.strip() for c in args.cols.split(",")]
56
+ if args.page:
57
+ cite["page"] = args.page
58
+ if args.quote:
59
+ cite["quote"] = args.quote
60
+ if args.source_url:
61
+ cite["sourceUrl"] = args.source_url
62
+ cite["section"] = args.section or cite.get("file") or args.doc
63
+ cite["vi" if args.vi else "note"] = args.vi or args.note or ""
64
+ needs = {"code": "lines", "csv": "rows", "pdf": "quote", "docx": "quote", "web": "quote"}.get(cite["type"])
65
+ if needs and needs not in cite:
66
+ raise SystemExit(f"type '{cite['type']}' needs --{needs}")
67
+ if cite["type"] == "pdf" and "page" not in cite:
68
+ raise SystemExit("type 'pdf' needs --page")
69
+ return cite
70
+
71
+
72
+ def cmd_add(args):
73
+ project = Project(args.dir)
74
+ project.require()
75
+ cite = build_cite(args, project)
76
+ problems = project.validate(cite)
77
+ if problems and not args.force:
78
+ raise SystemExit("Not saved: " + "; ".join(problems) + " (use --force to save anyway)")
79
+ project.upsert(CITATIONS, cite)
80
+ base = base_url(args)
81
+ print(f"Saved citation '{cite['id']}' ({cite['type']})")
82
+ print(f" screenshot this : {shot_url(cite, base)}")
83
+ print(f" save png to : {project.shot_path(cite)}")
84
+ print(f" link for reader : {cite_url(cite, base)}")
85
+
86
+
87
+ def cmd_urls(args):
88
+ project = Project(args.dir)
89
+ project.require()
90
+ base = base_url(args)
91
+ for cite in project.load(CITATIONS):
92
+ if args.id and cite["id"] != args.id:
93
+ continue
94
+ has_shot = project.shot_path(cite).exists()
95
+ if args.missing and has_shot:
96
+ continue
97
+ print(f"{cite['id']}\t{'shot ok' if has_shot else 'NO SHOT'}\n screenshot this : {shot_url(cite, base)}\n"
98
+ f" save png to : {project.shot_path(cite)}\n link for reader : {cite_url(cite, base)}")
99
+
100
+
101
+ def cmd_check(args):
102
+ project = Project(args.dir)
103
+ project.require()
104
+ bad = 0
105
+ for cite in project.load(CITATIONS):
106
+ problems = project.validate(cite)
107
+ if not project.shot_path(cite).exists():
108
+ problems.append("no screenshot yet")
109
+ bad += bool(problems)
110
+ print(f"{'OK ' if not problems else 'FAIL'} {cite['id']}" + (f" - {'; '.join(problems)}" if problems else ""))
111
+ sys.exit(1 if bad else 0)
112
+
113
+
114
+ def cmd_serve(args):
115
+ project = Project(args.dir)
116
+ project.require()
117
+ serve(project.root, args.host, args.port)
118
+
119
+
120
+ def cmd_usage(args):
121
+ print(files("suken_cite").joinpath("USAGE.md").read_text(encoding="utf-8"))
122
+
123
+
124
+ def main(argv=None):
125
+ parser = argparse.ArgumentParser(prog="suken-cite", description="Circled, translated screenshots of quotes from web pages, PDF, Word, CSV and code.")
126
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
127
+ sub = parser.add_subparsers(dest="command", required=True)
128
+
129
+ def command(name, func, help_text):
130
+ p = sub.add_parser(name, help=help_text)
131
+ p.add_argument("--dir", default=".", help="project folder (default: current folder)")
132
+ p.set_defaults(func=func)
133
+ return p
134
+
135
+ def with_server(p):
136
+ p.add_argument("--host", default="127.0.0.1")
137
+ p.add_argument("--port", type=int, default=DEFAULT_PORT)
138
+ return p
139
+
140
+ command("init", cmd_init, "create citations.json, docs.json, docs/, sources/, shots/")
141
+
142
+ p = command("snapshot", cmd_snapshot, "save a full copy of a web page (uses your logged-in Chrome)")
143
+ p.add_argument("url")
144
+ p.add_argument("--id", required=True, help="doc id to cite it with")
145
+ p.add_argument("--title")
146
+ p.add_argument("--root", help="CSS selector of the main content (optional)")
147
+ p.add_argument("--browser", default=DEFAULT_BROWSER, help=f"Chrome DevTools URL (default {DEFAULT_BROWSER})")
148
+
149
+ p = with_server(command("add", cmd_add, "add or replace a citation"))
150
+ p.add_argument("--id", required=True)
151
+ target = p.add_mutually_exclusive_group(required=True)
152
+ target.add_argument("--file", help="local file: code/text, .pdf, .docx, .csv/.tsv")
153
+ target.add_argument("--doc", help="web doc id from 'suken-cite snapshot'")
154
+ p.add_argument("--type", help="override renderer (default from extension)")
155
+ p.add_argument("--lines", help="code/text line range, e.g. 12-20")
156
+ p.add_argument("--rows", help="csv row range, header is row 1, e.g. 2-4")
157
+ p.add_argument("--cols", help="csv columns to show, names or 1-based numbers, e.g. id,status,3")
158
+ p.add_argument("--page", type=int, help="pdf page number")
159
+ p.add_argument("--quote", help="exact text to circle (pdf, docx, web)")
160
+ p.add_argument("--section", help="short label shown above the note")
161
+ note = p.add_mutually_exclusive_group()
162
+ note.add_argument("--vi", help="Vietnamese translation/explanation (shown with a 🇻🇳 flag)")
163
+ note.add_argument("--note", help="translation/explanation in any language")
164
+ p.add_argument("--source-url", help="link to the original (e.g. SharePoint URL of a .docx)")
165
+ p.add_argument("--force", action="store_true", help="save even if validation fails")
166
+
167
+ p = with_server(command("urls", cmd_urls, "print the screenshot URL and reader link of citations"))
168
+ p.add_argument("--id")
169
+ p.add_argument("--missing", action="store_true", help="only citations without a screenshot")
170
+
171
+ command("check", cmd_check, "validate every citation and report missing screenshots")
172
+ with_server(command("serve", cmd_serve, "serve the project to the browser"))
173
+ sub.add_parser("usage", help="print the usage guide for LLM agents").set_defaults(func=cmd_usage)
174
+
175
+ args = parser.parse_args(argv)
176
+ args.func(args)
@@ -0,0 +1,131 @@
1
+ import csv
2
+ import html
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import zipfile
8
+ from pathlib import Path
9
+ from urllib.parse import quote
10
+
11
+ CITATIONS = "citations.json"
12
+ DOCS = "docs.json"
13
+ FOLDERS = ("docs", "sources", "shots")
14
+ TYPE_BY_EXT = {"pdf": "pdf", "docx": "docx", "csv": "csv", "tsv": "csv"}
15
+
16
+
17
+ def squash(text):
18
+ return re.sub(r"\s+", "", text)
19
+
20
+
21
+ def html_text(path):
22
+ page = Path(path).read_text(encoding="utf-8", errors="replace")
23
+ page = re.sub(r"<(script|style)\b.*?</\1>", "", page, flags=re.S | re.I)
24
+ return html.unescape(re.sub(r"<[^>]+>", " ", page))
25
+
26
+
27
+ def docx_text(path):
28
+ xml = zipfile.ZipFile(path).read("word/document.xml").decode("utf-8")
29
+ return html.unescape(re.sub(r"<[^>]+>", "", re.sub(r"</w:p>", " ", xml)))
30
+
31
+
32
+ def pdf_page_text(path, page):
33
+ if not shutil.which("pdftotext"):
34
+ return None
35
+ out = subprocess.run(["pdftotext", "-f", str(page), "-l", str(page), str(path), "-"], capture_output=True, text=True)
36
+ return out.stdout if out.returncode == 0 else None
37
+
38
+
39
+ def type_for(file):
40
+ return TYPE_BY_EXT.get(Path(file).suffix.lower().lstrip("."), "code")
41
+
42
+
43
+ class Project:
44
+ def __init__(self, root):
45
+ self.root = Path(root).resolve()
46
+
47
+ def init(self):
48
+ for folder in FOLDERS:
49
+ (self.root / folder).mkdir(parents=True, exist_ok=True)
50
+ for name in (CITATIONS, DOCS):
51
+ path = self.root / name
52
+ if not path.exists():
53
+ path.write_text("[]\n", encoding="utf-8")
54
+
55
+ def require(self):
56
+ if not (self.root / CITATIONS).exists():
57
+ raise SystemExit(f"{self.root} is not a suken-cite project (no {CITATIONS}). Run: suken-cite init --dir {self.root}")
58
+
59
+ def load(self, name):
60
+ path = self.root / name
61
+ return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
62
+
63
+ def save(self, name, items):
64
+ (self.root / name).write_text(json.dumps(items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
65
+
66
+ def upsert(self, name, item):
67
+ items = [i for i in self.load(name) if i["id"] != item["id"]]
68
+ items.append(item)
69
+ self.save(name, items)
70
+
71
+ def find(self, name, item_id):
72
+ return next((i for i in self.load(name) if i["id"] == item_id), None)
73
+
74
+ def import_file(self, path):
75
+ src = Path(path).expanduser().resolve()
76
+ if not src.is_file():
77
+ raise SystemExit(f"File not found: {path}")
78
+ if src.is_relative_to(self.root):
79
+ return src.relative_to(self.root).as_posix()
80
+ dest = self.root / "sources" / src.name
81
+ if dest.exists() and dest.read_bytes() != src.read_bytes():
82
+ raise SystemExit(f"{dest} already exists with different content. Rename the source file or copy it into the project yourself.")
83
+ dest.parent.mkdir(parents=True, exist_ok=True)
84
+ shutil.copyfile(src, dest)
85
+ return dest.relative_to(self.root).as_posix()
86
+
87
+ def validate(self, cite):
88
+ kind = cite.get("type", "web")
89
+ if kind == "web":
90
+ doc = self.find(DOCS, cite.get("doc"))
91
+ if not doc:
92
+ return [f"web doc '{cite.get('doc')}' is not in {DOCS} (run suken-cite snapshot first)"]
93
+ return [] if squash(cite["quote"]) in squash(html_text(self.root / doc["file"])) else ["quote not found in the web snapshot"]
94
+ path = self.root / cite["file"]
95
+ if not path.is_file():
96
+ return [f"file not found: {cite['file']}"]
97
+ if kind == "code":
98
+ total = len(path.read_text(encoding="utf-8", errors="replace").split("\n"))
99
+ first, last = cite["lines"]
100
+ return [] if 1 <= first <= last <= total else [f"lines {first}-{last} outside 1-{total}"]
101
+ if kind == "csv":
102
+ sep = "\t" if path.suffix.lower() == ".tsv" else ","
103
+ with path.open(encoding="utf-8", errors="replace", newline="") as f:
104
+ rows = list(csv.reader(f, delimiter=sep))
105
+ first, last = cite["rows"]
106
+ problems = [] if 1 <= first <= last <= len(rows) else [f"rows {first}-{last} outside 1-{len(rows)}"]
107
+ head = rows[0] if rows else []
108
+ bad = [c for c in cite.get("cols", []) if (c not in head if isinstance(c, str) else not 1 <= c <= len(head))]
109
+ return problems + ([f"unknown columns {bad}; header is {head}"] if bad else [])
110
+ if kind == "docx":
111
+ return [] if squash(cite["quote"]) in squash(docx_text(path)) else ["quote not found in the .docx"]
112
+ if kind == "pdf":
113
+ text = pdf_page_text(path, cite["page"])
114
+ if text is None:
115
+ return []
116
+ return [] if squash(cite["quote"]) in squash(text) else [f"quote not found on page {cite['page']}"]
117
+ return []
118
+
119
+ def shot_path(self, cite):
120
+ return self.root / "shots" / f"{cite['id']}.png"
121
+
122
+
123
+ def shot_url(cite, base):
124
+ cid = quote(cite["id"])
125
+ if cite.get("type", "web") == "web":
126
+ return f"{base}/view/{quote(cite['doc'])}.html?cite={cid}&shot=1"
127
+ return f"{base}/render/{cite['type']}.html?cite={cid}"
128
+
129
+
130
+ def cite_url(cite, base):
131
+ return f"{base}/c.html?id={quote(cite['id'])}"