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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Horizontal, Vertical
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import Label, ListItem, ListView, Markdown
|
|
10
|
+
|
|
11
|
+
from endpaper.core.models import Document, Task
|
|
12
|
+
from endpaper.tui.command_bar import CommandBar
|
|
13
|
+
from endpaper.tui.rendering import render_preview_markdown
|
|
14
|
+
from endpaper.tui.status_bar import LIST_HELP, TASK_LIST_HELP, StatusBar, collection_indicator
|
|
15
|
+
|
|
16
|
+
EMPTY_STATE_MESSAGE = "No meetings yet. Press / then 'meeting <description>' to create one."
|
|
17
|
+
_NOTES_EMPTY_STATE_MESSAGE = (
|
|
18
|
+
"No notes yet. Press / then 'note' for today's note, or 'note <description>'."
|
|
19
|
+
)
|
|
20
|
+
_TASKS_EMPTY_STATE_MESSAGE = "No tasks yet. Press / then 'task <description>' to create one."
|
|
21
|
+
|
|
22
|
+
COLLECTIONS = ("meetings", "notes", "tasks")
|
|
23
|
+
_COLLECTION_LABELS = {"meetings": "Meetings", "notes": "Notes", "tasks": "Tasks"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _empty_state_message(active: str) -> str:
|
|
27
|
+
if active == "notes":
|
|
28
|
+
return _NOTES_EMPTY_STATE_MESSAGE
|
|
29
|
+
if active == "tasks":
|
|
30
|
+
return _TASKS_EMPTY_STATE_MESSAGE
|
|
31
|
+
return EMPTY_STATE_MESSAGE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DocumentRow(ListItem):
|
|
35
|
+
def __init__(self, document: Document) -> None:
|
|
36
|
+
super().__init__(Label(self._row_text(document)))
|
|
37
|
+
self.document = document
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def meeting(self) -> Document:
|
|
41
|
+
"""Feature 001 compatibility alias for `document`."""
|
|
42
|
+
return self.document
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _row_text(document: Document) -> str:
|
|
46
|
+
parts = [document.created[:10]]
|
|
47
|
+
if document.type:
|
|
48
|
+
parts.append(document.type)
|
|
49
|
+
parts.append(document.title)
|
|
50
|
+
if document.tags:
|
|
51
|
+
parts.append(",".join(document.tags))
|
|
52
|
+
return " ".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
MeetingRow = DocumentRow # alias, feature 001 compatibility
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TaskRow(ListItem):
|
|
59
|
+
def __init__(self, task: Task) -> None:
|
|
60
|
+
text = self._row_text(task)
|
|
61
|
+
if task.done:
|
|
62
|
+
text = f"[strike]{text}[/strike]"
|
|
63
|
+
super().__init__(Label(text))
|
|
64
|
+
self.record = task
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _row_text(task: Task) -> str:
|
|
68
|
+
parts = ["[x]" if task.done else "[ ]"]
|
|
69
|
+
if task.created:
|
|
70
|
+
parts.append(task.created.isoformat())
|
|
71
|
+
if task.type:
|
|
72
|
+
parts.append(task.type)
|
|
73
|
+
parts.append(task.text)
|
|
74
|
+
if task.tags:
|
|
75
|
+
parts.append(",".join(task.tags))
|
|
76
|
+
return " ".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CollectionRow(ListItem):
|
|
80
|
+
def __init__(self, name: str) -> None:
|
|
81
|
+
super().__init__(Label(_COLLECTION_LABELS[name]))
|
|
82
|
+
self.collection_name = name
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ListScreen(Screen[None]):
|
|
86
|
+
BINDINGS = [
|
|
87
|
+
("j", "cursor_down", "Down"),
|
|
88
|
+
("k", "cursor_up", "Up"),
|
|
89
|
+
("h", "focus_menu", "Menu"),
|
|
90
|
+
("left", "focus_menu", "Menu"),
|
|
91
|
+
("l", "focus_list", "List"),
|
|
92
|
+
("right", "focus_list", "List"),
|
|
93
|
+
("/", "open_command_bar", "Filter/command"),
|
|
94
|
+
("space", "toggle_task", "Toggle"),
|
|
95
|
+
("a", "toggle_show_done", "All"),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self._last_previewed_id: str | None = None
|
|
101
|
+
self._pending_error: str | None = None
|
|
102
|
+
|
|
103
|
+
def compose(self) -> ComposeResult:
|
|
104
|
+
with Horizontal(id="body"):
|
|
105
|
+
with Vertical(id="menu-pane"):
|
|
106
|
+
yield ListView(id="collection-menu")
|
|
107
|
+
with Vertical(id="list-pane"):
|
|
108
|
+
yield ListView(id="meeting-list")
|
|
109
|
+
with Vertical(id="preview-pane"):
|
|
110
|
+
yield Markdown(id="preview")
|
|
111
|
+
with Vertical(id="bottom-bar"):
|
|
112
|
+
yield CommandBar(id="command-bar")
|
|
113
|
+
yield StatusBar(LIST_HELP, id="status-bar")
|
|
114
|
+
|
|
115
|
+
async def on_mount(self) -> None:
|
|
116
|
+
menu = self.query_one("#collection-menu", ListView)
|
|
117
|
+
await menu.extend(CollectionRow(name) for name in COLLECTIONS)
|
|
118
|
+
self.query_one("#meeting-list", ListView).focus()
|
|
119
|
+
await self.refresh_rows()
|
|
120
|
+
|
|
121
|
+
async def on_screen_resume(self) -> None:
|
|
122
|
+
# Coming back from PreviewScreen: a document may have been created while we
|
|
123
|
+
# were away (command bar create lands straight in preview, without ever
|
|
124
|
+
# selecting a row), so the rows built at initial mount are potentially
|
|
125
|
+
# stale. Rebuild, preferring the document that was actually being previewed
|
|
126
|
+
# over whatever the list happened to have highlighted before it was opened.
|
|
127
|
+
await self.refresh_rows(select_id=self._last_previewed_id)
|
|
128
|
+
|
|
129
|
+
def _sync_menu_highlight(self) -> None:
|
|
130
|
+
menu = self.query_one("#collection-menu", ListView)
|
|
131
|
+
active = self.app.active # type: ignore[attr-defined]
|
|
132
|
+
index = COLLECTIONS.index(active)
|
|
133
|
+
if menu.index != index:
|
|
134
|
+
menu.index = index
|
|
135
|
+
|
|
136
|
+
async def refresh_rows(
|
|
137
|
+
self, *, select_id: str | None = None, reset_selection: bool = False
|
|
138
|
+
) -> None:
|
|
139
|
+
app = self.app
|
|
140
|
+
list_view = self.query_one("#meeting-list", ListView)
|
|
141
|
+
is_tasks = app.active == "tasks" # type: ignore[attr-defined]
|
|
142
|
+
|
|
143
|
+
if select_id is None and not reset_selection:
|
|
144
|
+
highlighted = list_view.highlighted_child
|
|
145
|
+
if isinstance(highlighted, DocumentRow):
|
|
146
|
+
select_id = highlighted.document.id
|
|
147
|
+
elif isinstance(highlighted, TaskRow):
|
|
148
|
+
select_id = highlighted.record.id
|
|
149
|
+
|
|
150
|
+
# The removal side of clear() is scheduled, not immediate -- awaiting it
|
|
151
|
+
# (and the mount below) keeps list_view._nodes in sync with what's on
|
|
152
|
+
# screen before `.index` is set, so the reactive's highlight watcher
|
|
153
|
+
# marks the row actually being displayed rather than one about to be
|
|
154
|
+
# removed or not yet mounted.
|
|
155
|
+
await list_view.clear()
|
|
156
|
+
items = cast(
|
|
157
|
+
"list[Document | Task]",
|
|
158
|
+
app.visible_tasks if is_tasks else app.visible_documents, # type: ignore[attr-defined]
|
|
159
|
+
)
|
|
160
|
+
if not items:
|
|
161
|
+
await list_view.append(ListItem(Label(_empty_state_message(app.active)))) # type: ignore[attr-defined]
|
|
162
|
+
else:
|
|
163
|
+
rows = [TaskRow(item) if is_tasks else DocumentRow(item) for item in items] # type: ignore[arg-type]
|
|
164
|
+
await list_view.extend(rows)
|
|
165
|
+
index = 0
|
|
166
|
+
if select_id is not None:
|
|
167
|
+
for i, item in enumerate(items):
|
|
168
|
+
if item.id == select_id:
|
|
169
|
+
index = i
|
|
170
|
+
break
|
|
171
|
+
list_view.index = index
|
|
172
|
+
self._sync_menu_highlight()
|
|
173
|
+
self._update_preview()
|
|
174
|
+
self._render_status()
|
|
175
|
+
|
|
176
|
+
def _update_preview(self) -> None:
|
|
177
|
+
list_view = self.query_one("#meeting-list", ListView)
|
|
178
|
+
preview = self.query_one("#preview", Markdown)
|
|
179
|
+
highlighted = list_view.highlighted_child
|
|
180
|
+
if isinstance(highlighted, DocumentRow):
|
|
181
|
+
preview.update(render_preview_markdown(highlighted.document.path, highlighted.document))
|
|
182
|
+
else:
|
|
183
|
+
preview.update("")
|
|
184
|
+
|
|
185
|
+
def _render_status(
|
|
186
|
+
self,
|
|
187
|
+
mode: str | None = None,
|
|
188
|
+
verb: str = "",
|
|
189
|
+
bar_open: bool = False,
|
|
190
|
+
error: str | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
status = self.query_one(StatusBar)
|
|
193
|
+
if error:
|
|
194
|
+
status.update(f"⚠ {error}")
|
|
195
|
+
return
|
|
196
|
+
if bar_open and mode:
|
|
197
|
+
label = f"[command: {verb}]" if mode == "command" else "[filter]"
|
|
198
|
+
status.update(f"{label} enter run esc cancel")
|
|
199
|
+
return
|
|
200
|
+
active = self.app.active # type: ignore[attr-defined]
|
|
201
|
+
help_text = TASK_LIST_HELP if active == "tasks" else LIST_HELP
|
|
202
|
+
text = f"{collection_indicator(active)} {help_text}"
|
|
203
|
+
warnings = len(self.app.warnings[active]) # type: ignore[attr-defined]
|
|
204
|
+
if warnings:
|
|
205
|
+
text += f" {warnings} warning{'s' if warnings != 1 else ''}"
|
|
206
|
+
status.update(text)
|
|
207
|
+
|
|
208
|
+
@on(ListView.Highlighted, "#meeting-list")
|
|
209
|
+
def _on_highlighted(self, event: ListView.Highlighted) -> None:
|
|
210
|
+
self._update_preview()
|
|
211
|
+
|
|
212
|
+
@on(ListView.Selected, "#meeting-list")
|
|
213
|
+
def _on_selected(self, event: ListView.Selected) -> None:
|
|
214
|
+
if isinstance(event.item, DocumentRow):
|
|
215
|
+
from endpaper.tui.preview_screen import PreviewScreen
|
|
216
|
+
|
|
217
|
+
document = event.item.document
|
|
218
|
+
self._last_previewed_id = document.id
|
|
219
|
+
self.app.push_screen(PreviewScreen(document.path, document))
|
|
220
|
+
|
|
221
|
+
@on(ListView.Highlighted, "#collection-menu")
|
|
222
|
+
async def _on_menu_highlighted(self, event: ListView.Highlighted) -> None:
|
|
223
|
+
item = event.item
|
|
224
|
+
if isinstance(item, CollectionRow) and item.collection_name != self.app.active: # type: ignore[attr-defined]
|
|
225
|
+
self.app.switch_collection(item.collection_name) # type: ignore[attr-defined]
|
|
226
|
+
await self.refresh_rows(reset_selection=True)
|
|
227
|
+
|
|
228
|
+
@on(ListView.Selected, "#collection-menu")
|
|
229
|
+
def _on_menu_selected(self, event: ListView.Selected) -> None:
|
|
230
|
+
self.query_one("#meeting-list", ListView).focus()
|
|
231
|
+
|
|
232
|
+
def _focused_list(self) -> ListView:
|
|
233
|
+
focused = self.focused
|
|
234
|
+
if isinstance(focused, ListView) and focused.id == "collection-menu":
|
|
235
|
+
return focused
|
|
236
|
+
return self.query_one("#meeting-list", ListView)
|
|
237
|
+
|
|
238
|
+
def action_cursor_down(self) -> None:
|
|
239
|
+
self._focused_list().action_cursor_down()
|
|
240
|
+
|
|
241
|
+
def action_cursor_up(self) -> None:
|
|
242
|
+
self._focused_list().action_cursor_up()
|
|
243
|
+
|
|
244
|
+
def action_focus_menu(self) -> None:
|
|
245
|
+
self.query_one("#collection-menu", ListView).focus()
|
|
246
|
+
|
|
247
|
+
def action_focus_list(self) -> None:
|
|
248
|
+
self.query_one("#meeting-list", ListView).focus()
|
|
249
|
+
|
|
250
|
+
def action_open_command_bar(self) -> None:
|
|
251
|
+
self.query_one(CommandBar).open()
|
|
252
|
+
|
|
253
|
+
async def action_toggle_task(self) -> None:
|
|
254
|
+
if self.app.active != "tasks": # type: ignore[attr-defined]
|
|
255
|
+
return
|
|
256
|
+
list_view = self.query_one("#meeting-list", ListView)
|
|
257
|
+
highlighted = list_view.highlighted_child
|
|
258
|
+
if not isinstance(highlighted, TaskRow) or highlighted.record.id is None:
|
|
259
|
+
return
|
|
260
|
+
task_id = highlighted.record.id
|
|
261
|
+
self.app.toggle_task_and_track(task_id) # type: ignore[attr-defined]
|
|
262
|
+
error = self.app.last_task_error # type: ignore[attr-defined]
|
|
263
|
+
await self.refresh_rows(select_id=task_id)
|
|
264
|
+
if error:
|
|
265
|
+
self._render_status(error=error)
|
|
266
|
+
|
|
267
|
+
async def action_toggle_show_done(self) -> None:
|
|
268
|
+
if self.app.active != "tasks": # type: ignore[attr-defined]
|
|
269
|
+
return
|
|
270
|
+
self.app.toggle_show_done() # type: ignore[attr-defined]
|
|
271
|
+
await self.refresh_rows()
|
|
272
|
+
|
|
273
|
+
@on(CommandBar.ModeChanged)
|
|
274
|
+
def _on_mode_changed(self, message: CommandBar.ModeChanged) -> None:
|
|
275
|
+
self._render_status(mode=message.mode, verb=message.verb, bar_open=True)
|
|
276
|
+
|
|
277
|
+
@on(CommandBar.FilterChanged)
|
|
278
|
+
async def _on_filter_changed(self, message: CommandBar.FilterChanged) -> None:
|
|
279
|
+
self.app.apply_filter(message.query) # type: ignore[attr-defined]
|
|
280
|
+
await self.refresh_rows()
|
|
281
|
+
|
|
282
|
+
@on(CommandBar.ClearRequested)
|
|
283
|
+
async def _on_clear_requested(self, message: CommandBar.ClearRequested) -> None:
|
|
284
|
+
self.app.apply_filter("") # type: ignore[attr-defined]
|
|
285
|
+
await self.refresh_rows()
|
|
286
|
+
|
|
287
|
+
@on(CommandBar.CreateRequested)
|
|
288
|
+
async def _on_create_requested(self, message: CommandBar.CreateRequested) -> None:
|
|
289
|
+
if message.kind == "task":
|
|
290
|
+
task = self.app.add_task_and_track( # type: ignore[attr-defined]
|
|
291
|
+
message.description, message.type
|
|
292
|
+
)
|
|
293
|
+
if task is not None:
|
|
294
|
+
self._pending_error = None
|
|
295
|
+
await self.refresh_rows(select_id=task.id)
|
|
296
|
+
else:
|
|
297
|
+
self._pending_error = self.app.last_create_error # type: ignore[attr-defined]
|
|
298
|
+
return
|
|
299
|
+
if message.kind == "note":
|
|
300
|
+
document = self.app.create_note_and_track( # type: ignore[attr-defined]
|
|
301
|
+
message.description, message.type
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
document = self.app.create_meeting_and_track( # type: ignore[attr-defined]
|
|
305
|
+
message.description, message.type
|
|
306
|
+
)
|
|
307
|
+
if document is not None:
|
|
308
|
+
from endpaper.tui.preview_screen import PreviewScreen
|
|
309
|
+
|
|
310
|
+
self._last_previewed_id = document.id
|
|
311
|
+
self._pending_error = None
|
|
312
|
+
self.app.push_screen(PreviewScreen(document.path, document))
|
|
313
|
+
else:
|
|
314
|
+
self._pending_error = self.app.last_create_error # type: ignore[attr-defined]
|
|
315
|
+
|
|
316
|
+
@on(CommandBar.DailyRequested)
|
|
317
|
+
def _on_daily_requested(self, message: CommandBar.DailyRequested) -> None:
|
|
318
|
+
daily = self.app.open_daily_note_and_track() # type: ignore[attr-defined]
|
|
319
|
+
from endpaper.tui.preview_screen import PreviewScreen
|
|
320
|
+
|
|
321
|
+
if daily.document is not None:
|
|
322
|
+
self._last_previewed_id = daily.document.id
|
|
323
|
+
self._pending_error = None
|
|
324
|
+
self.app.push_screen(PreviewScreen(daily.path, daily.document))
|
|
325
|
+
else:
|
|
326
|
+
self._pending_error = None
|
|
327
|
+
self.app.push_screen(
|
|
328
|
+
PreviewScreen(daily.path, None, note="frontmatter could not be read")
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
@on(CommandBar.CollectionRequested)
|
|
332
|
+
async def _on_collection_requested(self, message: CommandBar.CollectionRequested) -> None:
|
|
333
|
+
self.app.switch_collection(message.name) # type: ignore[attr-defined]
|
|
334
|
+
self._pending_error = None
|
|
335
|
+
await self.refresh_rows(reset_selection=True)
|
|
336
|
+
|
|
337
|
+
@on(CommandBar.BarError)
|
|
338
|
+
def _on_bar_error(self, message: CommandBar.BarError) -> None:
|
|
339
|
+
self._pending_error = message.message
|
|
340
|
+
|
|
341
|
+
@on(CommandBar.Closed)
|
|
342
|
+
def _on_command_bar_closed(self, message: CommandBar.Closed) -> None:
|
|
343
|
+
self._render_status(error=self._pending_error)
|
|
344
|
+
self._pending_error = None
|
|
345
|
+
self.query_one("#meeting-list", ListView).focus()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import Markdown
|
|
10
|
+
|
|
11
|
+
from endpaper.core.documents import _read_document
|
|
12
|
+
from endpaper.core.editing import load_for_edit
|
|
13
|
+
from endpaper.core.models import Document
|
|
14
|
+
from endpaper.tui.rendering import render_preview_markdown
|
|
15
|
+
from endpaper.tui.status_bar import PREVIEW_HELP, StatusBar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PreviewScreen(Screen[None]):
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("e", "edit", "Edit", show=True),
|
|
21
|
+
Binding("escape", "close_preview", "Back", show=True),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path, document: Document | None, *, note: str | None = None) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.path = path
|
|
27
|
+
self.document = document
|
|
28
|
+
self._note = note
|
|
29
|
+
self._resumed_once = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def meeting(self) -> Document | None:
|
|
33
|
+
"""Feature 001 compatibility alias for `document`."""
|
|
34
|
+
return self.document
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield Markdown(id="full-preview")
|
|
38
|
+
with Vertical(id="bottom-bar"):
|
|
39
|
+
yield StatusBar(PREVIEW_HELP, id="status-bar")
|
|
40
|
+
|
|
41
|
+
def on_mount(self) -> None:
|
|
42
|
+
self._update_content()
|
|
43
|
+
if self._note:
|
|
44
|
+
self.query_one(StatusBar).update(f"⚠ {self._note} {PREVIEW_HELP}")
|
|
45
|
+
|
|
46
|
+
def on_screen_resume(self) -> None:
|
|
47
|
+
# `ScreenResume` also fires once at the initial push, coincident with
|
|
48
|
+
# `on_mount`; only re-render on a genuine return to this screen (e.g.
|
|
49
|
+
# popping back from EditScreen after a save), FR-007.
|
|
50
|
+
if not self._resumed_once:
|
|
51
|
+
self._resumed_once = True
|
|
52
|
+
return
|
|
53
|
+
self.document = _read_document(self.path)
|
|
54
|
+
self._update_content()
|
|
55
|
+
|
|
56
|
+
def _update_content(self) -> None:
|
|
57
|
+
self.query_one("#full-preview", Markdown).update(
|
|
58
|
+
render_preview_markdown(self.path, self.document)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def action_edit(self) -> None:
|
|
62
|
+
from endpaper.tui.edit_screen import EditScreen
|
|
63
|
+
|
|
64
|
+
file = load_for_edit(self.path)
|
|
65
|
+
self.app.push_screen(EditScreen(file))
|
|
66
|
+
|
|
67
|
+
def action_close_preview(self) -> None:
|
|
68
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from endpaper.core.models import Document
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _strip_frontmatter(text: str) -> str:
|
|
9
|
+
if not text.startswith("---\n"):
|
|
10
|
+
return text
|
|
11
|
+
end = text.find("\n---", 3)
|
|
12
|
+
if end == -1:
|
|
13
|
+
return text
|
|
14
|
+
return text[end + 4 :].lstrip("\n")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_preview_markdown(path: Path, document: Document | None) -> str:
|
|
18
|
+
"""Build the markdown shown in the preview panes: a heading and metadata line,
|
|
19
|
+
never the raw frontmatter block (which is not valid standalone markdown and
|
|
20
|
+
collapses into a single paragraph if rendered directly).
|
|
21
|
+
|
|
22
|
+
When `document` is None (an existing file whose frontmatter does not parse),
|
|
23
|
+
falls back to a filename heading with no metadata line -- nothing is invented
|
|
24
|
+
for fields that could not be read."""
|
|
25
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
26
|
+
body = _strip_frontmatter(text)
|
|
27
|
+
|
|
28
|
+
if document is None:
|
|
29
|
+
heading = f"# {path.name}"
|
|
30
|
+
return f"{heading}\n\n{body}" if body else f"{heading}\n"
|
|
31
|
+
|
|
32
|
+
meta_parts = [document.created[:10]]
|
|
33
|
+
if document.type:
|
|
34
|
+
meta_parts.append(document.type)
|
|
35
|
+
if document.tags:
|
|
36
|
+
meta_parts.append(", ".join(f"#{tag}" for tag in document.tags))
|
|
37
|
+
meta = " · ".join(meta_parts)
|
|
38
|
+
|
|
39
|
+
heading = f"# {document.title}" if document.title else "# (untitled)"
|
|
40
|
+
if body:
|
|
41
|
+
return f"{heading}\n\n*{meta}*\n\n{body}"
|
|
42
|
+
return f"{heading}\n\n*{meta}*\n"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.widgets import Static
|
|
4
|
+
|
|
5
|
+
LIST_HELP = "/ filter or command ↑↓/jk move h/l pane enter open ctrl+q quit"
|
|
6
|
+
TASK_LIST_HELP = "/ filter or command ↑↓/jk move h/l pane space toggle a all ctrl+q quit"
|
|
7
|
+
PREVIEW_HELP = "e edit esc back ↑↓/pgup/pgdn scroll ctrl+q quit"
|
|
8
|
+
EDIT_HELP = "ctrl+o save ctrl+x save & back esc discard ctrl+q quit"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def collection_indicator(active: str) -> str:
|
|
12
|
+
return f"[{active}]"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StatusBar(Static):
|
|
16
|
+
pass
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: endpaper
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A corporate-friendly Markdown notes engine that makes your AI happy.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Ryan Stalets
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Requires-Dist: textual>=8.2
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
35
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# endpaper
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣤⣤⣤⣤⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
42
|
+
⠀⠀⠀⠀⢀⣠⣶⣾⡿⠿⢛⣛⣛⣛⡛⠻⠿⣿⣷⣦⡀⠀⠀⠀⠀⠀
|
|
43
|
+
⠀⠀⢀⣴⣿⡿⠛⠁⠀⣴⣿⣿⣿⣿⣿⣷⡄⠀⠉⠻⣿⣷⡀⠀⠀⠀
|
|
44
|
+
⠀⢀⣾⡿⠋⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⡧⠀⠀⠀⠈⠻⣿⣦⠀⠀
|
|
45
|
+
⢀⣿⡿⢁⣠⣤⣤⣤⣈⢿⣿⣿⣿⣿⣿⣿⠇⣠⣤⣤⣤⣀⠹⣿⣧⠀
|
|
46
|
+
⣼⣿⢳⣿⣿⣿⣿⣿⣿⣧⠙⢻⣿⣿⠛⢡⣾⣿⣿⣿⣿⣿⣷⣻⣿⡀
|
|
47
|
+
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
|
|
48
|
+
⣿⣿⠘⣿⣿⣿⣿⣿⣿⣿⡀⢸⣿⣿⠀⣸⣿⣿⣿⣿⣿⣿⡿⢹⣿⡇
|
|
49
|
+
⢿⣿⡄⠈⠙⠛⠟⠛⠙⢿⣿⣾⣿⣿⣾⣿⠟⠙⠛⠟⠛⠉⠀⣸⣿⠁
|
|
50
|
+
⠈⣿⣷⡀⠀⠀⠀⠀⠀⠀⠙⢿⣿⣿⡟⠃⠀⠀⠀⠀⠀⠀⣰⣿⡟⠀
|
|
51
|
+
⠀⠘⢿⣷⣄⠀⠀⠀⠀⠀⠀⢸⣿⣿⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠀⠀
|
|
52
|
+
⠀⠀⠈⠻⣿⣷⣄⡀⠀⠀⠀⢸⣿⣿⠀⠀⠀⠀⢀⣴⣿⡿⠃⠀⠀⠀
|
|
53
|
+
⠀⠀⠀⠀⠈⠙⠿⣿⣷⣶⣤⣼⣛⣻⣤⣤⣶⣾⡿⠟⠋⠀⠀⠀⠀⠀
|
|
54
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠛⠛⠛⠛⠛⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀
|
|
55
|
+
KEEP YOUR CORPO OVERLORDS HAPPY, FEED YOUR AI
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**A corporate-friendly Markdown notes engine that makes your AI happy.**
|
|
59
|
+
|
|
60
|
+
A local-only, terminal-based tool for capturing and organizing meeting notes, general notes, and tasks as plain markdown files — structured enough for a human to navigate through a TUI, and legible enough that an AI assistant can search and edit the vault through a CLI without any integration work.
|
|
61
|
+
|
|
62
|
+
## Why
|
|
63
|
+
|
|
64
|
+
Markdown is the native format of AI assistants — they read it, write it, diff it, and reason over it without an adapter layer. Almost nobody at a large company can actually work that way, because the sanctioned note-taking tools (OneNote, Confluence, SharePoint) lock notes behind proprietary formats and authenticated APIs, and markdown-first apps like Obsidian or Logseq are rarely on the approved-software list.
|
|
65
|
+
|
|
66
|
+
endpaper installs without admin rights, stores nothing outside a directory you already have, and needs no server or account — just a folder.
|
|
67
|
+
|
|
68
|
+
The tool disappears into the twenty seconds before a meeting starts. You type `/meeting.standup Q3 planning #platform`, a file exists with correct frontmatter and a date-stamped name, and you're typing notes before anyone has finished joining the call. You never decide where a file goes or what to call it.
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
### Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv tool install endpaper
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Create a workspace
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
mkdir notes && cd notes
|
|
82
|
+
endpaper init
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This creates `.endpaper/config.toml`, `AGENTS.md`, and `meetings/` in the current directory. This directory is now your workspace root — every command below runs from inside it.
|
|
86
|
+
|
|
87
|
+
### Capture a meeting
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
endpaper meeting new "Q3 planning" --type standup --tag platform
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Prints the path to the new file: `meetings/2026-07-28-standup-q3-planning.md`, already stamped with frontmatter (`id`, `type`, `title`, `tags`, `created`, `updated`).
|
|
94
|
+
|
|
95
|
+
### Browse and list
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
endpaper meeting list --json
|
|
99
|
+
endpaper meeting list --type standup --since 2026-07-01
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or launch the TUI with no arguments:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
endpaper
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
`/` opens a combined filter/command bar — type to filter the list live, or type `meeting.standup <description>` and hit `enter` to create one without leaving the screen. Arrow keys move, `enter` opens the selected meeting in a rendered markdown preview, `e` drops into a raw editor, `ctrl+o`/`ctrl+x` save, `esc` backs out.
|
|
111
|
+
|
|
112
|
+

|
|
113
|
+
|
|
114
|
+
### For AI assistants
|
|
115
|
+
|
|
116
|
+
An assistant working in an endpaper workspace reads `AGENTS.md` at the root and takes it from there — non-interactive, `--json`-capable, and scriptable by design. Nothing opens an editor or blocks on a prompt.
|
|
117
|
+
|
|
118
|
+
Below, an assistant is asked to pull up a vendor sync meeting, research the competitor options it calls for, and write up its findings — reading and writing the vault the same way it reads and writes a codebase:
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
The note it produced shows up like any other — because it is:
|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
See [REQUIREMENTS.md](REQUIREMENTS.md) for the full CLI reference, frontmatter schema, and exit codes.
|
|
127
|
+
|
|
128
|
+
## Features (v0.0.1)
|
|
129
|
+
|
|
130
|
+
- **Meeting notes** — `/meeting.standup Q3 planning #platform` creates a dated, frontmatter-tagged file and drops you straight into it. Browse with `/meetings`, filter live, open with `enter`.
|
|
131
|
+
- **General notes** — `/note` opens (or creates) today's daily note; `/note.research vendor landscape #procurement` creates a typed note. Browse with `/notes`.
|
|
132
|
+
- **Tasks** — `/task.followup send the vendor comparison #procurement` appends a checkbox line to `tasks.md`. `/tasks` lists open items; `space` toggles done. The file stays hand-editable plain markdown — no database.
|
|
133
|
+
- **Workspace init** — `endpaper init` sets up a workspace (config, `AGENTS.md`, `meetings/`, `notes/daily/`, `tasks.md`) in the current directory. Multi-user shared workspaces are planned but not in v0.0.1 — see [Roadmap](#roadmap).
|
|
134
|
+
- **View and edit** — every note or meeting opens in a rendered markdown preview (`enter`), switches to a raw editor (`e`) with line numbers and soft wrap, and saves with `ctrl+o` (stay) or `ctrl+x` (save and return). `esc` discards, but only prompts when there's something to lose. `ctrl+s` is also bound as a save alias, but `ctrl+o` is the canonical key: some terminals treat `ctrl+s` as the legacy XOFF flow-control signal and swallow it before it ever reaches endpaper. If saves with `ctrl+s` seem to do nothing, run `stty -ixon` in that shell (or use `ctrl+o` instead).
|
|
135
|
+
- **AI-friendly CLI** — every TUI action has a non-interactive CLI equivalent backed by the same core library: `endpaper find`, `read`, `write`, `append`, `--json` on every read command, meaningful exit codes, nothing that opens an editor or blocks on input.
|
|
136
|
+
- **No index, no database** — the markdown files are the only state. endpaper globs and parses the workspace in memory on launch; nothing to corrupt, nothing to reindex.
|
|
137
|
+
- **`AGENTS.md`** — generated at `init`, under ~60 lines, so an assistant landing in the workspace is productive immediately.
|
|
138
|
+
|
|
139
|
+
See [REQUIREMENTS.md](REQUIREMENTS.md) for the full v0.0.1 specification, including CLI syntax, frontmatter schema, and acceptance criteria. Not everything above has landed on `main` yet — check [CHANGELOG.md](CHANGELOG.md) for what's actually shipped so far.
|
|
140
|
+
|
|
141
|
+
## Roadmap
|
|
142
|
+
|
|
143
|
+
Planned for a future release, tracked in [REQUIREMENTS.md §6](REQUIREMENTS.md#6-backlog--future):
|
|
144
|
+
|
|
145
|
+
- **Multi-user shared workspaces** — `endpaper init <name>` inside a shared root (e.g. a OneDrive folder), `/workspace` switching, and cross-workspace search, so a team can share one root without a server.
|
|
146
|
+
|
|
147
|
+
Considered and explicitly out of scope for v0.0.1 (see [REQUIREMENTS.md §5](REQUIREMENTS.md#5-explicitly-out-of-scope-for-v001)):
|
|
148
|
+
|
|
149
|
+
- AI invocation from inside endpaper (`/claude` markers, SDK integration)
|
|
150
|
+
- Webcam or image capture (`/pic`)
|
|
151
|
+
- Embeddings, vector search, semantic retrieval
|
|
152
|
+
- Tasks created inline from inside a note or meeting
|
|
153
|
+
- Backlinks, wikilinks, graph views
|
|
154
|
+
- Syntax highlighting in the editor
|
|
155
|
+
|
|
156
|
+
## Status
|
|
157
|
+
|
|
158
|
+
Draft / pre-release. v0.0.1 targets Python 3.11+, installable via `uv tool install` or `pipx`, on Windows, macOS, and Linux with no network access required.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
endpaper/__init__.py,sha256=4GZKi13lDTD25YBkGakhZyEQZWTER_OWQMNPoH_UM2c,22
|
|
2
|
+
endpaper/__main__.py,sha256=248zEhKRj3QNlLA-1YuhIXUeKJEtpDk8YqgCX5LdOOk,92
|
|
3
|
+
endpaper/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
endpaper/cli/main.py,sha256=RqDOt6TIKcuJTDGjeEerRizahxCWpTVgUJvJp_AY24Y,10914
|
|
5
|
+
endpaper/cli/output.py,sha256=k4mjxNKFy6h8dZlRIESGnvFEP6Z8cmnSrVyV-W854sk,2245
|
|
6
|
+
endpaper/core/__init__.py,sha256=20vtZVedXzA4r4m0L1_bRAsBQ1hIuGd1FZ0NvrRSWkM,2137
|
|
7
|
+
endpaper/core/documents.py,sha256=BNhkfHUFCEtkYxuqCP6TUireMfhdjZBlpncEzkqt3do,6374
|
|
8
|
+
endpaper/core/editing.py,sha256=uUgyW3H7mWw5HblMUtdm20TchMsniRlgWpKxgGA3ukc,3360
|
|
9
|
+
endpaper/core/errors.py,sha256=SMX4YyVbljQ9L1dMWtXr_qDtoTIpOzsFzSlUTS0viN8,352
|
|
10
|
+
endpaper/core/frontmatter.py,sha256=wITj9jxfhTE8VSmqP4-KcYama-CPghQc_Q60estAW_0,2883
|
|
11
|
+
endpaper/core/meetings.py,sha256=uOWp3d0-xGGoEQehYBHA7mEKks49s8C5urVjZQF9CBc,1034
|
|
12
|
+
endpaper/core/models.py,sha256=BSoPZWa-SOz2LYqobspHbYPOeFWXMo2PDpRHtMpwEpk,2541
|
|
13
|
+
endpaper/core/notes.py,sha256=2KxKgzYoVBYuet9W4JSM1DcBDWk0Va2_3yxaGDPn--Q,1813
|
|
14
|
+
endpaper/core/tasks.py,sha256=nU68897E3urYkKuWqwPlPfr5-n8GjRCwXEiHKLGwTbM,13868
|
|
15
|
+
endpaper/core/text.py,sha256=E8SMlbIz9YY6rM2cAitom0iUWn9sqfzXNdAntsqs9-4,1414
|
|
16
|
+
endpaper/core/workspace.py,sha256=hPLVko-C9h22E7O_9emYkeA0B7wgeJcxD4X6CMBesqs,2910
|
|
17
|
+
endpaper/core/templates/AGENTS.md.tmpl,sha256=U-h1N8ZUQsMjXmlSoccMc2_A4z6ffYHINzL0WaoQmHM,2181
|
|
18
|
+
endpaper/core/templates/CLAUDE.md.tmpl,sha256=Nvcva6XbBG-Q_eYYXBsU4nBh8wPksi-i_t9mEne8awY,404
|
|
19
|
+
endpaper/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
endpaper/tui/app.py,sha256=H6XdC1-Cp9-d7GJTPNNFxP-tTeNc9B-q9okpEnBZAhQ,7261
|
|
21
|
+
endpaper/tui/app.tcss,sha256=3H9N3OiiR6U-ZGzUR4haz7bQh4PlHMMm7RgA-1eYYdA,944
|
|
22
|
+
endpaper/tui/command_bar.py,sha256=6fGft9qxr-Qeouzr88NJTxuNlX0zZRxC6Q1fN3FLcwk,5134
|
|
23
|
+
endpaper/tui/discard_dialog.py,sha256=lAwOnUy7kauSWrOl8DR9hRR6DeQ37lOLQH1Eih3M-Qc,798
|
|
24
|
+
endpaper/tui/edit_screen.py,sha256=uT4am_5XSFjC5SzlXU1rN3j5AFQS2OP0VXRoef1WXfA,3574
|
|
25
|
+
endpaper/tui/list_screen.py,sha256=yVoBHIbxf5MG1_vfHE1ukrWIJzRwO9vm5l_9aDMs6EM,13890
|
|
26
|
+
endpaper/tui/preview_screen.py,sha256=1rvgjwjdI9QpQDt8LueZWh2nTxVIUFGH-o1Fg_ZlMLI,2283
|
|
27
|
+
endpaper/tui/rendering.py,sha256=cFJe1EgHckq8fs3bFhWKjQhtbIsTTmtFpdg-r41GhG0,1458
|
|
28
|
+
endpaper/tui/status_bar.py,sha256=M6N5NqSzcCGp3NDMOnQhCSFQ8moKaefe8WU5VmgnG5U,524
|
|
29
|
+
endpaper-0.0.1.dist-info/METADATA,sha256=azu1xNomCCsAgbwUXCEAk7RxlsTG-2A0b3D-uiHiot4,9436
|
|
30
|
+
endpaper-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
31
|
+
endpaper-0.0.1.dist-info/entry_points.txt,sha256=6tKelpA5b6t77kYhKifvhzA4DzUsIGzuK6_42_rvpjA,52
|
|
32
|
+
endpaper-0.0.1.dist-info/licenses/LICENSE,sha256=K7CTx4RWZFy2NxLZVZEo_yXHb4OPrZB_FU1g64e84nk,1069
|
|
33
|
+
endpaper-0.0.1.dist-info/RECORD,,
|