mdview-cli 1.0.0__tar.gz → 1.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.
- {mdview_cli-1.0.0/src/mdview_cli.egg-info → mdview_cli-1.1.0}/PKG-INFO +14 -4
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/README.md +13 -3
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/api.py +25 -11
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/cli.py +96 -8
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/state.py +12 -7
- {mdview_cli-1.0.0 → mdview_cli-1.1.0/src/mdview_cli.egg-info}/PKG-INFO +14 -4
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/SOURCES.txt +1 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/scm_file_list.json +8 -7
- mdview_cli-1.1.0/src/mdview_cli.egg-info/scm_version.json +8 -0
- mdview_cli-1.1.0/tests/test_api.py +61 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/tests/test_cli.py +88 -0
- mdview_cli-1.0.0/src/mdview_cli.egg-info/scm_version.json +0 -8
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/.github/workflows/release.yml +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/.gitignore +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/LICENSE +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/pyproject.toml +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/setup.cfg +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/__init__.py +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/config.py +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/dependency_links.txt +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/entry_points.txt +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/requires.txt +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/top_level.txt +0 -0
- {mdview_cli-1.0.0 → mdview_cli-1.1.0}/tests/e2e/test_free_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdview-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Iterate on Markdown and verify every revision with mdview.io
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://mdview.io
|
|
@@ -81,8 +81,15 @@ When — and only when — you decide to publish, make it public; and grab a PDF
|
|
|
81
81
|
at any point:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
mdv share spec.md
|
|
85
|
-
mdv
|
|
84
|
+
mdv share spec.md # mint the public /s/ link (from then on it follows your syncs)
|
|
85
|
+
mdv share spec.md --slug my-doc # …or with a custom /s/my-doc slug
|
|
86
|
+
mdv export spec.md # render the current revision to spec.pdf
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or do both steps at once — sync, auto-repair, and share in one call:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
mdv publish spec.md --slug my-doc # sync + repair + share, atomically
|
|
86
93
|
```
|
|
87
94
|
|
|
88
95
|
Useful around the loop:
|
|
@@ -91,6 +98,7 @@ Useful around the loop:
|
|
|
91
98
|
mdv sync spec.md --no-fix # fast sync: skip auto-repair, just report failures
|
|
92
99
|
mdv export spec.md --no-sync # PDF of the last synced revision; local edits stay private
|
|
93
100
|
mdv list # all saved documents with their URLs
|
|
101
|
+
mdv status spec.md # document ID, public state, last sync, local edits pending?
|
|
94
102
|
mdv unlink spec.md # forget the file↔document link; the document stays online
|
|
95
103
|
```
|
|
96
104
|
|
|
@@ -145,11 +153,13 @@ for reading a local file without uploading it, use `mdv open`.
|
|
|
145
153
|
| `mdv FILE.md` / `mdv preview` | no | Publish anonymously, open the rendered page (30-day link) |
|
|
146
154
|
| `mdv open` | no | Render locally in the browser; nothing is uploaded |
|
|
147
155
|
| `mdv sync` | yes | Update a saved document at its private /p/ URL, verify + auto-repair |
|
|
148
|
-
| `mdv share` | yes | Make a synced document public at its /s/ URL |
|
|
156
|
+
| `mdv share` | yes | Make a synced document public at its /s/ URL, optionally with `--slug` |
|
|
157
|
+
| `mdv publish` | yes | Sync + auto-repair + share in one call |
|
|
149
158
|
| `mdv verify` | yes | Re-check rendering of a synced document |
|
|
150
159
|
| `mdv fix` | yes | Repair broken Mermaid diagrams, with a local backup |
|
|
151
160
|
| `mdv export` | yes | Export a synced document to PDF |
|
|
152
161
|
| `mdv list` | yes | List saved documents |
|
|
162
|
+
| `mdv status` | yes | Show a synced document's ID, public state, slug, and whether local edits are unsynced |
|
|
153
163
|
| `mdv unlink` | no | Remove the local file↔document association |
|
|
154
164
|
| `mdv keys set/list/path/unset` | — | Manage the stored CLI token |
|
|
155
165
|
|
|
@@ -63,8 +63,15 @@ When — and only when — you decide to publish, make it public; and grab a PDF
|
|
|
63
63
|
at any point:
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
mdv share spec.md
|
|
67
|
-
mdv
|
|
66
|
+
mdv share spec.md # mint the public /s/ link (from then on it follows your syncs)
|
|
67
|
+
mdv share spec.md --slug my-doc # …or with a custom /s/my-doc slug
|
|
68
|
+
mdv export spec.md # render the current revision to spec.pdf
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or do both steps at once — sync, auto-repair, and share in one call:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
mdv publish spec.md --slug my-doc # sync + repair + share, atomically
|
|
68
75
|
```
|
|
69
76
|
|
|
70
77
|
Useful around the loop:
|
|
@@ -73,6 +80,7 @@ Useful around the loop:
|
|
|
73
80
|
mdv sync spec.md --no-fix # fast sync: skip auto-repair, just report failures
|
|
74
81
|
mdv export spec.md --no-sync # PDF of the last synced revision; local edits stay private
|
|
75
82
|
mdv list # all saved documents with their URLs
|
|
83
|
+
mdv status spec.md # document ID, public state, last sync, local edits pending?
|
|
76
84
|
mdv unlink spec.md # forget the file↔document link; the document stays online
|
|
77
85
|
```
|
|
78
86
|
|
|
@@ -127,11 +135,13 @@ for reading a local file without uploading it, use `mdv open`.
|
|
|
127
135
|
| `mdv FILE.md` / `mdv preview` | no | Publish anonymously, open the rendered page (30-day link) |
|
|
128
136
|
| `mdv open` | no | Render locally in the browser; nothing is uploaded |
|
|
129
137
|
| `mdv sync` | yes | Update a saved document at its private /p/ URL, verify + auto-repair |
|
|
130
|
-
| `mdv share` | yes | Make a synced document public at its /s/ URL |
|
|
138
|
+
| `mdv share` | yes | Make a synced document public at its /s/ URL, optionally with `--slug` |
|
|
139
|
+
| `mdv publish` | yes | Sync + auto-repair + share in one call |
|
|
131
140
|
| `mdv verify` | yes | Re-check rendering of a synced document |
|
|
132
141
|
| `mdv fix` | yes | Repair broken Mermaid diagrams, with a local backup |
|
|
133
142
|
| `mdv export` | yes | Export a synced document to PDF |
|
|
134
143
|
| `mdv list` | yes | List saved documents |
|
|
144
|
+
| `mdv status` | yes | Show a synced document's ID, public state, slug, and whether local edits are unsynced |
|
|
135
145
|
| `mdv unlink` | no | Remove the local file↔document association |
|
|
136
146
|
| `mdv keys set/list/path/unset` | — | Manage the stored CLI token |
|
|
137
147
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
1
3
|
import httpx
|
|
2
4
|
|
|
5
|
+
BUSY_RETRY_ATTEMPTS = 4
|
|
6
|
+
BUSY_RETRY_DELAY = 1.0
|
|
7
|
+
|
|
3
8
|
|
|
4
9
|
class ApiError(RuntimeError):
|
|
5
10
|
def __init__(self, message: str, status_code: int | None = None):
|
|
@@ -16,18 +21,24 @@ class MdviewApi:
|
|
|
16
21
|
)
|
|
17
22
|
|
|
18
23
|
def request(self, method: str, path: str, **kwargs):
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
except httpx.HTTPError as error:
|
|
22
|
-
raise ApiError(f"Could not reach mdview.io: {error}") from error
|
|
23
|
-
if response.is_error:
|
|
24
|
+
delay = BUSY_RETRY_DELAY
|
|
25
|
+
for attempt in range(BUSY_RETRY_ATTEMPTS):
|
|
24
26
|
try:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
response = self.client.request(method, path, **kwargs)
|
|
28
|
+
except httpx.HTTPError as error:
|
|
29
|
+
raise ApiError(f"Could not reach mdview.io: {error}") from error
|
|
30
|
+
if response.is_error:
|
|
31
|
+
try:
|
|
32
|
+
body = response.json()
|
|
33
|
+
message = body.get("message") or body.get("error") or response.reason_phrase
|
|
34
|
+
except ValueError:
|
|
35
|
+
message = response.reason_phrase
|
|
36
|
+
if "busy" in str(message).lower() and attempt < BUSY_RETRY_ATTEMPTS - 1:
|
|
37
|
+
time.sleep(delay)
|
|
38
|
+
delay *= 2
|
|
39
|
+
continue
|
|
40
|
+
raise ApiError(str(message), response.status_code)
|
|
41
|
+
return response
|
|
31
42
|
|
|
32
43
|
def create(self, title, content):
|
|
33
44
|
return self.request("POST", "/api/documents", json={"title": title, "content": content}).json()
|
|
@@ -44,6 +55,9 @@ class MdviewApi:
|
|
|
44
55
|
def share(self, document_id):
|
|
45
56
|
return self.request("POST", f"/api/documents/{document_id}/share", json={}).json()
|
|
46
57
|
|
|
58
|
+
def set_slug(self, document_id, slug):
|
|
59
|
+
return self.request("PATCH", f"/api/documents/{document_id}/share/slug", json={"slug": slug}).json()
|
|
60
|
+
|
|
47
61
|
def verify(self, document_id, status=False):
|
|
48
62
|
suffix = "/status" if status else ""
|
|
49
63
|
return self.request("GET", f"/api/documents/{document_id}/verify{suffix}").json()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import functools
|
|
3
|
+
import hashlib
|
|
3
4
|
import io
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
@@ -67,6 +68,10 @@ def share_url(share):
|
|
|
67
68
|
return f"{base_url()}/s/{share}"
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def content_hash(markdown: str) -> str:
|
|
72
|
+
return hashlib.sha256(markdown.encode("utf-8")).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
70
75
|
def verdict(report) -> bool:
|
|
71
76
|
if "renderable" in report:
|
|
72
77
|
return bool(report["renderable"])
|
|
@@ -112,7 +117,7 @@ def print_result(payload, as_json=False, *, check=True):
|
|
|
112
117
|
raise click.exceptions.Exit(1)
|
|
113
118
|
|
|
114
119
|
|
|
115
|
-
def sync_file(path: Path, *, api=None, state=None, title=None, document_id=None, share=False, verify=True):
|
|
120
|
+
def sync_file(path: Path, *, api=None, state=None, title=None, document_id=None, share=False, slug=None, verify=True):
|
|
116
121
|
if not path.is_file():
|
|
117
122
|
raise click.UsageError(f"File not found: {path}")
|
|
118
123
|
markdown = path.read_text(encoding="utf-8")
|
|
@@ -128,21 +133,25 @@ def sync_file(path: Path, *, api=None, state=None, title=None, document_id=None,
|
|
|
128
133
|
created_body = api.create(doc_title, markdown)
|
|
129
134
|
document_id = created_body["id"]
|
|
130
135
|
updated_at = created_body.get("updated_at")
|
|
136
|
+
existing_share = existing_share or share_id(created_body)
|
|
131
137
|
else:
|
|
132
138
|
try:
|
|
133
139
|
updated = api.update(document_id, doc_title, markdown, updated_at)
|
|
134
140
|
updated_at = updated.get("updated_at")
|
|
141
|
+
existing_share = existing_share or share_id(updated)
|
|
135
142
|
except ApiError as error:
|
|
136
143
|
if error.status_code != 404 or document_id != (association and association["document_id"]):
|
|
137
144
|
raise
|
|
138
145
|
created_body = api.create(doc_title, markdown)
|
|
139
146
|
document_id = created_body["id"]
|
|
140
147
|
updated_at = created_body.get("updated_at")
|
|
141
|
-
existing_share =
|
|
148
|
+
existing_share = share_id(created_body)
|
|
142
149
|
created = True
|
|
143
150
|
if share and not existing_share:
|
|
144
151
|
existing_share = share_id(api.share(document_id))
|
|
145
|
-
|
|
152
|
+
if slug and existing_share and slug != existing_share:
|
|
153
|
+
existing_share = share_id(api.set_slug(document_id, slug)) or slug
|
|
154
|
+
state.put(base_url(), path, document_id, existing_share, updated_at, content_hash(markdown))
|
|
146
155
|
report = api.verify(document_id) if verify else {"renderable": True, "diagrams": {}, "tables": {}}
|
|
147
156
|
return result_payload(document_id, existing_share, report, created)
|
|
148
157
|
|
|
@@ -201,14 +210,14 @@ def keys_list():
|
|
|
201
210
|
click.echo("default" if get_token() else "No token configured.")
|
|
202
211
|
|
|
203
212
|
|
|
204
|
-
def repair_synced_file(file, api, state, document_id):
|
|
213
|
+
def repair_synced_file(file, api, state, document_id, *, share=False, slug=None):
|
|
205
214
|
repaired = api.fix(document_id)
|
|
206
215
|
markdown = repaired.get("markdown")
|
|
207
216
|
if markdown is None:
|
|
208
217
|
raise ServiceError("The repair response did not include Markdown.")
|
|
209
218
|
backup = backup_file(file, document_id)
|
|
210
219
|
atomic_write(file, markdown)
|
|
211
|
-
payload = sync_file(file, api=api, state=state)
|
|
220
|
+
payload = sync_file(file, api=api, state=state, share=share, slug=slug)
|
|
212
221
|
payload["backup"] = str(backup)
|
|
213
222
|
payload["fixed"] = repaired.get("diagrams", {}).get("fixed", 0)
|
|
214
223
|
return payload
|
|
@@ -234,21 +243,51 @@ def sync_command(file, no_fix, title, document_id, as_json):
|
|
|
234
243
|
print_result(payload, as_json)
|
|
235
244
|
|
|
236
245
|
|
|
246
|
+
@cli.command("publish")
|
|
247
|
+
@click.argument("file", type=click.Path(path_type=Path))
|
|
248
|
+
@click.option("--title")
|
|
249
|
+
@click.option("--slug", help="Set a custom share slug, e.g. /s/SLUG.")
|
|
250
|
+
@click.option("--id", "document_id")
|
|
251
|
+
@click.option("--no-fix", "no_fix", is_flag=True, help="Skip auto-repair; just report failing diagrams.")
|
|
252
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
253
|
+
@service_errors
|
|
254
|
+
def publish_command(file, title, slug, document_id, no_fix, as_json):
|
|
255
|
+
"""Sync FILE, repair broken diagrams, and make it public in one step."""
|
|
256
|
+
api = api_for_token()
|
|
257
|
+
state = DocumentState()
|
|
258
|
+
payload = sync_file(file, api=api, state=state, title=title, document_id=document_id, share=True, slug=slug)
|
|
259
|
+
if not payload["renderable"] and not no_fix:
|
|
260
|
+
try:
|
|
261
|
+
payload = repair_synced_file(file, api, state, payload["document_id"], share=True, slug=slug)
|
|
262
|
+
except ApiError as error:
|
|
263
|
+
click.secho(f"Auto-repair unavailable: {error}", fg="yellow", err=True)
|
|
264
|
+
print_result(payload, as_json)
|
|
265
|
+
|
|
266
|
+
|
|
237
267
|
@cli.command("share")
|
|
238
268
|
@click.argument("file", required=False, type=click.Path(path_type=Path))
|
|
239
269
|
@click.option("--id", "document_id")
|
|
270
|
+
@click.option("--slug", help="Set a custom share slug, e.g. /s/SLUG.")
|
|
271
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
240
272
|
@service_errors
|
|
241
|
-
def share_command(file, document_id):
|
|
273
|
+
def share_command(file, document_id, slug, as_json):
|
|
242
274
|
"""Make FILE's document public and print its share URL."""
|
|
243
275
|
state = DocumentState()
|
|
244
276
|
association = state.get(base_url(), file) if file else None
|
|
245
277
|
document_id = document_id or (association and association["document_id"])
|
|
246
278
|
if not document_id:
|
|
247
279
|
raise click.UsageError("Provide a synced FILE or --id.")
|
|
248
|
-
|
|
280
|
+
api = api_for_token()
|
|
281
|
+
sid = share_id(api.share(document_id))
|
|
282
|
+
if slug and slug != sid:
|
|
283
|
+
sid = share_id(api.set_slug(document_id, slug)) or slug
|
|
249
284
|
if file:
|
|
250
285
|
state.put(base_url(), file, document_id, sid, None)
|
|
251
|
-
|
|
286
|
+
url = share_url(sid)
|
|
287
|
+
if as_json:
|
|
288
|
+
click.echo(json.dumps({"document_id": document_id, "slug": sid, "share_url": url}, separators=(",", ":")))
|
|
289
|
+
else:
|
|
290
|
+
click.echo(url)
|
|
252
291
|
|
|
253
292
|
|
|
254
293
|
@cli.command("verify")
|
|
@@ -365,6 +404,55 @@ def unlink_command(file):
|
|
|
365
404
|
click.echo("Association removed; remote document was not deleted." if removed else "No association found.")
|
|
366
405
|
|
|
367
406
|
|
|
407
|
+
@cli.command("status")
|
|
408
|
+
@click.argument("file", type=click.Path(path_type=Path))
|
|
409
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
410
|
+
@service_errors
|
|
411
|
+
def status_command(file, as_json):
|
|
412
|
+
"""Show FILE's linked document, public state, and whether it has unsynced local changes."""
|
|
413
|
+
state = DocumentState()
|
|
414
|
+
association = state.get(base_url(), file)
|
|
415
|
+
if not association:
|
|
416
|
+
if as_json:
|
|
417
|
+
click.echo(json.dumps({"file": str(file), "linked": False}, separators=(",", ":")))
|
|
418
|
+
else:
|
|
419
|
+
click.echo(f"{file}: not linked. Run 'mdv sync {file}' to create an association.")
|
|
420
|
+
return
|
|
421
|
+
document_id = association["document_id"]
|
|
422
|
+
documents = api_for_token().documents()
|
|
423
|
+
remote = next((doc for doc in documents if doc.get("id") == document_id), None)
|
|
424
|
+
sid = share_id(remote) if remote else association["share_id"]
|
|
425
|
+
if remote and sid != association["share_id"]:
|
|
426
|
+
state.put(base_url(), file, document_id, sid, association["updated_at"])
|
|
427
|
+
local_hash = content_hash(file.read_text(encoding="utf-8")) if file.is_file() else None
|
|
428
|
+
dirty = bool(local_hash and association["content_hash"] and local_hash != association["content_hash"])
|
|
429
|
+
payload = {
|
|
430
|
+
"file": str(file),
|
|
431
|
+
"linked": True,
|
|
432
|
+
"document_id": document_id,
|
|
433
|
+
"url": f"{base_url()}/p/{document_id}",
|
|
434
|
+
"published": bool(sid),
|
|
435
|
+
"slug": sid,
|
|
436
|
+
"share_url": share_url(sid) if sid else None,
|
|
437
|
+
"last_synced": association["updated_at"],
|
|
438
|
+
"dirty": dirty,
|
|
439
|
+
"found_remote": remote is not None,
|
|
440
|
+
}
|
|
441
|
+
if as_json:
|
|
442
|
+
click.echo(json.dumps(payload, separators=(",", ":")))
|
|
443
|
+
return
|
|
444
|
+
click.echo(f"Document: {payload['document_id']}")
|
|
445
|
+
click.echo(f"Private: {payload['url']}")
|
|
446
|
+
click.echo(f"Share: {payload['share_url']}" if payload["share_url"] else "Public: no")
|
|
447
|
+
click.echo(f"Last synced: {payload['last_synced'] or 'unknown'}")
|
|
448
|
+
if not payload["found_remote"]:
|
|
449
|
+
click.secho("Warning: document not found in account's document list.", fg="yellow", err=True)
|
|
450
|
+
if dirty:
|
|
451
|
+
click.secho("Local file has changes not yet synced.", fg="yellow")
|
|
452
|
+
elif local_hash is not None:
|
|
453
|
+
click.echo("Local file matches last synced version.")
|
|
454
|
+
|
|
455
|
+
|
|
368
456
|
@cli.command("preview")
|
|
369
457
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
370
458
|
@click.option("--title")
|
|
@@ -19,6 +19,10 @@ class DocumentState:
|
|
|
19
19
|
PRIMARY KEY (server, path)
|
|
20
20
|
)"""
|
|
21
21
|
)
|
|
22
|
+
columns = [row[1] for row in self.connection.execute("PRAGMA table_info(associations)")]
|
|
23
|
+
if "content_hash" not in columns:
|
|
24
|
+
self.connection.execute("ALTER TABLE associations ADD COLUMN content_hash TEXT")
|
|
25
|
+
self.connection.commit()
|
|
22
26
|
|
|
23
27
|
@staticmethod
|
|
24
28
|
def canonical(path: Path) -> str:
|
|
@@ -26,22 +30,23 @@ class DocumentState:
|
|
|
26
30
|
|
|
27
31
|
def get(self, server: str, path: Path):
|
|
28
32
|
row = self.connection.execute(
|
|
29
|
-
"SELECT document_id, share_id, updated_at FROM associations WHERE server = ? AND path = ?",
|
|
33
|
+
"SELECT document_id, share_id, updated_at, content_hash FROM associations WHERE server = ? AND path = ?",
|
|
30
34
|
(server, self.canonical(path)),
|
|
31
35
|
).fetchone()
|
|
32
36
|
if not row:
|
|
33
37
|
return None
|
|
34
|
-
return {"document_id": row[0], "share_id": row[1], "updated_at": row[2]}
|
|
38
|
+
return {"document_id": row[0], "share_id": row[1], "updated_at": row[2], "content_hash": row[3]}
|
|
35
39
|
|
|
36
|
-
def put(self, server: str, path: Path, document_id: str, share_id=None, updated_at=None):
|
|
40
|
+
def put(self, server: str, path: Path, document_id: str, share_id=None, updated_at=None, content_hash=None):
|
|
37
41
|
self.connection.execute(
|
|
38
|
-
"""INSERT INTO associations (server, path, document_id, share_id, updated_at)
|
|
39
|
-
VALUES (?, ?, ?, ?, ?)
|
|
42
|
+
"""INSERT INTO associations (server, path, document_id, share_id, updated_at, content_hash)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
40
44
|
ON CONFLICT(server, path) DO UPDATE SET
|
|
41
45
|
document_id=excluded.document_id,
|
|
42
46
|
share_id=COALESCE(excluded.share_id, associations.share_id),
|
|
43
|
-
updated_at=COALESCE(excluded.updated_at, associations.updated_at)
|
|
44
|
-
|
|
47
|
+
updated_at=COALESCE(excluded.updated_at, associations.updated_at),
|
|
48
|
+
content_hash=COALESCE(excluded.content_hash, associations.content_hash)""",
|
|
49
|
+
(server, self.canonical(path), document_id, share_id, updated_at, content_hash),
|
|
45
50
|
)
|
|
46
51
|
self.connection.commit()
|
|
47
52
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdview-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Iterate on Markdown and verify every revision with mdview.io
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://mdview.io
|
|
@@ -81,8 +81,15 @@ When — and only when — you decide to publish, make it public; and grab a PDF
|
|
|
81
81
|
at any point:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
mdv share spec.md
|
|
85
|
-
mdv
|
|
84
|
+
mdv share spec.md # mint the public /s/ link (from then on it follows your syncs)
|
|
85
|
+
mdv share spec.md --slug my-doc # …or with a custom /s/my-doc slug
|
|
86
|
+
mdv export spec.md # render the current revision to spec.pdf
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or do both steps at once — sync, auto-repair, and share in one call:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
mdv publish spec.md --slug my-doc # sync + repair + share, atomically
|
|
86
93
|
```
|
|
87
94
|
|
|
88
95
|
Useful around the loop:
|
|
@@ -91,6 +98,7 @@ Useful around the loop:
|
|
|
91
98
|
mdv sync spec.md --no-fix # fast sync: skip auto-repair, just report failures
|
|
92
99
|
mdv export spec.md --no-sync # PDF of the last synced revision; local edits stay private
|
|
93
100
|
mdv list # all saved documents with their URLs
|
|
101
|
+
mdv status spec.md # document ID, public state, last sync, local edits pending?
|
|
94
102
|
mdv unlink spec.md # forget the file↔document link; the document stays online
|
|
95
103
|
```
|
|
96
104
|
|
|
@@ -145,11 +153,13 @@ for reading a local file without uploading it, use `mdv open`.
|
|
|
145
153
|
| `mdv FILE.md` / `mdv preview` | no | Publish anonymously, open the rendered page (30-day link) |
|
|
146
154
|
| `mdv open` | no | Render locally in the browser; nothing is uploaded |
|
|
147
155
|
| `mdv sync` | yes | Update a saved document at its private /p/ URL, verify + auto-repair |
|
|
148
|
-
| `mdv share` | yes | Make a synced document public at its /s/ URL |
|
|
156
|
+
| `mdv share` | yes | Make a synced document public at its /s/ URL, optionally with `--slug` |
|
|
157
|
+
| `mdv publish` | yes | Sync + auto-repair + share in one call |
|
|
149
158
|
| `mdv verify` | yes | Re-check rendering of a synced document |
|
|
150
159
|
| `mdv fix` | yes | Repair broken Mermaid diagrams, with a local backup |
|
|
151
160
|
| `mdv export` | yes | Export a synced document to PDF |
|
|
152
161
|
| `mdv list` | yes | List saved documents |
|
|
162
|
+
| `mdv status` | yes | Show a synced document's ID, public state, slug, and whether local edits are unsynced |
|
|
153
163
|
| `mdv unlink` | no | Remove the local file↔document association |
|
|
154
164
|
| `mdv keys set/list/path/unset` | — | Manage the stored CLI token |
|
|
155
165
|
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
|
-
".gitignore",
|
|
4
|
-
"README.md",
|
|
5
3
|
"pyproject.toml",
|
|
6
4
|
"LICENSE",
|
|
7
|
-
"
|
|
5
|
+
"README.md",
|
|
6
|
+
".gitignore",
|
|
7
|
+
".github/workflows/release.yml",
|
|
8
|
+
"tests/test_api.py",
|
|
9
|
+
"tests/test_cli.py",
|
|
10
|
+
"tests/e2e/test_free_tools.py",
|
|
8
11
|
"src/mdview_cli/state.py",
|
|
9
12
|
"src/mdview_cli/config.py",
|
|
10
|
-
"src/mdview_cli/cli.py",
|
|
11
13
|
"src/mdview_cli/__init__.py",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"tests/e2e/test_free_tools.py"
|
|
14
|
+
"src/mdview_cli/cli.py",
|
|
15
|
+
"src/mdview_cli/api.py"
|
|
15
16
|
]
|
|
16
17
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from mdview_cli.api import ApiError, MdviewApi
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeResponse:
|
|
7
|
+
def __init__(self, status_code, body):
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self.is_error = status_code >= 400
|
|
10
|
+
self._body = body
|
|
11
|
+
self.reason_phrase = "error"
|
|
12
|
+
|
|
13
|
+
def json(self):
|
|
14
|
+
return self._body
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_api(monkeypatch, responses):
|
|
18
|
+
api = MdviewApi("https://mdview.io", token="mdv1_test")
|
|
19
|
+
calls = []
|
|
20
|
+
|
|
21
|
+
def fake_request(method, path, **kwargs):
|
|
22
|
+
calls.append((method, path))
|
|
23
|
+
return responses[len(calls) - 1]
|
|
24
|
+
|
|
25
|
+
monkeypatch.setattr(api.client, "request", fake_request)
|
|
26
|
+
monkeypatch.setattr("mdview_cli.api.time.sleep", lambda seconds: None)
|
|
27
|
+
return api, calls
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_retries_on_busy_then_succeeds(monkeypatch):
|
|
31
|
+
responses = [
|
|
32
|
+
FakeResponse(409, {"error": "export_busy"}),
|
|
33
|
+
FakeResponse(409, {"error": "export_busy"}),
|
|
34
|
+
FakeResponse(200, {"ok": True}),
|
|
35
|
+
]
|
|
36
|
+
api, calls = make_api(monkeypatch, responses)
|
|
37
|
+
|
|
38
|
+
result = api.request("GET", "/api/documents/doc123/export/pdf")
|
|
39
|
+
|
|
40
|
+
assert result.json() == {"ok": True}
|
|
41
|
+
assert len(calls) == 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_gives_up_after_bounded_retries(monkeypatch):
|
|
45
|
+
responses = [FakeResponse(409, {"error": "export_busy"}) for _ in range(10)]
|
|
46
|
+
api, calls = make_api(monkeypatch, responses)
|
|
47
|
+
|
|
48
|
+
with pytest.raises(ApiError, match="export_busy"):
|
|
49
|
+
api.request("GET", "/api/documents/doc123/export/pdf")
|
|
50
|
+
|
|
51
|
+
assert len(calls) == 4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_does_not_retry_non_busy_errors(monkeypatch):
|
|
55
|
+
responses = [FakeResponse(404, {"error": "not_found"})]
|
|
56
|
+
api, calls = make_api(monkeypatch, responses)
|
|
57
|
+
|
|
58
|
+
with pytest.raises(ApiError, match="not_found"):
|
|
59
|
+
api.request("GET", "/api/documents/doc123")
|
|
60
|
+
|
|
61
|
+
assert len(calls) == 1
|
|
@@ -15,6 +15,7 @@ class FakeApi:
|
|
|
15
15
|
self.verified = 0
|
|
16
16
|
self.shared = 0
|
|
17
17
|
self.missing_on_update = False
|
|
18
|
+
self.slug_calls = []
|
|
18
19
|
|
|
19
20
|
def create(self, title, content):
|
|
20
21
|
self.created += 1
|
|
@@ -31,6 +32,13 @@ class FakeApi:
|
|
|
31
32
|
self.shared += 1
|
|
32
33
|
return {"shortId": "share123"}
|
|
33
34
|
|
|
35
|
+
def set_slug(self, document_id, slug):
|
|
36
|
+
self.slug_calls.append((document_id, slug))
|
|
37
|
+
return {"customSlug": slug}
|
|
38
|
+
|
|
39
|
+
def documents(self):
|
|
40
|
+
return [{"id": "doc123", "title": "Doc", "customSlug": "share123"}]
|
|
41
|
+
|
|
34
42
|
def verify(self, document_id, status=False):
|
|
35
43
|
self.verified += 1
|
|
36
44
|
return {
|
|
@@ -266,6 +274,86 @@ def test_fix_local_rewrites_file_without_syncing(monkeypatch, tmp_path):
|
|
|
266
274
|
assert api.verified == 0
|
|
267
275
|
|
|
268
276
|
|
|
277
|
+
def test_share_accepts_custom_slug_and_json(monkeypatch, tmp_path):
|
|
278
|
+
api = FakeApi()
|
|
279
|
+
configure(monkeypatch, tmp_path, api)
|
|
280
|
+
document = tmp_path / "doc.md"
|
|
281
|
+
document.write_text("# Doc\n", encoding="utf-8")
|
|
282
|
+
runner = CliRunner()
|
|
283
|
+
assert runner.invoke(cli, ["sync", str(document)]).exit_code == 0
|
|
284
|
+
|
|
285
|
+
result = runner.invoke(cli, ["share", str(document), "--slug", "my-slug", "--json"])
|
|
286
|
+
|
|
287
|
+
assert result.exit_code == 0
|
|
288
|
+
payload = json.loads(result.output)
|
|
289
|
+
assert payload["slug"] == "my-slug"
|
|
290
|
+
assert payload["share_url"] == "https://mdview.io/s/my-slug"
|
|
291
|
+
assert api.slug_calls == [("doc123", "my-slug")]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_publish_syncs_and_shares_with_slug_in_one_step(monkeypatch, tmp_path):
|
|
295
|
+
api = FakeApi()
|
|
296
|
+
configure(monkeypatch, tmp_path, api)
|
|
297
|
+
document = tmp_path / "doc.md"
|
|
298
|
+
document.write_text("# Doc\n", encoding="utf-8")
|
|
299
|
+
|
|
300
|
+
result = CliRunner().invoke(cli, ["publish", str(document), "--slug", "my-slug", "--json"])
|
|
301
|
+
|
|
302
|
+
assert result.exit_code == 0
|
|
303
|
+
payload = json.loads(result.output)
|
|
304
|
+
assert payload["share_url"] == "https://mdview.io/s/my-slug"
|
|
305
|
+
assert api.created == 1
|
|
306
|
+
assert api.shared == 1
|
|
307
|
+
assert api.slug_calls == [("doc123", "my-slug")]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_publish_repairs_broken_diagrams_before_sharing(monkeypatch, tmp_path):
|
|
311
|
+
api = FakeApi(renderable=False)
|
|
312
|
+
configure(monkeypatch, tmp_path, api)
|
|
313
|
+
document = tmp_path / "broken.md"
|
|
314
|
+
document.write_text("# Broken\n\n```mermaid\nnope\n```\n", encoding="utf-8")
|
|
315
|
+
|
|
316
|
+
result = CliRunner().invoke(cli, ["publish", str(document), "--json"])
|
|
317
|
+
|
|
318
|
+
assert result.exit_code == 0
|
|
319
|
+
payload = json.loads(result.output)
|
|
320
|
+
assert payload["renderable"] is True
|
|
321
|
+
assert payload["share_url"] == "https://mdview.io/s/share123"
|
|
322
|
+
assert document.read_text(encoding="utf-8").startswith("# Fixed")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_status_reports_unlinked_file(monkeypatch, tmp_path):
|
|
326
|
+
api = FakeApi()
|
|
327
|
+
configure(monkeypatch, tmp_path, api)
|
|
328
|
+
document = tmp_path / "doc.md"
|
|
329
|
+
document.write_text("# Doc\n", encoding="utf-8")
|
|
330
|
+
|
|
331
|
+
result = CliRunner().invoke(cli, ["status", str(document), "--json"])
|
|
332
|
+
|
|
333
|
+
assert result.exit_code == 0
|
|
334
|
+
assert json.loads(result.output) == {"file": str(document), "linked": False}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_status_reconciles_share_state_from_server_and_detects_dirty_file(monkeypatch, tmp_path):
|
|
338
|
+
api = FakeApi()
|
|
339
|
+
configure(monkeypatch, tmp_path, api)
|
|
340
|
+
document = tmp_path / "doc.md"
|
|
341
|
+
document.write_text("# Doc\n", encoding="utf-8")
|
|
342
|
+
runner = CliRunner()
|
|
343
|
+
assert runner.invoke(cli, ["sync", str(document)]).exit_code == 0
|
|
344
|
+
|
|
345
|
+
clean = runner.invoke(cli, ["status", str(document), "--json"])
|
|
346
|
+
document.write_text("# Doc\n\nEdited locally.\n", encoding="utf-8")
|
|
347
|
+
dirty = runner.invoke(cli, ["status", str(document), "--json"])
|
|
348
|
+
|
|
349
|
+
clean_payload = json.loads(clean.output)
|
|
350
|
+
dirty_payload = json.loads(dirty.output)
|
|
351
|
+
assert clean_payload["published"] is True
|
|
352
|
+
assert clean_payload["share_url"] == "https://mdview.io/s/share123"
|
|
353
|
+
assert clean_payload["dirty"] is False
|
|
354
|
+
assert dirty_payload["dirty"] is True
|
|
355
|
+
|
|
356
|
+
|
|
269
357
|
def test_fix_backs_up_rewrites_resyncs_and_verifies(monkeypatch, tmp_path):
|
|
270
358
|
api = FakeApi(renderable=False)
|
|
271
359
|
configure(monkeypatch, tmp_path, api)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|