endpaper 0.0.1__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.
- endpaper/__init__.py +1 -0
- endpaper/__main__.py +4 -0
- endpaper/cli/__init__.py +0 -0
- endpaper/cli/main.py +329 -0
- endpaper/cli/output.py +85 -0
- endpaper/core/__init__.py +97 -0
- endpaper/core/documents.py +187 -0
- endpaper/core/editing.py +101 -0
- endpaper/core/errors.py +21 -0
- endpaper/core/frontmatter.py +89 -0
- endpaper/core/meetings.py +37 -0
- endpaper/core/models.py +137 -0
- endpaper/core/notes.py +55 -0
- endpaper/core/tasks.py +434 -0
- endpaper/core/templates/AGENTS.md.tmpl +62 -0
- endpaper/core/templates/CLAUDE.md.tmpl +8 -0
- endpaper/core/text.py +48 -0
- endpaper/core/workspace.py +87 -0
- endpaper/tui/__init__.py +0 -0
- endpaper/tui/app.py +178 -0
- endpaper/tui/app.tcss +80 -0
- endpaper/tui/command_bar.py +144 -0
- endpaper/tui/discard_dialog.py +24 -0
- endpaper/tui/edit_screen.py +94 -0
- endpaper/tui/list_screen.py +345 -0
- endpaper/tui/preview_screen.py +68 -0
- endpaper/tui/rendering.py +42 -0
- endpaper/tui/status_bar.py +16 -0
- endpaper-0.0.1.dist-info/METADATA +162 -0
- endpaper-0.0.1.dist-info/RECORD +33 -0
- endpaper-0.0.1.dist-info/WHEEL +4 -0
- endpaper-0.0.1.dist-info/entry_points.txt +2 -0
- endpaper-0.0.1.dist-info/licenses/LICENSE +21 -0
endpaper/core/tasks.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from endpaper.core.documents import _TOKEN_PATTERN, _validate_token
|
|
12
|
+
from endpaper.core.errors import NotFoundError, UsageError, WorkspaceError
|
|
13
|
+
from endpaper.core.models import ParsedTasks, ScanWarning, Task, TaskFilter, Workspace
|
|
14
|
+
from endpaper.core.text import new_task_id, parse_tags
|
|
15
|
+
|
|
16
|
+
_TASK_LINE = re.compile(
|
|
17
|
+
r"^(?P<indent>[ \t]*)(?P<marker>[-*+])[ \t]+\[(?P<state>[ xX])\][ \t]+(?P<rest>.*)$"
|
|
18
|
+
)
|
|
19
|
+
_IDVAL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
20
|
+
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
21
|
+
_RECOGNIZED_KEYS = frozenset({"id", "type", "tags", "created"})
|
|
22
|
+
_TERMINATORS = ("\r\n", "\n", "\r")
|
|
23
|
+
|
|
24
|
+
TASKS_PATH = Path("tasks.md")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _split_terminator(line: str) -> tuple[str, str]:
|
|
28
|
+
for terminator in _TERMINATORS:
|
|
29
|
+
if line.endswith(terminator):
|
|
30
|
+
return line[: -len(terminator)], terminator
|
|
31
|
+
return line, ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _split_comment(rest: str) -> tuple[str, str | None, bool]:
|
|
35
|
+
"""Locate the last ``<!-- ... -->`` on the line, if any.
|
|
36
|
+
|
|
37
|
+
Returns (text_before, body, unterminated). `body` is None when there is no
|
|
38
|
+
`<!--` at all. `unterminated` is True when a `<!--` is opened but never closed.
|
|
39
|
+
"""
|
|
40
|
+
idx = rest.rfind("<!--")
|
|
41
|
+
if idx == -1:
|
|
42
|
+
return rest, None, False
|
|
43
|
+
end = rest.find("-->", idx + 4)
|
|
44
|
+
if end == -1:
|
|
45
|
+
return rest, None, True
|
|
46
|
+
return rest[:idx], rest[idx + 4 : end], False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _classify_body(body: str) -> tuple[str, dict[str, str]]:
|
|
50
|
+
"""Classify a closed comment's contents as bare / malformed / task."""
|
|
51
|
+
tokens = body.split()
|
|
52
|
+
fields: dict[str, str] = {}
|
|
53
|
+
has_recognized = False
|
|
54
|
+
has_unknown = False
|
|
55
|
+
|
|
56
|
+
for token in tokens:
|
|
57
|
+
key, sep, value = token.partition(":")
|
|
58
|
+
if sep and key in _RECOGNIZED_KEYS:
|
|
59
|
+
has_recognized = True
|
|
60
|
+
fields[key] = value
|
|
61
|
+
else:
|
|
62
|
+
has_unknown = True
|
|
63
|
+
|
|
64
|
+
if not has_recognized:
|
|
65
|
+
return "bare", {}
|
|
66
|
+
if has_unknown:
|
|
67
|
+
return "malformed", {}
|
|
68
|
+
|
|
69
|
+
if "id" in fields and not _IDVAL.match(fields["id"]):
|
|
70
|
+
return "malformed", {}
|
|
71
|
+
if "type" in fields and not _TOKEN_PATTERN.match(fields["type"]):
|
|
72
|
+
return "malformed", {}
|
|
73
|
+
if "tags" in fields:
|
|
74
|
+
tag_values = fields["tags"].split(",") if fields["tags"] else []
|
|
75
|
+
if not tag_values or any(not _TOKEN_PATTERN.match(tag) for tag in tag_values):
|
|
76
|
+
return "malformed", {}
|
|
77
|
+
|
|
78
|
+
return "task", fields
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_tasks(text: str) -> ParsedTasks:
|
|
82
|
+
"""Classify every line of a tasks.md document.
|
|
83
|
+
|
|
84
|
+
Never raises. Malformed lines become warnings, not exceptions.
|
|
85
|
+
``"".join(result.lines) == text`` for any input.
|
|
86
|
+
"""
|
|
87
|
+
raw_lines = text.splitlines(keepends=True)
|
|
88
|
+
tasks: list[Task] = []
|
|
89
|
+
warnings: list[ScanWarning] = []
|
|
90
|
+
needs_id: list[int] = []
|
|
91
|
+
|
|
92
|
+
for idx, raw_line in enumerate(raw_lines):
|
|
93
|
+
line_no = idx + 1
|
|
94
|
+
content, _terminator = _split_terminator(raw_line)
|
|
95
|
+
|
|
96
|
+
match = _TASK_LINE.match(content)
|
|
97
|
+
if match is None:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
rest = match.group("rest")
|
|
101
|
+
done = match.group("state") in ("x", "X")
|
|
102
|
+
|
|
103
|
+
before, body, unterminated = _split_comment(rest)
|
|
104
|
+
|
|
105
|
+
if body is None and not unterminated:
|
|
106
|
+
tasks.append(
|
|
107
|
+
Task(
|
|
108
|
+
id=None,
|
|
109
|
+
text=rest.rstrip(),
|
|
110
|
+
done=done,
|
|
111
|
+
type="",
|
|
112
|
+
tags=(),
|
|
113
|
+
created=None,
|
|
114
|
+
line=line_no,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
needs_id.append(idx)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if unterminated:
|
|
121
|
+
warnings.append(
|
|
122
|
+
ScanWarning(
|
|
123
|
+
path=TASKS_PATH,
|
|
124
|
+
reason="task_unterminated_comment",
|
|
125
|
+
message=f"tasks.md:{line_no}: unterminated comment on a task line",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
assert body is not None
|
|
131
|
+
classification, fields = _classify_body(body)
|
|
132
|
+
|
|
133
|
+
if classification == "bare":
|
|
134
|
+
tasks.append(
|
|
135
|
+
Task(
|
|
136
|
+
id=None,
|
|
137
|
+
text=rest.rstrip(),
|
|
138
|
+
done=done,
|
|
139
|
+
type="",
|
|
140
|
+
tags=(),
|
|
141
|
+
created=None,
|
|
142
|
+
line=line_no,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
needs_id.append(idx)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if classification == "malformed":
|
|
149
|
+
warnings.append(
|
|
150
|
+
ScanWarning(
|
|
151
|
+
path=TASKS_PATH,
|
|
152
|
+
reason="task_malformed_comment",
|
|
153
|
+
message=f"tasks.md:{line_no}: malformed metadata comment on a task line",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
task_id = fields.get("id")
|
|
159
|
+
task_type = fields.get("type", "")
|
|
160
|
+
tags_raw = fields.get("tags", "")
|
|
161
|
+
tags = tuple(tags_raw.split(",")) if tags_raw else ()
|
|
162
|
+
|
|
163
|
+
created_raw = fields.get("created")
|
|
164
|
+
created_value: date | None = None
|
|
165
|
+
if created_raw:
|
|
166
|
+
if _ISO_DATE.match(created_raw):
|
|
167
|
+
try:
|
|
168
|
+
created_value = date.fromisoformat(created_raw)
|
|
169
|
+
except ValueError:
|
|
170
|
+
created_value = None
|
|
171
|
+
if created_value is None:
|
|
172
|
+
warnings.append(
|
|
173
|
+
ScanWarning(
|
|
174
|
+
path=TASKS_PATH,
|
|
175
|
+
reason="task_invalid_value",
|
|
176
|
+
message=f"tasks.md:{line_no}: invalid created date {created_raw!r}",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
tasks.append(
|
|
181
|
+
Task(
|
|
182
|
+
id=task_id,
|
|
183
|
+
text=before.rstrip(),
|
|
184
|
+
done=done,
|
|
185
|
+
type=task_type,
|
|
186
|
+
tags=tags,
|
|
187
|
+
created=created_value,
|
|
188
|
+
line=line_no,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return ParsedTasks(
|
|
193
|
+
tasks=tuple(tasks),
|
|
194
|
+
warnings=tuple(warnings),
|
|
195
|
+
lines=tuple(raw_lines),
|
|
196
|
+
needs_id=tuple(needs_id),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _render_comment(
|
|
201
|
+
*, id: str, type: str = "", tags: Sequence[str] = (), created: date | None = None
|
|
202
|
+
) -> str:
|
|
203
|
+
fields = [f"id:{id}"]
|
|
204
|
+
if type:
|
|
205
|
+
fields.append(f"type:{type}")
|
|
206
|
+
if tags:
|
|
207
|
+
fields.append(f"tags:{','.join(tags)}")
|
|
208
|
+
if created is not None:
|
|
209
|
+
fields.append(f"created:{created.isoformat()}")
|
|
210
|
+
return f"<!-- {' '.join(fields)} -->"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def render_task_line(
|
|
214
|
+
text: str,
|
|
215
|
+
*,
|
|
216
|
+
done: bool = False,
|
|
217
|
+
id: str,
|
|
218
|
+
type: str = "",
|
|
219
|
+
tags: Sequence[str] = (),
|
|
220
|
+
created: date | None = None,
|
|
221
|
+
) -> str:
|
|
222
|
+
"""Render one task line, without a terminator.
|
|
223
|
+
|
|
224
|
+
Fields appear in the order id, type, tags, created; empty ones are omitted.
|
|
225
|
+
Raises UsageError if `text` is empty after stripping.
|
|
226
|
+
"""
|
|
227
|
+
stripped = text.strip()
|
|
228
|
+
if not stripped:
|
|
229
|
+
raise UsageError("task text must not be empty")
|
|
230
|
+
checkbox = "x" if done else " "
|
|
231
|
+
comment = _render_comment(id=id, type=type, tags=tags, created=created)
|
|
232
|
+
return f"- [{checkbox}] {stripped} {comment}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def filter_tasks(tasks: Iterable[Task], f: TaskFilter) -> list[Task]:
|
|
236
|
+
"""Conjunctive filter. Sorts oldest-first, undated last, stable within a date."""
|
|
237
|
+
results: list[Task] = []
|
|
238
|
+
for task in tasks:
|
|
239
|
+
if not f.include_done and task.done:
|
|
240
|
+
continue
|
|
241
|
+
if f.type is not None and task.type.lower() != f.type.lower():
|
|
242
|
+
continue
|
|
243
|
+
if f.tags:
|
|
244
|
+
task_tags = {tag.lower() for tag in task.tags}
|
|
245
|
+
if not all(tag.lower() in task_tags for tag in f.tags):
|
|
246
|
+
continue
|
|
247
|
+
results.append(task)
|
|
248
|
+
return sorted(results, key=lambda t: (t.created is None, t.created or date.min))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def match_task(task: Task, query: str) -> bool:
|
|
252
|
+
"""Case-insensitive substring over text, type, and tags. For the TUI's live filter."""
|
|
253
|
+
haystack = " ".join([task.text, task.type, *task.tags]).lower()
|
|
254
|
+
return query.lower() in haystack
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _atomic_write(path: Path, lines: Sequence[str]) -> None:
|
|
258
|
+
try:
|
|
259
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
261
|
+
tmp_path = Path(tmp_name)
|
|
262
|
+
try:
|
|
263
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
|
|
264
|
+
fh.write("".join(lines))
|
|
265
|
+
os.replace(tmp_path, path)
|
|
266
|
+
except BaseException:
|
|
267
|
+
tmp_path.unlink(missing_ok=True)
|
|
268
|
+
raise
|
|
269
|
+
except (PermissionError, OSError) as exc:
|
|
270
|
+
raise WorkspaceError(f"could not write {path}: {exc}") from exc
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_text(path: Path) -> str:
|
|
274
|
+
try:
|
|
275
|
+
with open(path, encoding="utf-8", newline="") as fh:
|
|
276
|
+
return fh.read()
|
|
277
|
+
except OSError as exc:
|
|
278
|
+
raise WorkspaceError(f"could not read {path}: {exc}") from exc
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load_tasks(workspace: Workspace) -> tuple[list[Task], list[ScanWarning]]:
|
|
282
|
+
"""Read tasks.md, backfill missing identifiers in place, return records.
|
|
283
|
+
|
|
284
|
+
A missing tasks.md is an empty list, not an error. Backfill is best-effort: if
|
|
285
|
+
the file cannot be written, the affected tasks are returned with id=None plus
|
|
286
|
+
a warning, and the read still succeeds (FR-038).
|
|
287
|
+
"""
|
|
288
|
+
path = workspace.tasks_file
|
|
289
|
+
if not path.exists():
|
|
290
|
+
return [], []
|
|
291
|
+
|
|
292
|
+
text = _read_text(path)
|
|
293
|
+
parsed = parse_tasks(text)
|
|
294
|
+
warnings = [replace(w, path=path) for w in parsed.warnings]
|
|
295
|
+
tasks = list(parsed.tasks)
|
|
296
|
+
|
|
297
|
+
if not parsed.needs_id:
|
|
298
|
+
return tasks, warnings
|
|
299
|
+
|
|
300
|
+
taken = {t.id for t in tasks if t.id is not None}
|
|
301
|
+
lines = list(parsed.lines)
|
|
302
|
+
id_map: dict[int, str] = {}
|
|
303
|
+
for idx in parsed.needs_id:
|
|
304
|
+
new_id = new_task_id(taken)
|
|
305
|
+
taken.add(new_id)
|
|
306
|
+
id_map[idx] = new_id
|
|
307
|
+
content, terminator = _split_terminator(lines[idx])
|
|
308
|
+
lines[idx] = f"{content} <!-- id:{new_id} -->{terminator}"
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
_atomic_write(path, lines)
|
|
312
|
+
except WorkspaceError as exc:
|
|
313
|
+
warnings.append(
|
|
314
|
+
ScanWarning(
|
|
315
|
+
path=path,
|
|
316
|
+
reason="task_invalid_value",
|
|
317
|
+
message=f"could not backfill task ids: {exc}",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
return tasks, warnings
|
|
321
|
+
|
|
322
|
+
return [
|
|
323
|
+
replace(task, id=id_map[task.line - 1]) if (task.line - 1) in id_map else task
|
|
324
|
+
for task in tasks
|
|
325
|
+
], warnings
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def add_task(
|
|
329
|
+
workspace: Workspace,
|
|
330
|
+
description: str,
|
|
331
|
+
*,
|
|
332
|
+
type: str = "",
|
|
333
|
+
tags: Sequence[str] = (),
|
|
334
|
+
now: datetime | None = None,
|
|
335
|
+
) -> Task:
|
|
336
|
+
"""Append one task to tasks.md and return it.
|
|
337
|
+
|
|
338
|
+
Parses inline #tags out of `description` and merges them after `tags`, exactly
|
|
339
|
+
as create_document does. Creates tasks.md if absent. Every pre-existing byte is
|
|
340
|
+
preserved; if the file did not end with a newline, the terminator is added.
|
|
341
|
+
"""
|
|
342
|
+
when = now or datetime.now()
|
|
343
|
+
title, inline_tags = parse_tags(description)
|
|
344
|
+
if not title:
|
|
345
|
+
raise UsageError("description must not be empty after removing #tag tokens")
|
|
346
|
+
|
|
347
|
+
normalized_type = _validate_token(type, "type") if type else ""
|
|
348
|
+
merged_tags: list[str] = []
|
|
349
|
+
for tag in (*tags, *inline_tags):
|
|
350
|
+
normalized = _validate_token(tag, "tag")
|
|
351
|
+
if normalized not in merged_tags:
|
|
352
|
+
merged_tags.append(normalized)
|
|
353
|
+
|
|
354
|
+
path = workspace.tasks_file
|
|
355
|
+
existing_text = _read_text(path) if path.exists() else ""
|
|
356
|
+
parsed = parse_tasks(existing_text)
|
|
357
|
+
taken = {t.id for t in parsed.tasks if t.id is not None}
|
|
358
|
+
new_id = new_task_id(taken)
|
|
359
|
+
|
|
360
|
+
rendered = render_task_line(
|
|
361
|
+
title,
|
|
362
|
+
done=False,
|
|
363
|
+
id=new_id,
|
|
364
|
+
type=normalized_type,
|
|
365
|
+
tags=tuple(merged_tags),
|
|
366
|
+
created=when.date(),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
lines = list(parsed.lines)
|
|
370
|
+
if lines:
|
|
371
|
+
last_content, last_terminator = _split_terminator(lines[-1])
|
|
372
|
+
if not last_terminator:
|
|
373
|
+
lines[-1] = last_content + "\n"
|
|
374
|
+
lines.append(rendered + "\n")
|
|
375
|
+
|
|
376
|
+
_atomic_write(path, lines)
|
|
377
|
+
|
|
378
|
+
return Task(
|
|
379
|
+
id=new_id,
|
|
380
|
+
text=title,
|
|
381
|
+
done=False,
|
|
382
|
+
type=normalized_type,
|
|
383
|
+
tags=tuple(merged_tags),
|
|
384
|
+
created=when.date(),
|
|
385
|
+
line=len(lines),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _format_line_numbers(numbers: Sequence[int]) -> str:
|
|
390
|
+
if len(numbers) == 2:
|
|
391
|
+
return f"{numbers[0]} and {numbers[1]}"
|
|
392
|
+
return ", ".join(str(n) for n in numbers[:-1]) + f", and {numbers[-1]}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def set_task_state(workspace: Workspace, task_id: str, *, done: bool) -> Task:
|
|
396
|
+
"""Set one task's checkbox and return the task as it now stands.
|
|
397
|
+
|
|
398
|
+
Re-reads and re-parses before writing; locates by id, never by a cached line
|
|
399
|
+
number. Changes exactly one character on that line. No-op (no write) if the
|
|
400
|
+
task already has the requested state.
|
|
401
|
+
"""
|
|
402
|
+
path = workspace.tasks_file
|
|
403
|
+
if not path.exists():
|
|
404
|
+
raise NotFoundError(f"no task with id {task_id!r}")
|
|
405
|
+
|
|
406
|
+
text = _read_text(path)
|
|
407
|
+
parsed = parse_tasks(text)
|
|
408
|
+
matches = [t for t in parsed.tasks if t.id == task_id]
|
|
409
|
+
|
|
410
|
+
if not matches:
|
|
411
|
+
raise NotFoundError(f"no task with id {task_id!r}")
|
|
412
|
+
if len(matches) > 1:
|
|
413
|
+
line_numbers = _format_line_numbers([t.line for t in matches])
|
|
414
|
+
raise UsageError(
|
|
415
|
+
f"id {task_id!r} appears on lines {line_numbers}; "
|
|
416
|
+
"edit tasks.md to give one of them a different id"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
task = matches[0]
|
|
420
|
+
if task.done == done:
|
|
421
|
+
return task
|
|
422
|
+
|
|
423
|
+
lines = list(parsed.lines)
|
|
424
|
+
idx = task.line - 1
|
|
425
|
+
content, terminator = _split_terminator(lines[idx])
|
|
426
|
+
match = _TASK_LINE.match(content)
|
|
427
|
+
assert match is not None
|
|
428
|
+
state_start, state_end = match.span("state")
|
|
429
|
+
new_char = "x" if done else " "
|
|
430
|
+
lines[idx] = content[:state_start] + new_char + content[state_end:] + terminator
|
|
431
|
+
|
|
432
|
+
_atomic_write(path, lines)
|
|
433
|
+
|
|
434
|
+
return replace(task, done=done)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# endpaper workspace
|
|
2
|
+
|
|
3
|
+
Plain Markdown files with YAML frontmatter, no database. Every command below is
|
|
4
|
+
non-interactive and produces stable, parseable output.
|
|
5
|
+
|
|
6
|
+
## Layout
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
.endpaper/config.toml workspace marker, do not edit
|
|
10
|
+
meetings/YYYY/MM/ one markdown file per meeting
|
|
11
|
+
notes/YYYY/MM/ one markdown file per typed or untyped note
|
|
12
|
+
notes/daily/YYYY/MM/ one markdown file per day, named YYYY-MM-DD.md
|
|
13
|
+
tasks.md one checkbox line per task, at the workspace root
|
|
14
|
+
AGENTS.md this file
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Frontmatter
|
|
18
|
+
|
|
19
|
+
Every file in `meetings/` and `notes/` starts with exactly these six keys:
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
---
|
|
23
|
+
id: m_20260728_a1b2c3d4
|
|
24
|
+
type: "standup"
|
|
25
|
+
title: "Q3 planning"
|
|
26
|
+
tags: ["platform"]
|
|
27
|
+
created: 2026-07-28T09:14:00
|
|
28
|
+
updated: 2026-07-28T09:14:00
|
|
29
|
+
---
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`id` is prefixed `m_` for meetings, `n_` for notes. `type` is `""` when untyped; `tags` is `[]`.
|
|
33
|
+
|
|
34
|
+
## Tasks
|
|
35
|
+
|
|
36
|
+
One checkbox line per task in `tasks.md`, metadata in a trailing HTML comment (invisible in
|
|
37
|
+
any markdown viewer). `[ ]` open, `[x]` done; a bare `- [ ] ...` gets an `id` on the next scan:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
- [ ] send the vendor comparison <!-- id:t_a1b2 type:followup tags:procurement created:2026-07-28 -->
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
endpaper meeting new "Q3 planning" --type standup --tag platform
|
|
47
|
+
endpaper meeting list --json --type standup --tag platform --since 2026-07-01
|
|
48
|
+
endpaper note today # open/create today's note
|
|
49
|
+
endpaper note new "vendor landscape" --type research --tag procurement
|
|
50
|
+
endpaper note list --json --type daily --since 2026-07-01
|
|
51
|
+
endpaper task add "send the vendor comparison" --type followup --tag procurement
|
|
52
|
+
endpaper task list --json --all --type followup --tag procurement
|
|
53
|
+
endpaper task done t_a1b2 # endpaper task undone t_a1b2
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`--tag` is repeatable and the supported way to attach tags on the command line -- an unquoted
|
|
57
|
+
`#tag` is eaten by the shell first; a `#tag` inside a *quoted* description is parsed out too.
|
|
58
|
+
|
|
59
|
+
## Exit codes
|
|
60
|
+
|
|
61
|
+
`0` success · `1` not found · `2` usage error · `3` workspace error. Diagnostics go to
|
|
62
|
+
stderr; data goes to stdout; the two are never interleaved.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# endpaper workspace
|
|
2
|
+
|
|
3
|
+
This directory is an endpaper workspace. Before creating or changing anything here, read
|
|
4
|
+
`AGENTS.md` in this same directory — it documents the commands, the layout, and the file
|
|
5
|
+
format this tool expects.
|
|
6
|
+
|
|
7
|
+
The one thing worth stating twice: create new files through the commands in `AGENTS.md`,
|
|
8
|
+
not by hand. Once a file exists, edit its Markdown body directly like any other file.
|
endpaper/core/text.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import secrets
|
|
5
|
+
import unicodedata
|
|
6
|
+
from collections.abc import Container
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
10
|
+
_TAG_TOKEN = re.compile(r"#([A-Za-z0-9][A-Za-z0-9_-]*)")
|
|
11
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def slugify(text: str, *, max_length: int = 40) -> str:
|
|
15
|
+
normalized = unicodedata.normalize("NFKD", text)
|
|
16
|
+
without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
|
17
|
+
casefolded = without_marks.casefold()
|
|
18
|
+
collapsed = _NON_ALNUM.sub("-", casefolded)
|
|
19
|
+
stripped = collapsed.strip("-")
|
|
20
|
+
truncated = stripped[:max_length].rstrip("-")
|
|
21
|
+
return truncated or "untitled"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_tags(description: str) -> tuple[str, tuple[str, ...]]:
|
|
25
|
+
tags: list[str] = []
|
|
26
|
+
for match in _TAG_TOKEN.finditer(description):
|
|
27
|
+
tag = match.group(1).lower()
|
|
28
|
+
if tag not in tags:
|
|
29
|
+
tags.append(tag)
|
|
30
|
+
|
|
31
|
+
title = _TAG_TOKEN.sub(" ", description)
|
|
32
|
+
title = _WHITESPACE.sub(" ", title).strip()
|
|
33
|
+
return title, tuple(tags)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def new_document_id(when: date, prefix: str) -> str:
|
|
37
|
+
return f"{prefix}{when:%Y%m%d}_{secrets.token_hex(4)}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def new_meeting_id(when: date) -> str:
|
|
41
|
+
return new_document_id(when, "m_")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def new_task_id(taken: Container[str]) -> str:
|
|
45
|
+
while True:
|
|
46
|
+
candidate = f"t_{secrets.token_hex(2)}"
|
|
47
|
+
if candidate not in taken:
|
|
48
|
+
return candidate
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from endpaper.core.errors import WorkspaceError
|
|
9
|
+
from endpaper.core.models import InitResult, Workspace
|
|
10
|
+
|
|
11
|
+
SUPPORTED_SCHEMA = 1
|
|
12
|
+
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
13
|
+
_GUIDANCE_TEMPLATES = {
|
|
14
|
+
"AGENTS.md": _TEMPLATES_DIR / "AGENTS.md.tmpl",
|
|
15
|
+
"CLAUDE.md": _TEMPLATES_DIR / "CLAUDE.md.tmpl",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_workspace(start: Path) -> Workspace:
|
|
20
|
+
current = start.resolve()
|
|
21
|
+
while True:
|
|
22
|
+
marker = current / ".endpaper" / "config.toml"
|
|
23
|
+
if marker.is_file():
|
|
24
|
+
_check_schema(marker)
|
|
25
|
+
return Workspace(root=current)
|
|
26
|
+
parent = current.parent
|
|
27
|
+
if parent == current:
|
|
28
|
+
break
|
|
29
|
+
current = parent
|
|
30
|
+
raise WorkspaceError(
|
|
31
|
+
"no workspace found in this directory or any parent. Run 'endpaper init' to create one."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_schema(marker: Path) -> None:
|
|
36
|
+
with marker.open("rb") as f:
|
|
37
|
+
data = tomllib.load(f)
|
|
38
|
+
schema = data.get("workspace", {}).get("schema")
|
|
39
|
+
if schema != SUPPORTED_SCHEMA:
|
|
40
|
+
raise WorkspaceError(
|
|
41
|
+
f"unsupported workspace schema {schema!r} in {marker}; "
|
|
42
|
+
f"this build only supports schema {SUPPORTED_SCHEMA}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_guidance_file(target: Path, name: str) -> bool:
|
|
47
|
+
"""Write one guidance file if absent. Returns True when written, False when an
|
|
48
|
+
existing file was left untouched. Uses O_EXCL so the guarantee is enforced by the
|
|
49
|
+
OS rather than a check-then-write race."""
|
|
50
|
+
template = _GUIDANCE_TEMPLATES[name].read_text(encoding="utf-8")
|
|
51
|
+
try:
|
|
52
|
+
fd = os.open(target / name, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
53
|
+
except FileExistsError:
|
|
54
|
+
return False
|
|
55
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
56
|
+
fh.write(template)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def init_workspace(target: Path) -> InitResult:
|
|
61
|
+
target = target.resolve()
|
|
62
|
+
endpaper_dir = target / ".endpaper"
|
|
63
|
+
if endpaper_dir.exists():
|
|
64
|
+
raise WorkspaceError(f"this directory is already an endpaper workspace: {target}")
|
|
65
|
+
|
|
66
|
+
(target / "meetings").mkdir(parents=True, exist_ok=True)
|
|
67
|
+
(target / "notes" / "daily").mkdir(parents=True, exist_ok=True)
|
|
68
|
+
(target / "tasks.md").touch(exist_ok=True)
|
|
69
|
+
|
|
70
|
+
written: list[str] = []
|
|
71
|
+
skipped: list[str] = []
|
|
72
|
+
for name in ("AGENTS.md", "CLAUDE.md"):
|
|
73
|
+
if _write_guidance_file(target, name):
|
|
74
|
+
written.append(name)
|
|
75
|
+
else:
|
|
76
|
+
skipped.append(name)
|
|
77
|
+
|
|
78
|
+
endpaper_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
now = datetime.now().replace(microsecond=0).isoformat()
|
|
80
|
+
(endpaper_dir / "config.toml").write_text(
|
|
81
|
+
f'[workspace]\nschema = {SUPPORTED_SCHEMA}\ncreated = "{now}"\n',
|
|
82
|
+
encoding="utf-8",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return InitResult(
|
|
86
|
+
workspace=Workspace(root=target), written=tuple(written), skipped=tuple(skipped)
|
|
87
|
+
)
|
endpaper/tui/__init__.py
ADDED
|
File without changes
|