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.
Files changed (24) hide show
  1. {mdview_cli-1.0.0/src/mdview_cli.egg-info → mdview_cli-1.1.0}/PKG-INFO +14 -4
  2. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/README.md +13 -3
  3. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/api.py +25 -11
  4. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/cli.py +96 -8
  5. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/state.py +12 -7
  6. {mdview_cli-1.0.0 → mdview_cli-1.1.0/src/mdview_cli.egg-info}/PKG-INFO +14 -4
  7. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/SOURCES.txt +1 -0
  8. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/scm_file_list.json +8 -7
  9. mdview_cli-1.1.0/src/mdview_cli.egg-info/scm_version.json +8 -0
  10. mdview_cli-1.1.0/tests/test_api.py +61 -0
  11. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/tests/test_cli.py +88 -0
  12. mdview_cli-1.0.0/src/mdview_cli.egg-info/scm_version.json +0 -8
  13. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/.github/workflows/release.yml +0 -0
  14. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/.gitignore +0 -0
  15. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/LICENSE +0 -0
  16. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/pyproject.toml +0 -0
  17. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/setup.cfg +0 -0
  18. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/__init__.py +0 -0
  19. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli/config.py +0 -0
  20. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/dependency_links.txt +0 -0
  21. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/entry_points.txt +0 -0
  22. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/requires.txt +0 -0
  23. {mdview_cli-1.0.0 → mdview_cli-1.1.0}/src/mdview_cli.egg-info/top_level.txt +0 -0
  24. {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.0.0
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 # mint the public /s/ link (from then on it follows your syncs)
85
- mdv export spec.md # render the current revision to spec.pdf
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 # mint the public /s/ link (from then on it follows your syncs)
67
- mdv export spec.md # render the current revision to spec.pdf
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
- try:
20
- response = self.client.request(method, path, **kwargs)
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
- body = response.json()
26
- message = body.get("message") or body.get("error") or response.reason_phrase
27
- except ValueError:
28
- message = response.reason_phrase
29
- raise ApiError(str(message), response.status_code)
30
- return response
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 = None
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
- state.put(base_url(), path, document_id, existing_share, updated_at)
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
- sid = share_id(api_for_token().share(document_id))
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
- click.echo(share_url(sid))
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
- (server, self.canonical(path), document_id, share_id, updated_at),
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.0.0
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 # mint the public /s/ link (from then on it follows your syncs)
85
- mdv export spec.md # render the current revision to spec.pdf
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
 
@@ -16,5 +16,6 @@ src/mdview_cli.egg-info/requires.txt
16
16
  src/mdview_cli.egg-info/scm_file_list.json
17
17
  src/mdview_cli.egg-info/scm_version.json
18
18
  src/mdview_cli.egg-info/top_level.txt
19
+ tests/test_api.py
19
20
  tests/test_cli.py
20
21
  tests/e2e/test_free_tools.py
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "files": [
3
- ".gitignore",
4
- "README.md",
5
3
  "pyproject.toml",
6
4
  "LICENSE",
7
- "src/mdview_cli/api.py",
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
- ".github/workflows/release.yml",
13
- "tests/test_cli.py",
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,8 @@
1
+ {
2
+ "tag": "1.1.0",
3
+ "distance": 0,
4
+ "node": "gdb3fffae21c71c4d9c01e14eb7aa3823f6bbf9e6",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-22"
8
+ }
@@ -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)
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "1.0.0",
3
- "distance": 0,
4
- "node": "g3c5d7f760b99e8bc0a5b93e1cf747fd01ac52421",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-18"
8
- }
File without changes
File without changes
File without changes
File without changes