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/editing.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from endpaper.core.models import EditableFile, SaveResult
|
|
10
|
+
|
|
11
|
+
_UPDATED_LINE = re.compile(r"^updated:.*$", re.MULTILINE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def stamp_updated(text: str, timestamp: str) -> tuple[str, bool]:
|
|
15
|
+
"""Replace the value on the frontmatter block's first `updated:` line, changing no
|
|
16
|
+
other byte. Returns (new_text, True) when stamped, (text, False) when the block or
|
|
17
|
+
the line could not be located. Never raises.
|
|
18
|
+
"""
|
|
19
|
+
if not text.startswith("---\n"):
|
|
20
|
+
return text, False
|
|
21
|
+
|
|
22
|
+
terminator = text.find("\n---", 3)
|
|
23
|
+
if terminator == -1:
|
|
24
|
+
return text, False
|
|
25
|
+
|
|
26
|
+
block = text[4 : terminator + 1]
|
|
27
|
+
match = _UPDATED_LINE.search(block)
|
|
28
|
+
if match is None:
|
|
29
|
+
return text, False
|
|
30
|
+
|
|
31
|
+
start = 4 + match.start()
|
|
32
|
+
end = 4 + match.end()
|
|
33
|
+
new_text = text[:start] + f"updated: {timestamp}" + text[end:]
|
|
34
|
+
return new_text, True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_for_edit(path: Path) -> EditableFile:
|
|
38
|
+
"""Read a file for editing: whole text including frontmatter, normalised to "\\n",
|
|
39
|
+
with the line-ending convention and trailing-newline state captured for restoration.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
OSError: the file cannot be read.
|
|
43
|
+
"""
|
|
44
|
+
with open(path, encoding="utf-8", errors="replace", newline="") as f:
|
|
45
|
+
raw = f.read()
|
|
46
|
+
|
|
47
|
+
first_break = raw.find("\n")
|
|
48
|
+
if first_break > 0 and raw[first_break - 1] == "\r":
|
|
49
|
+
newline = "\r\n"
|
|
50
|
+
else:
|
|
51
|
+
newline = "\n"
|
|
52
|
+
|
|
53
|
+
trailing_newline = raw.endswith("\n")
|
|
54
|
+
text = raw.replace("\r\n", "\n")
|
|
55
|
+
return EditableFile(path=path, text=text, newline=newline, trailing_newline=trailing_newline)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _apply_line_ending_policy(text: str, newline: str, trailing_newline: bool) -> str:
|
|
59
|
+
if newline != "\n":
|
|
60
|
+
text = text.replace("\n", newline)
|
|
61
|
+
has_trailing = text.endswith("\n")
|
|
62
|
+
if trailing_newline and not has_trailing:
|
|
63
|
+
text = text + newline
|
|
64
|
+
elif not trailing_newline and has_trailing:
|
|
65
|
+
text = text[: -len(newline)]
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_buffer(
|
|
70
|
+
path: Path,
|
|
71
|
+
text: str,
|
|
72
|
+
file: EditableFile,
|
|
73
|
+
*,
|
|
74
|
+
now: datetime | None = None,
|
|
75
|
+
) -> SaveResult:
|
|
76
|
+
"""Stamp `updated`, restore `file`'s line endings and trailing newline, and write
|
|
77
|
+
atomically to `path` via a same-directory temp file and os.replace.
|
|
78
|
+
|
|
79
|
+
Never raises on a write failure -- returns SaveResult(ok=False) with a
|
|
80
|
+
user-facing message, leaving the target byte-identical.
|
|
81
|
+
"""
|
|
82
|
+
assert path == file.path
|
|
83
|
+
|
|
84
|
+
when = now or datetime.now()
|
|
85
|
+
timestamp = when.replace(microsecond=0).isoformat()
|
|
86
|
+
stamped_text, stamped = stamp_updated(text, timestamp)
|
|
87
|
+
out_text = _apply_line_ending_policy(stamped_text, file.newline, file.trailing_newline)
|
|
88
|
+
|
|
89
|
+
tmp_path: Path | None = None
|
|
90
|
+
try:
|
|
91
|
+
with tempfile.NamedTemporaryFile(
|
|
92
|
+
"w", encoding="utf-8", newline="", dir=path.parent, delete=False, suffix=".tmp"
|
|
93
|
+
) as tmp_file:
|
|
94
|
+
tmp_file.write(out_text)
|
|
95
|
+
tmp_path = Path(tmp_file.name)
|
|
96
|
+
os.replace(tmp_path, path)
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
if tmp_path is not None and tmp_path.exists():
|
|
99
|
+
tmp_path.unlink()
|
|
100
|
+
return SaveResult(ok=False, saved_text="", stamped=False, message=str(exc))
|
|
101
|
+
return SaveResult(ok=True, saved_text=stamped_text, stamped=stamped, message="")
|
endpaper/core/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EndpaperError(Exception):
|
|
7
|
+
"""Base. Carries the exit code the CLI should use."""
|
|
8
|
+
|
|
9
|
+
exit_code: ClassVar[int]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NotFoundError(EndpaperError):
|
|
13
|
+
exit_code = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UsageError(EndpaperError):
|
|
17
|
+
exit_code = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkspaceError(EndpaperError):
|
|
21
|
+
exit_code = 3
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from endpaper.core.models import Document, ScanWarningReason
|
|
8
|
+
|
|
9
|
+
REQUIRED_KEYS = frozenset({"id", "type", "title", "tags", "created", "updated"})
|
|
10
|
+
|
|
11
|
+
_KEY_ORDER = ("id", "type", "title", "tags", "created", "updated")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FrontmatterError(Exception):
|
|
15
|
+
"""Internal. Raised on a structural frontmatter problem; never escapes scan_meetings."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, reason: ScanWarningReason, message: str) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.reason = reason
|
|
20
|
+
self.message = message
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _construct_scalar_as_str(loader: yaml.SafeLoader, node: yaml.Node) -> str:
|
|
24
|
+
return str(loader.construct_scalar(node)) # type: ignore[arg-type]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _TolerantLoader(yaml.SafeLoader):
|
|
28
|
+
"""Leaves YAML 1.1's bool/timestamp/float/int/null coercions as their original scalar text."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
for _tag in (
|
|
32
|
+
"tag:yaml.org,2002:bool",
|
|
33
|
+
"tag:yaml.org,2002:timestamp",
|
|
34
|
+
"tag:yaml.org,2002:float",
|
|
35
|
+
"tag:yaml.org,2002:int",
|
|
36
|
+
"tag:yaml.org,2002:null",
|
|
37
|
+
):
|
|
38
|
+
_TolerantLoader.add_constructor(_tag, _construct_scalar_as_str)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_frontmatter(text: str) -> dict[str, str | list[str]]:
|
|
42
|
+
try:
|
|
43
|
+
data = yaml.load(text, Loader=_TolerantLoader)
|
|
44
|
+
except yaml.YAMLError as exc:
|
|
45
|
+
raise FrontmatterError("malformed_yaml", f"invalid YAML: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
raise FrontmatterError("not_a_mapping", "frontmatter is not a mapping")
|
|
49
|
+
|
|
50
|
+
keys = set(data)
|
|
51
|
+
extra = keys - REQUIRED_KEYS
|
|
52
|
+
missing = REQUIRED_KEYS - keys
|
|
53
|
+
if extra:
|
|
54
|
+
raise FrontmatterError("unexpected_fields", f"unexpected fields: {sorted(extra)}")
|
|
55
|
+
if missing:
|
|
56
|
+
raise FrontmatterError("missing_fields", f"missing fields: {sorted(missing)}")
|
|
57
|
+
|
|
58
|
+
result: dict[str, str | list[str]] = {}
|
|
59
|
+
for key, value in data.items():
|
|
60
|
+
if key == "tags":
|
|
61
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
62
|
+
raise FrontmatterError("invalid_value", "tags must be a list of strings")
|
|
63
|
+
result[key] = list(value)
|
|
64
|
+
else:
|
|
65
|
+
if not isinstance(value, str):
|
|
66
|
+
raise FrontmatterError("invalid_value", f"{key} must be a scalar string")
|
|
67
|
+
result[key] = value
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _quoted(value: str) -> str:
|
|
72
|
+
return json.dumps(value, ensure_ascii=False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_frontmatter(document: Document) -> str:
|
|
76
|
+
type_field = _quoted(document.type) if document.type else '""'
|
|
77
|
+
tags_field = "[" + ", ".join(_quoted(tag) for tag in document.tags) + "]"
|
|
78
|
+
lines = [
|
|
79
|
+
"---",
|
|
80
|
+
f"id: {document.id}",
|
|
81
|
+
f"type: {type_field}",
|
|
82
|
+
f"title: {_quoted(document.title)}",
|
|
83
|
+
f"tags: {tags_field}",
|
|
84
|
+
f"created: {document.created}",
|
|
85
|
+
f"updated: {document.updated}",
|
|
86
|
+
"---",
|
|
87
|
+
"",
|
|
88
|
+
]
|
|
89
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from endpaper.core.documents import (
|
|
7
|
+
create_document,
|
|
8
|
+
filter_documents,
|
|
9
|
+
match_document,
|
|
10
|
+
scan_documents,
|
|
11
|
+
)
|
|
12
|
+
from endpaper.core.models import Collection, Document, DocumentFilter, ScanWarning, Workspace
|
|
13
|
+
|
|
14
|
+
MEETINGS = Collection("m_", "meetings", ("meetings",), frozenset())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_meeting(
|
|
18
|
+
workspace: Workspace,
|
|
19
|
+
description: str,
|
|
20
|
+
*,
|
|
21
|
+
type: str = "",
|
|
22
|
+
tags: Sequence[str] = (),
|
|
23
|
+
now: datetime | None = None,
|
|
24
|
+
) -> Document:
|
|
25
|
+
return create_document(workspace, MEETINGS, description, type=type, tags=tags, now=now)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def scan_meetings(workspace: Workspace) -> tuple[list[Document], list[ScanWarning]]:
|
|
29
|
+
return scan_documents(workspace, MEETINGS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def filter_meetings(meetings: Iterable[Document], f: DocumentFilter) -> list[Document]:
|
|
33
|
+
return filter_documents(meetings, f)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def match_meeting(meeting: Document, query: str) -> bool:
|
|
37
|
+
return match_document(meeting, query)
|
endpaper/core/models.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class Workspace:
|
|
11
|
+
root: Path
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def meetings_dir(self) -> Path:
|
|
15
|
+
return self.root / "meetings"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def notes_dir(self) -> Path:
|
|
19
|
+
return self.root / "notes"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def daily_dir(self) -> Path:
|
|
23
|
+
return self.root / "notes" / "daily"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def tasks_file(self) -> Path:
|
|
27
|
+
return self.root / "tasks.md"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Document:
|
|
32
|
+
id: str
|
|
33
|
+
path: Path
|
|
34
|
+
title: str
|
|
35
|
+
type: str
|
|
36
|
+
tags: tuple[str, ...]
|
|
37
|
+
created: str
|
|
38
|
+
updated: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Meeting = Document
|
|
42
|
+
Note = Document
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Collection:
|
|
47
|
+
id_prefix: str
|
|
48
|
+
create_dir: str
|
|
49
|
+
scan_dirs: tuple[str, ...]
|
|
50
|
+
reserved_types: frozenset[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class DailyNote:
|
|
55
|
+
path: Path
|
|
56
|
+
document: Document | None
|
|
57
|
+
created: bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ScanWarningReason = Literal[
|
|
61
|
+
"no_frontmatter",
|
|
62
|
+
"unterminated_frontmatter",
|
|
63
|
+
"malformed_yaml",
|
|
64
|
+
"not_a_mapping",
|
|
65
|
+
"missing_fields",
|
|
66
|
+
"unexpected_fields",
|
|
67
|
+
"invalid_value",
|
|
68
|
+
"task_unterminated_comment",
|
|
69
|
+
"task_malformed_comment",
|
|
70
|
+
"task_invalid_value",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class ScanWarning:
|
|
76
|
+
path: Path
|
|
77
|
+
reason: ScanWarningReason
|
|
78
|
+
message: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class DocumentFilter:
|
|
83
|
+
type: str | None = None
|
|
84
|
+
tags: tuple[str, ...] = ()
|
|
85
|
+
since: date | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
MeetingFilter = DocumentFilter
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class EditableFile:
|
|
93
|
+
path: Path
|
|
94
|
+
text: str
|
|
95
|
+
newline: str
|
|
96
|
+
trailing_newline: bool
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class SaveResult:
|
|
101
|
+
ok: bool
|
|
102
|
+
saved_text: str
|
|
103
|
+
stamped: bool
|
|
104
|
+
message: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class InitResult:
|
|
109
|
+
workspace: Workspace
|
|
110
|
+
written: tuple[str, ...]
|
|
111
|
+
skipped: tuple[str, ...]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class Task:
|
|
116
|
+
id: str | None
|
|
117
|
+
text: str
|
|
118
|
+
done: bool
|
|
119
|
+
type: str
|
|
120
|
+
tags: tuple[str, ...]
|
|
121
|
+
created: date | None
|
|
122
|
+
line: int
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class ParsedTasks:
|
|
127
|
+
tasks: tuple[Task, ...]
|
|
128
|
+
warnings: tuple[ScanWarning, ...]
|
|
129
|
+
lines: tuple[str, ...]
|
|
130
|
+
needs_id: tuple[int, ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class TaskFilter:
|
|
135
|
+
type: str | None = None
|
|
136
|
+
tags: tuple[str, ...] = ()
|
|
137
|
+
include_done: bool = False
|
endpaper/core/notes.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from endpaper.core.documents import _read_document, create_document, scan_documents
|
|
8
|
+
from endpaper.core.frontmatter import render_frontmatter
|
|
9
|
+
from endpaper.core.models import Collection, DailyNote, Document, ScanWarning, Workspace
|
|
10
|
+
from endpaper.core.text import new_document_id
|
|
11
|
+
|
|
12
|
+
NOTES = Collection("n_", "notes", ("notes",), frozenset({"daily"}))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_note(
|
|
16
|
+
workspace: Workspace,
|
|
17
|
+
description: str,
|
|
18
|
+
*,
|
|
19
|
+
type: str = "",
|
|
20
|
+
tags: Sequence[str] = (),
|
|
21
|
+
now: datetime | None = None,
|
|
22
|
+
) -> Document:
|
|
23
|
+
return create_document(workspace, NOTES, description, type=type, tags=tags, now=now)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scan_notes(workspace: Workspace) -> tuple[list[Document], list[ScanWarning]]:
|
|
27
|
+
return scan_documents(workspace, NOTES)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def open_daily_note(workspace: Workspace, *, now: datetime | None = None) -> DailyNote:
|
|
31
|
+
when = now or datetime.now()
|
|
32
|
+
path = workspace.daily_dir / f"{when:%Y/%m}" / f"{when:%Y-%m-%d}.md"
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
timestamp = when.replace(microsecond=0).isoformat()
|
|
36
|
+
date_str = when.strftime("%Y-%m-%d")
|
|
37
|
+
document = Document(
|
|
38
|
+
id=new_document_id(when.date(), NOTES.id_prefix),
|
|
39
|
+
path=path,
|
|
40
|
+
title=date_str,
|
|
41
|
+
type="daily",
|
|
42
|
+
tags=(),
|
|
43
|
+
created=timestamp,
|
|
44
|
+
updated=timestamp,
|
|
45
|
+
)
|
|
46
|
+
content = render_frontmatter(document)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
50
|
+
except FileExistsError:
|
|
51
|
+
return DailyNote(path=path, document=_read_document(path), created=False)
|
|
52
|
+
|
|
53
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
54
|
+
fh.write(content)
|
|
55
|
+
return DailyNote(path=path, document=document, created=True)
|