From 070a107873440d62b4519e6d91c2ba739d4b6fa2 Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 1 Nov 2026 07:03:26 -0800 Subject: Literate Doom Emacs configuration config.org tangles to config.el, which loads private/config.el last when it exists. bin/ holds the scripts some sections drive. Co-Authored-By: Claude Opus 5.5 --- bin/README.md | 19 ++ bin/astro-transits | 75 +++++ bin/docsync | 314 ++++++++++++++++++++ bin/imgcollage | 122 ++++++++ bin/photocollage/__init__.py | 0 bin/photocollage/collage.py | 568 +++++++++++++++++++++++++++++++++++++ bin/photocollage/render.py | 313 ++++++++++++++++++++ bin/system-theme | 29 ++ bin/systemd/docsync-backup.service | 8 + bin/systemd/docsync-backup.timer | 10 + bin/systemd/docsync-check.service | 8 + bin/systemd/docsync-check.timer | 10 + bin/systemd/docsync-forget.service | 8 + bin/systemd/docsync-forget.timer | 10 + 14 files changed, 1494 insertions(+) create mode 100644 bin/README.md create mode 100755 bin/astro-transits create mode 100755 bin/docsync create mode 100755 bin/imgcollage create mode 100644 bin/photocollage/__init__.py create mode 100644 bin/photocollage/collage.py create mode 100644 bin/photocollage/render.py create mode 100755 bin/system-theme create mode 100644 bin/systemd/docsync-backup.service create mode 100644 bin/systemd/docsync-backup.timer create mode 100644 bin/systemd/docsync-check.service create mode 100644 bin/systemd/docsync-check.timer create mode 100644 bin/systemd/docsync-forget.service create mode 100644 bin/systemd/docsync-forget.timer (limited to 'bin') diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..6c32c13 --- /dev/null +++ b/bin/README.md @@ -0,0 +1,19 @@ +# bin/ + +Scripts that sections of `config.org` drive. Each is a terminal program first, +and the config looks for it on `PATH` before falling back to this directory. + +| Script | Used by | Needs | +|------------------|----------------------------|-----------------------------------------| +| `astro-transits` | Astrology | astrolog, imv (or `ASTRO_VIEWER`) | +| `docsync` | Backups | restic, ssh, jq, notify-send | +| `imgcollage` | Media thumbnails > Collage | Python 3, Pillow | +| `system-theme` | Appearance > Theme | the COSMIC desktop | + +Each script's header or usage message (`docsync help`) says how to set it up. +`systemd/` holds docsync's user units: copy them to `~/.config/systemd/user/`, +put `docsync` at `~/.local/bin/docsync`, and enable the three timers. + +`photocollage/` is vendored unchanged from +[PhotoCollage](https://github.com/adrienverge/PhotoCollage) by Adrien Vergé, +and is licensed GPL-2.0-or-later, as its file headers say. diff --git a/bin/astro-transits b/bin/astro-transits new file mode 100755 index 0000000..8455050 --- /dev/null +++ b/bin/astro-transits @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -e + +# The current sky against a natal chart, with astrolog: an ordered transit +# table and a biwheel image. +# +# ASTRO_LAT, ASTRO_LONG where you are, signed decimal degrees (N and E positive) +# ASTRO_ZONE your standard (winter) UTC offset in hours west, +# e.g. 5 for New York, -1 for Berlin +# ASTRO_NATAL the natal chart, an astrolog chart file +# (default ~/.local/share/astrology/natal.dat) +# ASTROLOG astrolog's data directory, holding the Swiss +# Ephemeris files; without seas_18.se1, +# Chiron/Ceres/Pallas/Juno/Vesta come out as 0Ari00 +# ASTRO_VIEWER image viewer that stays open until closed (default imv) +# +# With --emacs, prints TABLE=path and IMAGE=path instead of showing them. + +: "${ASTRO_LAT:?set ASTRO_LAT to your latitude}" +: "${ASTRO_LONG:?set ASTRO_LONG to your longitude}" +: "${ASTRO_ZONE:?set ASTRO_ZONE to your standard UTC offset in hours west}" +NATAL="${ASTRO_NATAL:-$HOME/.local/share/astrology/natal.dat}" +: "${ASTROLOG:=$HOME/.local/share/astrolog}" +export ASTROLOG +[ -f "$NATAL" ] || { echo "astro-transits: no natal chart at $NATAL" >&2; exit 1; } + +# astrolog wants coordinates as DD:MMH, not the signed decimal degrees +# ASTRO_LAT/ASTRO_LONG are stored as. +to_dms() { + awk -v v="$1" -v pos="$2" -v neg="$3" 'BEGIN { + h = (v < 0) ? neg : pos + v = (v < 0) ? -v : v + d = int(v) + m = int((v - d) * 60 + 0.5) + if (m == 60) { d++; m = 0 } + printf "%d:%02d%s", d, m, h + }' +} +LAT_DMS=$(to_dms "$ASTRO_LAT" N S) +LONG_DMS=$(to_dms "$ASTRO_LONG" E W) + +# ASTRO_ZONE is the standard (winter) UTC offset in hours west; derive +# whether the current moment is in daylight time by comparing it to +# the actual local UTC offset right now. +off=$(date +%z) +sign=${off:0:1} +offset_min=$((10#${off:1:2} * 60 + 10#${off:3:2})) +[ "$sign" = "-" ] && offset_min=$(( -offset_min )) +standard_min=$(( -ASTRO_ZONE * 60 )) +if [ "$offset_min" -ne "$standard_min" ]; then DAYLIGHT=1; else DAYLIGHT=0; fi + +COMMON_ARGS=(-zl "$LONG_DMS" "$LAT_DMS" -z "$ASTRO_ZONE" -z0 "$DAYLIGHT") + +TABLE=$(mktemp --suffix=.txt) +IMAGE=$(mktemp --suffix=.png) + +# --emacs hands back the table/image paths instead of displaying them, +# so a caller (e.g. Emacs) takes ownership of the files and their +# cleanup; only clean up here if we fail before handing them off. +if [ "$1" = "--emacs" ]; then + trap 'rm -f "$TABLE" "$IMAGE"' ERR +else + trap 'rm -f "$TABLE" "$IMAGE"' EXIT +fi + +astrolog "${COMMON_ARGS[@]}" -i "$NATAL" -Tn -os "$TABLE" >/dev/null +astrolog "${COMMON_ARGS[@]}" -y "$NATAL" -X -Xbp -Xo "$IMAGE" >/dev/null + +if [ "$1" = "--emacs" ]; then + echo "TABLE=$TABLE" + echo "IMAGE=$IMAGE" +else + cat "$TABLE" + "${ASTRO_VIEWER:-imv}" "$IMAGE" +fi diff --git a/bin/docsync b/bin/docsync new file mode 100755 index 0000000..5f0c19c --- /dev/null +++ b/bin/docsync @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/docsync" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/docsync" +INCLUDE_FILE="$CONFIG_DIR/include" +EXCLUDE_FILE="$CONFIG_DIR/exclude" + +# The repository is reached over SFTP. $CONFIG_DIR/config sets SSH_HOST to a +# Host in ~/.ssh/config that logs in without a prompt (a key, BatchMode-safe), +# and may set REPO_PATH, the repository's path on that host. The password +# file holds the repository's password, kept in a file so the timer never +# waits on a prompt; restic's other keys (a paper recovery passphrase, say) +# still open the repository as usual. +SSH_HOST= +REPO_PATH=repo +# shellcheck source=/dev/null +[[ -f "$CONFIG_DIR/config" ]] && . "$CONFIG_DIR/config" +export RESTIC_REPOSITORY="sftp:$SSH_HOST:$REPO_PATH" +export RESTIC_PASSWORD_FILE="$CONFIG_DIR/restic-password" + +# Every docsync snapshot carries this tag, and snapshots are grouped by host +# and tag rather than by path list, so adding or dropping a path continues the +# same history (and retention) instead of starting a new one. +TAG=docsync +GROUP_BY=host,tags + +# Retention for 'docsync forget': every hourly snapshot for two days, then one +# a day for a month, one a week for a year, and one a month for good. +KEEP=(--keep-hourly 48 --keep-daily 30 --keep-weekly 52 --keep-monthly unlimited) + +# How much of the stored data 'docsync check' downloads and verifies per run. +CHECK_SUBSET=5% + +# Desktop notifications (only when run by a systemd timer): a failure while the +# server is reachable is worth hearing about soon, being offline only once no +# backup has landed for a day. At most one notification per window either way. +ERROR_NOTIFY_EVERY=$((6 * 3600)) +OFFLINE_NOTIFY_AFTER=$((24 * 3600)) + +usage() { + cat <, + and optionally REPO_PATH= (default: repo) + restic-password the repository's password, mode 600 + include one path per line, relative to ~ unless it starts with / + exclude restic exclude patterns; a leading ~/ means your home +Blank lines and lines starting with # are ignored in include and exclude. +Create the repository once with: docsync restic init +The systemd units beside this script run backup hourly, forget weekly and +check monthly; they expect it at ~/.local/bin/docsync. + +State for other tools (Emacs) is in $STATE_DIR: + last-success epoch seconds of the last backup that saved a snapshot + last-result "EPOCH STATUS MESSAGE", STATUS one of ok, warning, offline, error + missing include paths that didn't exist at the last backup + manifest every file in the latest snapshot as TYPE SIZE MTIME PATH, + tab-separated (jq @tsv escaping), after a "# snapshot ID TIME" + line; Emacs compares it with the disk to mark what's changed +EOF +} + +die() { echo "docsync: $*" >&2; exit 1; } + +now() { date +%s; } + +# A list file's entries, without comments, blank lines or surrounding space. +list_lines() { + [[ -f "$1" ]] || return 0 + sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$1" +} + +include_paths() { + local p + list_lines "$INCLUDE_FILE" | while IFS= read -r p; do + p="${p#\~/}" + [[ "$p" == /* ]] || p="$HOME/$p" + [[ "$p" == / ]] || p="${p%/}" + printf '%s\n' "$p" + done +} + +exclude_patterns() { + local p + list_lines "$EXCLUDE_FILE" | while IFS= read -r p; do + [[ "$p" == "~/"* ]] && p="$HOME/${p#\~/}" + printf '%s\n' "$p" + done +} + +# ---------------------------------------------------------------- state + +put_state() { # NAME LINE... + if (( $# > 1 )); then printf '%s\n' "${@:2}"; fi > "$STATE_DIR/$1.tmp" + mv "$STATE_DIR/$1.tmp" "$STATE_DIR/$1" +} + +get_state() { # NAME DEFAULT + if [[ -s "$STATE_DIR/$1" ]]; then head -n1 "$STATE_DIR/$1"; else printf '%s\n' "$2"; fi +} + +notify() { # WINDOW URGENCY MESSAGE + [[ -n "${INVOCATION_ID:-}" ]] || return 0 + local last; last=$(get_state notified 0) + (( $(now) - last >= $1 )) || return 0 + notify-send -a docsync -u "$2" docsync "$3" 2>/dev/null || true + put_state notified "$(now)" +} + +# A run that dies mid-operation (the server dropping off during a prune, say) +# leaves its lock behind, and restic never clears one by itself: every later +# run would wait out --retry-lock and fail. Without --remove-all, unlock +# removes only stale locks, ones whose process has exited or that haven't been +# refreshed for 30 minutes (a running restic refreshes its lock every 5). +unlock_stale() { + restic unlock || true +} + +# A cheap "is the server reachable" probe. With an SFTP-only account, whose +# forced command answers any login with an SFTP server that exits at EOF, this +# still returns as soon as the connection is up. +reachable() { + ssh -o BatchMode=yes "$SSH_HOST" true /dev/null 2>&1 +} + +# Not a failure (a laptop's network comes and goes, and the next hourly run +# tries again), so only worth a notification once no backup has landed for a day. +backup_offline() { + put_state last-result "$(now) offline can't reach $SSH_HOST" + if (( $(now) - $(get_state last-success 0) >= OFFLINE_NOTIFY_AFTER )); then + notify "$OFFLINE_NOTIFY_AFTER" normal "No backup for over a day: can't reach $SSH_HOST." + fi + echo "docsync: can't reach $SSH_HOST, skipping this backup" >&2 + exit 0 +} + +ago() { # EPOCH + local s=$(( $(now) - $1 )) + if (( s < 90 )); then echo "just now" + elif (( s < 5400 )); then echo "$(( s / 60 )) min ago" + elif (( s < 172800 )); then echo "$(( s / 3600 )) h ago" + else echo "$(( s / 86400 )) days ago" + fi +} + +# ---------------------------------------------------------------- commands + +# Every file in the latest snapshot with its size and modification time, so +# Emacs can tell what has changed since without asking the repository. A +# failure keeps the old manifest: Emacs then shows more as changed than has, +# never less. +cmd_manifest() { + mkdir -p "$STATE_DIR" + local out="$STATE_DIR/manifest" + if restic ls --json --tag "$TAG" latest 2>/dev/null | jq -r ' + if .struct_type == "snapshot" then "# snapshot \(.id) \(.time)" + elif .struct_type == "node" then [.type, (.size // 0), .mtime, .path] | @tsv + else empty end' > "$out.tmp"; then + mv "$out.tmp" "$out" + else + rm -f "$out.tmp" + echo "docsync: couldn't list the latest snapshot; the manifest is stale" >&2 + return 1 + fi +} + +cmd_backup() { + mkdir -p "$STATE_DIR" + exec 9>"$STATE_DIR/lock" + flock -n 9 || { echo "docsync: a backup is already running" >&2; exit 0; } + + [[ -f "$RESTIC_PASSWORD_FILE" ]] || + die "no password file; put the repository's password in $RESTIC_PASSWORD_FILE (mode 600)" + [[ -f "$INCLUDE_FILE" ]] || die "nothing to back up: $INCLUDE_FILE is missing" + + # Global, not local: the EXIT trap runs after this function has returned. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + local -a present=() missing=() + local p + while IFS= read -r p; do + if [[ -e "$p" || -L "$p" ]]; then present+=("$p"); else missing+=("$p"); fi + done < <(include_paths) + put_state missing "${missing[@]}" + for p in "${missing[@]}"; do echo "docsync: listed but missing: $p" >&2; done + + if (( ${#present[@]} == 0 )); then + put_state last-result "$(now) error none of the listed paths exist" + notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: none of the listed paths exist." + die "none of the paths in $INCLUDE_FILE exist" + fi + printf '%s\n' "${present[@]}" > "$tmp/include" + exclude_patterns > "$tmp/exclude" + + reachable || backup_offline + unlock_stale + local rc=0 + restic backup \ + --files-from-verbatim "$tmp/include" \ + --exclude-file "$tmp/exclude" \ + --exclude-caches \ + --tag "$TAG" --group-by "$GROUP_BY" \ + --skip-if-unchanged \ + --retry-lock 1h \ + 2>"$tmp/err" || rc=$? + # Echoed by bash rather than cat: journald files a line under its unit by + # looking up the writing process, and cat has exited by then, so restic's + # error would be missing from journalctl --user -u docsync-backup. + [[ ! -s "$tmp/err" ]] || printf '%s\n' "$(<"$tmp/err")" >&2 + local err; err=$(grep -v '^[[:space:]]*$' "$tmp/err" | tail -n1 || true) + + case $rc in + 0|3) put_state last-success "$(now)" + rm -f "$STATE_DIR/notified" + cmd_manifest || true ;; + esac + case $rc in + 0) if (( ${#missing[@]} )); then + put_state last-result "$(now) warning ${#missing[@]} listed path(s) missing" + notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but ${#missing[@]} listed path(s) no longer exist." + else + put_state last-result "$(now) ok" + fi ;; + 3) put_state last-result "$(now) warning some files couldn't be read: $err" + notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but some files couldn't be read." ;; + *) # Losing the server partway through is being offline, not an error. + reachable || backup_offline + put_state last-result "$(now) error ${err:-restic exited with $rc}" + notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: ${err:-restic exited with $rc}" + exit "$rc" ;; + esac +} + +# forget and check: on failure under a timer, say so, unless the server dropped +# off; the next scheduled run clears the stale lock that leaves and starts over. +maintenance() { # WHAT RESTIC-ARGS... + local what=$1; shift + unlock_stale + if ! restic "$@" --retry-lock 1h; then + [[ -n "${INVOCATION_ID:-}" ]] && reachable && + notify-send -a docsync -u critical docsync "docsync $what failed; see journalctl --user -u docsync-$what" 2>/dev/null + exit 1 + fi +} + +cmd_forget() { + maintenance forget forget --tag "$TAG" --group-by "$GROUP_BY" "${KEEP[@]}" --prune +} + +cmd_check() { + maintenance check check --read-data-subset "$CHECK_SUBSET" +} + +cmd_status() { + local last result + last=$(get_state last-success "") + result=$(get_state last-result "") + if [[ -n "$last" ]]; then + echo "Last backup: $(date -d "@$last" '+%F %R') ($(ago "$last"))" + else + echo "Last backup: never" + fi + if [[ -n "$result" ]]; then + read -r when status msg <<<"$result" + echo "Last run: $(date -d "@$when" '+%F %R') $status${msg:+: $msg}" + fi + if [[ -s "$STATE_DIR/missing" ]]; then + echo "Missing:"; sed 's/^/ /' "$STATE_DIR/missing" + fi + echo + restic snapshots --tag "$TAG" --group-by "$GROUP_BY" --latest 5 --compact || + echo "(couldn't list snapshots; is $SSH_HOST reachable?)" +} + +cmd_paths() { + local p + echo "Include:" + include_paths | while IFS= read -r p; do + if [[ -e "$p" || -L "$p" ]]; then echo " $p"; else echo " $p (missing)"; fi + done + echo "Exclude:" + exclude_patterns | sed 's/^/ /' +} + +case "${1:-}" in + backup|forget|check|status|manifest|restic) + [[ -n "$SSH_HOST" ]] || die "set SSH_HOST in $CONFIG_DIR/config" ;; +esac + +case "${1:-}" in + backup) cmd_backup ;; + forget) cmd_forget ;; + check) cmd_check ;; + status) cmd_status ;; + paths) cmd_paths ;; + manifest) cmd_manifest ;; + restic) shift; exec restic "$@" ;; + -h|--help|help|"") usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/bin/imgcollage b/bin/imgcollage new file mode 100755 index 0000000..6211f87 --- /dev/null +++ b/bin/imgcollage @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""imgcollage: build a single masonry-style collage image from a folder. + +A headless CLI wrapper around the layout/rendering engine from PhotoCollage +(https://github.com/adrienverge/PhotoCollage, GPL-2.0-or-later). It reuses +that project's `photocollage.collage` and `photocollage.render` modules +verbatim (vendored alongside this script) and drives them with the same +auto-layout heuristic PhotoCollage's GTK app uses (see UserCollage.make_page +in its gtkgui.py), without needing GTK/pycairo or any GUI at all. +""" +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from photocollage import collage, render # noqa: E402 + +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"} + + +def collect_images(paths): + files = [] + skipped = [] + for p in paths: + if os.path.isdir(p): + for name in sorted(os.listdir(p)): + full = os.path.join(p, name) + if os.path.splitext(name)[1].lower() in IMAGE_EXTS: + files.append(full) + elif os.path.isfile(full): + skipped.append(full) + elif os.path.splitext(p)[1].lower() in IMAGE_EXTS: + files.append(p) + else: + skipped.append(p) + if skipped: + print(f"Ignoring {len(skipped)} non-image file(s): " + f"{', '.join(os.path.basename(s) for s in skipped)}", + file=sys.stderr) + return files + + +def auto_no_cols(photolist, ratio): + avg_ratio = sum(p.h / p.w for p in photolist) / len(photolist) + virtual_no_imgs = 2 * len(photolist) + return max(1, int(round(math.sqrt(avg_ratio / ratio * virtual_no_imgs)))) + + +def main(): + ap = argparse.ArgumentParser( + description="Arrange a folder of differently-sized images into one " + "masonry-style collage image, auto-computing the layout.") + ap.add_argument("images", nargs="+", + help="image files and/or directories of images") + ap.add_argument("-o", "--output", default="collage.jpg", + help="output file (default: collage.jpg)") + ap.add_argument("-W", "--width", type=int, default=2000, + help="output width in px (default: 2000)") + ap.add_argument("-H", "--height", type=int, default=1500, + help="output height in px (default: 1500)") + ap.add_argument("-c", "--cols", type=int, default=0, + help="number of columns (default: auto)") + ap.add_argument("-b", "--border", type=float, default=1.0, + help="border width as %% of poster size (default: 1.0, " + "use 0 for no border)") + ap.add_argument("--border-color", default="black", + help="border color, any PIL-recognized name/hex " + "(default: black)") + ap.add_argument("--seed", type=int, default=None, + help="random seed, for a reproducible layout") + args = ap.parse_args() + + if args.seed is not None: + random.seed(args.seed) + + filelist = collect_images(args.images) + if not filelist: + sys.exit("No images found.") + + try: + photolist = render.build_photolist(filelist) + except render.BadPhoto as e: + sys.exit(f"Could not read image: {e.photoname}") + + ratio = args.height / args.width + no_cols = args.cols or auto_no_cols(photolist, ratio) + + page = collage.Page(1.0, ratio, no_cols) + random.shuffle(photolist) + for photo in photolist: + page.add_cell(photo) + page.adjust() + + enlargement = args.width / page.w + page.scale(enlargement) + + errors = [] + + task = render.RenderingTask( + page, + border_width=args.border / 100.0 * max(page.w, page.h), + border_color=args.border_color, + quality=render.QUALITY_BEST, + output_file=args.output, + on_fail=errors.append, + ) + task.run() # run synchronously in this thread; no need for start()/join() + + if errors: + sys.exit(f"Rendering failed: {errors[0]}") + + if os.path.exists(args.output): + print(f"Wrote {args.output} " + f"({int(page.w)}x{int(page.h)}, {no_cols} columns, " + f"{len(photolist)} images)") + + +if __name__ == "__main__": + main() diff --git a/bin/photocollage/__init__.py b/bin/photocollage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bin/photocollage/collage.py b/bin/photocollage/collage.py new file mode 100644 index 0000000..134735b --- /dev/null +++ b/bin/photocollage/collage.py @@ -0,0 +1,568 @@ +# Copyright (C) 2014 Adrien Vergé +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import random + +""" +Summary of collage objects: + +---------------------- +| | +| | +| Page | The "Page" object represents the whole page that +| | will give the final assembled image. +| | +---------------------- + +---------------------- +| | | | +| |Column| | +| | |Column| A page is divided into columns. +|Column| | | +| | | | +---------------------- + +---------------------- +| Cell | Cell | Each column contains cells. When a +|------| x--|----- CellExtent is located in several columns, +| Cell |-------------| its "extended" flag is set, and a +|------| Cell | | CellExtent object is added to the +| Cell | | Cell | column on the right to reserve the +---------------------- place. + + ,------> Photo + / ,---------> Photo +---------------------- +| | | Each cell is associated +|------| | to a photo. +| |-------------| +|------| | |----> Photo +| | | | +---------------------- + `----------> Photo + +The layout placing process is divided in three phases. + +Phase A: Fill columns with photos. + Photos are added to columns, one by one, until there are no more photos. + Each new photo is put in the smallest column, so as to have balanced + columns. If two columns have approximately the same height, a photo can be + "extended" to fit two columns. In this case, the "Cell" object is put in + the first column, and the second column takes a "CellExtent" object to + reserve the taken space. + +Phase B: Set all columns to same height. + A global common height is computed for all columns, and every of them is + stressed or extended to this common length. This can result in a decrease + or increase in columns' width. + +Phase C: Adapt columns' width. + Since cells in a column may have different widths, each column width is set + to the smallest width amongst its images. + +""" + + +class Photo: + def __init__(self, filename, w, h, orientation=0): + self.filename = filename + self.w = w + self.h = h + self.orientation = orientation + self.offset_w = 0.5 + self.offset_h = 0.5 + + @property + def ratio(self): + return float(self.h) / float(self.w) + + def move(self, x, y): + self.offset_w = self.calculate_new_offset(self.offset_w, x) + self.offset_h = self.calculate_new_offset(self.offset_h, y) + + @staticmethod + def calculate_new_offset(offset, value): + new_offset = offset + value + if new_offset < 0: + new_offset = 0 + elif new_offset > 1: + new_offset = 1 + return new_offset + + +class Cell: + """Represents a cell in a column + + Properties: + <- x -><- w -> + ---------------------- ^ + | | | |y + |------| | | + | |-------------| v + |------| Cell | | ^ + | | | | |h + ---------------------- v + + """ + def __init__(self, parents, photo): + self.parents = parents + self.photo = photo + self.extent = None + self.h = self.w * self.wanted_ratio + + def __repr__(self): + """Representation of the cell in ASCII art""" + end = "]" + if self.extent is not None: + end = "--" + return "[%d %d%s" % (self.w, self.h, end) + + @property + def x(self): + return self.parents[0].x + + @property + def y(self): + """Returns the cell's y coordinate + + It assumes that the cell is in a single column, so it is the previous + cell's y + h. + + """ + prev = None + for c in self.parents[0].cells: + if self is c: + if prev: + return prev.y + prev.h + return 0 + prev = c + + @property + def w(self): + return sum(c.w for c in self.parents) + + @property + def ratio(self): + return self.h / self.w + + @property + def wanted_ratio(self): + return self.photo.ratio + + def scale(self, alpha): + self.h *= alpha + + def is_extended(self): + return hasattr(self, 'extent') and self.extent is not None + + def is_extension(self): + return isinstance(self, CellExtent) + + def content_coords(self): + """Returns the coordinates of the contained image + + These are computed in order not to loose space, so the content area + will always be greater than the cell itself. It is the space taken by + the contained image if it wasn't cropped. + + """ + # If the contained image is too thick to fit + if self.wanted_ratio < self.ratio: + h = self.h + w = self.h / self.wanted_ratio + y = self.y + x = self.x - (w - self.w) / 2.0 + # If the contained image is too tall to fit + elif self.wanted_ratio > self.ratio: + w = self.w + h = self.w * self.wanted_ratio + x = self.x + y = self.y - (h - self.h) / 2.0 + else: + w = self.w + h = self.h + x = self.x + y = self.y + return x, y, w, h + + def top_neighbor(self): + """Returns the cell above this one""" + prev = None + for c in self.parents[0].cells: + if self is c: + return prev + prev = c + + def bottom_neighbor(self): + """Returns the cell below this one""" + prev = None + for c in reversed(self.parents[0].cells): + if self is c: + return prev + prev = c + + +class CellExtent(Cell): + def __init__(self, cell): + self.origin = cell + self.origin.extent = self + + def __repr__(self): + """Representation of the cell in ASCII art""" + return "------]" + + @property + def parents(self): + return (self.origin.parents[1],) + + @property + def photo(self): + return self.origin.photo + + @property + def y(self): + return self.origin.y + + @property + def h(self): + return self.origin.h + + def scale(self, alpha): + pass + + +class Column: + """Represents a column in a page + + Properties: + <----- x ----><-- w -> + ---------------------- ^ + | | | | | + | | | | + | | |Column| h + | | | | + -------| | | | + | |------- v + -------- + + """ + def __init__(self, parent, w): + self.parent = parent + self.cells = [] + self.w = w + + def __repr__(self): + """Representation of the column in ASCII art""" + return "\n".join(c.__repr__() for c in self.cells) + + @property + def h(self): + """Returns the column's total height + + This is not simply the sum of its cells heights, because there can be + empty spaces between cells. + + """ + if not self.cells: + return 0 + return self.cells[-1].y + self.cells[-1].h + + @property + def x(self): + x = 0 + for c in self.parent.cols: + if self is c: + break + x += c.w + return x + + def scale(self, alpha): + self.w *= alpha + for c in self.cells: + c.scale(alpha) + + def left_neighbor(self): + """Returns the column on the left of this one""" + prev = None + for c in self.parent.cols: + if self is c: + return prev + prev = c + + def right_neighbor(self): + """Returns the column on the right of this one""" + prev = None + for c in reversed(self.parent.cols): + if self is c: + return prev + prev = c + + def adjust_height(self, target_h): + """Set the column's height to a given value by resizing cells""" + # First, make groups of "movable" cells. Since cell extents are not + # movable, these groups only contain pure cell objects. We only resize + # those groups. + class Group: + def __init__(self, y): + self.y = y + self.h = 0 + self.cells = [] + + groups = [] + groups.append(Group(0)) + for c in self.cells: + # While a cell extent is not reached, keep add cells to the group + if not c.is_extension(): + groups[-1].cells.append(c) + else: + # Close current group and create a new one + groups[-1].h = c.y - groups[-1].y + groups.append(Group(c.y + c.h)) + groups[-1].h = target_h - groups[-1].y + + # Adjust height for each group independently + for group in groups: + if not group.cells: + continue + alpha = group.h / sum(c.h for c in group.cells) + for c in group.cells: + c.h = c.h * alpha + + +class Page: + """Represents a whole page + + Properties: + <-------- w --------> + ---------------------- ^ + | | | + | | + | Page | h + | | + | | | + ---------------------- v + + """ + def __init__(self, w, target_ratio, no_cols): + self.target_ratio = target_ratio + col_w = float(w)/no_cols + self.cols = [] + for i in range(no_cols): + self.cols.append(Column(self, col_w)) + + def __repr__(self): + """Representation of the page in ASCII art + + Returns something like: + [62 52] [125 134-- ------] [62 87] + [62 47] [62 66] [125 132-- [62 45] + [62 46] ------] [62 49] ------] + [62 78] ------] [62 49] [62 45] + [125 102-- ------] [62 49] [62 65] + [125 135-- [62 85] [62 53] + [125 91-- [125 89-- [62 64] + ------] + """ + lines = [] + n = 0 + end = False + while not end: + lines.append("") + end = True + for col in self.cols: + cells = col.__repr__().split("\n") + w = max(len(cell) for cell in cells) + if col != self.cols[-1]: + w += 1 + cell = w * " " + if n < len(cells): + cell = cells[n] + (w - len(cells[n])) * " " + if n < len(cells) - 1: + end = False + lines[-1] += cell + n += 1 + return "\n".join(lines) + + @property + def no_cols(self): + return len(self.cols) + + @property + def w(self): + return sum(c.w for c in self.cols) + + @property + def h(self): + return max(c.h for c in self.cols) + + @property + def ratio(self): + return self.h / self.w + + def scale(self, alpha): + for c in self.cols: + c.scale(alpha) + + def scale_to_fit(self, max_w, max_h=None): + if max_h is None or self.w * max_h > self.h * max_w: + self.scale(max_w / self.w) + else: + self.scale(max_h / self.h) + + def next_free_col(self): + """Returns the column with lowest height""" + minimum = min(c.h for c in self.cols) + candidates = [] + for c in self.cols: + if c.h == minimum: + candidates.append(c) + return random.choice(candidates) + + def add_cell_single_col(self, col, photo): + col.cells.append(Cell((col,), photo)) + + def add_cell_multi_col(self, col1, col2, photo): + cell = Cell((col1, col2), photo) + extent = CellExtent(cell) + col1.cells.append(cell) + col2.cells.append(extent) + + def add_cell(self, photo): + """Add a new cell in the best computed place + + If possible, and if it's worth, make a "multiple-column" cell. + + """ + col = self.next_free_col() + left = col.left_neighbor() + right = col.right_neighbor() + if 2 * random.random() > photo.ratio: + if left and abs(col.h - left.h) < 0.5 * col.w: + return self.add_cell_multi_col(left, col, photo) + elif right and abs(col.h - right.h) < 0.5 * col.w: + return self.add_cell_multi_col(col, right, photo) + + self.add_cell_single_col(col, photo) + + def remove_empty_cols(self): + i = 0 + while i < len(self.cols): + if len(self.cols[i].cells) == 0: + self.cols.pop(i) + else: + i += 1 + + def remove_bottom_holes(self): + """Remove holes created by extended cells + + Example (case A): + The bottom-right cell should be extended to fill the hole. + ---------------------- ---------------------- + | | | | | | | | + | |-------------| | |-------------| + |------| | |------| | + | |-------------- | |-------------- + | | | ^ | | ^ | | + --------------- hole -------- hole -------- + + Example (case B): + The bottom cell should be moved under the other extended cell. + ---------------------- ---------------------- + | | | | | | | | + |------|-------------| |-------------|------| + | | | | | | + |--------------------- ---------------------| + | | <-- hole hole -> | | + --------------- --------------- + + """ + for col in self.cols: + cell = col.cells[-1] + if cell == col.cells[0]: + continue + + # Case A + # If cell is not extended, is below an extended cell and has no + # neighbour under the latter, it should be extended. + if not cell.is_extended() and not cell.is_extension(): + # Case A1 + if cell.top_neighbor().is_extended() \ + and cell.top_neighbor().extent \ + .bottom_neighbor() is None: + # Extend cell to right + extent = CellExtent(cell) + col.right_neighbor().cells.append(extent) + cell.parents = (col, col.right_neighbor()) + # Case A2 + elif cell.top_neighbor().is_extension() \ + and cell.top_neighbor().origin \ + .bottom_neighbor() is None: + # Extend cell to left + col.cells.remove(cell) + col.left_neighbor().cells.append(cell) + extent = CellExtent(cell) + col.cells.append(extent) + cell.parents = (col.left_neighbor(), col) + # Case B + # If cell is extended and one of the cells above is extended too, + # the bottom cell should be placed right below the top one. + elif cell.is_extended() and cell.extent.bottom_neighbor() is None: + # Case B1 + if cell.extent.top_neighbor().is_extended() \ + and cell.extent.top_neighbor().extent \ + .bottom_neighbor() is None: + # Move cell to right + col.cells.remove(cell) + col.right_neighbor().cells.remove(cell.extent) + col.right_neighbor().cells.append(cell) + col.right_neighbor().right_neighbor().cells \ + .append(cell.extent) + cell.parents = (col.right_neighbor(), + col.right_neighbor().right_neighbor()) + # Case B2 + elif cell.top_neighbor().is_extension() \ + and cell.top_neighbor().origin \ + .bottom_neighbor() is None: + # Move cell to left + col.cells.remove(cell) + col.right_neighbor().cells.remove(cell.extent) + col.left_neighbor().cells.append(cell) + col.cells.append(cell.extent) + cell.parents = (col.left_neighbor(), col) + + def adjust_cols_heights(self): + """Set all columns' heights to same value by shrinking them""" + target_h = self.w * self.target_ratio + for c in self.cols: + c.adjust_height(target_h) + + def adjust(self): + self.remove_empty_cols() + self.remove_bottom_holes() + self.adjust_cols_heights() + + def get_cell_at_position(self, x, y): + for col in self.cols: + if x >= col.x and x < col.x + col.w: + for cell in col.cells: + if y >= cell.y and y < cell.y + cell.h: + if cell.is_extension(): + return cell.origin + return cell + return None + + def swap_photos(self, cell1, cell2): + cell1.photo, cell2.photo = cell2.photo, cell1.photo diff --git a/bin/photocollage/render.py b/bin/photocollage/render.py new file mode 100644 index 0000000..f9b9024 --- /dev/null +++ b/bin/photocollage/render.py @@ -0,0 +1,313 @@ +# Copyright (C) 2014 Adrien Vergé +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import random +from threading import Thread +import time + +import PIL.Image +import PIL.ImageDraw +import PIL.ImageFile + +from photocollage.collage import Photo + + +QUALITY_SKEL = 0 +QUALITY_FAST = 1 +QUALITY_BEST = 2 + + +# Try to continue even if the input file is corrupted. +# See issue at https://github.com/adrienverge/PhotoCollage/issues/65 +PIL.ImageFile.LOAD_TRUNCATED_IMAGES = True + + +class PIL_SUPPORTED_EXTS: + """File extensions supported by PIL + + Compiled from: + - http://pillow.readthedocs.org/en/2.3.0/handbook/image-file-formats.html + - https://github.com/python-imaging/Pillow/blob/master/PIL/*ImagePlugin.py + + """ + RW = { + "BMP": ("bmp",), + # "EPS": ("ps", "eps",), # doesn't seem to work + "GIF": ("gif",), + "IM": ("im",), + "JPEG": ("jfif", "jpe", "jpg", "jpeg",), + "MSP": ("msp",), + "PCX": ("pcx",), + "PNG": ("png",), + "PPM": ("pbm", "pgm", "ppm",), + "TGA": ("tga",), + "TIFF": ("tif", "tiff",), + "WebP": ("webp",), + "XBM": ("xbm",), + } + RO = { + "CUR": ("cur",), + "DCX": ("dcx",), + "FLI": ("fli", "flc",), + "FPX": ("fpx",), + "GBR": ("gbr",), + "ICO": ("ico",), + "IPTC/NAA": ("iim",), + "PCD": ("pcd",), + "PSD": ("psd",), + "SGI": ("bw", "rgb", "rgba", "sgi",), + "XPM": ("xpm",), + } + WO = { + # "PALM": ("palm",), # doesn't seem to work + # "PDF": ("pdf",), # doesn't seem to work + } + + +def random_color(): + r = random.randrange(256) + g = random.randrange(256) + b = random.randrange(256) + if r + g + b > 0.7 * 3 * 256: + r -= 50 + g -= 50 + b -= 50 + return (r, g, b) + + +class BadPhoto(Exception): + def __init__(self, photoname): + self.photoname = photoname + + +def build_photolist(filelist): + ret = [] + + for name in filelist: + try: + img = PIL.Image.open(name) + except OSError: + raise BadPhoto(name) + w, h = img.size + + orientation = 0 + try: + exif = img._getexif() + if 274 in exif: # orientation tag + orientation = exif[274] + if orientation == 6 or orientation == 8: + w, h = h, w + except Exception: + pass + + ret.append(Photo(name, w, h, orientation)) + return ret + + +cache = {} + + +class RenderingTask(Thread): + """Execution thread to do the actual poster rendering + + Image computation is a heavy task, that can take several seconds. During + this, the program might be unresponding. To avoid this, rendering is done + is a separated thread. + + """ + def __init__(self, page, border_width=0.01, border_color=(0, 0, 0), + quality=QUALITY_FAST, output_file=None, + on_update=None, on_complete=None, on_fail=None): + super().__init__() + + self.page = page + self.border_width = border_width + self.border_color = border_color + self.quality = quality + + self.output_file = output_file + + self.on_update = on_update + self.on_complete = on_complete + self.on_fail = on_fail + + self.canceled = False + + def abort(self): + self.canceled = True + + def draw_skeleton(self, canvas): + for col in self.page.cols: + for c in col.cells: + if c.is_extension(): + continue + color = random_color() + x, y, w, h = c.content_coords() + xy = (x, y) + xY = (x, y + h - 1) + Xy = (x + w - 1, y) + XY = (x + w - 1, y + h - 1) + + draw = PIL.ImageDraw.Draw(canvas) + draw.line(xy + Xy, fill=color) + draw.line(xy + xY, fill=color) + draw.line(xY + XY, fill=color) + draw.line(Xy + XY, fill=color) + draw.line(xy + XY, fill=color) + draw.line(xY + Xy, fill=color) + return canvas + + def draw_borders(self, canvas): + if self.border_width == 0: + return + + W = self.page.w - 1 + H = self.page.h - 1 + border = self.border_width - 1 + color = self.border_color + + draw = PIL.ImageDraw.Draw(canvas) + draw.rectangle((0, 0) + (border, H), color) + draw.rectangle((W - border, 0) + (W, H), color) + draw.rectangle((0, 0) + (W, border), color) + draw.rectangle((0, H - border) + (W, H), color) + + for col in self.page.cols: + # Draw horizontal borders + for c in col.cells[1:]: + xy = (col.x, c.y - border / 2) + XY = (col.x + col.w, c.y + border / 2) + draw.rectangle(xy + XY, color) + # Draw vertical borders + if col.x > 0: + for c in col.cells: + if not c.is_extension(): + xy = (col.x - border / 2, c.y) + XY = (col.x + border / 2, c.y + c.h) + draw.rectangle(xy + XY, color) + return canvas + + def resize_photo(self, cell, use_cache=False): + # If a thumbnail is already in cache, let's use it. But only if it is + # bigger than what we need, because we don't want to lose quality. + if (use_cache and cell.photo.filename in cache and + cache[cell.photo.filename].size[0] >= int(round(cell.w)) and + cache[cell.photo.filename].size[1] >= int(round(cell.h))): + img = cache[cell.photo.filename].copy() + else: + img = PIL.Image.open(cell.photo.filename) + + # Rotate image is EXIF says so + if cell.photo.orientation == 3: + img = img.rotate(180, expand=True) + elif cell.photo.orientation == 6: + img = img.rotate(270, expand=True) + elif cell.photo.orientation == 8: + img = img.rotate(90, expand=True) + + if self.quality == QUALITY_FAST: + method = PIL.Image.NEAREST + else: + method = PIL.Image.LANCZOS # renamed from ANTIALIAS in Pillow 10 + + shape = img.size[0] * cell.h - img.size[1] * cell.w + if shape > 0: # image is too thick + img = img.resize((int(round(cell.h * img.size[0] / img.size[1])), + int(round(cell.h))), method) + elif shape < 0: # image is too tall + img = img.resize((int(round(cell.w)), + int(round(cell.w * img.size[1] / img.size[0]))), + method) + else: + img = img.resize((int(round(cell.w)), int(round(cell.h))), method) + + # Save this new image to cache (if it is larger than the previous one) + if (use_cache and (cell.photo.filename not in cache or + cache[cell.photo.filename].size[0] < img.size[0])): + cache[cell.photo.filename] = img + + if shape > 0: # image is too thick + width_to_crop = img.size[0] - cell.w + img = img.crop(( + int(round(width_to_crop * cell.photo.offset_w)), + 0, + int(round(img.size[0] - width_to_crop * + (1 - cell.photo.offset_w))), + int(round(cell.h)) + )) + elif shape < 0: # image is too tall + height_to_crop = img.size[1] - cell.h + img = img.crop(( + 0, + int(round(height_to_crop * cell.photo.offset_h)), + int(round(cell.w)), + int(round(img.size[1] - height_to_crop * + (1 - cell.photo.offset_h))) + )) + + return img + + def paste_photo(self, canvas, cell, img): + canvas.paste(img, (int(round(cell.x)), int(round(cell.y)))) + return canvas + + def run(self): + try: + canvas = PIL.Image.new( + "RGB", (int(self.page.w), int(self.page.h)), "white") + + self.draw_skeleton(canvas) + self.draw_borders(canvas) + + if self.quality != QUALITY_SKEL: + n = sum([len([cell for cell in col.cells if not + cell.is_extension()]) for col in self.page.cols]) + i = 0.0 + if self.on_update: + self.on_update(canvas, 0.0) + last_update = time.time() + + for col in self.page.cols: + for c in col.cells: + if self.canceled: # someone clicked "abort" + return + + if c.is_extension(): + continue + + img = self.resize_photo(c, use_cache=True) + self.paste_photo(canvas, c, img) + + # Only needed for interactive rendering + if self.on_update: + self.draw_borders(canvas) + + i += 1 + now = time.time() + if self.on_update and now > last_update + 0.1: + self.on_update(canvas, i / n) + last_update = now + + self.draw_borders(canvas) + + if self.output_file: + canvas.save(self.output_file) + + if self.on_complete: + self.on_complete(canvas) + except Exception as e: + if self.on_fail: + self.on_fail(e) diff --git a/bin/system-theme b/bin/system-theme new file mode 100755 index 0000000..862b243 --- /dev/null +++ b/bin/system-theme @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +# Switch the COSMIC desktop between light and dark mode, and optionally the +# wallpaper with it: set SYSTEM_THEME_WALLPAPER_DARK and +# SYSTEM_THEME_WALLPAPER_LIGHT to image files. Emacs's auto-dark follows the +# desktop's mode on its own. On another desktop, this is the file to rewrite. + +COSMIC="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic" + +set_wallpaper() { + [ -n "$1" ] || return 0 + wallpaper="$(realpath "$1")" || return 1 + sed -i 's|^.*source: Path(".*"),$| source: Path("'"$wallpaper"'"),|' \ + "$COSMIC/com.system76.CosmicBackground/v1/all" +} + +case "$1" in + dark) + echo "true" > "$COSMIC/com.system76.CosmicTheme.Mode/v1/is_dark" + set_wallpaper "${SYSTEM_THEME_WALLPAPER_DARK:-}" + ;; + light) + echo "false" > "$COSMIC/com.system76.CosmicTheme.Mode/v1/is_dark" + set_wallpaper "${SYSTEM_THEME_WALLPAPER_LIGHT:-}" + ;; + *) + echo "usage: system-theme dark|light" >&2 + exit 1 + ;; +esac diff --git a/bin/systemd/docsync-backup.service b/bin/systemd/docsync-backup.service new file mode 100644 index 0000000..1c83da8 --- /dev/null +++ b/bin/systemd/docsync-backup.service @@ -0,0 +1,8 @@ +[Unit] +Description=docsync: encrypted hourly backup + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/docsync backup +Nice=10 +IOSchedulingClass=idle diff --git a/bin/systemd/docsync-backup.timer b/bin/systemd/docsync-backup.timer new file mode 100644 index 0000000..0ad450c --- /dev/null +++ b/bin/systemd/docsync-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=docsync: hourly backup + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/bin/systemd/docsync-check.service b/bin/systemd/docsync-check.service new file mode 100644 index 0000000..c577352 --- /dev/null +++ b/bin/systemd/docsync-check.service @@ -0,0 +1,8 @@ +[Unit] +Description=docsync: verify the backup repository + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/docsync check +Nice=10 +IOSchedulingClass=idle diff --git a/bin/systemd/docsync-check.timer b/bin/systemd/docsync-check.timer new file mode 100644 index 0000000..ae9d03e --- /dev/null +++ b/bin/systemd/docsync-check.timer @@ -0,0 +1,10 @@ +[Unit] +Description=docsync: monthly repository check + +[Timer] +OnCalendar=monthly +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/bin/systemd/docsync-forget.service b/bin/systemd/docsync-forget.service new file mode 100644 index 0000000..72efeb9 --- /dev/null +++ b/bin/systemd/docsync-forget.service @@ -0,0 +1,8 @@ +[Unit] +Description=docsync: thin out old snapshots and free their space + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/docsync forget +Nice=10 +IOSchedulingClass=idle diff --git a/bin/systemd/docsync-forget.timer b/bin/systemd/docsync-forget.timer new file mode 100644 index 0000000..548669d --- /dev/null +++ b/bin/systemd/docsync-forget.timer @@ -0,0 +1,10 @@ +[Unit] +Description=docsync: weekly snapshot thinning + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target -- cgit v1.2.3