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:
ackandClaude Fable 5.1 committed 2026-10-11 16:59:03 +02:00
1 parent 4e3a281d65
commit 0d01e23d7c
15 files changed
+711 -63

No files matched your search

+125 -6
View File
@@ -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)