A host from the web site; the same page kept once; deleting from the Notebooks chooser
From the development repository at 299f73d, 2026-10-11: hosting from the site documented; a page loaded from a notebook and left unchanged is not kept again; notebooks and pages are deleted from the chooser, to the system's Trash (--trash); the Notebooks button. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e3a281d65
commit
0d01e23d7c
15 files changed
+711
-63
No files matched your search
+125
-6
@@ -27,6 +27,7 @@ import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -42,6 +43,22 @@ MAX_PAGE_BYTES = 64 << 20
|
||||
CURRENT_PAGE = "Current.svg"
|
||||
KEPT_PAGE = re.compile(r"^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$")
|
||||
|
||||
# What names a page file rather than describes the page: the session,
|
||||
# the host, the page's id and number on the root element, and the title
|
||||
# that repeats the number. A page loaded from a notebook is a new page
|
||||
# with new ones of these, so two files of the same page differ here and
|
||||
# nowhere else (2026-10-11, after loading a kept page and leaving it
|
||||
# unchanged kept it again at every change of page). They stand before
|
||||
# the paper's <rect>, and only there; typed text (in <desc>) may contain
|
||||
# anything, so the search stops at the paper.
|
||||
VOLATILE = re.compile(rb' data-(?:session|host|page|number)="[^"]*"|<title>[^<]*</title>\n?')
|
||||
|
||||
|
||||
def page_content(data):
|
||||
"""The page file without what names it, for telling the same page in two files."""
|
||||
head, paper, rest = data.partition(b"<rect ")
|
||||
return VOLATILE.sub(b"", head) + paper + rest
|
||||
|
||||
# Notebook files are changed by one request at a time.
|
||||
notebook_lock = threading.Lock()
|
||||
|
||||
@@ -89,6 +106,72 @@ def default_notebooks_directory():
|
||||
return documents / "Kokkoja"
|
||||
|
||||
|
||||
class Trash:
|
||||
"""Where deleted notebooks and pages go, so that a deletion can be
|
||||
undone in the file manager (Andy, 2026-10-11: the Trash if
|
||||
possible). Two layouts: the freedesktop Trash on Linux, which wants
|
||||
an info file beside each entry, and a plain directory, which is what
|
||||
macOS's Trash is to a program and what --trash names.
|
||||
"""
|
||||
|
||||
def __init__(self, directory, freedesktop=False):
|
||||
self.directory = pathlib.Path(directory)
|
||||
self.freedesktop = freedesktop
|
||||
|
||||
@classmethod
|
||||
def system(cls):
|
||||
"""The system's Trash, or None where this program knows none
|
||||
(Windows: the Recycle Bin needs the shell's help)."""
|
||||
if sys.platform == "darwin":
|
||||
return cls(pathlib.Path.home() / ".Trash")
|
||||
if sys.platform.startswith("linux"):
|
||||
base = os.environ.get("XDG_DATA_HOME") or pathlib.Path.home() / ".local" / "share"
|
||||
return cls(pathlib.Path(base) / "Trash", freedesktop=True)
|
||||
return None
|
||||
|
||||
def take(self, path):
|
||||
"""Move a file or directory into the Trash under a name not
|
||||
yet taken there. Raises OSError if that cannot be done; nothing is
|
||||
deleted then."""
|
||||
files = self.directory / "files" if self.freedesktop else self.directory
|
||||
files.mkdir(parents=True, exist_ok=True)
|
||||
if self.freedesktop:
|
||||
info = self.directory / "info"
|
||||
info.mkdir(parents=True, exist_ok=True)
|
||||
self.directory.chmod(0o700)
|
||||
for serial in range(1, 1000):
|
||||
name = path.name if serial == 1 else f"{path.stem}.{serial}{path.suffix}"
|
||||
if self.freedesktop:
|
||||
# The info file reserves the name (the spec's way), and
|
||||
# tells the file manager where the entry came from.
|
||||
try:
|
||||
descriptor = os.open(info / f"{name}.trashinfo", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError:
|
||||
continue
|
||||
with os.fdopen(descriptor, "w") as file:
|
||||
file.write("[Trash Info]\n"
|
||||
f"Path={urllib.parse.quote(str(path.resolve()))}\n"
|
||||
f"DeletionDate={time.strftime('%Y-%m-%dT%H:%M:%S')}\n")
|
||||
elif (files / name).exists():
|
||||
continue
|
||||
shutil.move(str(path), str(files / name))
|
||||
return
|
||||
raise OSError(f"no free name for {path.name} in the Trash")
|
||||
|
||||
|
||||
def discard(path, trash):
|
||||
"""Move a notebook or a page to the Trash, or delete it outright
|
||||
where there is none. Returns whether the Trash took it."""
|
||||
if trash is not None:
|
||||
trash.take(path)
|
||||
return True
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
return False
|
||||
|
||||
|
||||
def load_token(directory):
|
||||
"""The secret token, made on first use and kept in a private file."""
|
||||
path = directory / "token"
|
||||
@@ -146,7 +229,8 @@ def keep_page(notebook):
|
||||
|
||||
The copy is named for the day the page was last changed, with a serial
|
||||
number: 2026_10_02_01.svg. Nothing is copied if the page is blank or
|
||||
if an identical page is already kept.
|
||||
if the same page is already kept (the same but for what names the
|
||||
file: see page_content).
|
||||
|
||||
Returns (name, new): the name of the kept page, or None if there was
|
||||
nothing to keep, and whether the copy was made just now.
|
||||
@@ -163,8 +247,12 @@ def keep_page(notebook):
|
||||
|
||||
kept = [entry for entry in notebook.iterdir()
|
||||
if KEPT_PAGE.match(entry.name) and entry.is_file()]
|
||||
content = page_content(data)
|
||||
for entry in kept:
|
||||
if entry.stat().st_size == len(data) and entry.read_bytes() == data:
|
||||
# The ids are of fixed length, so files of the same page differ
|
||||
# in size by the digits of the page number at most; a notebook
|
||||
# of large pages is not read through for every keep.
|
||||
if abs(entry.stat().st_size - len(data)) <= 32 and page_content(entry.read_bytes()) == content:
|
||||
return entry.name, False
|
||||
|
||||
date = time.strftime("%Y_%m_%d", time.localtime(changed))
|
||||
@@ -256,6 +344,9 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
self.notebook_request("POST", self.path.split("?")[0])
|
||||
|
||||
def do_DELETE(self):
|
||||
self.notebook_request("DELETE", self.path.split("?")[0])
|
||||
|
||||
def allowed(self):
|
||||
"""Whether a notebook request comes from the user's own browser."""
|
||||
if not LOCAL_HOST.match(self.headers.get("Host", "")):
|
||||
@@ -316,6 +407,12 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
GET /api/notebooks/<notebook>/<page> a page
|
||||
PUT /api/notebooks/<notebook>/<page> write a page
|
||||
POST /api/notebooks/<notebook>/keep keep a copy of Current.svg
|
||||
DELETE /api/notebooks/<notebook> the notebook, pages and all
|
||||
DELETE /api/notebooks/<notebook>/<page> a kept page
|
||||
|
||||
A deletion moves the notebook or page to the Trash when the
|
||||
program has one; the listing of the notebooks says whether it has
|
||||
("trash"), so that the client can say which it will be.
|
||||
"""
|
||||
if not self.allowed():
|
||||
return
|
||||
@@ -337,6 +434,16 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
self.send_json({"kept": name, "new": new})
|
||||
elif method == "POST":
|
||||
self.refuse(405, "nothing to do")
|
||||
elif method == "DELETE":
|
||||
if not names:
|
||||
return self.refuse(405, "the notebooks directory stays")
|
||||
if len(names) == 2 and names[1] == CURRENT_PAGE:
|
||||
return self.refuse(400, "the current page is being written")
|
||||
with notebook_lock:
|
||||
if not target.exists():
|
||||
return self.refuse(404, "not found")
|
||||
basket = discard(target, self.trash)
|
||||
self.send_json({"discarded": names[-1], "trash": basket})
|
||||
elif method == "PUT":
|
||||
if len(names) != 2:
|
||||
return self.refuse(405, "only a page can be written")
|
||||
@@ -350,7 +457,8 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
self.send_json({"saved": names[1]})
|
||||
elif len(names) == 0:
|
||||
self.send_json({"directory": str(self.notebooks),
|
||||
"notebooks": self.listing(self.notebooks, directories=True)})
|
||||
"notebooks": self.listing(self.notebooks, directories=True),
|
||||
"trash": self.trash is not None})
|
||||
elif len(names) == 1:
|
||||
self.send_json({"pages": self.listing(target, directories=False)})
|
||||
else:
|
||||
@@ -474,10 +582,13 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
(entry.is_dir() if directories else entry.is_file() and entry.name.endswith(".svg")))
|
||||
|
||||
|
||||
def make_server(port, relay, notebooks, token, config, site=""):
|
||||
def make_server(port, relay, notebooks, token, config, site="", trash=None):
|
||||
"""trash: a Trash for deleted notebooks and pages, or None
|
||||
to delete them outright."""
|
||||
handler = type("ConfiguredHandler", (Handler,), {
|
||||
"relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token,
|
||||
"notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config)})
|
||||
"notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config),
|
||||
"trash": trash})
|
||||
handler = functools.partial(handler, directory=str(CLIENT_DIRECTORY))
|
||||
return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
|
||||
|
||||
@@ -499,6 +610,11 @@ def main():
|
||||
parser.add_argument("--config", type=pathlib.Path, default=None,
|
||||
help="directory for Kokkoja's own settings "
|
||||
"(default: your system's usual place)")
|
||||
parser.add_argument("--trash", default="",
|
||||
help="where deleted notebooks and pages are moved: a "
|
||||
"directory, or 'none' to delete them outright; by "
|
||||
"default the system's Trash, or "
|
||||
"outright where the program knows none")
|
||||
parser.add_argument("--no-browser", action="store_true",
|
||||
help="do not open a browser window")
|
||||
arguments = parser.parse_args()
|
||||
@@ -511,7 +627,10 @@ def main():
|
||||
" python3 kokkoja.py --relay wss://kokkoja.example/relay\n"
|
||||
"Afterwards the address is remembered and python3 kokkoja.py is enough.\n")
|
||||
token = load_token(config)
|
||||
server = make_server(arguments.port, relay, notebooks, token, config, arguments.site)
|
||||
trash = (None if arguments.trash == "none"
|
||||
else Trash(arguments.trash) if arguments.trash
|
||||
else Trash.system())
|
||||
server = make_server(arguments.port, relay, notebooks, token, config, arguments.site, trash)
|
||||
address = f"http://localhost:{arguments.port}/#token={token}"
|
||||
print(f"Kokkoja is at {address}", flush=True)
|
||||
print(f"Relay: {relay}" + (" (remembered from last time)" if remembered else ""), flush=True)
|
||||
|
||||
Reference in new issue
Block a user