draftwatch 0.2.1__tar.gz → 0.2.2__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 (21) hide show
  1. {draftwatch-0.2.1 → draftwatch-0.2.2}/PKG-INFO +1 -1
  2. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/app.py +153 -15
  3. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/PKG-INFO +1 -1
  4. {draftwatch-0.2.1 → draftwatch-0.2.2}/pyproject.toml +1 -1
  5. {draftwatch-0.2.1 → draftwatch-0.2.2}/LICENSE +0 -0
  6. {draftwatch-0.2.1 → draftwatch-0.2.2}/README.md +0 -0
  7. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/__init__.py +0 -0
  8. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/__main__.py +0 -0
  9. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/codemirror.js +0 -0
  10. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/marked.js +0 -0
  11. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/purify.js +0 -0
  12. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/turndown.js +0 -0
  13. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/xterm.css +0 -0
  14. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/xterm.js +0 -0
  15. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/term.py +0 -0
  16. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/SOURCES.txt +0 -0
  17. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/dependency_links.txt +0 -0
  18. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/entry_points.txt +0 -0
  19. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/requires.txt +0 -0
  20. {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/top_level.txt +0 -0
  21. {draftwatch-0.2.1 → draftwatch-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwatch
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Review an AI agent's edits to your writing files as a real git word-diff — keep or revert changes hunk by hunk, then commit.
5
5
  Author: Mike Konczal
6
6
  License: MIT
@@ -40,8 +40,8 @@ TERM_SUPPORTED = _term is not None
40
40
  # Single source of truth for the version and the date of the latest release.
41
41
  # __init__.py re-exports __version__; the About panel shows both (injected into
42
42
  # the served page from these constants).
43
- __version__ = "0.2.1"
44
- RELEASE_DATE = "2026-07-07"
43
+ __version__ = "0.2.2"
44
+ RELEASE_DATE = "2026-09-23"
45
45
 
46
46
  # Preferred port. When --port is not given, Draftwatch tries this first and
47
47
  # falls back to a free port if it is busy (so a second instance can start while
@@ -244,6 +244,53 @@ def resolve_new_file(root, path):
244
244
  return cand
245
245
 
246
246
 
247
+ # Local images the preview may load: extension -> MIME type. The image route
248
+ # serves only these, only from inside the repo root, and only up to the size
249
+ # cap, so it can't be used to read arbitrary files (source, .env, keys).
250
+ IMAGE_TYPES = {
251
+ ".png": "image/png",
252
+ ".jpg": "image/jpeg",
253
+ ".jpeg": "image/jpeg",
254
+ ".gif": "image/gif",
255
+ ".webp": "image/webp",
256
+ ".avif": "image/avif",
257
+ ".svg": "image/svg+xml",
258
+ ".bmp": "image/bmp",
259
+ ".ico": "image/x-icon",
260
+ }
261
+ MAX_IMAGE_BYTES = 25 * 1024 * 1024
262
+
263
+
264
+ def resolve_image(root, relpath, src):
265
+ """Resolve an <img src> from the markdown to (abspath, mime), or raise ValueError.
266
+
267
+ `src` is the raw attribute value, resolved relative to the open file's
268
+ directory (the repo root when no file is open), the way a markdown renderer
269
+ on disk would. URLs, absolute paths, and anything resolving outside the repo
270
+ or to a non-image type are refused.
271
+ """
272
+ from urllib.parse import unquote
273
+ s = (src or "").split("#", 1)[0].split("?", 1)[0].strip()
274
+ s = unquote(s)
275
+ if not s:
276
+ raise ValueError("no image path given")
277
+ if ":" in s.split("/", 1)[0] or s.startswith("/") or os.path.isabs(s):
278
+ raise ValueError("only relative image paths are served")
279
+ base = os.path.join(root, os.path.dirname(relpath)) if relpath else root
280
+ cand = os.path.realpath(os.path.join(base, s))
281
+ root_prefix = root.rstrip(os.sep) + os.sep
282
+ if not cand.startswith(root_prefix):
283
+ raise ValueError("refusing an image outside the repository")
284
+ mime = IMAGE_TYPES.get(os.path.splitext(cand)[1].lower())
285
+ if mime is None:
286
+ raise ValueError("not an image type the preview serves")
287
+ if not os.path.isfile(cand):
288
+ raise ValueError("image not found")
289
+ if os.path.getsize(cand) > MAX_IMAGE_BYTES:
290
+ raise ValueError("image too large")
291
+ return cand, mime
292
+
293
+
247
294
  def list_repo_files(root, has_repo=True, limit=5000):
248
295
  """Files for the picker. In a git repo: tracked + non-ignored untracked,
249
296
  sorted, deduped. In write-only mode (no repo), fall back to a filesystem
@@ -887,6 +934,8 @@ class Handler(BaseHTTPRequestHandler):
887
934
  # writer's legitimate external images still render;
888
935
  # with connect-src+Referrer-Policy above, a stray
889
936
  # image beacon carries no token and no document data.
937
+ # blob: is for local images, fetched with the token
938
+ # from /api/image and shown via object URLs.
890
939
  # nosniff — don't let a response be reinterpreted as another type.
891
940
  self.send_header("Referrer-Policy", "no-referrer")
892
941
  self.send_header("X-Content-Type-Options", "nosniff")
@@ -895,7 +944,7 @@ class Handler(BaseHTTPRequestHandler):
895
944
  "default-src 'self'; "
896
945
  "script-src 'self' 'unsafe-inline'; "
897
946
  "style-src 'self' 'unsafe-inline'; "
898
- "img-src 'self' data: https:; "
947
+ "img-src 'self' data: blob: https:; "
899
948
  "connect-src 'self'; "
900
949
  "base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
901
950
  self.end_headers()
@@ -939,6 +988,8 @@ class Handler(BaseHTTPRequestHandler):
939
988
  self._api_baselines()
940
989
  elif path == "/api/files":
941
990
  self._api_files()
991
+ elif path == "/api/image":
992
+ self._api_image()
942
993
  else:
943
994
  self._send(404, "not found", "text/plain")
944
995
 
@@ -1031,6 +1082,22 @@ class Handler(BaseHTTPRequestHandler):
1031
1082
  self._json({"files": list_repo_files(st.root, has_repo),
1032
1083
  "current": current, "repo": has_repo})
1033
1084
 
1085
+ def _api_image(self):
1086
+ """A local image referenced by the open file, for the preview. Token-gated
1087
+ like the rest of /api/* (the client fetches it with the header and shows
1088
+ it as a blob: URL), so file contents never load without the session."""
1089
+ st = self.state
1090
+ with st.lock:
1091
+ relpath = st.relpath
1092
+ vals = self._query().get("src")
1093
+ try:
1094
+ abspath, mime = resolve_image(st.root, relpath, vals[0] if vals else "")
1095
+ with open(abspath, "rb") as fh:
1096
+ data = fh.read()
1097
+ except (ValueError, OSError) as e:
1098
+ return self._send(404, str(e), "text/plain")
1099
+ self._send(200, data, mime)
1100
+
1034
1101
  # ---- POST ----
1035
1102
  def do_POST(self):
1036
1103
  path = self.path.split("?", 1)[0]
@@ -1727,30 +1794,42 @@ INDEX_HTML = r"""<!DOCTYPE html>
1727
1794
  background: rgba(127, 127, 127, .28);
1728
1795
  }
1729
1796
  #editor-host .cm-placeholder { color: var(--muted); font-style: italic; }
1730
- /* CM search panel, themed with the app variables (light/dark for free) */
1731
- .cm-panels {
1732
- background: var(--panel) !important;
1733
- color: var(--text) !important;
1734
- border-color: var(--border) !important;
1797
+ /* CM search panel, themed with the app variables (light/dark for free).
1798
+ Scoped under #editor-host on purpose: CodeMirror's base theme injects
1799
+ two-class rules (e.g. `.ͼ2 .cm-textfield`, `.ͼ2 .cm-button`) that outrank a
1800
+ bare `.cm-panels input`, and since no dark theme is registered with CM it
1801
+ always applies its LIGHT palette (white input, pale gradient buttons, 70%
1802
+ font). Unscoped, that left near-white dark-mode text on a white field. */
1803
+ #editor-host .cm-panels {
1804
+ background: var(--panel);
1805
+ color: var(--text);
1806
+ border-color: var(--border);
1735
1807
  }
1736
- .cm-panels input, .cm-panels button, .cm-panels label {
1808
+ #editor-host .cm-panels input, #editor-host .cm-panels button, #editor-host .cm-panels label {
1737
1809
  font-family: var(--mono);
1738
1810
  font-size: 12px;
1739
1811
  color: var(--text);
1740
1812
  }
1741
- .cm-panels input {
1813
+ #editor-host .cm-panels input[type="checkbox"] { accent-color: var(--accent); }
1814
+ #editor-host .cm-panels .cm-textfield {
1742
1815
  background: var(--bg);
1743
1816
  border: 1px solid var(--border);
1744
1817
  border-radius: 4px;
1745
1818
  }
1746
- .cm-panels button {
1819
+ #editor-host .cm-panels .cm-textfield:focus { outline: none; border-color: var(--accent); }
1820
+ #editor-host .cm-panels .cm-button {
1747
1821
  background: var(--bg);
1748
- border: 1px solid var(--border) !important;
1822
+ background-image: none;
1823
+ border: 1px solid var(--border);
1749
1824
  border-radius: 4px;
1750
1825
  cursor: pointer;
1751
1826
  }
1752
- .cm-searchMatch { background: var(--warn-bg); outline: 1px solid var(--warn-border); }
1753
- .cm-searchMatch-selected { background: var(--warn-border); }
1827
+ #editor-host .cm-panels .cm-button:hover { border-color: var(--accent); }
1828
+ #editor-host .cm-panels .cm-button:active { background-image: none; }
1829
+ #editor-host .cm-searchMatch { background: var(--warn-bg); outline: 1px solid var(--warn-border); }
1830
+ #editor-host .cm-searchMatch-selected { background: var(--warn-border); }
1831
+ /* highlightSelectionMatches: CM's default is a bright translucent green */
1832
+ #editor-host .cm-selectionMatch { background: rgba(127, 127, 127, .22); }
1754
1833
 
1755
1834
  /* rendered markdown preview: reading typography, sanitized content only */
1756
1835
  #preview {
@@ -1790,6 +1869,8 @@ INDEX_HTML = r"""<!DOCTYPE html>
1790
1869
  color: var(--muted);
1791
1870
  }
1792
1871
  #preview img { max-width: 100%; }
1872
+ /* a local image the server could not serve: the alt text shows in its place */
1873
+ #preview img.img-missing { outline: 1px dashed var(--del-fg); outline-offset: 2px; color: var(--muted); }
1793
1874
  #preview hr { border: none; border-top: 1px solid var(--border); }
1794
1875
  #preview table { border-collapse: collapse; }
1795
1876
  #preview th, #preview td { border: 1px solid var(--border); padding: 4px 9px; }
@@ -2933,6 +3014,61 @@ INDEX_HTML = r"""<!DOCTYPE html>
2933
3014
  strongDelimiter: "**",
2934
3015
  linkStyle: "inlined"
2935
3016
  });
3017
+
3018
+ // Local images (e.g. ![](figs/chart.png)). A relative src would resolve to a
3019
+ // bare path on this server and 404, so the sanitizer moves it to data-md-src
3020
+ // (no request fires from the markup), and loadLocalImages fetches it through
3021
+ // the token-gated /api/image route and shows it as a blob: URL. data-md-src
3022
+ // keeps the markdown's own path, which Turndown writes back on preview edits
3023
+ // (never the blob: URL; its default rule would also drop a src-less image).
3024
+ function isLocalSrc(s) {
3025
+ return !!s && !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(s);
3026
+ }
3027
+ DOMPurify.addHook("afterSanitizeAttributes", function (node) {
3028
+ if (node.nodeName !== "IMG") return;
3029
+ var src = node.getAttribute("src");
3030
+ if (!isLocalSrc(src)) return;
3031
+ node.setAttribute("data-md-src", src);
3032
+ node.removeAttribute("src");
3033
+ });
3034
+ TURN.addRule("localImage", {
3035
+ filter: function (node) {
3036
+ return node.nodeName === "IMG" && node.hasAttribute("data-md-src");
3037
+ },
3038
+ replacement: function (content, node) {
3039
+ var clean = function (a) { return (a || "").replace(/(\n+\s*)+/g, "\n"); };
3040
+ var title = clean(node.getAttribute("title"));
3041
+ return "![" + clean(node.getAttribute("alt")) + "](" +
3042
+ node.getAttribute("data-md-src") + (title ? ' "' + title + '"' : "") + ")";
3043
+ }
3044
+ });
3045
+ var imageCache = {}; // "<file>|<src>" -> Promise of a blob: URL, or null
3046
+ function clearImageCache() {
3047
+ Object.keys(imageCache).forEach(function (k) {
3048
+ imageCache[k].then(function (u) { if (u) URL.revokeObjectURL(u); });
3049
+ });
3050
+ imageCache = {};
3051
+ }
3052
+ function loadLocalImages(root) {
3053
+ root.querySelectorAll("img[data-md-src]").forEach(function (img) {
3054
+ var src = img.getAttribute("data-md-src");
3055
+ var key = (currentFile || "") + "|" + src;
3056
+ if (!imageCache[key]) {
3057
+ imageCache[key] = fetch("/api/image?src=" + encodeURIComponent(src),
3058
+ { headers: { "X-Draftwatch-Token": TOKEN } })
3059
+ .then(function (r) { return r.ok ? r.blob() : null; })
3060
+ .then(function (b) { return b ? URL.createObjectURL(b) : null; })
3061
+ .catch(function () { return null; });
3062
+ // don't cache a miss: the image may be added to disk later
3063
+ imageCache[key].then(function (u) { if (!u) delete imageCache[key]; });
3064
+ }
3065
+ imageCache[key].then(function (u) {
3066
+ if (u) { img.src = u; img.classList.remove("img-missing"); }
3067
+ else img.classList.add("img-missing");
3068
+ });
3069
+ });
3070
+ }
3071
+
2936
3072
  function renderPreview() {
2937
3073
  var out = "";
2938
3074
  try {
@@ -2945,6 +3081,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
2945
3081
  // (innerHTML assignment does not fire 'input', but keep the intent explicit)
2946
3082
  $("preview").innerHTML = out ||
2947
3083
  '<p class="empty">nothing to preview</p>';
3084
+ loadLocalImages($("preview"));
2948
3085
  }
2949
3086
 
2950
3087
  // Convert the edited rendered HTML back to markdown and push it into the
@@ -2975,7 +3112,8 @@ INDEX_HTML = r"""<!DOCTYPE html>
2975
3112
  $("preview").setAttribute("contenteditable", on ? "true" : "false");
2976
3113
  // the format buttons + save stay available in preview — you can write here too
2977
3114
  $("view-toggle").textContent = on ? "source" : "preview";
2978
- if (on) renderPreview();
3115
+ // re-read local images each time preview opens, so a regenerated chart shows
3116
+ if (on) { clearImageCache(); renderPreview(); }
2979
3117
  }
2980
3118
  $("view-toggle").addEventListener("click", function () { setPreviewMode(!previewMode); });
2981
3119
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwatch
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Review an AI agent's edits to your writing files as a real git word-diff — keep or revert changes hunk by hunk, then commit.
5
5
  Author: Mike Konczal
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "draftwatch"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Review an AI agent's edits to your writing files as a real git word-diff — keep or revert changes hunk by hunk, then commit."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes