cista 0.6.0__tar.gz → 0.7.0__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 (89) hide show
  1. {cista-0.6.0 → cista-0.7.0}/PKG-INFO +3 -1
  2. {cista-0.6.0 → cista-0.7.0}/cista/_version.py +1 -1
  3. {cista-0.6.0 → cista-0.7.0}/cista/app.py +7 -1
  4. {cista-0.6.0 → cista-0.7.0}/cista/auth.py +32 -0
  5. cista-0.7.0/cista/preview.py +114 -0
  6. {cista-0.6.0 → cista-0.7.0}/cista/protocol.py +5 -2
  7. {cista-0.6.0 → cista-0.7.0}/cista/util/apphelpers.py +1 -1
  8. {cista-0.6.0 → cista-0.7.0}/cista/watching.py +161 -108
  9. cista-0.6.0/cista/wwwroot/assets/add-file-c48db6fd.js → cista-0.7.0/cista/wwwroot/assets/add-file-e19e18ff.js +1 -1
  10. cista-0.6.0/cista/wwwroot/assets/add-folder-ae7eb668.js → cista-0.7.0/cista/wwwroot/assets/add-folder-cf930a27.js +1 -1
  11. cista-0.6.0/cista/wwwroot/assets/arrow-bee3f6bb.js → cista-0.7.0/cista/wwwroot/assets/arrow-f09bf4bc.js +1 -1
  12. cista-0.6.0/cista/wwwroot/assets/arrows-h-dc1be328.js → cista-0.7.0/cista/wwwroot/assets/arrows-h-74714fc1.js +1 -1
  13. cista-0.6.0/cista/wwwroot/assets/arrows-v-fc21690e.js → cista-0.7.0/cista/wwwroot/assets/arrows-v-900e3e20.js +1 -1
  14. cista-0.6.0/cista/wwwroot/assets/check-872e6fef.js → cista-0.7.0/cista/wwwroot/assets/check-5d5030f9.js +1 -1
  15. cista-0.6.0/cista/wwwroot/assets/code-c1c725d1.js → cista-0.7.0/cista/wwwroot/assets/code-08d0327a.js +1 -1
  16. cista-0.6.0/cista/wwwroot/assets/copy-48612331.js → cista-0.7.0/cista/wwwroot/assets/copy-d4e131a3.js +1 -1
  17. cista-0.6.0/cista/wwwroot/assets/create-file-7b7d1627.js → cista-0.7.0/cista/wwwroot/assets/create-file-7ee36655.js +1 -1
  18. cista-0.6.0/cista/wwwroot/assets/create-folder-1790235c.js → cista-0.7.0/cista/wwwroot/assets/create-folder-4baad31c.js +1 -1
  19. cista-0.6.0/cista/wwwroot/assets/cross-efb25224.js → cista-0.7.0/cista/wwwroot/assets/cross-5dc70d0a.js +1 -1
  20. cista-0.6.0/cista/wwwroot/assets/disk-6c9f37c0.js → cista-0.7.0/cista/wwwroot/assets/disk-1c8c6bb2.js +1 -1
  21. cista-0.6.0/cista/wwwroot/assets/download-11627837.js → cista-0.7.0/cista/wwwroot/assets/download-4c58df40.js +1 -1
  22. cista-0.6.0/cista/wwwroot/assets/exclamation-200723e0.js → cista-0.7.0/cista/wwwroot/assets/exclamation-b23fc5a1.js +1 -1
  23. cista-0.6.0/cista/wwwroot/assets/eye-ec1b290f.js → cista-0.7.0/cista/wwwroot/assets/eye-fc97097a.js +1 -1
  24. cista-0.6.0/cista/wwwroot/assets/find-44c299ba.js → cista-0.7.0/cista/wwwroot/assets/find-9f5d36c7.js +1 -1
  25. cista-0.6.0/cista/wwwroot/assets/fullscreen-27780299.js → cista-0.7.0/cista/wwwroot/assets/fullscreen-72bac89d.js +1 -1
  26. cista-0.6.0/cista/wwwroot/assets/github-0f840091.js → cista-0.7.0/cista/wwwroot/assets/github-8781f34a.js +1 -1
  27. cista-0.7.0/cista/wwwroot/assets/index-5ab4ce9a.css +1 -0
  28. cista-0.7.0/cista/wwwroot/assets/index-c828fba8.js +13 -0
  29. cista-0.6.0/cista/wwwroot/assets/info-764a525a.js → cista-0.7.0/cista/wwwroot/assets/info-81c1e2fa.js +1 -1
  30. cista-0.6.0/cista/wwwroot/assets/link-89144190.js → cista-0.7.0/cista/wwwroot/assets/link-ddc2f9ba.js +1 -1
  31. cista-0.6.0/cista/wwwroot/assets/logo-b3f4a78a.js → cista-0.7.0/cista/wwwroot/assets/logo-10d7b218.js +1 -1
  32. cista-0.6.0/cista/wwwroot/assets/loop-b8da5e06.js → cista-0.7.0/cista/wwwroot/assets/loop-a579040b.js +1 -1
  33. cista-0.6.0/cista/wwwroot/assets/menu-d36af663.js → cista-0.7.0/cista/wwwroot/assets/menu-cb4bdef2.js +1 -1
  34. cista-0.6.0/cista/wwwroot/assets/next-34035830.js → cista-0.7.0/cista/wwwroot/assets/next-bb1c5152.js +1 -1
  35. cista-0.6.0/cista/wwwroot/assets/open-189253ec.js → cista-0.7.0/cista/wwwroot/assets/open-f8e4da33.js +1 -1
  36. cista-0.6.0/cista/wwwroot/assets/paste-0aec50e5.js → cista-0.7.0/cista/wwwroot/assets/paste-0bef6dfd.js +1 -1
  37. cista-0.6.0/cista/wwwroot/assets/pause-aa92dc1d.js → cista-0.7.0/cista/wwwroot/assets/pause-27898a74.js +1 -1
  38. cista-0.6.0/cista/wwwroot/assets/pencil-0b186fc4.js → cista-0.7.0/cista/wwwroot/assets/pencil-19d33c49.js +1 -1
  39. cista-0.6.0/cista/wwwroot/assets/play-addd980d.js → cista-0.7.0/cista/wwwroot/assets/play-fe6706ce.js +1 -1
  40. cista-0.6.0/cista/wwwroot/assets/plus-ee899018.js → cista-0.7.0/cista/wwwroot/assets/plus-ab9d4dbd.js +1 -1
  41. cista-0.6.0/cista/wwwroot/assets/previous-0f92e987.js → cista-0.7.0/cista/wwwroot/assets/previous-5be4e762.js +1 -1
  42. cista-0.6.0/cista/wwwroot/assets/reload-81e832ab.js → cista-0.7.0/cista/wwwroot/assets/reload-84455f3f.js +1 -1
  43. cista-0.6.0/cista/wwwroot/assets/rename-b5c230e5.js → cista-0.7.0/cista/wwwroot/assets/rename-88ab1b4d.js +1 -1
  44. cista-0.6.0/cista/wwwroot/assets/scissors-c12366c4.js → cista-0.7.0/cista/wwwroot/assets/scissors-1266cf56.js +1 -1
  45. cista-0.6.0/cista/wwwroot/assets/shuffle-d7dd9d32.js → cista-0.7.0/cista/wwwroot/assets/shuffle-0412a143.js +1 -1
  46. cista-0.6.0/cista/wwwroot/assets/signin-9a1cacc8.js → cista-0.7.0/cista/wwwroot/assets/signin-dcc16c88.js +1 -1
  47. cista-0.6.0/cista/wwwroot/assets/signout-330c1637.js → cista-0.7.0/cista/wwwroot/assets/signout-935b6b65.js +1 -1
  48. cista-0.6.0/cista/wwwroot/assets/skip-9ce9001e.js → cista-0.7.0/cista/wwwroot/assets/skip-adeae5b8.js +1 -1
  49. cista-0.6.0/cista/wwwroot/assets/spinner-f793897a.js → cista-0.7.0/cista/wwwroot/assets/spinner-7c5e1e66.js +1 -1
  50. cista-0.6.0/cista/wwwroot/assets/stop-87606523.js → cista-0.7.0/cista/wwwroot/assets/stop-6ec4fac4.js +1 -1
  51. cista-0.6.0/cista/wwwroot/assets/trash-a4bda4df.js → cista-0.7.0/cista/wwwroot/assets/trash-218fd3df.js +1 -1
  52. cista-0.6.0/cista/wwwroot/assets/triangle-7c4b08d9.js → cista-0.7.0/cista/wwwroot/assets/triangle-40a425a9.js +1 -1
  53. cista-0.6.0/cista/wwwroot/assets/unfullscreen-e1b42b29.js → cista-0.7.0/cista/wwwroot/assets/unfullscreen-3d51fed5.js +1 -1
  54. cista-0.6.0/cista/wwwroot/assets/up-arrow-b1321ecd.js → cista-0.7.0/cista/wwwroot/assets/up-arrow-af385124.js +1 -1
  55. cista-0.6.0/cista/wwwroot/assets/upload-cloud-8f6227a0.js → cista-0.7.0/cista/wwwroot/assets/upload-cloud-68d0fe9f.js +1 -1
  56. cista-0.6.0/cista/wwwroot/assets/user-cog-17094aa5.js → cista-0.7.0/cista/wwwroot/assets/user-cog-bca0b085.js +1 -1
  57. cista-0.6.0/cista/wwwroot/assets/user-a5d7349e.js → cista-0.7.0/cista/wwwroot/assets/user-dd4fef53.js +1 -1
  58. cista-0.6.0/cista/wwwroot/assets/volume-high-8398b839.js → cista-0.7.0/cista/wwwroot/assets/volume-high-0f8f0e3d.js +1 -1
  59. cista-0.6.0/cista/wwwroot/assets/volume-low-d60cf765.js → cista-0.7.0/cista/wwwroot/assets/volume-low-b1bd663e.js +1 -1
  60. cista-0.6.0/cista/wwwroot/assets/volume-medium-b7a98403.js → cista-0.7.0/cista/wwwroot/assets/volume-medium-cd88f329.js +1 -1
  61. cista-0.6.0/cista/wwwroot/assets/volume-mute-d334c51b.js → cista-0.7.0/cista/wwwroot/assets/volume-mute-458a2f48.js +1 -1
  62. cista-0.6.0/cista/wwwroot/assets/window-fcd5a9c7.js → cista-0.7.0/cista/wwwroot/assets/window-b63b3c5a.js +1 -1
  63. cista-0.6.0/cista/wwwroot/assets/window-cross-ceeeab35.js → cista-0.7.0/cista/wwwroot/assets/window-cross-b0bd1b60.js +1 -1
  64. cista-0.6.0/cista/wwwroot/assets/wordwrap-93890b9c.js → cista-0.7.0/cista/wwwroot/assets/wordwrap-26b55346.js +1 -1
  65. cista-0.6.0/cista/wwwroot/assets/zoomin-2a84ed61.js → cista-0.7.0/cista/wwwroot/assets/zoomin-cb10ed9f.js +1 -1
  66. cista-0.6.0/cista/wwwroot/assets/zoomout-f58eed4b.js → cista-0.7.0/cista/wwwroot/assets/zoomout-90bcc471.js +1 -1
  67. {cista-0.6.0 → cista-0.7.0}/cista/wwwroot/index.html +3 -4
  68. {cista-0.6.0 → cista-0.7.0}/pyproject.toml +2 -0
  69. cista-0.6.0/cista/preview.py +0 -52
  70. cista-0.6.0/cista/wwwroot/assets/index-824ecf90.js +0 -13
  71. cista-0.6.0/cista/wwwroot/assets/index-c009dda3.css +0 -1
  72. {cista-0.6.0 → cista-0.7.0}/.gitignore +0 -0
  73. {cista-0.6.0 → cista-0.7.0}/README.md +0 -0
  74. {cista-0.6.0 → cista-0.7.0}/cista/__init__.py +0 -0
  75. {cista-0.6.0 → cista-0.7.0}/cista/__main__.py +0 -0
  76. {cista-0.6.0 → cista-0.7.0}/cista/api.py +0 -0
  77. {cista-0.6.0 → cista-0.7.0}/cista/config.py +0 -0
  78. {cista-0.6.0 → cista-0.7.0}/cista/droppy.py +0 -0
  79. {cista-0.6.0 → cista-0.7.0}/cista/fileio.py +0 -0
  80. {cista-0.6.0 → cista-0.7.0}/cista/serve.py +0 -0
  81. {cista-0.6.0 → cista-0.7.0}/cista/server80.py +0 -0
  82. {cista-0.6.0 → cista-0.7.0}/cista/session.py +0 -0
  83. {cista-0.6.0 → cista-0.7.0}/cista/util/__init__.py +0 -0
  84. {cista-0.6.0 → cista-0.7.0}/cista/util/asynclink.py +0 -0
  85. {cista-0.6.0 → cista-0.7.0}/cista/util/filename.py +0 -0
  86. {cista-0.6.0 → cista-0.7.0}/cista/util/lrucache.py +0 -0
  87. {cista-0.6.0 → cista-0.7.0}/cista/util/pwgen.py +0 -0
  88. {cista-0.6.0 → cista-0.7.0}/cista/wwwroot/assets/logo-97d1d7eb.svg +0 -0
  89. {cista-0.6.0 → cista-0.7.0}/cista/wwwroot/robots.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cista
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Dropbox-like file server with modern web interface
5
5
  Project-URL: Homepage, https://git.zi.fi/Vasanko/cista-storage
6
6
  Author: Vasanko
@@ -14,7 +14,9 @@ Requires-Dist: msgspec
14
14
  Requires-Dist: natsort
15
15
  Requires-Dist: pathvalidate
16
16
  Requires-Dist: pillow
17
+ Requires-Dist: pyav
17
18
  Requires-Dist: pyjwt
19
+ Requires-Dist: pymupdf
18
20
  Requires-Dist: sanic
19
21
  Requires-Dist: stream-zip
20
22
  Requires-Dist: tomli-w
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by hatch build.
2
- __version__ = '0.6.0'
2
+ __version__ = '0.7.0'
@@ -11,7 +11,7 @@ from wsgiref.handlers import format_date_time
11
11
  import brotli
12
12
  import sanic.helpers
13
13
  from blake3 import blake3
14
- from sanic import Blueprint, Sanic, empty, raw
14
+ from sanic import Blueprint, Sanic, empty, raw, redirect
15
15
  from sanic.exceptions import Forbidden, NotFound
16
16
  from sanic.log import logger
17
17
  from stream_zip import ZIP_AUTO, stream_zip
@@ -198,6 +198,12 @@ async def wwwroot(req, path=""):
198
198
  return raw(data, headers=headers)
199
199
 
200
200
 
201
+ @app.route("/favicon.ico", methods=["GET", "HEAD"])
202
+ async def favicon(req):
203
+ # Browsers keep asking for it when viewing files (not HTML with icon link)
204
+ return redirect("/assets/logo-97d1d7eb.svg", status=308)
205
+
206
+
201
207
  def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
202
208
  loc = PurePosixPath()
203
209
  idx = 0
@@ -159,3 +159,35 @@ async def logout_post(request):
159
159
  res = json({"message": msg})
160
160
  session.delete(res)
161
161
  return res
162
+
163
+
164
+ @bp.post("/password-change")
165
+ async def change_password(request):
166
+ try:
167
+ if request.headers.content_type == "application/json":
168
+ username = request.json["username"]
169
+ pwchange = request.json["passwordChange"]
170
+ password = request.json["password"]
171
+ else:
172
+ username = request.form["username"][0]
173
+ pwchange = request.form["passwordChange"][0]
174
+ password = request.form["password"][0]
175
+ if not username or not password:
176
+ raise KeyError
177
+ except KeyError:
178
+ raise BadRequest(
179
+ "Missing username, passwordChange or password",
180
+ ) from None
181
+ try:
182
+ user = login(username, password)
183
+ set_password(user, pwchange)
184
+ except ValueError as e:
185
+ raise Forbidden(str(e), context={"redirect": "/login"}) from e
186
+
187
+ if "text/html" in request.headers.accept:
188
+ res = redirect("/")
189
+ session.flash(res, "Password updated")
190
+ else:
191
+ res = json({"message": "Password updated"})
192
+ session.create(res, username)
193
+ return res
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import io
3
+ import mimetypes
4
+ import urllib.parse
5
+ from pathlib import PurePosixPath
6
+ from urllib.parse import unquote
7
+ from wsgiref.handlers import format_date_time
8
+
9
+ import av
10
+ import av.datasets
11
+ import fitz # PyMuPDF
12
+ from PIL import Image
13
+ from sanic import Blueprint, empty, raw
14
+ from sanic.exceptions import NotFound
15
+ from sanic.log import logger
16
+
17
+ from cista import config
18
+ from cista.util.filename import sanitize
19
+
20
+ bp = Blueprint("preview", url_prefix="/preview")
21
+
22
+
23
+ @bp.get("/<path:path>")
24
+ async def preview(req, path):
25
+ """Preview a file"""
26
+ maxsize = int(req.args.get("px", 1024))
27
+ maxzoom = float(req.args.get("zoom", 2.0))
28
+ quality = int(req.args.get("q", 40))
29
+ rel = PurePosixPath(sanitize(unquote(path)))
30
+ path = config.config.path / rel
31
+ stat = path.lstat()
32
+ etag = config.derived_secret(
33
+ "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
34
+ ).hex()
35
+ savename = PurePosixPath(path.name).with_suffix(".webp")
36
+ headers = {
37
+ "etag": etag,
38
+ "last-modified": format_date_time(stat.st_mtime),
39
+ "cache-control": "max-age=604800, immutable"
40
+ + ("" if config.config.public else ", private"),
41
+ "content-type": "image/webp",
42
+ "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
43
+ }
44
+ if req.headers.if_none_match == etag:
45
+ # The client has it cached, respond 304 Not Modified
46
+ return empty(304, headers=headers)
47
+
48
+ if not path.is_file():
49
+ raise NotFound("File not found")
50
+
51
+ img = await asyncio.get_event_loop().run_in_executor(
52
+ req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
53
+ )
54
+ return raw(img, headers=headers)
55
+
56
+
57
+ def dispatch(path, quality, maxsize, maxzoom):
58
+ if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
59
+ return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
60
+ if mimetypes.guess_type(path.name)[0].startswith("video/"):
61
+ return process_video(path, quality=quality, maxsize=maxsize)
62
+ return process_image(path, quality=quality, maxsize=maxsize)
63
+
64
+
65
+ def process_image(path, *, maxsize, quality):
66
+ img = Image.open(path)
67
+ w, h = img.size
68
+ img.thumbnail((min(w, maxsize), min(h, maxsize)))
69
+ # Fix rotation based on EXIF data
70
+ try:
71
+ rotate_values = {3: 180, 6: 270, 8: 90}
72
+ orientation = img._getexif().get(274)
73
+ if orientation in rotate_values:
74
+ logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
75
+ img = img.rotate(rotate_values[orientation], expand=True)
76
+ except AttributeError:
77
+ ...
78
+ except Exception as e:
79
+ logger.error(f"Error rotating preview image: {e}")
80
+ # Save as webp
81
+ imgdata = io.BytesIO()
82
+ img.save(imgdata, format="webp", quality=quality, method=4)
83
+ return imgdata.getvalue()
84
+
85
+
86
+ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
87
+ pdf = fitz.open(path)
88
+ page = pdf.load_page(page_number)
89
+ w, h = page.rect[2:4]
90
+ zoom = min(maxsize / w, maxsize / h, maxzoom)
91
+ mat = fitz.Matrix(zoom, zoom)
92
+ pix = page.get_pixmap(matrix=mat)
93
+ return pix.pil_tobytes(format="webp", quality=quality, method=4)
94
+
95
+
96
+ def process_video(path, *, maxsize, quality):
97
+ with av.open(str(path)) as container:
98
+ stream = container.streams.video[0]
99
+ rotation = (
100
+ stream.side_data
101
+ and stream.side_data.get(av.stream.SideData.DISPLAYMATRIX)
102
+ or 0
103
+ )
104
+ stream.codec_context.skip_frame = "NONKEY"
105
+ container.seek(container.duration // 8)
106
+ frame = next(container.decode(stream))
107
+ img = frame.to_image()
108
+
109
+ img.thumbnail((maxsize, maxsize))
110
+ imgdata = io.BytesIO()
111
+ if rotation:
112
+ img = img.rotate(rotation, expand=True)
113
+ img.save(imgdata, format="webp", quality=quality, method=4)
114
+ return imgdata.getvalue()
@@ -112,7 +112,7 @@ class ErrorMsg(msgspec.Struct):
112
112
  ## Directory listings
113
113
 
114
114
 
115
- class FileEntry(msgspec.Struct, array_like=True):
115
+ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
116
116
  level: int
117
117
  name: str
118
118
  key: str
@@ -120,9 +120,12 @@ class FileEntry(msgspec.Struct, array_like=True):
120
120
  size: int
121
121
  isfile: int
122
122
 
123
- def __repr__(self):
123
+ def __str__(self):
124
124
  return self.key or "FileEntry()"
125
125
 
126
+ def __repr__(self):
127
+ return f"{self.name} ({self.size}, {self.mtime})"
128
+
126
129
 
127
130
  class Update(msgspec.Struct, array_like=True):
128
131
  ...
@@ -59,7 +59,7 @@ def websocket_wrapper(handler):
59
59
  code = e.status_code
60
60
  message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
61
61
  await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
62
- if not getattr(e, "quiet", False):
62
+ if not getattr(e, "quiet", False) or code == 500:
63
63
  logger.exception(f"{code} {e!r}")
64
64
  raise
65
65
 
@@ -24,7 +24,7 @@ class State:
24
24
  def __init__(self):
25
25
  self.lock = threading.RLock()
26
26
  self._space = Space(0, 0, 0, 0)
27
- self._listing: list[FileEntry] = []
27
+ self.root: list[FileEntry] = []
28
28
 
29
29
  @property
30
30
  def space(self):
@@ -36,80 +36,70 @@ class State:
36
36
  with self.lock:
37
37
  self._space = space
38
38
 
39
- @property
40
- def root(self) -> list[FileEntry]:
41
- with self.lock:
42
- return self._listing[:]
43
-
44
- @root.setter
45
- def root(self, listing: list[FileEntry]):
46
- with self.lock:
47
- self._listing = listing
48
39
 
49
- def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]):
50
- relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0)
51
- begin, end = 0, len(self._listing)
52
- level = 0
53
- isfile = 0
40
+ def treeiter(rootmod):
41
+ relpath = PurePosixPath()
42
+ for i, entry in enumerate(rootmod):
43
+ if entry.level > 0:
44
+ relpath = PurePosixPath(*relpath.parts[: entry.level - 1]) / entry.name
45
+ yield i, relpath, entry
54
46
 
55
- # Special case for root
56
- if not relpath.parts:
57
- return slice(begin, end)
58
47
 
59
- begin += 1
60
- for part in relpath.parts:
48
+ def treeget(rootmod: list[FileEntry], path: PurePosixPath):
49
+ begin = None
50
+ ret = []
51
+ for i, relpath, entry in treeiter(rootmod):
52
+ if begin is None:
53
+ if relpath == path:
54
+ begin = i
55
+ ret.append(entry)
56
+ continue
57
+ if entry.level <= len(path.parts):
58
+ break
59
+ ret.append(entry)
60
+ return begin, ret
61
+
62
+
63
+ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
64
+ # Find the first entry greater than the new one
65
+ # precondition: the new entry doesn't exist
66
+ isfile = 0
67
+ level = 0
68
+ i = 0
69
+ for i, rel, entry in treeiter(rootmod):
70
+ if entry.level > level:
71
+ # We haven't found item at level, skip subdirectories
72
+ continue
73
+ if entry.level < level:
74
+ # We have passed the level, so the new item is the first
75
+ return i
76
+ if level == 0:
77
+ # root
61
78
  level += 1
62
- found = False
63
-
64
- while begin < end:
65
- entry = self._listing[begin]
66
-
67
- if entry.level < level:
68
- break
69
-
70
- if entry.level == level:
71
- if entry.name == part:
72
- found = True
73
- if level == len(relpath.parts):
74
- isfile = relfile
75
- else:
76
- begin += 1
77
- break
78
- cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
79
- if cmp > 0:
80
- break
81
-
82
- begin += 1
83
-
84
- if not found:
85
- return slice(begin, begin)
86
-
87
- # Found the starting point, now find the end of the slice
88
- for end in range(begin + 1, len(self._listing) + 1):
89
- if end == len(self._listing) or self._listing[end].level <= level:
90
- break
91
- return slice(begin, end)
92
-
93
- def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]):
94
- with self.lock:
95
- return self._listing[self._slice(index)]
96
-
97
- def __setitem__(
98
- self, index: tuple[PurePosixPath, int], value: list[FileEntry]
99
- ) -> None:
100
- rel, isfile = index
101
- with self.lock:
102
- if rel.parts:
103
- parent = self._slice(rel.parent)
104
- if parent.start == parent.stop:
105
- raise ValueError(
106
- f"Parent folder {rel.as_posix()} is missing for {rel.name}"
107
- )
108
- self._listing[self._slice(index)] = value
109
-
110
- def __delitem__(self, relpath: PurePosixPath):
111
- with self.lock:
112
- del self._listing[self._slice(relpath)]
79
+ continue
80
+ ename = rel.parts[level - 1]
81
+ name = relpath.parts[level - 1]
82
+ esort = sortkey(ename)
83
+ nsort = sortkey(name)
84
+ # Non-leaf are always folders, only use relfile at leaf
85
+ isfile = relfile if len(relpath.parts) == level else 0
86
+ # First compare by isfile, then by sorting order and if that too matches then case sensitive
87
+ cmp = (
88
+ entry.isfile - isfile
89
+ or (esort > nsort) - (esort < nsort)
90
+ or (ename > name) - (ename < name)
91
+ )
92
+ if cmp > 0:
93
+ return i
94
+ if cmp < 0:
95
+ continue
96
+ level += 1
97
+ if level > len(relpath.parts):
98
+ print("ERROR: insertpos", relpath, i, entry.name, entry.level, level)
99
+ break
100
+ else:
101
+ i += 1
102
+ return i
113
103
 
114
104
 
115
105
  state = State()
@@ -124,7 +114,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
124
114
  ret = []
125
115
  try:
126
116
  st = stat or path.stat()
127
- isfile = not S_ISDIR(st.st_mode)
117
+ isfile = int(not S_ISDIR(st.st_mode))
128
118
  entry = FileEntry(
129
119
  level=len(rel.parts),
130
120
  name=rel.name,
@@ -136,7 +126,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
136
126
  if isfile:
137
127
  return [entry]
138
128
  # Walk all entries of the directory
139
- ret = [entry]
129
+ ret: list[FileEntry] = [...] # type: ignore
140
130
  li = []
141
131
  for f in path.iterdir():
142
132
  if quit.is_set():
@@ -153,42 +143,57 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
153
143
  # Build the tree as a list of FileEntries
154
144
  for [_, name, s] in humansorted(li):
155
145
  sub = walk(rel / name, stat=s)
156
- ret.extend(sub)
157
146
  child = sub[0]
158
- entry.mtime = max(entry.mtime, child.mtime)
159
- entry.size += child.size
147
+ entry = FileEntry(
148
+ level=entry.level,
149
+ name=entry.name,
150
+ key=entry.key,
151
+ size=entry.size + child.size,
152
+ mtime=max(entry.mtime, child.mtime),
153
+ isfile=entry.isfile,
154
+ )
155
+ ret.extend(sub)
160
156
  except FileNotFoundError:
161
157
  pass # Things may be rapidly in motion
162
158
  except OSError as e:
163
159
  if e.errno == 13: # Permission denied
164
160
  pass
165
161
  logger.error(f"Watching {path=}: {e!r}")
162
+ if ret:
163
+ ret[0] = entry
166
164
  return ret
167
165
 
168
166
 
169
167
  def update_root(loop):
170
168
  """Full filesystem scan"""
169
+ old = state.root
171
170
  new = walk(PurePosixPath())
172
- with state.lock:
173
- old = state.root
174
- if old != new:
171
+ if old != new:
172
+ update = format_update(old, new)
173
+ with state.lock:
174
+ broadcast(update, loop)
175
175
  state.root = new
176
- broadcast(format_update(old, new), loop)
177
176
 
178
177
 
179
- def update_path(relpath: PurePosixPath, loop):
178
+ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
180
179
  """Called on FS updates, check the filesystem and broadcast any changes."""
181
180
  new = walk(relpath)
182
- with state.lock:
183
- old = state[relpath]
184
- if old == new:
185
- return
186
- old = state.root
187
- if new:
188
- state[relpath, new[0].isfile] = new
189
- else:
190
- del state[relpath]
191
- broadcast(format_update(old, state.root), loop)
181
+ obegin, old = treeget(rootmod, relpath)
182
+ if old == new:
183
+ logger.debug(
184
+ f"Watch: Event without changes needed {relpath}"
185
+ if old
186
+ else f"Watch: Event with old and new missing: {relpath}"
187
+ )
188
+ return
189
+ if obegin is not None:
190
+ del rootmod[obegin : obegin + len(old)]
191
+ if new:
192
+ logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}")
193
+ i = treeinspos(rootmod, relpath, new[0].isfile)
194
+ rootmod[i:i] = new
195
+ else:
196
+ logger.debug(f"Watch: Removed {relpath}")
192
197
 
193
198
 
194
199
  def update_space(loop):
@@ -210,40 +215,57 @@ def update_space(loop):
210
215
  def format_update(old, new):
211
216
  # Make keep/del/insert diff until one of the lists ends
212
217
  oidx, nidx = 0, 0
218
+ oremain, nremain = set(old), set(new)
213
219
  update = []
214
220
  keep_count = 0
215
221
  while oidx < len(old) and nidx < len(new):
222
+ modified = False
223
+ # Matching entries are kept
216
224
  if old[oidx] == new[nidx]:
225
+ entry = old[oidx]
226
+ oremain.remove(entry)
227
+ nremain.remove(entry)
217
228
  keep_count += 1
218
229
  oidx += 1
219
230
  nidx += 1
220
231
  continue
221
232
  if keep_count > 0:
233
+ modified = True
222
234
  update.append(UpdKeep(keep_count))
223
235
  keep_count = 0
224
236
 
237
+ # Items only in old are deleted
225
238
  del_count = 0
226
- rest = new[nidx:]
227
- while oidx < len(old) and old[oidx] not in rest:
239
+ while oidx < len(old) and old[oidx] not in nremain:
240
+ oremain.remove(old[oidx])
228
241
  del_count += 1
229
242
  oidx += 1
230
243
  if del_count:
231
244
  update.append(UpdDel(del_count))
232
245
  continue
233
246
 
247
+ # Items only in new are inserted
234
248
  insert_items = []
235
- rest = old[oidx:]
236
- while nidx < len(new) and new[nidx] not in rest:
237
- insert_items.append(new[nidx])
249
+ while nidx < len(new) and new[nidx] not in oremain:
250
+ entry = new[nidx]
251
+ nremain.remove(entry)
252
+ insert_items.append(entry)
238
253
  nidx += 1
239
- update.append(UpdIns(insert_items))
254
+ if insert_items:
255
+ modified = True
256
+ update.append(UpdIns(insert_items))
257
+
258
+ if not modified:
259
+ raise Exception(
260
+ f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}"
261
+ )
240
262
 
241
263
  # Diff any remaining
242
264
  if keep_count > 0:
243
265
  update.append(UpdKeep(keep_count))
244
- if oidx < len(old):
245
- update.append(UpdDel(len(old) - oidx))
246
- elif nidx < len(new):
266
+ if oremain:
267
+ update.append(UpdDel(len(oremain)))
268
+ elif nremain:
247
269
  update.append(UpdIns(new[nidx:]))
248
270
 
249
271
  return msgspec.json.encode({"update": update}).decode()
@@ -289,13 +311,14 @@ def watcher_inotify(loop):
289
311
  while not quit.is_set():
290
312
  i = inotify.adapters.InotifyTree(rootpath.as_posix())
291
313
  # Initialize the tree from filesystem
314
+ t0 = time.perf_counter()
292
315
  update_root(loop)
293
- trefresh = time.monotonic() + 30.0
316
+ t1 = time.perf_counter()
317
+ logger.debug(f"Root update took {t1 - t0:.1f}s")
318
+ trefresh = time.monotonic() + 300.0
294
319
  tspace = time.monotonic() + 5.0
295
320
  # Watch for changes (frequent wakeups needed for quiting)
296
- for event in i.event_gen(timeout_s=0.1):
297
- if quit.is_set():
298
- break
321
+ while not quit.is_set():
299
322
  t = time.monotonic()
300
323
  # The watching is not entirely reliable, so do a full refresh every 30 seconds
301
324
  if t >= trefresh:
@@ -304,10 +327,40 @@ def watcher_inotify(loop):
304
327
  if t >= tspace:
305
328
  tspace = time.monotonic() + 5.0
306
329
  update_space(loop)
307
- # Inotify event, update the tree
308
- if event and any(f in modified_flags for f in event[1]):
309
- # Update modified path
310
- update_path(PurePosixPath(event[2]) / event[3], loop)
330
+ # Inotify events, update the tree
331
+ dirty = False
332
+ rootmod = state.root[:]
333
+ for event in i.event_gen(yield_nones=False, timeout_s=0.1):
334
+ assert event
335
+ if quit.is_set():
336
+ return
337
+ interesting = any(f in modified_flags for f in event[1])
338
+ if event[2] == rootpath.as_posix() and event[3] == "zzz":
339
+ logger.debug(f"Watch: {interesting=} {event=}")
340
+ if interesting:
341
+ # Update modified path
342
+ t0 = time.perf_counter()
343
+ path = PurePosixPath(event[2]) / event[3]
344
+ update_path(rootmod, path.relative_to(rootpath), loop)
345
+ t1 = time.perf_counter()
346
+ logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s")
347
+ if not dirty:
348
+ t = time.monotonic()
349
+ dirty = True
350
+ # Wait a maximum of 0.5s to push the updates
351
+ if dirty and time.monotonic() >= t + 0.5:
352
+ break
353
+ if dirty and state.root != rootmod:
354
+ t0 = time.perf_counter()
355
+ update = format_update(state.root, rootmod)
356
+ t1 = time.perf_counter()
357
+ with state.lock:
358
+ broadcast(update, loop)
359
+ state.root = rootmod
360
+ t2 = time.perf_counter()
361
+ logger.debug(
362
+ f"Format update took {t1 - t0:.1f}s, broadcast {t2 - t1:.1f}s"
363
+ )
311
364
 
312
365
  del i # Free the inotify object
313
366
 
@@ -1 +1 @@
1
- import{o as e,c as t,a as o}from"./index-824ecf90.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 28 28"},c=o("path",{d:"M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16h-4.5z"},null,-1),n=[c];function a(r,d){return e(),t("svg",s,[...n])}const h={render:a};export{h as default,a as render};
1
+ import{o as e,c as t,a as o}from"./index-c828fba8.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 28 28"},c=o("path",{d:"M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16h-4.5z"},null,-1),n=[c];function a(r,d){return e(),t("svg",s,[...n])}const h={render:a};export{h as default,a as render};
@@ -1 +1 @@
1
- import{o as e,c as o,a as t}from"./index-824ecf90.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"},c=t("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5h-4.7z"},null,-1),n=[c];function r(a,l){return e(),o("svg",s,[...n])}const h={render:r};export{h as default,r as render};
1
+ import{o as e,c as o,a as t}from"./index-c828fba8.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"},c=t("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5h-4.7z"},null,-1),n=[c];function r(a,l){return e(),o("svg",s,[...n])}const h={render:r};export{h as default,r as render};
@@ -1 +1 @@
1
- import{o as e,c as t,a as o}from"./index-824ecf90.js";const c={xmlns:"http://www.w3.org/2000/svg",width:"640",height:"640",viewBox:"0 -32 640 640"},s=o("path",{d:"M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72z"},null,-1),r=[s];function a(n,d){return e(),t("svg",c,[...r])}const i={render:a};export{i as default,a as render};
1
+ import{o as e,c as t,a as o}from"./index-c828fba8.js";const c={xmlns:"http://www.w3.org/2000/svg",width:"640",height:"640",viewBox:"0 -32 640 640"},s=o("path",{d:"M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72z"},null,-1),r=[s];function a(n,d){return e(),t("svg",c,[...r])}const i={render:a};export{i as default,a as render};
@@ -1 +1 @@
1
- import{o as e,c as o,a as t}from"./index-824ecf90.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-6 -2 44 36"},r=t("path",{d:"M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"},null,-1),c=[r];function n(a,h){return e(),o("svg",s,[...c])}const d={render:n};export{d as default,n as render};
1
+ import{o as e,c as o,a as t}from"./index-c828fba8.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-6 -2 44 36"},r=t("path",{d:"M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"},null,-1),c=[r];function n(a,h){return e(),o("svg",s,[...c])}const d={render:n};export{d as default,n as render};
@@ -1 +1 @@
1
- import{o as e,c as o,a as t}from"./index-824ecf90.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -6 16 44"},r=t("path",{d:"M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"},null,-1),c=[r];function n(a,l){return e(),o("svg",s,[...c])}const h={render:n};export{h as default,n as render};
1
+ import{o as e,c as o,a as t}from"./index-c828fba8.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -6 16 44"},r=t("path",{d:"M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"},null,-1),c=[r];function n(a,l){return e(),o("svg",s,[...c])}const h={render:n};export{h as default,n as render};
@@ -1 +1 @@
1
- import{o as e,c as t,a as o}from"./index-824ecf90.js";const c={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-48 0 512 512"},s=o("path",{d:"M320 96 128 288l-64-64-64 64 128 128 256-256-64-64z"},null,-1),n=[s];function r(a,h){return e(),t("svg",c,[...n])}const i={render:r};export{i as default,r as render};
1
+ import{o as e,c as t,a as o}from"./index-c828fba8.js";const c={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-48 0 512 512"},s=o("path",{d:"M320 96 128 288l-64-64-64 64 128 128 256-256-64-64z"},null,-1),n=[s];function r(a,h){return e(),t("svg",c,[...n])}const i={render:r};export{i as default,r as render};
@@ -1 +1 @@
1
- import{o as e,c as t,a as o}from"./index-824ecf90.js";const s={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-24 8 512 512"},c=o("path",{d:"m304 96-48 48 112 112-112 112 48 48 144-160L304 96zm-160 0L0 256l144 160 48-48L80 256l112-112-48-48z"},null,-1),n=[c];function r(a,d){return e(),t("svg",s,[...n])}const l={render:r};export{l as default,r as render};
1
+ import{o as e,c as t,a as o}from"./index-c828fba8.js";const s={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-24 8 512 512"},c=o("path",{d:"m304 96-48 48 112 112-112 112 48 48 144-160L304 96zm-160 0L0 256l144 160 48-48L80 256l112-112-48-48z"},null,-1),n=[c];function r(a,d){return e(),t("svg",s,[...n])}const l={render:r};export{l as default,r as render};
@@ -1 +1 @@
1
- import{o as e,c as o,a as t}from"./index-824ecf90.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -2 36 36"},c=t("path",{d:"M26 8h-6V6l-6-6H0v24h12v8h20V14l-6-6zm0 2.83L29.17 14H26v-3.17zm-12-8L17.17 6H14V2.83zM2 2h10v6h6v14H2V2zm28 28H14v-6h6V10h4v6h6v14z"},null,-1),h=[c];function n(r,a){return e(),o("svg",s,[...h])}const l={render:n};export{l as default,n as render};
1
+ import{o as e,c as o,a as t}from"./index-c828fba8.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -2 36 36"},c=t("path",{d:"M26 8h-6V6l-6-6H0v24h12v8h20V14l-6-6zm0 2.83L29.17 14H26v-3.17zm-12-8L17.17 6H14V2.83zM2 2h10v6h6v14H2V2zm28 28H14v-6h6V10h4v6h6v14z"},null,-1),h=[c];function n(r,a){return e(),o("svg",s,[...h])}const l={render:n};export{l as default,n as render};
@@ -1 +1 @@
1
- import{o as e,c,a as t}from"./index-824ecf90.js";const o={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"},s=t("path",{d:"M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"},null,-1),n=[s];function r(a,h){return e(),c("svg",o,[...n])}const d={render:r};export{d as default,r as render};
1
+ import{o as e,c,a as t}from"./index-c828fba8.js";const o={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"},s=t("path",{d:"M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"},null,-1),n=[s];function r(a,h){return e(),c("svg",o,[...n])}const d={render:r};export{d as default,r as render};
@@ -1 +1 @@
1
- import{o as e,c,a as t}from"./index-824ecf90.js";const o={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"},s=t("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5v2.8z"},null,-1),r=[s];function h(n,a){return e(),c("svg",o,[...r])}const d={render:h};export{d as default,h as render};
1
+ import{o as e,c,a as t}from"./index-c828fba8.js";const o={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"},s=t("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5v2.8z"},null,-1),r=[s];function h(n,a){return e(),c("svg",o,[...r])}const d={render:h};export{d as default,h as render};