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.
- {draftwatch-0.2.1 → draftwatch-0.2.2}/PKG-INFO +1 -1
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/app.py +153 -15
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/PKG-INFO +1 -1
- {draftwatch-0.2.1 → draftwatch-0.2.2}/pyproject.toml +1 -1
- {draftwatch-0.2.1 → draftwatch-0.2.2}/LICENSE +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/README.md +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/__init__.py +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/__main__.py +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/codemirror.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/marked.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/purify.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/turndown.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/xterm.css +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/assets/xterm.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch/term.py +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/SOURCES.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/dependency_links.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/entry_points.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/requires.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/draftwatch.egg-info/top_level.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.2}/setup.cfg +0 -0
|
@@ -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.
|
|
44
|
-
RELEASE_DATE = "2026-
|
|
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
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
|
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
|
-
|
|
1822
|
+
background-image: none;
|
|
1823
|
+
border: 1px solid var(--border);
|
|
1749
1824
|
border-radius: 4px;
|
|
1750
1825
|
cursor: pointer;
|
|
1751
1826
|
}
|
|
1752
|
-
.cm-
|
|
1753
|
-
.cm-
|
|
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. ). 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 " + (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
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "draftwatch"
|
|
7
|
-
version = "0.2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|