mdview-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- mdview_cli/api.py +58 -0
- mdview_cli/cli.py +324 -0
- mdview_cli/config.py +49 -0
- mdview_cli/state.py +54 -0
- mdview_cli-0.1.0.dist-info/METADATA +63 -0
- mdview_cli-0.1.0.dist-info/RECORD +11 -0
- mdview_cli-0.1.0.dist-info/WHEEL +5 -0
- mdview_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mdview_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- mdview_cli-0.1.0.dist-info/top_level.txt +1 -0
mdview_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mdview_cli/api.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ApiError(RuntimeError):
|
|
5
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
self.status_code = status_code
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MdviewApi:
|
|
11
|
+
def __init__(self, base_url: str, token: str | None = None, timeout: float = 75):
|
|
12
|
+
self.client = httpx.Client(
|
|
13
|
+
base_url=base_url,
|
|
14
|
+
headers={"Authorization": f"Bearer {token}"} if token else {},
|
|
15
|
+
timeout=timeout,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
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
|
+
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
|
|
31
|
+
|
|
32
|
+
def create(self, title, content):
|
|
33
|
+
return self.request("POST", "/api/documents", json={"title": title, "content": content}).json()
|
|
34
|
+
|
|
35
|
+
def publish(self, title, content):
|
|
36
|
+
return self.request("POST", "/api/public/publish", json={"title": title, "content": content}).json()
|
|
37
|
+
|
|
38
|
+
def update(self, document_id, title, content, updated_at=None):
|
|
39
|
+
body = {"title": title, "content": content}
|
|
40
|
+
if updated_at:
|
|
41
|
+
body["clientUpdatedAt"] = updated_at
|
|
42
|
+
return self.request("PUT", f"/api/documents/{document_id}", json=body).json()
|
|
43
|
+
|
|
44
|
+
def share(self, document_id):
|
|
45
|
+
return self.request("POST", f"/api/documents/{document_id}/share", json={}).json()
|
|
46
|
+
|
|
47
|
+
def verify(self, document_id, status=False):
|
|
48
|
+
suffix = "/status" if status else ""
|
|
49
|
+
return self.request("GET", f"/api/documents/{document_id}/verify{suffix}").json()
|
|
50
|
+
|
|
51
|
+
def fix(self, document_id):
|
|
52
|
+
return self.request("POST", f"/api/documents/{document_id}/fix/diagrams", json={}).json()
|
|
53
|
+
|
|
54
|
+
def export_pdf(self, document_id):
|
|
55
|
+
return self.request("GET", f"/api/documents/{document_id}/export/pdf").content
|
|
56
|
+
|
|
57
|
+
def documents(self):
|
|
58
|
+
return self.request("GET", "/api/documents").json()
|
mdview_cli/cli.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import functools
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import webbrowser
|
|
10
|
+
import zipfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from .api import ApiError, MdviewApi
|
|
17
|
+
from .config import base_url, data_dir, get_token, keys_path, remove_token, save_token
|
|
18
|
+
from .state import DocumentState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServiceError(click.ClickException):
|
|
22
|
+
exit_code = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def service_errors(command):
|
|
26
|
+
@functools.wraps(command)
|
|
27
|
+
def wrapper(*args, **kwargs):
|
|
28
|
+
try:
|
|
29
|
+
return command(*args, **kwargs)
|
|
30
|
+
except ApiError as error:
|
|
31
|
+
raise ServiceError(str(error)) from error
|
|
32
|
+
|
|
33
|
+
return wrapper
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def atomic_write(path: Path, data):
|
|
37
|
+
binary = isinstance(data, bytes)
|
|
38
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
39
|
+
try:
|
|
40
|
+
with os.fdopen(fd, "wb" if binary else "w", encoding=None if binary else "utf-8") as stream:
|
|
41
|
+
stream.write(data)
|
|
42
|
+
os.replace(temporary, path)
|
|
43
|
+
finally:
|
|
44
|
+
if os.path.exists(temporary):
|
|
45
|
+
os.unlink(temporary)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def api_for_token(token=None):
|
|
49
|
+
credential = token or get_token()
|
|
50
|
+
if not credential:
|
|
51
|
+
raise click.UsageError("No CLI token. Run 'mdv keys set' or set MDVIEW_TOKEN.")
|
|
52
|
+
return MdviewApi(base_url(), credential)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def title_for(path: Path, markdown: str, explicit=None) -> str:
|
|
56
|
+
if explicit:
|
|
57
|
+
return explicit
|
|
58
|
+
match = re.search(r"^#\s+(.+?)\s*$", markdown, re.MULTILINE)
|
|
59
|
+
return match.group(1) if match else path.stem
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def share_id(body):
|
|
63
|
+
return body.get("customSlug") or body.get("custom_slug") or body.get("shortId") or body.get("short_id")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def share_url(share):
|
|
67
|
+
return f"{base_url()}/s/{share}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verdict(report) -> bool:
|
|
71
|
+
if "renderable" in report:
|
|
72
|
+
return bool(report["renderable"])
|
|
73
|
+
return bool(report.get("known")) and int(report.get("diagrams", {}).get("failing", 0)) == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def result_payload(document_id, share, report, created=False):
|
|
77
|
+
return {
|
|
78
|
+
"document_id": document_id,
|
|
79
|
+
"created": created,
|
|
80
|
+
"share_url": share_url(share) if share else None,
|
|
81
|
+
"renderable": verdict(report),
|
|
82
|
+
"diagrams": report.get("diagrams", {}),
|
|
83
|
+
"tables": report.get("tables", {}),
|
|
84
|
+
"failures": report.get("failures", []),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def print_result(payload, as_json=False, *, check=True):
|
|
89
|
+
if as_json:
|
|
90
|
+
click.echo(json.dumps(payload, separators=(",", ":")))
|
|
91
|
+
else:
|
|
92
|
+
click.echo(f"Document: {payload['document_id']}")
|
|
93
|
+
if payload.get("share_url"):
|
|
94
|
+
click.echo(f"Share: {payload['share_url']}")
|
|
95
|
+
diagrams = payload.get("diagrams") or {}
|
|
96
|
+
failures = payload.get("failures") or []
|
|
97
|
+
failing = int(diagrams.get("failing", len(failures)) or 0)
|
|
98
|
+
total = int(diagrams.get("total", 0) or 0)
|
|
99
|
+
if payload["renderable"]:
|
|
100
|
+
click.secho(f"Renderable: yes ({total} diagrams, {payload.get('tables', {}).get('total', 0)} tables)", fg="green")
|
|
101
|
+
else:
|
|
102
|
+
click.secho(f"Renderable: no ({failing} of {total} diagrams failed)", fg="red", err=True)
|
|
103
|
+
for failure in failures:
|
|
104
|
+
index = failure.get("index", "?")
|
|
105
|
+
error = failure.get("error") or failure.get("message") or "render failed"
|
|
106
|
+
click.echo(f" Diagram {index}: {error}", err=True)
|
|
107
|
+
click.echo("Run 'mdv fix FILE' to repair the diagrams.", err=True)
|
|
108
|
+
if check and not payload["renderable"]:
|
|
109
|
+
raise click.exceptions.Exit(1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def sync_file(path: Path, *, api=None, state=None, title=None, document_id=None, share=True, verify=True):
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
raise click.UsageError(f"File not found: {path}")
|
|
115
|
+
markdown = path.read_text(encoding="utf-8")
|
|
116
|
+
api = api or api_for_token()
|
|
117
|
+
state = state or DocumentState()
|
|
118
|
+
association = state.get(base_url(), path)
|
|
119
|
+
document_id = document_id or (association and association["document_id"])
|
|
120
|
+
existing_share = association and association["share_id"]
|
|
121
|
+
updated_at = association and association["updated_at"]
|
|
122
|
+
created = document_id is None
|
|
123
|
+
doc_title = title_for(path, markdown, title)
|
|
124
|
+
if created:
|
|
125
|
+
created_body = api.create(doc_title, markdown)
|
|
126
|
+
document_id = created_body["id"]
|
|
127
|
+
updated_at = created_body.get("updated_at")
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
updated = api.update(document_id, doc_title, markdown, updated_at)
|
|
131
|
+
updated_at = updated.get("updated_at")
|
|
132
|
+
except ApiError as error:
|
|
133
|
+
if error.status_code != 404 or document_id != (association and association["document_id"]):
|
|
134
|
+
raise
|
|
135
|
+
created_body = api.create(doc_title, markdown)
|
|
136
|
+
document_id = created_body["id"]
|
|
137
|
+
updated_at = created_body.get("updated_at")
|
|
138
|
+
existing_share = None
|
|
139
|
+
created = True
|
|
140
|
+
if share and not existing_share:
|
|
141
|
+
existing_share = share_id(api.share(document_id))
|
|
142
|
+
state.put(base_url(), path, document_id, existing_share, updated_at)
|
|
143
|
+
report = api.verify(document_id) if verify else {"renderable": True, "diagrams": {}, "tables": {}}
|
|
144
|
+
return result_payload(document_id, existing_share, report, created)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MdvGroup(click.Group):
|
|
148
|
+
"""Treat `mdv FILE` as shorthand for `mdv preview FILE`."""
|
|
149
|
+
|
|
150
|
+
def resolve_command(self, ctx, args):
|
|
151
|
+
argument = args[0]
|
|
152
|
+
if self.get_command(ctx, argument) is None and (
|
|
153
|
+
Path(argument).is_file() or argument.endswith((".md", ".markdown"))
|
|
154
|
+
):
|
|
155
|
+
return "preview", self.commands["preview"], args
|
|
156
|
+
return super().resolve_command(ctx, args)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@click.group(cls=MdvGroup)
|
|
160
|
+
@click.version_option()
|
|
161
|
+
def cli():
|
|
162
|
+
"""Iterate on Markdown and verify every revision with mdview.io.
|
|
163
|
+
|
|
164
|
+
Running `mdv FILE.md` previews the file (same as `mdv preview FILE.md`).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@cli.group()
|
|
169
|
+
def keys():
|
|
170
|
+
"""Manage the mdview.io CLI token."""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@keys.command("set")
|
|
174
|
+
def keys_set():
|
|
175
|
+
token = click.prompt("CLI token", hide_input=True).strip()
|
|
176
|
+
if not token.startswith("mdv1_"):
|
|
177
|
+
raise click.UsageError("CLI tokens start with mdv1_.")
|
|
178
|
+
try:
|
|
179
|
+
api_for_token(token).documents()
|
|
180
|
+
except ApiError as error:
|
|
181
|
+
raise ServiceError(f"Token validation failed: {error}") from error
|
|
182
|
+
save_token(token)
|
|
183
|
+
click.echo(f"Token saved to {keys_path()}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@keys.command("path")
|
|
187
|
+
def keys_path_command():
|
|
188
|
+
click.echo(keys_path())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@keys.command("unset")
|
|
192
|
+
def keys_unset():
|
|
193
|
+
click.echo("Token removed." if remove_token() else "No stored token.")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@keys.command("list")
|
|
197
|
+
def keys_list():
|
|
198
|
+
click.echo("default" if get_token() else "No token configured.")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@cli.command("sync")
|
|
202
|
+
@click.argument("file", type=click.Path(path_type=Path))
|
|
203
|
+
@click.option("--title")
|
|
204
|
+
@click.option("--id", "document_id")
|
|
205
|
+
@click.option("--no-share", is_flag=True)
|
|
206
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
207
|
+
@service_errors
|
|
208
|
+
def sync_command(file, title, document_id, no_share, as_json):
|
|
209
|
+
"""Update FILE, preserve its URL, and verify PDF rendering."""
|
|
210
|
+
payload = sync_file(file, title=title, document_id=document_id, share=not no_share)
|
|
211
|
+
print_result(payload, as_json)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@cli.command("verify")
|
|
215
|
+
@click.argument("file", required=False, type=click.Path(path_type=Path))
|
|
216
|
+
@click.option("--id", "document_id")
|
|
217
|
+
@click.option("--status", is_flag=True)
|
|
218
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
219
|
+
@service_errors
|
|
220
|
+
def verify_command(file, document_id, status, as_json):
|
|
221
|
+
association = DocumentState().get(base_url(), file) if file else None
|
|
222
|
+
document_id = document_id or (association and association["document_id"])
|
|
223
|
+
if not document_id:
|
|
224
|
+
raise click.UsageError("Provide an associated FILE or --id.")
|
|
225
|
+
report = api_for_token().verify(document_id, status=status)
|
|
226
|
+
payload = result_payload(document_id, association and association["share_id"], report)
|
|
227
|
+
print_result(payload, as_json)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@cli.command("fix")
|
|
231
|
+
@click.argument("file", type=click.Path(path_type=Path))
|
|
232
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
233
|
+
@service_errors
|
|
234
|
+
def fix_command(file, as_json):
|
|
235
|
+
api = api_for_token()
|
|
236
|
+
state = DocumentState()
|
|
237
|
+
initial = sync_file(file, api=api, state=state, verify=False)
|
|
238
|
+
repaired = api.fix(initial["document_id"])
|
|
239
|
+
markdown = repaired.get("markdown")
|
|
240
|
+
if markdown is None:
|
|
241
|
+
raise ServiceError("The repair response did not include Markdown.")
|
|
242
|
+
backup_dir = data_dir() / "backups" / initial["document_id"]
|
|
243
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
245
|
+
backup = backup_dir / f"{stamp}-{file.name}"
|
|
246
|
+
shutil.copy2(file, backup)
|
|
247
|
+
atomic_write(file, markdown)
|
|
248
|
+
payload = sync_file(file, api=api, state=state)
|
|
249
|
+
payload["backup"] = str(backup)
|
|
250
|
+
payload["fixed"] = repaired.get("diagrams", {}).get("fixed", 0)
|
|
251
|
+
print_result(payload, as_json)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cli.command("export")
|
|
255
|
+
@click.argument("file", type=click.Path(path_type=Path))
|
|
256
|
+
@click.option("-o", "--output", type=click.Path(path_type=Path))
|
|
257
|
+
@click.option("--force", is_flag=True)
|
|
258
|
+
@service_errors
|
|
259
|
+
def export_command(file, output, force):
|
|
260
|
+
api = api_for_token()
|
|
261
|
+
payload = sync_file(file, api=api)
|
|
262
|
+
print_result(payload, check=False)
|
|
263
|
+
if not payload["renderable"] and not force:
|
|
264
|
+
raise click.ClickException("Document is not Renderable; use --force to export anyway.")
|
|
265
|
+
output = output or file.with_suffix(".pdf")
|
|
266
|
+
pdf = api.export_pdf(payload["document_id"])
|
|
267
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
atomic_write(output, pdf)
|
|
269
|
+
click.echo(f"PDF: {output}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@cli.command("list")
|
|
273
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
274
|
+
@service_errors
|
|
275
|
+
def list_command(as_json):
|
|
276
|
+
documents = api_for_token().documents()
|
|
277
|
+
if as_json:
|
|
278
|
+
click.echo(json.dumps(documents, separators=(",", ":")))
|
|
279
|
+
else:
|
|
280
|
+
for document in documents:
|
|
281
|
+
sid = share_id(document)
|
|
282
|
+
suffix = f" {share_url(sid)}" if sid else ""
|
|
283
|
+
click.echo(f"{document.get('id')} {document.get('title', 'Untitled')}{suffix}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@cli.command("unlink")
|
|
287
|
+
@click.argument("file", type=click.Path(path_type=Path))
|
|
288
|
+
def unlink_command(file):
|
|
289
|
+
removed = DocumentState().unlink(base_url(), file)
|
|
290
|
+
click.echo("Association removed; remote document was not deleted." if removed else "No association found.")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@cli.command("preview")
|
|
294
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
295
|
+
@click.option("--title")
|
|
296
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
297
|
+
@service_errors
|
|
298
|
+
def preview_command(file, title, as_json):
|
|
299
|
+
"""Publish FILE anonymously and open its temporary mdview page."""
|
|
300
|
+
markdown = file.read_text(encoding="utf-8")
|
|
301
|
+
body = MdviewApi(base_url()).publish(title_for(file, markdown, title), markdown)
|
|
302
|
+
url = body.get("shareUrl") or body.get("viewerUrl")
|
|
303
|
+
if not url:
|
|
304
|
+
sid = share_id(body)
|
|
305
|
+
url = share_url(sid) if sid else None
|
|
306
|
+
if not url:
|
|
307
|
+
raise ServiceError("The publish response did not include a share URL.")
|
|
308
|
+
if as_json:
|
|
309
|
+
click.echo(json.dumps(body, separators=(",", ":")))
|
|
310
|
+
else:
|
|
311
|
+
click.echo(f"Preview: {url}")
|
|
312
|
+
webbrowser.open(url)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@cli.command("open")
|
|
316
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
317
|
+
def open_command(file):
|
|
318
|
+
buffer = io.BytesIO()
|
|
319
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
320
|
+
archive.write(file, arcname=file.name)
|
|
321
|
+
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
322
|
+
url = f"{base_url()}/#mvd=zip:{payload}"
|
|
323
|
+
webbrowser.open(url)
|
|
324
|
+
click.echo(f"Opened {file}")
|
mdview_cli/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_config_path, user_data_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def config_dir() -> Path:
|
|
9
|
+
override = os.environ.get("MDVIEW_CONFIG_DIR")
|
|
10
|
+
return Path(override) if override else user_config_path("mdview-cli")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def data_dir() -> Path:
|
|
14
|
+
override = os.environ.get("MDVIEW_DATA_DIR")
|
|
15
|
+
return Path(override) if override else user_data_path("mdview-cli")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def keys_path() -> Path:
|
|
19
|
+
return config_dir() / "keys.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def base_url() -> str:
|
|
23
|
+
return os.environ.get("MDVIEW_BASE_URL", "https://mdview.io").rstrip("/")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_token() -> str | None:
|
|
27
|
+
value = os.environ.get("MDVIEW_TOKEN")
|
|
28
|
+
if value:
|
|
29
|
+
return value.strip()
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(keys_path().read_text(encoding="utf-8")).get("default")
|
|
32
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_token(token: str) -> None:
|
|
37
|
+
path = keys_path()
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(json.dumps({"default": token}) + "\n", encoding="utf-8")
|
|
40
|
+
if os.name == "posix":
|
|
41
|
+
path.chmod(0o600)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remove_token() -> bool:
|
|
45
|
+
try:
|
|
46
|
+
keys_path().unlink()
|
|
47
|
+
return True
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return False
|
mdview_cli/state.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .config import data_dir
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DocumentState:
|
|
8
|
+
def __init__(self, path: Path | None = None):
|
|
9
|
+
db_path = path or (data_dir() / "state.db")
|
|
10
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
self.connection = sqlite3.connect(db_path)
|
|
12
|
+
self.connection.execute(
|
|
13
|
+
"""CREATE TABLE IF NOT EXISTS associations (
|
|
14
|
+
server TEXT NOT NULL,
|
|
15
|
+
path TEXT NOT NULL,
|
|
16
|
+
document_id TEXT NOT NULL,
|
|
17
|
+
share_id TEXT,
|
|
18
|
+
updated_at TEXT,
|
|
19
|
+
PRIMARY KEY (server, path)
|
|
20
|
+
)"""
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def canonical(path: Path) -> str:
|
|
25
|
+
return str(path.expanduser().resolve())
|
|
26
|
+
|
|
27
|
+
def get(self, server: str, path: Path):
|
|
28
|
+
row = self.connection.execute(
|
|
29
|
+
"SELECT document_id, share_id, updated_at FROM associations WHERE server = ? AND path = ?",
|
|
30
|
+
(server, self.canonical(path)),
|
|
31
|
+
).fetchone()
|
|
32
|
+
if not row:
|
|
33
|
+
return None
|
|
34
|
+
return {"document_id": row[0], "share_id": row[1], "updated_at": row[2]}
|
|
35
|
+
|
|
36
|
+
def put(self, server: str, path: Path, document_id: str, share_id=None, updated_at=None):
|
|
37
|
+
self.connection.execute(
|
|
38
|
+
"""INSERT INTO associations (server, path, document_id, share_id, updated_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(server, path) DO UPDATE SET
|
|
41
|
+
document_id=excluded.document_id,
|
|
42
|
+
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),
|
|
45
|
+
)
|
|
46
|
+
self.connection.commit()
|
|
47
|
+
|
|
48
|
+
def unlink(self, server: str, path: Path) -> bool:
|
|
49
|
+
cursor = self.connection.execute(
|
|
50
|
+
"DELETE FROM associations WHERE server = ? AND path = ?",
|
|
51
|
+
(server, self.canonical(path)),
|
|
52
|
+
)
|
|
53
|
+
self.connection.commit()
|
|
54
|
+
return cursor.rowcount > 0
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdview-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Iterate on Markdown and verify every revision with mdview.io
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://mdview.io
|
|
7
|
+
Project-URL: Repository, https://github.com/gogainda/mdview-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/gogainda/mdview-cli/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: click>=8.1
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: platformdirs>=4.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# mdv
|
|
20
|
+
|
|
21
|
+
`mdv` opens Markdown in mdview.io and can keep an evolving file attached to one
|
|
22
|
+
stable document while checking every revision in the PDF renderer.
|
|
23
|
+
|
|
24
|
+
## Quick start — no token required
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv tool install mdview-cli
|
|
28
|
+
mdv architecture.md
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`mdv FILE.md` is shorthand for `mdv preview FILE.md`: it publishes the file
|
|
32
|
+
anonymously and opens a temporary mdview page you can share (30-day expiry,
|
|
33
|
+
2 MB limit). No account, token, or configuration required.
|
|
34
|
+
|
|
35
|
+
`open` renders a local file in the browser without uploading anything at all:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
mdv open architecture.md
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Verify every revision
|
|
42
|
+
|
|
43
|
+
Saved-document commands require a mdview.io CLI token because they update your
|
|
44
|
+
documents between runs. Configure it once, then sync the same file after every
|
|
45
|
+
edit:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
mdv keys set
|
|
49
|
+
mdv sync architecture.md
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Edit the file and run `mdv sync architecture.md` again. The share URL stays the
|
|
53
|
+
same. Exit status `0` means every Mermaid diagram rendered; status `1` means the
|
|
54
|
+
document needs attention.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
mdv fix architecture.md
|
|
58
|
+
mdv export architecture.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`open` and `preview` are token-free. `sync`, `verify`, `fix`, `export`, and `list` use a token
|
|
62
|
+
to access Saved documents. In CI, set `MDVIEW_TOKEN` instead of storing it.
|
|
63
|
+
`MDVIEW_BASE_URL` overrides the service URL for development.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mdview_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
mdview_cli/api.py,sha256=ecupcg7cNJvFtM3iMCvrRyjSrMPWJFrH_WFHPv4HrQY,2302
|
|
3
|
+
mdview_cli/cli.py,sha256=L3qnQ_3B-MwTpnwWX9EiQoKpAJ44S7Jsx7U6RWmeIcQ,11609
|
|
4
|
+
mdview_cli/config.py,sha256=dJu3k5q_LrrZe0_Rn5cpC-26BbvBpQb8skbu1-_FGW8,1248
|
|
5
|
+
mdview_cli/state.py,sha256=EHL5iy1Izbm41fOg-NFxmVScy3jjaLnHfF8ZNPoNvFI,2067
|
|
6
|
+
mdview_cli-0.1.0.dist-info/licenses/LICENSE,sha256=ZKMOPBiHvTgIhnX2ej1pankBmQOyFjx_bNzharc9DQQ,1066
|
|
7
|
+
mdview_cli-0.1.0.dist-info/METADATA,sha256=lJsa3YH25-hNDybd0mcRI-51JyV5FSgkBiWLmRQy46A,1888
|
|
8
|
+
mdview_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
mdview_cli-0.1.0.dist-info/entry_points.txt,sha256=smr7TlDjPfpnl3Cgihhg2OfFsSQrQJxnBoqk8NE1G44,43
|
|
10
|
+
mdview_cli-0.1.0.dist-info/top_level.txt,sha256=Uli2yMYFokqicsUBvA0FO3YhMFhPBIYuiGjEjq35aIY,11
|
|
11
|
+
mdview_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mdview.io
|
|
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 @@
|
|
|
1
|
+
mdview_cli
|