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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.3"
endpaper/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from endpaper.cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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