orgwarrior-web 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """The org agenda on a phone: a small web page over orgwarrior's engine."""
2
+
3
+ __version__ = "0.1.0"
orgwarrior_web/auth.py ADDED
@@ -0,0 +1,130 @@
1
+ """Per-device tokens: each device gets its own random token, named, and the
2
+ server keeps only their hashes, in a state file (not the config, which
3
+ may live in a dotfiles repo). `orgwarrior-web token add|list|revoke`."""
4
+
5
+ import argparse
6
+ import datetime
7
+ import hashlib
8
+ import hmac
9
+ import os
10
+ import re
11
+ import secrets
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from orgwarrior_web.jsonfile import JsonFile
16
+
17
+ NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,39}")
18
+
19
+
20
+ def default_path() -> Path:
21
+ state = os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state"
22
+ return Path(state) / "orgwarrior-web/tokens.json"
23
+
24
+
25
+ def digest(token: str) -> str:
26
+ # A plain hash is enough: the tokens are random, 32 bytes, not passwords.
27
+ return hashlib.sha256(token.encode()).hexdigest()
28
+
29
+
30
+ class Tokens:
31
+ """The tokens file, {name: {"sha256", "scope", "created"}}, reread when it
32
+ changes, so `token add`/`revoke` count without restarting the server."""
33
+
34
+ def __init__(self, path: Path | None = None):
35
+ self.file = JsonFile(path or default_path(), dict, 0o600)
36
+
37
+ @property
38
+ def path(self) -> Path:
39
+ return self.file.path
40
+
41
+ def load(self) -> dict:
42
+ return self.file.load()
43
+
44
+ def find(self, token: str) -> tuple[str, str] | None:
45
+ """(name, scope) of TOKEN, or None. Scope "full" opens everything,
46
+ "calendar" only the feed, "add" only adding (someone else's device,
47
+ adding to this list without seeing it), "mcp" only orgwarrior-mcp
48
+ (Claude: none of the page)."""
49
+ d = digest(token)
50
+ found = None
51
+ for name, t in self.load().items():
52
+ if hmac.compare_digest(d, t["sha256"]):
53
+ found = name, t["scope"]
54
+ return found
55
+
56
+ def add(self, name: str, scope: str = "full") -> str:
57
+ if not NAME.fullmatch(name):
58
+ raise ValueError(f"not a token name: {name!r} (letters, digits, _ . -; up to 40)")
59
+ tokens = dict(self.load())
60
+ if name in tokens:
61
+ raise ValueError(f"there is already a token {name!r}: revoke it first")
62
+ token = secrets.token_urlsafe(32)
63
+ tokens[name] = {"sha256": digest(token), "scope": scope,
64
+ "created": datetime.date.today().isoformat()}
65
+ self.file.save(tokens)
66
+ return token
67
+
68
+ def revoke(self, name: str):
69
+ tokens = dict(self.load())
70
+ if tokens.pop(name, None) is None:
71
+ raise ValueError(f"no token {name!r}")
72
+ self.file.save(tokens)
73
+
74
+
75
+ def token_main(argv: list[str]) -> int:
76
+ ap = argparse.ArgumentParser(
77
+ prog="orgwarrior-web token",
78
+ description="Manage the tokens that let a device in. Each device gets its own, "
79
+ "so one can be cut off without the others. Changes count at once, no restart.")
80
+ sub = ap.add_subparsers(dest="cmd", required=True)
81
+ add = sub.add_parser("add", help="make a token for a device and print its sign-in link")
82
+ add.add_argument("name", help="what it's for: phone, laptop, ...")
83
+ scope = add.add_mutually_exclusive_group()
84
+ scope.add_argument("--calendar", action="store_true",
85
+ help="a token for a calendar app: opens the calendar feed and nothing else")
86
+ scope.add_argument("--add", action="store_true",
87
+ help="a token for someone else: adds to your inbox (marked FROM them) "
88
+ "and sees nothing")
89
+ scope.add_argument("--mcp", action="store_true",
90
+ help="a token for Claude: opens orgwarrior-mcp and none of the page")
91
+ add.add_argument("--url", help="the server's address, to print the whole link "
92
+ "(e.g. https://10.0.0.5:22546)")
93
+ sub.add_parser("list", help="the tokens there are")
94
+ rev = sub.add_parser("revoke", help="cut a device off")
95
+ rev.add_argument("name")
96
+ args = ap.parse_args(argv)
97
+ tokens = Tokens()
98
+ try:
99
+ if args.cmd == "add":
100
+ token = tokens.add(args.name, "calendar" if args.calendar else "add" if args.add
101
+ else "mcp" if args.mcp else "full")
102
+ base = (args.url or "https://HOST:PORT").rstrip("/")
103
+ if args.mcp:
104
+ print(f"Connect Claude (or any MCP client) to {base}/mcp\n"
105
+ f"with this request header:\n\n"
106
+ f" Authorization: Bearer {token}\n\n"
107
+ f"A client that takes only a URL can use this one:\n\n"
108
+ f" {base}/mcp?key={token}\n")
109
+ elif args.calendar:
110
+ print(f"Subscribe to this in the calendar app (ICSx\u2075 on Android):\n\n"
111
+ f" {base}/calendar.ics?key={token}\n")
112
+ else:
113
+ print(f"Open this once on {args.name}; it then stays signed in:\n\n"
114
+ f" {base}/?key={token}\n\n"
115
+ f"On an iPhone, an app added to the home screen doesn't share Safari's\n"
116
+ f"sign-in: open it and paste the key there (the part after key=).\n")
117
+ print(f"It isn't shown again. `orgwarrior-web token revoke {args.name}` cuts it off.")
118
+ elif args.cmd == "list":
119
+ listed = tokens.load()
120
+ if not listed:
121
+ print("No tokens. `orgwarrior-web token add NAME` makes one.")
122
+ for name, t in listed.items():
123
+ print(f"{name:20} {t['scope']:10} made {t['created']}")
124
+ else:
125
+ tokens.revoke(args.name)
126
+ print(f"Revoked {args.name}.")
127
+ except (ValueError, OSError) as e:
128
+ print(f"orgwarrior-web token: {e}", file=sys.stderr)
129
+ return 1
130
+ return 0
orgwarrior_web/feed.py ADDED
@@ -0,0 +1,140 @@
1
+ """The calendar feed: orgwarrior's dated entries (api.calendar) as
2
+ iCalendar (RFC 5545), read-only, each event linking to its task page."""
3
+
4
+ import datetime
5
+ import re
6
+
7
+ from orgwarrior.config import ConfigError
8
+
9
+ DEFAULTS = {"keywords": True, "deadlines": True, "alarms": True, "alarm_time": "09:00",
10
+ "deadline_prefix": "Due: "}
11
+
12
+ FREQ = {"h": "HOURLY", "d": "DAILY", "w": "WEEKLY", "m": "MONTHLY", "y": "YEARLY"}
13
+ # iCalendar durations have no months or years: a warning of 2m is 60 days.
14
+ MINUTES = {"h": 60, "d": 1440, "w": 7 * 1440, "m": 30 * 1440, "y": 365 * 1440}
15
+
16
+
17
+ def options(table: dict) -> dict:
18
+ """[web.calendar] over the defaults, checked at startup like the tabs."""
19
+ if unknown := set(table) - set(DEFAULTS):
20
+ raise ConfigError(f"unknown key [web.calendar] {sorted(unknown)[0]!r}")
21
+ for key in ("keywords", "deadlines", "alarms"):
22
+ if not isinstance(table.get(key, True), bool):
23
+ raise ConfigError(f"[web.calendar] {key} is true or false")
24
+ if not isinstance(table.get("deadline_prefix", ""), str):
25
+ raise ConfigError('[web.calendar] deadline_prefix is text: "Due: ", or "" for none')
26
+ if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", str(table.get("alarm_time", "09:00"))):
27
+ raise ConfigError('[web.calendar] alarm_time is a time of day: "09:00"')
28
+ return {**DEFAULTS, **table}
29
+
30
+
31
+ def escape(text: str) -> str:
32
+ return (text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,")
33
+ .replace("\r\n", "\\n").replace("\r", "\\n").replace("\n", "\\n"))
34
+
35
+
36
+ def fold(line: str) -> str:
37
+ """LINE in pieces of at most 75 octets, as the RFC wants, never
38
+ splitting a UTF-8 character."""
39
+ out, piece, size = [], "", 0
40
+ for ch in line:
41
+ n = len(ch.encode())
42
+ if size + n > (75 if not out else 74):
43
+ out.append(piece)
44
+ piece, size = "", 0
45
+ piece += ch
46
+ size += n
47
+ out.append(piece)
48
+ return "\r\n ".join(out)
49
+
50
+
51
+ def ical_date(iso: str) -> str:
52
+ return iso.replace("-", "")
53
+
54
+
55
+ def ical_time(iso: str, hhmm: str) -> str:
56
+ return f"{ical_date(iso)}T{hhmm.replace(':', '')}00"
57
+
58
+
59
+ def next_day(iso: str) -> str:
60
+ return (datetime.date.fromisoformat(iso) + datetime.timedelta(days=1)).isoformat()
61
+
62
+
63
+ def duration(minutes: int) -> str:
64
+ sign = "-" if minutes < 0 else ""
65
+ days, rest = divmod(abs(minutes), 1440)
66
+ hours, mins = divmod(rest, 60)
67
+ time = (f"{hours}H" if hours else "") + (f"{mins}M" if mins else "")
68
+ return f"{sign}P" + (f"{days}D" if days or not time else "") + (f"T{time}" if time else "")
69
+
70
+
71
+ def times(stamp: dict) -> list[str]:
72
+ """DTSTART and DTEND. Floating times (no time zone): the device's own,
73
+ as org's are. All-day ends are exclusive, so a range's last day + 1."""
74
+ start, end, t0, t1 = stamp["start"], stamp["end"], stamp["start_time"], stamp["end_time"]
75
+ if not t0:
76
+ return [f"DTSTART;VALUE=DATE:{ical_date(start)}", f"DTEND;VALUE=DATE:{ical_date(next_day(end))}"]
77
+ lines = [f"DTSTART:{ical_time(start, t0)}"]
78
+ if t1 and (end, t1) != (start, t0):
79
+ lines.append(f"DTEND:{ical_time(end, t1)}")
80
+ elif end != start:
81
+ lines.append(f"DTEND:{ical_date(next_day(end))}T000000")
82
+ return lines
83
+
84
+
85
+ def alarm(stamp: dict, opts: dict, summary: str) -> list[str]:
86
+ """A deadline's warning (-3d) as an alarm that many days ahead: for an
87
+ all-day deadline, at alarm_time on that day."""
88
+ warning = stamp.get("warning")
89
+ if not (opts["alarms"] and stamp["kind"] == "deadline" and warning):
90
+ return []
91
+ ahead = warning["value"] * MINUTES[warning["unit"]]
92
+ if not stamp["start_time"]:
93
+ h, m = opts["alarm_time"].split(":")
94
+ ahead -= int(h) * 60 + int(m)
95
+ return ["BEGIN:VALARM", "ACTION:DISPLAY", f"DESCRIPTION:{escape(summary)}",
96
+ f"TRIGGER:{duration(-ahead)}", "END:VALARM"]
97
+
98
+
99
+ def ics(entries: list[dict], opts: dict, task_url, now: datetime.datetime | None = None) -> str:
100
+ """The calendar of ENTRIES (api.calendar): open tasks and events, no
101
+ habits; TASK_URL(id) is the link each event carries."""
102
+ stamp_now = (now or datetime.datetime.now(datetime.UTC)).strftime("%Y%m%dT%H%M%SZ")
103
+ lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//orgwarrior-web//EN",
104
+ "CALSCALE:GREGORIAN", "X-WR-CALNAME:orgwarrior",
105
+ "REFRESH-INTERVAL;VALUE=DURATION:PT1H", "X-PUBLISHED-TTL:PT1H"]
106
+ for e in entries:
107
+ if e["habit"] or e["done"]:
108
+ continue
109
+ summary = f"{e['keyword']} {e['title']}" if opts["keywords"] and e["keyword"] else e["title"]
110
+ url = task_url(e["id"])
111
+ common = [f"URL:{url}", f"DESCRIPTION:{escape(url)}"]
112
+ if e["tags"]:
113
+ common.append("CATEGORIES:" + ",".join(escape(t) for t in e["tags"]))
114
+ n = 0
115
+ for stamp in e["stamps"]:
116
+ kind = stamp["kind"]
117
+ if kind == "deadline" and not opts["deadlines"]:
118
+ continue
119
+ if kind not in ("scheduled", "deadline"):
120
+ n += 1
121
+ # Stable across fetches, so a calendar updates an event in place.
122
+ uid = f"{e['id']}-{kind if kind in ('scheduled', 'deadline') else f'{kind}{n}'}"
123
+ if "dates" in stamp: # a diary sexp, one event per day it holds
124
+ for d in stamp["dates"]:
125
+ lines += ["BEGIN:VEVENT", f"UID:{uid}-{ical_date(d['date'])}@orgwarrior",
126
+ f"DTSTAMP:{stamp_now}", f"DTSTART;VALUE=DATE:{ical_date(d['date'])}",
127
+ f"DTEND;VALUE=DATE:{ical_date(next_day(d['date']))}",
128
+ f"SUMMARY:{escape(d['text'] or summary)}", *common, "END:VEVENT"]
129
+ continue
130
+ # A deadline and a scheduled date would otherwise read the same.
131
+ title = opts["deadline_prefix"] + summary if kind == "deadline" else summary
132
+ lines += ["BEGIN:VEVENT", f"UID:{uid}@orgwarrior", f"DTSTAMP:{stamp_now}",
133
+ *times(stamp), f"SUMMARY:{escape(title)}", *common]
134
+ if r := stamp["repeat"]:
135
+ # .+ and ++ have no iCalendar equivalent: a plain repeat
136
+ # from the current date, which moves when it's done.
137
+ lines.append(f"RRULE:FREQ={FREQ[r['unit']]};INTERVAL={r['value']}")
138
+ lines += [*alarm(stamp, opts, title), "END:VEVENT"]
139
+ lines.append("END:VCALENDAR")
140
+ return "".join(fold(line) + "\r\n" for line in lines)
@@ -0,0 +1,37 @@
1
+ """A small JSON state file, beside tokens.json: reread when it changes on
2
+ disk (another process saved it), written atomically."""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ class JsonFile:
10
+ """PATH's JSON, EMPTY() while there's no file. MODE, if given, is the
11
+ new file's permissions (0o600 for secrets)."""
12
+
13
+ def __init__(self, path: Path, empty, mode: int | None = None):
14
+ self.path, self._empty, self._mode = path, empty, mode
15
+ self._stamp, self._data = None, empty()
16
+
17
+ def load(self):
18
+ try:
19
+ st = self.path.stat()
20
+ except FileNotFoundError:
21
+ self._stamp, self._data = None, self._empty()
22
+ return self._data
23
+ if (stamp := (st.st_mtime_ns, st.st_size, st.st_ino)) != self._stamp:
24
+ self._data = json.loads(self.path.read_text())
25
+ self._stamp = stamp
26
+ return self._data
27
+
28
+ def save(self, data):
29
+ self.path.parent.mkdir(parents=True, exist_ok=True)
30
+ tmp = self.path.with_suffix(".tmp")
31
+ if self._mode is None:
32
+ tmp.write_text(json.dumps(data, indent=1))
33
+ else:
34
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, self._mode)
35
+ with os.fdopen(fd, "w") as f:
36
+ json.dump(data, f, indent=1)
37
+ os.replace(tmp, self.path) # atomic: a reader never sees half a file
@@ -0,0 +1,321 @@
1
+ """The page's keys: every action it has a key for, org agenda's keys for
2
+ them (the defaults), the presets, and `[web.keys]` on top.
3
+
4
+ One table: the page dispatches on action names, draws its hints and `?`
5
+ from it, and the server checks the config against it at startup, so a
6
+ typo stops the server instead of leaving a key that does nothing.
7
+
8
+ Keys are written as Emacs writes them (`kbd`): `t`, `T`, `C-k`, `M-<down>`,
9
+ `M-S-<left>`, `RET`, `SPC`, `<prior>`; a sequence is its keys apart, `d d`
10
+ or `C-c C-s`, and a word of plain characters is one key a character (`dd`
11
+ is `d d`, as in Emacs). What's fixed is left out: Esc and Tab, the keys
12
+ inside sheets and pickers (org's fast keys, the arrows, y and n), Enter
13
+ in a field, Ctrl-Enter; `FIXED` lists them for `?`.
14
+ """
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+
20
+ class KeysError(ValueError):
21
+ """A key or [web.keys] entry that can't be used; the message says why."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Action:
26
+ name: str
27
+ group: str
28
+ keys: tuple[str, ...] # org agenda's, the default
29
+ what: str
30
+
31
+
32
+ def _a(name, group, keys, what):
33
+ return Action(name, group, tuple(keys), what)
34
+
35
+
36
+ # In the order `?` lists them. The defaults are org agenda's keys where they
37
+ # fit; where the page's matter more it differs on purpose (docs/keys.md's
38
+ # list): s d schedule and deadline (org's save, day view), w refile (week
39
+ # view), j k (go to date), r rename (redo; g still), c a A adding (calendar,
40
+ # archive, append), i o now and I O asking (org's I O are now), J K H L
41
+ # moving (org agenda can't move subtrees), e edit and E effort (org's e
42
+ # effort, E entry text).
43
+ ACTIONS = [
44
+ _a("down", "Moving", ["j", "n", "<down>"], "the next row (side by side: shown beside)"),
45
+ _a("up", "Moving", ["k", "p", "<up>"], "the row before"),
46
+ _a("open", "Moving", ["RET"], "open the row"),
47
+ _a("quit", "Moving", ["q"], "close the task, else back (Esc too, always)"),
48
+ _a("back", "Moving", ["["], "back through what was shown (Alt-← too)"),
49
+ _a("forward", "Moving", ["]"], "forward again (Alt-→ too)"),
50
+ _a("state", "The task", ["t"], "state"),
51
+ _a("priority", "The task", [","], "priority"),
52
+ _a("priority-up", "The task", ["S-<up>"], "priority up a step"),
53
+ _a("priority-down", "The task", ["S-<down>"], "priority down a step"),
54
+ _a("tags", "The task", [":"], "tags"),
55
+ _a("schedule", "The task", ["s"], "schedule"),
56
+ _a("deadline", "The task", ["d"], "deadline"),
57
+ _a("effort", "The task", ["E"], "effort: the estimate, clocked time shown against it"),
58
+ _a("note", "The task", ["z"], "add a note"),
59
+ _a("history", "The task", ["Z"], "its notes and history: change or delete one"),
60
+ _a("properties", "The task", ["C"], "its properties: change, delete or add one"),
61
+ _a("outline", "The task", ["h"], "zoom into it: its file's tree from there down, as org's narrowing"),
62
+ _a("rename", "The task", ["r"], "rename"),
63
+ _a("edit", "The task", ["e"], "edit its text"),
64
+ _a("read", "The task", ["V"], "read its text full screen"),
65
+ _a("refile", "The task", ["w"], "refile (type to filter)"),
66
+ _a("archive", "The task", ["$"], "archive (asks first)"),
67
+ _a("kill", "The task", ["C-k"], "kill (asks first)"),
68
+ _a("move", "Moving it", ["v"], "move: a sheet of ▲ ▼ ◀ ▶"),
69
+ _a("move-down", "Moving it", ["J", "M-<down>"], "down past the next heading"),
70
+ _a("move-up", "Moving it", ["K", "M-<up>"], "up past the one before"),
71
+ _a("promote", "Moving it", ["H", "M-S-<left>"], "out of its parent"),
72
+ _a("demote", "Moving it", ["L", "M-S-<right>"], "into the heading above"),
73
+ _a("clock-in", "Clocking", ["i"], "clock in now"),
74
+ _a("clock-in-at", "Clocking", ["I"], "clock in at a time"),
75
+ _a("clock-out", "Clocking", ["o"], "clock out now (the clock running, wherever it is)"),
76
+ _a("clock-out-at", "Clocking", ["O"], "clock out at a time"),
77
+ _a("clock-cancel", "Clocking", ["X"], "discard the running clock (asks first)"),
78
+ _a("clock-goto", "Clocking", [], "open the task clocked in"),
79
+ _a("clocks", "Clocking", ["l"], "the task's clocks: change or delete one"),
80
+ _a("clock-report", "Clocking", ["R"], "under a day's or week's agenda: what was clocked, on or off"),
81
+ _a("mark", "Marks", ["m"], "mark, moving down; actions go to the marked"),
82
+ _a("unmark", "Marks", ["u"], "unmark, moving down"),
83
+ _a("mark-toggle", "Marks", ["M-m"], "mark or unmark, moving down"),
84
+ _a("mark-range", "Marks", ["M"], "mark from the last mark to here: a range"),
85
+ _a("mark-all", "Marks", ["*"], "mark all"),
86
+ _a("unmark-all", "Marks", ["U"], "unmark all"),
87
+ _a("add", "Adding", ["c"], "add by keys: a card, then each field by its key, Enter adds"),
88
+ _a("add-under", "Adding", ["a"], "the same card, the new task under this one"),
89
+ _a("add-beside", "Adding", ["A"], "the same card, the new task beside this one"),
90
+ _a("earlier", "Views", ["b"], "a day's agenda: the day before; a week's: the week before"),
91
+ _a("later", "Views", ["f"], "the day after (a week's: the week after)"),
92
+ _a("earlier-week", "Views", ["B"], "a day's agenda: a week back"),
93
+ _a("later-week", "Views", ["F"], "a day's agenda: a week on"),
94
+ _a("today", "Views", ["."], "today (on a week's agenda, this week)"),
95
+ _a("date", "Views", ["D"], "a day's or week's agenda: pick the date"),
96
+ _a("widen", "Views", ["^"], "out of a zoom (h), a level up"),
97
+ _a("refresh", "Views", ["g"], "refresh"),
98
+ _a("save-tab", "Views", ["T"], "this view as a tab; on a saved tab, rename, move or remove it"),
99
+ *[_a(f"tab-{n}", "Views", [str(n % 10)], f"the {n}{'st' if n == 1 else 'nd' if n == 2 else 'rd' if n == 3 else 'th'} tab")
100
+ for n in range(1, 11)],
101
+ _a("box", "The page", ["/"], "the box: a view, a search, any orgwarrior command"),
102
+ _a("command", "The page", ["x"], "the box, for this task (or the marked): done, sched mon…"),
103
+ _a("help", "The page", ["?"], "this list"),
104
+ _a("preferences", "The page", ["P"], "preferences: theme, light or dark, key hints"),
105
+ ]
106
+ BY_NAME = {a.name: a for a in ACTIONS}
107
+
108
+ # Always these, whatever the table says (shown by `?` after the actions).
109
+ FIXED = [
110
+ ("ESC", "cancel, close: a field, a sheet, a form, the open task, the cursor"),
111
+ ("TAB", "in a file: fold or unfold; in an agenda: the task in its file"),
112
+ ("S-TAB", "in a file: fold or unfold everything"),
113
+ ("← → ↑ ↓, h l k j", "between choices in a sheet"),
114
+ ("a letter, SPC", "in a picker: that choice (org's fast keys); none"),
115
+ ("y / n", "yes or no, in a question"),
116
+ ("C-RET", "save the form or sheet you're in"),
117
+ ("help", "in the box (/): every command it takes, help dates for one"),
118
+ ]
119
+
120
+ # A preset: action -> its keys, replacing that action's defaults ([]: none);
121
+ # the rest keep theirs. Each says where its keys come from.
122
+ PRESETS = {
123
+ # evil-collection's org-agenda map (modes/org-agenda/evil-collection-org-agenda.el,
124
+ # evil-collection 2026-09-27; its theme keys from evil-collection.el's
125
+ # table), for every action the page has. An action it has no key for
126
+ # keeps the page's, unless evil-collection uses that key itself (alone or
127
+ # as the start of a sequence); then org's own key for it, else none. Two
128
+ # from vim: : and / open the box, C-o and C-i go back and forward (vim's
129
+ # jump list: evil-collection's [[ and ]] jump between blocks).
130
+ "evil": {
131
+ "down": ["j", "g j", "C-j", "<down>"], # org-agenda-next-line; next-item (gj, C-j)
132
+ "up": ["k", "g k", "C-k", "<up>"], # org-agenda-previous-line; previous-item
133
+ "quit": ["q", "Z Z", "Z Q", "x"], # org-agenda-quit (q, ZZ); -exit (ZQ, x)
134
+ "back": ["C-o"], # vim's jump list
135
+ "forward": ["C-i"],
136
+ "priority": ["p"], # org-agenda-priority
137
+ "tags": ["T"], # org-agenda-set-tags
138
+ "schedule": ["s"], # org-agenda-schedule
139
+ "deadline": ["D"], # org-agenda-deadline
140
+ "note": ["n"], # org-agenda-add-note
141
+ "history": [], # Z starts its Z Z, Z Q
142
+ "rename": ["R"], # its rename key in other modes (r R): r is refile here
143
+ "refile": ["r"], # org-agenda-refile
144
+ "archive": ["A"], # org-agenda-archive
145
+ "kill": ["d"], # org-agenda-kill (its delete key; asks first)
146
+ "move": [], # v starts its v d, v w, v m, v y
147
+ "move-down": ["M-<down>"], # org's: J is its go to date
148
+ "move-up": ["M-<up>"], # org's (K dropped too, to keep the pair)
149
+ "promote": ["M-S-<left>"], # org's: H is its earlier
150
+ "demote": ["M-S-<right>"], # org's: L is its later
151
+ "clock-in": ["I"], # org-agenda-clock-in
152
+ "clock-in-at": [], # I clocks in now: the toast's Adjust instead
153
+ "clock-out": ["O", "o"], # org-agenda-clock-out
154
+ "clock-out-at": [], # O clocks out now: the toast's Adjust instead
155
+ "clock-cancel": ["X"], # org-agenda-clock-cancel
156
+ "clock-goto": ["g J"], # org-agenda-clock-goto
157
+ "clock-report": [], # R is rename (above)
158
+ "add-beside": [], # A is its archive
159
+ "earlier": ["H"], # org-agenda-earlier
160
+ "later": ["L"], # org-agenda-later
161
+ "earlier-week": [], # B is its bulk action
162
+ "later-week": [], # F is its filters off
163
+ "date": ["J"], # org-agenda-goto-date (its jump key)
164
+ "refresh": ["g r", "g R"], # org-agenda-redo, -redo-all
165
+ "save-tab": [], # T is its tags
166
+ "box": ["/", ":"], # vim's search and command line
167
+ "command": ["B"], # org-agenda-bulk-action: the box, for the marked
168
+ },
169
+ }
170
+
171
+ # What evil-collection binds that the page has nothing for (docs/keys.md, `?`'s note).
172
+ EVIL_UNMATCHED = ("SPC (show the entry), S (save all), v d v w v m v y (day, week, month, year view), "
173
+ "f F (filter by tag, filters off), S-RET g o (open a link), [[ ]] (between blocks)")
174
+
175
+
176
+ # ---- Key syntax: Emacs's kbd, written back in one canonical form ----
177
+
178
+ NAMED = {"RET": "RET", "<return>": "RET", "SPC": "SPC", "TAB": "TAB", "<tab>": "TAB",
179
+ "ESC": "ESC", "<escape>": "ESC", "DEL": "DEL", "<backspace>": "DEL",
180
+ "<delete>": "<delete>", "<insert>": "<insert>", "<home>": "<home>", "<end>": "<end>",
181
+ "<prior>": "<prior>", "<pageup>": "<prior>", "<next>": "<next>", "<pagedown>": "<next>",
182
+ **{f"<{d}>": f"<{d}>" for d in ("up", "down", "left", "right")},
183
+ **{f"<f{n}>": f"<f{n}>" for n in range(1, 13)}}
184
+ MODS = {"C": "C-", "M": "M-", "S": "S-", "s": "s-"}
185
+ MOD_ORDER = "CMSs" # Emacs's order: C-M-S-s-
186
+
187
+ # Keys the browser keeps (a page can't stop them) and the page's own.
188
+ BROWSER_KEEPS = {"C-n", "C-t", "C-w", "C-q", "C-S-n", "C-S-t", "C-S-w", "C-S-q", "C-TAB", "C-S-TAB",
189
+ "C-<prior>", "C-<next>", "s-q", "s-w", "s-t", "s-n", "M-<f4>"}
190
+ PAGE_KEEPS = {"ESC", "TAB", "S-TAB"}
191
+
192
+
193
+ def parse_key(word: str) -> str:
194
+ """One key (`C-k`, `M-S-<left>`, `T`) in its canonical form."""
195
+ mods, rest = set(), word
196
+ while len(rest) > 2 and rest[1] == "-" and rest[0] in MODS:
197
+ if rest[0] in mods:
198
+ raise KeysError(f"{word!r}: {rest[0]}- twice")
199
+ mods.add(rest[0])
200
+ rest = rest[2:]
201
+ if rest in NAMED:
202
+ base = NAMED[rest]
203
+ elif len(rest) == 1 and rest.isprintable() and rest != " ":
204
+ base = rest
205
+ # A letter with C- or M- is the key, Shift apart (M-H is M-S-h); a
206
+ # character alone is as typed (T, $), S- with one folded into it.
207
+ if rest.isalpha() and rest.isupper() and mods & {"C", "M", "s"}:
208
+ base, mods = rest.lower(), mods | {"S"}
209
+ elif "S" in mods and not mods & {"C", "M", "s"}:
210
+ if not rest.isalpha():
211
+ raise KeysError(f"{word!r}: write the character Shift makes, not S-")
212
+ base, mods = rest.upper(), mods - {"S"}
213
+ else:
214
+ raise KeysError(f"{word!r} isn't a key: a character, RET SPC TAB ESC DEL, or <down> <f5>…, "
215
+ "after C- M- S- s- if any")
216
+ return "".join(MODS[m] for m in MOD_ORDER if m in mods) + base
217
+
218
+
219
+ def parse_sequence(spec: str) -> str:
220
+ """A key or a sequence (`d d`, `dd`, `C-c C-s`), canonical: its keys apart."""
221
+ words = spec.split()
222
+ if not words:
223
+ raise KeysError("an empty key")
224
+ keys = []
225
+ for w in words:
226
+ # A word of plain characters is one key a character, as kbd reads it.
227
+ if w not in NAMED and not re.match(r"^([CMSs]-)+.", w) and len(w) > 1 and not w.startswith("<"):
228
+ keys.extend(parse_key(c) for c in w)
229
+ else:
230
+ keys.append(parse_key(w))
231
+ seq = " ".join(keys)
232
+ for i, k in enumerate(keys):
233
+ if k in PAGE_KEEPS:
234
+ raise KeysError(f"{spec!r}: {k} is the page's own (Esc always cancels, Tab moves and folds)")
235
+ if k in BROWSER_KEEPS:
236
+ raise KeysError(f"{spec!r}: the browser keeps {k}; a page can't have it")
237
+ if len(keys) > 3:
238
+ raise KeysError(f"{spec!r}: three keys at most")
239
+ return seq
240
+
241
+
242
+ def _keys_of(value, where: str) -> list[str]:
243
+ """A config value, one key (a string) or several (a list; [] for none), as sequences."""
244
+ if isinstance(value, str):
245
+ specs = [value] if value.strip() else []
246
+ elif isinstance(value, list) and all(isinstance(v, str) for v in value):
247
+ specs = value
248
+ else:
249
+ raise KeysError(f'{where}: a key ("t"), or a list of them (["t", "C-c C-t"]; [] for none)')
250
+ try:
251
+ return list(dict.fromkeys(parse_sequence(s) for s in specs))
252
+ except KeysError as e:
253
+ raise KeysError(f"{where}: {e}") from None
254
+
255
+
256
+ def _layer(table: dict, where: str) -> dict[str, list[str]]:
257
+ """TABLE (action -> keys), checked: known actions, real keys, no key twice."""
258
+ out, seen = {}, {}
259
+ for name, value in table.items():
260
+ if name not in BY_NAME:
261
+ near = [a for a in BY_NAME if a.startswith(name[:3])][:3]
262
+ raise KeysError(f"{where}: no action {name!r}" + (f" (there's {', '.join(near)})" if near else ""))
263
+ keys = _keys_of(value, f"{where} {name}")
264
+ for i, k in enumerate(keys):
265
+ for o in keys[i + 1:]:
266
+ if _clashes(k, o):
267
+ raise KeysError(f"{where} {name}: {k!r} and {o!r}: one starts the other")
268
+ for k in keys:
269
+ for other, theirs in seen.items():
270
+ for o in theirs:
271
+ if o == k or o.startswith(k + " ") or k.startswith(o + " "):
272
+ raise KeysError(f"{where}: {k!r} ({name}) and {o!r} ({other}): "
273
+ + ("one key, two actions" if o == k else "one starts the other"))
274
+ seen[name] = keys
275
+ out[name] = keys
276
+ return out
277
+
278
+
279
+ def _clashes(a: str, b: str) -> bool:
280
+ return a == b or a.startswith(b + " ") or b.startswith(a + " ")
281
+
282
+
283
+ def resolve(config: dict | None) -> dict:
284
+ """The keys in effect: the defaults, then the preset, then the config's
285
+ own ([web.keys]), each later one taking its keys from the earlier ones
286
+ (a key, or the start of a sequence, the later one uses). For the page:
287
+ {"preset", "actions": [{name, group, what, keys, org}]}, keys and org
288
+ (the defaults, for `?` to mark what's changed) as canonical sequences."""
289
+ config = dict(config or {})
290
+ preset = config.pop("preset", None)
291
+ if preset is not None and preset not in PRESETS:
292
+ raise KeysError(f"[web.keys] preset: {preset!r} isn't one ({', '.join(PRESETS)})")
293
+ keys = {a.name: [parse_sequence(k) for k in a.keys] for a in ACTIONS}
294
+ layers = ([(_layer(PRESETS[preset], f"preset {preset}"), f"preset {preset}")] if preset else []) \
295
+ + [(_layer(config, "[web.keys]"), "[web.keys]")]
296
+ for layer, _ in layers:
297
+ taken = [k for ks in layer.values() for k in ks]
298
+ for name in keys:
299
+ if name not in layer:
300
+ keys[name] = [k for k in keys[name] if not any(_clashes(k, t) for t in taken)]
301
+ keys.update(layer)
302
+ return {"preset": preset,
303
+ "actions": [{"name": a.name, "group": a.group, "what": a.what, "keys": keys[a.name],
304
+ "org": [parse_sequence(k) for k in a.keys]} for a in ACTIONS],
305
+ "fixed": FIXED,
306
+ **({"unmatched": EVIL_UNMATCHED} if preset == "evil" else {})}
307
+
308
+
309
+ def readme_table() -> str:
310
+ """docs/keys.md's table of actions: `python -m orgwarrior_web.keymap`
311
+ prints it, and a test keeps the page's in step."""
312
+ org, evil = resolve({}), {a["name"]: a["keys"] for a in resolve({"preset": "evil"})["actions"]}
313
+ cell = lambda ks: ", ".join(f"`{k}`" for k in ks) or "–"
314
+ rows = ["| action | org (default) | evil | what it does |", "|---|---|---|---|"]
315
+ rows += [f"| `{a['name']}` | {cell(a['keys'])} | {cell(evil[a['name']])} | {a['what'].replace('|', '\\|')} |"
316
+ for a in org["actions"]]
317
+ return "\n".join(rows)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ print(readme_table())