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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.3"
|
endpaper/__main__.py
ADDED
endpaper/cli/__init__.py
ADDED
|
File without changes
|
endpaper/cli/main.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from endpaper import __version__
|
|
9
|
+
from endpaper.cli.output import (
|
|
10
|
+
print_documents_json,
|
|
11
|
+
print_documents_table,
|
|
12
|
+
print_error,
|
|
13
|
+
print_tasks_json,
|
|
14
|
+
print_tasks_table,
|
|
15
|
+
relative_path,
|
|
16
|
+
)
|
|
17
|
+
from endpaper.core.documents import filter_documents
|
|
18
|
+
from endpaper.core.errors import EndpaperError, UsageError, WorkspaceError
|
|
19
|
+
from endpaper.core.meetings import create_meeting, scan_meetings
|
|
20
|
+
from endpaper.core.models import DocumentFilter, TaskFilter
|
|
21
|
+
from endpaper.core.notes import create_note, open_daily_note, scan_notes
|
|
22
|
+
from endpaper.core.tasks import add_task, filter_tasks, load_tasks, set_task_state
|
|
23
|
+
from endpaper.core.workspace import find_workspace, init_workspace
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="endpaper",
|
|
29
|
+
description="A corporate-friendly Markdown notes engine that makes your AI happy.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"endpaper {__version__}")
|
|
32
|
+
|
|
33
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
subparsers.add_parser("init", help="create a workspace in the current directory")
|
|
35
|
+
|
|
36
|
+
meeting_parser = subparsers.add_parser("meeting", help="create or list meetings")
|
|
37
|
+
meeting_subparsers = meeting_parser.add_subparsers(dest="meeting_command", required=True)
|
|
38
|
+
|
|
39
|
+
new_parser = meeting_subparsers.add_parser(
|
|
40
|
+
"new",
|
|
41
|
+
help="create a meeting",
|
|
42
|
+
description=(
|
|
43
|
+
"Create a meeting. NOTE: '#' starts a comment in bash and zsh, so an unquoted "
|
|
44
|
+
"#tag is silently stripped by the shell before endpaper ever sees it. Use --tag "
|
|
45
|
+
"instead, or put the #tag inside a quoted description."
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
new_parser.add_argument("description")
|
|
49
|
+
new_parser.add_argument("--type", default="")
|
|
50
|
+
new_parser.add_argument(
|
|
51
|
+
"--tag",
|
|
52
|
+
action="append",
|
|
53
|
+
default=[],
|
|
54
|
+
help="repeatable; the supported way to attach a tag on the command line",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
list_parser = meeting_subparsers.add_parser("list", help="list meetings")
|
|
58
|
+
list_parser.add_argument("--json", action="store_true")
|
|
59
|
+
list_parser.add_argument("--type")
|
|
60
|
+
list_parser.add_argument("--tag", action="append", default=[])
|
|
61
|
+
list_parser.add_argument("--since", help="ISO date, e.g. 2026-07-28; inclusive")
|
|
62
|
+
|
|
63
|
+
note_parser = subparsers.add_parser("note", help="create or list notes")
|
|
64
|
+
note_subparsers = note_parser.add_subparsers(dest="note_command", required=True)
|
|
65
|
+
|
|
66
|
+
note_subparsers.add_parser("today", help="open (creating if needed) today's daily note")
|
|
67
|
+
|
|
68
|
+
note_new_parser = note_subparsers.add_parser(
|
|
69
|
+
"new",
|
|
70
|
+
help="create a note",
|
|
71
|
+
description=(
|
|
72
|
+
"Create a note. NOTE: '#' starts a comment in bash and zsh, so an unquoted "
|
|
73
|
+
"#tag is silently stripped by the shell before endpaper ever sees it. Use --tag "
|
|
74
|
+
"instead, or put the #tag inside a quoted description."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
note_new_parser.add_argument("description")
|
|
78
|
+
note_new_parser.add_argument("--type", default="")
|
|
79
|
+
note_new_parser.add_argument(
|
|
80
|
+
"--tag",
|
|
81
|
+
action="append",
|
|
82
|
+
default=[],
|
|
83
|
+
help="repeatable; the supported way to attach a tag on the command line",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
note_list_parser = note_subparsers.add_parser("list", help="list notes")
|
|
87
|
+
note_list_parser.add_argument("--json", action="store_true")
|
|
88
|
+
note_list_parser.add_argument("--type")
|
|
89
|
+
note_list_parser.add_argument("--tag", action="append", default=[])
|
|
90
|
+
note_list_parser.add_argument("--since", help="ISO date, e.g. 2026-07-28; inclusive")
|
|
91
|
+
|
|
92
|
+
task_parser = subparsers.add_parser("task", help="capture, list, and complete tasks")
|
|
93
|
+
task_subparsers = task_parser.add_subparsers(dest="task_command", required=True)
|
|
94
|
+
|
|
95
|
+
task_add_parser = task_subparsers.add_parser(
|
|
96
|
+
"add",
|
|
97
|
+
help="capture a task",
|
|
98
|
+
description=(
|
|
99
|
+
"Capture a task. NOTE: '#' starts a comment in bash and zsh, so an unquoted "
|
|
100
|
+
"#tag is silently stripped by the shell before endpaper ever sees it. Use --tag "
|
|
101
|
+
"instead, or put the #tag inside a quoted description."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
task_add_parser.add_argument("description")
|
|
105
|
+
task_add_parser.add_argument("--type", default="")
|
|
106
|
+
task_add_parser.add_argument(
|
|
107
|
+
"--tag",
|
|
108
|
+
action="append",
|
|
109
|
+
default=[],
|
|
110
|
+
help="repeatable; the supported way to attach a tag on the command line",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
task_list_parser = task_subparsers.add_parser("list", help="list tasks")
|
|
114
|
+
task_list_parser.add_argument("--json", action="store_true")
|
|
115
|
+
task_list_parser.add_argument("--all", action="store_true", help="include completed tasks")
|
|
116
|
+
task_list_parser.add_argument("--type")
|
|
117
|
+
task_list_parser.add_argument("--tag", action="append", default=[])
|
|
118
|
+
|
|
119
|
+
task_done_parser = task_subparsers.add_parser("done", help="mark a task complete")
|
|
120
|
+
task_done_parser.add_argument("id")
|
|
121
|
+
|
|
122
|
+
task_undone_parser = task_subparsers.add_parser("undone", help="mark a task incomplete")
|
|
123
|
+
task_undone_parser.add_argument("id")
|
|
124
|
+
|
|
125
|
+
return parser
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_tui() -> int:
|
|
129
|
+
if not sys.stdout.isatty():
|
|
130
|
+
print_error("stdout is not a terminal; refusing to open the interface")
|
|
131
|
+
return WorkspaceError.exit_code
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
workspace = find_workspace(Path.cwd())
|
|
135
|
+
except WorkspaceError as exc:
|
|
136
|
+
print_error(str(exc))
|
|
137
|
+
return exc.exit_code
|
|
138
|
+
|
|
139
|
+
from endpaper.tui.app import EndpaperApp
|
|
140
|
+
|
|
141
|
+
EndpaperApp(workspace).run()
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_GUIDANCE_ADVICE = {
|
|
146
|
+
"AGENTS.md": "Add the commands and file format an assistant needs.",
|
|
147
|
+
"CLAUDE.md": "Add a line telling your assistant to read AGENTS.md.",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cmd_init() -> int:
|
|
152
|
+
result = init_workspace(Path.cwd())
|
|
153
|
+
print(str(result.workspace.root))
|
|
154
|
+
for name in result.skipped:
|
|
155
|
+
print(
|
|
156
|
+
f"note: {name} already exists and was left unchanged.\n {_GUIDANCE_ADVICE[name]}",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_since(value: str | None) -> date | None:
|
|
163
|
+
if not value:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return date.fromisoformat(value)
|
|
167
|
+
except ValueError:
|
|
168
|
+
raise UsageError(f"--since expects a date like 2026-07-28, got {value!r}") from None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_meeting_new(namespace: argparse.Namespace) -> int:
|
|
172
|
+
workspace = find_workspace(Path.cwd())
|
|
173
|
+
meeting = create_meeting(
|
|
174
|
+
workspace,
|
|
175
|
+
namespace.description,
|
|
176
|
+
type=namespace.type,
|
|
177
|
+
tags=tuple(namespace.tag),
|
|
178
|
+
)
|
|
179
|
+
print(relative_path(workspace, meeting))
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _cmd_meeting_list(namespace: argparse.Namespace) -> int:
|
|
184
|
+
workspace = find_workspace(Path.cwd())
|
|
185
|
+
meetings, warnings = scan_meetings(workspace)
|
|
186
|
+
for warning in warnings:
|
|
187
|
+
print_error(warning.message)
|
|
188
|
+
|
|
189
|
+
document_filter = DocumentFilter(
|
|
190
|
+
type=namespace.type,
|
|
191
|
+
tags=tuple(namespace.tag),
|
|
192
|
+
since=_parse_since(namespace.since),
|
|
193
|
+
)
|
|
194
|
+
filtered = filter_documents(meetings, document_filter)
|
|
195
|
+
|
|
196
|
+
if namespace.json:
|
|
197
|
+
print_documents_json(workspace, filtered)
|
|
198
|
+
else:
|
|
199
|
+
print_documents_table(workspace, filtered)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cmd_note_today() -> int:
|
|
204
|
+
workspace = find_workspace(Path.cwd())
|
|
205
|
+
daily = open_daily_note(workspace)
|
|
206
|
+
print(daily.path.relative_to(workspace.root).as_posix())
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cmd_note_new(namespace: argparse.Namespace) -> int:
|
|
211
|
+
workspace = find_workspace(Path.cwd())
|
|
212
|
+
note = create_note(
|
|
213
|
+
workspace,
|
|
214
|
+
namespace.description,
|
|
215
|
+
type=namespace.type,
|
|
216
|
+
tags=tuple(namespace.tag),
|
|
217
|
+
)
|
|
218
|
+
print(relative_path(workspace, note))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cmd_note_list(namespace: argparse.Namespace) -> int:
|
|
223
|
+
workspace = find_workspace(Path.cwd())
|
|
224
|
+
notes, warnings = scan_notes(workspace)
|
|
225
|
+
for warning in warnings:
|
|
226
|
+
print_error(warning.message)
|
|
227
|
+
|
|
228
|
+
document_filter = DocumentFilter(
|
|
229
|
+
type=namespace.type,
|
|
230
|
+
tags=tuple(namespace.tag),
|
|
231
|
+
since=_parse_since(namespace.since),
|
|
232
|
+
)
|
|
233
|
+
filtered = filter_documents(notes, document_filter)
|
|
234
|
+
|
|
235
|
+
if namespace.json:
|
|
236
|
+
print_documents_json(workspace, filtered)
|
|
237
|
+
else:
|
|
238
|
+
print_documents_table(workspace, filtered)
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _cmd_task_add(namespace: argparse.Namespace) -> int:
|
|
243
|
+
workspace = find_workspace(Path.cwd())
|
|
244
|
+
task = add_task(
|
|
245
|
+
workspace,
|
|
246
|
+
namespace.description,
|
|
247
|
+
type=namespace.type,
|
|
248
|
+
tags=tuple(namespace.tag),
|
|
249
|
+
)
|
|
250
|
+
print(task.id)
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _cmd_task_list(namespace: argparse.Namespace) -> int:
|
|
255
|
+
workspace = find_workspace(Path.cwd())
|
|
256
|
+
tasks, warnings = load_tasks(workspace)
|
|
257
|
+
for warning in warnings:
|
|
258
|
+
print_error(warning.message)
|
|
259
|
+
|
|
260
|
+
task_filter = TaskFilter(
|
|
261
|
+
type=namespace.type,
|
|
262
|
+
tags=tuple(namespace.tag),
|
|
263
|
+
include_done=namespace.all,
|
|
264
|
+
)
|
|
265
|
+
filtered = filter_tasks(tasks, task_filter)
|
|
266
|
+
|
|
267
|
+
if namespace.json:
|
|
268
|
+
print_tasks_json(filtered)
|
|
269
|
+
else:
|
|
270
|
+
print_tasks_table(filtered)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _cmd_task_done(namespace: argparse.Namespace) -> int:
|
|
275
|
+
workspace = find_workspace(Path.cwd())
|
|
276
|
+
set_task_state(workspace, namespace.id, done=True)
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _cmd_task_undone(namespace: argparse.Namespace) -> int:
|
|
281
|
+
workspace = find_workspace(Path.cwd())
|
|
282
|
+
set_task_state(workspace, namespace.id, done=False)
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _dispatch(namespace: argparse.Namespace) -> int:
|
|
287
|
+
if namespace.command == "init":
|
|
288
|
+
return _cmd_init()
|
|
289
|
+
if namespace.command == "meeting":
|
|
290
|
+
if namespace.meeting_command == "new":
|
|
291
|
+
return _cmd_meeting_new(namespace)
|
|
292
|
+
return _cmd_meeting_list(namespace)
|
|
293
|
+
if namespace.command == "note":
|
|
294
|
+
if namespace.note_command == "today":
|
|
295
|
+
return _cmd_note_today()
|
|
296
|
+
if namespace.note_command == "new":
|
|
297
|
+
return _cmd_note_new(namespace)
|
|
298
|
+
return _cmd_note_list(namespace)
|
|
299
|
+
if namespace.command == "task":
|
|
300
|
+
if namespace.task_command == "add":
|
|
301
|
+
return _cmd_task_add(namespace)
|
|
302
|
+
if namespace.task_command == "done":
|
|
303
|
+
return _cmd_task_done(namespace)
|
|
304
|
+
if namespace.task_command == "undone":
|
|
305
|
+
return _cmd_task_undone(namespace)
|
|
306
|
+
return _cmd_task_list(namespace)
|
|
307
|
+
raise UsageError(f"unknown command: {namespace.command}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main(argv: list[str] | None = None) -> int:
|
|
311
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
312
|
+
|
|
313
|
+
if not args:
|
|
314
|
+
return _run_tui()
|
|
315
|
+
|
|
316
|
+
parser = _build_parser()
|
|
317
|
+
try:
|
|
318
|
+
namespace = parser.parse_args(args)
|
|
319
|
+
except SystemExit as exc:
|
|
320
|
+
code = exc.code
|
|
321
|
+
if isinstance(code, int):
|
|
322
|
+
return code
|
|
323
|
+
return 0 if code is None else 1
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
return _dispatch(namespace)
|
|
327
|
+
except EndpaperError as exc:
|
|
328
|
+
print_error(str(exc))
|
|
329
|
+
return exc.exit_code
|
endpaper/cli/output.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from endpaper.core.models import Document, Task, Workspace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def relative_path(workspace: Workspace, document: Document) -> str:
|
|
11
|
+
return document.path.relative_to(workspace.root).as_posix()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_document_line(workspace: Workspace, document: Document) -> None:
|
|
15
|
+
print(
|
|
16
|
+
"\t".join(
|
|
17
|
+
[
|
|
18
|
+
document.created[:10],
|
|
19
|
+
document.type,
|
|
20
|
+
document.title,
|
|
21
|
+
",".join(document.tags),
|
|
22
|
+
]
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_documents_table(workspace: Workspace, documents: Iterable[Document]) -> None:
|
|
28
|
+
for document in documents:
|
|
29
|
+
print_document_line(workspace, document)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print_documents_json(workspace: Workspace, documents: Iterable[Document]) -> None:
|
|
33
|
+
records = [
|
|
34
|
+
{
|
|
35
|
+
"id": document.id,
|
|
36
|
+
"path": relative_path(workspace, document),
|
|
37
|
+
"title": document.title,
|
|
38
|
+
"type": document.type,
|
|
39
|
+
"tags": list(document.tags),
|
|
40
|
+
"created": document.created,
|
|
41
|
+
"updated": document.updated,
|
|
42
|
+
}
|
|
43
|
+
for document in documents
|
|
44
|
+
]
|
|
45
|
+
print(json.dumps(records, ensure_ascii=False))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_error(message: str) -> None:
|
|
49
|
+
print(f"endpaper: {message}", file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_task_line(task: Task) -> None:
|
|
53
|
+
print(
|
|
54
|
+
"\t".join(
|
|
55
|
+
[
|
|
56
|
+
task.id or "",
|
|
57
|
+
"done" if task.done else "open",
|
|
58
|
+
task.created.isoformat() if task.created else "-",
|
|
59
|
+
task.type,
|
|
60
|
+
task.text,
|
|
61
|
+
",".join(task.tags),
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def print_tasks_table(tasks: Iterable[Task]) -> None:
|
|
68
|
+
for task in tasks:
|
|
69
|
+
print_task_line(task)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def print_tasks_json(tasks: Iterable[Task]) -> None:
|
|
73
|
+
records = [
|
|
74
|
+
{
|
|
75
|
+
"id": task.id,
|
|
76
|
+
"text": task.text,
|
|
77
|
+
"done": task.done,
|
|
78
|
+
"type": task.type,
|
|
79
|
+
"tags": list(task.tags),
|
|
80
|
+
"created": task.created.isoformat() if task.created else None,
|
|
81
|
+
"line": task.line,
|
|
82
|
+
}
|
|
83
|
+
for task in tasks
|
|
84
|
+
]
|
|
85
|
+
print(json.dumps(records, ensure_ascii=False))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from endpaper.core.documents import (
|
|
2
|
+
create_document,
|
|
3
|
+
filter_documents,
|
|
4
|
+
match_document,
|
|
5
|
+
scan_documents,
|
|
6
|
+
)
|
|
7
|
+
from endpaper.core.editing import load_for_edit, save_buffer, stamp_updated
|
|
8
|
+
from endpaper.core.errors import EndpaperError, NotFoundError, UsageError, WorkspaceError
|
|
9
|
+
from endpaper.core.frontmatter import read_frontmatter, render_frontmatter
|
|
10
|
+
from endpaper.core.meetings import create_meeting, filter_meetings, match_meeting, scan_meetings
|
|
11
|
+
from endpaper.core.models import (
|
|
12
|
+
Collection,
|
|
13
|
+
DailyNote,
|
|
14
|
+
Document,
|
|
15
|
+
DocumentFilter,
|
|
16
|
+
EditableFile,
|
|
17
|
+
InitResult,
|
|
18
|
+
Meeting,
|
|
19
|
+
MeetingFilter,
|
|
20
|
+
Note,
|
|
21
|
+
ParsedTasks,
|
|
22
|
+
SaveResult,
|
|
23
|
+
ScanWarning,
|
|
24
|
+
Task,
|
|
25
|
+
TaskFilter,
|
|
26
|
+
Workspace,
|
|
27
|
+
)
|
|
28
|
+
from endpaper.core.notes import create_note, open_daily_note, scan_notes
|
|
29
|
+
from endpaper.core.tasks import (
|
|
30
|
+
add_task,
|
|
31
|
+
filter_tasks,
|
|
32
|
+
load_tasks,
|
|
33
|
+
match_task,
|
|
34
|
+
parse_tasks,
|
|
35
|
+
render_task_line,
|
|
36
|
+
set_task_state,
|
|
37
|
+
)
|
|
38
|
+
from endpaper.core.text import (
|
|
39
|
+
new_document_id,
|
|
40
|
+
new_meeting_id,
|
|
41
|
+
new_task_id,
|
|
42
|
+
parse_tags,
|
|
43
|
+
slugify,
|
|
44
|
+
)
|
|
45
|
+
from endpaper.core.workspace import find_workspace, init_workspace
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Collection",
|
|
49
|
+
"DailyNote",
|
|
50
|
+
"Document",
|
|
51
|
+
"DocumentFilter",
|
|
52
|
+
"EditableFile",
|
|
53
|
+
"EndpaperError",
|
|
54
|
+
"InitResult",
|
|
55
|
+
"Meeting",
|
|
56
|
+
"MeetingFilter",
|
|
57
|
+
"Note",
|
|
58
|
+
"NotFoundError",
|
|
59
|
+
"ParsedTasks",
|
|
60
|
+
"SaveResult",
|
|
61
|
+
"ScanWarning",
|
|
62
|
+
"Task",
|
|
63
|
+
"TaskFilter",
|
|
64
|
+
"UsageError",
|
|
65
|
+
"Workspace",
|
|
66
|
+
"WorkspaceError",
|
|
67
|
+
"add_task",
|
|
68
|
+
"create_document",
|
|
69
|
+
"create_meeting",
|
|
70
|
+
"create_note",
|
|
71
|
+
"filter_documents",
|
|
72
|
+
"filter_meetings",
|
|
73
|
+
"filter_tasks",
|
|
74
|
+
"find_workspace",
|
|
75
|
+
"init_workspace",
|
|
76
|
+
"load_for_edit",
|
|
77
|
+
"load_tasks",
|
|
78
|
+
"match_document",
|
|
79
|
+
"match_meeting",
|
|
80
|
+
"match_task",
|
|
81
|
+
"new_document_id",
|
|
82
|
+
"new_meeting_id",
|
|
83
|
+
"new_task_id",
|
|
84
|
+
"open_daily_note",
|
|
85
|
+
"parse_tags",
|
|
86
|
+
"parse_tasks",
|
|
87
|
+
"read_frontmatter",
|
|
88
|
+
"render_frontmatter",
|
|
89
|
+
"render_task_line",
|
|
90
|
+
"save_buffer",
|
|
91
|
+
"scan_documents",
|
|
92
|
+
"scan_meetings",
|
|
93
|
+
"scan_notes",
|
|
94
|
+
"set_task_state",
|
|
95
|
+
"slugify",
|
|
96
|
+
"stamp_updated",
|
|
97
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from endpaper.core.errors import UsageError
|
|
11
|
+
from endpaper.core.frontmatter import FrontmatterError, read_frontmatter, render_frontmatter
|
|
12
|
+
from endpaper.core.models import Collection, Document, DocumentFilter, ScanWarning, Workspace
|
|
13
|
+
from endpaper.core.text import new_document_id, parse_tags, slugify
|
|
14
|
+
|
|
15
|
+
_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate_token(value: str, flag: str) -> str:
|
|
19
|
+
if not _TOKEN_PATTERN.match(value):
|
|
20
|
+
raise UsageError(f"--{flag} may not contain '/', '\\', '.', or start with '-'")
|
|
21
|
+
return value.lower()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_document(
|
|
25
|
+
workspace: Workspace,
|
|
26
|
+
collection: Collection,
|
|
27
|
+
description: str,
|
|
28
|
+
*,
|
|
29
|
+
type: str = "",
|
|
30
|
+
tags: Sequence[str] = (),
|
|
31
|
+
now: datetime | None = None,
|
|
32
|
+
) -> Document:
|
|
33
|
+
when = now or datetime.now()
|
|
34
|
+
title, inline_tags = parse_tags(description)
|
|
35
|
+
if not title:
|
|
36
|
+
raise UsageError("description must not be empty after removing #tag tokens")
|
|
37
|
+
|
|
38
|
+
normalized_type = _validate_token(type, "type") if type else ""
|
|
39
|
+
if normalized_type in collection.reserved_types:
|
|
40
|
+
raise UsageError(
|
|
41
|
+
f"type '{normalized_type}' is reserved; use 'endpaper note today' for the daily note"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
merged_tags: list[str] = []
|
|
45
|
+
for tag in (*tags, *inline_tags):
|
|
46
|
+
normalized = _validate_token(tag, "tag")
|
|
47
|
+
if normalized not in merged_tags:
|
|
48
|
+
merged_tags.append(normalized)
|
|
49
|
+
|
|
50
|
+
document_id = new_document_id(when.date(), collection.id_prefix)
|
|
51
|
+
timestamp = when.replace(microsecond=0).isoformat()
|
|
52
|
+
slug = slugify(title)
|
|
53
|
+
date_str = when.strftime("%Y-%m-%d")
|
|
54
|
+
stem = "-".join([date_str, *([normalized_type] if normalized_type else []), slug])
|
|
55
|
+
|
|
56
|
+
create_dir = workspace.root / collection.create_dir / f"{when:%Y/%m}"
|
|
57
|
+
create_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
partial = Document(
|
|
60
|
+
id=document_id,
|
|
61
|
+
path=create_dir / f"{stem}.md",
|
|
62
|
+
title=title,
|
|
63
|
+
type=normalized_type,
|
|
64
|
+
tags=tuple(merged_tags),
|
|
65
|
+
created=timestamp,
|
|
66
|
+
updated=timestamp,
|
|
67
|
+
)
|
|
68
|
+
content = render_frontmatter(partial)
|
|
69
|
+
|
|
70
|
+
suffix = 1
|
|
71
|
+
while True:
|
|
72
|
+
filename = f"{stem}.md" if suffix == 1 else f"{stem}-{suffix}.md"
|
|
73
|
+
candidate = create_dir / filename
|
|
74
|
+
try:
|
|
75
|
+
fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
76
|
+
except FileExistsError:
|
|
77
|
+
suffix += 1
|
|
78
|
+
continue
|
|
79
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
80
|
+
fh.write(content)
|
|
81
|
+
return replace(partial, path=candidate)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_document(text: str, path: Path) -> tuple[Document | None, ScanWarning | None]:
|
|
85
|
+
"""Parse one file's already-read text. Returns (document, None) on success or
|
|
86
|
+
(None, warning) on any structural problem -- never raises."""
|
|
87
|
+
if not text.startswith("---\n"):
|
|
88
|
+
return None, ScanWarning(
|
|
89
|
+
path=path,
|
|
90
|
+
reason="no_frontmatter",
|
|
91
|
+
message=f"{path.name}: does not start with a frontmatter block",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
terminator = text.find("\n---", 3)
|
|
95
|
+
if terminator == -1:
|
|
96
|
+
return None, ScanWarning(
|
|
97
|
+
path=path,
|
|
98
|
+
reason="unterminated_frontmatter",
|
|
99
|
+
message=f"{path.name}: frontmatter block is never terminated",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
block = text[4 : terminator + 1]
|
|
103
|
+
try:
|
|
104
|
+
data = read_frontmatter(block)
|
|
105
|
+
except FrontmatterError as exc:
|
|
106
|
+
return None, ScanWarning(
|
|
107
|
+
path=path, reason=exc.reason, message=f"{path.name}: {exc.message}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
type_value = str(data["type"])
|
|
111
|
+
tag_values = [str(t) for t in data["tags"]]
|
|
112
|
+
|
|
113
|
+
if type_value and not _TOKEN_PATTERN.match(type_value):
|
|
114
|
+
return None, ScanWarning(
|
|
115
|
+
path=path, reason="invalid_value", message=f"{path.name}: invalid type {type_value!r}"
|
|
116
|
+
)
|
|
117
|
+
if any(not _TOKEN_PATTERN.match(tag) for tag in tag_values):
|
|
118
|
+
return None, ScanWarning(
|
|
119
|
+
path=path,
|
|
120
|
+
reason="invalid_value",
|
|
121
|
+
message=f"{path.name}: invalid tag in {tag_values!r}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
document = Document(
|
|
125
|
+
id=str(data["id"]),
|
|
126
|
+
path=path,
|
|
127
|
+
title=str(data["title"]),
|
|
128
|
+
type=type_value,
|
|
129
|
+
tags=tuple(tag_values),
|
|
130
|
+
created=str(data["created"]),
|
|
131
|
+
updated=str(data["updated"]),
|
|
132
|
+
)
|
|
133
|
+
return document, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_document(path: Path) -> Document | None:
|
|
137
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
138
|
+
document, _ = _parse_document(text, path)
|
|
139
|
+
return document
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def scan_documents(
|
|
143
|
+
workspace: Workspace,
|
|
144
|
+
collection: Collection,
|
|
145
|
+
) -> tuple[list[Document], list[ScanWarning]]:
|
|
146
|
+
documents: list[Document] = []
|
|
147
|
+
warnings: list[ScanWarning] = []
|
|
148
|
+
|
|
149
|
+
for scan_dir in collection.scan_dirs:
|
|
150
|
+
directory = workspace.root / scan_dir
|
|
151
|
+
if not directory.is_dir():
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
for path in sorted(directory.rglob("*.md")):
|
|
155
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
156
|
+
document, warning = _parse_document(text, path)
|
|
157
|
+
if document is not None:
|
|
158
|
+
documents.append(document)
|
|
159
|
+
else:
|
|
160
|
+
assert warning is not None
|
|
161
|
+
warnings.append(warning)
|
|
162
|
+
|
|
163
|
+
documents.sort(key=lambda d: str(d.path))
|
|
164
|
+
documents.sort(key=lambda d: d.created, reverse=True)
|
|
165
|
+
return documents, warnings
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def filter_documents(documents: Iterable[Document], f: DocumentFilter) -> list[Document]:
|
|
169
|
+
results: list[Document] = []
|
|
170
|
+
for document in documents:
|
|
171
|
+
if f.type is not None and document.type.lower() != f.type.lower():
|
|
172
|
+
continue
|
|
173
|
+
if f.tags:
|
|
174
|
+
document_tags = {tag.lower() for tag in document.tags}
|
|
175
|
+
if not all(tag.lower() in document_tags for tag in f.tags):
|
|
176
|
+
continue
|
|
177
|
+
if f.since is not None:
|
|
178
|
+
created_date = date.fromisoformat(document.created[:10])
|
|
179
|
+
if created_date < f.since:
|
|
180
|
+
continue
|
|
181
|
+
results.append(document)
|
|
182
|
+
return results
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def match_document(document: Document, query: str) -> bool:
|
|
186
|
+
haystack = " ".join([document.title, document.type, *document.tags]).lower()
|
|
187
|
+
return query.lower() in haystack
|