web-edit 0.1a0__tar.gz → 0.1a2__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.
- {web_edit-0.1a0/web_edit.egg-info → web_edit-0.1a2}/PKG-INFO +3 -3
- {web_edit-0.1a0 → web_edit-0.1a2}/README.md +2 -2
- {web_edit-0.1a0 → web_edit-0.1a2}/frontend/app.js +57 -2
- {web_edit-0.1a0 → web_edit-0.1a2}/pyproject.toml +1 -1
- {web_edit-0.1a0 → web_edit-0.1a2}/scripts/check_wheel.py +6 -2
- {web_edit-0.1a0 → web_edit-0.1a2}/tests/test_app.py +35 -1
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/files.py +119 -49
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/app.js +17 -17
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/index.html +4 -2
- {web_edit-0.1a0 → web_edit-0.1a2/web_edit.egg-info}/PKG-INFO +3 -3
- {web_edit-0.1a0 → web_edit-0.1a2}/LICENSE +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/MANIFEST.in +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/frontend/build.mjs +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/package-lock.json +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/package.json +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/setup.cfg +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/tests/test_security.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/tests/test_web_edit.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/__init__.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/__main__.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/app.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/cli.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/security.py +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/THIRD_PARTY_LICENSES.txt +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/app.css +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/SOURCES.txt +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/dependency_links.txt +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/entry_points.txt +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/requires.txt +0 -0
- {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-edit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.1a2
|
|
4
4
|
Summary: Web app for editing files in a folder
|
|
5
5
|
Author: Simon Willison
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -66,14 +66,14 @@ To protect against [DNS rebinding attacks](https://tailscale.com/docs/reference/
|
|
|
66
66
|
- Browse folders with inline previews of images smaller than 2 MB.
|
|
67
67
|
- Create and edit UTF-8 text files with bundled CodeMirror 6, including on mobile.
|
|
68
68
|
- Save explicitly, with unsaved-change warnings and checks for external edits.
|
|
69
|
+
- Copy text to the clipboard, and images under 16 megapixels as PNG over HTTPS or localhost.
|
|
69
70
|
- Upload multiple files with custom paths and names, individual progress bars, and retries.
|
|
70
71
|
- Download files, or delete files and folders with inline confirmation.
|
|
71
72
|
|
|
72
73
|
## Development
|
|
73
74
|
|
|
74
75
|
```bash
|
|
75
|
-
uv
|
|
76
|
-
uv run web-edit .
|
|
76
|
+
uv run web-edit --help
|
|
77
77
|
uv run pytest
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -44,14 +44,14 @@ To protect against [DNS rebinding attacks](https://tailscale.com/docs/reference/
|
|
|
44
44
|
- Browse folders with inline previews of images smaller than 2 MB.
|
|
45
45
|
- Create and edit UTF-8 text files with bundled CodeMirror 6, including on mobile.
|
|
46
46
|
- Save explicitly, with unsaved-change warnings and checks for external edits.
|
|
47
|
+
- Copy text to the clipboard, and images under 16 megapixels as PNG over HTTPS or localhost.
|
|
47
48
|
- Upload multiple files with custom paths and names, individual progress bars, and retries.
|
|
48
49
|
- Download files, or delete files and folders with inline confirmation.
|
|
49
50
|
|
|
50
51
|
## Development
|
|
51
52
|
|
|
52
53
|
```bash
|
|
53
|
-
uv
|
|
54
|
-
uv run web-edit .
|
|
54
|
+
uv run web-edit --help
|
|
55
55
|
uv run pytest
|
|
56
56
|
```
|
|
57
57
|
|
|
@@ -18,6 +18,10 @@ const apiURL = (endpoint, path) => `./api/${endpoint}?path=${encodeURIComponent(
|
|
|
18
18
|
let currentFolder = "", rootName = "Files", editor = null, editingPath = "", fileVersion = null;
|
|
19
19
|
let savedText = "", isNew = false, saving = false, currentHash = "", navigation = 0;
|
|
20
20
|
const language = new Compartment();
|
|
21
|
+
// Images reach the clipboard as PNG, which needs the async clipboard API (HTTPS or localhost).
|
|
22
|
+
const canCopyImages = Boolean(navigator.clipboard?.write && window.ClipboardItem);
|
|
23
|
+
// The largest canvas iOS Safari allows; keeps PNG conversion consistent across browsers.
|
|
24
|
+
const COPY_PIXEL_LIMIT = 4096 * 4096;
|
|
21
25
|
|
|
22
26
|
function element(tag, className, text) {
|
|
23
27
|
const node = document.createElement(tag);
|
|
@@ -59,6 +63,46 @@ async function api(endpoint, options = {}) {
|
|
|
59
63
|
if (!response.ok) throw Object.assign(new Error(data.error || `Request failed (${response.status}).`), { status: response.status, data });
|
|
60
64
|
return data;
|
|
61
65
|
}
|
|
66
|
+
async function copyText(text) {
|
|
67
|
+
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
|
|
68
|
+
// Plain HTTP, such as a Tailscale IP address, has no async clipboard API.
|
|
69
|
+
const focused = document.activeElement;
|
|
70
|
+
const area = element("textarea");
|
|
71
|
+
area.value = text; area.readOnly = true;
|
|
72
|
+
area.style.cssText = "position: fixed; top: 0; left: -9999px; font-size: 16px;";
|
|
73
|
+
document.body.append(area);
|
|
74
|
+
area.select(); area.setSelectionRange(0, text.length);
|
|
75
|
+
const copied = document.execCommand("copy");
|
|
76
|
+
area.remove(); focused?.focus({ preventScroll: true });
|
|
77
|
+
if (!copied) throw new Error("Copy failed.");
|
|
78
|
+
}
|
|
79
|
+
function imagePNG(image) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const canvas = element("canvas");
|
|
82
|
+
canvas.width = image.naturalWidth; canvas.height = image.naturalHeight;
|
|
83
|
+
canvas.getContext("2d").drawImage(image, 0, 0);
|
|
84
|
+
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Conversion failed.")), "image/png");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function copyImage() {
|
|
88
|
+
const image = $("viewer-content").querySelector("img");
|
|
89
|
+
const path = $("viewer-title").textContent;
|
|
90
|
+
const pixels = image.naturalWidth * image.naturalHeight;
|
|
91
|
+
if (!pixels) { notify("This image has no fixed size, so it cannot be copied.", true); return; }
|
|
92
|
+
if (pixels > COPY_PIXEL_LIMIT) { notify("Images over 16 megapixels are too large to copy. You can download it instead.", true); return; }
|
|
93
|
+
// Safari only allows clipboard writes started directly by the click, so pass promises.
|
|
94
|
+
const types = { "image/png": imagePNG(image) };
|
|
95
|
+
if (/\.svg$/i.test(path)) {
|
|
96
|
+
types["text/plain"] = fetch(apiURL("image", path)).then(async (response) => {
|
|
97
|
+
if (!response.ok) throw new Error("Request failed.");
|
|
98
|
+
return new Blob([await response.text()], { type: "text/plain" });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await navigator.clipboard.write([new ClipboardItem(types)]);
|
|
103
|
+
notify(`Copied ${path} to the clipboard.`);
|
|
104
|
+
} catch { notify("Could not copy this image to the clipboard.", true); }
|
|
105
|
+
}
|
|
62
106
|
function dirty() { return editor && editor.state.doc.toString() !== savedText; }
|
|
63
107
|
function updateSaveState() {
|
|
64
108
|
$("save-state").textContent = saving ? "Saving…" : dirty() ? "Unsaved changes" : isNew ? "New file · not saved yet" : "All changes saved";
|
|
@@ -218,15 +262,20 @@ function openViewer(path, message, preview) {
|
|
|
218
262
|
$("viewer-title").textContent = path;
|
|
219
263
|
$("viewer-download").href = apiURL("download", path);
|
|
220
264
|
$("viewer-content").replaceChildren();
|
|
265
|
+
$("viewer-copy").hidden = !preview || !canCopyImages;
|
|
266
|
+
$("viewer-copy").disabled = true;
|
|
221
267
|
if (preview) {
|
|
222
268
|
const loading = loadingState("Loading image…");
|
|
223
269
|
const image = element("img");
|
|
224
270
|
image.alt = path; image.hidden = true;
|
|
225
|
-
image.addEventListener("load", () => {
|
|
271
|
+
image.addEventListener("load", () => {
|
|
272
|
+
if (!image.isConnected) return;
|
|
273
|
+
loading.remove(); image.hidden = false; $("viewer-copy").disabled = false;
|
|
274
|
+
});
|
|
226
275
|
image.addEventListener("error", () => {
|
|
227
276
|
if (!image.isConnected) return;
|
|
228
277
|
loading.replaceWith(element("p", "", "This image cannot be previewed. You can download it instead."));
|
|
229
|
-
image.remove();
|
|
278
|
+
image.remove(); $("viewer-copy").hidden = true;
|
|
230
279
|
});
|
|
231
280
|
$("viewer-content").append(loading, image);
|
|
232
281
|
image.src = apiURL("image", path);
|
|
@@ -397,6 +446,12 @@ $("new-file").addEventListener("click", () => { location.hash = routeURL("new",
|
|
|
397
446
|
$("refresh").addEventListener("click", route);
|
|
398
447
|
for (const id of ["back", "viewer-back"]) $(id).addEventListener("click", () => { location.hash = routeURL("browse", currentFolder); });
|
|
399
448
|
$("save").addEventListener("click", save);
|
|
449
|
+
$("editor-copy").addEventListener("click", async () => {
|
|
450
|
+
if (!editor) return;
|
|
451
|
+
try { await copyText(editor.state.doc.toString()); notify("Copied to the clipboard."); }
|
|
452
|
+
catch { notify("Could not copy to the clipboard.", true); }
|
|
453
|
+
});
|
|
454
|
+
$("viewer-copy").addEventListener("click", copyImage);
|
|
400
455
|
$("new-path").addEventListener("input", () => { if (editor) { const [name, support] = languageFor($("new-path").value); $("language-label").textContent = name; editor.dispatch({ effects: language.reconfigure(support) }); } });
|
|
401
456
|
window.addEventListener("hashchange", route);
|
|
402
457
|
window.addEventListener("beforeunload", (event) => { if (dirty() || saving || activeUploads) { event.preventDefault(); event.returnValue = ""; } });
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
"""Install a built wheel away from the checkout and exercise its packaged app.
|
|
2
2
|
|
|
3
|
-
Usage: uv run scripts/check_wheel.py dist
|
|
3
|
+
Usage: uv run scripts/check_wheel.py dist/*.whl
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import argparse
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
import tempfile
|
|
9
10
|
import zipfile
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
14
|
+
parser.add_argument("wheel", type=Path, help="Path to the single wheel to verify")
|
|
15
|
+
wheel = parser.parse_args().wheel.resolve()
|
|
16
|
+
print(f"Verifying {wheel.name}", flush=True)
|
|
13
17
|
required = {
|
|
14
18
|
"web_edit/static/index.html",
|
|
15
19
|
"web_edit/static/app.css",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import stat
|
|
@@ -8,7 +9,13 @@ import pytest
|
|
|
8
9
|
from starlette.testclient import TestClient
|
|
9
10
|
|
|
10
11
|
from web_edit.app import create_app
|
|
11
|
-
from web_edit.files import
|
|
12
|
+
from web_edit.files import (
|
|
13
|
+
IMAGE_LIMIT,
|
|
14
|
+
TEXT_LIMIT,
|
|
15
|
+
FileError,
|
|
16
|
+
FileStore,
|
|
17
|
+
preserve_endings,
|
|
18
|
+
)
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
@pytest.fixture
|
|
@@ -55,6 +62,16 @@ def test_browse(client, tmp_path):
|
|
|
55
62
|
assert client.get("/api/browse", params={"path": "missing"}).status_code == 404
|
|
56
63
|
|
|
57
64
|
|
|
65
|
+
def test_browse_nested_paths(client, tmp_path):
|
|
66
|
+
(tmp_path / "a" / "b").mkdir(parents=True)
|
|
67
|
+
(tmp_path / "a" / "b" / "c.txt").write_text("c")
|
|
68
|
+
root = client.get("/api/browse").json()
|
|
69
|
+
assert [entry["path"] for entry in root["entries"]] == ["a"]
|
|
70
|
+
nested = client.get("/api/browse", params={"path": "a//b/"}).json()
|
|
71
|
+
assert nested["path"] == "a/b"
|
|
72
|
+
assert [entry["path"] for entry in nested["entries"]] == ["a/b/c.txt"]
|
|
73
|
+
|
|
74
|
+
|
|
58
75
|
def test_create_read_edit_download(client, tmp_path):
|
|
59
76
|
path = "nested/a & #? café.txt"
|
|
60
77
|
created = save(client, path, "Hello 👋\n")
|
|
@@ -64,6 +81,8 @@ def test_create_read_edit_download(client, tmp_path):
|
|
|
64
81
|
changed = save(client, path, "Updated\n", text["version"])
|
|
65
82
|
assert changed.status_code == 200
|
|
66
83
|
assert changed.json()["version"] != text["version"]
|
|
84
|
+
reread = client.get("/api/text", params={"path": path}).json()
|
|
85
|
+
assert reread["version"] == changed.json()["version"]
|
|
67
86
|
downloaded = client.get("/api/download", params={"path": path})
|
|
68
87
|
assert downloaded.content == b"Updated\n"
|
|
69
88
|
assert "attachment" in downloaded.headers["content-disposition"]
|
|
@@ -96,7 +115,9 @@ def test_invalid_unicode(client):
|
|
|
96
115
|
(b"one\r\ntwo\r\n", "one\nchanged\n", b"one\r\nchanged\r\n"),
|
|
97
116
|
(b"one\rtwo\r", "one\nchanged\n", b"one\rchanged\r"),
|
|
98
117
|
(b"one\ntwo\n", "one\nchanged\n", b"one\nchanged\n"),
|
|
118
|
+
(b"one\ntwo\n", "one\r\nchanged\r", b"one\nchanged\n"),
|
|
99
119
|
(b"one\r\ntwo\nthree\r", "one\ntwo\nthree\n", b"one\r\ntwo\nthree\r"),
|
|
120
|
+
(b"a\r\n\r\nb\n\nc\r\n", "a\n\nb\n\nnew\nc\n", b"a\r\n\r\nb\n\nnew\r\nc\r\n"),
|
|
100
121
|
(b"\xef\xbb\xbfone\r\n", "changed\n", b"\xef\xbb\xbfchanged\r\n"),
|
|
101
122
|
(b"one", "two", b"two"),
|
|
102
123
|
(b"", "new\n", b"new\n"),
|
|
@@ -110,6 +131,18 @@ def test_preserves_line_endings_and_bom(client, tmp_path, original, edited, expe
|
|
|
110
131
|
assert path.read_bytes() == expected
|
|
111
132
|
|
|
112
133
|
|
|
134
|
+
def test_preserves_mixed_endings_with_scattered_edits():
|
|
135
|
+
lines = [(f"line {i}", "\r\n" if i % 3 else "\n") for i in range(3000)]
|
|
136
|
+
original = "".join(line + ending for line, ending in lines)
|
|
137
|
+
for i in range(0, len(lines), 7):
|
|
138
|
+
lines[i] = (f"changed {i}", "\r\n")
|
|
139
|
+
lines.insert(1500, ("inserted", "\r\n"))
|
|
140
|
+
del lines[10]
|
|
141
|
+
edited = "".join(line + "\n" for line, _ in lines)
|
|
142
|
+
expected = "".join(line + ending for line, ending in lines)
|
|
143
|
+
assert preserve_endings(original, edited) == expected
|
|
144
|
+
|
|
145
|
+
|
|
113
146
|
def test_preserves_permissions(client, tmp_path):
|
|
114
147
|
path = tmp_path / "script.sh"
|
|
115
148
|
path.write_text("echo before\n")
|
|
@@ -160,6 +193,7 @@ def test_upload_spooled_file_custom_path_and_binary(client, tmp_path):
|
|
|
160
193
|
data = b"\x00\xff" * (1024 * 1024)
|
|
161
194
|
response = upload(client, "new/folder/custom.bin", data)
|
|
162
195
|
assert response.status_code == 201
|
|
196
|
+
assert response.json()["version"] == hashlib.sha256(data).hexdigest()
|
|
163
197
|
assert (tmp_path / "new/folder/custom.bin").read_bytes() == data
|
|
164
198
|
assert (
|
|
165
199
|
client.get("/api/download", params={"path": "new/folder/custom.bin"}).content
|
|
@@ -8,6 +8,7 @@ import shutil
|
|
|
8
8
|
import stat
|
|
9
9
|
import tempfile
|
|
10
10
|
import threading
|
|
11
|
+
from bisect import bisect_left
|
|
11
12
|
from collections import Counter
|
|
12
13
|
from difflib import SequenceMatcher
|
|
13
14
|
from pathlib import Path, PurePosixPath
|
|
@@ -25,6 +26,9 @@ IMAGE_TYPES = {
|
|
|
25
26
|
"image/vnd.microsoft.icon",
|
|
26
27
|
"image/svg+xml",
|
|
27
28
|
}
|
|
29
|
+
CONTROL_BYTES = bytes([*range(0x09), 0x0B, 0x0C, *range(0x0E, 0x20)])
|
|
30
|
+
# Python 3.13+ can skip the URL parsing that guess_type does for every filename.
|
|
31
|
+
guess_file_type = getattr(mimetypes, "guess_file_type", mimetypes.guess_type)
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
class FileError(Exception):
|
|
@@ -53,30 +57,90 @@ def decode_text(data):
|
|
|
53
57
|
raise FileError(
|
|
54
58
|
"This file is not UTF-8 text. You can download it instead.", 415
|
|
55
59
|
)
|
|
56
|
-
|
|
60
|
+
# UTF-8 only uses these bytes for control characters; translate beats a regex.
|
|
61
|
+
if len(data.translate(None, CONTROL_BYTES)) != len(data):
|
|
57
62
|
raise FileError(
|
|
58
63
|
"This looks like a binary file. You can download it instead.", 415
|
|
59
64
|
)
|
|
60
65
|
return text
|
|
61
66
|
|
|
62
67
|
|
|
68
|
+
def matching_blocks(old, new):
|
|
69
|
+
"""Yield (old index, new index, size) for runs of matching lines.
|
|
70
|
+
|
|
71
|
+
SequenceMatcher alone slows down sharply on long files with scattered edits.
|
|
72
|
+
As in patience diff, lines that occur once in each version anchor the
|
|
73
|
+
comparison, so SequenceMatcher only aligns the gaps between anchors.
|
|
74
|
+
"""
|
|
75
|
+
old_counts, new_counts = Counter(old), Counter(new)
|
|
76
|
+
positions = {line: i for i, line in enumerate(old) if old_counts[line] == 1}
|
|
77
|
+
candidates = [
|
|
78
|
+
(positions[line], j)
|
|
79
|
+
for j, line in enumerate(new)
|
|
80
|
+
if new_counts[line] == 1 and line in positions
|
|
81
|
+
]
|
|
82
|
+
# Keep the longest series of candidates in the same order in both versions.
|
|
83
|
+
tails, ends, links = [], [], []
|
|
84
|
+
for index, (i, _) in enumerate(candidates):
|
|
85
|
+
length = bisect_left(tails, i)
|
|
86
|
+
links.append(ends[length - 1] if length else None)
|
|
87
|
+
if length == len(tails):
|
|
88
|
+
tails.append(i)
|
|
89
|
+
ends.append(index)
|
|
90
|
+
else:
|
|
91
|
+
tails[length] = i
|
|
92
|
+
ends[length] = index
|
|
93
|
+
anchors = [(len(old), len(new), 0)]
|
|
94
|
+
index = ends[-1] if ends else None
|
|
95
|
+
while index is not None:
|
|
96
|
+
anchors.append((*candidates[index], 1))
|
|
97
|
+
index = links[index]
|
|
98
|
+
anchors.append((0, 0, 0))
|
|
99
|
+
a = b = 0
|
|
100
|
+
for i, j, size in reversed(anchors):
|
|
101
|
+
if i < a:
|
|
102
|
+
continue # Already matched by extending the previous block.
|
|
103
|
+
# Extend each anchor backward, compare the remaining gap, then extend forward.
|
|
104
|
+
start_a, start_b = i, j
|
|
105
|
+
while a < start_a and b < start_b and old[start_a - 1] == new[start_b - 1]:
|
|
106
|
+
start_a, start_b = start_a - 1, start_b - 1
|
|
107
|
+
if a < start_a and b < start_b:
|
|
108
|
+
# SequenceMatcher's autojunk heuristic handles repetitive documents.
|
|
109
|
+
matcher = SequenceMatcher(None, old[a:start_a], new[b:start_b])
|
|
110
|
+
for match in matcher.get_matching_blocks():
|
|
111
|
+
yield a + match.a, b + match.b, match.size
|
|
112
|
+
a, b = i + size, j + size
|
|
113
|
+
while a < len(old) and b < len(new) and old[a] == new[b]:
|
|
114
|
+
a, b = a + 1, b + 1
|
|
115
|
+
yield start_a, start_b, a - start_a
|
|
116
|
+
|
|
117
|
+
|
|
63
118
|
def preserve_endings(original, edited):
|
|
64
119
|
"""Keep the endings of unchanged lines, including files with mixed endings."""
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
120
|
+
edited = edited.replace("\r\n", "\n").replace("\r", "\n")
|
|
121
|
+
crlf = original.count("\r\n")
|
|
122
|
+
counts = {
|
|
123
|
+
"\r\n": crlf,
|
|
124
|
+
"\r": original.count("\r") - crlf,
|
|
125
|
+
"\n": original.count("\n") - crlf,
|
|
126
|
+
}
|
|
127
|
+
used = [ending for ending, count in counts.items() if count]
|
|
128
|
+
if len(used) < 2:
|
|
129
|
+
# Files with one kind of line ending need no comparison.
|
|
130
|
+
return edited.replace("\n", used[0]) if used else edited
|
|
131
|
+
parts = re.split(r"(\r\n|\r|\n)", original)
|
|
132
|
+
old_lines, old_endings = parts[::2], parts[1::2]
|
|
133
|
+
new_lines = edited.split("\n")
|
|
134
|
+
default = Counter(old_endings).most_common(1)[0][0]
|
|
69
135
|
endings = [default] * (len(new_lines) - 1)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
for i, line in enumerate(new_lines)
|
|
79
|
-
)
|
|
136
|
+
for old_index, new_index, size in matching_blocks(old_lines, new_lines):
|
|
137
|
+
# The final line of each version has no ending.
|
|
138
|
+
size = min(size, len(old_endings) - old_index, len(endings) - new_index)
|
|
139
|
+
if size > 0:
|
|
140
|
+
endings[new_index : new_index + size] = old_endings[
|
|
141
|
+
old_index : old_index + size
|
|
142
|
+
]
|
|
143
|
+
return "".join(line + ending for line, ending in zip(new_lines, endings + [""]))
|
|
80
144
|
|
|
81
145
|
|
|
82
146
|
class FileStore:
|
|
@@ -114,7 +178,7 @@ class FileStore:
|
|
|
114
178
|
return path
|
|
115
179
|
|
|
116
180
|
def image_type(self, path):
|
|
117
|
-
mime =
|
|
181
|
+
mime = guess_file_type(path.name)[0]
|
|
118
182
|
return mime if mime in IMAGE_TYPES else None
|
|
119
183
|
|
|
120
184
|
def browse(self, relative):
|
|
@@ -123,32 +187,36 @@ class FileStore:
|
|
|
123
187
|
if not folder.exists():
|
|
124
188
|
raise FileNotFoundError()
|
|
125
189
|
raise FileError("This is not a folder.")
|
|
190
|
+
path = "" if folder == self.root else folder.relative_to(self.root).as_posix()
|
|
191
|
+
prefix = f"{path}/" if path else ""
|
|
126
192
|
entries = []
|
|
127
|
-
for
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
kind = "
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
193
|
+
# scandir and plain strings avoid creating Path objects for every entry.
|
|
194
|
+
with os.scandir(folder) as children:
|
|
195
|
+
for child in children:
|
|
196
|
+
if child.name.startswith(".web-edit-"):
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
info = child.stat(follow_symlinks=False)
|
|
200
|
+
except FileNotFoundError:
|
|
201
|
+
continue
|
|
202
|
+
kind = "directory" if stat.S_ISDIR(info.st_mode) else "file"
|
|
203
|
+
if stat.S_ISLNK(info.st_mode):
|
|
204
|
+
kind = "symlink"
|
|
205
|
+
elif not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
|
|
206
|
+
kind = "special"
|
|
207
|
+
entries.append(
|
|
208
|
+
{
|
|
209
|
+
"name": child.name,
|
|
210
|
+
"path": prefix + child.name,
|
|
211
|
+
"kind": kind,
|
|
212
|
+
"size": info.st_size,
|
|
213
|
+
"modified": info.st_mtime,
|
|
214
|
+
"delete_version": delete_version(info),
|
|
215
|
+
"preview": kind == "file"
|
|
216
|
+
and info.st_size < IMAGE_LIMIT
|
|
217
|
+
and bool(self.image_type(child)),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
152
220
|
entries.sort(
|
|
153
221
|
key=lambda item: (
|
|
154
222
|
item["kind"] != "directory",
|
|
@@ -157,9 +225,7 @@ class FileStore:
|
|
|
157
225
|
)
|
|
158
226
|
)
|
|
159
227
|
return {
|
|
160
|
-
"path":
|
|
161
|
-
if folder == self.root
|
|
162
|
-
else folder.relative_to(self.root).as_posix(),
|
|
228
|
+
"path": path,
|
|
163
229
|
"root_name": self.root.name or "/",
|
|
164
230
|
"entries": entries,
|
|
165
231
|
}
|
|
@@ -192,18 +258,18 @@ class FileStore:
|
|
|
192
258
|
def text(self, relative):
|
|
193
259
|
with self.lock:
|
|
194
260
|
path = self.regular_file(relative)
|
|
195
|
-
before = version(path)
|
|
196
261
|
if path.stat().st_size > TEXT_LIMIT:
|
|
197
262
|
raise FileError(
|
|
198
263
|
"Text editing is limited to 10 MiB. You can download this file.",
|
|
199
264
|
413,
|
|
200
265
|
)
|
|
201
266
|
data = path.read_bytes()
|
|
202
|
-
|
|
267
|
+
current = hashlib.sha256(data).hexdigest()
|
|
268
|
+
if version(path) != current:
|
|
203
269
|
raise FileError(
|
|
204
270
|
"The file changed while opening it. Please try again.", 409
|
|
205
271
|
)
|
|
206
|
-
return {"path": relative, "content": decode_text(data), "version":
|
|
272
|
+
return {"path": relative, "content": decode_text(data), "version": current}
|
|
207
273
|
|
|
208
274
|
def check_destination(self, relative, expected):
|
|
209
275
|
path = self.path(relative, file=True)
|
|
@@ -233,12 +299,16 @@ class FileStore:
|
|
|
233
299
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
234
300
|
self.path(relative, file=True)
|
|
235
301
|
temporary = None
|
|
302
|
+
# Hash while copying, so the version matches what was written without rereading.
|
|
303
|
+
digest = hashlib.sha256()
|
|
236
304
|
try:
|
|
237
305
|
with tempfile.NamedTemporaryFile(
|
|
238
306
|
prefix=".web-edit-", dir=target.parent, delete=False
|
|
239
307
|
) as output:
|
|
240
308
|
temporary = Path(output.name)
|
|
241
|
-
|
|
309
|
+
while chunk := source.read(1024 * 1024):
|
|
310
|
+
digest.update(chunk)
|
|
311
|
+
output.write(chunk)
|
|
242
312
|
output.flush()
|
|
243
313
|
os.fsync(output.fileno())
|
|
244
314
|
self.check_destination(relative, expected)
|
|
@@ -257,7 +327,7 @@ class FileStore:
|
|
|
257
327
|
finally:
|
|
258
328
|
if temporary is not None:
|
|
259
329
|
temporary.unlink(missing_ok=True)
|
|
260
|
-
return {"path": relative, "version":
|
|
330
|
+
return {"path": relative, "version": digest.hexdigest()}
|
|
261
331
|
|
|
262
332
|
def save_text(self, relative, content, expected):
|
|
263
333
|
from io import BytesIO
|