draftwatch 0.2.1__tar.gz → 0.2.3__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.3}/PKG-INFO +1 -1
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/app.py +216 -34
- draftwatch-0.2.3/draftwatch/assets/favicon.png +0 -0
- draftwatch-0.2.3/draftwatch/assets/icon.png +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch.egg-info/PKG-INFO +1 -1
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch.egg-info/SOURCES.txt +2 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/pyproject.toml +1 -1
- {draftwatch-0.2.1 → draftwatch-0.2.3}/LICENSE +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/README.md +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/__init__.py +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/__main__.py +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/assets/codemirror.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/assets/marked.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/assets/purify.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/assets/turndown.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/assets/xterm.css +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/assets/xterm.js +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch/term.py +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch.egg-info/dependency_links.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch.egg-info/entry_points.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch.egg-info/requires.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/draftwatch.egg-info/top_level.txt +0 -0
- {draftwatch-0.2.1 → draftwatch-0.2.3}/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.3"
|
|
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
|
|
@@ -793,6 +840,7 @@ STATIC_ASSETS = {
|
|
|
793
840
|
"marked.js": "application/javascript; charset=utf-8",
|
|
794
841
|
"purify.js": "application/javascript; charset=utf-8",
|
|
795
842
|
"turndown.js": "application/javascript; charset=utf-8",
|
|
843
|
+
"favicon.png": "image/png",
|
|
796
844
|
}
|
|
797
845
|
|
|
798
846
|
|
|
@@ -887,6 +935,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
887
935
|
# writer's legitimate external images still render;
|
|
888
936
|
# with connect-src+Referrer-Policy above, a stray
|
|
889
937
|
# image beacon carries no token and no document data.
|
|
938
|
+
# blob: is for local images, fetched with the token
|
|
939
|
+
# from /api/image and shown via object URLs.
|
|
890
940
|
# nosniff — don't let a response be reinterpreted as another type.
|
|
891
941
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
892
942
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
@@ -895,7 +945,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
895
945
|
"default-src 'self'; "
|
|
896
946
|
"script-src 'self' 'unsafe-inline'; "
|
|
897
947
|
"style-src 'self' 'unsafe-inline'; "
|
|
898
|
-
"img-src 'self' data: https:; "
|
|
948
|
+
"img-src 'self' data: blob: https:; "
|
|
899
949
|
"connect-src 'self'; "
|
|
900
950
|
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
|
|
901
951
|
self.end_headers()
|
|
@@ -939,6 +989,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
939
989
|
self._api_baselines()
|
|
940
990
|
elif path == "/api/files":
|
|
941
991
|
self._api_files()
|
|
992
|
+
elif path == "/api/image":
|
|
993
|
+
self._api_image()
|
|
942
994
|
else:
|
|
943
995
|
self._send(404, "not found", "text/plain")
|
|
944
996
|
|
|
@@ -1031,6 +1083,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1031
1083
|
self._json({"files": list_repo_files(st.root, has_repo),
|
|
1032
1084
|
"current": current, "repo": has_repo})
|
|
1033
1085
|
|
|
1086
|
+
def _api_image(self):
|
|
1087
|
+
"""A local image referenced by the open file, for the preview. Token-gated
|
|
1088
|
+
like the rest of /api/* (the client fetches it with the header and shows
|
|
1089
|
+
it as a blob: URL), so file contents never load without the session."""
|
|
1090
|
+
st = self.state
|
|
1091
|
+
with st.lock:
|
|
1092
|
+
relpath = st.relpath
|
|
1093
|
+
vals = self._query().get("src")
|
|
1094
|
+
try:
|
|
1095
|
+
abspath, mime = resolve_image(st.root, relpath, vals[0] if vals else "")
|
|
1096
|
+
with open(abspath, "rb") as fh:
|
|
1097
|
+
data = fh.read()
|
|
1098
|
+
except (ValueError, OSError) as e:
|
|
1099
|
+
return self._send(404, str(e), "text/plain")
|
|
1100
|
+
self._send(200, data, mime)
|
|
1101
|
+
|
|
1034
1102
|
# ---- POST ----
|
|
1035
1103
|
def do_POST(self):
|
|
1036
1104
|
path = self.path.split("?", 1)[0]
|
|
@@ -1395,6 +1463,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1395
1463
|
<meta name="referrer" content="no-referrer">
|
|
1396
1464
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1397
1465
|
<title>Draftwatch</title>
|
|
1466
|
+
<link rel="icon" type="image/png" href="/static/favicon.png">
|
|
1398
1467
|
<script src="/static/codemirror.js"></script>
|
|
1399
1468
|
<script src="/static/marked.js"></script>
|
|
1400
1469
|
<script src="/static/purify.js"></script>
|
|
@@ -1550,7 +1619,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1550
1619
|
|
|
1551
1620
|
/* app chrome (native window via pywebview, ?app=1): the OS title bar
|
|
1552
1621
|
already says "draftwatch · file", so the in-page wordmark is redundant —
|
|
1553
|
-
drop
|
|
1622
|
+
drop the word (the logo, outside .title, stays as the brand), tighten the toolbar, and make the chrome feel native (no text
|
|
1554
1623
|
selection on controls, no web-page tells). */
|
|
1555
1624
|
body.app-mode header .title { display: none; }
|
|
1556
1625
|
body.app-mode header { padding: 5px 10px; }
|
|
@@ -1571,14 +1640,22 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1571
1640
|
display: flex;
|
|
1572
1641
|
flex-direction: column;
|
|
1573
1642
|
}
|
|
1643
|
+
/* logo on the left, spanning both toolbar rows; the rows stack beside it */
|
|
1574
1644
|
header {
|
|
1575
1645
|
display: flex;
|
|
1576
|
-
|
|
1577
|
-
gap:
|
|
1646
|
+
align-items: center;
|
|
1647
|
+
gap: 10px;
|
|
1578
1648
|
padding: 8px 12px;
|
|
1579
1649
|
border-bottom: 1px solid var(--border);
|
|
1580
1650
|
background: var(--panel);
|
|
1581
1651
|
}
|
|
1652
|
+
header .rows {
|
|
1653
|
+
flex: 1;
|
|
1654
|
+
min-width: 0;
|
|
1655
|
+
display: flex;
|
|
1656
|
+
flex-direction: column;
|
|
1657
|
+
gap: 6px;
|
|
1658
|
+
}
|
|
1582
1659
|
/* Two fixed rows that never wrap into each other, so the layout stays put
|
|
1583
1660
|
regardless of title length or window width:
|
|
1584
1661
|
row 1 = what you're looking at (title, file picker, baseline selector —
|
|
@@ -1601,19 +1678,11 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1601
1678
|
gap: 7px;
|
|
1602
1679
|
letter-spacing: .02em;
|
|
1603
1680
|
}
|
|
1604
|
-
/* the
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
border: 1px solid var(--border);
|
|
1610
|
-
border-radius: 5px;
|
|
1611
|
-
padding: 0 5px;
|
|
1612
|
-
font-weight: 700;
|
|
1613
|
-
line-height: 1.45;
|
|
1614
|
-
}
|
|
1615
|
-
.mark .ma { color: var(--add-fg); }
|
|
1616
|
-
.mark .md { color: var(--del-fg); text-decoration: line-through; }
|
|
1681
|
+
/* the logo: same drawing as the Dock icon and favicon (scripts/make_icon.py),
|
|
1682
|
+
inline so it stays crisp at any size. Fixed brand colors, not the accent. */
|
|
1683
|
+
.logo { width: 28px; height: 28px; flex: none; display: block; }
|
|
1684
|
+
header > .logo { width: 54px; height: 54px; }
|
|
1685
|
+
#about h2 .logo { width: 48px; height: 48px; }
|
|
1617
1686
|
header .status { color: var(--muted); }
|
|
1618
1687
|
header #status {
|
|
1619
1688
|
flex: 1 1 auto;
|
|
@@ -1727,30 +1796,42 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1727
1796
|
background: rgba(127, 127, 127, .28);
|
|
1728
1797
|
}
|
|
1729
1798
|
#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
|
-
|
|
1799
|
+
/* CM search panel, themed with the app variables (light/dark for free).
|
|
1800
|
+
Scoped under #editor-host on purpose: CodeMirror's base theme injects
|
|
1801
|
+
two-class rules (e.g. `.ͼ2 .cm-textfield`, `.ͼ2 .cm-button`) that outrank a
|
|
1802
|
+
bare `.cm-panels input`, and since no dark theme is registered with CM it
|
|
1803
|
+
always applies its LIGHT palette (white input, pale gradient buttons, 70%
|
|
1804
|
+
font). Unscoped, that left near-white dark-mode text on a white field. */
|
|
1805
|
+
#editor-host .cm-panels {
|
|
1806
|
+
background: var(--panel);
|
|
1807
|
+
color: var(--text);
|
|
1808
|
+
border-color: var(--border);
|
|
1735
1809
|
}
|
|
1736
|
-
.cm-panels input, .cm-panels button, .cm-panels label {
|
|
1810
|
+
#editor-host .cm-panels input, #editor-host .cm-panels button, #editor-host .cm-panels label {
|
|
1737
1811
|
font-family: var(--mono);
|
|
1738
1812
|
font-size: 12px;
|
|
1739
1813
|
color: var(--text);
|
|
1740
1814
|
}
|
|
1741
|
-
.cm-panels input {
|
|
1815
|
+
#editor-host .cm-panels input[type="checkbox"] { accent-color: var(--accent); }
|
|
1816
|
+
#editor-host .cm-panels .cm-textfield {
|
|
1742
1817
|
background: var(--bg);
|
|
1743
1818
|
border: 1px solid var(--border);
|
|
1744
1819
|
border-radius: 4px;
|
|
1745
1820
|
}
|
|
1746
|
-
.cm-panels
|
|
1821
|
+
#editor-host .cm-panels .cm-textfield:focus { outline: none; border-color: var(--accent); }
|
|
1822
|
+
#editor-host .cm-panels .cm-button {
|
|
1747
1823
|
background: var(--bg);
|
|
1748
|
-
|
|
1824
|
+
background-image: none;
|
|
1825
|
+
border: 1px solid var(--border);
|
|
1749
1826
|
border-radius: 4px;
|
|
1750
1827
|
cursor: pointer;
|
|
1751
1828
|
}
|
|
1752
|
-
.cm-
|
|
1753
|
-
.cm-
|
|
1829
|
+
#editor-host .cm-panels .cm-button:hover { border-color: var(--accent); }
|
|
1830
|
+
#editor-host .cm-panels .cm-button:active { background-image: none; }
|
|
1831
|
+
#editor-host .cm-searchMatch { background: var(--warn-bg); outline: 1px solid var(--warn-border); }
|
|
1832
|
+
#editor-host .cm-searchMatch-selected { background: var(--warn-border); }
|
|
1833
|
+
/* highlightSelectionMatches: CM's default is a bright translucent green */
|
|
1834
|
+
#editor-host .cm-selectionMatch { background: rgba(127, 127, 127, .22); }
|
|
1754
1835
|
|
|
1755
1836
|
/* rendered markdown preview: reading typography, sanitized content only */
|
|
1756
1837
|
#preview {
|
|
@@ -1790,6 +1871,8 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1790
1871
|
color: var(--muted);
|
|
1791
1872
|
}
|
|
1792
1873
|
#preview img { max-width: 100%; }
|
|
1874
|
+
/* a local image the server could not serve: the alt text shows in its place */
|
|
1875
|
+
#preview img.img-missing { outline: 1px dashed var(--del-fg); outline-offset: 2px; color: var(--muted); }
|
|
1793
1876
|
#preview hr { border: none; border-top: 1px solid var(--border); }
|
|
1794
1877
|
#preview table { border-collapse: collapse; }
|
|
1795
1878
|
#preview th, #preview td { border: 1px solid var(--border); padding: 4px 9px; }
|
|
@@ -1986,9 +2069,26 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1986
2069
|
</style>
|
|
1987
2070
|
</head>
|
|
1988
2071
|
<body>
|
|
2072
|
+
<!-- logo geometry mirrors scripts/make_icon.py (1024 grid, tile at 100..924) -->
|
|
2073
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
2074
|
+
<defs>
|
|
2075
|
+
<linearGradient id="dw-logo-bg" x1="0" y1="0" x2="0" y2="1">
|
|
2076
|
+
<stop offset="0" stop-color="#3d82c7"/><stop offset="1" stop-color="#215892"/>
|
|
2077
|
+
</linearGradient>
|
|
2078
|
+
<symbol id="dw-logo" viewBox="100 100 824 824">
|
|
2079
|
+
<rect x="100" y="100" width="824" height="824" rx="185" fill="url(#dw-logo-bg)"/>
|
|
2080
|
+
<path fill="#f5f9fe" fill-rule="evenodd"
|
|
2081
|
+
d="M322 262H488A250 250 0 0 1 488 762H322Z M428 368H488A144 144 0 0 1 488 656H428Z"/>
|
|
2082
|
+
<rect x="470" y="452" width="120" height="42" rx="21" fill="#7ed08a"/>
|
|
2083
|
+
<rect x="470" y="530" width="92" height="42" rx="21" fill="#f08e7f"/>
|
|
2084
|
+
</symbol>
|
|
2085
|
+
</defs>
|
|
2086
|
+
</svg>
|
|
1989
2087
|
<header>
|
|
2088
|
+
<svg class="logo" aria-hidden="true"><use href="#dw-logo"/></svg>
|
|
2089
|
+
<div class="rows">
|
|
1990
2090
|
<div class="bar">
|
|
1991
|
-
<span class="title"><span class="
|
|
2091
|
+
<span class="title"><span class="word">Draftwatch</span></span>
|
|
1992
2092
|
<span class="filepick">
|
|
1993
2093
|
<label class="status" for="file-select">file</label>
|
|
1994
2094
|
<select id="file-select" title="pick a writing file in this repo"></select>
|
|
@@ -2013,6 +2113,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
2013
2113
|
<button id="accent-toggle" title="accent color — click to cycle"><span class="swatch"></span><span id="accent-name">blue</span></button>
|
|
2014
2114
|
<button id="about-btn" title="what is Draftwatch?">about</button>
|
|
2015
2115
|
</div>
|
|
2116
|
+
</div>
|
|
2016
2117
|
</header>
|
|
2017
2118
|
|
|
2018
2119
|
<div id="banner">
|
|
@@ -2025,7 +2126,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
2025
2126
|
<div id="about" role="dialog" aria-modal="true" aria-label="About Draftwatch">
|
|
2026
2127
|
<div class="card">
|
|
2027
2128
|
<button class="about-close" id="about-close" title="close (Esc)">close ✕</button>
|
|
2028
|
-
<h2><
|
|
2129
|
+
<h2><svg class="logo" aria-hidden="true"><use href="#dw-logo"/></svg>Draftwatch</h2>
|
|
2029
2130
|
<p class="tag">Review an AI agent's edits as a real git diff — locally.</p>
|
|
2030
2131
|
<p>An autonomous agent (Claude Code and the like) edits your files on disk.
|
|
2031
2132
|
Draftwatch shows you exactly what changed, as the word-level diff
|
|
@@ -2933,6 +3034,61 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
2933
3034
|
strongDelimiter: "**",
|
|
2934
3035
|
linkStyle: "inlined"
|
|
2935
3036
|
});
|
|
3037
|
+
|
|
3038
|
+
// Local images (e.g. ). A relative src would resolve to a
|
|
3039
|
+
// bare path on this server and 404, so the sanitizer moves it to data-md-src
|
|
3040
|
+
// (no request fires from the markup), and loadLocalImages fetches it through
|
|
3041
|
+
// the token-gated /api/image route and shows it as a blob: URL. data-md-src
|
|
3042
|
+
// keeps the markdown's own path, which Turndown writes back on preview edits
|
|
3043
|
+
// (never the blob: URL; its default rule would also drop a src-less image).
|
|
3044
|
+
function isLocalSrc(s) {
|
|
3045
|
+
return !!s && !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(s);
|
|
3046
|
+
}
|
|
3047
|
+
DOMPurify.addHook("afterSanitizeAttributes", function (node) {
|
|
3048
|
+
if (node.nodeName !== "IMG") return;
|
|
3049
|
+
var src = node.getAttribute("src");
|
|
3050
|
+
if (!isLocalSrc(src)) return;
|
|
3051
|
+
node.setAttribute("data-md-src", src);
|
|
3052
|
+
node.removeAttribute("src");
|
|
3053
|
+
});
|
|
3054
|
+
TURN.addRule("localImage", {
|
|
3055
|
+
filter: function (node) {
|
|
3056
|
+
return node.nodeName === "IMG" && node.hasAttribute("data-md-src");
|
|
3057
|
+
},
|
|
3058
|
+
replacement: function (content, node) {
|
|
3059
|
+
var clean = function (a) { return (a || "").replace(/(\n+\s*)+/g, "\n"); };
|
|
3060
|
+
var title = clean(node.getAttribute("title"));
|
|
3061
|
+
return " + (title ? ' "' + title + '"' : "") + ")";
|
|
3063
|
+
}
|
|
3064
|
+
});
|
|
3065
|
+
var imageCache = {}; // "<file>|<src>" -> Promise of a blob: URL, or null
|
|
3066
|
+
function clearImageCache() {
|
|
3067
|
+
Object.keys(imageCache).forEach(function (k) {
|
|
3068
|
+
imageCache[k].then(function (u) { if (u) URL.revokeObjectURL(u); });
|
|
3069
|
+
});
|
|
3070
|
+
imageCache = {};
|
|
3071
|
+
}
|
|
3072
|
+
function loadLocalImages(root) {
|
|
3073
|
+
root.querySelectorAll("img[data-md-src]").forEach(function (img) {
|
|
3074
|
+
var src = img.getAttribute("data-md-src");
|
|
3075
|
+
var key = (currentFile || "") + "|" + src;
|
|
3076
|
+
if (!imageCache[key]) {
|
|
3077
|
+
imageCache[key] = fetch("/api/image?src=" + encodeURIComponent(src),
|
|
3078
|
+
{ headers: { "X-Draftwatch-Token": TOKEN } })
|
|
3079
|
+
.then(function (r) { return r.ok ? r.blob() : null; })
|
|
3080
|
+
.then(function (b) { return b ? URL.createObjectURL(b) : null; })
|
|
3081
|
+
.catch(function () { return null; });
|
|
3082
|
+
// don't cache a miss: the image may be added to disk later
|
|
3083
|
+
imageCache[key].then(function (u) { if (!u) delete imageCache[key]; });
|
|
3084
|
+
}
|
|
3085
|
+
imageCache[key].then(function (u) {
|
|
3086
|
+
if (u) { img.src = u; img.classList.remove("img-missing"); }
|
|
3087
|
+
else img.classList.add("img-missing");
|
|
3088
|
+
});
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
|
|
2936
3092
|
function renderPreview() {
|
|
2937
3093
|
var out = "";
|
|
2938
3094
|
try {
|
|
@@ -2945,6 +3101,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
2945
3101
|
// (innerHTML assignment does not fire 'input', but keep the intent explicit)
|
|
2946
3102
|
$("preview").innerHTML = out ||
|
|
2947
3103
|
'<p class="empty">nothing to preview</p>';
|
|
3104
|
+
loadLocalImages($("preview"));
|
|
2948
3105
|
}
|
|
2949
3106
|
|
|
2950
3107
|
// Convert the edited rendered HTML back to markdown and push it into the
|
|
@@ -2975,7 +3132,8 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
2975
3132
|
$("preview").setAttribute("contenteditable", on ? "true" : "false");
|
|
2976
3133
|
// the format buttons + save stay available in preview — you can write here too
|
|
2977
3134
|
$("view-toggle").textContent = on ? "source" : "preview";
|
|
2978
|
-
|
|
3135
|
+
// re-read local images each time preview opens, so a regenerated chart shows
|
|
3136
|
+
if (on) { clearImageCache(); renderPreview(); }
|
|
2979
3137
|
}
|
|
2980
3138
|
$("view-toggle").addEventListener("click", function () { setPreviewMode(!previewMode); });
|
|
2981
3139
|
|
|
@@ -3661,6 +3819,30 @@ def _webview_available():
|
|
|
3661
3819
|
return False
|
|
3662
3820
|
|
|
3663
3821
|
|
|
3822
|
+
def _app_identity_kwargs(webview):
|
|
3823
|
+
"""Brand the native window as Draftwatch rather than the Python interpreter
|
|
3824
|
+
running it: the Dock icon (draftwatch/assets/icon.png, drawn by
|
|
3825
|
+
scripts/make_icon.py) and, on macOS, the app name in the menu bar and its
|
|
3826
|
+
About/Hide/Quit items. Best effort: any failure leaves pywebview's defaults.
|
|
3827
|
+
Returns the kwargs for webview.start()."""
|
|
3828
|
+
if sys.platform == "darwin":
|
|
3829
|
+
try:
|
|
3830
|
+
from Foundation import NSBundle # PyObjC, a pywebview dependency on macOS
|
|
3831
|
+
info = NSBundle.mainBundle().infoDictionary()
|
|
3832
|
+
if info is not None:
|
|
3833
|
+
info["CFBundleName"] = "Draftwatch"
|
|
3834
|
+
except Exception:
|
|
3835
|
+
pass
|
|
3836
|
+
icon = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "icon.png")
|
|
3837
|
+
try:
|
|
3838
|
+
import inspect
|
|
3839
|
+
takes_icon = "icon" in inspect.signature(webview.start).parameters
|
|
3840
|
+
except (TypeError, ValueError):
|
|
3841
|
+
takes_icon = False
|
|
3842
|
+
# start(icon=) arrived in pywebview 5; on 4.x the window just keeps its default
|
|
3843
|
+
return {"icon": icon} if takes_icon and os.path.isfile(icon) else {}
|
|
3844
|
+
|
|
3845
|
+
|
|
3664
3846
|
def _run_app_window(url, title):
|
|
3665
3847
|
"""Open the UI in a native window (PyWebView). Returns True when the window
|
|
3666
3848
|
ran and was closed by the user; False when unavailable, in which case the
|
|
@@ -3678,7 +3860,7 @@ def _run_app_window(url, title):
|
|
|
3678
3860
|
try:
|
|
3679
3861
|
webview.create_window(title, url, width=1280, height=860,
|
|
3680
3862
|
min_size=(880, 560))
|
|
3681
|
-
webview.start()
|
|
3863
|
+
webview.start(**_app_identity_kwargs(webview))
|
|
3682
3864
|
return True
|
|
3683
3865
|
except Exception as e:
|
|
3684
3866
|
sys.stderr.write(
|
|
Binary file
|
|
Binary file
|
|
@@ -12,6 +12,8 @@ draftwatch.egg-info/entry_points.txt
|
|
|
12
12
|
draftwatch.egg-info/requires.txt
|
|
13
13
|
draftwatch.egg-info/top_level.txt
|
|
14
14
|
draftwatch/assets/codemirror.js
|
|
15
|
+
draftwatch/assets/favicon.png
|
|
16
|
+
draftwatch/assets/icon.png
|
|
15
17
|
draftwatch/assets/marked.js
|
|
16
18
|
draftwatch/assets/purify.js
|
|
17
19
|
draftwatch/assets/turndown.js
|
|
@@ -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.3"
|
|
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
|