deltadeck 0.2.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.
Files changed (206) hide show
  1. deltadeck/__init__.py +3 -0
  2. deltadeck/__main__.py +6 -0
  3. deltadeck/app.py +309 -0
  4. deltadeck/cli.py +111 -0
  5. deltadeck/config.py +394 -0
  6. deltadeck/desktop.py +301 -0
  7. deltadeck/docx.py +786 -0
  8. deltadeck/edit.py +650 -0
  9. deltadeck/edit_routes.py +275 -0
  10. deltadeck/errors.py +33 -0
  11. deltadeck/events.py +44 -0
  12. deltadeck/file_routes.py +183 -0
  13. deltadeck/fsops.py +143 -0
  14. deltadeck/git.py +810 -0
  15. deltadeck/git_routes.py +49 -0
  16. deltadeck/hidden.py +242 -0
  17. deltadeck/http_common.py +79 -0
  18. deltadeck/names.py +99 -0
  19. deltadeck/paths.py +377 -0
  20. deltadeck/pdf.py +377 -0
  21. deltadeck/readers.py +203 -0
  22. deltadeck/reconcile.py +413 -0
  23. deltadeck/recovery.py +371 -0
  24. deltadeck/roots.py +459 -0
  25. deltadeck/search.py +1280 -0
  26. deltadeck/seen.py +480 -0
  27. deltadeck/store.py +772 -0
  28. deltadeck/ui/404.html +1 -0
  29. deltadeck/ui/__next.__PAGE__.txt +35 -0
  30. deltadeck/ui/__next._full.txt +31 -0
  31. deltadeck/ui/__next._tree.txt +3 -0
  32. deltadeck/ui/_next/static/Gw6M6fxM3aEj0n-ZqJJ4a/_buildManifest.js +11 -0
  33. deltadeck/ui/_next/static/Gw6M6fxM3aEj0n-ZqJJ4a/_clientMiddlewareManifest.js +1 -0
  34. deltadeck/ui/_next/static/Gw6M6fxM3aEj0n-ZqJJ4a/_ssgManifest.js +1 -0
  35. deltadeck/ui/_next/static/chunks/0-wb0zlv4fraf.js +1 -0
  36. deltadeck/ui/_next/static/chunks/01p-raf8lqk27.js +1 -0
  37. deltadeck/ui/_next/static/chunks/032ykre2bnri8.js +1 -0
  38. deltadeck/ui/_next/static/chunks/0336mqc8l-u5_.js +1 -0
  39. deltadeck/ui/_next/static/chunks/04ez-o2l4o42j.js +4 -0
  40. deltadeck/ui/_next/static/chunks/04njl6dpcj5du.js +1 -0
  41. deltadeck/ui/_next/static/chunks/05_6295hyq3hs.js +1 -0
  42. deltadeck/ui/_next/static/chunks/05i_pb-4sod72.js +1 -0
  43. deltadeck/ui/_next/static/chunks/063x1fm-axugv.js +66 -0
  44. deltadeck/ui/_next/static/chunks/06bkit1w9xkil.js +1 -0
  45. deltadeck/ui/_next/static/chunks/06q08a4vhb65a.js +1 -0
  46. deltadeck/ui/_next/static/chunks/0ash-9bppuz64.js +1 -0
  47. deltadeck/ui/_next/static/chunks/0ba5gc5bhtd21.js +77 -0
  48. deltadeck/ui/_next/static/chunks/0bk68y6ulxiqc.css +1 -0
  49. deltadeck/ui/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  50. deltadeck/ui/_next/static/chunks/0ddvch5u_tkod.js +79 -0
  51. deltadeck/ui/_next/static/chunks/0ettfkje508xs.js +1 -0
  52. deltadeck/ui/_next/static/chunks/0g3bmow2o6ryj.js +1 -0
  53. deltadeck/ui/_next/static/chunks/0h-upsn6lazz9.js +1 -0
  54. deltadeck/ui/_next/static/chunks/0hpdbh5840flr.js +1 -0
  55. deltadeck/ui/_next/static/chunks/0knge03tyejx7.js +1 -0
  56. deltadeck/ui/_next/static/chunks/0npwoogk1edkj.js +1 -0
  57. deltadeck/ui/_next/static/chunks/0ooh5ltmcljc8.js +3 -0
  58. deltadeck/ui/_next/static/chunks/0p3iz33mgcaih.js +1 -0
  59. deltadeck/ui/_next/static/chunks/0pyjpjo88bqy7.js +1 -0
  60. deltadeck/ui/_next/static/chunks/0tvysmxvj5v0j.js +203 -0
  61. deltadeck/ui/_next/static/chunks/0udjrf1iq4fuk.js +1 -0
  62. deltadeck/ui/_next/static/chunks/0ukld36iqalgh.js +287 -0
  63. deltadeck/ui/_next/static/chunks/0va2a7jsjmv12.js +38 -0
  64. deltadeck/ui/_next/static/chunks/0vk-f147r1rsw.js +1 -0
  65. deltadeck/ui/_next/static/chunks/0w_drhom8r86n.js +1 -0
  66. deltadeck/ui/_next/static/chunks/0y2g0ctlf5oun.js +1 -0
  67. deltadeck/ui/_next/static/chunks/0y8yqekdf2p1s.js +1 -0
  68. deltadeck/ui/_next/static/chunks/0zcdw19u5g48m.js +2 -0
  69. deltadeck/ui/_next/static/chunks/1-eb6ubxl1zy0.js +1 -0
  70. deltadeck/ui/_next/static/chunks/1-srxa7hk2_y5.js +1 -0
  71. deltadeck/ui/_next/static/chunks/10vdv64yk0odn.js +31 -0
  72. deltadeck/ui/_next/static/chunks/10xdnyh3oo0e4.js +1 -0
  73. deltadeck/ui/_next/static/chunks/10xpbkeuur1_0.js +24 -0
  74. deltadeck/ui/_next/static/chunks/1178ms98k8h8x.js +10 -0
  75. deltadeck/ui/_next/static/chunks/123yrmmwha8uh.js +1 -0
  76. deltadeck/ui/_next/static/chunks/13jklb69rtm7g.js +86 -0
  77. deltadeck/ui/_next/static/chunks/14g2qr9-ac9_w.js +179 -0
  78. deltadeck/ui/_next/static/chunks/14uv7in3hq33c.js +1 -0
  79. deltadeck/ui/_next/static/chunks/15s134ttbtii8.js +1 -0
  80. deltadeck/ui/_next/static/chunks/18mh54xq67c88.js +1 -0
  81. deltadeck/ui/_next/static/chunks/1_gv4d2u9qmn5.js +1 -0
  82. deltadeck/ui/_next/static/chunks/1b2hau0_697gg.js +1 -0
  83. deltadeck/ui/_next/static/chunks/1cbv07r70is7e.js +1 -0
  84. deltadeck/ui/_next/static/chunks/1ck4x9qed_h2r.js +1 -0
  85. deltadeck/ui/_next/static/chunks/1cr8soqffviy4.js +1 -0
  86. deltadeck/ui/_next/static/chunks/1ctl-01dm3brq.js +1 -0
  87. deltadeck/ui/_next/static/chunks/1cxper-3md1o-.js +1 -0
  88. deltadeck/ui/_next/static/chunks/1d0je4el_be8s.js +36 -0
  89. deltadeck/ui/_next/static/chunks/1dcd-anbt29ze.js +1 -0
  90. deltadeck/ui/_next/static/chunks/1f1182516lh5d.js +1 -0
  91. deltadeck/ui/_next/static/chunks/1frzy0c-d3tf7.js +41 -0
  92. deltadeck/ui/_next/static/chunks/1ib4bqx1yadoq.js +1 -0
  93. deltadeck/ui/_next/static/chunks/1ijnbjhgkhviq.js +1 -0
  94. deltadeck/ui/_next/static/chunks/1jp_4giffrr5c.js +61 -0
  95. deltadeck/ui/_next/static/chunks/1kd8ih36je1cc.js +93 -0
  96. deltadeck/ui/_next/static/chunks/1khocfuulae2_.js +1 -0
  97. deltadeck/ui/_next/static/chunks/1ljfghevr8007.js +1 -0
  98. deltadeck/ui/_next/static/chunks/1mrguikl1mfmy.js +2 -0
  99. deltadeck/ui/_next/static/chunks/1n3avhvzagjqo.js +1 -0
  100. deltadeck/ui/_next/static/chunks/1nu18zblzzkxr.js +1 -0
  101. deltadeck/ui/_next/static/chunks/1nxtwvwc1-g9z.js +309 -0
  102. deltadeck/ui/_next/static/chunks/1oqyvtqy352xk.js +160 -0
  103. deltadeck/ui/_next/static/chunks/1qf05_j_wigan.js +135 -0
  104. deltadeck/ui/_next/static/chunks/1shqupz4psfrm.js +1 -0
  105. deltadeck/ui/_next/static/chunks/1su1--jeub0vd.js +1 -0
  106. deltadeck/ui/_next/static/chunks/1t67aekbcq1y3.js +1 -0
  107. deltadeck/ui/_next/static/chunks/1ubm9mtml7ye6.js +1 -0
  108. deltadeck/ui/_next/static/chunks/1vctqj-4qx0f1.js +32 -0
  109. deltadeck/ui/_next/static/chunks/1x65vx7fbh5f2.js +1 -0
  110. deltadeck/ui/_next/static/chunks/1xmdq3m655638.js +1 -0
  111. deltadeck/ui/_next/static/chunks/1y9mnk06touvm.js +1 -0
  112. deltadeck/ui/_next/static/chunks/1zgb46ecbigo9.js +1 -0
  113. deltadeck/ui/_next/static/chunks/2-936-wie2c7z.js +1 -0
  114. deltadeck/ui/_next/static/chunks/2-lhuoqw-8u6m.js +22 -0
  115. deltadeck/ui/_next/static/chunks/2168en1g_ff0m.js +1 -0
  116. deltadeck/ui/_next/static/chunks/22fvu1i8drv0y.js +1 -0
  117. deltadeck/ui/_next/static/chunks/22nye0w1-65or.js +138 -0
  118. deltadeck/ui/_next/static/chunks/23jcrmneulc-t.js +1 -0
  119. deltadeck/ui/_next/static/chunks/252yyuyz9o6wd.js +1 -0
  120. deltadeck/ui/_next/static/chunks/25dikvv_q4k84.js +1 -0
  121. deltadeck/ui/_next/static/chunks/26y99mhsiclgy.js +1 -0
  122. deltadeck/ui/_next/static/chunks/27ly0qb-et_jc.js +15 -0
  123. deltadeck/ui/_next/static/chunks/28g7ko42eee5a.js +1 -0
  124. deltadeck/ui/_next/static/chunks/29ig-8esac5vg.js +1 -0
  125. deltadeck/ui/_next/static/chunks/29k_24gthdxe3.js +1 -0
  126. deltadeck/ui/_next/static/chunks/29wydom8qwv9i.js +24 -0
  127. deltadeck/ui/_next/static/chunks/2coohofy6ysw4.js +5 -0
  128. deltadeck/ui/_next/static/chunks/2dfi-3n0im0zh.js +1 -0
  129. deltadeck/ui/_next/static/chunks/2fsxl6h9wu_5o.js +23 -0
  130. deltadeck/ui/_next/static/chunks/2idqz4xj9bcod.js +286 -0
  131. deltadeck/ui/_next/static/chunks/2iqj05imzhp4m.js +1 -0
  132. deltadeck/ui/_next/static/chunks/2itsqt_1jw8_y.js +1 -0
  133. deltadeck/ui/_next/static/chunks/2kaw65j578cef.js +1 -0
  134. deltadeck/ui/_next/static/chunks/2krtxbjoqmvi1.js +1 -0
  135. deltadeck/ui/_next/static/chunks/2n-qh2g6xqyon.js +1 -0
  136. deltadeck/ui/_next/static/chunks/2of5-a32h_fzo.js +1 -0
  137. deltadeck/ui/_next/static/chunks/2ovdd04qwksb2.js +1 -0
  138. deltadeck/ui/_next/static/chunks/2piucm-g64elp.js +1 -0
  139. deltadeck/ui/_next/static/chunks/2r4yjf9zlczgc.js +1 -0
  140. deltadeck/ui/_next/static/chunks/2v-nzhb17_fmx.js +15 -0
  141. deltadeck/ui/_next/static/chunks/2x5312oohk9i1.js +1 -0
  142. deltadeck/ui/_next/static/chunks/2yecjfgk9i16a.js +1 -0
  143. deltadeck/ui/_next/static/chunks/2yk59ik12pbte.js +1 -0
  144. deltadeck/ui/_next/static/chunks/2z3sh82dl8glu.js.map +5 -0
  145. deltadeck/ui/_next/static/chunks/2zin2_z0f0kdt.js +1 -0
  146. deltadeck/ui/_next/static/chunks/309-hcw850qmx.js +1 -0
  147. deltadeck/ui/_next/static/chunks/31kdobv016cs1.js +122 -0
  148. deltadeck/ui/_next/static/chunks/338p9ntbs06p9.js +1 -0
  149. deltadeck/ui/_next/static/chunks/36l9fbswts8w8.js +1 -0
  150. deltadeck/ui/_next/static/chunks/36rd86hnjy--8.js +34 -0
  151. deltadeck/ui/_next/static/chunks/371c8kx9yq-nr.js +62 -0
  152. deltadeck/ui/_next/static/chunks/37d5batav2n82.js +148 -0
  153. deltadeck/ui/_next/static/chunks/37uockrobxqrf.js +92 -0
  154. deltadeck/ui/_next/static/chunks/384kjvhilvq0i.js +1 -0
  155. deltadeck/ui/_next/static/chunks/38l10l8pa-kih.js +1 -0
  156. deltadeck/ui/_next/static/chunks/39il1efd5-s8w.js +94 -0
  157. deltadeck/ui/_next/static/chunks/3actn9as2ghnu.js +2 -0
  158. deltadeck/ui/_next/static/chunks/3czk1zwynepq1.js +104 -0
  159. deltadeck/ui/_next/static/chunks/3d2tm2i87o5p_.js +1 -0
  160. deltadeck/ui/_next/static/chunks/3ed96tb7kaefk.js +15 -0
  161. deltadeck/ui/_next/static/chunks/3fqx0481wur_n.js +1 -0
  162. deltadeck/ui/_next/static/chunks/3g8rs-0o1gmnz.js +1 -0
  163. deltadeck/ui/_next/static/chunks/3iermca84mhmd.js +1 -0
  164. deltadeck/ui/_next/static/chunks/3kv82kxg4c50f.js +64 -0
  165. deltadeck/ui/_next/static/chunks/3lb6zi3ns5rpz.js +1 -0
  166. deltadeck/ui/_next/static/chunks/3ok2kyuqnjk1x.js +115 -0
  167. deltadeck/ui/_next/static/chunks/3pl3fu8ycp70s.js +62 -0
  168. deltadeck/ui/_next/static/chunks/3ti94rnk8g3g9.js +15 -0
  169. deltadeck/ui/_next/static/chunks/3waspvyikyn8d.js +1 -0
  170. deltadeck/ui/_next/static/chunks/3wo0teggh8855.js +1 -0
  171. deltadeck/ui/_next/static/chunks/3x3y44ggx9amw.js +1 -0
  172. deltadeck/ui/_next/static/chunks/3x_p7gm_efbdf.js +212 -0
  173. deltadeck/ui/_next/static/chunks/40aaur_-qhj85.js +22 -0
  174. deltadeck/ui/_next/static/chunks/41jdd8wvvl-p8.js +4 -0
  175. deltadeck/ui/_next/static/chunks/41ngr_kzkqmbv.js +27 -0
  176. deltadeck/ui/_next/static/chunks/41ymya8gm_nwe.js +1 -0
  177. deltadeck/ui/_next/static/chunks/43p3o8p9_gmdm.js +1 -0
  178. deltadeck/ui/_next/static/chunks/44_sq5v-kidea.js +1 -0
  179. deltadeck/ui/_next/static/chunks/44oxs3_litch8.js +1 -0
  180. deltadeck/ui/_next/static/chunks/44t0wft6n2b5n.js +1 -0
  181. deltadeck/ui/_next/static/chunks/turbopack-0y0dee_nb28iz.js +1 -0
  182. deltadeck/ui/_next/static/chunks/turbopack-2c3mg3r-05n1w.js +1 -0
  183. deltadeck/ui/_next/static/chunks/turbopack-2ikcxs2-0edba.js +1 -0
  184. deltadeck/ui/_next/static/chunks/turbopack-worker-1hrsqshz0gfey.js +1 -0
  185. deltadeck/ui/_next/static/media/icon.15prq8m0ln7xb.svg +18 -0
  186. deltadeck/ui/_next/static/media/worker.2tknvk8mcp098.ts +40 -0
  187. deltadeck/ui/_next/static/media/worker.3o_k-bouwf2w4.ts +27 -0
  188. deltadeck/ui/_not-found/__next._full.txt +26 -0
  189. deltadeck/ui/_not-found/__next._not-found.__PAGE__.txt +32 -0
  190. deltadeck/ui/_not-found/__next._tree.txt +4 -0
  191. deltadeck/ui/_not-found.html +1 -0
  192. deltadeck/ui/_not-found.txt +26 -0
  193. deltadeck/ui/icon.svg +18 -0
  194. deltadeck/ui/index.html +1 -0
  195. deltadeck/ui/index.txt +31 -0
  196. deltadeck/ui/logo.svg +18 -0
  197. deltadeck/ui/print/__next._full.txt +31 -0
  198. deltadeck/ui/print/__next._tree.txt +4 -0
  199. deltadeck/ui/print/__next.print.__PAGE__.txt +37 -0
  200. deltadeck/ui/print.html +1 -0
  201. deltadeck/ui/print.txt +31 -0
  202. deltadeck-0.2.0.dist-info/METADATA +167 -0
  203. deltadeck-0.2.0.dist-info/RECORD +206 -0
  204. deltadeck-0.2.0.dist-info/WHEEL +4 -0
  205. deltadeck-0.2.0.dist-info/entry_points.txt +2 -0
  206. deltadeck-0.2.0.dist-info/licenses/LICENSE +21 -0
deltadeck/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DeltaDeck viewer server (FastAPI backend)."""
2
+
3
+ __version__ = "0.2.0"
deltadeck/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m deltadeck` (also the PyInstaller entry point): same as `uv run deltadeck`."""
2
+
3
+ from deltadeck.cli import main # absolute: PyInstaller runs this file as a top-level script
4
+
5
+ if __name__ == "__main__":
6
+ main()
deltadeck/app.py ADDED
@@ -0,0 +1,309 @@
1
+ """FastAPI application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.responses import FileResponse, JSONResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
14
+
15
+ from . import __version__
16
+ from .config import Settings
17
+ from .edit import Editor
18
+ from .edit_routes import DEV_UI_PORT, MAX_UPLOAD_FILES, EditGate, is_edit_path, register_edit_routes
19
+ from .errors import register_error_handler
20
+ from .events import register_event_routes
21
+ from .file_routes import PDF_CSP, SVG_CSP, register_file_routes # noqa: F401 - CSPs re-exported
22
+ from .git import GitLink
23
+ from .git_routes import register_git_routes
24
+ from .hidden import GitIgnored, list_hidden_dir
25
+ from .http_common import ( # noqa: F401 - prefixes and path tests re-exported for older imports
26
+ ALLOWED_HOSTS,
27
+ ROOTS_PREFIX,
28
+ SEEN_PREFIX,
29
+ forwarded_hosts_ok,
30
+ is_gated_path,
31
+ is_roots_path,
32
+ is_seen_path,
33
+ same_site_request,
34
+ )
35
+ from .names import NAME_MAX
36
+ from .paths import PathGuard
37
+ from .pdf import PdfRenderer
38
+ from .reconcile import Reconciler, backstop_intervals, watch_loop
39
+ from .search import INDEX_DIR, MAX_COMPLETE, SearchIndex
40
+ from .store import Change, Store
41
+
42
+ log = logging.getLogger("deltadeck")
43
+
44
+ CSP = ("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
45
+ "img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; "
46
+ "object-src 'none'; base-uri 'none'; form-action 'none'")
47
+ ALLOWED_METHODS = {"GET", "HEAD"}
48
+
49
+
50
+ class SecurityMiddleware:
51
+ """Pure ASGI: refuse non-GET/HEAD (except below /api/edit, which EditGate guards, and, with
52
+ roots=True, below /api/roots and /api/seen, which RootGate guards), add security headers to
53
+ every response."""
54
+
55
+ def __init__(self, app, *, roots: bool = False):
56
+ self.app = app
57
+ self.roots = roots
58
+
59
+ async def __call__(self, scope, receive, send):
60
+ if scope["type"] != "http":
61
+ return await self.app(scope, receive, send)
62
+ if (scope["method"] not in ALLOWED_METHODS and not is_edit_path(scope["path"])
63
+ and not (self.roots and is_gated_path(scope["path"]))):
64
+ body = b'{"detail":"Method Not Allowed"}'
65
+ await send({"type": "http.response.start", "status": 405, "headers": [
66
+ (b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()),
67
+ (b"allow", b"GET, HEAD"), (b"content-security-policy", CSP.encode()),
68
+ (b"referrer-policy", b"no-referrer"), (b"x-content-type-options", b"nosniff")]})
69
+ await send({"type": "http.response.body", "body": body})
70
+ return
71
+
72
+ if not forwarded_hosts_ok(scope):
73
+ body = b'{"detail":"Invalid forwarded host"}'
74
+ await send({"type": "http.response.start", "status": 400, "headers": [
75
+ (b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()),
76
+ (b"content-security-policy", CSP.encode()),
77
+ (b"referrer-policy", b"no-referrer"), (b"x-content-type-options", b"nosniff")]})
78
+ await send({"type": "http.response.body", "body": body})
79
+ return
80
+
81
+ async def wrapped(message):
82
+ if message["type"] == "http.response.start":
83
+ headers = list(message.get("headers", []))
84
+ names = {k.lower() for k, _ in headers}
85
+ if b"content-security-policy" not in names:
86
+ headers.append((b"content-security-policy", CSP.encode()))
87
+ if b"referrer-policy" not in names:
88
+ headers.append((b"referrer-policy", b"no-referrer"))
89
+ if b"x-content-type-options" not in names:
90
+ headers.append((b"x-content-type-options", b"nosniff"))
91
+ message = {**message, "headers": headers}
92
+ await send(message)
93
+
94
+ await self.app(scope, receive, wrapped)
95
+
96
+
97
+ class ActivityMiddleware:
98
+ """Pure ASGI: each HTTP request (other than the live-update stream, which counts while it is
99
+ open) tells the reconciler someone is using the viewer."""
100
+
101
+ def __init__(self, app, *, reconciler: Reconciler):
102
+ self.app = app
103
+ self.reconciler = reconciler
104
+
105
+ async def __call__(self, scope, receive, send):
106
+ if scope["type"] == "http" and scope["path"] != "/api/events":
107
+ self.reconciler.touch()
108
+ await self.app(scope, receive, send)
109
+
110
+
111
+ def find_repo_prefix(root: str) -> str:
112
+ cur = root
113
+ while True:
114
+ if os.path.lexists(os.path.join(cur, ".git")):
115
+ rel = os.path.relpath(root, cur)
116
+ return "" if rel == "." else rel.replace(os.sep, "/") + "/"
117
+ parent = os.path.dirname(cur)
118
+ if parent == cur:
119
+ return ""
120
+ cur = parent
121
+
122
+
123
+ def config_payload(settings: Settings, *, root_present: bool, editor: Editor | None, git_enabled: bool,
124
+ repo_prefix: str = "") -> dict:
125
+ """The /api/config body shared by a root's app and the no-folder app (RootSwitcher); the
126
+ caller adds the request-dependent tokens."""
127
+ root = settings.root if root_present else None
128
+ return {"root_name": os.path.basename(root) if root else None, "repo_prefix": repo_prefix,
129
+ "search_max_bytes": settings.search_max_bytes, "version": __version__,
130
+ "edit_enabled": editor is not None, "edit_off_reason": settings.edit_off_reason or None,
131
+ "deny_patterns": list(settings.deny),
132
+ "ignore_patterns": list(settings.ignore), "upload_ext": list(settings.upload_ext),
133
+ "upload_max_bytes": settings.upload_max_bytes, "edit_max_bytes": settings.edit_max_bytes,
134
+ "upload_max_files": MAX_UPLOAD_FILES, "root_path": root, "no_root": not root_present,
135
+ "root_switch_enabled": False, "git_enabled": git_enabled, "desktop": settings.desktop,
136
+ "reveal_enabled": settings.allow_reveal}
137
+
138
+
139
+ def mount_ui(app: FastAPI, ui_dir: str) -> None:
140
+ """/print + the static export at /, or a 503 hint on / when the UI is not built."""
141
+ def get(path: str):
142
+ return app.api_route(path, methods=["GET", "HEAD"])
143
+
144
+ if os.path.isdir(ui_dir):
145
+ # The static export writes /print as print.html; StaticFiles does not add ".html".
146
+ @get("/print")
147
+ def ui_print():
148
+ f = os.path.join(ui_dir, "print.html")
149
+ if not os.path.isfile(f):
150
+ return JSONResponse({"detail": "print page not built; run 'npm run build'"}, status_code=404)
151
+ return FileResponse(f, media_type="text/html", headers={"Cache-Control": "no-cache"})
152
+
153
+ app.mount("/", StaticFiles(directory=ui_dir, html=True), name="ui")
154
+ else:
155
+ msg = (f"UI build not found at {ui_dir}. Run 'npm run build' in the web/ "
156
+ "directory; the API is still available under /api.")
157
+ log.warning(msg)
158
+
159
+ @get("/")
160
+ def ui_missing():
161
+ return JSONResponse({"detail": msg}, status_code=503)
162
+
163
+
164
+ def make_pdf_renderer(settings: Settings):
165
+ """--desktop: rendered by the Electron shell over its bridge; else Playwright + system Chrome
166
+ (playwright itself is only imported on the first export)."""
167
+ if settings.desktop:
168
+ from .desktop import BridgeClient, BridgePdfRenderer # local: desktop imports roots imports app
169
+
170
+ return BridgePdfRenderer(settings.port, BridgeClient(settings.bridge_url, settings.bridge_token))
171
+ return PdfRenderer(settings.port)
172
+
173
+
174
+ def search_index_dir(settings: Settings) -> str | None:
175
+ """<data_dir>/search, or None when the data dir overlaps the root (read-only mode never writes
176
+ inside the browsed folder; the index then goes to a temporary file, see deltadeck.search)."""
177
+ if settings.data_dir_overlaps_root():
178
+ return None
179
+ return os.path.join(settings.data_dir, INDEX_DIR)
180
+
181
+
182
+ def create_app(settings: Settings) -> FastAPI:
183
+ if settings.root is None:
184
+ raise ValueError("create_app needs a root (RootSwitcher serves the no-folder mode)")
185
+ settings.validate() # ConfigError: data dir overlaps the root while editing is enabled
186
+ guard = PathGuard(settings)
187
+ git = GitLink(settings, guard) # detected on first use (off: not a repo, no git, --git off)
188
+ ignored = GitIgnored(git.repo, guard)
189
+ store = Store(guard, ignored)
190
+ editor = Editor(settings, guard, store) if settings.allow_edit else None
191
+ index = SearchIndex(store, settings.search_max_bytes, index_dir=search_index_dir(settings),
192
+ max_files=settings.search_max_files, max_total_bytes=settings.search_max_total_bytes)
193
+ repo_prefix = find_repo_prefix(settings.root)
194
+ pdf = make_pdf_renderer(settings)
195
+ closing = asyncio.Event() # set on lifespan shutdown: ends this app's SSE streams (root switch)
196
+
197
+ async def on_change(ch: Change) -> None:
198
+ data = ch.to_json()
199
+ store.publish(ch)
200
+ await asyncio.to_thread(index.apply_change, data)
201
+
202
+ quick_s, full_s, remote = backstop_intervals(settings)
203
+ reconciler = Reconciler(store, on_change, quick_interval=quick_s, full_interval=full_s,
204
+ watching=settings.watch, pause_idle=not settings.rescan_when_idle, remote=remote)
205
+ store.on_subscribe = reconciler.client_connected
206
+
207
+ @contextlib.asynccontextmanager
208
+ async def lifespan(app: FastAPI):
209
+ store.bind_loop(asyncio.get_running_loop())
210
+ await asyncio.to_thread(ignored.refresh) # failing: shown as before, asked again at the next pass
211
+ await asyncio.to_thread(store.full_scan)
212
+ reconciler.reset()
213
+ index.start_background_build()
214
+ stop = asyncio.Event()
215
+ tasks = [asyncio.create_task(reconciler.run(stop))]
216
+ if settings.watch:
217
+ tasks.append(asyncio.create_task(watch_loop(store, on_change, stop, reconciler)))
218
+ log.info("change detection: %s; directory pass %s, full pass %s%s",
219
+ "file watcher" if settings.watch else "no file watcher",
220
+ f"every {quick_s:g} s" if quick_s else "off", f"every {full_s:g} s" if full_s else "off",
221
+ " (network volume)" if remote else "")
222
+ try:
223
+ yield
224
+ finally:
225
+ closing.set()
226
+ store.close_streams()
227
+ stop.set()
228
+ index.stop()
229
+ for t in tasks:
230
+ t.cancel()
231
+ await asyncio.gather(*tasks, return_exceptions=True)
232
+ await pdf.close()
233
+
234
+ app = FastAPI(
235
+ title="DeltaDeck", version=__version__, lifespan=lifespan,
236
+ docs_url=None, redoc_url=None, openapi_url=None,
237
+ telemetry={"tracing": False, "metrics": False, "logs": False,
238
+ "operation_spans": False, "auto_configure": False},
239
+ )
240
+ register_error_handler(app) # PathError, DocxError, PdfError, ...: {"detail"} with their status
241
+ app.state.settings = settings
242
+ app.state.store = store
243
+ app.state.index = index
244
+ app.state.pdf = pdf
245
+ app.state.editor = editor
246
+ app.state.closing = closing
247
+ app.state.git = git
248
+ app.state.reconciler = reconciler
249
+ app.state.config_extra = None # RootSwitcher: request -> {"root_switch_enabled", "root_token"?}
250
+
251
+ def get(path: str):
252
+ return app.api_route(path, methods=["GET", "HEAD"])
253
+
254
+ @get("/api/config")
255
+ def api_config(request: Request):
256
+ out = config_payload(settings, root_present=True, editor=editor, git_enabled=git.repo() is not None,
257
+ repo_prefix=repo_prefix)
258
+ if editor is not None and same_site_request(request):
259
+ out["edit_token"] = editor.token
260
+ if app.state.config_extra is not None:
261
+ out.update(app.state.config_extra(request))
262
+ return JSONResponse(out, headers={"Cache-Control": "no-store"})
263
+
264
+ @get("/api/tree")
265
+ def api_tree(hidden: str = "0"):
266
+ return store.snapshot(hidden=hidden in ("1", "true"), denied=settings.allow_reveal)
267
+
268
+ @get("/api/hidden")
269
+ def api_hidden(path: str | None = None):
270
+ """The children of one hidden folder, listed when it is expanded (Show hidden files)."""
271
+ return list_hidden_dir(store, guard, path, include_denied=settings.allow_reveal)
272
+
273
+ register_file_routes(app, settings, guard, pdf, repo_prefix, store)
274
+
275
+ @get("/api/search")
276
+ def api_search(q: str = "", folder: str = "", types: str = "", limit: int = 50):
277
+ return index.search(q, folder=folder, types=types, limit=limit)
278
+
279
+ @get("/api/complete")
280
+ def api_complete(prefix: str = "", path: str = "", limit: int = MAX_COMPLETE):
281
+ """Project-wide identifiers for editor completion, from the search index:
282
+ {"names": [{name, def, files}], "capped": bool}."""
283
+ if not 1 <= len(prefix) <= NAME_MAX:
284
+ return JSONResponse({"detail": f"prefix must be 1 to {NAME_MAX} characters"}, status_code=400)
285
+ if not 1 <= limit <= MAX_COMPLETE:
286
+ return JSONResponse({"detail": f"limit must be 1 to {MAX_COMPLETE}"}, status_code=400)
287
+ return index.complete(prefix, path=path, limit=limit)
288
+
289
+ register_event_routes(app, store, closing)
290
+
291
+ if editor is not None:
292
+ register_edit_routes(app, editor, on_change)
293
+
294
+ register_git_routes(app, git.repo, same_site_request)
295
+
296
+ @get("/api/{rest:path}")
297
+ def api_not_found(rest: str):
298
+ return JSONResponse({"detail": "Not Found"}, status_code=404)
299
+
300
+ mount_ui(app, settings.ui_dir)
301
+
302
+ origins = {f"http://{h}:{p}" for h in ALLOWED_HOSTS for p in (settings.port, DEV_UI_PORT)}
303
+ app.add_middleware(EditGate, token=editor.token if editor else None, origins=origins,
304
+ json_max=settings.edit_max_bytes * 6 + 64 * 1024,
305
+ upload_max=settings.upload_max_bytes * MAX_UPLOAD_FILES + 1024 * 1024)
306
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
307
+ app.add_middleware(ActivityMiddleware, reconciler=reconciler)
308
+ app.add_middleware(SecurityMiddleware) # outermost: also covers TrustedHost rejections
309
+ return app
deltadeck/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ """Console entry point: `uv run deltadeck`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ import uvicorn
10
+
11
+ from .config import HOST, settings_from_args
12
+ from .roots import RootSwitcher
13
+
14
+
15
+ def self_check() -> None:
16
+ """Hidden `--self-check` (packaging): the frozen binary has the app, SQLite FTS5, mammoth (.docx),
17
+ the file watcher's native backend (watchfiles) and no playwright."""
18
+ import importlib.util
19
+ import io
20
+ import sqlite3
21
+ import zipfile
22
+
23
+ from . import app, desktop # noqa: F401 - import errors are the check
24
+ from .docx import convert_docx
25
+ from .search import CONTENTLESS_DELETE, TOKENIZE
26
+
27
+ # store.py imports watchfiles lazily: check its native (FSEvents/inotify) extension made it into the bundle
28
+ from watchfiles import _rust_notify # noqa: F401
29
+
30
+ # the search index's table: contentless FTS5, its tokenizer, and contentless_delete when SQLite has it
31
+ db = sqlite3.connect(":memory:")
32
+ opts = ", contentless_delete=1" if CONTENTLESS_DELETE else ""
33
+ db.execute(f"CREATE VIRTUAL TABLE t USING fts5(body, content=''{opts}, tokenize='{TOKENIZE}')")
34
+ db.execute("INSERT INTO t (rowid, body) VALUES (1, 'hello frozen café')")
35
+ assert db.execute("SELECT count(*) FROM t WHERE t MATCH 'frozen AND cafe'").fetchone()[0] == 1
36
+ if CONTENTLESS_DELETE:
37
+ db.execute("DELETE FROM t WHERE rowid = 1")
38
+ assert db.execute("SELECT count(*) FROM t WHERE t MATCH 'frozen'").fetchone()[0] == 0
39
+ buf = io.BytesIO()
40
+ with zipfile.ZipFile(buf, "w") as z:
41
+ z.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
42
+ '<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
43
+ 'relationships/officeDocument" Target="word/document.xml"/></Relationships>')
44
+ z.writestr("word/document.xml", '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/'
45
+ '2006/main"><w:body><w:p><w:r><w:t>frozen docx</w:t></w:r></w:p></w:body></w:document>')
46
+ assert convert_docx(buf.getvalue()).markdown == "frozen docx\n"
47
+ frozen = getattr(sys, "frozen", False)
48
+ if frozen and importlib.util.find_spec("playwright") is not None:
49
+ sys.exit("[deltadeck] self-check: playwright is bundled")
50
+ delete = "contentless_delete" if CONTENTLESS_DELETE else "no-contentless_delete"
51
+ print(f"DELTADECK_SELF_CHECK ok sqlite {sqlite3.sqlite_version} fts5 {delete} docx watchfiles"
52
+ f" python {sys.version.split()[0]}{' frozen' if frozen else ''}")
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> None:
56
+ if (sys.argv[1:] if argv is None else argv) == ["--self-check"]:
57
+ return self_check()
58
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
59
+ settings = settings_from_args(argv)
60
+ if settings.desktop:
61
+ from .desktop import check_bridge
62
+
63
+ if err := check_bridge(settings.bridge_url, settings.bridge_token):
64
+ sys.exit(f"[deltadeck] {err}")
65
+ if settings.root is not None and not os.path.isdir(settings.root):
66
+ sys.exit(f"root folder not found: {settings.root}")
67
+ if settings.root is None and settings.lock_root:
68
+ sys.exit("[deltadeck] --lock-root needs a folder: pass --root PATH or set DELTADECK_ROOT")
69
+ if not os.path.isdir(settings.ui_dir):
70
+ print(f"[deltadeck] UI not built ({settings.ui_dir} missing). "
71
+ "Run 'npm run build' in web/ - serving the API only.", file=sys.stderr)
72
+ if settings.root is None:
73
+ print("[deltadeck] no folder selected — choose one in the browser", file=sys.stderr)
74
+ else:
75
+ src = {"state": " (last used)", "env": " (DELTADECK_ROOT)"}.get(settings.root_source, "")
76
+ print(f"[deltadeck] root: {settings.root}{src}", file=sys.stderr)
77
+ here = settings.editable() # this root only; `settings` stays the base for folder switches
78
+ if here.edit_off_reason:
79
+ print(f"[deltadeck] read-only here: {here.edit_off_reason}", file=sys.stderr)
80
+ elif here.allow_edit:
81
+ where = settings.store_dir if settings.root else f"{settings.data_dir}/roots/<key>"
82
+ print(f"[deltadeck] editing on (--read-only to turn off); history/trash/audit in {where}", file=sys.stderr)
83
+ elif settings.root is not None and settings.data_dir_overlaps_root():
84
+ print(f"[deltadeck] note: data dir {settings.data_dir} overlaps the root; it is hidden from the viewer", file=sys.stderr)
85
+ if settings.lock_root:
86
+ print("[deltadeck] folder switching disabled (--lock-root)", file=sys.stderr)
87
+ if settings.desktop:
88
+ return _run_desktop(settings)
89
+ print(f"[deltadeck] open http://{HOST}:{settings.port}/", file=sys.stderr)
90
+ app = RootSwitcher(settings)
91
+ uvicorn.run(app, host=HOST, port=settings.port, log_level="info", lifespan="on",
92
+ proxy_headers=False, server_header=False, timeout_graceful_shutdown=3)
93
+
94
+
95
+ def _run_desktop(settings) -> None:
96
+ """Child of the Electron shell: bridge picker + renderer, ready line on stdout, exit on stdin EOF."""
97
+ from .desktop import GRACEFUL_S, BridgeClient, bridge_picker, make_server, start_stdin_watchdog
98
+
99
+ print(f"[deltadeck] desktop mode; bridge {settings.bridge_url}", file=sys.stderr)
100
+ app = RootSwitcher(settings, picker=bridge_picker(BridgeClient(settings.bridge_url, settings.bridge_token)))
101
+ config = uvicorn.Config(app, host=HOST, port=settings.port, log_level="info", lifespan="on",
102
+ proxy_headers=False, server_header=False, timeout_graceful_shutdown=GRACEFUL_S)
103
+ server = make_server(config, settings.port)
104
+ start_stdin_watchdog(server)
105
+ server.run()
106
+ if not server.started:
107
+ sys.exit("[deltadeck] server did not start (see the log above)")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()