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.
- {cista-0.6.0 → cista-0.7.0}/PKG-INFO +3 -1
- {cista-0.6.0 → cista-0.7.0}/cista/_version.py +1 -1
- {cista-0.6.0 → cista-0.7.0}/cista/app.py +7 -1
- {cista-0.6.0 → cista-0.7.0}/cista/auth.py +32 -0
- cista-0.7.0/cista/preview.py +114 -0
- {cista-0.6.0 → cista-0.7.0}/cista/protocol.py +5 -2
- {cista-0.6.0 → cista-0.7.0}/cista/util/apphelpers.py +1 -1
- {cista-0.6.0 → cista-0.7.0}/cista/watching.py +161 -108
- cista-0.6.0/cista/wwwroot/assets/add-file-c48db6fd.js → cista-0.7.0/cista/wwwroot/assets/add-file-e19e18ff.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/add-folder-ae7eb668.js → cista-0.7.0/cista/wwwroot/assets/add-folder-cf930a27.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/arrow-bee3f6bb.js → cista-0.7.0/cista/wwwroot/assets/arrow-f09bf4bc.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/arrows-h-dc1be328.js → cista-0.7.0/cista/wwwroot/assets/arrows-h-74714fc1.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/arrows-v-fc21690e.js → cista-0.7.0/cista/wwwroot/assets/arrows-v-900e3e20.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/check-872e6fef.js → cista-0.7.0/cista/wwwroot/assets/check-5d5030f9.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/code-c1c725d1.js → cista-0.7.0/cista/wwwroot/assets/code-08d0327a.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/copy-48612331.js → cista-0.7.0/cista/wwwroot/assets/copy-d4e131a3.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/create-file-7b7d1627.js → cista-0.7.0/cista/wwwroot/assets/create-file-7ee36655.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/create-folder-1790235c.js → cista-0.7.0/cista/wwwroot/assets/create-folder-4baad31c.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/cross-efb25224.js → cista-0.7.0/cista/wwwroot/assets/cross-5dc70d0a.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/disk-6c9f37c0.js → cista-0.7.0/cista/wwwroot/assets/disk-1c8c6bb2.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/download-11627837.js → cista-0.7.0/cista/wwwroot/assets/download-4c58df40.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/exclamation-200723e0.js → cista-0.7.0/cista/wwwroot/assets/exclamation-b23fc5a1.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/eye-ec1b290f.js → cista-0.7.0/cista/wwwroot/assets/eye-fc97097a.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/find-44c299ba.js → cista-0.7.0/cista/wwwroot/assets/find-9f5d36c7.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/fullscreen-27780299.js → cista-0.7.0/cista/wwwroot/assets/fullscreen-72bac89d.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/github-0f840091.js → cista-0.7.0/cista/wwwroot/assets/github-8781f34a.js +1 -1
- cista-0.7.0/cista/wwwroot/assets/index-5ab4ce9a.css +1 -0
- cista-0.7.0/cista/wwwroot/assets/index-c828fba8.js +13 -0
- cista-0.6.0/cista/wwwroot/assets/info-764a525a.js → cista-0.7.0/cista/wwwroot/assets/info-81c1e2fa.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/link-89144190.js → cista-0.7.0/cista/wwwroot/assets/link-ddc2f9ba.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/logo-b3f4a78a.js → cista-0.7.0/cista/wwwroot/assets/logo-10d7b218.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/loop-b8da5e06.js → cista-0.7.0/cista/wwwroot/assets/loop-a579040b.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/menu-d36af663.js → cista-0.7.0/cista/wwwroot/assets/menu-cb4bdef2.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/next-34035830.js → cista-0.7.0/cista/wwwroot/assets/next-bb1c5152.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/open-189253ec.js → cista-0.7.0/cista/wwwroot/assets/open-f8e4da33.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/paste-0aec50e5.js → cista-0.7.0/cista/wwwroot/assets/paste-0bef6dfd.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/pause-aa92dc1d.js → cista-0.7.0/cista/wwwroot/assets/pause-27898a74.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/pencil-0b186fc4.js → cista-0.7.0/cista/wwwroot/assets/pencil-19d33c49.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/play-addd980d.js → cista-0.7.0/cista/wwwroot/assets/play-fe6706ce.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/plus-ee899018.js → cista-0.7.0/cista/wwwroot/assets/plus-ab9d4dbd.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/previous-0f92e987.js → cista-0.7.0/cista/wwwroot/assets/previous-5be4e762.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/reload-81e832ab.js → cista-0.7.0/cista/wwwroot/assets/reload-84455f3f.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/rename-b5c230e5.js → cista-0.7.0/cista/wwwroot/assets/rename-88ab1b4d.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/scissors-c12366c4.js → cista-0.7.0/cista/wwwroot/assets/scissors-1266cf56.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/shuffle-d7dd9d32.js → cista-0.7.0/cista/wwwroot/assets/shuffle-0412a143.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/signin-9a1cacc8.js → cista-0.7.0/cista/wwwroot/assets/signin-dcc16c88.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/signout-330c1637.js → cista-0.7.0/cista/wwwroot/assets/signout-935b6b65.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/skip-9ce9001e.js → cista-0.7.0/cista/wwwroot/assets/skip-adeae5b8.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/spinner-f793897a.js → cista-0.7.0/cista/wwwroot/assets/spinner-7c5e1e66.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/stop-87606523.js → cista-0.7.0/cista/wwwroot/assets/stop-6ec4fac4.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/trash-a4bda4df.js → cista-0.7.0/cista/wwwroot/assets/trash-218fd3df.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/triangle-7c4b08d9.js → cista-0.7.0/cista/wwwroot/assets/triangle-40a425a9.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/unfullscreen-e1b42b29.js → cista-0.7.0/cista/wwwroot/assets/unfullscreen-3d51fed5.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/up-arrow-b1321ecd.js → cista-0.7.0/cista/wwwroot/assets/up-arrow-af385124.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/upload-cloud-8f6227a0.js → cista-0.7.0/cista/wwwroot/assets/upload-cloud-68d0fe9f.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/user-cog-17094aa5.js → cista-0.7.0/cista/wwwroot/assets/user-cog-bca0b085.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/user-a5d7349e.js → cista-0.7.0/cista/wwwroot/assets/user-dd4fef53.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/volume-high-8398b839.js → cista-0.7.0/cista/wwwroot/assets/volume-high-0f8f0e3d.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/volume-low-d60cf765.js → cista-0.7.0/cista/wwwroot/assets/volume-low-b1bd663e.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/volume-medium-b7a98403.js → cista-0.7.0/cista/wwwroot/assets/volume-medium-cd88f329.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/volume-mute-d334c51b.js → cista-0.7.0/cista/wwwroot/assets/volume-mute-458a2f48.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/window-fcd5a9c7.js → cista-0.7.0/cista/wwwroot/assets/window-b63b3c5a.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/window-cross-ceeeab35.js → cista-0.7.0/cista/wwwroot/assets/window-cross-b0bd1b60.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/wordwrap-93890b9c.js → cista-0.7.0/cista/wwwroot/assets/wordwrap-26b55346.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/zoomin-2a84ed61.js → cista-0.7.0/cista/wwwroot/assets/zoomin-cb10ed9f.js +1 -1
- cista-0.6.0/cista/wwwroot/assets/zoomout-f58eed4b.js → cista-0.7.0/cista/wwwroot/assets/zoomout-90bcc471.js +1 -1
- {cista-0.6.0 → cista-0.7.0}/cista/wwwroot/index.html +3 -4
- {cista-0.6.0 → cista-0.7.0}/pyproject.toml +2 -0
- cista-0.6.0/cista/preview.py +0 -52
- cista-0.6.0/cista/wwwroot/assets/index-824ecf90.js +0 -13
- cista-0.6.0/cista/wwwroot/assets/index-c009dda3.css +0 -1
- {cista-0.6.0 → cista-0.7.0}/.gitignore +0 -0
- {cista-0.6.0 → cista-0.7.0}/README.md +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/__init__.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/__main__.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/api.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/config.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/droppy.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/fileio.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/serve.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/server80.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/session.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/util/__init__.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/util/asynclink.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/util/filename.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/util/lrucache.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/util/pwgen.py +0 -0
- {cista-0.6.0 → cista-0.7.0}/cista/wwwroot/assets/logo-97d1d7eb.svg +0 -0
- {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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
level
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 = [
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
245
|
-
update.append(UpdDel(len(
|
|
246
|
-
elif
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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};
|