orgwarrior 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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """A taskwarrior-style command line for org-mode's agenda."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ class OrgwarriorError(Exception):
7
+ """Any orgwarrior failure; the message is fit to show the user as is."""
orgwarrior/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from orgwarrior.cli import main
2
+
3
+ raise SystemExit(main())
orgwarrior/api.py ADDED
@@ -0,0 +1,372 @@
1
+ """orgwarrior's engine for other programs: the command line's views and
2
+ actions, as plain data.
3
+
4
+ from orgwarrior.api import Orgwarrior
5
+ ow = Orgwarrior() # the user's config.toml and daemon
6
+ ow.view("wait +@phone") # the same words `orgwarrior` takes
7
+
8
+ A view is a list of lines, each {"text", "id", "spans"}: `id` is null on
9
+ headers and separators; a date header also has "date" ("2026-10-12"); `spans` are [start, end, role, keyword] for
10
+ coloring, with roles as in config.ROLES. A habit's line also has "habit":
11
+ org-habit's consistency graph, {"text", "spans", "colors"}, a day per
12
+ character ("*" done, "!" today), its roles the days' states, "colors" each
13
+ role's background as Emacs draws it (#rrggbb), for when the config sets none. Every failure is an
14
+ OrgwarriorError whose message is fit to show the user.
15
+ """
16
+
17
+ import os
18
+
19
+ from orgwarrior import OrgwarriorError, cli, config as configmod, emacs
20
+
21
+ __all__ = ["Orgwarrior", "OrgwarriorError"]
22
+
23
+
24
+ class Orgwarrior:
25
+ def __init__(self, config: configmod.Config | None = None, socket: str | None = None):
26
+ self.config = config or configmod.load()
27
+ self.socket = socket or os.environ.get("ORGWARRIOR_SOCKET") or self.config.socket
28
+
29
+ def _call(self, request: dict, **kw):
30
+ return emacs.call(request, self.socket, **kw)
31
+
32
+ def views(self) -> list[str]:
33
+ """View names to offer: the built-ins, then the config's [views]."""
34
+ return [*cli.BUILTIN_VIEWS, *(v for v in self.config.views if v not in cli.BUILTIN_VIEWS)]
35
+
36
+ def view(self, query: str = "", width: int | None = None, archive: bool = False) -> list[dict]:
37
+ """The view `orgwarrior QUERY` shows ("" is the default view). What the
38
+ CLI would take as a task ID or a command (`bed`, `add`) is a text
39
+ search here: a front end's query box only shows views. WIDTH is
40
+ what org right-aligns tags to with tags = "right". ARCHIVE (or
41
+ --archive in QUERY) takes in the archive files too."""
42
+ try:
43
+ command, _ = cli.parse(query.split(), self.config)
44
+ except cli.UsageError:
45
+ command = None
46
+ if not isinstance(command, cli.View):
47
+ command = cli.View("search", query.replace("--archive", "").strip().strip('"'),
48
+ archive="--archive" in query.split())
49
+ command.archive = command.archive or archive
50
+ return self._call(cli.view_request(command, self.config, width))
51
+
52
+ def show(self, task_id: str, html: bool = False) -> dict:
53
+ """{"id", "file", "path", "parents", "prev", "next", "heading", "title",
54
+ "tags", "inherited", "scheduled", "deadline", "keyword", "done", "body",
55
+ "lines", "links", "checkboxes", "notes", "priority", "from", "keywords",
56
+ "keyword_keys", "done_keywords", "children", "clocks", "history",
57
+ "log_drawers"}: `orgwarrior ID`'s
58
+ content. parents are the headings above it, {"id", "title"},
59
+ outermost first; prev and next its siblings' titles, or null. `tags` are the heading's own;
60
+ `inherited` come from the file's #+filetags and parent headings;
61
+ scheduled and deadline are org's timestamps ("<2026-10-16 Fri
62
+ +1w>"), or null, closed the CLOSED one ("[2026-10-20 Tue 09:16]"); lines are {"text", "spans", "links"}, the text as
63
+ Emacs shows it, its links {"start", "end", "url"} being what each
64
+ covers of it; links are {"type", "url", "text"}; checkboxes are {"line", "text", "state"}, line an index into
65
+ lines, state "on", "off" or "trans" (see check); children are
66
+ the direct child headings, {"id", "heading", "text", "tags",
67
+ "keyword", "done"}, text being the heading without keyword and
68
+ tags; notes are the log items with text, newest first, {"time"
69
+ ("[2026-10-15 Thu 14:02]"), "state" (a state change's new
70
+ keyword, or null), "kind" (org's purpose: "note", "state",
71
+ "done", ...), "text"}; priority is the cookie's value ("A"), or
72
+ null; from is the FROM property (who added it, someone else), or
73
+ null; keywords and done_keywords are its file's (a #+TODO line
74
+ makes them differ from file to file); clocks its own, as clocks()
75
+ has them; with HTML, "html": its text as org exports it (body only,
76
+ drawers and planning left out, source blocks colored by face name:
77
+ `org-keyword`...); untrusted where others wrote it, so sanitize it
78
+ before a page shows it; history every log item, notes or not, newest first, {"n"
79
+ (as note_edit takes it), "time", "state", "from" (a state change's
80
+ old keyword), "kind", "heading" (its first line), "text", "raw"};
81
+ log_drawers the log and clock drawers holding only those and
82
+ CLOCK lines, which a page showing history and clocks can leave out;
83
+ properties its own, {"name", "value"}, in its drawer's order;
84
+ effort its Effort, effort_minutes that in minutes (null: none, or
85
+ not a duration)."""
86
+ return self._call({"op": "show", "id": task_id, "html": html} if html else {"op": "show", "id": task_id})
87
+
88
+ def keywords(self, file: str | None = None) -> dict:
89
+ """{"keywords": [...], "done": [...], "keys": [...], "priorities": [...],
90
+ "warning_days"}: the TODO keywords, done ones too, their fast keys (C-c
91
+ C-t's: `TODO(t)`, else org's choice; null for none), the priorities,
92
+ highest first, and org-deadline-warning-days (a deadline without a -3d
93
+ of its own shows that many days ahead).
94
+ Org's own, or FILE's (its #+TODO line, if it has one); show's
95
+ keyword_keys are its file's."""
96
+ return self._call({"op": "keywords", "file": file} if file else {"op": "keywords"})
97
+
98
+ def files(self) -> list[str]:
99
+ """The agenda files, as IDs and the other calls name them
100
+ ("projects.org": relative to org-directory)."""
101
+ return self._call({"op": "files"})
102
+
103
+ def ping(self, timeout: float = 3) -> None:
104
+ """Raise OrgwarriorError unless the daemon answers within TIMEOUT
105
+ seconds with orgwarrior.el loaded: a health check's question."""
106
+ self._call({"op": "keywords"}, timeout=timeout)
107
+
108
+ def tags(self) -> dict:
109
+ """For a tag picker: {"groups": [{"exclusive", "tags"}], "other": [...],
110
+ "keys": [[tag, key], ...]}, keys being C-c C-q's (#+TAGS: @home(h), the
111
+ rest as org assigns them).
112
+ Groups are as the agenda files' #+TAGS declare them (an exclusive one
113
+ is a { } group: setting one of its tags removes the others); "other"
114
+ are tags in use but not declared, filetags left out."""
115
+ return self._call({"op": "tags"})
116
+
117
+ def calendar(self, days: int = 365) -> list[dict]:
118
+ """Every dated entry, for a calendar: {"id" (the whole hash, which
119
+ any ID taking command takes), "title" (as the agenda shows it, no
120
+ keyword, tags or timestamps), "keyword", "done", "habit", "tags",
121
+ "inherited" (as show has them), "category", "stamps"}. A stamp is {"kind":
122
+ "scheduled", "deadline" or "timestamp", "start", "start_time",
123
+ "end", "end_time", "repeat", "warning"}, dates "2026-10-14",
124
+ times "14:00" or null, end being the range's end or the start
125
+ again; repeat {"type": "+", "++" or ".+", "value", "unit": "h", "d",
126
+ "w", "m" or "y"} and warning {"value", "unit"} as org parsed them,
127
+ or null. Diary sexps (`%%(...)` lines, kind "sexp", and `<%%(...)>`
128
+ timestamps) are evaluated for each of the next DAYS days instead:
129
+ {"kind", "dates": [{"date", "text"}]}, text being what the sexp
130
+ returned ("Robin is 46"), or null. Done tasks and habits are
131
+ included: what to show is the caller's to decide."""
132
+ return self._call({"op": "calendar", "days": days})
133
+
134
+ def refile_targets(self, ids: list[str]) -> list[str]:
135
+ """Where IDS can be refiled, as labels ("tasks.org › Tasks") that
136
+ act(ids, "refile", [label]) takes as is."""
137
+ return self._call({"op": "refile-targets", "ids": list(ids)})
138
+
139
+ def refile_places(self, ids: list[str]) -> list[str]:
140
+ """Where a new heading for IDS can go (act's NEW): the top level of
141
+ each file in org-refile-targets ("projects.org"), then under each
142
+ target, as labels act takes as is."""
143
+ return self._call({"op": "refile-places", "ids": list(ids)})
144
+
145
+ def outline(self, file: str) -> dict:
146
+ """{"file", "children"}: FILE's (an agenda file as show names it,
147
+ "projects.org") top-level headings, in file order, each as show's
148
+ children are, with its own "children" the same way, all the way
149
+ down (a foldable tree)."""
150
+ return self._call({"op": "outline", "file": file})
151
+
152
+ def reorder(self, ids: list[str], parent: str | None = None, file: str | None = None) -> list[dict]:
153
+ """Put the children of PARENT (a task ID), or FILE's top-level
154
+ headings, in the order of IDS, which must be all of them (else
155
+ they changed since they were shown: refused). Each moves with its
156
+ subtree. Returns the children as they now are."""
157
+ return self._call({"op": "reorder", "ids": list(ids), "parent": parent, "file": file})
158
+
159
+ def wants_note(self, ids: list[str], verb: str) -> bool:
160
+ """Whether org would ask for a note when VERB (a keyword) is applied to IDS."""
161
+ return self._call({"op": "wants-note", "ids": ids, "verb": verb})
162
+
163
+ def check(self, task_id: str, n: int, text: str) -> dict:
164
+ """Toggle checkbox N (from 1, as show lists them), as C-c C-c does:
165
+ cookies follow. TEXT is the item's text as shown, and must still
166
+ match, so a list that changed underneath is refused. For a front
167
+ end that shows boxes in place; the CLI has no such verb."""
168
+ return self._call({"op": "check", "id": task_id, "n": n, "text": text})
169
+
170
+ def set_entry(self, task_id: str, text: str, old: str) -> dict:
171
+ """Replace the task's text below its heading (show's "body":
172
+ planning line, drawers, body; children stay) with TEXT, saved
173
+ with one blank line after the planning line and drawers. OLD is
174
+ the body as shown, and must still match, so an entry that changed
175
+ underneath is refused. A line org would read as a heading is
176
+ refused too."""
177
+ return self._call({"op": "set-entry", "id": task_id, "text": text, "old": old})
178
+
179
+ def act(self, ids: list[str], verb: str, args: list[str] = (), note: str | None = None,
180
+ new: str | None = None) -> list[dict]:
181
+ """Apply VERB (a keyword or nokeyword, sched, due, tag, pri, note,
182
+ prop (ARGS: name, value; value "none" removes it), effort (a
183
+ duration, one of Effort_ALL's where set, or "none"), rename, refile,
184
+ up, down, promote, demote, archive, kill) to IDS.
185
+ No confirmation here: kill deletes at once. Results
186
+ of refile and rename carry "old", the ID before. NEW (refile only)
187
+ is the title of a plain heading to make and refile into, as the
188
+ last child of the place ARGS names (one of refile_places, default
189
+ config's [refile] new_in)."""
190
+ return self._call(cli.act_request(cli.Act(list(ids), verb, list(args), note, new=new),
191
+ self.config))
192
+
193
+ def add(self, text: str | None = None, *, title: str | None = None,
194
+ keyword: str | None = None, priority: str | None = None,
195
+ tags: list[str] = (), scheduled: str | None = None,
196
+ deadline: str | None = None, body: str | None = None,
197
+ properties: dict[str, str] | None = None, where: str | None = None,
198
+ captured: bool | None = None, to: str | None = None,
199
+ under: str | None = None, effort: str | None = None,
200
+ closed: str | None = None) -> dict:
201
+ """Add a task where config's [add] says: from `orgwarrior add` syntax
202
+ (TEXT), or from fields (TITLE etc.), where nothing in the title is
203
+ read as syntax. KEYWORD "" adds a plain heading; PRIORITY is one
204
+ of keywords()'s priorities; EFFORT a duration, as the effort verb takes
205
+ it. Dates are org-read-date strings; BODY goes below
206
+ the drawer, as set_entry lays it out; PROPERTIES go in the drawer
207
+ (beside :CAPTURED:). WHERE ("task" or "event") files it where
208
+ config says that kind goes; CAPTURED, if given, overrides config's
209
+ :CAPTURED: stamp. TO puts it elsewhere, a place from add_places
210
+ (or any name refile takes); UNDER under a heading, by its ID."""
211
+ if title is None:
212
+ return self._call(self._placed(cli.add_request(text, self.config), where, captured, to, under))
213
+ return self._call(self._placed(cli.add_request(None, self.config, {
214
+ "title": title, "keyword": keyword, "priority": priority, "tags": list(tags),
215
+ "scheduled": scheduled, "deadline": deadline, "body": body, "effort": effort,
216
+ "closed": closed, "properties": properties or {}}), where, captured, to, under))
217
+
218
+ def _placed(self, request: dict, where: str | None, captured: bool | None = None,
219
+ to: str | None = None, under: str | None = None) -> dict:
220
+ """REQUEST filed where config puts a task (WHERE "task") or an event
221
+ ("event") instead of where its own kind goes; CAPTURED overriding
222
+ whether it's stamped."""
223
+ if where not in (None, "task", "event"):
224
+ raise ValueError(f"where is task or event, not {where!r}")
225
+ if where:
226
+ place = cli.add_request(None, self.config, event=where == "event")
227
+ request.update({k: place[k] for k in ("file", "heading", "captured")})
228
+ if captured is not None:
229
+ request["captured"] = captured
230
+ return {**request, "to": to, "under": under}
231
+
232
+ def usual_target(self, labels: list[str]) -> str | None:
233
+ """Which of LABELS (refile targets) config's [refile] usual names:
234
+ a heading of that name in a file of it (`tasks` is tasks.org › Tasks,
235
+ not inbox.org › Tasks), then in any file (as refile matches), then a
236
+ file of it, else a heading containing it. None without one, or no
237
+ match."""
238
+ want = (self.config.refile_usual or "").strip().lower()
239
+ if not want:
240
+ return None
241
+ last = lambda l: l.split(" › ")[-1].lower()
242
+ for test in (lambda l: l.lower().startswith(f"{want}.org › ") and last(l) == want,
243
+ lambda l: " › " in l and last(l) == want,
244
+ lambda l: l.lower() in (want, f"{want}.org"),
245
+ lambda l: " › " in l and want in last(l)):
246
+ if hit := next((l for l in labels if test(l)), None):
247
+ return hit
248
+ return None
249
+
250
+ def add_places(self, event: bool = False) -> dict:
251
+ """Where add (add_event, with EVENT) can put something:
252
+ {"places": [{"label", "file"}], "keywords": {file: [open keywords]}}.
253
+ A label is what TO takes: where config says first, then the
254
+ refile targets as seen from that file, each file's top level
255
+ included. The keywords are those files' and the agenda files'
256
+ (a #+TODO line makes a file's its own)."""
257
+ place = cli.add_request(None, self.config, event=event)
258
+ r = self._call({"op": "add-places", "file": place["file"], "heading": place["heading"]})
259
+ return {"places": r["places"], "keywords": {k["file"]: k["open"] for k in r["keywords"]}}
260
+
261
+ def add_event(self, text: str | None = None, *, title: str | None = None,
262
+ tags: list[str] = (), when: str | None = None, body: str | None = None,
263
+ properties: dict[str, str] | None = None, where: str | None = None,
264
+ captured: bool | None = None, to: str | None = None,
265
+ under: str | None = None) -> dict:
266
+ """Add an event (no keyword, a plain timestamp) where config's
267
+ [add.event] says: from `orgwarrior add event` syntax (TEXT, the date
268
+ as @DATE), or from fields. WHEN is a date as sched takes it,
269
+ ranges and repeaters too. PROPERTIES and WHERE as for add: WHERE
270
+ "task" files it with the tasks (an inbox to triage); TO and UNDER
271
+ as for add."""
272
+ if title is None:
273
+ return self._call(self._placed(cli.add_request(text, self.config, event=True), where,
274
+ captured, to, under))
275
+ return self._call(self._placed(cli.add_request(None, self.config, {
276
+ "title": title, "tags": list(tags), "when": when, "body": body,
277
+ "properties": properties or {}}, event=True), where, captured, to, under))
278
+
279
+ def clock(self) -> list[dict]:
280
+ """The clocks running: the open CLOCK lines in the agenda files, not
281
+ Emacs's memory, oldest first. Usually one; two when two machines
282
+ clocked in. Each {"id", "heading", "title", "file", "start"
283
+ ("2026-10-20 14:05"), "end" (null), "minutes", "stale" (running past
284
+ 4 hours: likely forgotten)}."""
285
+ return self._call({"op": "clock"})
286
+
287
+ def clock_in(self, task_id: str, time: str | None = None) -> dict:
288
+ """Clock in on the task, as org-clock-in does, at TIME (None: now):
289
+ "17:30", "5pm", "-15m", "yesterday 17:30", never later than now.
290
+ Whatever clock is running is closed at the same time. {"clock": as
291
+ clock() gives it, "closed": those closed, as clock_out gives them}."""
292
+ return self._call({"op": "clock-in", "id": task_id, "time": time})
293
+
294
+ def clock_out(self, task_id: str | None = None, time: str | None = None) -> dict:
295
+ """Clock out at TIME (as clock_in takes it, later than now too:
296
+ "+10m"; None: now): the task's
297
+ clock, else the one running (several: refused, they're listed). As
298
+ clock() gives it, with "end" and "minutes", and "removed" when org
299
+ removed a zero-time line (org-clock-out-remove-zero-time-clocks)."""
300
+ return self._call({"op": "clock-out", "id": task_id, "time": time})
301
+
302
+ def clock_cancel(self, task_id: str | None = None) -> dict:
303
+ """Remove the running clock's line, as org-clock-cancel does: the
304
+ task's, else the one running."""
305
+ return self._call({"op": "clock-cancel", "id": task_id})
306
+
307
+ def clocks(self, task_id: str) -> dict:
308
+ """The task's own clocks, newest first: {"id", "heading", "minutes"
309
+ (all of them), "clocks": [{"n" (from 1), "start", "end" (null while
310
+ running), "minutes", "text" (the line, for clock_edit's OLD)}]}."""
311
+ return self._call({"op": "clocks", "id": task_id})
312
+
313
+ def clock_edit(self, task_id: str, n: int, start: str | None = None,
314
+ end: str | None = None, old: str | None = None) -> dict:
315
+ """Change clock N's START and/or END (as clock_in and clock_out take
316
+ them, so an end may be later than now; a
317
+ time of day is on the clock's own date, an end before the start
318
+ the next day's). OLD, the clock's text as shown, must still match.
319
+ Org recomputes its total. The task's clocks, as clocks() gives them."""
320
+ return self._call({"op": "clock-edit", "id": task_id, "n": n, "start": start,
321
+ "end": end, "old": old})
322
+
323
+ def clock_add(self, task_id: str, start: str, end: str | None = None,
324
+ duration: str | None = None) -> dict:
325
+ """Log time already spent: a finished clock from START (as clock_in
326
+ takes it) to END (a time of day on START's date) or for DURATION
327
+ ("1h30m", "45m", "1:30"), not later than now. A running clock is
328
+ left alone. The task's clocks after, as clocks() gives them."""
329
+ return self._call({"op": "clock-add", "id": task_id, "start": start, "end": end,
330
+ "duration": duration})
331
+
332
+ def clock_remove(self, task_id: str, n: int, old: str | None = None) -> dict:
333
+ """Remove clock N (a running one too: as clock_cancel). OLD as for
334
+ clock_edit. The task's clocks after."""
335
+ return self._call({"op": "clock-edit", "id": task_id, "n": n, "old": old, "remove": True})
336
+
337
+ def note_edit(self, task_id: str, n: int, text: str, old: str | None = None) -> list[dict]:
338
+ """Set log item N's text (as show's history numbers them; "" leaves
339
+ just its first line, which a plain note can't do). OLD, its "raw" as
340
+ shown, must still match. The task's history after."""
341
+ return self._call({"op": "note-edit", "id": task_id, "n": n, "text": text, "old": old})
342
+
343
+ def note_remove(self, task_id: str, n: int, old: str | None = None) -> list[dict]:
344
+ """Remove log item N, as note_edit numbers and guards it; an emptied
345
+ log drawer goes too. The task's history after."""
346
+ return self._call({"op": "note-edit", "id": task_id, "n": n, "old": old, "remove": True})
347
+
348
+ def report(self, words: str = "", archive: bool = False) -> dict:
349
+ """Time clocked, as org's clocktable counts it, over the agenda files:
350
+ `orgwarrior clocked WORDS` (a span: today, yesterday, week, lastweek,
351
+ month, lastmonth, year, 2026-10-20, 2026-W43, 2026-10, 2026, or
352
+ 2026-10-01..2026-10-15; default today; then a tag match). {"label",
353
+ "kind" (day, week, month, year, range), "start", "end" (its last day),
354
+ "prev", "next" (the spans either side, null for a range), "match",
355
+ "minutes", "files": [{"file", "minutes", "rows": [{"depth", "id",
356
+ "title", "minutes", "effort_minutes"}]}]}: every heading holding
357
+ time, by depth, in file order. ARCHIVE counts the archive files too."""
358
+ return self._call({"op": "report", "words": words, "archive": archive})
359
+
360
+ def props(self, task_id: str | None = None, name: str | None = None) -> dict:
361
+ """For a property editor: {"names": the property names in use in the
362
+ agenda files, "values": NAME's allowed values for the task (its
363
+ NAME_ALL, a #+PROPERTY line or a parent's), or null}."""
364
+ return self._call({"op": "props", "id": task_id, "name": name})
365
+
366
+ def entries_with(self, prop: str, value: str) -> list[dict]:
367
+ """The entries whose own PROP is VALUE (FROM = robin: what someone
368
+ added), archived and commented ones left out: {"title", "keyword",
369
+ "done", "scheduled", "deadline", "timestamp" (the first plain one,
370
+ an event's), "closed", "captured"}, the dates as org writes them,
371
+ or null."""
372
+ return self._call({"op": "entries", "property": prop, "value": value})