diff --git a/.gitignore b/.gitignore index ccf80b8..9c6ebf0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ local.properties .gradle/ build/ +__pycache__/ diff --git a/rollover/install.sh b/rollover/install.sh new file mode 100755 index 0000000..89af539 --- /dev/null +++ b/rollover/install.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Install the rollover onto the server. Idempotent; safe to re-run after edits. +set -eu + +LIB=/home/connor/.local/lib/todo +UNITS=/etc/systemd/system +HERE=$(cd "$(dirname "$0")" && pwd) + +mkdir -p "$LIB" +install -m 0755 "$HERE/prune.py" "$HERE/rollover.py" "$LIB/" + +sudo install -m 0644 "$HERE/todo-rollover.service" "$HERE/todo-rollover.timer" "$UNITS/" +sudo systemctl daemon-reload + +echo "installed. enable with: sudo systemctl enable --now todo-rollover.timer" diff --git a/rollover/rollover.py b/rollover/rollover.py new file mode 100755 index 0000000..91c4b8e --- /dev/null +++ b/rollover/rollover.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Create today's note from the most recent one, pruning finished work. + +Runs on the server, once, at 00:01. A single writer is the whole point: two +clients each creating today's file independently is what produced the Syncthing +conflicts this system is replacing. + +Never modifies an existing file. If today's note is already there it exits +without touching anything, so re-running is always safe. +""" + +import argparse +import os +import re +import stat +import sys +import tempfile +from datetime import datetime +from zoneinfo import ZoneInfo + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from prune import prune + +NOTE = re.compile(r"^\d{4}-\d{2}-\d{2}\.md$") +DATE_HEADING = re.compile(r"^#\s+\d{4}-\d{2}-\d{2}\s*$") + + +def latest_note(d, exclude=None): + """Newest note by filename. Deliberately not by mtime: this tree is on NFS, + where mtimes come from the server's clock rather than the writer's.""" + notes = sorted(f for f in os.listdir(d) if NOTE.match(f) and f != exclude) + return notes[-1] if notes else None + + +def retitle(text, today): + """Replace the date heading on line 1, or prepend one if it is missing.""" + lines = text.split("\n") + if lines and DATE_HEADING.match(lines[0]): + lines[0] = f"# {today}" + return "\n".join(lines) + return f"# {today}\n\n" + text + + +def write_atomic(path, text, mode): + d = os.path.dirname(path) + fd, tmp = tempfile.mkstemp(dir=d, prefix=".rollover-", suffix=".tmp") + try: + with os.fdopen(fd, "w") as fh: + fh.write(text) + # mkstemp makes the file private; the notes are 0644 and syncthing and + # sftpgo both read this tree, so carry the previous note's mode over. + os.chmod(tmp, mode) + os.replace(tmp, path) + except BaseException: + os.path.exists(tmp) and os.unlink(tmp) + raise + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--dir", default=os.path.expanduser("~/docs/notes/todo")) + ap.add_argument("--tz", default="America/Louisville") + ap.add_argument("--date", help="override today, for testing") + ap.add_argument("--dry-run", action="store_true", help="print, do not write") + a = ap.parse_args() + + today = a.date or datetime.now(ZoneInfo(a.tz)).strftime("%Y-%m-%d") + target = os.path.join(a.dir, f"{today}.md") + + if os.path.exists(target) and not a.dry_run: + print(f"{today}.md already exists, nothing to do") + return 0 + + prev = latest_note(a.dir, exclude=f"{today}.md") + if prev is None: + sys.exit(f"no notes in {a.dir} to roll forward from, refusing to guess") + src = os.path.join(a.dir, prev) + mode = stat.S_IMODE(os.stat(src).st_mode) + body = retitle(prune(open(src).read()), today) + + if a.dry_run: + sys.stdout.write(body) + print(f"\n--- would write {target} from {prev} ---", file=sys.stderr) + return 0 + + write_atomic(target, body, mode) + print(f"created {today}.md from {prev}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/rollover/todo-rollover.service b/rollover/todo-rollover.service new file mode 100644 index 0000000..d3921a7 --- /dev/null +++ b/rollover/todo-rollover.service @@ -0,0 +1,22 @@ +[Unit] +Description=Roll today's todo note forward from the most recent one +Documentation=https://github.com/connorjohnstone/todo +After=network-online.target remote-fs.target +Wants=remote-fs.target + +[Service] +Type=oneshot +User=connor +Group=connor +ExecStart=/home/connor/.local/lib/todo/rollover.py +# The notes live on an NFS mount, so a stalled server must not wedge the timer. +TimeoutStartSec=120 +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/connor/docs/notes/todo +NoNewPrivileges=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true diff --git a/rollover/todo-rollover.timer b/rollover/todo-rollover.timer new file mode 100644 index 0000000..828ca96 --- /dev/null +++ b/rollover/todo-rollover.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Roll the todo note forward at 00:01 + +[Timer] +OnCalendar=*-*-* 00:01:00 +Timezone=America/Louisville +# Catch up after downtime, so a server that was off at midnight still rolls over. +Persistent=true +AccuracySec=1min +Unit=todo-rollover.service + +[Install] +WantedBy=timers.target