md-collab-editor 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.
- md_collab_editor/__init__.py +3 -0
- md_collab_editor/__main__.py +3 -0
- md_collab_editor/server.py +406 -0
- md_collab_editor/static/app.css +309 -0
- md_collab_editor/static/app.js +947 -0
- md_collab_editor/static/index.html +126 -0
- md_collab_editor/static/render.js +174 -0
- md_collab_editor-0.1.0.dist-info/METADATA +97 -0
- md_collab_editor-0.1.0.dist-info/RECORD +12 -0
- md_collab_editor-0.1.0.dist-info/WHEEL +4 -0
- md_collab_editor-0.1.0.dist-info/entry_points.txt +2 -0
- md_collab_editor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MD Collaborative Editor: a local GitHub-style markdown editor with Claude in the loop.
|
|
3
|
+
|
|
4
|
+
Serves the editor UI, reads and writes .md files under a root folder, pushes
|
|
5
|
+
on-disk changes to the browser (so edits made by Claude Code in a terminal show
|
|
6
|
+
up live), and answers "ask Claude" requests by running `claude -p` headless.
|
|
7
|
+
|
|
8
|
+
md-editor # edit the current folder
|
|
9
|
+
md-editor ~/notes # edit a folder
|
|
10
|
+
md-editor ~/proj/README.md # edit one file (its folder becomes the root)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import webbrowser
|
|
22
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import parse_qs, urlparse
|
|
25
|
+
|
|
26
|
+
HERE = Path(__file__).resolve().parent
|
|
27
|
+
STATIC = HERE / "static"
|
|
28
|
+
SKILLS_DIR = Path.home() / ".claude" / "skills"
|
|
29
|
+
MD_EXT = (".md", ".markdown")
|
|
30
|
+
SKIP_DIRS = {"node_modules", "__pycache__", ".git", ".venv", "venv"}
|
|
31
|
+
|
|
32
|
+
ROOT: Path = Path.cwd()
|
|
33
|
+
CLAUDE_BIN = shutil.which("claude") or "claude"
|
|
34
|
+
CHROME_BIN = next((b for b in (os.environ.get("MDEDIT_CHROME"), "google-chrome", "google-chrome-stable",
|
|
35
|
+
"chromium", "chromium-browser") if b and shutil.which(b)), None)
|
|
36
|
+
|
|
37
|
+
PDF_TEMPLATE = """<!doctype html>
|
|
38
|
+
<html lang="en"><head><meta charset="utf-8"><title>{title}</title>
|
|
39
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.1/github-markdown-light.min.css">
|
|
40
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
41
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
|
42
|
+
<style>
|
|
43
|
+
@page {{ size: A4; margin: 16mm 15mm 18mm; }}
|
|
44
|
+
body {{ margin: 0; background: #fff; }}
|
|
45
|
+
.markdown-body {{ font-size: 11pt; max-width: none; padding: 0; }}
|
|
46
|
+
.markdown-body .math-block {{ margin-bottom: 16px; }}
|
|
47
|
+
.markdown-body .mermaid-box {{ text-align: center; margin-bottom: 16px; }}
|
|
48
|
+
.markdown-body .mermaid-box svg {{ max-width: 100%; height: auto; }}
|
|
49
|
+
.markdown-body .mermaid-src, .markdown-body .anchor {{ display: none; }}
|
|
50
|
+
.markdown-body .task-list-item {{ list-style: none; }}
|
|
51
|
+
.markdown-body .task-list-item input {{ margin: 0 0.2em 0.25em -1.4em; vertical-align: middle; }}
|
|
52
|
+
.markdown-body pre, .markdown-body table, .markdown-body img, .markdown-body .markdown-alert,
|
|
53
|
+
.markdown-body .math-block, .markdown-body .mermaid-box {{ break-inside: avoid; }}
|
|
54
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {{ break-after: avoid; }}
|
|
55
|
+
.markdown-body pre code {{ white-space: pre-wrap; word-break: break-word; }}
|
|
56
|
+
</style></head>
|
|
57
|
+
<body><article class="markdown-body">{body}</article></body></html>
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
SYSTEM_PROMPT = """You are a writing assistant embedded in a markdown editor. The user has \
|
|
61
|
+
highlighted part of a markdown document and asked you to do something with it. \
|
|
62
|
+
The full document is supplied for context; the highlighted part is wrapped in \
|
|
63
|
+
⟦SELECTION⟧ … ⟦/SELECTION⟧ markers.
|
|
64
|
+
|
|
65
|
+
Rules:
|
|
66
|
+
- Reply with the result ONLY: no preamble, no explanation, no closing remarks, \
|
|
67
|
+
no surrounding quotes, no code fence around the whole reply.
|
|
68
|
+
- In "replace" mode your reply replaces the selection verbatim, so it must be \
|
|
69
|
+
valid markdown that fits seamlessly where the selection was. Keep the existing \
|
|
70
|
+
markdown structure (headings, lists, links, emphasis, math, code) unless asked \
|
|
71
|
+
to change it. Do not include text from outside the selection.
|
|
72
|
+
- In "comment" mode reply with concise feedback in markdown; the document is not changed.
|
|
73
|
+
- British English spelling unless the document clearly uses another convention.
|
|
74
|
+
- If asked to use a skill, invoke it with the Skill tool before writing."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------- files
|
|
78
|
+
|
|
79
|
+
def safe_path(rel: str) -> Path:
|
|
80
|
+
p = (ROOT / rel).resolve()
|
|
81
|
+
if p != ROOT and ROOT not in p.parents:
|
|
82
|
+
raise ValueError("path escapes the root folder")
|
|
83
|
+
return p
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def version_of(p: Path) -> str:
|
|
87
|
+
return str(p.stat().st_mtime_ns) if p.exists() else "0"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_files():
|
|
91
|
+
out = []
|
|
92
|
+
for dirpath, dirnames, filenames in os.walk(ROOT):
|
|
93
|
+
dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS)
|
|
94
|
+
for f in sorted(filenames):
|
|
95
|
+
if f.lower().endswith(MD_EXT):
|
|
96
|
+
p = Path(dirpath) / f
|
|
97
|
+
out.append({"path": p.relative_to(ROOT).as_posix(), "version": version_of(p)})
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def browse(dirpath: str):
|
|
102
|
+
"""Folders and markdown files in `dirpath`, for the in-app file browser."""
|
|
103
|
+
d = Path(dirpath).expanduser().resolve() if dirpath else ROOT
|
|
104
|
+
if not d.is_dir():
|
|
105
|
+
raise ValueError(f"not a folder: {d}")
|
|
106
|
+
entries = []
|
|
107
|
+
try:
|
|
108
|
+
for p in d.iterdir():
|
|
109
|
+
if p.name.startswith(".") or p.name in SKIP_DIRS:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
if p.is_dir():
|
|
113
|
+
entries.append({"name": p.name, "dir": True})
|
|
114
|
+
elif p.suffix.lower() in MD_EXT:
|
|
115
|
+
entries.append({"name": p.name, "dir": False})
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
except PermissionError:
|
|
119
|
+
raise ValueError(f"permission denied: {d}")
|
|
120
|
+
entries.sort(key=lambda e: (not e["dir"], e["name"].lower()))
|
|
121
|
+
return {"dir": str(d), "parent": str(d.parent) if d.parent != d else None,
|
|
122
|
+
"home": str(Path.home()), "entries": entries}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def set_root(target: str):
|
|
126
|
+
"""Point the editor at a folder, or at a file's folder; returns the file to open."""
|
|
127
|
+
global ROOT
|
|
128
|
+
t = Path(target).expanduser().resolve()
|
|
129
|
+
if t.is_file() and t.suffix.lower() in MD_EXT:
|
|
130
|
+
ROOT = t.parent
|
|
131
|
+
return t.name
|
|
132
|
+
if t.is_dir():
|
|
133
|
+
ROOT = t
|
|
134
|
+
return None
|
|
135
|
+
raise ValueError(f"not a markdown file or folder: {t}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def list_skills():
|
|
139
|
+
skills = []
|
|
140
|
+
if SKILLS_DIR.is_dir():
|
|
141
|
+
for d in sorted(SKILLS_DIR.iterdir()):
|
|
142
|
+
md = d / "SKILL.md"
|
|
143
|
+
if not md.is_file():
|
|
144
|
+
continue
|
|
145
|
+
desc = ""
|
|
146
|
+
try:
|
|
147
|
+
head = md.read_text(encoding="utf-8").split("---")
|
|
148
|
+
for line in head[1].splitlines() if len(head) > 2 else []:
|
|
149
|
+
if line.startswith("description:"):
|
|
150
|
+
desc = line.split(":", 1)[1].strip()
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
skills.append({"name": d.name, "description": desc})
|
|
154
|
+
return skills
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------- claude
|
|
158
|
+
|
|
159
|
+
def build_prompt(req):
|
|
160
|
+
doc = req.get("doc", "")
|
|
161
|
+
s, e = int(req.get("start", 0)), int(req.get("end", 0))
|
|
162
|
+
whole = s == 0 and e >= len(doc)
|
|
163
|
+
marked = doc if whole else doc[:s] + "⟦SELECTION⟧" + doc[s:e] + "⟦/SELECTION⟧" + doc[e:]
|
|
164
|
+
mode = req.get("mode", "replace")
|
|
165
|
+
parts = [
|
|
166
|
+
f'<document path="{req.get("path", "")}">\n{marked}\n</document>',
|
|
167
|
+
f"<selection>\n{doc[s:e]}\n</selection>" if not whole else
|
|
168
|
+
"<selection>The whole document is selected.</selection>",
|
|
169
|
+
f"<mode>{mode}</mode>",
|
|
170
|
+
f"<instruction>\n{req.get('instruction', '').strip()}\n</instruction>",
|
|
171
|
+
]
|
|
172
|
+
if req.get("previous"):
|
|
173
|
+
parts.append(f"<previous_attempt>\n{req['previous']}\n</previous_attempt>\n"
|
|
174
|
+
"The user was not satisfied with the previous attempt; the instruction "
|
|
175
|
+
"above is their feedback on it.")
|
|
176
|
+
parts.append("Reply with the replacement text only." if mode == "replace"
|
|
177
|
+
else "Reply with your comments only.")
|
|
178
|
+
return "\n\n".join(parts)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def unfence(text: str, original: str) -> str:
|
|
182
|
+
t = text.strip()
|
|
183
|
+
if t.startswith("```") and t.endswith("```") and not original.lstrip().startswith("```"):
|
|
184
|
+
lines = t.splitlines()
|
|
185
|
+
if len(lines) >= 2:
|
|
186
|
+
t = "\n".join(lines[1:-1])
|
|
187
|
+
return t
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def ask_claude(req):
|
|
191
|
+
cmd = [CLAUDE_BIN, "-p", "--output-format", "json", "--no-session-persistence",
|
|
192
|
+
"--append-system-prompt", SYSTEM_PROMPT]
|
|
193
|
+
if req.get("model"):
|
|
194
|
+
cmd += ["--model", req["model"]]
|
|
195
|
+
cmd += ["--tools", "Skill,Read", "--allowedTools", "Skill,Read"]
|
|
196
|
+
t0 = time.time()
|
|
197
|
+
proc = subprocess.run(cmd, input=build_prompt(req), capture_output=True, text=True,
|
|
198
|
+
timeout=600, cwd=tempfile.gettempdir())
|
|
199
|
+
try:
|
|
200
|
+
data = json.loads(proc.stdout)
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
|
+
raise RuntimeError((proc.stderr or proc.stdout or "no output from claude").strip()[:2000])
|
|
203
|
+
if data.get("is_error"):
|
|
204
|
+
raise RuntimeError(str(data.get("result") or data)[:2000])
|
|
205
|
+
doc = req.get("doc", "")
|
|
206
|
+
original = doc[int(req.get("start", 0)):int(req.get("end", 0))]
|
|
207
|
+
return {
|
|
208
|
+
"result": unfence(data.get("result", ""), original),
|
|
209
|
+
"seconds": round(time.time() - t0, 1),
|
|
210
|
+
"cost": data.get("total_cost_usd"),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------- pdf
|
|
215
|
+
|
|
216
|
+
def export_pdf(req):
|
|
217
|
+
"""Print the editor's rendered HTML to <name>.pdf beside the markdown file."""
|
|
218
|
+
if not CHROME_BIN:
|
|
219
|
+
raise RuntimeError("PDF export needs Google Chrome or Chromium (or set MDEDIT_CHROME)")
|
|
220
|
+
md = safe_path(req["path"])
|
|
221
|
+
pdf = md.with_suffix(".pdf")
|
|
222
|
+
page = PDF_TEMPLATE.format(title=md.stem.replace("<", "<"), body=req["html"])
|
|
223
|
+
# the page sits beside the .md so relative image links resolve
|
|
224
|
+
tmp_html = md.with_name(f".{md.stem}.print.html")
|
|
225
|
+
tmp_html.write_text(page, encoding="utf-8")
|
|
226
|
+
profile = tempfile.mkdtemp(prefix="mdedit-chrome-")
|
|
227
|
+
try:
|
|
228
|
+
proc = subprocess.run(
|
|
229
|
+
[CHROME_BIN, "--headless=new", "--disable-gpu", "--no-first-run", "--no-pdf-header-footer",
|
|
230
|
+
f"--user-data-dir={profile}", "--virtual-time-budget=15000", "--run-all-compositor-stages-before-draw",
|
|
231
|
+
f"--print-to-pdf={pdf}", tmp_html.as_uri()],
|
|
232
|
+
capture_output=True, text=True, timeout=120)
|
|
233
|
+
if proc.returncode != 0 or not pdf.exists():
|
|
234
|
+
raise RuntimeError(f"Chrome failed to print: {(proc.stderr or proc.stdout).strip()[-800:]}")
|
|
235
|
+
finally:
|
|
236
|
+
tmp_html.unlink(missing_ok=True)
|
|
237
|
+
shutil.rmtree(profile, ignore_errors=True)
|
|
238
|
+
return {"pdf": pdf.relative_to(ROOT).as_posix(), "bytes": pdf.stat().st_size}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------- http
|
|
242
|
+
|
|
243
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
244
|
+
def __init__(self, *a, **kw):
|
|
245
|
+
super().__init__(*a, directory=str(STATIC), **kw)
|
|
246
|
+
|
|
247
|
+
def log_message(self, fmt, *args):
|
|
248
|
+
if "/api/events" not in str(args[0] if args else ""):
|
|
249
|
+
super().log_message(fmt, *args)
|
|
250
|
+
|
|
251
|
+
def end_headers(self):
|
|
252
|
+
self.send_header("Cache-Control", "no-store")
|
|
253
|
+
super().end_headers()
|
|
254
|
+
|
|
255
|
+
def send_json(self, obj, status=200):
|
|
256
|
+
body = json.dumps(obj).encode()
|
|
257
|
+
self.send_response(status)
|
|
258
|
+
self.send_header("Content-Type", "application/json")
|
|
259
|
+
self.send_header("Content-Length", str(len(body)))
|
|
260
|
+
self.end_headers()
|
|
261
|
+
self.wfile.write(body)
|
|
262
|
+
|
|
263
|
+
def read_json(self):
|
|
264
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
265
|
+
return json.loads(self.rfile.read(n) or b"{}")
|
|
266
|
+
|
|
267
|
+
def do_GET(self):
|
|
268
|
+
u = urlparse(self.path)
|
|
269
|
+
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
|
270
|
+
try:
|
|
271
|
+
if u.path == "/api/config":
|
|
272
|
+
return self.send_json({"root": str(ROOT), "initial": INITIAL,
|
|
273
|
+
"skills": list_skills(), "files": list_files()})
|
|
274
|
+
if u.path == "/api/download":
|
|
275
|
+
p = safe_path(q["path"])
|
|
276
|
+
if not p.is_file() or p.suffix.lower() != ".pdf":
|
|
277
|
+
return self.send_json({"error": "not found"}, 404)
|
|
278
|
+
data = p.read_bytes()
|
|
279
|
+
self.send_response(200)
|
|
280
|
+
self.send_header("Content-Type", "application/pdf")
|
|
281
|
+
self.send_header("Content-Disposition", f'attachment; filename="{p.name}"')
|
|
282
|
+
self.send_header("Content-Length", str(len(data)))
|
|
283
|
+
self.end_headers()
|
|
284
|
+
self.wfile.write(data)
|
|
285
|
+
return
|
|
286
|
+
if u.path == "/api/browse":
|
|
287
|
+
return self.send_json(browse(q.get("dir", "")))
|
|
288
|
+
if u.path == "/api/files":
|
|
289
|
+
return self.send_json(list_files())
|
|
290
|
+
if u.path == "/api/file":
|
|
291
|
+
p = safe_path(q["path"])
|
|
292
|
+
if not p.is_file():
|
|
293
|
+
return self.send_json({"error": "not found"}, 404)
|
|
294
|
+
return self.send_json({"path": q["path"], "text": p.read_text(encoding="utf-8"),
|
|
295
|
+
"version": version_of(p)})
|
|
296
|
+
if u.path == "/api/events":
|
|
297
|
+
return self.events()
|
|
298
|
+
except (ValueError, KeyError) as exc:
|
|
299
|
+
return self.send_json({"error": str(exc)}, 400)
|
|
300
|
+
return super().do_GET()
|
|
301
|
+
|
|
302
|
+
def do_PUT(self):
|
|
303
|
+
if urlparse(self.path).path != "/api/file":
|
|
304
|
+
return self.send_json({"error": "unknown endpoint"}, 404)
|
|
305
|
+
try:
|
|
306
|
+
req = self.read_json()
|
|
307
|
+
p = safe_path(req["path"])
|
|
308
|
+
base = req.get("base_version")
|
|
309
|
+
if not req.get("force") and base is not None and version_of(p) != base:
|
|
310
|
+
return self.send_json({"error": "conflict", "version": version_of(p),
|
|
311
|
+
"text": p.read_text(encoding="utf-8") if p.exists() else ""}, 409)
|
|
312
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
tmp = p.with_name("." + p.name + ".tmp")
|
|
314
|
+
tmp.write_text(req["text"], encoding="utf-8")
|
|
315
|
+
os.replace(tmp, p)
|
|
316
|
+
return self.send_json({"version": version_of(p)})
|
|
317
|
+
except (ValueError, KeyError) as exc:
|
|
318
|
+
return self.send_json({"error": str(exc)}, 400)
|
|
319
|
+
|
|
320
|
+
def do_POST(self):
|
|
321
|
+
u = urlparse(self.path)
|
|
322
|
+
try:
|
|
323
|
+
req = self.read_json()
|
|
324
|
+
if u.path == "/api/pdf":
|
|
325
|
+
return self.send_json(export_pdf(req))
|
|
326
|
+
if u.path == "/api/root":
|
|
327
|
+
initial = set_root(req["path"])
|
|
328
|
+
print(f"[root] {ROOT}", flush=True)
|
|
329
|
+
return self.send_json({"root": str(ROOT), "initial": initial, "files": list_files()})
|
|
330
|
+
if u.path == "/api/new":
|
|
331
|
+
rel = req["path"].strip()
|
|
332
|
+
if not rel.lower().endswith(MD_EXT):
|
|
333
|
+
rel += ".md"
|
|
334
|
+
p = safe_path(rel)
|
|
335
|
+
if p.exists():
|
|
336
|
+
return self.send_json({"error": "file already exists"}, 409)
|
|
337
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
p.write_text(req.get("text", f"# {p.stem}\n\n"), encoding="utf-8")
|
|
339
|
+
return self.send_json({"path": rel, "version": version_of(p)})
|
|
340
|
+
if u.path == "/api/ask":
|
|
341
|
+
print(f"[ask] {req.get('mode')}: {req.get('instruction', '')[:80]!r}", flush=True)
|
|
342
|
+
return self.send_json(ask_claude(req))
|
|
343
|
+
except subprocess.TimeoutExpired:
|
|
344
|
+
return self.send_json({"error": "Claude timed out"}, 504)
|
|
345
|
+
except RuntimeError as exc:
|
|
346
|
+
return self.send_json({"error": str(exc)}, 502)
|
|
347
|
+
except (ValueError, KeyError) as exc:
|
|
348
|
+
return self.send_json({"error": str(exc)}, 400)
|
|
349
|
+
return self.send_json({"error": "unknown endpoint"}, 404)
|
|
350
|
+
|
|
351
|
+
def events(self):
|
|
352
|
+
"""Server-sent events: file list changes and per-file version changes."""
|
|
353
|
+
self.send_response(200)
|
|
354
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
355
|
+
self.end_headers()
|
|
356
|
+
last = None
|
|
357
|
+
try:
|
|
358
|
+
while True:
|
|
359
|
+
files = list_files()
|
|
360
|
+
if files != last:
|
|
361
|
+
self.wfile.write(f"data: {json.dumps(files)}\n\n".encode())
|
|
362
|
+
self.wfile.flush()
|
|
363
|
+
last = files
|
|
364
|
+
else:
|
|
365
|
+
self.wfile.write(b": ping\n\n")
|
|
366
|
+
self.wfile.flush()
|
|
367
|
+
time.sleep(0.6)
|
|
368
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
INITIAL = None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def main():
|
|
376
|
+
global ROOT, INITIAL
|
|
377
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
378
|
+
ap.add_argument("path", nargs="?", default=".", help="folder or .md file to edit (default: current folder)")
|
|
379
|
+
ap.add_argument("--port", type=int, default=8765)
|
|
380
|
+
ap.add_argument("--no-browser", action="store_true")
|
|
381
|
+
args = ap.parse_args()
|
|
382
|
+
|
|
383
|
+
target = Path(args.path).expanduser().resolve()
|
|
384
|
+
if target.suffix.lower() in MD_EXT:
|
|
385
|
+
ROOT, INITIAL = target.parent, target.name
|
|
386
|
+
if not target.exists():
|
|
387
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
target.write_text(f"# {target.stem}\n\n", encoding="utf-8")
|
|
389
|
+
else:
|
|
390
|
+
ROOT = target
|
|
391
|
+
ROOT.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
|
|
393
|
+
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
394
|
+
server.daemon_threads = True
|
|
395
|
+
url = f"http://127.0.0.1:{args.port}/"
|
|
396
|
+
print(f"MD editor on {url} (root: {ROOT})", flush=True)
|
|
397
|
+
if not args.no_browser:
|
|
398
|
+
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
|
399
|
+
try:
|
|
400
|
+
server.serve_forever()
|
|
401
|
+
except KeyboardInterrupt:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
main()
|