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.
@@ -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="")
@@ -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)
@@ -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)