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.
- orgwarrior_web/__init__.py +3 -0
- orgwarrior_web/auth.py +130 -0
- orgwarrior_web/feed.py +140 -0
- orgwarrior_web/jsonfile.py +37 -0
- orgwarrior_web/keymap.py +321 -0
- orgwarrior_web/sanitize.py +66 -0
- orgwarrior_web/server.py +676 -0
- orgwarrior_web/static/actions.js +867 -0
- orgwarrior_web/static/app.js +856 -0
- orgwarrior_web/static/apple-touch-icon.png +0 -0
- orgwarrior_web/static/draft.js +223 -0
- orgwarrior_web/static/forms.js +597 -0
- orgwarrior_web/static/icon-192.png +0 -0
- orgwarrior_web/static/icon-512.png +0 -0
- orgwarrior_web/static/icon-maskable-512.png +0 -0
- orgwarrior_web/static/icon-maskable.svg +1 -0
- orgwarrior_web/static/icon-monochrome-512.png +0 -0
- orgwarrior_web/static/icon-monochrome.svg +1 -0
- orgwarrior_web/static/icon.svg +1 -0
- orgwarrior_web/static/index.html +79 -0
- orgwarrior_web/static/keys.js +745 -0
- orgwarrior_web/static/manifest.webmanifest +20 -0
- orgwarrior_web/static/prefs.js +100 -0
- orgwarrior_web/static/sheets.js +519 -0
- orgwarrior_web/static/style.css +713 -0
- orgwarrior_web/static/sw.js +7 -0
- orgwarrior_web/static/taskpage.js +322 -0
- orgwarrior_web-0.1.0.dist-info/METADATA +365 -0
- orgwarrior_web-0.1.0.dist-info/RECORD +32 -0
- orgwarrior_web-0.1.0.dist-info/WHEEL +4 -0
- orgwarrior_web-0.1.0.dist-info/entry_points.txt +3 -0
- orgwarrior_web-0.1.0.dist-info/licenses/LICENSE +674 -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
|
orgwarrior_web/keymap.py
ADDED
|
@@ -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())
|