diff --git a/README.md b/README.md index 29aa259..cf7b6b6 100644 --- a/README.md +++ b/README.md @@ -22,187 +22,40 @@ Top-level config sections: - `filter` for custom `--filter-` query snippets - `order-by` for custom `--order-by-` query snippets - `with` for custom `--with-` query snippets +- `capture` for named task capture templates +- `agenda` for named agenda views +- `board` for named board views -Custom switch argument handling: - -- If a custom query contains `$arg`, the generated CLI switch requires exactly one argument. -- If `$arg` is not present, the generated switch does not require an argument. - -```yaml -defaults: - --done-states: DONE,CANCELLED,DELEGATED - --buckets: 80 - --filter-priority: A - --mapping: examples/mapping_example.json - --exclude: examples/exclude_example.txt -filter: - level-above: select(.level > $arg) - has-todo: select(.todo != none) -order-by: - recent-first: sort_by(.repeats + .deadline + .closed + .scheduled | max) -with: - priority-value: .properties.priority_value = .priority -``` +See [docs/index.md](docs/index.md) for the full config layout. ## Commands -### `org tasks query` - -Run jq-style queries over your Org Mode tasks. - -```bash -# Expand all nodes, select completed tasks, extract heading, return a page of results -poetry run org tasks query '[ .[][] | select(.todo in $done_states) | .title_text ][$offset: $offset + $limit]' \ - --done-states DONE,CANCELLED \ - --max-results 10 \ - --offset 10 \ - examples/ARCHIVE_small -``` - -```text -Fix broken data endpoint in spartan sensor mesh on ESP8266 when there's too large JSON to stringify. -Replace the humidity/temperature sensors on the inside sensor with CO sensor. -... -``` +### `tasks` -Query language reference: [docs/query_language.md](docs/query_language.md) +- `org tasks add` - Create a new task heading and insert it into an Org file. +- `org tasks update` - Update one or more matched task headings and save the changes. +- `org tasks remove` - Delete one or more matched task headings and their subtrees. +- `org tasks capture` - Create a task from a configured capture template. +- `org tasks list` - List matching tasks in interactive, static, or exported form. +- `org tasks query` - Run jq-style queries over loaded Org task data. + Query language reference: [docs/query_language.md](docs/query_language.md) -### `org stats all` - -Show a full overview: global task stats, top tasks, top tags, and groups. - -```bash -poetry run org stats all \ - --use tags \ - --max-results 5 \ - --max-tags 3 \ - --max-groups 2 \ - --max-relations 3 \ - examples/ARCHIVE_small -``` - -```text -2023-10-19 2023-11-14 -┊▂ ▂ ▂ ▂ ▆ █ ▄ ▄ ▂ ▄ █ ▄ ▄ ▂ ▄ █ ┊ 4 (2023-10-26) -‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -Total tasks: 33 -Unique tasks: 32 -Average tasks per day: 1.22 -Max tasks on a single day: 4 -Max repeats of a single task: 2 - -Task states: - DONE ┊████████████████████████████████████████████████████████████████████████ 30 - CANCELLED┊██ 1 - DELEGATED┊ 0 - TODO ┊██ 1 - SUSPENDED┊██ 1 -... -``` +### `stats` -### `org stats summary` +- `org stats summary` - Show task-only summary metrics and histograms. +- `org stats tags` - Show statistics for selected tags or top tag results. +- `org stats groups` - Show statistics for explicit or discovered tag groups. +- `org stats all` - Show the combined overview of tasks, tags, and groups. -Show task-only metrics and histograms. +### `agenda` -```bash -poetry run org stats summary \ - --with-tags-as-category \ - --filter-date-from 2023-10-20 \ - --filter-date-until 2023-11-15 \ - examples/ARCHIVE_small -``` - -### `org stats tags` - -Show focused stats for selected tags or top tags. - -```bash -poetry run org stats tags \ - --show Debugging,Jira \ - --max-relations 3 \ - --filter-not-completed \ - examples/ARCHIVE_small -``` - -### `org stats groups` - -Show stats for explicit or discovered tag groups. - -```bash -poetry run org stats groups \ - --group Debugging,Erlang \ - --group ProjectManagement,Jira \ - --max-results 2 \ - examples/ARCHIVE_small -``` +- `org agenda` - Show a day-oriented interactive agenda with scheduling and deadline views. -### `org tasks list` +### `board` -List matching tasks with filters and ordering. +- `org board` - Show an interactive board view of tasks using configured or fallback columns. -```bash -poetry run org tasks list \ - --filter-completed \ - --order-by-level \ - --order-by-timestamp-asc \ - --max-results 5 \ - --offset 20 \ - examples/ARCHIVE_small -``` - -```text -examples/ARCHIVE_small: * DONE Prepare some stories for refinement. :Jira:ProjectManagement: -examples/ARCHIVE_small: * DONE Reorganize tickets on the CHAT project board.:Jira:ProjectManagement: -... -``` - -### `org board` - -Display matching tasks as a workflow-style board with one column per todo state plus `COMPLETED`. - -```bash -poetry run org board \ - --todo-states TODO,WAITING,INPROGRESS \ - --done-states DONE,CANCELLED \ - examples/ARCHIVE_small -``` - -### `org tasks add` - -Create a new heading in an Org file. - -```bash -poetry run org tasks add \ - --todo TODO \ - --title "Update docs" \ - --tag Docs \ - --body "Refresh command documentation" \ - ROADMAP.org -``` - -### `org tasks update` - -Update one task heading selected by query ID or title. - -```bash -poetry run org tasks update \ - --query-id task-1 \ - --todo DONE \ - --closed "<2026-04-13>" \ - ROADMAP.org -``` - -### `org tasks remove` - -Delete one task heading (including its subtree) by title or ID. - -```bash -poetry run org tasks remove \ - --title "Update docs" \ - ROADMAP.org -``` -## Notes +## Roadmap -- Command docs and defaults: [docs/index.md](docs/index.md) - Current roadmap: [VIBES.org](VIBES.org) diff --git a/VIBES.org b/VIBES.org index 9db7c11..baa28d9 100644 --- a/VIBES.org +++ b/VIBES.org @@ -6932,6 +6932,19 @@ Perform steps in a loop while there are any tasks available for stepping. - =--log= - file to log the transitions into - =--workflow= to specify which configured workflow to use. +*** Embed a rules-based engine into the workflow +- Each workflow step is a rule. +- Org files are the store. +- Support unification in the query language (=x as $v= and =$v as x= and =$v as $u= and =x as y= should unify the variable/value with the other value/variable). Bindings should be available in the rule body. +- Use a proper DSL for the rules: +#+begin_example +rule Init (.todo as $foo and $foo as null): + .id = .category + "-" + str([.document.all_headings[] | select(.id != null)] | length); + .todo = "TODO" +#+end_example +- Embed a Rete/datalog engine? +- Alternatively, when the store changes, reload the file, compute a delta and rerun the rules on just the affected tasks. + *** CANCELLED Flow refine command :LOGBOOK: - State "CANCELLED" from [2026-05-05 Tue 18:47] \\ @@ -7243,12 +7256,13 @@ When search is enabled in the board, the currently visibla tasks for each column It would be really nice if the search results could be updated live, whenever any input is entered into the search prompt. Update all interactive commands that permit search to live-update during search input prompting. -*** All interactive commands should support the =--width= CLI switch +*** DONE All interactive commands should support the =--width= CLI switch +:LOGBOOK: +- State "DONE" from [2026-06-07 Sun 10:26] +:END: *** Task IDs The tasks IDs of newly created tasks should default to ={{document_category}}-{{count_of_headings_in_file + 1}}= Show the task ID (when present) in the board task panels as well as agenda and task list. In all cases, it should preceed the TODO state. -*** Bulk actions -It should be possible to mark multiple items in agenda, board & task list and apply an action to them in bulk *** Task availability indicators The agenda and board commands should accept a =--workflow NAME= CLI switch that adds the following task availability indicators: @@ -7259,7 +7273,6 @@ The agenda list should have a way to indicate that a task is blocked or doesn't To convey that, the task line should be grayed out in the agenda time-breakdown (other sections should not be affected). When hovering a task line in the time-breakdown section of the agenda, if the task is blocked in the =.is_blocked= sense, or some of its workflow requirements aren't met, a suitable message should be displayed in the status bar. Use the =info= property of the requirement or a generic =Task is blocked= string. *** Do not add =#+TODO:= line to file contents, but rather modify done_states. - *** Better heading & body parsing Use stripped =RichText= for body/heading tags. *** Parallelize document processing @@ -7340,19 +7353,882 @@ Please don't leave unused arguments in functions, if an argument is no longer ne *** Mark as completed should respect repeaters The timestamp advancement should only be used if the new state is a completed state. In such a case, the state should be updated to the first TODO state defined for the document, a logbook repeat should be added to the task (`State {node.todo} to {new_state}`) and the repeaters should be advanced according to its rules. -** Codebase improvements :Cleanup: -*** Extract the =stats= command wrapper into a separate module -*** Refactor the task CRUD utils into a separate module re-used by all the commands -*** Refactor config handling so that no global state is mutated +*** TODO Timestamps without time components should be preserved in math ops :Bug: +Running this query: ~timestamp("<2026-06-06>") + days(1)~ should produce a timestamp without a time component as the original date did not have it. + +*** Show configured filters/orderings/mutators in the command help strings +*** Ability to add extra filter/order-by queries in interactive commands +Commands should support the following key bindings: +- `Shift-f` for filtering +- `Shift-o` for order-by +- `Shift-m` for mutators + +Show a modal with a multi-selection list for all entities in each category. The user should be able to select multiple filters, and each state transition should result in the entity being applied to the org mode files. +*** Mor uniform key bindings +- =Control + f= for search. +- No need for =x= if search can be set to blank. +- Drop =n=, =p=, =b= and =f= for agenda navigation. +- Support =t= for todo and =g= for tags in all commands. +- Support =s=, =c=, =d= for planning in all commands. +- Support =r= for refile and =$= for archive in all commands. +- Support =k= for clocking and =e= for effort estimation. +- Support =C- + +** TODO Codebase improvements :Cleanup: +*** DONE Extract the =stats= command wrapper into a separate module +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-02 Tue 20:06] +:END: +Organize it the same as the other commands (most recentry modified: agenda, board and tasks list). + *** Avoid using =getattr= and =setattr= like the plague Refactor all places that currently use either of these functions so that the concrete type is known and concrete field is accessed. Introduce linter rules that make sure these function cannot be used. -*** Avoid monkey patching in tests -*** Use Textual for better TUI organization -- Selection for prompts -- Sparkline for freequency plots -*** Config handling is too generic -Split config handling to be per-command and have the config file reflect that. +*** Avoid monkey patching in tests :Tests: +*** DONE Textual for app organization & input handling +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-07 Sun 23:17] +:END: +Refine the plan in @plans/textual-migration.md to better reflect the current state of the codebase. +Focus on just the TUI organization & input handling aspects, we'll handle widgets etc later. + +Implement the plan from @plans/textual-migration.md +**** DONE Broken input/modals +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:14] +:END: +The help modal (key binding: ?) doesn't show the bindings, just a blank screen. The input prompt doesn't display the text that's being written, nor any message such as "Search term" or similar. + +**** DONE Broken modal lifecycle +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:14] +:END: +Modals that can be cancelled with the Esc key, also cause the whole app to exit. These should only close the modal. The exception should be the key-bindings modal - when any button is pressed, it should close the modal and apply that binding to the underlying screen. The modal should be updated to have a clear title (same as the other input modals) that says "Key bindings". The final line that says "Press any key to return" should be removed. + +**** DONE src/org/commands/tasks/list/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:35] +:END: + +In file src/org/commands/tasks/list/command.py around line 375, near the ~def _run_tasks_list_interactive(~ + +#+begin_example ++ del console ++ run_tasks_list_app(args, data) + +#+end_example + +It's better to refactor the function call sites not to pass the =console= there. + +**** DONE Put CSS into a separate file for better organization +:PROPERTIES: +:capture_time: [2026-06-05 Fri 11:13] +:context: [[file:~/projects/software/org-cli/VIBES.org::*src/org/commands/tasks/list/command.py][src/org/commands/tasks/list/command.py]] +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:20] +:END: +**** DONE Tasks capture preview should be on the left of the form. +:PROPERTIES: +:CAPTURE_TIME: <2026-06-05 Fri 12:03> +:ID: Vibes-490 +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 13:17] +:END: + +Instead of using two columns, please move the task preview panel to the left of the screen and the form to the right. +The inputs should support newlines (enter key). The save/cancel buttons should be center-aligned underr the form, but on the same line. + +Ideally, the form itself should be part of the preview (as-in, the task preview is displayed as the main panel, with input boxes in place of the placeholders.) + +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 13:22] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/tests/commands/tasks/test_tasks_capture_app.py::capture.TasksCaptureArgs( + template_name="quick", + config=".org-cli.yaml", + file=None, + parent=None, + set_values=None, + ), + "quick",]] +:END: + +Filename: /home/k/projects/software/org-cli/tests/commands/tasks/test_tasks_capture_app.py + +#+begin_example + capture.TasksCaptureArgs( + template_name="quick", + config=".org-cli.yaml", + file=None, + parent=None, + set_values=None, + ), + "quick", +#+end_example + +Please don't rely on the .org-cli.yaml file present in the repository in tests. Substritute a temporary file if the configuration is needed. +**** DONE The test suite is a lot slower after the Textual rewrite +:PROPERTIES: +:capture_time: [2026-06-05 Fri 13:19] +:context: [[file:~/projects/software/org-cli/VIBES.org::*Code review comment][Code review comment]] +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-06 Sat 16:04] +:END: +It used to be under 8 seconds. Please identify the slow tests and update them to run faster. +**** DONE src/org/commands/agenda/layout.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-06 Sat 19:56] +:END: + +In file src/org/commands/agenda/layout.py around line 624, near the ~def _selected_row_location(session: AgendaSession) -> tuple[int, int] | None:~ + +#+begin_example ++def selected_row_location(session: AgendaSession) -> tuple[int, int] | None: ++ """Return the selected interactive row location for one agenda session.""" ++ return _selected_row_location(session) + +#+end_example + +This wrapper should be removed, prefer renaming functions and adjusting callsites. +*** DONE Use Textual Selection widget for selections from a list of values +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 11:32] +:END: +This should be done instead of an input prompt wheneven the set of acceptable values is know before prompting. +Some examples include: prompting for new state when changing task's todo state, capture template selection, refiling tasks to known files. + +*** DONE Reorganize file parsing & formatting +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 12:30] +:END: +Add a new package =org/serde= that will contain any parsing/output formatting code. It should contain: +- =parse= - current contents of =org/parse.py= +- =format= - current contents of =org/output_format.py= + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new serialization package. + +*** DONE Reorganize the config file handling +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 17:17] +:END: +Add a new package =org/config= that will contain: +- =app= - current config of =org/config.py= +- =logging= - current config of =org/logging_config.py= + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new config package. + +*** DONE Reorganize the TUI +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 19:32] +:END: +With Textual as the backbone of the interactive commands, it makes sense to expand the =org/tui.py= module to be a full package +=org/tui= that contains: +- =color= - current =org/color.py= +- =bits= - current contents of =org/tui.py= that render specific items to Rich renderables, +- =plot= - current =org/plot.py= +- =histogram= - current =org/histogram.py= +- =help= - current contents of =org/commands/runtime.py=, specifically the help modal, and also the help modal rendering from =org/commands/interactive_common.py= +- =prompt= - current contents of =org/commands/runtime.py=, specifically the input prompt modal +- =selection= - current contents of =org/commands/runtime.py=, specifically the selection modal +- =footer= - footer rendering of =org/commands/runtime.py=, current contents of =org/commands/interactive_common.py= pertaining to footer state. +- =header= - current contents of =org/commands/interactive_common.py= pertaining to header state. + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new TUI package. + +*** TODO Reorganize common command logic +The shared logic that the commands use are currently present in multiple modules, but should be reorganized into a new package =org/logic= that contains: +- =tasks= - current contents of =org/commands/interactive_common.py= pertaining to modifying tasks, +- =archive= - current contents of =org/commands/archive.py= +- =edit= - current contents of =org/commands/editor.py= +- =search= - current contents of =org/commands/search_common.py= +- =stats= - current contents of =org/analyze.py= +- =filtering= - current contents of =org/cli_common.py= +- =time= - current contents of =org/timestamp.py= and time-related functions from =org/commands/interactive_common.py= +- =validation= - current contents of =org/validation.py= + +In addition to the above, the =org/tui/bits.py= module now contains a lot of non-UI related functions. These should also be moved to the =org/logic= package to the appropriate submodules. + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new logic package. + +**** DONE src/org/commands/agenda/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file src/org/commands/agenda/command.py around line 16, near the ~import typer~ + +#+begin_example ++from org.logic.filtering import load_and_process_data + +#+end_example + +Sounds like that function should be in serde module, not logic filtering. +**** DONE src/org/commands/agenda/ui.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file src/org/commands/agenda/ui.py around line 18, near the ~from rich import box~ + +#+begin_example ++from org.logic.time import local_now, shift_datetimes_by_unit ++from org.logic.validation import parse_date_argument + +#+end_example + +The =parse_date_argument= should either be in serde package, or in =logic.time=. I'm leaning towards =logic.time=. +**** DONE src/org/commands/stats/groups.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file src/org/commands/stats/groups.py around line 20, near the ~import typer~ + +#+begin_example ++from org.logic.filtering import ( ++ load_and_process_data, ++ resolve_date_filters, ++ resolve_exclude_set, ++ resolve_group_values, ++ resolve_mapping, + +#+end_example + +All of these should be elsewhere than the filtering module. +**** DONE tests/cli_common/test_filter_construction.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file tests/cli_common/test_filter_construction.py around line 89, near the ~def test_build_query_text_with_ordering_and_slice() -> None:~ + +#+begin_example +- from org.cli_common import build_query_text ++ from org.logic.filtering import build_query_text + +#+end_example + +Query building should not be a part of the filtering module, it should be in a dedicated module. +**** DONE tests/cli_common/test_top_tasks.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file tests/cli_common/test_top_tasks.py around line 7, near the ~Merge~ + +#+begin_example ++from org.logic.filtering import get_most_recent_timestamp, get_top_tasks + +#+end_example + +=get_most_recent_timestamp= should be in the time module. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/edit.py::def _load_document_from_text(document_text: str, filename: str | None) -> Document: + """Parse full document text and preserve original filename when present.""" + try: + document = org_parser.loads(document_text) + except (TypeError, ValueError) as err: + raise typer.BadParameter(f"Edited document content is invalid: {err}") from err + + document.filename = "" if filename is None else filename + return document]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/edit.py + +#+begin_example + +def _load_document_from_text(document_text: str, filename: str | None) -> Document: + """Parse full document text and preserve original filename when present.""" + try: + document = org_parser.loads(document_text) + except (TypeError, ValueError) as err: + raise typer.BadParameter(f"Edited document content is invalid: {err}") from err + + document.filename = "" if filename is None else filename + return document + +#+end_example + +This should be moved to the =org.pipeline.load=. Ideally, one way to load a file should be used accross the whole app. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_day_info(time_range: TimeRange | None) -> tuple\[str, int\] | None: + """Extract top day and its count from TimeRange.""" + if not time_range or not time_range.timeline: + return None + max_count = max(time_range.timeline.values()) + top_day = min(d for d, count in time_range.timeline.items() if count == max_count) + return (top_day.isoformat(), max_count)]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: + """Extract top day and its count from TimeRange.""" + if not time_range or not time_range.timeline: + return None + max_count = max(time_range.timeline.values()) + top_day = min(d for d, count in time_range.timeline.items() if count == max_count) + return (top_day.isoformat(), max_count) + +#+end_example + +This should be in the =org.logic.stats= module. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_tasks(nodes: list\[Heading\], max_results: int) -> list\[Heading\]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list\[tuple\[Heading, datetime\]\] = \[\] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x\[1\], reverse=True) + + return \[node for node, _ in sorted_nodes\[:max_results\]\]]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list[tuple[Heading, datetime]] = [] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) + + return [node for node, _ in sorted_nodes[:max_results]] + +#+end_example + +This should be in the =org.logic.stats= module. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 21:51] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def count_filter_values(value: list\[str\] | None) -> int: + """Count filter values for append-style filters.""" + return len(value) if value else 0]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def count_filter_values(value: list[str] | None) -> int: + """Count filter values for append-style filters.""" + return len(value) if value else 0 + +#+end_example + +This function makes no sense, just do this directly wherever this is invoked. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 21:52] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/edit.py::def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: + """Edit the original task document, preferring in-place file editing at task line.""" + return attempt_edit_heading_subtree_in_external_editor(heading)]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/edit.py + +#+begin_example + +def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: + """Edit the original task document, preferring in-place file editing at task line.""" + return attempt_edit_heading_subtree_in_external_editor(heading) + +#+end_example + +This wrapper makes no sense, please rename the functions and clean this up. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 21:52] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_most_recent_timestamp(node: Heading) -> datetime | None: + """Get the most recent timestamp from a node.""" + timestamps = extract_timestamp_any(node) + return max(timestamps) if timestamps else None]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example +def get_most_recent_timestamp(node: Heading) -> datetime | None: + """Get the most recent timestamp from a node.""" + timestamps = extract_timestamp_any(node) + return max(timestamps) if timestamps else None + +#+end_example + +This is available in the =Heading= class as =latest_timestamp=, please remove this function and use the built-in way to fetch the timestamp. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def validate_custom_switches(argv: list\[str\], include_builtin_ordering: bool) -> None: + """Validate prefixed custom switches against configured/built-in options.""" + builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() + allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, + ) + allowed_order_options = builtin_order_options.union( + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, + ) + allowed_with_options = WITH_OPTIONS_FLAGS.union( + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, + ) + + for index, token in enumerate(argv): + option = _extract_option_token(token) + if option.startswith("--filter-") and option not in allowed_filter_options: + raise click.NoSuchOption(option) + if option.startswith("--order-by-") and option not in allowed_order_options: + raise click.NoSuchOption(option) + if option.startswith("--with-") and option not in allowed_with_options: + raise click.NoSuchOption(option) + + custom_option = _resolve_custom_option(option) + if custom_option is None: + continue + + _, requires_arg = custom_option + if not requires_arg or token.startswith(f"{option}="): + continue + + next_index = index + 1 + if next_index >= len(argv): + raise _required_custom_arg_error(option) + + next_token = argv\[next_index\] + if next_token.startswith("-"): + raise _required_custom_arg_error(option)]] +:END: + +#+begin_example + +def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: + """Validate prefixed custom switches against configured/built-in options.""" + builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() + allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, + ) + allowed_order_options = builtin_order_options.union( + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, + ) + allowed_with_options = WITH_OPTIONS_FLAGS.union( + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, + ) + + for index, token in enumerate(argv): + option = _extract_option_token(token) + if option.startswith("--filter-") and option not in allowed_filter_options: + raise click.NoSuchOption(option) + if option.startswith("--order-by-") and option not in allowed_order_options: + raise click.NoSuchOption(option) + if option.startswith("--with-") and option not in allowed_with_options: + raise click.NoSuchOption(option) + + custom_option = _resolve_custom_option(option) + if custom_option is None: + continue + + _, requires_arg = custom_option + if not requires_arg or token.startswith(f"{option}="): + continue + + next_index = index + 1 + if next_index >= len(argv): + raise _required_custom_arg_error(option) + + next_token = argv[next_index] + if next_token.startswith("-"): + raise _required_custom_arg_error(option) + +#+end_example + +Probably should be moved to the validation module. +**** CANCELLED Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/validation.py]] +:END: +:LOGBOOK: +- State "CANCELLED" from "TODO" [2026-06-13 Sat 20:59] \\ + Meh +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/validation.py + +Validation module should be placed in =org.pipeline= package. + +**** CANCELLED Code review comment +:LOGBOOK: +- State "CANCELLED" from "TODO" [2026-06-13 Sat 20:40] \\ + Already done. +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::class DataLoadArgs(QueryBuildArgs, Protocol): + """Protocol for loading and preprocessing data.""" + + files: list\[str\] | None + todo_states: str + done_states: str + width: int | None + + +class SlicedDataLoadArgs(Protocol): + """Protocol for args that support query slicing.""" + + offset: int + max_results: int + + +class RootDataLoadArgs(Protocol): + """Protocol for loading root nodes without filters or enrichment.""" + + files: list\[str\] | None + todo_states: str + done_states: str + width: int | None + + +def _resolve_and_load_roots( + args: RootDataLoadArgs, +) -> tuple\[list\[Document\], list\[str\], list\[str\]\]: + """Resolve inputs and load org roots after validating todo/done keys.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + return _load_roots_for_inputs(args.files, todo_states, done_states) + + +def _load_roots_for_inputs( + files: list\[str\] | None, + todo_states: list\[str\], + done_states: list\[str\], +) -> tuple\[list\[Document\], list\[str\], list\[str\]\]: + """Resolve file inputs and load all org root nodes.""" + filenames = resolve_input_paths(files) + return load_root_nodes(filenames, todo_states, done_states) + + +def load_root_data( + args: RootDataLoadArgs, +) -> tuple\[list\[Document\], list\[str\], list\[str\]\]: + """Load org root nodes without filters, enrichment, or ordering.""" + return _resolve_and_load_roots(args) + + +def load_and_process_data( + args: DataLoadArgs, +) -> tuple\[list\[Heading\], list\[str\], list\[str\]\]: + """Load nodes, preprocess, and apply query-based filters/ordering.""" + include_ordering = hasattr(args, "order_by_level") + validate_custom_switches(sys.argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + roots, todo_states, done_states = _load_roots_for_inputs( + normalized_files, + todo_states, + done_states, + ) + nodes = \[node for root in roots for node in list(root)\] + + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + query = build_query( + args, + sys.argv, + include_ordering=include_ordering, + include_slice=include_slice, + ) + + context_vars: dict\[str, object\] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) + if include_slice: + sliced_args = cast("SlicedDataLoadArgs", args) + context_vars\["offset"\] = sliced_args.offset + context_vars\["limit"\] = sliced_args.max_results + + logger.info("Query context: %s", context_vars) + + try: + results = query(Stream(\[nodes\]), EvalContext(context_vars)) + except (QueryParseError, QueryRuntimeError) as exc: + raise typer.BadParameter(str(exc)) from exc + + flattened: list\[object\] + if len(results) == 1 and isinstance(results\[0\], list): + flattened = cast("list\[object\]", results\[0\]) + else: + flattened = list(results) + nodes = \[value for value in flattened if isinstance(value, Heading)\] + + return nodes, todo_states, done_states]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +class DataLoadArgs(QueryBuildArgs, Protocol): + """Protocol for loading and preprocessing data.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +class SlicedDataLoadArgs(Protocol): + """Protocol for args that support query slicing.""" + + offset: int + max_results: int + + +class RootDataLoadArgs(Protocol): + """Protocol for loading root nodes without filters or enrichment.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +def _resolve_and_load_roots( + args: RootDataLoadArgs, +) -> tuple[list[Document], list[str], list[str]]: + """Resolve inputs and load org roots after validating todo/done keys.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + return _load_roots_for_inputs(args.files, todo_states, done_states) + + +def _load_roots_for_inputs( + files: list[str] | None, + todo_states: list[str], + done_states: list[str], +) -> tuple[list[Document], list[str], list[str]]: + """Resolve file inputs and load all org root nodes.""" + filenames = resolve_input_paths(files) + return load_root_nodes(filenames, todo_states, done_states) + + +def load_root_data( + args: RootDataLoadArgs, +) -> tuple[list[Document], list[str], list[str]]: + """Load org root nodes without filters, enrichment, or ordering.""" + return _resolve_and_load_roots(args) + + +def load_and_process_data( + args: DataLoadArgs, +) -> tuple[list[Heading], list[str], list[str]]: + """Load nodes, preprocess, and apply query-based filters/ordering.""" + include_ordering = hasattr(args, "order_by_level") + validate_custom_switches(sys.argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + roots, todo_states, done_states = _load_roots_for_inputs( + normalized_files, + todo_states, + done_states, + ) + nodes = [node for root in roots for node in list(root)] + + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + query = build_query( + args, + sys.argv, + include_ordering=include_ordering, + include_slice=include_slice, + ) + + context_vars: dict[str, object] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) + if include_slice: + sliced_args = cast("SlicedDataLoadArgs", args) + context_vars["offset"] = sliced_args.offset + context_vars["limit"] = sliced_args.max_results + + logger.info("Query context: %s", context_vars) + + try: + results = query(Stream([nodes]), EvalContext(context_vars)) + except (QueryParseError, QueryRuntimeError) as exc: + raise typer.BadParameter(str(exc)) from exc + + flattened: list[object] + if len(results) == 1 and isinstance(results[0], list): + flattened = cast("list[object]", results[0]) + else: + flattened = list(results) + nodes = [value for value in flattened if isinstance(value, Heading)] + + return nodes, todo_states, done_states + +#+end_example + +All of this should be moved to the =org.pipeline.load= module. +*** CANCELLED Use Textual toasts for status messages. +:LOGBOOK: +- State "CANCELLED" from [2026-06-13 Sat 21:02] \\ + Nah, these are annoying. +:END: +*** DONE Reorganize the query execution engine code +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:04] +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 23:13] +:END: + +Introduce a new package =org.query= and move the query-execution-related helpers in there: +- Current =org.logic.query= should be moved to that package as =org.query.runner= +- Current =org.query_language= package should be renamed to =org.query.engine= +- The query compiler (=org.query.engine.compiler=) and execution engine (=org.query.engine.interpreter=) should be separated, with all the operator/function definitions also separated to their own module =org.query.engine.defs= + +*** Streamline query execution +- One way to run a query used by all commands. +- Error handling left to the caller +- Runner accepts a list of files, context and a set of query stages that are all piped together, it performs query building, compilation and execution. + +*** TODO Streamline data processing. +:PROPERTIES: +:capture_time: [2026-06-12 Fri 23:54] +:context: +:END: + +The commands use a common ETL pipeline where they load some data from files, apply filters/ordering/mutators and lastly present the results in some way. +It would be nice to make this as streamlined as possible, ideally, =org.logic.query= would expose a function =run_query= that takes a list of documents, a textlist of query stages to run and the environment for the query to run in, and compiles and executes the query and returns the result. +That streamlined query execution should be used in all places that extract or transform the org document data - the data should be loaded, then processed with the =run_query= function and used as needed. +*** TODO Org file repository abstraction +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:12] +:context: +:END: + +Add a new package =org.db= that will store the org file repository abstraction. +In that package, add a repository-pattern abstraction over one or more org files. The abstraction should automatically load all the files, maintain their modification state and automatically perform saving of the modified files whenever they change. +The abstraction should also support querying the repository using the query language. + +All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. +*** TODO Load configuration first and pass the object to the commands +Do not use global state for configuration storage. The goal is for the commands to be stateless. + +The config processing flow should look like this: +- create a well-defined structured object that will hold the configuration, +- populate the config object with code-defaults, +- determine the configuration file to use, +- load the configuration file with well-defined sections, +- update the structured config object with config file values, +- pass the configuration object to the specific command, +- overwrite the configuration object with command CLI switch values where applicable, +- log the effective config object, +- execute the command with all the configuration values taken from the resulting config object + +It might be the case, that the command tree construction will need to be done after the configuration is loaded. That is absolutely fine - update the =main= function to do that. + +The shape of the configuration object: +#+begin_example +config: + defaults: dict[string, string] # for backwards compatibility until we clean this up + + todo_states: list[string], same content as defaults.--todo-states when provided + done_states: list[string], same content as defaults.--done-states when provided + + filters: list[current filter config] + orderings: list[current order-by config] + mutators: list[current with config] + + stats: + exclude: file path to the exclude file (same as defaults.--exclude if provided) + mapping: file path to the mapping file (same as defaults.--mapping if provided) + + capture: + templates: list[current capture templates config] + + agenda: + views: list[current agenda view config] + + board: + views: list[current board vie config] +#+end_example + +Update the commands to use this new structured configuration object (prefer the command-specific sections for the value, not =config.defaults=). +Update the tests not to rely on the global state. + +*** CANCELLED Use Textual header & footer widgets in the interactive commands +:LOGBOOK: +- State "CANCELLED" from [2026-06-11 Thu 09:46] \\ + Not very useful right now. +:END: *** REWORKED Interactive loop refactor :LOGBOOK: - State "REWORKED" from "TODO" [2026-05-26 Tue 21:27] @@ -7368,7 +8244,51 @@ A domain should be defined with all common utils reorganized as domain specific For command output rendering, a common toolkit of widgets and widget-adjacent rendering tools should be extracted. Lastly, some common logic should be extracted that allows performing actions generically. Views should only trigger those actions and actions should modify the domain. -** TODO Agenda usability improvements :UX: +*** DONE Remove legacy non-interactive rendering from interactive commands +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-10 Wed 19:48] +:END: +**** DONE src/org/commands/tasks/list/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-10 Wed 19:48] +:END: + +In file src/org/commands/tasks/list/command.py around line 663, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ if context is not None: ++ details_switch_present = details_switch_present or ( ++ context.get_parameter_source("details") == ParameterSource.COMMANDLINE ++ ) ++ out_switch_present = out_switch_present or ( ++ context.get_parameter_source("out") == ParameterSource.COMMANDLINE ++ ) + +#+end_example + +I absolutely hate this. Please refactor the =out= and =details= switches to be optional and check their existance that way. + +*** DONE Extract filter/order/with handling from the query package and into config. +:PROPERTIES: +:capture_time: [2026-06-13 Sat 23:09] +:context: [[file:~/projects/software/org-cli/src/org/query/runner.py::for arg_name, expected in expected_counts.items():]] +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-14 Sun 11:14] +:END: + +All the functions dealing with CLI argument processing, ordering of filters, etc should be moved to a new module =org.config.cli=. The =org.query= package should only contain query building and execution. The =org.config= package is a suitable place for the default pipeline configuration and user-provided overrides in the form of CLI switches. + +** TODO Built-in editor +*** Use Textual textarea for org-mode syntax highlighting & editing +- Separate config setting for internal/external editor. + +*** Show a Textual modal when performing external edits, instead of suspending the app + +** TODO Agenda usability improvements :UX:Agenda: *** Agenda non-interactive output format - Should be adjusted to "look and feel" just like the interactive one. *** Show state transition for repeats on the timeline @@ -7652,6 +8572,27 @@ agenda: align: right #+end_src +*** Customizable agenda actions :Config: +Allow defining additional task actions that can be executed in the agenda view. +#+begin_example +agenda: + actions: + - name: Set todo + description: Set todo state of a task + key: t + input: select + options: .document.all_states + action: .todo = $option + - name: Shift date + description: Shift the date of a task + key: S-right_arrow + action: .scheduled.start = .scheduled.start + days(1) +#+end_example + +Built-in task actions that can be expressed in terms of this system, should be refactored to rely on it. +*** Bulk actions +It should be possible to mark multiple items in agenda, board & task list and apply an action to them in bulk + *** CANCELLED Agenda review/standalone review command :LOGBOOK: - State "CANCELLED" from [2026-05-31 Sun 22:10] \\ @@ -7684,7 +8625,18 @@ The action will prompt the user for each step multiple times (empty response mov When typing, the action should propose existing tasks by title. When a newly added task matches an existing recurrent task by title it should be "reused" instead of creating a duplicate (reuse as in, scheduled set to the current day while preserving the repeater if present). -** Query language extensions +*** Flicker when attempting to edit a non-task row in the agenda. :Bug: +:PROPERTIES: +:capture_time: [2026-06-11 Thu 15:54] +:context: +:END: +*** Agenda command allows highlighting the section titles. :Bug: +Section titles should be omitted when scrolling the highlight. +:PROPERTIES: +:capture_time: [2026-06-11 Thu 15:54] +:context: +:END: +** Query language & command improvements :UX:Query: *** Query language improvements - Uniform function calling: =foo | not= is the same as =not(foo)= - Field assignment should return the mutated object: =foo.title = "bar"= evaluates to =foo= after the change. @@ -7717,9 +8669,52 @@ Introduce wrappers for the analysis results functions that can be visualized aut - =histogram()= - histogram plot - =progress()= - progress plot -** Board command improvements :UX: +*** Search sigil +Searches documents for a specific node either by title or by id. Makes use of that documents cache. +#+begin_example +.[] | #"id-or-title" +.[][] | select(.id == "id-or-title" or .title.text == "id-or-title") +#+end_example + +*** TODO sort_by accepting multiple criteria +Currently, =sort_by()= only accepts one criteria for ordering the stream. It should accept multiple criteria as well, so things like the following are possible: +#+begin_example +sort_by(.priority) # sort by priority alone +sort_by(.priority, .latest_timestamp) # sort by priority first, break ties by latest timestamp +sort_by(.priority, .category, .title) # sort by multiple values in order +sort_by(-.property.some_val, .latest_timestamp.start.month) # sort by multiple compound subqueries +#+end_example +*** Saved queries :Config: +Add a configuration file section for saved queries that can be invoked with =org tasks query= in place of the actual query string. + +#+begin_example +query: + - name: all-todos + query: .[][] | select(.todo == "TODO") +#+end_example + +Invocation: +#+begin_example +org tasks query all-todos +#+end_example + +*** Query data visualization +- Functions that encode stats into widget objects +- Show stats widgets when query return widget objects +- Support mixed data (title string + histogram widget, etc) + +** Board command improvements :UX:Board: *** Board plan command Shows long term plan including completeness of tasks that have children and gannt-style chart for execution. +*** Customizable agenda actions :Config: +Allow defining additional task actions that can be executed in the board view. +#+begin_example +board : + actions: + - key: C-i + description: Set the ID property on a tasks + action: .id = .id or (.category + "-" + str(.document.all_nodes | length)) +#+end_example *** Gantt chart - Show a gantt chart of tasks and their dependencies *** Progress view @@ -7728,6 +8723,14 @@ Shows long term plan including completeness of tasks that have children and gann - List to be oredered by tree order (top-level tasks followed by their children) *** High CPU usage when browsing the board :Bug: +*** Bulk actions +It should be possible to mark multiple items in agenda, board & task list and apply an action to them in bulk + +*** Copy & copy as markdown actions +** Tasks list command improvements +*** Configurable column view +Should work the same as the configurable agenda view columns. + ** Stats command improvements *** =--days= switch for =stats= commands - Same as =--filter-date-from= with date X days ago. @@ -7735,8 +8738,16 @@ Shows long term plan including completeness of tasks that have children and gann - Distribution of task durations - for tasks that have a =capture_time= property and are completed, a time the task was "up" can be computed and put on a chart (needs special handling for recurrent tasks) - Effort distribution - for tasks that have an =effort= property, convert that to minutes and show on chart - Gamify exp progression on various skills -** Per-project config +*** Interactive UI +- Watch input files for changes and recompute stats live +- Use collapsiple sections for summary (open by default), tags and groups +- Allow adjusting filters/analysis parameters live +- Support =--out json/plain= for non-interactive results + +** Per-project config :Config: - Use =.org= directory to store the config file and any other useful bits such as indeces. +- Cache parsed nodes in the =.org= dir. +- Default views config for each command that supports it, no global CLI switch defaults. *** Init command - Initializes the config file with some defaults. @@ -7762,7 +8773,17 @@ Shows long term plan including completeness of tasks that have children and gann - Set workflow states, etc automatically. - Lint/Format files **** Check for file errors and log them -** Dashboard command +*** Config handling is too generic +Split config handling to be per-command and have the config file reflect that. +*** Refactor config handling so that no global state is mutated +*** TODO Built-in filters/orderings/mutators should be treated the same as custom ones +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:01] +:context: +:END: + +After loading the config defaults, these should be populated in their respective config object sections. +** Dashboard command :Dashboard: - Reads a dashboard config and shows a visualization on a server. - Tabbed views for the agenda, board, backlog and stats - Live updating. @@ -7783,7 +8804,7 @@ It should apply to each of the available commands. This parameter should be made - Document the config format - Update the help strings in the CLI commands to better match the usage -** Website for org-cli +*** Website for org-cli - Publish the package - Add a static site for org-cli.idorobots.org on github pages. - Documentation with mkDocs @@ -7811,12 +8832,20 @@ Extend =org tasks find= with =--search-rag=. This is better served with data export & a separate script. :END: To be done as a separate script that get's the analysis result JSON. -*** D3 visualization - tag cloud - -*** D3 visualization - relations graph +*** CANCELLED D3 visualization - tag cloud +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: +*** CANCELLED D3 visualization - relations graph +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Node size is how many tasks are related to a tag. Closeness is how related these are. -*** D3 visualization - tag charts over time +*** CANCELLED D3 visualization - tag charts over time +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Allow selecting specific tags or top tags. ** CANCELLED Agents command @@ -7828,23 +8857,32 @@ Spawn agents based on workflow config, all tasks that can be done in parallel, s Should be able to daemonize and hook up to a running daemon. Show spawned agents, their status, what they are working on, etc. -*** Agents command: multi-session screen UI +*** CANCELLED Agents command: multi-session screen UI :PROPERTIES: :CAPTURE_TIME: <2026-05-05 Tue 09:24> :ID: Vibes-425 :END: +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Implement an Agents command UI that supports running multiple agent harness sessions in the background and browsing them in a split-screen TUI. Add a left sidebar listing open sessions with current task and a clear indicator for sessions waiting for human input (for example permissions). Show the active session view on the right. Initial focus should be on the sidebar; moving focus right should enter session interaction mode; provide a keybinding to detach and return focus to the session list; sessions must remain attachable at any time while continuing to run in the background. -*** Agents command: workflow loop daemon run (--deamon) +*** CANCELLED Agents command: workflow loop daemon run (--deamon) :PROPERTIES: :CAPTURE_TIME: <2026-05-05 Tue 11:45> :ID: Vibes-427 :END: +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Add org agents run --deamon to run the workflow loop using separate agent sessions for each available task until completion or human input is required. Loop: pick a task in the earliest available state, assign the specified agent, create a git worktree and a branch for the task, spawn a session, let the agent do its job, and use skills to push tasks along. -*** Agents command: notify only on human intervention required +*** CANCELLED Agents command: notify only on human intervention required :PROPERTIES: :CAPTURE_TIME: <2026-05-05 Tue 11:45> :ID: Vibes-428 :END: +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Add a notification command integration (for instance notify-send) when human intervention is required, such as permission approval. Notifications should be sent only when the agent requires human intervention, and not for normal progress updates. ** CANCELLED Spec command :LOGBOOK: diff --git a/docs/agenda_command.md b/docs/agenda_command.md index 2b057e4..dd18fb9 100644 --- a/docs/agenda_command.md +++ b/docs/agenda_command.md @@ -36,9 +36,7 @@ poetry run org agenda [OPTIONS] [FILE ...] ## Interactive mode -When both stdin and stdout are TTYs, `org agenda` runs in interactive mode. -When not running in a TTY (for example in tests, piping, or redirected output), it falls back -to non-interactive rendering. +`org agenda` runs in interactive mode and requires both stdin and stdout to be TTYs. Interactive keys: diff --git a/docs/board_command.md b/docs/board_command.md index 4ba4a6d..147ca93 100644 --- a/docs/board_command.md +++ b/docs/board_command.md @@ -47,9 +47,7 @@ When omitted, column results keep the processed order from the main board pipeli ## Interactive mode -When both stdin and stdout are TTYs, `org board` runs in interactive mode. -When not running in a TTY (for example in tests, piping, or redirected output), it falls back -to non-interactive rendering. +`org board` runs in interactive mode and requires both stdin and stdout to be TTYs. Interactive keys: @@ -136,5 +134,4 @@ poetry run org board \ ## Output - The command always renders a Rich board layout (no `--out` format selection). -- In non-interactive mode, when rendered board height exceeds the viewport, output is shown in a pager. - Empty result set prints `No results`. diff --git a/docs/index.md b/docs/index.md index 2acc4a2..fd32fe3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,13 +28,14 @@ For query syntax details, use [query_language.md](query_language.md). ### Config file layout -Config uses six top-level sections: +Config uses seven top-level sections: - `defaults`: built-in option defaults (for example `--done-states`, `--limit`, `--filter-priority`, `--order-by-priority`). - `filter`: custom `--filter-` query snippets. - `order-by`: custom `--order-by-` query snippets. - `with`: custom `--with-` query snippets. - `capture`: named capture templates under `capture.templates`. +- `agenda`: named agenda views under `agenda.views` with filter-based sections. - `board`: named board views under `board.views` with filter-based columns. Custom switch argument handling: @@ -64,7 +65,9 @@ Most analysis commands accept many `--filter-*` switches. Ordering controls are Built-in argument defaults: -- Global: `--limit 10`, `--offset 0`, `--todo-states TODO`, `--done-states DONE`. +- Shared defaults: `--offset 0`, `--todo-states TODO`, `--done-states DONE`. +- Limit defaults are command-specific: many query/list/stats commands default `--limit` to `10`, while + `org agenda` and `org board` default to all results. - Stats: `--use tags`, `--max-tags 5` (all), `--max-relations 5`, `--max-groups 5` (all), `--min-group-size 2` (all). - Built-in filter additions: `--filter-priority P`. - Tasks list/board built-in ordering: `--order-by-priority`, `--order-by-level`, `--order-by-file-order`, `--order-by-file-order-reversed`, `--order-by-timestamp-asc`, `--order-by-timestamp-desc`. diff --git a/docs/tasks_capture_command.md b/docs/tasks_capture_command.md index 09e806a..033d2b2 100644 --- a/docs/tasks_capture_command.md +++ b/docs/tasks_capture_command.md @@ -73,6 +73,12 @@ capture: - `--set` values are used directly and skip prompting for those placeholders. - Placeholder matching supports whitespace: `{{ title }}` and `{{title}}` are equivalent. +Outside a TTY, capture only works when the CLI fully resolves the request up front: + +- `TEMPLATE_NAME` must be provided explicitly. +- Every non-built-in placeholder must already be resolved by `--set`, document metadata, or parent metadata. +- If template selection or placeholder prompting would be required, the command exits with an error. + ## Validation and insertion - Rendered template content is parsed with `Heading.from_source` before file mutation. diff --git a/docs/tasks_list_command.md b/docs/tasks_list_command.md index 27b72ce..04f976d 100644 --- a/docs/tasks_list_command.md +++ b/docs/tasks_list_command.md @@ -10,8 +10,9 @@ poetry run org tasks list [OPTIONS] [FILE ...] ## Interactive mode -When both stdin and stdout are TTYs, `org tasks list` runs in interactive backlog mode. -If `--details` or `--out` is explicitly passed, it stays in non-interactive static mode. +When neither `--details` nor `--out` is explicitly passed, `org tasks list` runs in interactive +backlog mode and requires both stdin and stdout to be TTYs. +If `--details` or `--out` is explicitly passed, it uses the non-interactive static/export path. The footer shows row/search context on the left and `Type ? for help` on the right. Press `?` to open a key-bindings modal panel; press any key to close it and return. diff --git a/plans/textual-migration.md b/plans/textual-migration.md new file mode 100644 index 0000000..2b4dce6 --- /dev/null +++ b/plans/textual-migration.md @@ -0,0 +1,451 @@ +# Textual Migration Plan + +## Recommendation + +Use a phased rewrite to Textual. + +1. Introduce a shared Textual runtime for interactive commands within the existing command structure. +2. Rewrite `tasks list` first. +3. Rewrite `board`. +4. Rewrite `agenda`. +5. Rewrite `tasks capture` fullscreen placeholder flow last. +6. Remove all obsolete non-Textual interactive code and tests. + +That order matches complexity and risk: + +- `tasks list` is the simplest and best for proving the shared runtime. +- `board` adds more layout complexity. +- `agenda` adds time-driven refresh and richer row semantics. +- `tasks capture` is currently its own separate input/fullscreen implementation. + +This is not a compatibility migration. The goal is to end with one interactive implementation model: Textual. + +This rewrite should preserve the existing CLI organization and command nesting. Non-interactive commands should remain available exactly where they are today, including `tasks list` remaining a subcommand of `tasks`. + +For each command, the existing CLI surface should be preserved unless there is an explicit decision to change it. That includes: + +- command/subcommand names +- CLI switches and arguments +- help text behavior +- config default application and config-driven behavior +- logging and argument handling conventions where they are part of the current command flow + +It is acceptable for Textual to become the new app entry point, but that entry point must support the full CLI rather than only the interactive screens. The Textual-based app structure should still expose and run non-interactive commands within the same overall command tree. + +## Current Interaction Patterns + +Shared patterns in the current code: + +- `tasks list`, `board`, and `agenda` already share one fullscreen loop and most input plumbing in `src/org/commands/interactive_common.py`. +- Manual fullscreen loop using Rich `Live(screen=True)`. +- Manual input mode management with `termios` / `tty`. +- Shared key decoding, help modal rendering, mouse toggles, paste handling, prompt editing, and external-action suspension in `src/org/commands/interactive_common.py`. +- In-memory session objects per command with: + - selection state + - scroll state + - search/filter state + - status message + - help-modal state + - active prompt state + - external-action runner callback +- External blocking actions suspend the screen: + - editor + - capture + - archive-like file mutations +- `tasks capture` is the outlier: + - separate fullscreen `Live(screen=True)` flow + - separate `termios` / `tty` handling + - separate placeholder input loop + +This is exactly the kind of code Textual should replace fully rather than wrap. + +## Textual Architecture + +Do not build separate bespoke apps or split interactive and non-interactive behavior into different top-level applications. Rewrite the existing command implementation in place, using Textual as the application foundation while still supporting the full command tree from the same app entry point. + +Recommended structure: + +- keep the current CLI and command nesting unchanged +- allow Textual to become the primary app entry point +- ensure the Textual-based app entry point supports both interactive and non-interactive commands +- keep interactive and non-interactive command entrypoints logically in their current command areas even if some wiring moves to the new app entry point +- preserve existing command switches, help text, and config handling for each command +- introduce shared Textual runtime/helpers alongside the existing command code rather than in a separate package +- keep `tasks list` under `src/org/commands/tasks/list/` +- keep `tasks capture` under `src/org/commands/tasks/` +- keep `board` under `src/org/commands/board/` +- keep `agenda` under `src/org/commands/agenda/` + +Recommended architectural rules: + +- Keep domain logic outside Textual widgets. +- Textual should own: + - rendering + - focus + - key bindings + - mouse events + - resize handling + - modal workflows + - timers +- Existing command logic should continue to own: + - task mutation + - filtering/search semantics + - reload/preserve-selection logic + - archive/refile/capture/edit behavior + +That means: + +- current session dataclasses and mutation/reload helpers remain useful where they still reflect real behavior +- current manual loop/input code is a removal target, not a parallel fallback to keep +- `src/org/commands/interactive_common.py` should not survive as a second interactive runtime once the rewrite is complete + +## Shared TUI Design + +Recommended shared application concepts across all interactive commands: + +- shared app-level action dispatch +- shared external-action suspension/resume +- shared modal lifecycle +- shared command/session mounting within the existing command modules +- command-owned state for selection, search, status, and reload preservation + +The app is being rewritten, not reorganized into a new CLI shape. Textual may become the application entry point, but it must support the existing command tree rather than replacing it with an interactive-only application. + +For prompt interactions, do not preserve the current footer-editing mechanics as infrastructure. +Use Textual-native interaction: + +- small modal input dialog for: + - search + - state selection + - planning timestamp edits + - refile + - clock duration + - capture template selection +- optional inline search only if it materially improves flow + +Recommendation: + +- Preserve useful prompt semantics where they still make sense. +- Replace manual cursor editing, raw input handling, and footer-specific prompt mechanics with Textual-native prompt flows. +- Do not keep the old prompt implementation in parallel just for compatibility. + +## External Action Strategy + +Current behavior: + +- suspend live screen +- launch editor / capture flow +- resume screen +- refresh state + +In Textual, recommend: + +- centralize this as an app-level helper +- temporarily suspend the app UI before launching blocking terminal interactions +- resume the app and refresh the screen state afterward + +For external editor specifically: + +- keep command session state alive +- preserve selection/search/scroll where possible +- show a short status before suspension if useful + +## Tasks List + +Current behavior: + +- vertical selection +- search prompt with live filtering +- state change +- tag edit +- planning edits +- capture prompt +- archive +- external editor +- help modal + +Textual recommendation: + +- Main screen should be the first full rewrite onto the shared Textual runtime while staying under the existing `tasks list` command path. +- Search, help, and prompts should use Textual-native interaction rather than the shared manual input loop. +- Existing task mutation and reload logic should be reused only where it still fits the new runtime. +- Good first migration target because: + - single-axis navigation + - no time-based updates + - simplest state model + +Migration notes: + +- reuse current filtering and mutation functions where still appropriate +- remove its old manual interactive path once the Textual rewrite is complete +- remove tests that only assert the old interactive loop/input behavior +- add Textual-based interaction tests for the rewritten command + +## Board + +Current behavior: + +- multi-column board +- horizontal and vertical movement +- variable-height cards/panels +- search filtering +- capture +- archive +- external editor +- state move across columns +- priority changes + +Textual recommendation: + +- Main widget: board container with one widget per column +- Each column should own a vertical list of cards +- App-level state tracks: + - selected column + - selected row within column +- Avoid trying to fake the current Rich panel grid directly +- Better model: + - custom board widget + - focus model separate from raw rendered layout + +Migration notes: + +- rewrite onto the same shared Textual runtime as `tasks list`, without changing the command/module nesting +- likely needs a custom widget rather than stock Textual table/list widgets +- good candidate for virtualization later if columns get large +- Textual should simplify resize behavior a lot here +- remove the old manual interactive board path once complete +- remove tests that only make sense for the deleted manual implementation +- add Textual-based interaction tests + +Recommendation: + +- preserve keyboard model: + - arrows for movement + - shifted arrows for state/priority actions +- consider making focused card visually stronger than today + +## Agenda + +Current behavior: + +- mixed row types: + - task rows + - hour markers + - now marker + - relative sections +- time-driven refresh for now marker +- date-range paging/scrolling +- task-only actions depending on selected row kind +- capture on timetable-like rows +- refile +- clock duration prompt +- state changes +- planning shifts +- search + +Textual recommendation: + +- Main widget: custom agenda list/tree-like widget, not plain table +- Model rows explicitly by type +- Make selection row-aware: + - some rows actionable + - some rows navigational only +- Use a timer for now-marker refresh + - `set_interval(60, ...)` or minute-aware scheduling +- Do not keep timeout-based polling to drive refresh once the Textual version exists +- This is the biggest input/runtime benefit Textual brings here + +Migration notes: + +- keep current row-building/domain logic initially +- move row actions into command handlers that inspect selected row type +- remove the old timeout/event-loop path once the Textual rewrite is complete +- replace old agenda-specific interactive tests with Textual-based tests + +Recommendation: + +- agenda should be the only screen with timer-driven UI refresh +- this screen most benefits from the migration + +## Tasks Capture + +Current behavior: + +- fullscreen template preview +- placeholder-by-placeholder editing +- inline footer input +- help modal +- custom text input handling + +Textual recommendation: + +- Treat this as a dedicated form/editor screen inside the shared Textual runtime while keeping it under the existing `tasks capture` command. +- Replace its bespoke `Live(screen=True)` and `termios` / `tty` input loop entirely. +- Use Textual-native input handling rather than preserving raw key-reading code. +- Layout: + - preview pane/body + - active field status + - input widget +- Instead of raw placeholder stepping logic in the footer: + - use focused input field + preview updates + - optionally one screen per field or a single form screen with next/prev controls + +Migration notes: + +- likely simpler after Textual rewrite than today +- but it is sufficiently distinct that it should come after the other commands +- once rewritten, remove the old capture fullscreen/input implementation and its obsolete tests + +Recommendation: + +- redesign slightly instead of preserving the exact current interaction +- Textual form flow will likely be better than emulating the manual footer editor + +## What Gets Replaced + +Likely mostly replaced: + +- `interactive_common.py` input-mode and terminal-control sections +- shared manual fullscreen event loop +- custom key/input byte decoding +- mouse reporting toggles +- bracketed-paste handling +- help modal rendering helpers tied to the manual runtime +- footer prompt editing mechanics +- the bespoke capture `Live`/raw-input loop + +Likely partially retained: + +- selection-preservation helpers +- search/filter/mutation helpers +- command-specific session refresh logic + +Likely retained as domain logic: + +- task mutation functions +- archive/refile/edit/capture integration +- data loading/reload functions +- current render-independent computations + +Likely retained as CLI structure: + +- one unified application entry point +- current Typer command tree +- current command nesting and subcommand paths +- current non-interactive command availability +- current command switches and arguments +- current command help text behavior +- current command config handling behavior + +## Testing Plan + +A Textual rewrite changes test style significantly. + +Current tests are heavy on: + +- pure renderable output +- manual input/runtime helpers +- direct key dispatch against the old event-loop model +- prompt state mutations tied to the old footer-editing flow + +Textual plan should add: + +- app-level interaction tests using Textual pilot +- screen-specific behavior tests: + - navigation + - modal open/close + - prompt submit/cancel + - state refresh after mutations +- keep domain/unit tests around current mutation/filter helpers when those helpers still exist and still represent production behavior + +Recommended split: + +- preserve pure unit tests only for domain/state/data logic that still exists in production +- remove tests that no longer make sense once the old manual runtime is deleted +- add integration-style Textual tests for UI behavior +- avoid carrying old architecture-specific tests forward once the corresponding production code is gone + +## Dependency and Tooling Impact + +Would entail: + +- add `textual` dependency +- likely add test helpers for Textual pilot-based testing +- validate compatibility with current Python target and strict typing setup + +Likely no need to replace: + +- Typer +- Rich for non-interactive rendering +- current CLI command structure +- current command nesting such as `tasks list` +- current command option/argument surface +- current help text and config-default behavior + +## Migration Phases + +### Phase 1 + +- Add Textual dependency +- Create shared Textual runtime architecture inside the existing command layout +- Make the Textual-based app entry point support both interactive and non-interactive commands +- Preserve per-command switches, help text, and config handling while rewiring execution onto the new app foundation +- Define app-level external-action suspend/resume API +- Define shared command mounting and input/action dispatch model + +### Phase 2 + +- Rewrite `tasks list` in Textual +- Keep non-interactive `tasks list` unchanged +- Remove the old interactive implementation for `tasks list` +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 3 + +- Rewrite `board` in Textual +- Remove the old interactive implementation for `board` +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 4 + +- Rewrite `agenda` in Textual +- Add timer-based now-marker refresh +- Remove the old interactive implementation for `agenda` +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 5 + +- Rewrite `tasks capture` in Textual +- Replace manual placeholder loop with Textual form/editor screen +- Remove the old capture fullscreen/input implementation +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 6 + +- Remove obsolete terminal/input plumbing +- Remove remaining Rich `Live` fullscreen helpers no longer used +- Remove obsolete interactive helper code that no longer corresponds to production behavior +- Ensure there are no remaining non-Textual interactive runtime remnants + +## Recommendations + +1. Rewrite all interactive TUI code in Textual; do not keep the old manual runtime in parallel. +2. Do not start with `agenda`. +3. Do not try to preserve the footer-prompt implementation exactly. +4. Use modal prompts and app-managed timers. +5. Keep the existing CLI organization and command nesting intact, including `tasks list` remaining a subcommand of `tasks`. +6. Remove tests that no longer make sense after the rewrite, and replace them with Textual-based tests. +7. Treat `tasks capture` as a separate UX redesign, not just a port. +8. Keep business logic outside Textual widgets. +9. If Textual becomes the app entry point, it must support non-interactive commands too, not just interactive screens. +10. Preserve each command's switches, help text, and config handling unless there is an explicit decision to change them. + +## Open Questions + +1. Do you want the Textual TUI to preserve existing key bindings almost exactly, or is some UX cleanup acceptable? +2. Do you want `tasks list` to remain a single-pane list, or should migration be used to add a detail pane? +3. Should `tasks capture` stay placeholder-step-based, or can it become a more form-like editor? diff --git a/poetry.lock b/poetry.lock index b90ec9f..e827e90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -244,6 +244,27 @@ files = [ {file = "librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862"}, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e"}, + {file = "linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx_book_theme"] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -257,6 +278,7 @@ files = [ ] [package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} mdurl = ">=0.1,<1.0" [package.extras] @@ -268,6 +290,26 @@ profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d"}, + {file = "mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"}, +] + +[package.dependencies] +markdown-it-py = ">=2.0.0,<5.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"] + [[package]] name = "mdurl" version = "0.1.2" @@ -436,6 +478,18 @@ optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] +[[package]] +name = "platformdirs" +version = "4.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -721,6 +775,29 @@ mslex = {version = ">=1.1.0,<2.0.0", markers = "sys_platform == \"win32\""} psutil = ">=5.7.2,<7" tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""} +[[package]] +name = "textual" +version = "8.2.7" +description = "Modern Text User Interface framework" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73"}, + {file = "textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105"}, +] + +[package.dependencies] +markdown-it-py = {version = ">=2.1.0", extras = ["linkify"]} +mdit-py-plugins = "*" +platformdirs = ">=3.6.0,<5" +pygments = ">=2.19.2,<3.0.0" +rich = ">=14.2.0" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.25.0) ; python_version >= \"3.10\"", "tree-sitter-bash (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-css (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-go (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-html (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-java (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-javascript (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-json (>=0.24.0) ; python_version >= \"3.10\"", "tree-sitter-markdown (>=0.3.0) ; python_version >= \"3.10\"", "tree-sitter-python (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-regex (>=0.24.0) ; python_version >= \"3.10\"", "tree-sitter-rust (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-sql (>=0.3.11) ; python_version >= \"3.10\"", "tree-sitter-toml (>=0.6.0) ; python_version >= \"3.10\"", "tree-sitter-xml (>=0.7.0) ; python_version >= \"3.10\"", "tree-sitter-yaml (>=0.6.0) ; python_version >= \"3.10\""] + [[package]] name = "tomli" version = "2.4.0" @@ -886,13 +963,40 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c"}, + {file = "uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "vulture" +version = "2.16" +description = "Find dead code" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee"}, + {file = "vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717"}, +] + [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "bbadb8e532df0da51683a02aca1202ef717cde43671876a0a51827330a9ba09a" +content-hash = "9f743b6b30bbc98607d4f8229be0f42fc7d88a1a07b6df68c7ab8baf3a5b7544" diff --git a/pyproject.toml b/pyproject.toml index 1422327..be4237c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ typer = "^0.24.1" rich = "^14.3.3" parsy = "^2.2" pyyaml = "^6.0.3" +textual = "^8.2.7" [tool.poetry.group.dev.dependencies] pytest = "^9.0.2" @@ -27,6 +28,7 @@ mypy = "^1.18.1" taskipy = "^1.14.1" pyright = "^1.1.408" types-pyyaml = "^6.0.12.20260408" +vulture = "^2.16" [tool.poetry.scripts] org = "org.cli:main" @@ -39,6 +41,7 @@ lint-fix = "ruff check --fix src/ tests/" format-check = "ruff format -q --check src/ tests/" format = "ruff format src/ tests/" type = "mypy src/org tests" +dead-code = "vulture src/org tests" check = "task format-check && task lint && task type && task test" check-cov = "task format-check && task lint && task type && task test-cov" @@ -101,7 +104,7 @@ lines-after-imports = 2 "SLF001", # private member access is tolerated in tests/support code ] -"src/org/output_format.py" = [ +"src/org/pipeline/format.py" = [ "S603", # pandoc subprocess invocation is an intentional feature "S112", # best-effort serialization intentionally skips unreadable attributes ] diff --git a/src/org/__init__.py b/src/org/__init__.py index cc56955..b03d00f 100644 --- a/src/org/__init__.py +++ b/src/org/__init__.py @@ -1,18 +1,11 @@ """org - Analyze Emacs Org-mode archive files for task statistics.""" -from org.analyze import AnalysisResult, Frequency, Relations, Tag, TimeRange, analyze from org.cli import main __version__ = "0.1.0" __all__ = [ - "AnalysisResult", - "Frequency", - "Relations", - "Tag", - "TimeRange", "__version__", - "analyze", "main", ] diff --git a/src/org/cli.py b/src/org/cli.py index db47daa..2b1a2c0 100644 --- a/src/org/cli.py +++ b/src/org/cli.py @@ -6,12 +6,10 @@ import typer -from org import config, logging_config -from org.commands import agenda +import org.config.app +import org.logging +from org.commands import agenda, stats from org.commands import board as board_command -from org.commands.stats import all as stats_all -from org.commands.stats import groups, tags -from org.commands.stats import summary as stats_summary from org.commands.tasks import command as tasks_command @@ -19,19 +17,6 @@ help="Analyze Emacs Org-mode archive files for task statistics.", no_args_is_help=True, ) -stats_app = typer.Typer( - help="Analyze Org-mode archive files for task statistics.", - no_args_is_help=True, -) - - -DEFAULT_VERBOSE: dict[str, bool] = {"value": False} - - -def _resolve_verbose(verbose: bool | None) -> bool: - if verbose is None: - return DEFAULT_VERBOSE["value"] - return verbose @app.callback() @@ -44,65 +29,45 @@ def main_callback( ), ) -> None: """Global CLI options.""" - if verbose is None and not DEFAULT_VERBOSE["value"]: + if verbose is None and not org.config.app.DEFAULT_VERBOSE["value"]: return - logging_config.configure_logging(_resolve_verbose(verbose)) - - -@stats_app.callback() -def stats_callback( - verbose: bool | None = typer.Option( - None, - "--verbose", - "-v", - help="Enable verbose logging output", - ), -) -> None: - """Global stats CLI options.""" - if verbose is None and not DEFAULT_VERBOSE["value"]: - return - logging_config.configure_logging(_resolve_verbose(verbose)) - + org.logging.configure_logging(org.config.app.resolve_verbose(verbose)) -stats_all.register(stats_app) -groups.register(stats_app) -stats_summary.register(stats_app) -tags.register(stats_app) agenda.register(app) board_command.register(app) -app.add_typer(stats_app, name="stats") +stats.register(app) tasks_command.register(app) def main() -> None: """Run the CLI entry point.""" - loaded_config = config.load_cli_config(sys.argv) + loaded_config = org.config.app.load_cli_config(sys.argv) defaults = loaded_config.defaults if defaults is not None: - DEFAULT_VERBOSE["value"] = bool(defaults.pop("verbose", False)) - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(loaded_config.append_defaults) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(loaded_config.inline_defaults) - config.CONFIG_DEFAULTS.clear() + org.config.app.DEFAULT_VERBOSE["value"] = bool(defaults.pop("verbose", False)) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(loaded_config.append_defaults) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(loaded_config.inline_defaults) + org.config.app.CONFIG_DEFAULTS.clear() if defaults is not None: - config.CONFIG_DEFAULTS.update(defaults) - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(loaded_config.custom_filters) - config.CONFIG_CUSTOM_ORDER_BY.clear() - config.CONFIG_CUSTOM_ORDER_BY.update(loaded_config.custom_order_by) - config.CONFIG_CUSTOM_WITH.clear() - config.CONFIG_CUSTOM_WITH.update(loaded_config.custom_with) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(loaded_config.capture_templates) - config.CONFIG_BOARD_VIEWS.clear() - config.CONFIG_BOARD_VIEWS.update(loaded_config.board_views) - config.CONFIG_AGENDA_VIEWS.clear() - config.CONFIG_AGENDA_VIEWS.update(loaded_config.agenda_views) + org.config.app.CONFIG_DEFAULTS.update(defaults) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(loaded_config.custom_filters) + org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() + org.config.app.CONFIG_CUSTOM_ORDER_BY.update(loaded_config.custom_order_by) + org.config.app.CONFIG_CUSTOM_WITH.clear() + org.config.app.CONFIG_CUSTOM_WITH.update(loaded_config.custom_with) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(loaded_config.capture_templates) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(loaded_config.board_views) + org.config.app.CONFIG_AGENDA_VIEWS.clear() + org.config.app.CONFIG_AGENDA_VIEWS.update(loaded_config.agenda_views) command = typer.main.get_command(app) - default_map = config.build_default_map(defaults) if defaults else None + default_map = org.config.app.build_default_map(defaults) if defaults else None command.main( args=sys.argv[1:], prog_name="org", diff --git a/src/org/color.py b/src/org/color.py deleted file mode 100644 index f521ec8..0000000 --- a/src/org/color.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Color support for CLI output using Rich markup.""" - -import sys - -from rich.markup import escape - - -def should_use_color(color_flag: bool | None) -> bool: - """Determine if color should be used based on flag and TTY detection. - - Args: - color_flag: Explicit color preference (True/False) or None for auto-detect - - Returns: - True if colors should be used, False otherwise - """ - if color_flag is None: - return sys.stdout.isatty() - return color_flag - - -def escape_text(text: str, enabled: bool) -> str: - """Escape markup characters when color output is enabled. - - Args: - text: Text to escape - enabled: Whether coloring is enabled - - Returns: - Escaped text when enabled, original text otherwise - """ - if not enabled: - return text - return escape(text) - - -def colorize(text: str, style: str, enabled: bool) -> str: - """Apply Rich markup style to text if enabled. - - Args: - text: Text to colorize - style: Rich style string (e.g., "green", "bold white") - enabled: Whether coloring is enabled - - Returns: - Styled text if enabled, original text otherwise - """ - if not enabled: - return text - return f"[{style}]{escape(text)}[/]" - - -def bright_white(text: str, enabled: bool) -> str: - """Apply bright white color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold white", enabled) - - -def white(text: str, enabled: bool) -> str: - """Apply white color to text (default, usually no-op). - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "white", enabled) - - -def dim_white(text: str, enabled: bool) -> str: - """Apply dim white color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "dim white", enabled) - - -def magenta(text: str, enabled: bool) -> str: - """Apply magenta color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "magenta", enabled) - - -def green(text: str, enabled: bool) -> str: - """Apply green color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "green", enabled) - - -def bright_green(text: str, enabled: bool) -> str: - """Apply bright green color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold green", enabled) - - -def bright_red(text: str, enabled: bool) -> str: - """Apply bright red color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold red", enabled) - - -def bright_yellow(text: str, enabled: bool) -> str: - """Apply bright yellow color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold yellow", enabled) - - -def bright_blue(text: str, enabled: bool) -> str: - """Apply bright blue color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold blue", enabled) - - -def get_state_color( - state: str, - done_states: list[str], - todo_states: list[str], - enabled: bool, -) -> str: - """Get appropriate style for a task state. - - Args: - state: Task state (e.g., "DONE", "TODO", "CANCELLED") - done_states: List of done state keywords - todo_states: List of todo state keywords - enabled: Whether coloring is enabled - - Returns: - Rich style string for the state - """ - if not enabled: - return "" - - if state in done_states: - if state == "CANCELLED": - return "bold red" - return "bold green" - - if state in todo_states or state == "" or state.lower() == "null": - return "dim white" - - return "bold yellow" diff --git a/src/org/commands/agenda/events.py b/src/org/commands/agenda/actions.py similarity index 62% rename from src/org/commands/agenda/events.py rename to src/org/commands/agenda/actions.py index 728e2d3..54c42db 100644 --- a/src/org/commands/agenda/events.py +++ b/src/org/commands/agenda/actions.py @@ -11,59 +11,39 @@ import typer from org_parser.time import Clock, Timestamp -from org.cli_common import load_and_process_data, resolve_input_paths -from org.commands.archive import archive_heading_subtree_and_save -from org.commands.editor import edit_heading_subtree_in_external_editor -from org.commands.interactive_common import ( - FooterPromptState, - HeadingLocator, - InputEvent, - InteractiveEvent, - InteractivePromptState, - TimeoutEvent, - advance_timestamp_by_repeater, - append_repeat_transition, - apply_help_modal_key, - create_interactive_prompt_state, - handle_active_prompt_event, - heading_locator, - interactive_loop, - local_now, - resolve_heading_locator, - set_timestamp_fields, -) -from org.commands.search_common import filter_nodes_by_search from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( - PromptActionConfig, - capture_template_prompt_config, - clock_duration_prompt_config, configured_capture_template_names, duration_to_org_text, iter_descendants, load_document, parse_clock_duration, - refile_prompt_config, - resolve_capture_template_selection, - resolve_refile_destination_input, - resolve_todo_state_selection, save_document, - state_selection_prompt_config, todo_states_for_heading, ) +from org.logic.archive import archive_heading_subtree_and_save +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import ( + HeadingLocator, + append_repeat_transition, + heading_locator, + resolve_heading_locator, +) +from org.logic.time import advance_timestamp_by_repeater, local_now, set_timestamp_fields +from org.pipeline.load import load_and_process_data -from .layout import ( +from .ui import ( AgendaColumnWidths, AgendaRow, DayRowModel, RenderContext, ViewportRow, - _has_specific_time, - _interactive_artifacts, - _resolve_agenda_start_date, - _selected_row_location, + build_interactive_artifacts, build_view_day_model, - interactive_agenda_renderable, + has_specific_time, + resolve_agenda_start_date, + selected_row_location, ) @@ -71,14 +51,12 @@ from collections.abc import Callable from org_parser.document import Document, Heading - from rich.console import Console from .command import AgendaArgs from .views import AgendaViewContext logger = logging.getLogger("org") -_INTERACTIVE_INPUT_TIMEOUT_SECONDS = 1.0 @dataclass @@ -101,9 +79,6 @@ class AgendaSession: status_message: str search_text: str view_ctx: AgendaViewContext - search_prompt_previous_text: str | None = None - show_help_modal: bool = False - active_prompt: InteractivePromptState | None = None run_external: Callable[[Callable[[], None]], None] | None = None @@ -127,9 +102,11 @@ def refresh_session( day_models.append(day_model) session.day_models = day_models - session.row_locations, session.interactive_rows, session.column_widths = _interactive_artifacts( - day_models, - session.render, + session.row_locations, session.interactive_rows, session.column_widths = ( + build_interactive_artifacts( + day_models, + session.render, + ) ) if not session.row_locations: session.selected_row_index = 0 @@ -159,7 +136,7 @@ def refresh_visible_nodes( def selected_task_row(session: AgendaSession) -> AgendaRow | None: """Return currently selected task row when selected row is a task.""" - location = _selected_row_location(session) + location = selected_row_location(session) if location is None: return None day_index, row_index = location @@ -169,7 +146,7 @@ def selected_task_row(session: AgendaSession) -> AgendaRow | None: return row -def _refresh_session_if_minute_changed(session: AgendaSession) -> bool: +def refresh_session_if_minute_changed(session: AgendaSession) -> bool: """Refresh agenda rows when local wall-clock minute changes.""" current_now = local_now().replace(second=0, microsecond=0) session_now = session.now.replace(second=0, microsecond=0) @@ -194,7 +171,7 @@ def _paths_refer_to_same_file(source_path: str, destination_path: str) -> bool: return source_resolved == destination_resolved -def _move_selection(session: AgendaSession, step: int) -> None: +def move_selection(session: AgendaSession, step: int) -> None: """Move highlighted row selection forward/backward with wraparound.""" if not session.row_locations: return @@ -228,7 +205,8 @@ def _reload_session_nodes(session: AgendaSession) -> None: session.nodes = filter_nodes_by_search(nodes, session.search_text) -def _edit_selected_task_in_external_editor(session: AgendaSession) -> None: +def edit_selected_task_in_external_editor(session: AgendaSession) -> None: + """Open the selected agenda task in the external editor workflow.""" row = selected_task_row(session) if row is None or row.node is None: session.status_message = "Action available only on task rows" @@ -248,7 +226,8 @@ def _edit_selected_task_in_external_editor(session: AgendaSession) -> None: session.status_message = "Task updated" -def _archive_selected_task(session: AgendaSession) -> None: +def archive_selected_task(session: AgendaSession) -> None: + """Archive the currently selected agenda task subtree.""" row = selected_task_row(session) if row is None or row.node is None: session.status_message = "Action available only on task rows" @@ -319,7 +298,7 @@ def shift_planning_time_for_row( field_name = "deadline" if timestamp is None: return None, "Time shifting is available only for timed scheduled/deadline rows" - if not _has_specific_time(timestamp): + if not has_specific_time(timestamp): return None, "Selected planning timestamp has no specific hour" before = str(timestamp) _shift_timestamp_by_hours(timestamp, hour_delta) @@ -392,7 +371,8 @@ def apply_shift_date(session: AgendaSession, *, day_delta: int) -> None: session.status_message = status -def _apply_shift_time(session: AgendaSession, *, hour_delta: int) -> None: +def apply_shift_time(session: AgendaSession, *, hour_delta: int) -> None: + """Shift the selected timed planning row by the requested hour delta.""" row = selected_task_row(session) if row is None or row.node is None: session.status_message = "Action available only on task rows" @@ -426,18 +406,8 @@ def apply_refile_with_value(session: AgendaSession, destination_input: str) -> N if row is None or row.node is None: session.status_message = "Action available only on task rows" return - current_files = resolve_input_paths(session.args.files) - destination_path, validation_error = resolve_refile_destination_input( - destination_input, - current_files, - ) - if destination_path is None and validation_error is None: - session.status_message = "Refile cancelled" - return - if validation_error is not None: - session.status_message = validation_error - return - if destination_path is None: + destination_path = destination_input.strip() + if not destination_path: session.status_message = "Refile cancelled" return try: @@ -496,7 +466,8 @@ def apply_clock_entry_with_value(session: AgendaSession, duration_input: str) -> session.status_message = f"Added clock entry ({duration_text})" -def _clear_search(session: AgendaSession) -> None: +def clear_search(session: AgendaSession) -> None: + """Clear the active agenda search filter while preserving selection when possible.""" if not session.search_text: session.status_message = "Search already clear" return @@ -511,7 +482,7 @@ def _clear_search(session: AgendaSession) -> None: def selected_row(session: AgendaSession) -> AgendaRow | None: """Return the currently selected agenda row.""" - location = _selected_row_location(session) + location = selected_row_location(session) if location is None: return None day_index, row_index = location @@ -542,7 +513,8 @@ def _timetable_schedule_for_selected_row(session: AgendaSession) -> Timestamp | return Timestamp.from_source(f"<{row.day:%Y-%m-%d} {day_name} {hour:02d}:{minute:02d}>") -def _apply_capture_task(session: AgendaSession, template_name: str) -> None: +def apply_capture_task(session: AgendaSession, template_name: str) -> None: + """Capture a task from one template and schedule it for the selected timetable row.""" scheduled = _timetable_schedule_for_selected_row(session) if scheduled is None: session.status_message = "Capture is available only on timetable time rows" @@ -574,7 +546,8 @@ def _apply_capture_task(session: AgendaSession, template_name: str) -> None: session.status_message = f"Task captured and scheduled for {scheduled}" -def _state_choices_for_selected_row(session: AgendaSession) -> list[str]: +def state_choices_for_selected_row(session: AgendaSession) -> list[str]: + """Return valid TODO state choices for the selected task row.""" row = selected_task_row(session) if row is None or row.node is None: return [] @@ -585,12 +558,18 @@ def _can_activate_agenda_state_prompt(session: AgendaSession) -> str | None: row = selected_task_row(session) if row is None or row.node is None: return "Action available only on task rows" - if not _state_choices_for_selected_row(session): + if not state_choices_for_selected_row(session): return "No TODO states defined" return None -def _can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: +def can_activate_agenda_state_prompt(session: AgendaSession) -> str | None: + """Return status text when the state prompt cannot be opened.""" + return _can_activate_agenda_state_prompt(session) + + +def can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: + """Return status text when the capture prompt cannot be opened.""" if _timetable_schedule_for_selected_row(session) is None: return "Capture is available only on timetable time rows" if not configured_capture_template_names(): @@ -598,7 +577,8 @@ def _can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: return None -def _apply_search_text(session: AgendaSession, search_text: str) -> None: +def apply_search_text(session: AgendaSession, search_text: str) -> None: + """Apply agenda search text and refresh the visible day models.""" selected_row = selected_task_row(session) preserve_identity = None if selected_row is not None and selected_row.node is not None: @@ -622,7 +602,7 @@ def create_agenda_session( all_nodes=list(nodes), nodes=nodes, render=render, - start_date=_resolve_agenda_start_date(args.date), + start_date=resolve_agenda_start_date(args.date), days=args.days, now=local_now(), day_models=[], @@ -634,242 +614,17 @@ def create_agenda_session( status_message="", search_text="", view_ctx=view_ctx, - show_help_modal=False, - active_prompt=None, ) refresh_session(session, None) return session -def _set_start_date_relative(session: AgendaSession, *, day_delta: int) -> None: +def set_start_date_relative(session: AgendaSession, *, day_delta: int) -> None: + """Move the agenda start date relative to the current visible span.""" session.start_date += timedelta(days=day_delta) refresh_session(session, None) -def _activate_prompt( - session: AgendaSession, - config: PromptActionConfig, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> None: - session.active_prompt = create_interactive_prompt_state( - config, - submit_value=submit_value, - preview_value=preview_value, - cancel=cancel, - ) - - -def _activate_capture_prompt(session: AgendaSession) -> None: - status_message = _can_activate_agenda_capture_prompt(session) - if status_message is not None: - session.status_message = status_message - return - template_names = configured_capture_template_names() - config = capture_template_prompt_config() - - def _submit(value: str) -> bool: - value = value.strip() - template_name = resolve_capture_template_selection(value, template_names) - if template_name is None and not value: - session.status_message = config.cancel_status - return False - if template_name is None: - session.status_message = config.invalid_status - return True - _run_external(session, lambda: _apply_capture_task(session, template_name)) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_search_prompt(session: AgendaSession) -> None: - session.search_prompt_previous_text = session.search_text - config = PromptActionConfig( - prompt=FooterPromptState(label="Search text (blank clears)"), - cancel_status="Search cancelled", - invalid_status="Invalid search input", - ) - - def _submit(value: str) -> bool: - session.search_prompt_previous_text = None - _apply_search_text(session, value.strip()) - return False - - def _preview(value: str) -> None: - _apply_search_text(session, value.strip()) - - def _cancel() -> None: - previous_text = session.search_prompt_previous_text or "" - session.search_prompt_previous_text = None - _apply_search_text(session, previous_text) - session.status_message = config.cancel_status - - _activate_prompt(session, config, submit_value=_submit, preview_value=_preview, cancel=_cancel) - - -def _activate_state_selection_prompt(session: AgendaSession, states: list[str]) -> None: - config = state_selection_prompt_config(states) - - def _submit(value: str) -> bool: - value = value.strip() - selected_state = resolve_todo_state_selection(value, states) - if selected_state is None and not value: - session.status_message = config.cancel_status - return False - if selected_state is None: - session.status_message = config.invalid_status - return True - apply_state_change_with_value(session, selected_state) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_value_prompt( - session: AgendaSession, - config: PromptActionConfig, - apply_value: Callable[[str], None], -) -> None: - def _submit(value: str) -> bool: - value = value.strip() - if not value: - session.status_message = config.cancel_status - return False - apply_value(value) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _handle_capture_prompt_activation(session: AgendaSession) -> None: - _activate_capture_prompt(session) - - -def _handle_search_prompt_activation(session: AgendaSession) -> None: - _activate_search_prompt(session) - - -def _handle_state_prompt_activation(session: AgendaSession) -> None: - status_message = _can_activate_agenda_state_prompt(session) - if status_message is not None: - session.status_message = status_message - return - _activate_state_selection_prompt(session, _state_choices_for_selected_row(session)) - - -def _handle_refile_prompt_activation(session: AgendaSession) -> None: - if selected_task_row(session) is None: - session.status_message = "Action available only on task rows" - return - _activate_value_prompt( - session, - refile_prompt_config(resolve_input_paths(session.args.files)), - lambda value: apply_refile_with_value(session, value), - ) - - -def _handle_clock_prompt_activation(session: AgendaSession) -> None: - if selected_task_row(session) is None: - session.status_message = "Action available only on task rows" - return - _activate_value_prompt( - session, - clock_duration_prompt_config(), - lambda value: apply_clock_entry_with_value(session, value), - ) - - -def _handle_help_modal_event(session: AgendaSession, event: InteractiveEvent) -> bool: - if not session.show_help_modal: - return False - if not isinstance(event, TimeoutEvent): - session.show_help_modal = False - return True - - -def _handle_navigation_key(session: AgendaSession, key: str) -> bool: - handler = { - "n": lambda: _move_selection(session, 1), - "DOWN": lambda: _move_selection(session, 1), - "WHEEL-DOWN": lambda: _move_selection(session, 1), - "p": lambda: _move_selection(session, -1), - "UP": lambda: _move_selection(session, -1), - "WHEEL-UP": lambda: _move_selection(session, -1), - "f": lambda: _set_start_date_relative(session, day_delta=session.days), - "RIGHT": lambda: _set_start_date_relative(session, day_delta=session.days), - "b": lambda: _set_start_date_relative(session, day_delta=-session.days), - "LEFT": lambda: _set_start_date_relative(session, day_delta=-session.days), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_mutation_key( - session: AgendaSession, - key: str, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "ENTER": lambda: run_external(lambda: _edit_selected_task_in_external_editor(session)), - "$": lambda: _archive_selected_task(session), - "x": lambda: _clear_search(session), - "S-LEFT": lambda: apply_shift_date(session, day_delta=-1), - "S-RIGHT": lambda: apply_shift_date(session, day_delta=1), - "S-UP": lambda: _apply_shift_time(session, hour_delta=-1), - "S-DOWN": lambda: _apply_shift_time(session, hour_delta=1), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_prompt_activation_key( - session: AgendaSession, - key: str, - _run_external_callback: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "a": _handle_capture_prompt_activation, - "/": _handle_search_prompt_activation, - "t": _handle_state_prompt_activation, - "r": _handle_refile_prompt_activation, - "c": _handle_clock_prompt_activation, - }.get(key) - if handler is None: - return False - handler(session) - return True - - -def _handle_keypress_event( - session: AgendaSession, - key: str, - run_external: Callable[[Callable[[], None]], None] | None = None, -) -> bool: - effective_run_external = passthrough_run_external if run_external is None else run_external - consumed, next_help_modal = apply_help_modal_key(key, show_help_modal=session.show_help_modal) - session.show_help_modal = next_help_modal - if consumed: - return True - if key in {"q", "ESC"}: - return False - if _handle_navigation_key(session, key): - return True - if _handle_mutation_key(session, key, effective_run_external): - return True - if _handle_prompt_activation_key(session, key, effective_run_external): - return True - if key and key != "IGNORE": - session.status_message = f"Unsupported key: {key}" - return True - - def passthrough_run_external(callback: Callable[[], None]) -> None: """Run an external callback immediately.""" callback() @@ -878,39 +633,3 @@ def passthrough_run_external(callback: Callable[[], None]) -> None: def _run_external(session: AgendaSession, callback: Callable[[], None]) -> None: runner = session.run_external or passthrough_run_external runner(callback) - - -def handle_interactive_event( - session: AgendaSession, - event: InteractiveEvent, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - """Handle one agenda interactive event.""" - if _handle_help_modal_event(session, event): - return True - if session.active_prompt is not None: - return handle_active_prompt_event(session, event) - if isinstance(event, TimeoutEvent): - _refresh_session_if_minute_changed(session) - return True - if isinstance(event, InputEvent): - return True - return _handle_keypress_event(session, event.key, run_external) - - -def run_agenda_interactive(console: Console, session: AgendaSession) -> None: - """Run the interactive agenda loop.""" - run_external: list[Callable[[Callable[[], None]], None]] = [passthrough_run_external] - - def _bind_run_external(callback: Callable[[Callable[[], None]], None]) -> None: - run_external[0] = callback - session.run_external = callback - - session.run_external = run_external[0] - interactive_loop( - console=console, - render=lambda: interactive_agenda_renderable(console, session), - on_event=lambda event: handle_interactive_event(session, event, run_external[0]), - bind_run_external=_bind_run_external, - timeout_seconds=_INTERACTIVE_INPUT_TIMEOUT_SECONDS, - ) diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py new file mode 100644 index 0000000..cd0c720 --- /dev/null +++ b/src/org/commands/agenda/app.py @@ -0,0 +1,478 @@ +"""Textual app for the interactive agenda command.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.rule import Rule +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.widgets import Static + +import org.tui.app +import org.tui.footer +import org.tui.help +import org.tui.prompt +import org.tui.selection +from org.commands.tasks.common import ( + clock_duration_prompt_label, + configured_capture_template_names, +) +from org.pipeline.load import resolve_input_paths + +from . import actions, ui +from .actions import AgendaSession, create_agenda_session + + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import ClassVar + + from org_parser.document import Heading + from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollUp, Resize + from textual.widget import Widget + + from .command import AgendaArgs + from .views import AgendaViewContext + + +_HELP_FOOTER_TEXT = "Type ? for help" + + +class AgendaRowWidget(Static): + """One reusable rendered line in the agenda viewport.""" + + def set_row(self, row_text: Text) -> None: + """Update the widget content for one viewport row.""" + self.update(row_text) + + +class AgendaViewport(Vertical): + """Virtualized agenda viewport backed by a reusable row widget pool.""" + + def __init__( + self, + *children: Widget, + name: str | None = None, + widget_id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Track row widgets explicitly so pool updates stay synchronous.""" + super().__init__( + *children, + name=name, + id=widget_id, + classes=classes, + disabled=disabled, + ) + self._row_widgets: list[AgendaRowWidget] = [] + + def ensure_row_pool(self, count: int) -> None: + """Grow or shrink the rendered row pool to match the viewport height.""" + missing = count - len(self._row_widgets) + for _ in range(max(0, missing)): + widget = AgendaRowWidget(classes="agenda-row") + self._row_widgets.append(widget) + self.mount(widget) + while len(self._row_widgets) > count: + self._row_widgets.pop().remove() + + def row_widgets(self) -> list[AgendaRowWidget]: + """Return row widgets in display order.""" + return self._row_widgets + + +class AgendaApp(org.tui.app.CommandApp): + """Textual app that backs interactive `agenda`.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", show=False), + Binding("down", "move_down", show=False), + Binding("n", "move_down", show=False), + Binding("p", "move_up", show=False), + Binding("left", "page_backward", show=False), + Binding("right", "page_forward", show=False), + Binding("f", "page_forward", show=False), + Binding("b", "page_backward", show=False), + Binding("enter", "edit_selected", show=False), + Binding("slash", "prompt_search", show=False), + Binding("x", "clear_search", show=False), + Binding("a", "prompt_capture", show=False), + Binding("dollar_sign", "archive_selected", show=False), + Binding("t", "prompt_state", show=False), + Binding("r", "prompt_refile", show=False), + Binding("c", "prompt_clock", show=False), + Binding("shift+left", "shift_date_backward", show=False), + Binding("shift+right", "shift_date_forward", show=False), + Binding("shift+up", "shift_time_backward", show=False), + Binding("shift+down", "shift_time_forward", show=False), + Binding("question_mark", "show_help", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def __init__(self, session: AgendaSession) -> None: + """Store the prepared agenda session.""" + super().__init__() + self.session = session + + def compose(self) -> ComposeResult: + """Build the main agenda screen ui.""" + yield Vertical( + Static(id="agenda-header"), + Static(id="agenda-header-rule"), + AgendaViewport(widget_id="agenda-body"), + Static(id="agenda-footer-rule"), + Static(id="agenda-footer"), + Static(id="agenda-status"), + ) + + def on_mount(self) -> None: + """Render initial state and arm the minute-refresh timer.""" + self.set_interval(1.0, self._refresh_for_clock_tick) + self._refresh_view() + + def on_resize(self, _event: Resize) -> None: + """Refresh viewport ui after terminal resize.""" + self._refresh_view() + + def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: + """Match legacy wheel-up navigation behavior.""" + actions.move_selection(self.session, -1) + self._refresh_view() + + def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: + """Match legacy wheel-down navigation behavior.""" + actions.move_selection(self.session, 1) + self._refresh_view() + + def _header_widget(self) -> Static: + return self.query_one("#agenda-header", Static) + + def _body_widget(self) -> AgendaViewport: + return self.query_one("#agenda-body", AgendaViewport) + + def _header_rule_widget(self) -> Static: + return self.query_one("#agenda-header-rule", Static) + + def _footer_widget(self) -> Static: + return self.query_one("#agenda-footer", Static) + + def _footer_rule_widget(self) -> Static: + return self.query_one("#agenda-footer-rule", Static) + + def _status_widget(self) -> Static: + return self.query_one("#agenda-status", Static) + + def _viewport_height(self) -> int: + body_height = self._body_widget().size.height + if body_height <= 0: + return max(3, self.size.height - 3) + return max(3, body_height) + + def _line_width(self) -> int: + body_width = self._body_widget().size.width + if body_width <= 0: + return max(40, self.size.width) + return max(40, body_width) + + def _refresh_header(self) -> None: + sticky = ui.sticky_day(self.session) + self._header_widget().update( + Text( + sticky.strftime("%A %Y-%m-%d"), + style="bold" if self.session.render.color_enabled else "", + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_footer(self, viewport_height: int) -> None: + total_rows = max(len(self.session.interactive_rows), 1) + end_line = min( + self.session.scroll_offset + viewport_height, + len(self.session.interactive_rows), + ) + search_text = self.session.search_text or "-" + footer_style = "dim" if self.session.render.color_enabled else "" + self._header_rule_widget().update(Rule(style=footer_style)) + self._footer_rule_widget().update(Rule(style=footer_style)) + self._footer_widget().update( + org.tui.footer.footer_renderable( + f"Lines {end_line}/{total_rows} | Search: {search_text}", + _HELP_FOOTER_TEXT, + style=footer_style, + ), + ) + + def _refresh_status(self) -> None: + footer_style = "dim" if self.session.render.color_enabled else "" + self._status_widget().update( + Text( + self.session.status_message or "", + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_rows(self, viewport_height: int) -> None: + body = self._body_widget() + body.ensure_row_pool(viewport_height) + ui.sync_scroll_offset(self.session, viewport_height) + selected_location = ui.selected_row_location(self.session) + line_width = self._line_width() + window = self.session.interactive_rows[ + self.session.scroll_offset : self.session.scroll_offset + viewport_height + ] + + for widget, row in zip(body.row_widgets(), window, strict=False): + widget.set_row( + ui.render_viewport_row_text( + row, + self.session.render, + self.session.column_widths, + line_width=line_width, + highlighted=row.location == selected_location, + ), + ) + + for widget in body.row_widgets()[len(window) :]: + widget.set_row(Text("")) + + def _refresh_view(self) -> None: + viewport_height = self._viewport_height() + self._refresh_header() + self._refresh_rows(viewport_height) + self._refresh_footer(viewport_height) + self._refresh_status() + + def _refresh_for_clock_tick(self) -> None: + if actions.refresh_session_if_minute_changed(self.session): + self._refresh_view() + + def _set_status(self, status_message: str) -> None: + self.session.status_message = status_message + + def _run_external(self, callback: Callable[[], None]) -> None: + self.run_external_and_refresh(callback, refresh=self._refresh_view) + + def _open_prompt( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen( + org.tui.prompt.PromptModalScreen( + label, + initial_value=initial_value, + on_change=on_change, + ), + callback=_complete, + ) + + def _open_selection( + self, + label: str, + options: list[org.tui.selection.SelectionOption], + *, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen(org.tui.selection.SelectionModalScreen(label, options), callback=_complete) + + def action_move_up(self) -> None: + """Move the selection one row upward.""" + actions.move_selection(self.session, -1) + self._refresh_view() + + def action_move_down(self) -> None: + """Move the selection one row downward.""" + actions.move_selection(self.session, 1) + self._refresh_view() + + def action_page_backward(self) -> None: + """Move the visible agenda window backward by the current span.""" + actions.set_start_date_relative(self.session, day_delta=-self.session.days) + self._refresh_view() + + def action_page_forward(self) -> None: + """Move the visible agenda window forward by the current span.""" + actions.set_start_date_relative(self.session, day_delta=self.session.days) + self._refresh_view() + + def action_edit_selected(self) -> None: + """Open the selected task in the external editor.""" + self._run_external(lambda: actions.edit_selected_task_in_external_editor(self.session)) + + def action_archive_selected(self) -> None: + """Archive the selected task subtree.""" + actions.archive_selected_task(self.session) + self._refresh_view() + + def action_clear_search(self) -> None: + """Clear the active search filter.""" + actions.clear_search(self.session) + self._refresh_view() + + def action_shift_date_backward(self) -> None: + """Shift the selected planning date backward by one day.""" + actions.apply_shift_date(self.session, day_delta=-1) + self._refresh_view() + + def action_shift_date_forward(self) -> None: + """Shift the selected planning date forward by one day.""" + actions.apply_shift_date(self.session, day_delta=1) + self._refresh_view() + + def action_shift_time_backward(self) -> None: + """Shift the selected timed planning row backward by one hour.""" + actions.apply_shift_time(self.session, hour_delta=-1) + self._refresh_view() + + def action_shift_time_forward(self) -> None: + """Shift the selected timed planning row forward by one hour.""" + actions.apply_shift_time(self.session, hour_delta=1) + self._refresh_view() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen( + org.tui.help.HelpModalScreen( + ui.AGENDA_HELP_ENTRIES, + color_enabled=self.session.render.color_enabled, + ), + ) + + def action_prompt_search(self) -> None: + """Open the search prompt with live filtering preview.""" + previous_text = self.session.search_text + self._search_cancel_text = previous_text + + def _preview(value: str) -> None: + actions.apply_search_text(self.session, value.strip()) + self._refresh_view() + + self._open_prompt( + "Search text (blank clears)", + initial_value=previous_text, + on_change=_preview, + on_submit=lambda value: actions.apply_search_text(self.session, value.strip()), + on_cancel=lambda: self._cancel_search(previous_text), + ) + + def _cancel_search(self, previous_text: str) -> None: + actions.apply_search_text(self.session, previous_text) + self.session.status_message = "Search cancelled" + + def action_prompt_capture(self) -> None: + """Prompt for a capture template and create a task.""" + status_message = actions.can_activate_agenda_capture_prompt(self.session) + if status_message is not None: + self._set_status(status_message) + self._refresh_view() + return + + template_names = configured_capture_template_names() + + self._open_selection( + "Capture template", + [org.tui.selection.SelectionOption(value=name, label=name) for name in template_names], + on_submit=lambda template_name: self._run_external( + lambda: actions.apply_capture_task(self.session, template_name), + ), + on_cancel=lambda: self._set_status("Capture cancelled"), + ) + + def action_prompt_state(self) -> None: + """Prompt for a TODO state transition.""" + status_message = actions.can_activate_agenda_state_prompt(self.session) + if status_message is not None: + self._set_status(status_message) + self._refresh_view() + return + states = actions.state_choices_for_selected_row(self.session) + + self._open_selection( + "TODO state", + [org.tui.selection.SelectionOption(value=state, label=state) for state in states], + on_submit=lambda selected_state: actions.apply_state_change_with_value( + self.session, + selected_state, + ), + on_cancel=lambda: self._set_status("State change cancelled"), + ) + + def action_prompt_refile(self) -> None: + """Prompt for a destination file and refile the selected task.""" + if actions.selected_task_row(self.session) is None: + self._set_status("Action available only on task rows") + self._refresh_view() + return + + self._open_selection( + "Destination file", + [ + org.tui.selection.SelectionOption(value=path, label=path) + for path in resolve_input_paths(self.session.args.files) + ], + on_submit=lambda destination_path: actions.apply_refile_with_value( + self.session, + destination_path, + ), + on_cancel=lambda: self._set_status("Refile cancelled"), + ) + + def action_prompt_clock(self) -> None: + """Prompt for a clock duration and append the clock entry.""" + if actions.selected_task_row(self.session) is None: + self._set_status("Action available only on task rows") + self._refresh_view() + return + + self._open_prompt( + clock_duration_prompt_label(), + on_submit=lambda value: actions.apply_clock_entry_with_value( + self.session, + value.strip(), + ), + on_cancel=lambda: self._set_status("Clock action cancelled"), + ) + + def action_quit_app(self) -> None: + """Exit the interactive agenda app.""" + self.exit() + + +def run_agenda_app( + args: AgendaArgs, + nodes: list[Heading], + render: ui.RenderContext, + view_ctx: AgendaViewContext, +) -> None: + """Run the Textual-backed interactive agenda app.""" + session = create_agenda_session(args, nodes, render, view_ctx) + AgendaApp(session).run() diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 039e572..901c89a 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -6,20 +6,21 @@ import sys from dataclasses import dataclass +import click import typer -from org import config as config_module -from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text, local_now -from org.tui import build_console, processing_status, setup_output +import org.config.app +import org.logging +from org.pipeline.load import load_and_process_data +from org.tui.bits import build_console, processing_status, setup_output +from org.tui.help import interactive_help_command_text -from . import events, layout +from . import ui +from .app import run_agenda_app from .views import resolve_view_context logger = logging.getLogger("org") -_HIGHLIGHT_ROW_STYLE = "on grey23" -_INTERACTIVE_INPUT_TIMEOUT_SECONDS = 1.0 @dataclass @@ -73,6 +74,11 @@ def _resolve_tasks_limit(max_results: int | None) -> int: return max_results +def _validate_agenda_args(args: AgendaArgs) -> None: + """Validate agenda arguments, including date parsing.""" + ui.resolve_agenda_start_date(args.date) + + def run_agenda(args: AgendaArgs) -> None: """Run the agenda command.""" color_enabled = setup_output(args) @@ -86,6 +92,7 @@ def run_agenda(args: AgendaArgs) -> None: raise typer.BadParameter("--days must be at least 1") args.max_results = _resolve_tasks_limit(args.max_results) + _validate_agenda_args(args) view_ctx = resolve_view_context(args) @@ -96,33 +103,16 @@ def run_agenda(args: AgendaArgs) -> None: console.print("No results", markup=False) return - if sys.stdin.isatty() and sys.stdout.isatty(): - events.run_agenda_interactive( - console, - events.create_agenda_session( - args, - nodes, - layout.RenderContext( - color_enabled=color_enabled, - done_states=done_states, - todo_states=todo_states, - ), - view_ctx, - ), - ) - return + if not (sys.stdin.isatty() and sys.stdout.isatty()): + raise click.UsageError("org agenda requires a TTY") - layout.render_agenda( - console, - layout.AgendaRenderInput( - args=args, - nodes=nodes, - now=local_now(), - render=layout.RenderContext( - color_enabled=color_enabled, - done_states=done_states, - todo_states=todo_states, - ), + run_agenda_app( + args, + nodes, + ui.RenderContext( + color_enabled=color_enabled, + done_states=done_states, + todo_states=todo_states, ), view_ctx, ) @@ -136,7 +126,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, help=interactive_help_command_text( "Show agenda for one day or a date range.", - layout.AGENDA_HELP_ENTRIES, + ui.AGENDA_HELP_ENTRIES, ), ) def agenda( # noqa: PLR0913 @@ -397,7 +387,7 @@ def agenda( # noqa: PLR0913 future_repeats=future_repeats, view=view, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "agenda") - config_module.log_command_arguments(args, "agenda") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "agenda") + org.logging.log_command_arguments(args, "agenda") run_agenda(args) diff --git a/src/org/commands/agenda/styles/app.tcss b/src/org/commands/agenda/styles/app.tcss new file mode 100644 index 0000000..5958219 --- /dev/null +++ b/src/org/commands/agenda/styles/app.tcss @@ -0,0 +1,20 @@ +Screen { + layout: vertical; +} + +#agenda-header, +#agenda-header-rule, +#agenda-footer, +#agenda-footer-rule, +#agenda-status { + height: auto; +} + +#agenda-body { + height: 1fr; + overflow: hidden; +} + +.agenda-row { + height: 1; +} diff --git a/src/org/commands/agenda/layout.py b/src/org/commands/agenda/ui.py similarity index 84% rename from src/org/commands/agenda/layout.py rename to src/org/commands/agenda/ui.py index 3fe490c..454a108 100644 --- a/src/org/commands/agenda/layout.py +++ b/src/org/commands/agenda/ui.py @@ -9,34 +9,26 @@ import org_parser.time as org_time import typer from rich import box -from rich.console import Console, Group -from rich.rule import Rule from rich.table import Table from rich.text import Text -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - InteractiveHelpEntry, - build_footer_prompt_text, - local_now, - render_interactive_help_modal, - shift_datetimes_by_unit, -) -from org.query_language import EvalContext, QueryRuntimeError, Stream -from org.tui import ( +from org.logic.time import local_now, parse_date_argument, shift_datetimes_by_unit +from org.query.runner import execute_query_or_raise +from org.tui.bits import ( heading_title_to_text, task_priority_to_text, task_state_prefix_to_text, task_tags_to_text, ) -from org.validation import parse_date_argument +from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: from org_parser.document import Heading + from rich.console import Console + from .actions import AgendaSession from .command import AgendaArgs - from .events import AgendaSession from .views import AgendaSectionSpec, AgendaViewContext @@ -113,7 +105,6 @@ class DayRowModel: day: date rows: list[AgendaRow] - selectable_row_indexes: list[int] @dataclass(frozen=True) @@ -188,7 +179,7 @@ class ViewportRow: location: tuple[int, int] | None -def _has_specific_time(timestamp: Timestamp) -> bool: +def has_specific_time(timestamp: Timestamp) -> bool: """Return whether an org timestamp includes an explicit start time.""" return timestamp.start_hour is not None @@ -198,7 +189,7 @@ def _is_active_planning_timestamp(timestamp: Timestamp | None) -> bool: return bool(timestamp is not None and timestamp.is_active) -def _resolve_agenda_start_date(raw_date: str | None) -> date: +def resolve_agenda_start_date(raw_date: str | None) -> date: """Resolve agenda start date from --date or today's date.""" if raw_date is None: return local_now().date() @@ -243,7 +234,7 @@ def _plain_section_row_details(node: Heading, day: date) -> tuple[str, str]: scheduled_source = "scheduled_untimed" if scheduled_day < day: scheduled_source = "overdue_scheduled" - elif _has_specific_time(node.scheduled): + elif has_specific_time(node.scheduled): scheduled_source = "scheduled" return _relative_day_text(scheduled_day, day), scheduled_source return "", "scheduled" @@ -254,12 +245,12 @@ def _has_selected_untimed_planning(node: Heading) -> bool: has_untimed_deadline = bool( node.deadline is not None and _is_active_planning_timestamp(node.deadline) - and not _has_specific_time(node.deadline), + and not has_specific_time(node.deadline), ) has_untimed_scheduled = bool( node.scheduled is not None and _is_active_planning_timestamp(node.scheduled) - and not _has_specific_time(node.scheduled), + and not has_specific_time(node.scheduled), ) return has_untimed_deadline or has_untimed_scheduled @@ -422,7 +413,7 @@ def _collect_repeat_timed_entries( for repeat in repeats: if repeat.timestamp.start.date() != day: continue - if _has_specific_time(repeat.timestamp): + if has_specific_time(repeat.timestamp): timed.append( _TimedEntry( node=node, @@ -450,7 +441,7 @@ def _collect_scheduled_entries( ): return timed, untimed - if _has_specific_time(node.scheduled): + if has_specific_time(node.scheduled): timed.append(_TimedEntry(node=node, when=node.scheduled.start, kind="scheduled")) else: untimed.append(node) @@ -472,7 +463,7 @@ def _collect_deadline_entries( if not _planning_matches_day(node.deadline, day, future_repeats=future_repeats): return timed, untimed - if _has_specific_time(node.deadline): + if has_specific_time(node.deadline): timed.append(_TimedEntry(node=node, when=node.deadline.start, kind="deadline")) else: untimed.append(node) @@ -488,7 +479,135 @@ def _merge_row_style(base_style: str, *, highlighted: bool) -> str: return _HIGHLIGHT_ROW_STYLE -def _selected_row_location(session: AgendaSession) -> tuple[int, int] | None: +def _cell_padding(cell: Text, width: int, *, justify: str = "left") -> Text: + """Return one padded text cell clipped to the requested display width.""" + padded = cell.copy() + padded.no_wrap = True + padded.overflow = "ellipsis" + padded.truncate(max(width, 0), overflow="ellipsis") + remaining = max(width - padded.cell_len, 0) + if remaining == 0: + return padded + spacing = Text(" " * remaining) + if justify == "right": + spacing.append_text(padded) + return spacing + padded.append(" " * remaining) + return padded + + +def viewport_task_width(widths: AgendaColumnWidths, line_width: int) -> int: + """Return the available task-column width for one interactive viewport row.""" + reserved_width = widths.category + widths.time + widths.tags + 3 + return max(10, line_width - reserved_width) + + +def _render_viewport_task_text( + agenda_row: AgendaRow, + render: RenderContext, + widths: AgendaColumnWidths, + *, + line: Text, + line_width: int, +) -> None: + """Render one interactive task row into the provided line.""" + node = agenda_row.node + if node is None: + return + task_width = viewport_task_width(widths, line_width) + line.append_text(_cell_padding(_category_text(node), widths.category)) + line.append(" ") + line.append_text(_cell_padding(Text(agenda_row.time_text), widths.time)) + line.append(" ") + line.append_text( + _cell_padding( + _heading_text( + node, + render=render, + prefix=agenda_row.prefix, + state_override=agenda_row.state_override, + ), + task_width, + ), + ) + line.append(" ") + line.append_text( + _cell_padding( + _tags_text(node, render.color_enabled), + widths.tags, + justify="right", + ), + ) + + +def _render_viewport_non_task_text( + agenda_row: AgendaRow, + render: RenderContext, + widths: AgendaColumnWidths, + *, + line: Text, +) -> None: + """Render one non-task interactive row into the provided line.""" + if agenda_row.kind == "day_header": + line.append(agenda_row.section_label, style="bold" if render.color_enabled else "") + return + if agenda_row.kind == "section": + if render.color_enabled and "[" in agenda_row.section_label: + line.append_text(Text.from_markup(agenda_row.section_label)) + else: + line.append(agenda_row.section_label, style="bold" if render.color_enabled else "") + return + if agenda_row.kind == "hour_marker": + line.append(" " * (widths.category + 1)) + line.append_text(_cell_padding(Text(agenda_row.time_text), widths.time)) + line.append(" ") + line.append("---------------", style="dim") + return + if agenda_row.kind == "now_marker": + line.append(" " * (widths.category + 1)) + line.append_text(_cell_padding(Text(agenda_row.time_text), widths.time)) + line.append(" ") + line.append( + "------ NOW ------", + style="bold yellow" if render.color_enabled else "", + ) + + +def render_viewport_row_text( + row: ViewportRow, + render: RenderContext, + widths: AgendaColumnWidths, + *, + line_width: int, + highlighted: bool, +) -> Text: + """Render one interactive viewport row as a single-line Textual-friendly text object.""" + if row.kind == "spacer": + return Text("") + + agenda_row = row.agenda_row + if agenda_row is None: + return Text("") + + line = Text(no_wrap=True, overflow="ellipsis", style=agenda_row.style or "") + + if agenda_row.kind == "task": + _render_viewport_task_text( + agenda_row, + render, + widths, + line=line, + line_width=line_width, + ) + else: + _render_viewport_non_task_text(agenda_row, render, widths, line=line) + + if highlighted: + line.stylize(_HIGHLIGHT_ROW_STYLE) + return line + + +def selected_row_location(session: AgendaSession) -> tuple[int, int] | None: """Return selected day/row indexes or None when no rows exist.""" if not session.row_locations: return None @@ -668,12 +787,15 @@ def _apply_section_query( view_name: str, ) -> list[Heading]: """Apply section query against nodes with $date in context, returning matched nodes.""" - try: - results = spec.query(Stream(nodes), EvalContext({"date": day})) - except QueryRuntimeError as err: - raise typer.BadParameter( - f"Agenda filter/order-by query failed (section={spec.name}, view={view_name}): {err}", - ) from err + results = execute_query_or_raise( + spec.query, + nodes, + {"date": day}, + lambda message: typer.BadParameter( + "Agenda filter/order-by query failed " + f"(section={spec.name}, view={view_name}): {message}", + ), + ) return [cast("Heading", node) for node in results] @@ -695,10 +817,7 @@ def build_view_day_model( continue rows.append(AgendaRow(kind="spacer", day=day)) rows.extend(section_rows) - selectable = [ - idx for idx, row in enumerate(rows) if row.kind == "task" and row.node is not None - ] - return DayRowModel(day=day, rows=rows, selectable_row_indexes=selectable) + return DayRowModel(day=day, rows=rows) def _build_agenda_table_with_widths( @@ -798,7 +917,7 @@ def _render_section_row( return 1 -def _interactive_artifacts( +def build_interactive_artifacts( day_models: list[DayRowModel], render: RenderContext, ) -> tuple[list[tuple[int, int]], list[ViewportRow], AgendaColumnWidths]: @@ -925,79 +1044,6 @@ def _render_viewport_row( ) -def interactive_agenda_renderable(console: Console, session: AgendaSession) -> Group: - """Build scrollable interactive agenda renderable with fixed footer controls.""" - if session.show_help_modal: - return Group( - render_interactive_help_modal( - AGENDA_HELP_ENTRIES, - color_enabled=session.render.color_enabled, - ), - ) - rows = session.interactive_rows - widths = session.column_widths - viewport_height = max(3, console.size.height - 5) - selected_row = _selected_viewport_row_index(rows, _selected_row_location(session)) - max_offset = max(0, len(rows) - viewport_height) - session.scroll_offset = min(max(session.scroll_offset, 0), max_offset) - session.scroll_offset = _adjust_scroll_offset( - session.scroll_offset, - selected_row, - viewport_height, - max_offset, - ) - window = rows[session.scroll_offset : session.scroll_offset + viewport_height] - selected_location = _selected_row_location(session) - viewport_table = _build_interactive_viewport_table_with_widths(widths) - for row in window: - _render_viewport_row(viewport_table, row, session, selected_location) - for _ in range(viewport_height - len(window)): - viewport_table.add_row(Text(""), Text(""), Text(""), Text("")) - sticky_day = _resolve_sticky_day(session, rows) - end_line = min(session.scroll_offset + viewport_height, len(rows)) - total_lines = max(len(rows), 1) - search_text = session.search_text or "-" - scroll_text = f"Lines {end_line}/{total_lines} | Search: {search_text}" - prompt_line = None - if session.active_prompt is not None: - prompt_line = build_footer_prompt_text(session.active_prompt.prompt) - status = session.status_message or "" - footer_style = "dim" if session.render.color_enabled else "" - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(scroll_text, style=footer_style, no_wrap=True, overflow="ellipsis"), - Text(INTERACTIVE_HELP_FOOTER_HINT, style=footer_style, no_wrap=True, overflow="ellipsis"), - ) - status_text = Text(status, style=footer_style, no_wrap=True, overflow="ellipsis") - sticky_header = Text( - _day_header_label(sticky_day), - style="bold" if session.render.color_enabled else "", - no_wrap=True, - overflow="ellipsis", - ) - header_rule = Rule(style=footer_style) - if prompt_line is None: - return Group( - sticky_header, - header_rule, - viewport_table, - Rule(style=footer_style), - footer_line, - status_text, - ) - return Group( - sticky_header, - header_rule, - viewport_table, - Rule(style=footer_style), - footer_line, - prompt_line, - status_text, - ) - - def _adjust_scroll_offset( scroll_offset: int, selected_row: int | None, @@ -1017,7 +1063,7 @@ def _adjust_scroll_offset( def _resolve_sticky_day(session: AgendaSession, rows: list[ViewportRow]) -> date: """Resolve the sticky interactive day header from selection or visible rows.""" - selected_location = _selected_row_location(session) + selected_location = selected_row_location(session) if selected_location is not None: return session.day_models[selected_location[0]].day if rows: @@ -1025,13 +1071,31 @@ def _resolve_sticky_day(session: AgendaSession, rows: list[ViewportRow]) -> date return session.start_date +def sticky_day(session: AgendaSession) -> date: + """Return the current sticky day for the interactive agenda viewport.""" + return _resolve_sticky_day(session, session.interactive_rows) + + +def sync_scroll_offset(session: AgendaSession, viewport_height: int) -> None: + """Clamp and adjust scroll offset to keep the current selection visible.""" + rows = session.interactive_rows + selected_row = _selected_viewport_row_index(rows, selected_row_location(session)) + max_offset = max(0, len(rows) - viewport_height) + session.scroll_offset = _adjust_scroll_offset( + session.scroll_offset, + selected_row, + viewport_height, + max_offset, + ) + + def render_agenda( console: Console, render_input: AgendaRenderInput, view_ctx: AgendaViewContext, ) -> None: """Render agenda output table using view section specs.""" - start_date = _resolve_agenda_start_date(render_input.args.date) + start_date = resolve_agenda_start_date(render_input.args.date) rendered_tables: list[Table] = [] total_rows = 0 day_models: list[DayRowModel] = [] diff --git a/src/org/commands/agenda/views.py b/src/org/commands/agenda/views.py index 28c2b57..d1679ab 100644 --- a/src/org/commands/agenda/views.py +++ b/src/org/commands/agenda/views.py @@ -7,11 +7,13 @@ import typer -from org import config as config_module -from org.query_language import CompiledQuery, QueryParseError, compile_query_text +import org.config.app +from org.query.runner import compile_filter_order_query if TYPE_CHECKING: + from org.query.engine.compiler import CompiledQuery + from .command import AgendaArgs @@ -35,18 +37,15 @@ class AgendaViewContext: def _compile_section_query(filter_query: str, order_by: str | None) -> CompiledQuery: """Compile one agenda section filter/order query text.""" - base_query = f"select({filter_query})" - if order_by is None: - return compile_query_text(base_query) - return compile_query_text(f"{base_query} | sort_by({order_by})") + return compile_filter_order_query(filter_query, order_by) -def _fallback_agenda_view() -> config_module.AgendaViewConfig: +def _fallback_agenda_view() -> org.config.app.AgendaViewConfig: """Return built-in fallback agenda view definition.""" - return config_module.AgendaViewConfig( + return org.config.app.AgendaViewConfig( name="default", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="[bold dim white]Agenda[/]", filter="true", order_by=None, @@ -57,13 +56,13 @@ def _fallback_agenda_view() -> config_module.AgendaViewConfig: ) -def _compile_view_section_specs(view: config_module.AgendaViewConfig) -> list[AgendaSectionSpec]: +def _compile_view_section_specs(view: org.config.app.AgendaViewConfig) -> list[AgendaSectionSpec]: """Compile one agenda view's filters into renderable section specs.""" section_specs: list[AgendaSectionSpec] = [] for section in view.sections: try: query = _compile_section_query(section.filter, section.order_by) - except QueryParseError as err: + except Exception as err: raise typer.BadParameter( f"Invalid agenda filter/order-by (view={view.name}, section={section.name}): {err}", ) from err @@ -81,7 +80,7 @@ def _compile_view_section_specs(view: config_module.AgendaViewConfig) -> list[Ag def resolve_view_context(args: AgendaArgs) -> AgendaViewContext: """Resolve configured or fallback view context for agenda rendering.""" selected_view = args.view.strip() if args.view else None - configured_views = config_module.CONFIG_AGENDA_VIEWS + configured_views = org.config.app.CONFIG_AGENDA_VIEWS if selected_view is None: view = _fallback_agenda_view() diff --git a/src/org/commands/board/__init__.py b/src/org/commands/board/__init__.py index 5c36e46..fcd0cce 100644 --- a/src/org/commands/board/__init__.py +++ b/src/org/commands/board/__init__.py @@ -1,6 +1,6 @@ """Board command package.""" -from .command import BoardArgs, register, run_flow_board +from .command import BoardArgs, register, run_board -__all__ = ["BoardArgs", "register", "run_flow_board"] +__all__ = ["BoardArgs", "register", "run_board"] diff --git a/src/org/commands/board/events.py b/src/org/commands/board/actions.py similarity index 65% rename from src/org/commands/board/events.py rename to src/org/commands/board/actions.py index 088c319..f3a34b9 100644 --- a/src/org/commands/board/events.py +++ b/src/org/commands/board/actions.py @@ -9,54 +9,30 @@ import typer -from org import config as config_module -from org.cli_common import load_and_process_data -from org.commands.archive import archive_heading_subtree_and_save -from org.commands.editor import edit_heading_subtree_in_external_editor -from org.commands.interactive_common import ( - FooterPromptState, +import org.config.app +from org.commands.tasks.capture import TasksCaptureArgs, capture_task +from org.commands.tasks.common import configured_capture_template_names, save_document +from org.logic.archive import archive_heading_subtree_and_save +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import ( HeadingLocator, - InputEvent, - InteractiveEvent, - InteractivePromptState, - TimeoutEvent, - advance_timestamp_by_repeater, append_repeat_transition, - apply_help_modal_key, - create_interactive_prompt_state, - handle_active_prompt_event, heading_locator, - interactive_loop, - local_now, resolve_heading_locator, shift_priority, ) -from org.commands.search_common import filter_nodes_by_search -from org.commands.tasks.capture import TasksCaptureArgs, capture_task -from org.commands.tasks.common import ( - PromptActionConfig, - capture_template_prompt_config, - configured_capture_template_names, - resolve_capture_template_selection, - save_document, -) -from org.query_language import ( - CompiledQuery, - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) - -from .layout import _interactive_flow_board_renderable +from org.logic.time import advance_timestamp_by_repeater, local_now +from org.pipeline.load import load_and_process_data +from org.query.runner import compile_filter_order_query, execute_query_or_raise if TYPE_CHECKING: from collections.abc import Callable, Sequence from org_parser.document import Document, Heading - from rich.console import Console + + from org.query.engine.compiler import CompiledQuery from .command import BoardArgs @@ -64,6 +40,21 @@ logger = logging.getLogger("org") +def _board_query_error_builder( + view_name: str, + column_name: str, +) -> Callable[[str], typer.BadParameter]: + """Build one board query runtime error converter.""" + + def _query_error(message: str) -> typer.BadParameter: + return typer.BadParameter( + "Board filter/order-by query failed " + f"(view={view_name}, column={column_name}): {message}", + ) + + return _query_error + + @dataclass class BoardColumn: """One board column with title and assigned tasks.""" @@ -97,10 +88,6 @@ class BoardSession: status_message: str all_columns: Sequence[BoardColumn] = field(default_factory=list) search_text: str = "" - search_prompt_previous_text: str | None = None - show_help_modal: bool = False - active_prompt: InteractivePromptState | None = None - run_external: Callable[[Callable[[], None]], None] | None = None def _coerce_latest_timestamp_start(node: Heading) -> datetime | None: @@ -164,34 +151,31 @@ def _compile_column_filter_query( order_by: str | None, ) -> CompiledQuery: """Compile one board column filter/order query text.""" - base_query = f"select({filter_query})" - if order_by is None: - return compile_query_text(base_query) - return compile_query_text(f"{base_query} | sort_by({order_by})") + return compile_filter_order_query(filter_query, order_by) -def _fallback_view_config() -> config_module.BoardViewConfig: +def _fallback_view_config() -> org.config.app.BoardViewConfig: """Return built-in fallback board filter view definition.""" - return config_module.BoardViewConfig( + return org.config.app.BoardViewConfig( name="fallback", columns=[ - config_module.BoardColumnConfig(name="Backlog", filter=".todo == null"), - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig( name="TODO", filter=".todo != null and not(.is_completed)", ), - config_module.BoardColumnConfig(name="DONE", filter=".is_completed"), + org.config.app.BoardColumnConfig(name="DONE", filter=".is_completed"), ], ) -def compile_view_column_specs(view: config_module.BoardViewConfig) -> list[BoardColumnSpec]: +def compile_view_column_specs(view: org.config.app.BoardViewConfig) -> list[BoardColumnSpec]: """Compile one board view's filters into renderable column specs.""" column_specs: list[BoardColumnSpec] = [] for column in view.columns: try: query = _compile_column_filter_query(column.filter, column.order_by) - except QueryParseError as err: + except Exception as err: raise typer.BadParameter( f"Invalid board filter/order-by (view={view.name}, column={column.name}): {err}", ) from err @@ -219,7 +203,7 @@ def _resolve_selected_view_name(args: BoardArgs) -> str | None: def resolve_column_specs(args: BoardArgs) -> list[BoardColumnSpec]: """Resolve configured or fallback filter columns for board rendering.""" selected_view = _resolve_selected_view_name(args) - configured_views = config_module.CONFIG_BOARD_VIEWS + configured_views = org.config.app.CONFIG_BOARD_VIEWS if selected_view is None: return compile_view_column_specs(_fallback_view_config()) @@ -241,15 +225,12 @@ def build_selector_board_columns( """Evaluate filter specs against processed task stream.""" columns: list[BoardColumn] = [] for spec in column_specs: - try: - results = spec.query(Stream(nodes), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter( - ( - "Board filter/order-by query failed " - f"(view={spec.view_name}, column={spec.name}): {err}" - ), - ) from err + results = execute_query_or_raise( + spec.query, + nodes, + {}, + _board_query_error_builder(spec.view_name, spec.name), + ) column_nodes = [cast("Heading", node) for node in results] columns.append(BoardColumn(title=spec.name, nodes=column_nodes)) @@ -299,7 +280,7 @@ def _ensure_selection_bounds(session: BoardSession) -> None: session.selected_row_index = min(max(session.selected_row_index, 0), len(selected_nodes) - 1) -def _refresh_visible_columns( +def refresh_visible_columns( session: BoardSession, preserve_identity: HeadingLocator | None, ) -> None: @@ -354,17 +335,17 @@ def reload_session( filtered_nodes, resolve_column_specs(session.args), ) - _refresh_visible_columns(session, preserve_identity) + refresh_visible_columns(session, preserve_identity) -def create_flow_board_session( +def create_board_session( args: BoardArgs, nodes: list[Heading], todo_states: list[str], done_states: list[str], color_enabled: bool, ) -> BoardSession: - """Create interactive flow board session state.""" + """Create interactive board session state.""" columns = build_selector_board_columns(nodes, resolve_column_specs(args)) selected_column_index = 0 for index, column in enumerate(columns): @@ -385,9 +366,6 @@ def create_flow_board_session( scroll_offset=0, status_message="", search_text="", - show_help_modal=False, - active_prompt=None, - run_external=None, ) _ensure_selection_bounds(session) return session @@ -429,7 +407,7 @@ def step_heading_state(heading: Heading, *, direction: int) -> tuple[str | None, return new_state, status -def _move_selection_vertical(session: BoardSession, step: int) -> None: +def move_selection_vertical(session: BoardSession, step: int) -> None: """Move highlighted task up/down in the selected column.""" selected_nodes = session.columns[session.selected_column_index].nodes if not selected_nodes: @@ -509,7 +487,7 @@ def apply_state_move(session: BoardSession, *, direction: int) -> None: session.status_message = f"State updated: {old_state or '-'} -> {new_state or '-'}" -def _apply_priority_shift(session: BoardSession, *, increase: bool) -> None: +def apply_priority_shift(session: BoardSession, *, increase: bool) -> None: """Increase or decrease selected task priority.""" heading = selected_node(session) if heading is None: @@ -541,7 +519,7 @@ def _apply_priority_shift(session: BoardSession, *, increase: bool) -> None: session.status_message = f"Priority updated: {old_priority or '-'} -> {new_priority or '-'}" -def _edit_selected_task_in_external_editor(session: BoardSession) -> None: +def edit_selected_task_in_external_editor(session: BoardSession) -> None: """Edit selected task subtree in configured external editor.""" heading = selected_node(session) if heading is None: @@ -568,7 +546,7 @@ def _edit_selected_task_in_external_editor(session: BoardSession) -> None: session.status_message = "Task updated" -def _archive_selected_task(session: BoardSession) -> None: +def archive_selected_task(session: BoardSession) -> None: """Archive selected task subtree using shared archive-location rules.""" heading = selected_node(session) if heading is None: @@ -591,7 +569,7 @@ def _archive_selected_task(session: BoardSession) -> None: session.status_message = "Task archived" -def _apply_capture_task(session: BoardSession, template_name: str) -> None: +def apply_capture_task(session: BoardSession, template_name: str) -> None: """Capture a new task and reload board session.""" session.status_message = "" capture_args = TasksCaptureArgs( @@ -618,7 +596,7 @@ def _apply_capture_task(session: BoardSession, template_name: str) -> None: session.status_message = "Task captured" -def _clear_search(session: BoardSession) -> None: +def clear_search(session: BoardSession) -> None: """Clear active interactive search and restore full board columns.""" if not session.search_text: session.status_message = "Search already clear" @@ -627,220 +605,24 @@ def _clear_search(session: BoardSession) -> None: selected = selected_node(session) preserve_identity = heading_locator(selected) if selected is not None else None session.search_text = "" - _refresh_visible_columns(session, preserve_identity) + refresh_visible_columns(session, preserve_identity) session.status_message = "Search cleared" -def _apply_search_text(session: BoardSession, search_text: str) -> None: +def apply_search_text(session: BoardSession, search_text: str) -> None: """Apply search text to board columns and update match status.""" selected = selected_node(session) preserve_identity = heading_locator(selected) if selected is not None else None session.search_text = search_text - _refresh_visible_columns(session, preserve_identity) + refresh_visible_columns(session, preserve_identity) session.status_message = ( "Search cleared" if not search_text else f"{_visible_task_count(session.columns)} matches" ) -def _activate_prompt( - session: BoardSession, - config: PromptActionConfig, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> None: - """Attach one active footer prompt to the board session.""" - session.active_prompt = create_interactive_prompt_state( - config, - submit_value=submit_value, - preview_value=preview_value, - cancel=cancel, - ) - - -def _activate_capture_prompt(session: BoardSession) -> None: - template_names = configured_capture_template_names() - if not template_names: - session.status_message = "No capture templates configured" - return - config = capture_template_prompt_config() - - def _submit(value: str) -> bool: - value = value.strip() - template_name = resolve_capture_template_selection(value, template_names) - if template_name is None and not value: - session.status_message = config.cancel_status - return False - if template_name is None: - session.status_message = config.invalid_status - return True - _run_external(session, lambda: _apply_capture_task(session, template_name)) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_search_prompt(session: BoardSession) -> None: - session.search_prompt_previous_text = session.search_text - config = PromptActionConfig( - prompt=FooterPromptState(label="Search text (blank clears)"), - cancel_status="Search cancelled", - invalid_status="Invalid search input", - ) - - def _submit(value: str) -> bool: - session.search_prompt_previous_text = None - _apply_search_text(session, value.strip()) - return False - - def _preview(value: str) -> None: - _apply_search_text(session, value.strip()) - - def _cancel() -> None: - previous_text = session.search_prompt_previous_text or "" - session.search_prompt_previous_text = None - _apply_search_text(session, previous_text) - session.status_message = config.cancel_status - - _activate_prompt( - session, - config, - submit_value=_submit, - preview_value=_preview, - cancel=_cancel, - ) - - -def _handle_capture_prompt_activation(session: BoardSession) -> None: - _activate_capture_prompt(session) - - -def _handle_search_prompt_activation(session: BoardSession) -> None: - _activate_search_prompt(session) - - -def _handle_help_modal_event(session: BoardSession, event: InteractiveEvent) -> bool: - if not session.show_help_modal: - return False - if not isinstance(event, TimeoutEvent): - session.show_help_modal = False - return True - - -def _handle_navigation_key(session: BoardSession, key: str) -> bool: - handler = { - "DOWN": lambda: _move_selection_vertical(session, 1), - "WHEEL-DOWN": lambda: _move_selection_vertical(session, 1), - "UP": lambda: _move_selection_vertical(session, -1), - "WHEEL-UP": lambda: _move_selection_vertical(session, -1), - "RIGHT": lambda: move_selection_horizontal(session, 1), - "LEFT": lambda: move_selection_horizontal(session, -1), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_mutation_key( - session: BoardSession, - key: str, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "ENTER": lambda: run_external(lambda: _edit_selected_task_in_external_editor(session)), - "$": lambda: _archive_selected_task(session), - "x": lambda: _clear_search(session), - "S-LEFT": lambda: apply_state_move(session, direction=-1), - "S-RIGHT": lambda: apply_state_move(session, direction=1), - "S-UP": lambda: _apply_priority_shift(session, increase=True), - "S-DOWN": lambda: _apply_priority_shift(session, increase=False), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_prompt_activation_key( - session: BoardSession, - key: str, - _run_external_callback: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "a": _handle_capture_prompt_activation, - "/": _handle_search_prompt_activation, - }.get(key) - if handler is None: - return False - handler(session) - return True - - -def _handle_keypress_event( - session: BoardSession, - key: str, - run_external: Callable[[Callable[[], None]], None] | None = None, -) -> bool: - effective_run_external = passthrough_run_external if run_external is None else run_external - consumed, next_help_modal = apply_help_modal_key(key, show_help_modal=session.show_help_modal) - session.show_help_modal = next_help_modal - if consumed: - return True - if key in {"q", "ESC"}: - return False - if _handle_navigation_key(session, key): - return True - if _handle_mutation_key(session, key, effective_run_external): - return True - if _handle_prompt_activation_key(session, key, effective_run_external): - return True - if key and key != "IGNORE": - session.status_message = f"Unsupported key: {key}" - return True - - -def passthrough_run_external(callback: Callable[[], None]) -> None: - """Run an external callback immediately.""" - callback() - - -def _run_external(session: BoardSession, callback: Callable[[], None]) -> None: - runner = session.run_external or passthrough_run_external - runner(callback) - - -def handle_interactive_event( - session: BoardSession, - event: InteractiveEvent, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - """Handle one flow-board interactive event.""" - if _handle_help_modal_event(session, event): - return True - if session.active_prompt is not None: - return handle_active_prompt_event(session, event) - if isinstance(event, TimeoutEvent): - return True - if isinstance(event, InputEvent): - return True - return _handle_keypress_event(session, event.key, run_external) - - -def run_flow_board_interactive(console: Console, session: BoardSession) -> None: - """Run the interactive flow-board loop.""" - run_external: list[Callable[[Callable[[], None]], None]] = [passthrough_run_external] - - def _bind_run_external(callback: Callable[[Callable[[], None]], None]) -> None: - run_external[0] = callback - session.run_external = callback - - session.run_external = run_external[0] - interactive_loop( - console=console, - render=lambda: _interactive_flow_board_renderable(console, session), - on_event=lambda event: handle_interactive_event(session, event, run_external[0]), - bind_run_external=_bind_run_external, - timeout_seconds=None, - ) +def can_activate_capture_prompt(session: BoardSession) -> str | None: + """Return status text when the capture prompt cannot be opened.""" + del session + if not configured_capture_template_names(): + return "No capture templates configured" + return None diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py new file mode 100644 index 0000000..3252633 --- /dev/null +++ b/src/org/commands/board/app.py @@ -0,0 +1,426 @@ +"""Textual app for the interactive board command.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.align import Align +from rich.console import Group, RenderableType +from rich.rule import Rule +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + +import org.tui.app +import org.tui.footer +import org.tui.help +import org.tui.prompt +import org.tui.selection +from org.commands.tasks.common import ( + configured_capture_template_names, +) + +from . import actions, ui +from .actions import BoardSession, create_board_session + + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import ClassVar + + from org_parser.document import Heading + from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollUp, Resize + from textual.widget import Widget + + from .command import BoardArgs + + +_HELP_FOOTER_TEXT = "Type ? for help" + + +class BoardColumnWidget(Static): + """One interactive board column with title and visible card slice.""" + + def set_column( + self, + title: str, + body: RenderableType, + *, + selected: bool, + ) -> None: + """Update the column title and rendered card group.""" + title_text = ui.render_column_title_text(title) + title_text.no_wrap = True + title_text.overflow = "ellipsis" + if selected: + title_text.stylize("reverse") + self.update(Group(Align.center(title_text), body)) + + +class BoardViewport(Horizontal): + """Horizontal board viewport that owns one widget per visible column.""" + + def __init__( + self, + *children: Widget, + name: str | None = None, + widget_id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Track column widgets explicitly so pool updates stay synchronous.""" + super().__init__( + *children, + name=name, + id=widget_id, + classes=classes, + disabled=disabled, + ) + self._column_widgets: list[BoardColumnWidget] = [] + + def ensure_column_pool(self, count: int) -> None: + """Grow or shrink the rendered column pool to match the board state.""" + missing = count - len(self._column_widgets) + for _ in range(max(0, missing)): + widget = BoardColumnWidget(classes="board-column") + self._column_widgets.append(widget) + self.mount(widget) + while len(self._column_widgets) > count: + self._column_widgets.pop().remove() + + def column_widgets(self) -> list[BoardColumnWidget]: + """Return board column widgets in display order.""" + return self._column_widgets + + +class BoardApp(org.tui.app.CommandApp): + """Textual app that backs interactive `board`.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", show=False), + Binding("down", "move_down", show=False), + Binding("left", "move_left", show=False), + Binding("right", "move_right", show=False), + Binding("enter", "edit_selected", show=False), + Binding("slash", "prompt_search", show=False), + Binding("x", "clear_search", show=False), + Binding("a", "prompt_capture", show=False), + Binding("dollar_sign", "archive_selected", show=False), + Binding("shift+left", "move_state_left", show=False), + Binding("shift+right", "move_state_right", show=False), + Binding("shift+up", "increase_priority", show=False), + Binding("shift+down", "decrease_priority", show=False), + Binding("question_mark", "show_help", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def __init__(self, session: BoardSession) -> None: + """Store the prepared board session.""" + super().__init__() + self.session = session + self._visible_end_row = 0 + + def compose(self) -> ComposeResult: + """Build the main board app ui.""" + yield Vertical( + BoardViewport(widget_id="board-body"), + Static(id="board-footer-rule"), + Static(id="board-footer"), + Static(id="board-status"), + ) + + def on_mount(self) -> None: + """Render initial state after mount.""" + self._refresh_view() + + def on_resize(self, _event: Resize) -> None: + """Refresh viewport ui after terminal resize.""" + self._refresh_view() + + def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: + """Match legacy wheel-up navigation behavior.""" + actions.move_selection_vertical(self.session, -1) + self._refresh_view() + + def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: + """Match legacy wheel-down navigation behavior.""" + actions.move_selection_vertical(self.session, 1) + self._refresh_view() + + def _body_widget(self) -> BoardViewport: + return self.query_one("#board-body", BoardViewport) + + def _footer_widget(self) -> Static: + return self.query_one("#board-footer", Static) + + def _footer_rule_widget(self) -> Static: + return self.query_one("#board-footer-rule", Static) + + def _status_widget(self) -> Static: + return self.query_one("#board-status", Static) + + def _body_height(self) -> int: + body_height = self._body_widget().size.height + if body_height <= 0: + return max(3, self.size.height - 2) + return max(3, body_height - 1) + + def _body_width(self) -> int: + body_width = self._body_widget().size.width + if body_width <= 0: + return max(80, self.size.width) + return max(80, body_width) + + def _render_config(self) -> ui.BoardPanelRenderConfig: + return ui.BoardPanelRenderConfig( + width=ui.estimate_panel_content_width( + self._body_width(), + len(self.session.columns), + ), + color_enabled=self.session.color_enabled, + done_states=self.session.done_states, + todo_states=self.session.todo_states, + ) + + def _refresh_columns(self) -> None: + body = self._body_widget() + body.ensure_column_pool(len(self.session.columns)) + render = self._render_config() + body_height = self._body_height() + start_row, end_row = ui.selected_column_window( + self.session, + render, + body_height=body_height, + ) + self._visible_end_row = min(end_row, ui.selected_column_total_rows(self.session)) + + for column_index, (widget, column) in enumerate( + zip(body.column_widgets(), self.session.columns, strict=False), + ): + column_end = ui.column_window_end( + column.nodes, + render, + start_row=start_row, + body_height=body_height, + ) + panels = [ + ui.build_task_panel( + node, + render, + highlighted=( + column_index == self.session.selected_column_index + and row_index == self.session.selected_row_index + ), + ) + for row_index, node in enumerate( + column.nodes[start_row:column_end], + start=start_row, + ) + ] + renderable: RenderableType = Text("") if not panels else Group(*panels) + widget.set_column( + column.title, + renderable, + selected=column_index == self.session.selected_column_index, + ) + + def _refresh_footer(self) -> None: + search_text = self.session.search_text or "-" + total_rows = ui.selected_column_total_rows(self.session) + footer_style = "dim" if self.session.color_enabled else "" + self._footer_rule_widget().update(Rule(style=footer_style)) + self._footer_widget().update( + org.tui.footer.footer_renderable( + f"Rows {self._visible_end_row}/{total_rows} | Search: {search_text}", + _HELP_FOOTER_TEXT, + style=footer_style, + ), + ) + + def _refresh_status(self) -> None: + footer_style = "dim" if self.session.color_enabled else "" + status = " ".join((self.session.status_message or "").splitlines()) + self._status_widget().update( + Text( + status, + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_view(self) -> None: + self._refresh_columns() + self._refresh_footer() + self._refresh_status() + + def _set_status(self, status_message: str) -> None: + self.session.status_message = status_message + + def _run_external(self, callback: Callable[[], None]) -> None: + self.run_external_and_refresh(callback, refresh=self._refresh_view) + + def _open_prompt( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen( + org.tui.prompt.PromptModalScreen( + label, + initial_value=initial_value, + on_change=on_change, + ), + callback=_complete, + ) + + def _open_selection( + self, + label: str, + options: list[org.tui.selection.SelectionOption], + *, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen(org.tui.selection.SelectionModalScreen(label, options), callback=_complete) + + def action_move_up(self) -> None: + """Move the selection one task upward in the selected column.""" + actions.move_selection_vertical(self.session, -1) + self._refresh_view() + + def action_move_down(self) -> None: + """Move the selection one task downward in the selected column.""" + actions.move_selection_vertical(self.session, 1) + self._refresh_view() + + def action_move_left(self) -> None: + """Move board focus one non-empty column to the left.""" + actions.move_selection_horizontal(self.session, -1) + self._refresh_view() + + def action_move_right(self) -> None: + """Move board focus one non-empty column to the right.""" + actions.move_selection_horizontal(self.session, 1) + self._refresh_view() + + def action_move_state_left(self) -> None: + """Step the selected task state backward through document state order.""" + actions.apply_state_move(self.session, direction=-1) + self._refresh_view() + + def action_move_state_right(self) -> None: + """Step the selected task state forward through document state order.""" + actions.apply_state_move(self.session, direction=1) + self._refresh_view() + + def action_increase_priority(self) -> None: + """Increase the selected task priority.""" + actions.apply_priority_shift(self.session, increase=True) + self._refresh_view() + + def action_decrease_priority(self) -> None: + """Decrease the selected task priority.""" + actions.apply_priority_shift(self.session, increase=False) + self._refresh_view() + + def action_edit_selected(self) -> None: + """Open the selected task in the external editor.""" + self._run_external(lambda: actions.edit_selected_task_in_external_editor(self.session)) + + def action_archive_selected(self) -> None: + """Archive the selected task subtree.""" + actions.archive_selected_task(self.session) + self._refresh_view() + + def action_clear_search(self) -> None: + """Clear the active board search filter.""" + actions.clear_search(self.session) + self._refresh_view() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen( + org.tui.help.HelpModalScreen( + ui.BOARD_HELP_ENTRIES, + color_enabled=self.session.color_enabled, + ), + ) + + def action_prompt_search(self) -> None: + """Open the search prompt with live per-column filtering preview.""" + previous_text = self.session.search_text + + def _preview(value: str) -> None: + actions.apply_search_text(self.session, value.strip()) + self._refresh_view() + + self._open_prompt( + "Search text (blank clears)", + initial_value=previous_text, + on_change=_preview, + on_submit=lambda value: actions.apply_search_text(self.session, value.strip()), + on_cancel=lambda: self._cancel_search(previous_text), + ) + + def _cancel_search(self, previous_text: str) -> None: + actions.apply_search_text(self.session, previous_text) + self.session.status_message = "Search cancelled" + + def action_prompt_capture(self) -> None: + """Prompt for a capture template and create a task.""" + status_message = actions.can_activate_capture_prompt(self.session) + if status_message is not None: + self._set_status(status_message) + self._refresh_view() + return + + template_names = configured_capture_template_names() + + self._open_selection( + "Capture template", + [org.tui.selection.SelectionOption(value=name, label=name) for name in template_names], + on_submit=lambda template_name: self._run_external( + lambda: actions.apply_capture_task(self.session, template_name), + ), + on_cancel=lambda: self._set_status("Capture cancelled"), + ) + + def action_quit_app(self) -> None: + """Exit the interactive board app.""" + self.exit() + + +def run_board_app( + args: BoardArgs, + nodes: list[Heading], + todo_states: list[str], + done_states: list[str], + color_enabled: bool, +) -> None: + """Run the Textual-backed interactive board app.""" + session = create_board_session(args, nodes, todo_states, done_states, color_enabled) + BoardApp(session).run() diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index f91540a..0600585 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -6,13 +6,17 @@ import sys from dataclasses import dataclass +import click import typer -from org import config as config_module -from org.cli_common import load_and_process_data -from org.commands.board import events, layout -from org.commands.interactive_common import interactive_help_command_text -from org.tui import build_console, processing_status, setup_output +import org.config.app +import org.logging +from org.pipeline.load import load_and_process_data +from org.tui.bits import build_console, processing_status, setup_output +from org.tui.help import interactive_help_command_text + +from . import actions, ui +from .app import run_board_app logger = logging.getLogger("org") @@ -57,7 +61,7 @@ class BoardArgs: with_tags_as_category: bool -_BOARD_HELP_ENTRIES = layout.BOARD_HELP_ENTRIES +_BOARD_HELP_ENTRIES = ui.BOARD_HELP_ENTRIES def _resolve_tasks_limit(max_results: int | None) -> int: @@ -67,8 +71,13 @@ def _resolve_tasks_limit(max_results: int | None) -> int: return max_results -def run_flow_board(args: BoardArgs) -> None: - """Run the flow board command.""" +def _validate_board_args(args: BoardArgs) -> None: + """Validate board arguments and configured view selection.""" + actions.resolve_column_specs(args) + + +def run_board(args: BoardArgs) -> None: + """Run the board command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) if console.width < 80: @@ -80,11 +89,12 @@ def run_flow_board(args: BoardArgs) -> None: if args.days < 0: raise typer.BadParameter("--days must be non-negative") args.max_results = _resolve_tasks_limit(args.max_results) + _validate_board_args(args) with processing_status(console, color_enabled): nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args) - nodes = events.filter_recent_completed_nodes(nodes, args.days) - todo_states, done_states = events.resolved_states( + nodes = actions.filter_recent_completed_nodes(nodes, args.days) + todo_states, done_states = actions.resolved_states( args, discovered_todo_states, discovered_done_states, @@ -94,31 +104,14 @@ def run_flow_board(args: BoardArgs) -> None: console.print("No results", markup=False) return - if sys.stdin.isatty() and sys.stdout.isatty(): - events.run_flow_board_interactive( - console, - events.create_flow_board_session( - args, - nodes, - todo_states, - done_states, - color_enabled, - ), - ) - return + if not (sys.stdin.isatty() and sys.stdout.isatty()): + raise click.UsageError("org board requires a TTY") - columns = events.build_selector_board_columns(nodes, events.resolve_column_specs(args)) - layout.render_static_flow_board( - console, - columns, - done_states=done_states, - todo_states=todo_states, - color_enabled=color_enabled, - ) + run_board_app(args, nodes, todo_states, done_states, color_enabled) def register(app: typer.Typer) -> None: - """Register the flow board command.""" + """Register the board command.""" @app.command( "board", @@ -128,7 +121,7 @@ def register(app: typer.Typer) -> None: _BOARD_HELP_ENTRIES, ), ) - def flow_board( # noqa: PLR0913 + def board( # noqa: PLR0913 files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -318,7 +311,7 @@ def flow_board( # noqa: PLR0913 help="Preprocess nodes to set category from first tag", ), ) -> None: - """Display tasks as an interactive flow board.""" + """Display tasks as an interactive board.""" args = BoardArgs( files=files, config=config, @@ -354,7 +347,7 @@ def flow_board( # noqa: PLR0913 order_by_timestamp_desc=order_by_timestamp_desc, with_tags_as_category=with_tags_as_category, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "board") - config_module.log_command_arguments(args, "board") - run_flow_board(args) + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "board") + org.logging.log_command_arguments(args, "board") + run_board(args) diff --git a/src/org/commands/board/styles/app.tcss b/src/org/commands/board/styles/app.tcss new file mode 100644 index 0000000..937322c --- /dev/null +++ b/src/org/commands/board/styles/app.tcss @@ -0,0 +1,26 @@ +Screen { + layout: vertical; +} + +#board-body { + height: 1fr; +} + +#board-footer, +#board-footer-rule, +#board-status { + height: auto; +} + +.board-column { + width: 1fr; + height: 1fr; +} + +.board-column-title { + height: auto; +} + +.board-column-body { + height: 1fr; +} diff --git a/src/org/commands/board/layout.py b/src/org/commands/board/ui.py similarity index 62% rename from src/org/commands/board/layout.py rename to src/org/commands/board/ui.py index 05a9a9b..e53f91b 100644 --- a/src/org/commands/board/layout.py +++ b/src/org/commands/board/ui.py @@ -6,23 +6,17 @@ from typing import TYPE_CHECKING, Protocol from rich import box +from rich.align import Align from rich.cells import cell_len from rich.console import Console, Group, RenderableType from rich.errors import MarkupError -from rich.layout import Layout from rich.panel import Panel -from rich.rule import Rule from rich.table import Table from rich.text import Text -from org.color import get_state_color -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - InteractiveHelpEntry, - build_footer_prompt_text, - render_interactive_help_modal, -) -from org.tui import heading_title_to_text, task_priority_to_text, task_tags_to_text +from org.tui.bits import heading_title_to_text, task_priority_to_text, task_tags_to_text +from org.tui.color import get_state_color +from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: @@ -30,7 +24,7 @@ from org_parser.document import Heading - from .events import BoardSession + from .actions import BoardSession class _BoardColumnLike(Protocol): @@ -42,9 +36,6 @@ def nodes(self) -> list[Heading]: ... _HIGHLIGHT_PANEL_STYLE = "on grey23" -_INTERACTIVE_HEADER_HEIGHT = 2 -_INTERACTIVE_FOOTER_HEIGHT = 3 -_INTERACTIVE_FOOTER_HEIGHT_WITH_PROMPT = 4 _INTERACTIVE_PANEL_HEIGHT = 4 @@ -213,7 +204,7 @@ def render_column_title_text(title: str) -> Text: return Text(title) -def _estimate_panel_content_width(console_width: int, column_count: int) -> int: +def estimate_panel_content_width(console_width: int, column_count: int) -> int: """Estimate panel inner width for pre-wrapping task card lines.""" safe_columns = max(1, column_count) raw_width = console_width // safe_columns @@ -244,7 +235,7 @@ def _estimate_board_height(columns: Sequence[_BoardColumnLike], panel_content_wi return content_row_height + 3 -def render_static_flow_board( +def render_static_board( console: Console, columns: Sequence[_BoardColumnLike], *, @@ -252,15 +243,15 @@ def render_static_flow_board( todo_states: list[str], color_enabled: bool, ) -> None: - """Render non-interactive flow board output.""" + """Render non-interactive board output.""" table = Table(expand=True, box=box.SQUARE, show_lines=False, show_header=False) for _ in columns: table.add_column(ratio=1) - header_row = [render_column_title_text(column.title) for column in columns] + header_row = [Align.center(render_column_title_text(column.title)) for column in columns] table.add_row(*header_row) - panel_content_width = _estimate_panel_content_width(console.width, len(columns)) + panel_content_width = estimate_panel_content_width(console.width, len(columns)) render = BoardPanelRenderConfig( width=panel_content_width, color_enabled=color_enabled, @@ -289,28 +280,6 @@ def _column_row_heights(nodes: Sequence[Heading], render: BoardPanelRenderConfig return [_interactive_panel_height(node, render) for node in nodes] -def _interactive_viewport_rows(console_height: int) -> int: - available_space = console_height - _INTERACTIVE_HEADER_HEIGHT - _INTERACTIVE_FOOTER_HEIGHT - available_lines = max(1, available_space) - return max(1, available_lines // _INTERACTIVE_PANEL_HEIGHT) - - -def _interactive_row_heights( - session: BoardSession, - render: BoardPanelRenderConfig, -) -> list[int]: - total_rows = _max_column_nodes(session.columns) - row_heights: list[int] = [] - for row_index in range(total_rows): - max_height = 1 - for column in session.columns: - if row_index >= len(column.nodes): - continue - max_height = max(max_height, _interactive_panel_height(column.nodes[row_index], render)) - row_heights.append(max_height) - return row_heights - - def _selected_column_row_heights( session: BoardSession, render: BoardPanelRenderConfig, @@ -342,11 +311,12 @@ def _window_end_for_height( return end, used_lines -def _sync_scroll_for_selection( +def sync_scroll_for_selection( session: BoardSession, row_heights: list[int], available_lines: int, ) -> tuple[int, int, int]: + """Clamp scroll offset and return the selected-column visible row window.""" if not row_heights: session.scroll_offset = 0 return 0, 0, 0 @@ -379,125 +349,42 @@ def _sync_scroll_for_selection( return session.scroll_offset, end_row, used_lines -def _build_board_header(columns: Sequence[_BoardColumnLike]) -> Table: - header = Table(expand=True, box=None, show_lines=False, show_header=False, pad_edge=False) - for _ in columns: - header.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - header_cells: list[Text] = [] - for column in columns: - title_text = render_column_title_text(column.title) - title_text.overflow = "ellipsis" - title_text.no_wrap = True - header_cells.append(title_text) - header.add_row(*header_cells) - return header - - -def _build_board_body( +def selected_column_window( session: BoardSession, - panel_content_width: int, + render: BoardPanelRenderConfig, + *, body_height: int, -) -> tuple[Table, int]: - body = Table(expand=True, box=None, show_lines=False, show_header=False, pad_edge=False) - for _ in session.columns: - body.add_column(ratio=1) - - render = BoardPanelRenderConfig( - width=panel_content_width, - color_enabled=session.color_enabled, - done_states=session.done_states, - todo_states=session.todo_states, - ) +) -> tuple[int, int]: + """Return the selected-column visible row window after syncing scroll state.""" selected_row_heights = _selected_column_row_heights(session, render) - start_row, end_row, _used_lines = _sync_scroll_for_selection( + start_row, end_row, _used_lines = sync_scroll_for_selection( session, selected_row_heights, body_height, ) + return start_row, end_row - body_cells: list[RenderableType] = [] - for column_index, column in enumerate(session.columns): - panels: list[RenderableType] = [] - if start_row >= len(column.nodes): - body_cells.append(Text("")) - continue - column_end_row, _column_used_lines = _window_end_for_height( - _column_row_heights(column.nodes, render), - start_row, - body_height, - ) - if column_end_row < len(column.nodes): - column_end_row += 1 - for row_index in range(start_row, column_end_row): - if row_index >= len(column.nodes): - continue - node = column.nodes[row_index] - highlighted = ( - column_index == session.selected_column_index - and row_index == session.selected_row_index - ) - panels.append(build_task_panel(node, render, highlighted=highlighted)) - body_cells.append(Group(*panels) if panels else Text("")) - - body.add_row(*body_cells) - return body, end_row - - -def _interactive_flow_board_renderable(console: Console, session: BoardSession) -> RenderableType: - if session.show_help_modal: - return render_interactive_help_modal( - BOARD_HELP_ENTRIES, - color_enabled=session.color_enabled, - ) - prompt_line = None - active_prompt = session.active_prompt - if active_prompt is not None: - prompt_line = build_footer_prompt_text(active_prompt.prompt) - footer_height = ( - _INTERACTIVE_FOOTER_HEIGHT - if prompt_line is None - else _INTERACTIVE_FOOTER_HEIGHT_WITH_PROMPT +def column_window_end( + nodes: Sequence[Heading], + render: BoardPanelRenderConfig, + *, + start_row: int, + body_height: int, +) -> int: + """Return the exclusive end row for one board column's visible card slice.""" + column_end_row, _used_lines = _window_end_for_height( + _column_row_heights(nodes, render), + start_row, + body_height, ) - panel_content_width = _estimate_panel_content_width(console.width, len(session.columns)) - body_height = max(1, console.size.height - _INTERACTIVE_HEADER_HEIGHT - footer_height) - header = _build_board_header(session.columns) - body, end_row = _build_board_body(session, panel_content_width, body_height) + if column_end_row < len(nodes): + column_end_row += 1 + return column_end_row - selected_nodes = session.columns[session.selected_column_index].nodes - total_rows = max(len(selected_nodes), 1) - visible_end_row = min(end_row, total_rows) - search_text = session.search_text or "-" - row_text = f"Rows {visible_end_row}/{total_rows} | Search: {search_text}" - status = " ".join((session.status_message or "").splitlines()) - footer_style = "dim" if session.color_enabled else "" - - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(row_text, style=footer_style, no_wrap=True, overflow="ellipsis"), - Text( - INTERACTIVE_HELP_FOOTER_HINT, - style=footer_style, - no_wrap=True, - overflow="ellipsis", - ), - ) - layout = Layout(name="board") - layout.split_column( - Layout(name="header", size=_INTERACTIVE_HEADER_HEIGHT), - Layout(name="body"), - Layout(name="footer", size=footer_height), - ) - layout["header"].update(Group(header, Rule(style=footer_style))) - layout["body"].update(body) - status_text = Text(status, style=footer_style, no_wrap=True, overflow="ellipsis") - if prompt_line is None: - layout["footer"].update(Group(Rule(style=footer_style), footer_line, status_text)) - else: - layout["footer"].update( - Group(Rule(style=footer_style), footer_line, prompt_line, status_text), - ) - return layout +def selected_column_total_rows(session: BoardSession) -> int: + """Return the selected column row count, defaulting to one for footer display.""" + if not session.columns: + return 1 + return max(len(session.columns[session.selected_column_index].nodes), 1) diff --git a/src/org/commands/interactive_common.py b/src/org/commands/interactive_common.py deleted file mode 100644 index 997696a..0000000 --- a/src/org/commands/interactive_common.py +++ /dev/null @@ -1,933 +0,0 @@ -"""Shared helpers for interactive command-mode UIs.""" - -from __future__ import annotations - -import calendar -import os -import select -import sys -import termios -import tty -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any, Protocol, Self, cast - -from org_parser.element import Repeat -from org_parser.time import Timestamp -from rich.console import Console, Group, RenderableType -from rich.live import Live -from rich.markup import escape -from rich.panel import Panel -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text - -from org.output_format import DEFAULT_OUTPUT_THEME - - -if TYPE_CHECKING: - from collections.abc import Callable - - from org_parser.document import Heading - - -MOUSE_REPORTING_ENABLE = "\x1b[?1000h\x1b[?1006h" -MOUSE_REPORTING_DISABLE = "\x1b[?1000l\x1b[?1006l" -BRACKETED_PASTE_ENABLE = "\x1b[?2004h" -BRACKETED_PASTE_DISABLE = "\x1b[?2004l" -BRACKETED_PASTE_START = b"\x1b[200~" -BRACKETED_PASTE_END = b"\x1b[201~" -INTERACTIVE_HELP_FOOTER_HINT = "Type ? for help" -INTERACTIVE_HELP_CLOSE_HINT = "Press any key to return" -INTERACTIVE_HELP_CLI_NOTE = ( - "In interactive mode, press ? to open key bindings help (press any key to close)." -) - - -@dataclass(frozen=True) -class InteractiveHelpEntry: - """One key binding entry for interactive help rendering.""" - - key: str - description: str - - -@dataclass(frozen=True) -class HeadingLocator: - """Stable heading locator used to restore selection across reloads.""" - - filename: str - heading_id: str | None - title: str - - -@dataclass -class FooterPromptState: - """Editable footer prompt state shared by interactive TUIs.""" - - label: str - value: str = "" - cursor_position: int = 0 - error_message: str = "" - - -@dataclass(frozen=True) -class KeypressEvent: - """One decoded keypress or control token.""" - - key: str - - -@dataclass(frozen=True) -class InputEvent: - """One pasted text input event.""" - - key: str - - -@dataclass(frozen=True) -class TimeoutEvent: - """One idle timeout event emitted by the interactive loop.""" - - -type InteractiveEvent = KeypressEvent | InputEvent | TimeoutEvent - - -@dataclass -class InteractivePromptState: - """Command-owned footer prompt state and callbacks.""" - - prompt: FooterPromptState - cancel_status: str - invalid_status: str - submit_callback: Callable[[], bool] - preview: Callable[[], None] | None = None - cancel: Callable[[], None] | None = None - - -class InteractivePromptOwner(Protocol): - """Type protocol for objects that own one active interactive prompt.""" - - active_prompt: InteractivePromptState | None - status_message: str - - -class _PromptConfigLike(Protocol): - """Shape required to build one interactive prompt state.""" - - @property - def prompt(self) -> FooterPromptState: - """Return footer prompt configuration.""" - ... - - @property - def cancel_status(self) -> str: - """Return cancel status message.""" - ... - - @property - def invalid_status(self) -> str: - """Return invalid-input status message.""" - ... - - -@dataclass(frozen=True) -class PromptEventResult: - """Result of applying one interactive event to prompt state.""" - - submitted: bool = False - changed: bool = False - - -@dataclass -class InteractiveInputController: - """Manage one interactive terminal session for fullscreen TUIs.""" - - _active: bool = False - _fd: int | None = None - _previous_mode: list[object] | None = None - - def __enter__(self) -> Self: - """Enable mouse reporting and cbreak input mode.""" - _set_mouse_reporting(True) - self.start() - return self - - def __exit__(self, *_args: object) -> None: - """Restore terminal input mode and disable mouse reporting.""" - self.stop() - _set_mouse_reporting(False) - - def start(self) -> None: - """Enable cbreak input mode when inactive.""" - if self._active: - return - - fd = sys.stdin.fileno() - self._fd = fd - self._previous_mode = cast("list[object]", termios.tcgetattr(fd)) - tty.setcbreak(fd) - self._active = True - - def stop(self) -> None: - """Restore the previous terminal input mode when active.""" - if not self._active or self._fd is None or self._previous_mode is None: - return - - termios.tcsetattr(self._fd, termios.TCSADRAIN, cast("Any", self._previous_mode)) - self._fd = None - self._previous_mode = None - self._active = False - - def suspend_live(self, live: Live) -> None: - """Suspend interactive input and mouse support before leaving live mode.""" - self.stop() - _set_mouse_reporting(False) - live.stop() - - def resume_live(self, live: Live) -> None: - """Resume live mode, mouse reporting, and interactive input.""" - live.start() - _set_mouse_reporting(True) - self.start() - - -def render_interactive_help_panel_text(entries: list[InteractiveHelpEntry]) -> str: - """Render key bindings as plain help text lines.""" - key_width = max(12, max((len(entry.key) for entry in entries), default=0) + 1) - lines = ["[white not dim]Key bindings:[/]"] - for entry in entries: - key_text = escape(f"{entry.key:<{key_width}}") - description_text = escape(entry.description) - lines.append(f" [bold white not dim]{key_text}[/][white not dim]{description_text}[/]") - return "\n".join(lines) - - -def interactive_help_command_text(base_text: str, entries: list[InteractiveHelpEntry]) -> str: - """Append shared interactive-help note and key-bindings panel to help text.""" - normalized = " ".join(base_text.split()) - panel_text = render_interactive_help_panel_text(entries) - return f"{normalized} {INTERACTIVE_HELP_CLI_NOTE}\n\n{panel_text}" - - -def apply_help_modal_key( - key: str, - *, - show_help_modal: bool, -) -> tuple[bool, bool]: - """Apply one key to help-modal state and return (consumed, next_state).""" - if show_help_modal: - return True, False - if key == "?": - return True, True - return False, show_help_modal - - -def render_interactive_help_modal( - entries: list[InteractiveHelpEntry], - *, - color_enabled: bool, - title: str = "Key bindings", - close_hint: str = INTERACTIVE_HELP_CLOSE_HINT, -) -> RenderableType: - """Render a generic interactive key bindings modal panel.""" - key_column_width = max(18, max((len(entry.key) for entry in entries), default=0) + 1) - content = Table.grid(expand=True, padding=(0, 2)) - content.add_column(width=key_column_width, no_wrap=True, overflow="fold") - content.add_column(ratio=1, no_wrap=False, overflow="fold") - for entry in entries: - content.add_row( - Text(entry.key, style="bold", no_wrap=True), - Text(entry.description), - ) - - border_style = "grey50" if color_enabled else "" - panel_content: RenderableType = content - if close_hint: - panel_content = Group(content, Text(close_hint, style="dim")) - - return Panel( - panel_content, - title=title, - title_align="left", - border_style=border_style, - padding=(0, 1), - expand=True, - ) - - -def local_now() -> datetime: - """Return local timezone-aware current datetime.""" - return datetime.now(tz=UTC).astimezone() - - -def shift_priority(priority: str | None, *, increase: bool) -> str | None: - """Shift priority one step across A/B/C/none.""" - order: list[str | None] = ["A", "B", "C", None] - normalized = priority if priority in {"A", "B", "C"} else None - index = order.index(normalized) - if increase: - return order[max(0, index - 1)] - return order[min(len(order) - 1, index + 1)] - - -def heading_locator(node: Heading) -> HeadingLocator: - """Build a stable heading locator for selection restoration.""" - return HeadingLocator( - filename=node.document.filename or "", - heading_id=node.id, - title=node.title_text, - ) - - -def resolve_heading_locator( - candidates: list[Heading], - locator: HeadingLocator | None, -) -> Heading | None: - """Resolve a preserved heading locator against a candidate heading list.""" - if locator is None or not candidates: - return None - - document = None - for candidate in candidates: - if (candidate.document.filename or "") == locator.filename: - document = candidate.document - break - - if document is None: - return None - - resolved = ( - document.heading_by_id(locator.heading_id) - if locator.heading_id is not None - else document.heading_by_title(locator.title) - ) - if resolved is None: - return None - - for candidate in candidates: - if candidate is resolved: - return candidate - return None - - -def append_repeat_transition( - heading: Heading, - before: str | None, - after: str | None, - now: datetime, -) -> None: - """Append one repeat transition entry at current time.""" - transition_time = now.replace(second=0, microsecond=0) - repeat = Repeat( - before=before, - after=after, - timestamp=Timestamp.from_datetime(transition_time, is_active=False), - ) - heading.repeats.append(repeat) - - -def set_timestamp_fields(timestamp: Timestamp, start: datetime, end: datetime | None) -> None: - """Set timestamp date/time fields while preserving active/repeater metadata.""" - timestamp.start_year = start.year - timestamp.start_month = start.month - timestamp.start_day = start.day - timestamp.start_dayname = start.strftime("%a") - if timestamp.start_hour is not None: - timestamp.start_hour = start.hour - timestamp.start_minute = start.minute - - if end is None or timestamp.end is None: - return - - timestamp.end_year = end.year - timestamp.end_month = end.month - timestamp.end_day = end.day - timestamp.end_dayname = end.strftime("%a") - if timestamp.end_hour is not None: - timestamp.end_hour = end.hour - timestamp.end_minute = end.minute - - -def add_months(value: datetime, months: int) -> datetime: - """Add months to a datetime while clamping day to month length.""" - year = value.year + (value.month - 1 + months) // 12 - month = (value.month - 1 + months) % 12 + 1 - day = min(value.day, calendar.monthrange(year, month)[1]) - return value.replace(year=year, month=month, day=day) - - -def shift_datetimes_by_unit( - start: datetime, - end: datetime | None, - *, - value: int, - unit: str, -) -> tuple[datetime, datetime | None]: - """Shift start/end datetimes by one repeater unit.""" - if unit == "d": - delta = timedelta(days=value) - return start + delta, None if end is None else end + delta - if unit == "w": - delta = timedelta(weeks=value) - return start + delta, None if end is None else end + delta - if unit == "h": - delta = timedelta(hours=value) - return start + delta, None if end is None else end + delta - if unit == "m": - return add_months(start, value), None if end is None else add_months(end, value) - if unit == "y": - months = value * 12 - return add_months(start, months), None if end is None else add_months(end, months) - raise ValueError(f"Unsupported repeater unit: {unit}") - - -def now_aligned_for_datetime(start: datetime, now: datetime) -> datetime: - """Normalize current datetime to match timezone-awareness of start.""" - if start.tzinfo is None: - return now.replace(tzinfo=None) - if now.tzinfo is None: - return now.replace(tzinfo=start.tzinfo) - return now.astimezone(start.tzinfo) - - -def advance_timestamp_by_repeater(timestamp: Timestamp) -> bool: - """Advance timestamp once by its repeater marker, when present.""" - if timestamp.repeater is None: - return False - - mark = timestamp.repeater.mark - value = timestamp.repeater.value - unit = timestamp.repeater.unit - if value <= 0: - return False - - start = timestamp.start - end = timestamp.end - - try: - if mark == "+": - shifted_start, shifted_end = shift_datetimes_by_unit( - start, - end, - value=value, - unit=unit, - ) - elif mark == "++": - now = now_aligned_for_datetime(start, local_now()) - shifted_start, shifted_end = shift_datetimes_by_unit( - start, - end, - value=value, - unit=unit, - ) - while shifted_start <= now: - shifted_start, shifted_end = shift_datetimes_by_unit( - shifted_start, - shifted_end, - value=value, - unit=unit, - ) - elif mark == ".+": - now = now_aligned_for_datetime(start, local_now()) - base_start = start.replace(year=now.year, month=now.month, day=now.day) - base_end = None if end is None else base_start + (end - start) - shifted_start, shifted_end = shift_datetimes_by_unit( - base_start, - base_end, - value=value, - unit=unit, - ) - else: - return False - except ValueError: - return False - - set_timestamp_fields(timestamp, shifted_start, shifted_end) - return True - - -def decode_mouse_sequence(sequence: bytes) -> str | None: - """Decode xterm SGR mouse sequence into command token.""" - if not sequence.startswith(b"\x1b[<"): - return None - if sequence[-1:] not in {b"M", b"m"}: - return None - - try: - body = sequence[3:-1].decode("ascii") - cb_text, _col_text, _row_text = body.split(";", 2) - cb = int(cb_text) - except UnicodeDecodeError, ValueError: - return "UNSUPPORTED-MOUSE" - - if cb & 64 == 0: - result = "UNSUPPORTED-MOUSE" - elif cb & 4: - result = "UNSUPPORTED-SHIFT-WHEEL" - else: - button = cb & 0b11 - result = {0: "WHEEL-UP", 1: "WHEEL-DOWN"}.get(button, "UNSUPPORTED-MOUSE") - - return result - - -def decode_escape_sequence(sequence: bytes) -> str: - """Decode terminal escape sequence into one key token.""" - mapping = { - b"\x1b[A": "UP", - b"\x1b[B": "DOWN", - b"\x1b[1;2A": "S-UP", - b"\x1b[1;2B": "S-DOWN", - b"\x1b[1;2C": "S-RIGHT", - b"\x1b[1;2D": "S-LEFT", - b"\x1b[;2A": "S-UP", - b"\x1b[;2B": "S-DOWN", - b"\x1b[;2C": "S-RIGHT", - b"\x1b[;2D": "S-LEFT", - b"\x1b[C": "RIGHT", - b"\x1b[D": "LEFT", - } - mapped = mapping.get(sequence) - if mapped is not None: - return mapped - - if sequence == b"\x1b": - return "ESC" - - mouse_token = decode_mouse_sequence(sequence) - if mouse_token is not None: - return mouse_token - - return f"UNSUPPORTED-ESC:{sequence.hex()}" - - -def read_escape_sequence(fd: int) -> bytes: - """Read one terminal escape sequence from stdin.""" - payload = bytearray(b"\x1b") - ready, _, _ = select.select([fd], [], [], 0.01) - if not ready: - return bytes(payload) - - payload.extend(os.read(fd, 1)) - - second = payload[-1:] - if second != b"[": - return bytes(payload) - - for _ in range(32): - ready, _, _ = select.select([fd], [], [], 0.002) - if not ready: - break - next_byte = os.read(fd, 1) - payload.extend(next_byte) - if next_byte in {b"~", b"M", b"m"}: - break - if next_byte.isalpha(): - break - - return bytes(payload) - - -def read_bracketed_paste_payload(fd: int, initial_payload: bytes) -> bytes: - """Read remaining bytes until bracketed-paste end marker is received.""" - payload = bytearray(initial_payload) - while BRACKETED_PASTE_END not in payload: - ready, _, _ = select.select([fd], [], [], 0.05) - if not ready: - break - payload.extend(os.read(fd, 1024)) - return bytes(payload) - - -def extract_bracketed_paste_text(payload: bytes) -> str | None: - """Extract decoded text from a bracketed-paste payload.""" - if not payload.startswith(BRACKETED_PASTE_START): - return None - end_index = payload.find(BRACKETED_PASTE_END) - if end_index < 0: - return "" - - text_bytes = payload[len(BRACKETED_PASTE_START) : end_index] - try: - return text_bytes.decode("utf-8") - except UnicodeDecodeError: - return text_bytes.decode("utf-8", errors="ignore") - - -def read_available_bytes(fd: int) -> bytes: - """Read currently available bytes from stdin with short timeout.""" - payload = bytearray() - ready, _, _ = select.select([fd], [], [], 0.05) - if not ready: - return b"" - - payload.extend(os.read(fd, 1024)) - while True: - ready, _, _ = select.select([fd], [], [], 0.01) - if not ready: - break - payload.extend(os.read(fd, 1024)) - return bytes(payload) - - -def _utf8_char_width(first_byte: int) -> int: - """Return expected UTF-8 byte width for one first byte.""" - if first_byte & 0b1000_0000 == 0: - return 1 - if first_byte & 0b1110_0000 == 0b1100_0000: - return 2 - if first_byte & 0b1111_0000 == 0b1110_0000: - return 3 - if first_byte & 0b1111_1000 == 0b1111_0000: - return 4 - return 1 - - -def _has_control_characters(text: str) -> bool: - """Return whether text contains ASCII control characters.""" - return any(ord(char) < 32 or ord(char) == 127 for char in text) - - -def read_utf8_input_text(fd: int, first_byte: bytes) -> str | None: - """Read one UTF-8 input text value from initial first byte.""" - utf8_bytes = bytearray(first_byte) - expected = _utf8_char_width(first_byte[0]) - while len(utf8_bytes) < expected: - ready, _, _ = select.select([fd], [], [], 0.01) - if not ready: - break - utf8_bytes.extend(os.read(fd, 1)) - - try: - text = bytes(utf8_bytes).decode("utf-8") - except UnicodeDecodeError: - return None - if _has_control_characters(text): - return None - return text - - -def _decode_bytes_payload(payload: bytes) -> str: - """Decode byte payload as UTF-8 with lossy fallback.""" - try: - return payload.decode("utf-8") - except UnicodeDecodeError: - return payload.decode("utf-8", errors="ignore") - - -def _read_ctrl_p_paste_event(fd: int) -> tuple[str, str]: - """Read one Ctrl-P paste event payload.""" - payload = read_available_bytes(fd) - bracketed_text = extract_bracketed_paste_text(payload) - if bracketed_text is not None: - return ("TEXT", bracketed_text) - return ("TEXT", _decode_bytes_payload(payload)) - - -def _read_escape_input_event( - fd: int, - token_map: dict[str, str] | None, -) -> tuple[str, str]: - """Read one escape-sequence input event.""" - escape_sequence = read_escape_sequence(fd) - if escape_sequence.startswith(BRACKETED_PASTE_START): - bracketed_payload = read_bracketed_paste_payload(fd, escape_sequence) - pasted_text = extract_bracketed_paste_text(bracketed_payload) - return ("IGNORE", "") if pasted_text is None else ("TEXT", pasted_text) - - if escape_sequence == b"\x1b[3~": - return ("DELETE", "") - - escape_token = decode_escape_sequence(escape_sequence) - defaults = { - "LEFT": "LEFT", - "RIGHT": "RIGHT", - "HOME": "HOME", - "END": "END", - "ESC": "ESC", - } - event_map = defaults if token_map is None else token_map - return (event_map.get(escape_token, "IGNORE"), "") - - -def _read_escape_event(fd: int) -> InteractiveEvent: - """Read one escape-sequence interactive event.""" - escape_sequence = read_escape_sequence(fd) - if escape_sequence.startswith(BRACKETED_PASTE_START): - bracketed_payload = read_bracketed_paste_payload(fd, escape_sequence) - pasted_text = extract_bracketed_paste_text(bracketed_payload) - return InputEvent("" if pasted_text is None else pasted_text) - - if escape_sequence == b"\x1b[3~": - return KeypressEvent("DELETE") - - return KeypressEvent(decode_escape_sequence(escape_sequence)) - - -def _read_interactive_event_from_first_byte(fd: int, first: bytes) -> InteractiveEvent: - """Decode one shared interactive event from an already-read first byte.""" - if first in {b"\r", b"\n"}: - return KeypressEvent("ENTER") - if first in {b"\x7f", b"\x08"}: - return KeypressEvent("BACKSPACE") - if first == b"\x10": - return InputEvent(_decode_bytes_payload(read_available_bytes(fd))) - if first == b"\x1b": - return _read_escape_event(fd) - - text = read_utf8_input_text(fd, first) - if text is None: - return KeypressEvent("IGNORE") - return KeypressEvent(text) - - -def read_interactive_event(timeout_seconds: float | None = None) -> InteractiveEvent: - """Read one shared interactive event for fullscreen command loops.""" - fd = sys.stdin.fileno() - if timeout_seconds is not None: - ready, _, _ = select.select([fd], [], [], timeout_seconds) - if not ready: - return TimeoutEvent() - - first = os.read(fd, 1) - return _read_interactive_event_from_first_byte(fd, first) - - -def _read_input_event_from_first_byte( - fd: int, - first: bytes, - *, - token_map: dict[str, str] | None = None, - ctrl_p_as_paste: bool = False, -) -> tuple[str, str]: - """Decode one input event from an already-read first byte.""" - if first in {b"\r", b"\n"}: - return ("ENTER", "") - if first in {b"\x7f", b"\x08"}: - return ("BACKSPACE", "") - - if first == b"\x10" and ctrl_p_as_paste: - return _read_ctrl_p_paste_event(fd) - - if first == b"\x1b": - return _read_escape_input_event(fd, token_map) - - text = read_utf8_input_text(fd, first) - if text is None: - return ("IGNORE", "") - return ("TEXT", text) - - -def read_input_event( - timeout_seconds: float | None = None, - *, - token_map: dict[str, str] | None = None, - ctrl_p_as_paste: bool = False, -) -> tuple[str, str] | None: - """Read one input event token and optional text payload.""" - active_fd = sys.stdin.fileno() - if timeout_seconds is not None: - ready, _, _ = select.select([active_fd], [], [], timeout_seconds) - if not ready: - return None - - first = os.read(active_fd, 1) - return _read_input_event_from_first_byte( - active_fd, - first, - token_map=token_map, - ctrl_p_as_paste=ctrl_p_as_paste, - ) - - -def _set_mouse_reporting(enabled: bool) -> None: - """Enable or disable terminal mouse reporting.""" - if not sys.stdout.isatty(): - return - - sequence = MOUSE_REPORTING_ENABLE if enabled else MOUSE_REPORTING_DISABLE - try: - sys.stdout.write(sequence) - sys.stdout.flush() - except OSError: - return - - -def set_bracketed_paste(enabled: bool) -> None: - """Enable or disable terminal bracketed paste mode.""" - if not sys.stdout.isatty(): - return - - sequence = BRACKETED_PASTE_ENABLE if enabled else BRACKETED_PASTE_DISABLE - try: - sys.stdout.write(sequence) - sys.stdout.flush() - except OSError: - return - - -def apply_prompt_event(prompt: FooterPromptState, event: InteractiveEvent) -> PromptEventResult: - """Apply one interactive event to prompt state.""" - result = PromptEventResult() - if isinstance(event, TimeoutEvent): - return result - - if isinstance(event, InputEvent): - prompt.value = ( - f"{prompt.value[: prompt.cursor_position]}" - f"{event.key}" - f"{prompt.value[prompt.cursor_position :]}" - ) - prompt.cursor_position += len(event.key) - return PromptEventResult(changed=bool(event.key)) - - if event.key == "ENTER": - result = PromptEventResult(submitted=True) - elif event.key in {"LEFT", "RIGHT", "HOME", "END"}: - cursor_targets = { - "LEFT": max(0, prompt.cursor_position - 1), - "RIGHT": min(len(prompt.value), prompt.cursor_position + 1), - "HOME": 0, - "END": len(prompt.value), - } - prompt.cursor_position = cursor_targets[event.key] - elif event.key == "BACKSPACE" and prompt.cursor_position > 0: - prompt.value = ( - f"{prompt.value[: prompt.cursor_position - 1]}{prompt.value[prompt.cursor_position :]}" - ) - prompt.cursor_position -= 1 - result = PromptEventResult(changed=True) - elif event.key == "DELETE" and prompt.cursor_position < len(prompt.value): - prompt.value = ( - f"{prompt.value[: prompt.cursor_position]}{prompt.value[prompt.cursor_position + 1 :]}" - ) - result = PromptEventResult(changed=True) - elif len(event.key) == 1 and ord(event.key) >= 32: - prompt.value = ( - f"{prompt.value[: prompt.cursor_position]}" - f"{event.key}" - f"{prompt.value[prompt.cursor_position :]}" - ) - prompt.cursor_position += len(event.key) - result = PromptEventResult(changed=True) - - return result - - -def create_interactive_prompt_state( - config: _PromptConfigLike, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> InteractivePromptState: - """Build one active interactive prompt state from prompt config and callbacks.""" - prompt = FooterPromptState(label=config.prompt.label) - - def _submit() -> bool: - return submit_value(prompt.value) - - def _preview() -> None: - if preview_value is None: - return - preview_value(prompt.value) - - return InteractivePromptState( - prompt=prompt, - cancel_status=config.cancel_status, - invalid_status=config.invalid_status, - submit_callback=_submit, - preview=None if preview_value is None else _preview, - cancel=cancel, - ) - - -def handle_active_prompt_event(session: InteractivePromptOwner, event: InteractiveEvent) -> bool: - """Handle one interactive event while a footer prompt is active.""" - active_prompt = session.active_prompt - if active_prompt is None: - return True - if isinstance(event, TimeoutEvent): - return True - if isinstance(event, KeypressEvent) and event.key == "ESC": - if active_prompt.cancel is not None: - active_prompt.cancel() - else: - session.status_message = active_prompt.cancel_status - session.active_prompt = None - return True - - prompt_result = apply_prompt_event(active_prompt.prompt, event) - if prompt_result.submitted: - keep_open = active_prompt.submit_callback() - if not keep_open: - session.active_prompt = None - return True - if prompt_result.changed and active_prompt.preview is not None: - active_prompt.preview() - return True - - -def interactive_loop( - *, - console: Console, - render: Callable[[], RenderableType], - on_event: Callable[[InteractiveEvent], bool], - bind_run_external: Callable[[Callable[[Callable[[], None]], None]], None] | None = None, - timeout_seconds: float | None = None, -) -> None: - """Run one shared fullscreen interactive event loop.""" - with ( - Live(render(), console=console, screen=True, auto_refresh=False) as live, - InteractiveInputController() as controller, - ): - - def _run_external(callback: Callable[[], None]) -> None: - controller.suspend_live(live) - try: - callback() - finally: - controller.resume_live(live) - - if bind_run_external is not None: - bind_run_external(_run_external) - - while True: - event = read_interactive_event(timeout_seconds) - if not on_event(event): - break - live.update(render(), refresh=True) - - -def build_footer_prompt_text(prompt: FooterPromptState) -> Text: - """Build prompt text with visible cursor for footer rendering.""" - clamped_cursor = max(0, min(prompt.cursor_position, len(prompt.value))) - text = Text(f"{prompt.label}: ", style="bold") - text.append(prompt.value[:clamped_cursor]) - if clamped_cursor < len(prompt.value): - text.append(prompt.value[clamped_cursor], style="reverse") - text.append(prompt.value[clamped_cursor + 1 :]) - else: - text.append(" ", style="reverse") - return text - - -def detail_org_block(node: Heading) -> str: - """Build detailed org block text for one heading subtree.""" - filename = node.document.filename or "unknown" - node_text = node.render().rstrip() - return f"# {filename}\n{node_text}" if node_text else f"# {filename}" - - -def open_task_detail_in_pager(console: Console, node: Heading, *, color_enabled: bool) -> None: - """Open task detail in pager with Org syntax highlighting.""" - detail = Syntax( - detail_org_block(node), - "org", - theme=DEFAULT_OUTPUT_THEME, - line_numbers=False, - word_wrap=True, - ) - with console.pager(styles=color_enabled): - console.print(detail) diff --git a/src/org/commands/stats/__init__.py b/src/org/commands/stats/__init__.py index 541aa33..26b5c2e 100644 --- a/src/org/commands/stats/__init__.py +++ b/src/org/commands/stats/__init__.py @@ -1 +1,6 @@ """Stats command package.""" + +from .command import register + + +__all__ = ["register"] diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 75399ea..e846f03 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -11,41 +11,30 @@ from rich.panel import Panel from rich.text import Text -from org import config as config_module -from org.analyze import AnalysisResult, Tag, TimeRange, analyze, clean -from org.analyze import Group as TagGroup -from org.cli_common import ( - CATEGORY_NAMES, - get_top_tasks, - load_and_process_data, - resolve_date_filters, - resolve_exclude_set, - resolve_mapping, -) +import org.config.app +import org.logging from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary -from org.tui import ( +from org.logic.stats import AnalysisResult, Tag, TimeRange, analyze, clean, get_top_tasks +from org.logic.stats import Group as TagGroup +from org.logic.time import resolve_date_filters +from org.logic.validation import validate_stats_arguments +from org.pipeline.load import load_and_process_data +from org.tui.bits import ( GroupBlockConfig, TagBlockConfig, TaskLineConfig, - TimelineFormatConfig, - TopTasksSectionConfig, - apply_indent, build_console, format_group_block, - format_groups_section, format_tag_block, format_task_line, - format_top_tasks_section, lines_to_text, processing_status, - section_header_lines, setup_output, ) -from org.validation import validate_stats_arguments +from org.tui.plot import TimelineFormatConfig if TYPE_CHECKING: - from collections.abc import Callable from datetime import datetime from org_parser.document import Heading @@ -120,20 +109,6 @@ class _TagsDisplayConfig: color_enabled: bool -@dataclass(frozen=True) -class _TagsSectionConfig: - """Configuration for rendering a single tags section.""" - - max_relations: int - plot_width: int - date_from: datetime | None - date_until: datetime | None - global_timerange: TimeRange - max_items: int - exclude_set: set[str] - color_enabled: bool - - @dataclass(frozen=True) class _StatsAllDisplayConfig: """Configuration shared across stats all layout sections.""" @@ -162,129 +137,6 @@ class _StatsAllSectionBuildConfig: use_summary_based_default: bool -def format_tags_section( - category_name: str, - tags: dict[str, Tag], - config: _TagsSectionConfig, - order_fn: Callable[[tuple[str, Tag]], int], - indent: str = "", -) -> str: - """Return formatted output for a single tags section.""" - if config.max_items == 0: - return "" - - cleaned = clean(config.exclude_set, tags) - sorted_items = sorted(cleaned.items(), key=order_fn)[0 : config.max_items] - - lines = section_header_lines(category_name.upper(), config.color_enabled) - - if not sorted_items: - lines.append(" No results") - return lines_to_text(apply_indent(lines, indent)) - - for idx, (name, tag) in enumerate(sorted_items): - if idx > 0: - lines.append("") - lines.extend( - format_tag_block( - name, - tag, - TagBlockConfig( - max_relations=config.max_relations, - exclude_set=config.exclude_set, - date_from=config.date_from, - date_until=config.date_until, - global_timerange=config.global_timerange, - timeline=TimelineFormatConfig( - color_enabled=config.color_enabled, - indent=" ", - plot_width=config.plot_width, - ), - name_indent=" ", - stats_indent=" ", - ), - ), - ) - - return lines_to_text(apply_indent(lines, indent)) - - -def format_stats_all_output( - result: AnalysisResult, - nodes: list[Heading], - args: StatsAllArgs, - display_config: _StatsAllDisplayConfig, - plot_width: int, -) -> str: - """Return formatted output for the stats all command.""" - - def order_by_total(item: tuple[str, Tag]) -> int: - """Sort by total count (descending).""" - return -item[1].total_tasks - - category_name = CATEGORY_NAMES[args.use] - task_limit = args.max_results if args.max_results is not None else 10 - - return "".join( - section - for section in ( - format_tasks_summary( - result, - SummaryDisplayConfig( - date_from=display_config.date_from, - date_until=display_config.date_until, - done_states=display_config.done_states, - todo_states=display_config.todo_states, - color_enabled=display_config.color_enabled, - ), - plot_width, - ), - format_top_tasks_section( - nodes, - TopTasksSectionConfig( - max_results=task_limit, - color_enabled=display_config.color_enabled, - done_states=display_config.done_states, - todo_states=display_config.todo_states, - line_width=plot_width, - indent="", - ), - ), - format_tags_section( - category_name, - result.tags, - _TagsSectionConfig( - max_relations=args.max_relations, - plot_width=plot_width, - date_from=display_config.date_from, - date_until=display_config.date_until, - global_timerange=result.timerange, - max_items=args.max_tags, - exclude_set=display_config.exclude_set, - color_enabled=display_config.color_enabled, - ), - order_by_total, - indent=" ", - ), - format_groups_section( - result.tag_groups, - display_config.exclude_set, - ( - args.min_group_size, - plot_width, - display_config.date_from, - display_config.date_until, - result.timerange, - display_config.color_enabled, - ), - args.max_groups, - indent=" ", - ), - ) - if section - ) - - def _line_count(text: str) -> int: """Return visual line count for preformatted text blocks.""" normalized = text.strip("\n") @@ -395,7 +247,7 @@ def _build_stats_all_panel_sections( ) sections.append( _StatsAllPanelSection( - title=CATEGORY_NAMES[args.use].upper(), + title=org.config.app.CATEGORY_NAMES[args.use].upper(), body=_normalize_panel_body(tags_body), ), ) @@ -677,8 +529,8 @@ def run_stats(args: StatsAllArgs) -> None: layout_height = 0 with processing_status(console, color_enabled): - mapping = resolve_mapping(args) - exclude_set = resolve_exclude_set(args) + mapping = org.config.app.resolve_mapping(args) + exclude_set = org.config.app.resolve_exclude_set(args) nodes, todo_states, done_states = load_and_process_data(args) if nodes: result = analyze(nodes, mapping, args.use, args.max_relations) @@ -914,7 +766,7 @@ def stats_all( # noqa: PLR0913 min_group_size=min_group_size, max_groups=max_groups, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats all") - config_module.log_command_arguments(args, "stats all") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats all") + org.logging.log_command_arguments(args, "stats all") run_stats(args) diff --git a/src/org/commands/stats/command.py b/src/org/commands/stats/command.py new file mode 100644 index 0000000..175b9e6 --- /dev/null +++ b/src/org/commands/stats/command.py @@ -0,0 +1,41 @@ +"""Stats command wiring.""" + +from __future__ import annotations + +import typer + +import org.config.app +import org.logging +from org.commands.stats import all as stats_all +from org.commands.stats import groups, tags +from org.commands.stats import summary as stats_summary + + +stats_app = typer.Typer( + help="Analyze Org-mode archive files for task statistics.", + no_args_is_help=True, +) + + +@stats_app.callback() +def stats_callback( + verbose: bool | None = typer.Option( + None, + "--verbose", + "-v", + help="Enable verbose logging output", + ), +) -> None: + """Global stats CLI options.""" + if verbose is None and not org.config.app.DEFAULT_VERBOSE["value"]: + return + org.logging.configure_logging(org.config.app.resolve_verbose(verbose)) + + +def register(app: typer.Typer) -> None: + """Register stats commands on the root CLI app.""" + stats_all.register(stats_app) + groups.register(stats_app) + stats_summary.register(stats_app) + tags.register(stats_app) + app.add_typer(stats_app, name="stats") diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index 339ccd4..4c296c6 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -8,8 +8,9 @@ import typer -from org import config as config_module -from org.analyze import ( +import org.config.app +import org.logging +from org.logic.stats import ( Group, TimeRange, compute_explicit_groups, @@ -19,17 +20,13 @@ compute_per_tag_statistics, compute_relations, compute_time_ranges, -) -from org.cli_common import ( - load_and_process_data, - resolve_date_filters, - resolve_exclude_set, resolve_group_values, - resolve_mapping, ) -from org.tui import ( +from org.logic.time import resolve_date_filters +from org.logic.validation import validate_stats_arguments +from org.pipeline.load import load_and_process_data +from org.tui.bits import ( GroupBlockConfig, - TimelineFormatConfig, apply_indent, build_console, format_group_block, @@ -38,7 +35,7 @@ processing_status, setup_output, ) -from org.validation import validate_stats_arguments +from org.tui.plot import TimelineFormatConfig if TYPE_CHECKING: @@ -146,8 +143,8 @@ def run_stats_groups(args: GroupsArgs) -> None: max_results = args.max_results if args.max_results is not None else 10 with processing_status(console, color_enabled): - mapping = resolve_mapping(args) - exclude_set = resolve_exclude_set(args) + mapping = org.config.app.resolve_mapping(args) + exclude_set = org.config.app.resolve_exclude_set(args) nodes, _, _ = load_and_process_data(args) if not nodes: @@ -399,7 +396,7 @@ def stats_groups( # noqa: PLR0913 min_group_size=0, max_groups=0, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats groups") - config_module.log_command_arguments(args, "stats groups") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats groups") + org.logging.log_command_arguments(args, "stats groups") run_stats_groups(args) diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index 51f04f5..170eb4e 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -8,9 +8,11 @@ import typer -from org import config as config_module -from org.analyze import ( +import org.config.app +import org.logging +from org.logic.stats import ( AnalysisResult, + Distribution, compute_avg_tasks_per_day, compute_category_histogram, compute_day_of_week_histogram, @@ -20,21 +22,19 @@ compute_task_state_histogram, compute_task_stats, ) -from org.cli_common import load_and_process_data, resolve_date_filters -from org.color import magenta -from org.histogram import Histogram, RenderConfig -from org.tui import ( - HistogramSectionConfig, - TimelineFormatConfig, +from org.logic.time import resolve_date_filters +from org.pipeline.load import load_and_process_data +from org.tui.bits import ( apply_indent, build_console, - format_histogram_section, - format_timeline_lines, lines_to_text, print_output, processing_status, setup_output, ) +from org.tui.color import magenta +from org.tui.histogram import HistogramSectionConfig, RenderConfig, format_histogram_section +from org.tui.plot import TimelineFormatConfig, format_timeline_lines if TYPE_CHECKING: @@ -89,7 +89,7 @@ def _validate_summary_arguments(args: SummaryArgs) -> None: def _build_task_state_order( - task_states: Histogram, + task_states: Distribution, done_states: list[str], todo_states: list[str], ) -> list[str]: @@ -447,7 +447,7 @@ def stats_summary( # noqa: PLR0913 max_results=max_results, with_tags_as_category=with_tags_as_category, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats summary") - config_module.log_command_arguments(args, "stats summary") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats summary") + org.logging.log_command_arguments(args, "stats summary") run_stats_summary(args) diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index f3cdd1a..adff97e 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -8,8 +8,9 @@ import typer -from org import config as config_module -from org.analyze import ( +import org.config.app +import org.logging +from org.logic.stats import ( Tag, TimeRange, clean, @@ -18,17 +19,13 @@ compute_per_tag_statistics, compute_relations, compute_time_ranges, -) -from org.cli_common import ( - load_and_process_data, normalize_show_value, - resolve_date_filters, - resolve_exclude_set, - resolve_mapping, ) -from org.tui import ( +from org.logic.time import resolve_date_filters +from org.logic.validation import validate_stats_arguments +from org.pipeline.load import load_and_process_data +from org.tui.bits import ( TagBlockConfig, - TimelineFormatConfig, apply_indent, build_console, format_tag_block, @@ -37,7 +34,7 @@ processing_status, setup_output, ) -from org.validation import validate_stats_arguments +from org.tui.plot import TimelineFormatConfig if TYPE_CHECKING: @@ -169,8 +166,8 @@ def run_stats_tags(args: TagsArgs) -> None: max_results = args.max_results if args.max_results is not None else 10 with processing_status(console, color_enabled): - mapping = resolve_mapping(args) - exclude_set = resolve_exclude_set(args) + mapping = org.config.app.resolve_mapping(args) + exclude_set = org.config.app.resolve_exclude_set(args) nodes, _, _ = load_and_process_data(args) if not nodes: @@ -409,7 +406,7 @@ def stats_tags( # noqa: PLR0913 min_group_size=2, max_groups=0, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats tags") - config_module.log_command_arguments(args, "stats tags") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats tags") + org.logging.log_command_arguments(args, "stats tags") run_stats_tags(args) diff --git a/src/org/commands/styles/help_modal.tcss b/src/org/commands/styles/help_modal.tcss new file mode 100644 index 0000000..6f2986f --- /dev/null +++ b/src/org/commands/styles/help_modal.tcss @@ -0,0 +1,18 @@ +HelpModalScreen { + align: center middle; + background: $surface 60%; +} + +#help-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 80%; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#help-content { + width: 1fr; +} diff --git a/src/org/commands/styles/prompt_modal.tcss b/src/org/commands/styles/prompt_modal.tcss new file mode 100644 index 0000000..44109d5 --- /dev/null +++ b/src/org/commands/styles/prompt_modal.tcss @@ -0,0 +1,26 @@ +PromptModalScreen { + align: center middle; + background: $surface 60%; +} + +#prompt-dialog { + width: 80%; + max-width: 100; + height: auto; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#prompt-label { + margin-bottom: 1; +} + +#prompt-instructions { + margin-bottom: 1; +} + +#prompt-input { + width: 1fr; + min-width: 20; +} diff --git a/src/org/commands/styles/selection_modal.tcss b/src/org/commands/styles/selection_modal.tcss new file mode 100644 index 0000000..dfc398a --- /dev/null +++ b/src/org/commands/styles/selection_modal.tcss @@ -0,0 +1,29 @@ +SelectionModalScreen { + align: center middle; + background: $surface 60%; +} + +#selection-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 24; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#selection-label { + margin-bottom: 1; +} + +#selection-instructions { + margin-bottom: 1; +} + +#selection-options { + width: 1fr; + min-width: 20; + height: auto; + max-height: 16; +} diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index 45b9354..87433e3 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -10,8 +10,8 @@ import typer from org_parser.document import Document, Heading -from org import config as config_module -from org.cli_common import resolve_input_paths +import org.config.app +import org.logging from org.commands.tasks.common import ( load_document, normalize_optional_value, @@ -24,6 +24,7 @@ resolve_parent_heading, save_document, ) +from org.pipeline.load import resolve_input_paths _TASK_TEMPLATE = "{heading}\n{planning}{properties}{body}" @@ -477,7 +478,7 @@ def tasks_add( # noqa: PLR0913 parent=parent, file=file, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks add") - config_module.log_command_arguments(args, "tasks add") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks add") + org.logging.log_command_arguments(args, "tasks add") run_tasks_add(args) diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index ecb0187..46c38a3 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -9,14 +9,15 @@ import typer from org_parser.document import Document, Heading -from org import config as config_module -from org.cli_common import resolve_input_paths -from org.commands.archive import archive_heading_subtree, archive_result_documents_to_save +import org.config.app +import org.logging from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, save_document, ) +from org.logic.archive import archive_heading_subtree, archive_result_documents_to_save +from org.pipeline.load import resolve_input_paths logger = logging.getLogger("org") @@ -126,7 +127,7 @@ def tasks_archive( query_id=query_id, query=query, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks archive") - config_module.log_command_arguments(args, "tasks archive") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks archive") + org.logging.log_command_arguments(args, "tasks archive") run_tasks_archive(args) diff --git a/src/org/commands/tasks/capture.py b/src/org/commands/tasks/capture.py deleted file mode 100644 index b87527d..0000000 --- a/src/org/commands/tasks/capture.py +++ /dev/null @@ -1,835 +0,0 @@ -"""Tasks capture command for creating tasks from named templates.""" - -from __future__ import annotations - -import logging -import re -import sys -import termios -import tty -from dataclasses import dataclass -from datetime import datetime -from uuid import uuid4 - -import typer -from org_parser.document import Document, Heading -from rich.console import Console, Group, RenderableType -from rich.layout import Layout -from rich.live import Live -from rich.rule import Rule -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text -from typer.rich_utils import _get_rich_console - -from org import config as config_module -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - InteractiveHelpEntry, - apply_help_modal_key, - interactive_help_command_text, - read_input_event, - render_interactive_help_modal, - set_bracketed_paste, -) -from org.commands.tasks.common import load_document, resolve_parent_heading, save_document -from org.output_format import DEFAULT_OUTPUT_THEME -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) - - -_PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*}}") -_ORG_WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -logger = logging.getLogger("org") - - -_CAPTURE_HELP_ENTRIES = [ - InteractiveHelpEntry( - "Enter", - "Confirm current placeholder value and move to the next unresolved field.", - ), - InteractiveHelpEntry( - "Left/Right", - "Move the input cursor inside the current placeholder value.", - ), - InteractiveHelpEntry("Home/End", "Jump the cursor to the start or end of the input value."), - InteractiveHelpEntry( - "Backspace/Delete", - "Delete one character left/right of cursor in the current input.", - ), - InteractiveHelpEntry( - "Type text / Paste", - "Insert typed or pasted text at cursor position in the current placeholder value.", - ), - InteractiveHelpEntry( - "Esc or Ctrl-C", - "Cancel capture without applying the currently active interactive input flow.", - ), -] - - -@dataclass -class TasksCaptureArgs: - """Arguments for the tasks capture command.""" - - template_name: str | None - config: str - file: str | None - parent: str | None - set_values: list[str] | None - - -@dataclass(frozen=True) -class TasksCaptureResult: - """Result metadata for one successful capture operation.""" - - template_name: str - heading: Heading - document: Document - interactive_used: bool = False - - -def _template_names(templates: dict[str, dict[str, str]]) -> list[str]: - """Return stable template name ordering for prompts and errors.""" - return sorted(templates) - - -def _require_templates(templates: dict[str, dict[str, str]]) -> None: - """Require at least one capture template before running capture.""" - if templates: - return - raise typer.BadParameter("No capture templates configured") - - -def _valid_template_names_text(template_names: list[str]) -> str: - """Return comma-separated template names for error messages.""" - return ", ".join(template_names) - - -def _select_template_name(template_names: list[str]) -> str: - """Prompt user for template selection by numeric index.""" - typer.echo("Select capture template:") - for index, name in enumerate(template_names, start=1): - typer.echo(f"{index}) {name}") - - selected_text = typer.prompt("Template number").strip() - if not selected_text: - raise typer.BadParameter("Template selection cannot be empty") - if not selected_text.isdigit(): - raise typer.BadParameter("Template selection must be a number") - - selected_index = int(selected_text) - if selected_index < 1 or selected_index > len(template_names): - raise typer.BadParameter( - f"Unknown template number {selected_index}. " - f"Valid template numbers: 1-{len(template_names)}", - ) - return template_names[selected_index - 1] - - -def _resolve_template_name( - template_name: str | None, - templates: dict[str, dict[str, str]], -) -> tuple[str, bool]: - """Resolve template name from CLI argument or interactive selection.""" - template_names = _template_names(templates) - if template_name is None: - return (_select_template_name(template_names), True) - - if template_name in templates: - return (template_name, False) - - valid_names = _valid_template_names_text(template_names) - raise typer.BadParameter( - f"Unknown capture template '{template_name}'. Valid templates: {valid_names}", - ) - - -def _template_placeholders(template_content: str) -> list[str]: - """Return unique placeholders in first-appearance order.""" - placeholders = [match.group(1) for match in _PLACEHOLDER_RE.finditer(template_content)] - return list(dict.fromkeys(placeholders)) - - -@dataclass(frozen=True) -class _TemplatePreview: - """Rendered capture template preview with placeholder spans.""" - - text: str - placeholder_spans: dict[str, list[tuple[int, int]]] - - -@dataclass(frozen=True) -class _FooterState: - """Footer display state for interactive capture editor.""" - - current_placeholder: str | None - current_input_value: str - cursor_position: int - current_field_index: int | None - total_fields: int - show_help_modal: bool = False - - -@dataclass(frozen=True) -class _ActiveField: - """Descriptor for one actively edited placeholder field.""" - - placeholder: str - field_index: int - total_fields: int - - -def _placeholder_token(placeholder: str) -> str: - """Return canonical placeholder token for unresolved values.""" - return f"{{{{{placeholder}}}}}" - - -def _render_template_preview(template_content: str, values: dict[str, str]) -> _TemplatePreview: - """Render template preview text and track placeholder output spans.""" - chunks: list[str] = [] - placeholder_spans: dict[str, list[tuple[int, int]]] = {} - output_index = 0 - cursor = 0 - - for match in _PLACEHOLDER_RE.finditer(template_content): - leading_text = template_content[cursor : match.start()] - chunks.append(leading_text) - output_index += len(leading_text) - - placeholder = match.group(1) - replacement = values.get(placeholder, _placeholder_token(placeholder)) - replacement_start = output_index - replacement_end = replacement_start + len(replacement) - chunks.append(replacement) - output_index = replacement_end - placeholder_spans.setdefault(placeholder, []).append((replacement_start, replacement_end)) - - cursor = match.end() - - trailing_text = template_content[cursor:] - chunks.append(trailing_text) - return _TemplatePreview( - text="".join(chunks), - placeholder_spans=placeholder_spans, - ) - - -def _build_template_body( - template_content: str, - values: dict[str, str], - current_placeholder: str | None, - current_input_value: str, -) -> Text: - """Build syntax-highlighted template body with active placeholder emphasis.""" - preview_values = dict(values) - if current_placeholder is not None and current_input_value: - preview_values[current_placeholder] = current_input_value - - preview = _render_template_preview(template_content, preview_values) - syntax = Syntax( - preview.text, - "org", - theme=DEFAULT_OUTPUT_THEME, - line_numbers=False, - word_wrap=True, - ) - highlighted = syntax.highlight(preview.text) - if current_placeholder is not None: - for start, end in preview.placeholder_spans.get(current_placeholder, []): - if end > start: - highlighted.stylize("bold black on bright_yellow", start, end) - return highlighted - - -def _build_footer_prompt( - footer_state: _FooterState, -) -> Text: - """Build interactive footer prompt with visible cursor position.""" - if footer_state.current_placeholder is None: - return Text( - "All placeholders resolved", - style="bold", - no_wrap=True, - overflow="ellipsis", - ) - - input_value_length = len(footer_state.current_input_value) - clamped_cursor = max(0, min(footer_state.cursor_position, input_value_length)) - footer = Text( - f"Value for '{footer_state.current_placeholder}': ", - style="bold", - ) - footer.append(footer_state.current_input_value[:clamped_cursor]) - if clamped_cursor < len(footer_state.current_input_value): - footer.append(footer_state.current_input_value[clamped_cursor], style="reverse") - footer.append(footer_state.current_input_value[clamped_cursor + 1 :]) - else: - footer.append(" ", style="reverse") - return footer - - -def _value_progress_marker(footer_state: _FooterState) -> str: - """Build footer field progress marker text.""" - if footer_state.total_fields < 1: - return "Value 0/0" - if footer_state.current_field_index is None: - return f"Value {footer_state.total_fields}/{footer_state.total_fields}" - return f"Value {footer_state.current_field_index}/{footer_state.total_fields}" - - -def _build_footer_status_line(footer_state: _FooterState) -> Table: - """Build footer status line with marker and key bindings.""" - status_line = Table.grid(expand=True) - status_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - status_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - status_line.add_row( - Text(_value_progress_marker(footer_state), style="dim", no_wrap=True, overflow="ellipsis"), - Text(INTERACTIVE_HELP_FOOTER_HINT, style="dim", no_wrap=True, overflow="ellipsis"), - ) - return status_line - - -def _count_wrapped_prompt_lines( - footer_prompt: Text, - *, - console_width: int, -) -> int: - """Count wrapped footer prompt lines for the current terminal width.""" - wrap_width = max(1, console_width) - measure_console = Console(width=wrap_width, force_terminal=True) - wrapped_lines = footer_prompt.wrap( - measure_console, - width=wrap_width, - overflow="fold", - no_wrap=False, - ) - return max(1, len(wrapped_lines)) - - -def _resolve_terminal_width() -> int: - """Resolve interactive terminal width from Typer's Rich console.""" - return max(1, _get_rich_console(stderr=False).size.width) - - -def _build_fullscreen_capture_renderable( - template_content: str, - values: dict[str, str], - footer_state: _FooterState, - console_width: int, -) -> RenderableType: - """Build full-screen capture renderable with template body and footer prompt.""" - if footer_state.show_help_modal: - return render_interactive_help_modal( - _CAPTURE_HELP_ENTRIES, - color_enabled=True, - ) - - footer_prompt = _build_footer_prompt(footer_state) - footer_prompt_height = _count_wrapped_prompt_lines(footer_prompt, console_width=console_width) - footer_height = max(2, 1 + footer_prompt_height) - layout = Layout(name="capture") - layout.split_column( - Layout(name="body", ratio=1), - Layout(name="separator", size=1), - Layout(name="footer", size=footer_height), - ) - layout["body"].update( - _build_template_body( - template_content, - values, - footer_state.current_placeholder, - footer_state.current_input_value, - ), - ) - layout["separator"].update(Rule(style="dim")) - layout["footer"].update( - Group( - _build_footer_status_line(footer_state), - footer_prompt, - ), - ) - return layout - - -def _supports_live_template_prompt() -> bool: - """Return whether interactive terminal supports live capture preview.""" - return sys.stdin.isatty() and sys.stdout.isatty() - - -def _prompt_unresolved_placeholders( - placeholders: list[str], - values: dict[str, str], -) -> dict[str, str]: - """Prompt unresolved placeholders using standard CLI prompt fallback.""" - resolved_values = dict(values) - for placeholder in placeholders: - resolved_values[placeholder] = typer.prompt( - f"Value for '{placeholder}'", - default="", - show_default=False, - ) - return resolved_values - - -def _apply_input_event( - value: str, - cursor_position: int, - event_name: str, - event_text: str, -) -> tuple[str, int, bool]: - """Apply one input event to editable text and cursor state.""" - if event_name == "ENTER": - return (value, cursor_position, True) - - cursor_targets = { - "LEFT": max(0, cursor_position - 1), - "RIGHT": min(len(value), cursor_position + 1), - "HOME": 0, - "END": len(value), - } - target_cursor = cursor_targets.get(event_name) - if target_cursor is not None: - return (value, target_cursor, False) - - if event_name == "BACKSPACE" and cursor_position > 0: - return ( - f"{value[: cursor_position - 1]}{value[cursor_position:]}", - cursor_position - 1, - False, - ) - if event_name == "DELETE" and cursor_position < len(value): - return (f"{value[:cursor_position]}{value[cursor_position + 1 :]}", cursor_position, False) - if event_name == "TEXT": - return ( - f"{value[:cursor_position]}{event_text}{value[cursor_position:]}", - cursor_position + len(event_text), - False, - ) - return (value, cursor_position, False) - - -def _read_live_placeholder_value( - live: Live, - template_content: str, - resolved_values: dict[str, str], - active_field: _ActiveField, -) -> str: - """Read one placeholder value from raw keyboard events in live mode.""" - current_value = "" - cursor_position = 0 - show_help_modal = False - fd = sys.stdin.fileno() - previous_mode = termios.tcgetattr(fd) - try: - tty.setcbreak(fd) - while True: - live.update( - _build_fullscreen_capture_renderable( - template_content, - resolved_values, - _FooterState( - current_placeholder=active_field.placeholder, - current_input_value=current_value, - cursor_position=cursor_position, - current_field_index=active_field.field_index, - total_fields=active_field.total_fields, - show_help_modal=show_help_modal, - ), - console_width=live.console.size.width, - ), - refresh=True, - ) - event = read_input_event(ctrl_p_as_paste=True) - if event is None: - continue - event_name, event_text = event - help_key = event_text if event_name == "TEXT" else event_name - consumed, show_help_modal = apply_help_modal_key( - help_key, - show_help_modal=show_help_modal, - ) - if consumed: - continue - if event_name == "ESC": - raise KeyboardInterrupt - current_value, cursor_position, done = _apply_input_event( - current_value, - cursor_position, - event_name, - event_text, - ) - if done: - return current_value - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, previous_mode) - - -def _prompt_with_live_preview( - template_content: str, - placeholders: list[str], - values: dict[str, str], -) -> dict[str, str]: - """Prompt unresolved placeholders in a full-screen interactive editor.""" - resolved_values = dict(values) - total_fields = len(placeholders) - try: - fd = sys.stdin.fileno() - termios.tcgetattr(fd) - except OSError, ValueError, termios.error: - return _prompt_unresolved_placeholders(placeholders, resolved_values) - - with Live( - _build_fullscreen_capture_renderable( - template_content, - resolved_values, - _FooterState( - current_placeholder=placeholders[0], - current_input_value="", - cursor_position=0, - current_field_index=1, - total_fields=total_fields, - show_help_modal=False, - ), - console_width=_resolve_terminal_width(), - ), - screen=True, - refresh_per_second=12, - auto_refresh=False, - transient=False, - ) as live: - set_bracketed_paste(True) - try: - for field_index, placeholder in enumerate(placeholders, start=1): - resolved_values[placeholder] = _read_live_placeholder_value( - live, - template_content, - resolved_values, - _ActiveField( - placeholder=placeholder, - field_index=field_index, - total_fields=total_fields, - ), - ) - - live.update( - _build_fullscreen_capture_renderable( - template_content, - resolved_values, - _FooterState( - current_placeholder=None, - current_input_value="", - cursor_position=0, - current_field_index=None, - total_fields=total_fields, - show_help_modal=False, - ), - console_width=live.console.size.width, - ), - refresh=True, - ) - finally: - set_bracketed_paste(False) - - return resolved_values - - -def _static_placeholder_values(document: Document) -> dict[str, str]: - """Build values for static non-interactive placeholders.""" - now = datetime.now().astimezone().replace(microsecond=0) - day_name = _ORG_WEEKDAY_ABBR[now.weekday()] - today_org = f"<{now:%Y-%m-%d} {day_name}>" - now_org = f"<{now:%Y-%m-%d} {day_name} {now:%H:%M}>" - next_id = str(len(list(document)) + 1) - return { - "uuid": str(uuid4()), - "today": today_org, - "now": now_org, - "id": next_id, - } - - -def _parent_placeholder_values(parent_heading: Heading) -> dict[str, str]: - """Build parent-based placeholders for one resolved parent heading.""" - - def _to_string(value: object) -> str: - if value is None: - return "" - return str(value) - - return { - "parent_category": _to_string(parent_heading.category), - "parent_title": _to_string(parent_heading.title_text), - "parent_todo": _to_string(parent_heading.todo), - "parent_id": _to_string(parent_heading.id), - } - - -def _build_static_placeholder_values( - document: Document, - parent_heading: Heading | None, -) -> dict[str, str]: - """Build static placeholder map, optionally including parent placeholders.""" - static_values = _static_placeholder_values(document) - if parent_heading is not None: - static_values.update(_parent_placeholder_values(parent_heading)) - return static_values - - -def _parse_set_values(set_values: list[str] | None) -> dict[str, str]: - """Parse repeated --set KEY=VALUE options into one map.""" - if set_values is None: - return {} - - parsed: dict[str, str] = {} - for raw_value in set_values: - if "=" not in raw_value: - raise typer.BadParameter(f"--set must be KEY=VALUE, got {raw_value!r}") - key, value = raw_value.split("=", 1) - normalized_key = key.strip() - if not normalized_key: - raise typer.BadParameter("--set key cannot be empty") - if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", normalized_key): - raise typer.BadParameter( - f"--set key {normalized_key!r} is not a valid template parameter name", - ) - parsed[normalized_key] = value - return parsed - - -def _document_placeholder_values(document: Document) -> dict[str, str]: - """Return explicit document-backed placeholder values.""" - - def _to_optional_string(value: object) -> str | None: - if value is None: - return None - text = str(value) - if not text: - return None - return text - - values = { - "document_category": _to_optional_string(document.category), - "document_filename": _to_optional_string(document.filename), - "document_title": _to_optional_string(document.title), - "document_author": _to_optional_string(document.author), - "document_description": _to_optional_string(document.description), - } - return {key: value for key, value in values.items() if value is not None} - - -def _resolve_placeholder_values( - template_content: str, - placeholders: list[str], - set_values: dict[str, str], - static_values: dict[str, str], - document_values: dict[str, str], -) -> tuple[dict[str, str], bool]: - """Resolve placeholder values from static values, --set, and prompts.""" - values: dict[str, str] = {} - unresolved_placeholders: list[str] = [] - for placeholder in placeholders: - if placeholder in set_values: - values[placeholder] = set_values[placeholder] - continue - if placeholder in static_values: - values[placeholder] = static_values[placeholder] - continue - if placeholder in document_values: - values[placeholder] = document_values[placeholder] - continue - if placeholder in values: - continue - unresolved_placeholders.append(placeholder) - - if _supports_live_template_prompt() and unresolved_placeholders: - return (_prompt_with_live_preview(template_content, unresolved_placeholders, values), True) - - for placeholder in unresolved_placeholders: - values[placeholder] = typer.prompt( - f"Value for '{placeholder}'", - default="", - show_default=False, - ) - return (values, bool(unresolved_placeholders)) - - -def _render_capture_content( - template_content: str, - set_values: list[str] | None, - static_values: dict[str, str], - document: Document, -) -> tuple[str, bool]: - """Render capture template content by replacing handlebars placeholders.""" - placeholders = _template_placeholders(template_content) - provided_values = _parse_set_values(set_values) - unknown_names = sorted(set(provided_values) - set(placeholders)) - if unknown_names: - unknown_list = ", ".join(unknown_names) - logger.warning("Ignoring unknown capture --set parameter(s): %s", unknown_list) - - values, prompted = _resolve_placeholder_values( - template_content, - placeholders, - provided_values, - static_values, - _document_placeholder_values(document), - ) - - def _replace(match: re.Match[str]) -> str: - return values[match.group(1)] - - rendered = _PLACEHOLDER_RE.sub(_replace, template_content) - if rendered.endswith("\n"): - return (rendered, prompted) - return (f"{rendered}\n", prompted) - - -def _validate_rendered_heading(rendered_content: str) -> Heading: - """Parse rendered capture template as a single heading.""" - try: - return Heading.from_source(rendered_content) - except ValueError as err: - raise typer.BadParameter(f"Invalid rendered capture heading: {err}") from err - - -def _resolve_parent_from_selector(document: Document, parent_selector: str) -> Heading: - """Resolve one parent heading from selector expression against one target document.""" - normalized_selector = parent_selector.strip() - if not normalized_selector: - raise typer.BadParameter("Capture template parent selector cannot be empty") - - query_text = f".[] | select({normalized_selector})" - - try: - compiled_query = compile_query_text(query_text) - except QueryParseError as err: - raise typer.BadParameter(f"Invalid parent selector: {err}") from err - - try: - results = compiled_query(Stream([document]), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter(f"Parent selector failed: {err}") from err - - matches_by_identity: dict[int, Heading] = {} - for result in results: - if not isinstance(result, Heading): - raise typer.BadParameter("Parent selector must match task headings") - matches_by_identity[id(result)] = result - - matches = list(matches_by_identity.values()) - if not matches: - raise typer.BadParameter("Parent selector did not match any heading") - if len(matches) > 1: - raise typer.BadParameter("Parent selector matched multiple headings") - return matches[0] - - -def _attach_heading(document: Document, parent_heading: Heading | None, heading: Heading) -> None: - """Attach heading at root or under one resolved parent.""" - if parent_heading is None: - document.children.append(heading) - return - parent_heading.children.append(heading) - - -def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: - """Capture one task from templates and return created heading metadata.""" - templates = config_module.CONFIG_CAPTURE_TEMPLATES - _require_templates(templates) - - template_key, template_prompted = _resolve_template_name(args.template_name, templates) - template = templates[template_key] - target_file = template["file"] if args.file is None else args.file - template_parent_selector = template.get("parent") - document = load_document(target_file) - - parent_heading = None - if args.parent is not None: - parent_heading = resolve_parent_heading(document, args.parent) - elif template_parent_selector is not None: - parent_heading = _resolve_parent_from_selector(document, template_parent_selector) - - static_values = _build_static_placeholder_values(document, parent_heading) - rendered_content, placeholder_prompted = _render_capture_content( - template["content"], - args.set_values, - static_values, - document, - ) - heading = _validate_rendered_heading(rendered_content) - - _attach_heading(document, parent_heading, heading) - save_document(document) - - return TasksCaptureResult( - template_name=template_key, - heading=heading, - document=document, - interactive_used=template_prompted or placeholder_prompted, - ) - - -def run_tasks_capture(args: TasksCaptureArgs) -> None: - """Run tasks capture command using configured templates.""" - result = capture_task(args) - if not result.interactive_used: - typer.echo(result.heading.id or result.heading.title_text) - - -def register(app: typer.Typer) -> None: - """Register the tasks capture command.""" - - @app.command( - "capture", - help=interactive_help_command_text( - "Create a task from a configured capture template.", - _CAPTURE_HELP_ENTRIES, - ), - ) - def capture( - template_name: str | None = typer.Argument( - None, - metavar="TEMPLATE_NAME", - help="Capture template name. If omitted, interactive selection is shown", - ), - config: str = typer.Option( - ".org-cli.yaml", - "--config", - metavar="FILE", - help="Config file name to load from current directory", - ), - file: str | None = typer.Option( - None, - "--file", - metavar="FILE", - help="Override template target file path", - ), - parent: str | None = typer.Option( - None, - "--parent", - metavar="ID_OR_TITLE", - help="Override template parent by heading ID or title", - ), - set_values: list[str] | None = typer.Option( # noqa: B008 - None, - "--set", - metavar="KEY=VALUE", - help="Set template parameter values without prompting", - ), - ) -> None: - """Create a task from a configured capture template.""" - args = TasksCaptureArgs( - template_name=template_name, - config=config, - file=file, - parent=parent, - set_values=set_values, - ) - config_module.log_command_arguments(args, "tasks capture") - run_tasks_capture(args) diff --git a/src/org/commands/tasks/capture/__init__.py b/src/org/commands/tasks/capture/__init__.py new file mode 100644 index 0000000..c34e69d --- /dev/null +++ b/src/org/commands/tasks/capture/__init__.py @@ -0,0 +1,45 @@ +"""Tasks capture command package.""" + +from .app import run_capture_form_app, run_template_selection_app +from .command import capture_task, register, run_tasks_capture +from .domain import ( + CapturePlan, + TasksCaptureArgs, + TasksCaptureResult, + _build_static_placeholder_values, + _document_placeholder_values, + _parent_placeholder_values, + _parse_set_values, + _render_capture_content_with_values, + _render_template_preview, + _resolve_parent_from_selector, + _static_placeholder_values, + _template_names, + _template_placeholders, + _validate_rendered_heading, + prepare_capture_plan, +) + + +__all__ = [ + "CapturePlan", + "TasksCaptureArgs", + "TasksCaptureResult", + "_build_static_placeholder_values", + "_document_placeholder_values", + "_parent_placeholder_values", + "_parse_set_values", + "_render_capture_content_with_values", + "_render_template_preview", + "_resolve_parent_from_selector", + "_static_placeholder_values", + "_template_names", + "_template_placeholders", + "_validate_rendered_heading", + "capture_task", + "prepare_capture_plan", + "register", + "run_capture_form_app", + "run_tasks_capture", + "run_template_selection_app", +] diff --git a/src/org/commands/tasks/capture/app.py b/src/org/commands/tasks/capture/app.py new file mode 100644 index 0000000..6f6bc44 --- /dev/null +++ b/src/org/commands/tasks/capture/app.py @@ -0,0 +1,208 @@ +"""Interactive Textual apps for tasks capture.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.console import Group +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import Button, Static, TextArea + +import org.tui.app +import org.tui.help +import org.tui.selection + +from .domain import ( + _CAPTURE_HELP_ENTRIES, + CapturePlan, + _build_template_body, +) + + +if TYPE_CHECKING: + from typing import ClassVar + + from textual import events as textual_events + from textual.app import ComposeResult + + +class _TemplateSelectionApp(org.tui.app.CommandApp): + """Small app for selecting a capture template interactively.""" + + CSS_PATH = "styles/selection.tcss" + + def __init__(self, template_names: list[str]) -> None: + super().__init__() + self._template_names = template_names + self.selected_template_name: str | None = None + + def compose(self) -> ComposeResult: + yield Container( + Static(Text("Capture template", style="bold"), id="selection-title"), + id="selection-root", + ) + + def on_mount(self) -> None: + self._prompt_template_selection() + + def _prompt_template_selection(self) -> None: + self.push_screen( + org.tui.selection.SelectionModalScreen( + "Capture template", + [ + org.tui.selection.SelectionOption(value=name, label=name) + for name in self._template_names + ], + ), + callback=self._complete_template_selection, + ) + + def _complete_template_selection(self, value: str | None) -> None: + """Exit after one shared selection modal result.""" + self.selected_template_name = value + self.exit() + + +class CaptureApp(org.tui.app.CommandApp): + """Interactive multi-field capture form with preview.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+s", "save_capture", show=False, priority=True), + Binding("escape", "cancel_capture", show=False, priority=True), + Binding("question_mark", "show_help", show=False, priority=True), + ] + + def __init__(self, plan: CapturePlan) -> None: + """Store prepared capture plan and mutable field values.""" + super().__init__() + self.plan = plan + self.field_values = { + placeholder: plan.values.get(placeholder, "") for placeholder in plan.placeholders + } + self.result_values: dict[str, str] | None = None + self.status_message = "" + + def compose(self) -> ComposeResult: + """Build the interactive capture form and preview layout.""" + yield Vertical( + Static(Text(f"Template: {self.plan.template_name}", style="bold"), id="capture-title"), + Horizontal( + Vertical( + Static(Text("Preview", style="bold"), id="preview-title"), + Static(id="capture-preview"), + id="capture-preview-panel", + ), + Vertical( + Static(Text("Fields", style="bold"), id="fields-title"), + Vertical(id="capture-fields"), + Horizontal( + Button("Save", id="capture-save"), + Button("Cancel", id="capture-cancel"), + id="capture-buttons", + ), + id="capture-form", + ), + id="capture-body", + ), + Static(id="capture-status"), + ) + + def on_mount(self) -> None: + """Mount unresolved placeholder inputs and focus the first field.""" + fields = self.query_one("#capture-fields", Vertical) + for placeholder in self.plan.unresolved_placeholders: + fields.mount( + Static( + Text(f"Value for '{placeholder}'", style="bold"), + classes="field-label", + ), + ) + fields.mount( + TextArea( + self.field_values[placeholder], + id=f"field-{placeholder}", + classes="field-input", + soft_wrap=True, + compact=True, + ), + ) + if self.plan.unresolved_placeholders: + self.query_one(f"#field-{self.plan.unresolved_placeholders[0]}", TextArea).focus() + self._refresh_view() + + def _preview_values(self) -> dict[str, str]: + return {**self.plan.values, **self.field_values} + + def _refresh_view(self) -> None: + preview = _build_template_body( + self.plan.template_content, + self._preview_values(), + None, + "", + ) + self.query_one("#capture-preview", Static).update(Group(preview)) + self.query_one("#capture-status", Static).update(Text(self.status_message, style="dim")) + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + """Keep preview in sync with edited field values.""" + widget_id = event.text_area.id or "" + if not widget_id.startswith("field-"): + return + placeholder = widget_id.removeprefix("field-") + self.field_values[placeholder] = event.text_area.text + self._refresh_view() + + def on_key(self, event: textual_events.Key) -> None: + """Handle global capture keys even while inputs are focused.""" + key = event.key + character = event.character or "" + handler = None + if key in {"question_mark", "?"} or character == "?": + handler = self.action_show_help + elif key == "ctrl+s": + handler = self.action_save_capture + elif key == "escape": + handler = self.action_cancel_capture + if handler is None: + return + event.stop() + handler() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle save/cancel button presses.""" + if event.button.id == "capture-save": + self.action_save_capture() + elif event.button.id == "capture-cancel": + self.action_cancel_capture() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen(org.tui.help.HelpModalScreen(_CAPTURE_HELP_ENTRIES, color_enabled=True)) + + def action_cancel_capture(self) -> None: + """Cancel interactive capture without saving.""" + self.result_values = None + self.exit() + + def action_save_capture(self) -> None: + """Save the current field values and exit the form.""" + self.result_values = self._preview_values() + self.exit() + + +def run_template_selection_app(template_names: list[str]) -> str | None: + """Run the template selection app and return the selected template name.""" + app = _TemplateSelectionApp(template_names) + app.run() + return app.selected_template_name + + +def run_capture_form_app(plan: CapturePlan) -> dict[str, str] | None: + """Run the capture form app and return resolved field values.""" + app = CaptureApp(plan) + app.run() + return app.result_values diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py new file mode 100644 index 0000000..6fd489d --- /dev/null +++ b/src/org/commands/tasks/capture/command.py @@ -0,0 +1,139 @@ +"""CLI wiring for tasks capture.""" + +from __future__ import annotations + +import sys +from dataclasses import replace + +import click +import typer + +import org.config.app +import org.logging +from org.tui.help import interactive_help_command_text + +from .app import run_capture_form_app, run_template_selection_app +from .domain import ( + _CAPTURE_HELP_ENTRIES, + TasksCaptureArgs, + TasksCaptureResult, + _missing_placeholder_error, + _missing_template_name_error, + _require_templates, + _template_names, + _valid_template_names_text, + finalize_capture_plan, + prepare_capture_plan, +) + + +def _is_interactive_terminal() -> bool: + """Return whether capture can launch interactive terminal apps.""" + return sys.stdin.isatty() and sys.stdout.isatty() + + +def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: + """Capture one task from templates and return created heading metadata.""" + templates = org.config.app.CONFIG_CAPTURE_TEMPLATES + _require_templates(templates) + + interactive_used = False + resolved_args = args + interactive_terminal = _is_interactive_terminal() + + if args.template_name is None: + if not interactive_terminal: + raise _missing_template_name_error() + selected_name = run_template_selection_app(_template_names(templates)) + if selected_name is None: + raise KeyboardInterrupt + resolved_args = replace(args, template_name=selected_name) + interactive_used = True + + if resolved_args.template_name is None: + raise _missing_template_name_error() + if resolved_args.template_name not in templates: + valid_names = _valid_template_names_text(_template_names(templates)) + raise typer.BadParameter( + "Unknown capture template " + f"'{resolved_args.template_name}'. Valid templates: {valid_names}", + ) + + plan = prepare_capture_plan(resolved_args, resolved_args.template_name) + if plan.unresolved_placeholders: + if not interactive_terminal: + raise _missing_placeholder_error(plan.unresolved_placeholders) + values = run_capture_form_app(plan) + if values is None: + raise KeyboardInterrupt + return finalize_capture_plan(plan, values, interactive_used=True) + + return finalize_capture_plan(plan, plan.values, interactive_used=interactive_used) + + +def run_tasks_capture(args: TasksCaptureArgs) -> None: + """Run tasks capture command using configured templates.""" + if args.template_name is None and not _is_interactive_terminal(): + raise click.UsageError( + "org tasks capture requires a TTY unless TEMPLATE_NAME and all values are provided", + ) + + result = capture_task(args) + if not result.interactive_used: + typer.echo(result.heading.id or result.heading.title_text) + + +def register(app: typer.Typer) -> None: + """Register the tasks capture command.""" + + @app.command( + "capture", + help=interactive_help_command_text( + "Create a task from a configured capture template.", + _CAPTURE_HELP_ENTRIES, + ), + ) + def capture( + template_name: str | None = typer.Argument( + None, + metavar="TEMPLATE_NAME", + help="Capture template name. If omitted, interactive selection is shown", + ), + config: str = typer.Option( + ".org-cli.yaml", + "--config", + metavar="FILE", + help="Config file name to load from current directory", + ), + file: str | None = typer.Option( + None, + "--file", + metavar="FILE", + help="Override template target file path", + ), + parent: str | None = typer.Option( + None, + "--parent", + metavar="ID_OR_TITLE", + help="Override template parent by heading ID or title", + ), + set_values: list[str] | None = typer.Option( # noqa: B008 + None, + "--set", + metavar="KEY=VALUE", + help="Set template parameter values without prompting", + ), + ) -> None: + """Create a task from a configured capture template.""" + args = TasksCaptureArgs( + template_name=template_name, + config=config, + file=file, + parent=parent, + set_values=set_values, + ) + org.logging.log_command_arguments(args, "tasks capture") + run_tasks_capture(args) + + +__all__ = ["TasksCaptureArgs", "TasksCaptureResult", "register", "run_tasks_capture"] diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py new file mode 100644 index 0000000..49b558b --- /dev/null +++ b/src/org/commands/tasks/capture/domain.py @@ -0,0 +1,401 @@ +"""Domain logic for tasks capture.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import uuid4 + +import typer +from org_parser.document import Document, Heading +from rich.syntax import Syntax + +import org.config.app +from org.commands.tasks.common import load_document, resolve_parent_heading, save_document +from org.pipeline.format import DEFAULT_OUTPUT_THEME +from org.query.runner import compile_query_or_raise, execute_query_or_raise +from org.tui.help import InteractiveHelpEntry + + +if TYPE_CHECKING: + from rich.text import Text + + +_PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*}}") +_ORG_WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +logger = logging.getLogger("org") + + +_CAPTURE_HELP_ENTRIES = [ + InteractiveHelpEntry("Tab / Shift-Tab", "Move focus between form fields and controls."), + InteractiveHelpEntry("Type text", "Edit the focused field and update the preview live."), + InteractiveHelpEntry("Ctrl-S", "Save the rendered capture entry."), + InteractiveHelpEntry("Esc", "Cancel capture and return to the shell."), +] + + +@dataclass +class TasksCaptureArgs: + """Arguments for the tasks capture command.""" + + template_name: str | None + config: str + file: str | None + parent: str | None + set_values: list[str] | None + + +@dataclass(frozen=True) +class TasksCaptureResult: + """Result metadata for one successful capture operation.""" + + template_name: str + heading: Heading + document: Document + interactive_used: bool = False + + +@dataclass(frozen=True) +class CapturePlan: + """Prepared capture plan with unresolved placeholder metadata.""" + + args: TasksCaptureArgs + template_name: str + template_content: str + document: Document + parent_heading: Heading | None + placeholders: list[str] + values: dict[str, str] + unresolved_placeholders: list[str] + + +@dataclass(frozen=True) +class _TemplatePreview: + """Rendered capture template preview with placeholder spans.""" + + text: str + placeholder_spans: dict[str, list[tuple[int, int]]] + + +def _template_names(templates: dict[str, dict[str, str]]) -> list[str]: + """Return stable template name ordering for prompts and errors.""" + return sorted(templates) + + +def _require_templates(templates: dict[str, dict[str, str]]) -> None: + """Require at least one capture template before running capture.""" + if templates: + return + raise typer.BadParameter("No capture templates configured") + + +def _valid_template_names_text(template_names: list[str]) -> str: + """Return comma-separated template names for error messages.""" + return ", ".join(template_names) + + +def _template_placeholders(template_content: str) -> list[str]: + """Return unique placeholders in first-appearance order.""" + placeholders = [match.group(1) for match in _PLACEHOLDER_RE.finditer(template_content)] + return list(dict.fromkeys(placeholders)) + + +def _placeholder_token(placeholder: str) -> str: + """Return canonical placeholder token for unresolved values.""" + return f"{{{{{placeholder}}}}}" + + +def _render_template_preview(template_content: str, values: dict[str, str]) -> _TemplatePreview: + """Render template preview text and track placeholder output spans.""" + chunks: list[str] = [] + placeholder_spans: dict[str, list[tuple[int, int]]] = {} + output_index = 0 + cursor = 0 + + for match in _PLACEHOLDER_RE.finditer(template_content): + leading_text = template_content[cursor : match.start()] + chunks.append(leading_text) + output_index += len(leading_text) + + placeholder = match.group(1) + replacement = values.get(placeholder, _placeholder_token(placeholder)) + replacement_start = output_index + replacement_end = replacement_start + len(replacement) + chunks.append(replacement) + output_index = replacement_end + placeholder_spans.setdefault(placeholder, []).append((replacement_start, replacement_end)) + + cursor = match.end() + + trailing_text = template_content[cursor:] + chunks.append(trailing_text) + return _TemplatePreview(text="".join(chunks), placeholder_spans=placeholder_spans) + + +def _build_template_body( + template_content: str, + values: dict[str, str], + current_placeholder: str | None, + current_input_value: str, +) -> Text: + """Build syntax-highlighted template body with active placeholder emphasis.""" + preview_values = dict(values) + if current_placeholder is not None and current_input_value: + preview_values[current_placeholder] = current_input_value + + preview = _render_template_preview(template_content, preview_values) + syntax = Syntax( + preview.text, + "org", + theme=DEFAULT_OUTPUT_THEME, + line_numbers=False, + word_wrap=True, + ) + highlighted = syntax.highlight(preview.text) + if current_placeholder is not None: + for start, end in preview.placeholder_spans.get(current_placeholder, []): + if end > start: + highlighted.stylize("bold black on bright_yellow", start, end) + return highlighted + + +def _static_placeholder_values(document: Document) -> dict[str, str]: + """Build values for static non-interactive placeholders.""" + now = datetime.now().astimezone().replace(microsecond=0) + day_name = _ORG_WEEKDAY_ABBR[now.weekday()] + today_org = f"<{now:%Y-%m-%d} {day_name}>" + now_org = f"<{now:%Y-%m-%d} {day_name} {now:%H:%M}>" + next_id = str(len(list(document)) + 1) + return { + "uuid": str(uuid4()), + "today": today_org, + "now": now_org, + "id": next_id, + } + + +def _parent_placeholder_values(parent_heading: Heading) -> dict[str, str]: + """Build parent-based placeholders for one resolved parent heading.""" + + def _to_string(value: object) -> str: + if value is None: + return "" + return str(value) + + return { + "parent_category": _to_string(parent_heading.category), + "parent_title": _to_string(parent_heading.title_text), + "parent_todo": _to_string(parent_heading.todo), + "parent_id": _to_string(parent_heading.id), + } + + +def _build_static_placeholder_values( + document: Document, + parent_heading: Heading | None, +) -> dict[str, str]: + """Build static placeholder map, optionally including parent placeholders.""" + static_values = _static_placeholder_values(document) + if parent_heading is not None: + static_values.update(_parent_placeholder_values(parent_heading)) + return static_values + + +def _parse_set_values(set_values: list[str] | None) -> dict[str, str]: + """Parse repeated --set KEY=VALUE options into one map.""" + if set_values is None: + return {} + + parsed: dict[str, str] = {} + for raw_value in set_values: + if "=" not in raw_value: + raise typer.BadParameter(f"--set must be KEY=VALUE, got {raw_value!r}") + key, value = raw_value.split("=", 1) + normalized_key = key.strip() + if not normalized_key: + raise typer.BadParameter("--set key cannot be empty") + if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", normalized_key): + raise typer.BadParameter( + f"--set key {normalized_key!r} is not a valid template parameter name", + ) + parsed[normalized_key] = value + return parsed + + +def _document_placeholder_values(document: Document) -> dict[str, str]: + """Return explicit document-backed placeholder values.""" + + def _to_optional_string(value: object) -> str | None: + if value is None: + return None + text = str(value) + if not text: + return None + return text + + values = { + "document_category": _to_optional_string(document.category), + "document_filename": _to_optional_string(document.filename), + "document_title": _to_optional_string(document.title), + "document_author": _to_optional_string(document.author), + "document_description": _to_optional_string(document.description), + } + return {key: value for key, value in values.items() if value is not None} + + +def resolve_initial_placeholder_values( + placeholders: list[str], + set_values: dict[str, str], + static_values: dict[str, str], + document_values: dict[str, str], +) -> tuple[dict[str, str], list[str]]: + """Resolve initial placeholder values without prompting.""" + values: dict[str, str] = {} + unresolved_placeholders: list[str] = [] + for placeholder in placeholders: + if placeholder in set_values: + values[placeholder] = set_values[placeholder] + continue + if placeholder in static_values: + values[placeholder] = static_values[placeholder] + continue + if placeholder in document_values: + values[placeholder] = document_values[placeholder] + continue + if placeholder in values: + continue + unresolved_placeholders.append(placeholder) + return values, unresolved_placeholders + + +def _render_capture_content_with_values(template_content: str, values: dict[str, str]) -> str: + """Render capture content from fully resolved placeholder values.""" + + def _replace(match: re.Match[str]) -> str: + return values[match.group(1)] + + rendered = _PLACEHOLDER_RE.sub(_replace, template_content) + if rendered.endswith("\n"): + return rendered + return f"{rendered}\n" + + +def _validate_rendered_heading(rendered_content: str) -> Heading: + """Parse rendered capture template as a single heading.""" + try: + return Heading.from_source(rendered_content) + except ValueError as err: + raise typer.BadParameter(f"Invalid rendered capture heading: {err}") from err + + +def _resolve_parent_from_selector(document: Document, parent_selector: str) -> Heading: + """Resolve one parent heading from selector expression against one target document.""" + normalized_selector = parent_selector.strip() + if not normalized_selector: + raise typer.BadParameter("Capture template parent selector cannot be empty") + + query_text = f".[] | select({normalized_selector})" + + compiled_query = compile_query_or_raise( + query_text, + lambda message: typer.BadParameter(f"Invalid parent selector: {message}"), + ) + results = execute_query_or_raise( + compiled_query, + [document], + {}, + lambda message: typer.BadParameter(f"Parent selector failed: {message}"), + ) + + matches_by_identity: dict[int, Heading] = {} + for result in results: + if not isinstance(result, Heading): + raise typer.BadParameter("Parent selector must match task headings") + matches_by_identity[id(result)] = result + + matches = list(matches_by_identity.values()) + if not matches: + raise typer.BadParameter("Parent selector did not match any heading") + if len(matches) > 1: + raise typer.BadParameter("Parent selector matched multiple headings") + return matches[0] + + +def _attach_heading(document: Document, parent_heading: Heading | None, heading: Heading) -> None: + """Attach heading at root or under one resolved parent.""" + if parent_heading is None: + document.children.append(heading) + return + parent_heading.children.append(heading) + + +def prepare_capture_plan(args: TasksCaptureArgs, template_name: str) -> CapturePlan: + """Prepare capture plan from resolved template and current document state.""" + templates = org.config.app.CONFIG_CAPTURE_TEMPLATES + template = templates[template_name] + target_file = template["file"] if args.file is None else args.file + template_parent_selector = template.get("parent") + document = load_document(target_file) + + parent_heading = None + if args.parent is not None: + parent_heading = resolve_parent_heading(document, args.parent) + elif template_parent_selector is not None: + parent_heading = _resolve_parent_from_selector(document, template_parent_selector) + + placeholders = _template_placeholders(template["content"]) + set_values = _parse_set_values(args.set_values) + unknown_names = sorted(set(set_values) - set(placeholders)) + if unknown_names: + unknown_list = ", ".join(unknown_names) + logger.warning("Ignoring unknown capture --set parameter(s): %s", unknown_list) + + static_values = _build_static_placeholder_values(document, parent_heading) + document_values = _document_placeholder_values(document) + values, unresolved_placeholders = resolve_initial_placeholder_values( + placeholders, + set_values, + static_values, + document_values, + ) + return CapturePlan( + args=args, + template_name=template_name, + template_content=template["content"], + document=document, + parent_heading=parent_heading, + placeholders=placeholders, + values=values, + unresolved_placeholders=unresolved_placeholders, + ) + + +def _missing_template_name_error() -> typer.BadParameter: + return typer.BadParameter("Capture template name is required in non-interactive mode") + + +def _missing_placeholder_error(unresolved_placeholders: list[str]) -> typer.BadParameter: + names = ", ".join(unresolved_placeholders) + return typer.BadParameter(f"Missing placeholder values for: {names}") + + +def finalize_capture_plan( + plan: CapturePlan, + values: dict[str, str], + *, + interactive_used: bool, +) -> TasksCaptureResult: + """Render and save the prepared capture plan.""" + rendered_content = _render_capture_content_with_values(plan.template_content, values) + heading = _validate_rendered_heading(rendered_content) + _attach_heading(plan.document, plan.parent_heading, heading) + save_document(plan.document) + return TasksCaptureResult( + template_name=plan.template_name, + heading=heading, + document=plan.document, + interactive_used=interactive_used, + ) diff --git a/src/org/commands/tasks/capture/styles/app.tcss b/src/org/commands/tasks/capture/styles/app.tcss new file mode 100644 index 0000000..461c130 --- /dev/null +++ b/src/org/commands/tasks/capture/styles/app.tcss @@ -0,0 +1,63 @@ +Screen { + layout: vertical; +} + +#capture-title { + margin-bottom: 1; +} + +#capture-body { + height: 1fr; +} + +#capture-preview-panel { + width: 1fr; + padding-right: 1; +} + +#preview-title { + margin-bottom: 1; +} + +#capture-form { + width: 40%; + min-width: 32; + height: 1fr; +} + +#capture-fields { + height: 1fr; + overflow-y: auto; +} + +.field-label { + margin-top: 1; +} + +.field-input { + height: auto; + min-height: 3; + max-height: 8; +} + +#capture-preview { + height: 1fr; + border: round $primary; + padding: 1; +} + +#capture-buttons { + margin-top: 1; + width: 100%; + align-horizontal: center; + height: auto; +} + +#capture-buttons Button { + width: 16; +} + +#capture-status { + height: auto; + margin-top: 1; +} diff --git a/src/org/commands/tasks/capture/styles/selection.tcss b/src/org/commands/tasks/capture/styles/selection.tcss new file mode 100644 index 0000000..f8422f0 --- /dev/null +++ b/src/org/commands/tasks/capture/styles/selection.tcss @@ -0,0 +1,15 @@ +Screen { + align: center middle; +} + +#selection-root { + width: 60%; + max-width: 80; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#selection-title { + margin-bottom: 1; +} diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index 6efa5b5..606175b 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -4,7 +4,6 @@ import json import logging -from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Literal @@ -14,15 +13,8 @@ from org_parser.text import CompletionCounter from org_parser.time import Timestamp -from org import config as config_module -from org.commands.interactive_common import FooterPromptState -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) +import org.config.app +from org.query.runner import compile_query_or_raise, execute_query_or_raise if TYPE_CHECKING: @@ -33,15 +25,6 @@ PlanningTimestampField = Literal["scheduled", "deadline", "closed"] -@dataclass(frozen=True) -class PromptActionConfig: - """Standardized interactive prompt texts for one user-input action.""" - - prompt: FooterPromptState - cancel_status: str - invalid_status: str - - def parse_comment_flag(value: str) -> bool: """Parse --comment value as strict true/false.""" normalized = value.strip().lower() @@ -96,17 +79,6 @@ def iter_descendants(heading: Heading) -> list[Heading]: return descendants -def apply_subtree_level(heading: Heading, new_level: int) -> None: - """Apply heading level and shift descendants by the same delta.""" - level_delta = new_level - heading.level - if level_delta == 0: - return - - heading.level = new_level - for descendant in iter_descendants(heading): - descendant.level += level_delta - - def normalize_selector(value: str | None, option_name: str) -> str | None: """Normalize optional selector value and reject blank strings.""" if value is None: @@ -219,50 +191,11 @@ def todo_states_for_heading(heading: Heading) -> list[str]: return list(dict.fromkeys(heading.document.all_states)) -def resolve_todo_state_selection(selection: str, states: list[str]) -> str | None: - """Resolve TODO state from numeric shortcut or explicit value.""" - normalized = selection.strip() - if not normalized: - return None - if normalized.isdigit(): - index = int(normalized) - 1 - if 0 <= index < len(states): - return states[index] - return None - if normalized in states: - return normalized - return None - - -def state_selection_prompt_label(states: list[str]) -> str: - """Return standard interactive prompt label for TODO state selection.""" - options = ", ".join(f"{index}:{state}" for index, state in enumerate(states, start=1)) - return f"State number or value ({options})" - - -def state_selection_prompt_config(states: list[str]) -> PromptActionConfig: - """Return standardized prompt config for TODO state selection.""" - return PromptActionConfig( - prompt=FooterPromptState(label=state_selection_prompt_label(states)), - cancel_status="State change cancelled", - invalid_status="Invalid TODO state selection", - ) - - def tags_prompt_label() -> str: """Return standard interactive prompt label for tags editing.""" return "Tags CSV (blank clears)" -def tags_prompt_config() -> PromptActionConfig: - """Return standardized prompt config for tags editing.""" - return PromptActionConfig( - prompt=FooterPromptState(label=tags_prompt_label()), - cancel_status="Tags edit cancelled", - invalid_status="Invalid tags input", - ) - - def planning_prompt_label(field: PlanningTimestampField) -> str: """Return standard interactive prompt label for planning timestamp editing.""" labels: dict[PlanningTimestampField, str] = { @@ -273,60 +206,11 @@ def planning_prompt_label(field: PlanningTimestampField) -> str: return f"{labels[field]} (blank clears)" -def planning_prompt_config(field: PlanningTimestampField) -> PromptActionConfig: - """Return standardized prompt config for planning timestamp editing.""" - return PromptActionConfig( - prompt=FooterPromptState(label=planning_prompt_label(field)), - cancel_status="Planning timestamp edit cancelled", - invalid_status="Invalid planning timestamp", - ) - - -def refile_prompt_label(paths: list[str]) -> str: - """Return standard interactive prompt label for refile destination.""" - options = ", ".join(f"{index}:{filename}" for index, filename in enumerate(paths, start=1)) - return f"Destination file (# or path, blank cancels) [{options}]" - - -def refile_prompt_config(paths: list[str]) -> PromptActionConfig: - """Return standardized prompt config for task refile destination.""" - return PromptActionConfig( - prompt=FooterPromptState(label=refile_prompt_label(paths)), - cancel_status="Refile cancelled", - invalid_status="Invalid destination shortcut", - ) - - -def resolve_refile_destination_input( - destination_input: str, - paths: list[str], -) -> tuple[str | None, str | None]: - """Resolve refile destination path or return a validation error message.""" - normalized = destination_input.strip() - if not normalized: - return None, None - if normalized.isdigit(): - index = int(normalized) - 1 - if 0 <= index < len(paths): - return paths[index], None - return None, "Invalid destination shortcut" - return normalized, None - - def clock_duration_prompt_label() -> str: """Return standard interactive prompt label for clock duration input.""" return "Clock duration (H:MM, Xm, Xh, minutes; blank cancels)" -def clock_duration_prompt_config() -> PromptActionConfig: - """Return standardized prompt config for clock duration input.""" - return PromptActionConfig( - prompt=FooterPromptState(label=clock_duration_prompt_label()), - cancel_status="Clock action cancelled", - invalid_status="Invalid clock duration", - ) - - def capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]: """Return stable capture template name ordering for prompts.""" return sorted(templates) @@ -334,36 +218,7 @@ def capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]: def configured_capture_template_names() -> list[str]: """Return stable capture template names from loaded config.""" - return capture_template_names(config_module.CONFIG_CAPTURE_TEMPLATES) - - -def capture_template_prompt_label(template_names: list[str]) -> str: - """Return standard interactive prompt label for capture template selection.""" - options = ", ".join(f"{index}:{name}" for index, name in enumerate(template_names, start=1)) - return f"Capture template number (blank cancels) [{options}]" - - -def capture_template_prompt_config(template_names: list[str] | None = None) -> PromptActionConfig: - """Return standardized prompt config for capture template selection.""" - names = configured_capture_template_names() if template_names is None else template_names - return PromptActionConfig( - prompt=FooterPromptState(label=capture_template_prompt_label(names)), - cancel_status="Capture cancelled", - invalid_status="Invalid capture template shortcut", - ) - - -def resolve_capture_template_selection(selection: str, template_names: list[str]) -> str | None: - """Resolve capture template from numeric shortcut input.""" - normalized = selection.strip() - if not normalized: - return None - if not normalized.isdigit(): - return None - index = int(normalized) - 1 - if 0 <= index < len(template_names): - return template_names[index] - return None + return capture_template_names(org.config.app.CONFIG_CAPTURE_TEMPLATES) def parse_clock_duration(value: str) -> timedelta: @@ -507,20 +362,22 @@ def resolve_headings_by_query( selector_query: str, ) -> list[Heading]: """Resolve matching headings across files from selector query.""" - try: - compiled_query = compile_query_text(selector_query) - except QueryParseError as err: - raise typer.BadParameter(f"Invalid task selector query: {err}") from err + compiled_query = compile_query_or_raise( + selector_query, + lambda message: typer.BadParameter(f"Invalid task selector query: {message}"), + ) logger.info("Task selector query: %s", selector_query) matches_by_identity: dict[int, Heading] = {} for filename in filenames: document = load_document(filename) logger.info("Running task selector query against file: %s", filename) - try: - results = compiled_query(Stream([document]), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter(f"Task selector query failed: {err}") from err + results = execute_query_or_raise( + compiled_query, + [document], + {}, + lambda message: typer.BadParameter(f"Task selector query failed: {message}"), + ) for value in results: if not isinstance(value, Heading): diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index d0422a5..e001686 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -8,13 +8,14 @@ import typer -from org import config as config_module -from org.cli_common import resolve_input_paths -from org.commands.editor import edit_heading_subtree_in_external_editor +import org.config.app +import org.logging from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, ) +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.pipeline.load import resolve_input_paths logger = logging.getLogger("org") @@ -101,7 +102,7 @@ def tasks_edit( query_id=query_id, query=query, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks edit") - config_module.log_command_arguments(args, "tasks edit") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks edit") + org.logging.log_command_arguments(args, "tasks edit") run_tasks_edit(args) diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 747231a..37b8401 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -12,24 +12,19 @@ import typer from org_parser.document import Heading -from org import config as config_module -from org.cli_common import load_root_data +import org.config.app +import org.logging from org.commands.tasks.common import normalize_selector from org.commands.tasks.query import get_query_formatter -from org.output_format import ( +from org.pipeline.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, print_prepared_output, ) -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) -from org.tui import build_console, processing_status, setup_output +from org.pipeline.load import load_root_data +from org.query.runner import compile_query_or_raise, execute_query_or_raise +from org.tui.bits import build_console, processing_status, setup_output @dataclass @@ -145,18 +140,16 @@ def run_tasks_find(args: TasksFindArgs) -> None: with processing_status(console, color_enabled): query_text = _build_find_query(args) - try: - compiled_query = compile_query_text(query_text) - except QueryParseError as exc: - raise click.UsageError(str(exc)) from exc + compiled_query = compile_query_or_raise(query_text, click.UsageError) roots, todo_states, done_states = load_root_data(args) nodes = [node for root in roots for node in list(root)] - context = EvalContext({"todo_states": todo_states, "done_states": done_states}) - try: - results = compiled_query(Stream([nodes]), context) - except QueryRuntimeError as exc: - raise click.UsageError(str(exc)) from exc + results = execute_query_or_raise( + compiled_query, + [nodes], + {"todo_states": todo_states, "done_states": done_states}, + click.UsageError, + ) matched_nodes = [value for value in results if isinstance(value, Heading)] output_nodes = _nodes_with_context(matched_nodes, args.include_context) @@ -307,7 +300,7 @@ def find_command( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks find") - config_module.log_command_arguments(args, "tasks find") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks find") + org.logging.log_command_arguments(args, "tasks find") run_tasks_find(args) diff --git a/src/org/commands/tasks/list/events.py b/src/org/commands/tasks/list/actions.py similarity index 55% rename from src/org/commands/tasks/list/events.py rename to src/org/commands/tasks/list/actions.py index 36ac904..0cf0bcd 100644 --- a/src/org/commands/tasks/list/events.py +++ b/src/org/commands/tasks/list/actions.py @@ -8,50 +8,31 @@ import typer -from org.cli_common import load_and_process_data -from org.commands.archive import archive_heading_subtree_and_save -from org.commands.editor import edit_heading_subtree_in_external_editor -from org.commands.interactive_common import ( - FooterPromptState, - HeadingLocator, - InputEvent, - InteractiveEvent, - InteractiveHelpEntry, - InteractivePromptState, - TimeoutEvent, - advance_timestamp_by_repeater, - append_repeat_transition, - apply_help_modal_key, - create_interactive_prompt_state, - handle_active_prompt_event, - heading_locator, - local_now, - resolve_heading_locator, - shift_priority, -) -from org.commands.search_common import filter_nodes_by_search from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( PlanningTimestampField, - PromptActionConfig, - capture_template_prompt_config, - configured_capture_template_names, planning_field_label, - planning_prompt_config, replace_heading_tags_from_csv, replace_planning_timestamp_from_raw, - resolve_capture_template_selection, - resolve_todo_state_selection, save_document, - state_selection_prompt_config, - tags_prompt_config, todo_states_for_heading, ) +from org.logic.archive import archive_heading_subtree_and_save +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import ( + HeadingLocator, + append_repeat_transition, + heading_locator, + resolve_heading_locator, + shift_priority, +) +from org.logic.time import advance_timestamp_by_repeater, local_now +from org.pipeline.load import load_and_process_data +from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: - from collections.abc import Callable - from org_parser.document import Document, Heading from .command import ListArgs, _TasksListSessionData @@ -74,10 +55,6 @@ class TasksListSession: scroll_offset: int status_message: str search_text: str - search_prompt_previous_text: str | None = None - show_help_modal: bool = False - active_prompt: InteractivePromptState | None = None - run_external: Callable[[Callable[[], None]], None] | None = None TASKS_LIST_HELP_ENTRIES = [ @@ -427,274 +404,6 @@ def create_tasks_list_session(args: ListArgs, data: _TasksListSessionData) -> Ta scroll_offset=0, status_message="", search_text="", - show_help_modal=False, - active_prompt=None, - run_external=None, ) ensure_selection_bounds(session) return session - - -def _activate_prompt( - session: TasksListSession, - config: PromptActionConfig, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> None: - """Attach one active footer prompt to the tasks list session.""" - session.active_prompt = create_interactive_prompt_state( - config, - submit_value=submit_value, - preview_value=preview_value, - cancel=cancel, - ) - - -def _activate_capture_prompt(session: TasksListSession) -> None: - """Activate capture-template prompt when templates are configured.""" - template_names = configured_capture_template_names() - if not template_names: - session.status_message = "No capture templates configured" - return - - config = capture_template_prompt_config() - - def _submit(value: str) -> bool: - value = value.strip() - template_name = resolve_capture_template_selection(value, template_names) - if template_name is None and not value: - session.status_message = config.cancel_status - return False - if template_name is None: - session.status_message = config.invalid_status - return True - _run_external(session, lambda: apply_capture_task(session, template_name)) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_search_prompt(session: TasksListSession) -> None: - """Activate search prompt and preserve current search text for cancellation.""" - session.search_prompt_previous_text = session.search_text - config = PromptActionConfig( - prompt=FooterPromptState(label="Search text (blank clears)"), - cancel_status="Search cancelled", - invalid_status="Invalid search input", - ) - - def _submit(value: str) -> bool: - session.search_prompt_previous_text = None - apply_search_text(session, value.strip()) - return False - - def _preview(value: str) -> None: - apply_search_text(session, value.strip()) - - def _cancel() -> None: - previous_text = session.search_prompt_previous_text or "" - session.search_prompt_previous_text = None - apply_search_text(session, previous_text) - session.status_message = config.cancel_status - - _activate_prompt( - session, - config, - submit_value=_submit, - preview_value=_preview, - cancel=_cancel, - ) - - -def _activate_tags_prompt(session: TasksListSession) -> None: - """Activate tags editing prompt for the selected task.""" - if selected_node(session) is None: - session.status_message = "Action available only on task rows" - return - - config = tags_prompt_config() - - def _submit(value: str) -> bool: - apply_tags_edit(session, value) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_planning_prompt(session: TasksListSession, field: PlanningTimestampField) -> None: - """Activate one planning timestamp prompt for the selected task.""" - if selected_node(session) is None: - session.status_message = "Action available only on task rows" - return - - config = planning_prompt_config(field) - - def _submit(value: str) -> bool: - apply_planning_timestamp_edit(session, field=field, raw_timestamp=value) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_state_selection_prompt(session: TasksListSession, states: list[str]) -> None: - """Activate TODO-state selection prompt for the selected task.""" - config = state_selection_prompt_config(states) - - def _submit(value: str) -> bool: - value = value.strip() - selected_state = resolve_todo_state_selection(value, states) - if selected_state is None and not value: - session.status_message = config.cancel_status - return False - if selected_state is None: - session.status_message = config.invalid_status - return True - apply_state_change_with_value(session, selected_state) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _handle_capture_prompt_activation(session: TasksListSession) -> None: - _activate_capture_prompt(session) - - -def _handle_search_prompt_activation(session: TasksListSession) -> None: - _activate_search_prompt(session) - - -def _handle_state_prompt_activation(session: TasksListSession) -> None: - status_message = can_activate_state_prompt(session) - if status_message is not None: - session.status_message = status_message - return - _activate_state_selection_prompt(session, state_choices_for_selected_node(session)) - - -def _handle_tags_prompt_activation(session: TasksListSession) -> None: - _activate_tags_prompt(session) - - -def _handle_scheduled_prompt_activation(session: TasksListSession) -> None: - _activate_planning_prompt(session, "scheduled") - - -def _handle_deadline_prompt_activation(session: TasksListSession) -> None: - _activate_planning_prompt(session, "deadline") - - -def _handle_closed_prompt_activation(session: TasksListSession) -> None: - _activate_planning_prompt(session, "closed") - - -def _handle_help_modal_event(session: TasksListSession, event: InteractiveEvent) -> bool: - if not session.show_help_modal: - return False - if not isinstance(event, TimeoutEvent): - session.show_help_modal = False - return True - - -def _handle_navigation_key(session: TasksListSession, key: str) -> bool: - handler = { - "n": lambda: move_selection(session, 1), - "DOWN": lambda: move_selection(session, 1), - "WHEEL-DOWN": lambda: move_selection(session, 1), - "p": lambda: move_selection(session, -1), - "UP": lambda: move_selection(session, -1), - "WHEEL-UP": lambda: move_selection(session, -1), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_mutation_key( - session: TasksListSession, - key: str, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "ENTER": lambda: run_external(lambda: edit_selected_task_in_external_editor(session)), - "$": lambda: archive_selected_task(session), - "x": lambda: clear_search(session), - "S-UP": lambda: apply_priority_shift(session, increase=True), - "S-DOWN": lambda: apply_priority_shift(session, increase=False), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_prompt_activation_key( - session: TasksListSession, - key: str, - _run_external_callback: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "a": _handle_capture_prompt_activation, - "/": _handle_search_prompt_activation, - "t": _handle_state_prompt_activation, - "g": _handle_tags_prompt_activation, - "s": _handle_scheduled_prompt_activation, - "d": _handle_deadline_prompt_activation, - "c": _handle_closed_prompt_activation, - }.get(key) - if handler is None: - return False - handler(session) - return True - - -def _handle_keypress_event( - session: TasksListSession, - key: str, - run_external: Callable[[Callable[[], None]], None] | None = None, -) -> bool: - effective_run_external = passthrough_run_external if run_external is None else run_external - consumed, next_help_modal = apply_help_modal_key(key, show_help_modal=session.show_help_modal) - session.show_help_modal = next_help_modal - if consumed: - return True - if key in {"q", "ESC"}: - return False - if _handle_navigation_key(session, key): - return True - if _handle_mutation_key(session, key, effective_run_external): - return True - if _handle_prompt_activation_key(session, key, effective_run_external): - return True - if key and key != "IGNORE": - session.status_message = f"Unsupported key: {key}" - return True - - -def passthrough_run_external(callback: Callable[[], None]) -> None: - """Run an external callback immediately.""" - callback() - - -def _run_external(session: TasksListSession, callback: Callable[[], None]) -> None: - runner = session.run_external or passthrough_run_external - runner(callback) - - -def handle_interactive_event( - session: TasksListSession, - event: InteractiveEvent, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - """Handle one tasks-list interactive event.""" - if _handle_help_modal_event(session, event): - return True - if session.active_prompt is not None: - return handle_active_prompt_event(session, event) - if isinstance(event, TimeoutEvent): - return True - if isinstance(event, InputEvent): - return True - return _handle_keypress_event(session, event.key, run_external) diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py new file mode 100644 index 0000000..034e968 --- /dev/null +++ b/src/org/commands/tasks/list/app.py @@ -0,0 +1,377 @@ +"""Textual app for the interactive tasks list command.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.console import Group, RenderableType +from rich.rule import Rule +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.widgets import Static + +import org.tui.app +import org.tui.footer +import org.tui.help +import org.tui.prompt +import org.tui.selection +from org.commands.tasks.common import ( + PlanningTimestampField, + configured_capture_template_names, + planning_prompt_label, + tags_prompt_label, +) +from org.tui.bits import TaskLineConfig, format_task_line + +from . import actions +from .actions import TASKS_LIST_HELP_ENTRIES, create_tasks_list_session + + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import ClassVar + + from org_parser.document import Heading + from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollUp, Resize + + from .command import ListArgs, _TasksListSessionData + + +_HIGHLIGHT_ROW_STYLE = "on grey23" +_HELP_FOOTER_TEXT = "Type ? for help" + + +class TasksListApp(org.tui.app.CommandApp): + """Textual app that backs interactive `tasks list`.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", show=False), + Binding("down", "move_down", show=False), + Binding("n", "move_down", show=False), + Binding("p", "move_up", show=False), + Binding("shift+up", "increase_priority", show=False), + Binding("shift+down", "decrease_priority", show=False), + Binding("enter", "edit_selected", show=False), + Binding("slash", "prompt_search", show=False), + Binding("x", "clear_search", show=False), + Binding("a", "prompt_capture", show=False), + Binding("dollar_sign", "archive_selected", show=False), + Binding("t", "prompt_state", show=False), + Binding("g", "prompt_tags", show=False), + Binding("s", "prompt_scheduled", show=False), + Binding("d", "prompt_deadline", show=False), + Binding("c", "prompt_closed", show=False), + Binding("question_mark", "show_help", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def __init__(self, args: ListArgs, data: _TasksListSessionData) -> None: + """Build one tasks list app from CLI args and loaded session data.""" + super().__init__() + self.session = create_tasks_list_session(args, data) + + def compose(self) -> ComposeResult: + """Build the main app layout.""" + yield Vertical( + Static(id="tasks-body"), + Static(id="tasks-footer-rule"), + Static(id="tasks-footer"), + Static(id="tasks-status"), + ) + + def on_mount(self) -> None: + """Render initial state after mount.""" + self._refresh_view() + + def on_resize(self, _event: Resize) -> None: + """Refresh viewport layout after terminal resize.""" + self._refresh_view() + + def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: + """Match legacy wheel-up navigation behavior.""" + actions.move_selection(self.session, -1) + self._refresh_view() + + def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: + """Match legacy wheel-down navigation behavior.""" + actions.move_selection(self.session, 1) + self._refresh_view() + + def _body_widget(self) -> Static: + return self.query_one("#tasks-body", Static) + + def _footer_widget(self) -> Static: + return self.query_one("#tasks-footer", Static) + + def _footer_rule_widget(self) -> Static: + return self.query_one("#tasks-footer-rule", Static) + + def _status_widget(self) -> Static: + return self.query_one("#tasks-status", Static) + + def _viewport_height(self) -> int: + body_height = self._body_widget().size.height + if body_height <= 0: + return max(5, self.size.height - 2) + return max(5, body_height) + + def _sync_scroll(self, viewport_height: int) -> None: + max_offset = max(0, len(self.session.visible_nodes) - viewport_height) + self.session.scroll_offset = min(max(self.session.scroll_offset, 0), max_offset) + if not self.session.visible_nodes: + return + if self.session.selected_index < self.session.scroll_offset: + self.session.scroll_offset = self.session.selected_index + elif self.session.selected_index >= self.session.scroll_offset + viewport_height: + self.session.scroll_offset = self.session.selected_index - viewport_height + 1 + self.session.scroll_offset = min(max(self.session.scroll_offset, 0), max_offset) + + def _row_text(self, node: Heading, *, line_width: int) -> Text: + line = format_task_line( + node, + TaskLineConfig( + color_enabled=self.session.color_enabled, + done_states=self.session.done_states, + todo_states=self.session.todo_states, + line_width=line_width, + ), + ) + if self.session.color_enabled: + return Text.from_markup(line) + return Text(line) + + def _body_renderable(self) -> Group: + viewport_height = self._viewport_height() + actions.ensure_selection_bounds(self.session) + self._sync_scroll(viewport_height) + window = self.session.visible_nodes[ + self.session.scroll_offset : self.session.scroll_offset + viewport_height + ] + rows: list[Text] = [] + line_width = max(20, self.size.width - 1) + for index, node in enumerate(window, start=self.session.scroll_offset): + row = self._row_text(node, line_width=line_width) + if index == self.session.selected_index: + row.stylize(_HIGHLIGHT_ROW_STYLE) + rows.append(row) + rows.extend(Text("") for _ in range(viewport_height - len(window))) + return Group(*rows) + + def _footer_renderable(self) -> RenderableType: + selected_row = self.session.selected_index + 1 if self.session.visible_nodes else 0 + total_rows = len(self.session.visible_nodes) + search_text = self.session.search_text or "-" + footer_style = "dim" if self.session.color_enabled else "" + return org.tui.footer.footer_renderable( + f"Rows {selected_row}/{total_rows} | Search: {search_text}", + _HELP_FOOTER_TEXT, + style=footer_style, + ) + + def _status_renderable(self) -> Text: + footer_style = "dim" if self.session.color_enabled else "" + return Text( + self.session.status_message or "", + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ) + + def _refresh_view(self) -> None: + footer_style = "dim" if self.session.color_enabled else "" + self._body_widget().update(self._body_renderable()) + self._footer_rule_widget().update(Rule(style=footer_style)) + self._footer_widget().update(self._footer_renderable()) + self._status_widget().update(self._status_renderable()) + + def _open_prompt( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen( + org.tui.prompt.PromptModalScreen( + label, + initial_value=initial_value, + on_change=on_change, + ), + callback=_complete, + ) + + def _run_external(self, callback: Callable[[], None]) -> None: + self.run_external_and_refresh(callback, refresh=self._refresh_view) + + def _open_selection( + self, + label: str, + options: list[org.tui.selection.SelectionOption], + *, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen(org.tui.selection.SelectionModalScreen(label, options), callback=_complete) + + def action_move_up(self) -> None: + """Move the selection one row upward.""" + actions.move_selection(self.session, -1) + self._refresh_view() + + def action_move_down(self) -> None: + """Move the selection one row downward.""" + actions.move_selection(self.session, 1) + self._refresh_view() + + def action_increase_priority(self) -> None: + """Raise the selected task priority one step.""" + actions.apply_priority_shift(self.session, increase=True) + self._refresh_view() + + def action_decrease_priority(self) -> None: + """Lower the selected task priority one step.""" + actions.apply_priority_shift(self.session, increase=False) + self._refresh_view() + + def action_clear_search(self) -> None: + """Clear the active search filter.""" + actions.clear_search(self.session) + self._refresh_view() + + def action_edit_selected(self) -> None: + """Open the selected task in the external editor.""" + self._run_external(lambda: actions.edit_selected_task_in_external_editor(self.session)) + + def action_archive_selected(self) -> None: + """Archive the selected task subtree.""" + actions.archive_selected_task(self.session) + self._refresh_view() + + def action_quit_app(self) -> None: + """Exit the interactive tasks list app.""" + self.exit() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen( + org.tui.help.HelpModalScreen( + TASKS_LIST_HELP_ENTRIES, + color_enabled=self.session.color_enabled, + ), + ) + + def action_prompt_search(self) -> None: + """Open the search prompt with live filtering preview.""" + previous_text = self.session.search_text + + def _preview(value: str) -> None: + actions.apply_search_text(self.session, value.strip()) + self._refresh_view() + + self._open_prompt( + "Search text (blank clears)", + initial_value=self.session.search_text, + on_change=_preview, + on_submit=lambda value: actions.apply_search_text(self.session, value.strip()), + on_cancel=lambda: self._cancel_search(previous_text), + ) + + def _cancel_search(self, previous_text: str) -> None: + actions.apply_search_text(self.session, previous_text) + self.session.status_message = "Search cancelled" + + def action_prompt_capture(self) -> None: + """Prompt for a capture template and create a task.""" + template_names = configured_capture_template_names() + if not template_names: + self.session.status_message = "No capture templates configured" + self._refresh_view() + return + + self._open_selection( + "Capture template", + [org.tui.selection.SelectionOption(value=name, label=name) for name in template_names], + on_submit=lambda template_name: self._run_external( + lambda: actions.apply_capture_task(self.session, template_name), + ), + on_cancel=lambda: self._set_status("Capture cancelled"), + ) + + def action_prompt_state(self) -> None: + """Prompt for a TODO state transition.""" + status_message = actions.can_activate_state_prompt(self.session) + if status_message is not None: + self.session.status_message = status_message + self._refresh_view() + return + states = actions.state_choices_for_selected_node(self.session) + + self._open_selection( + "TODO state", + [org.tui.selection.SelectionOption(value=state, label=state) for state in states], + on_submit=lambda selected_state: actions.apply_state_change_with_value( + self.session, + selected_state, + ), + on_cancel=lambda: self._set_status("State change cancelled"), + ) + + def action_prompt_tags(self) -> None: + """Prompt for replacement tag CSV on the selected task.""" + self._open_prompt( + tags_prompt_label(), + on_submit=lambda value: actions.apply_tags_edit(self.session, value), + on_cancel=lambda: self._set_status("Tags edit cancelled"), + ) + + def _set_status(self, status_message: str) -> None: + self.session.status_message = status_message + + def _prompt_planning(self, field: PlanningTimestampField) -> None: + self._open_prompt( + planning_prompt_label(field), + on_submit=lambda value: actions.apply_planning_timestamp_edit( + self.session, + field=field, + raw_timestamp=value, + ), + on_cancel=lambda: self._set_status("Planning timestamp edit cancelled"), + ) + + def action_prompt_scheduled(self) -> None: + """Prompt for a scheduled timestamp value.""" + self._prompt_planning("scheduled") + + def action_prompt_deadline(self) -> None: + """Prompt for a deadline timestamp value.""" + self._prompt_planning("deadline") + + def action_prompt_closed(self) -> None: + """Prompt for a closed timestamp value.""" + self._prompt_planning("closed") + + +def run_tasks_list_app(args: ListArgs, data: _TasksListSessionData) -> None: + """Run the Textual-backed interactive tasks list app.""" + TasksListApp(args, data).run() diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 2d93341..0d124a9 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -11,11 +11,9 @@ import typer from rich.syntax import Syntax -from org import config as config_module -from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text, interactive_loop -from org.commands.search_common import filter_nodes_by_search -from org.output_format import ( +import org.config.app +import org.logging +from org.pipeline.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -29,7 +27,8 @@ _prepare_output, print_prepared_output, ) -from org.tui import ( +from org.pipeline.load import load_and_process_data +from org.tui.bits import ( TaskLineConfig, build_console, format_task_line, @@ -37,13 +36,13 @@ processing_status, setup_output, ) +from org.tui.help import interactive_help_command_text -from . import events, layout +from . import actions +from .app import run_tasks_list_app if TYPE_CHECKING: - from collections.abc import Callable - from org_parser.document import Heading from rich.console import Console @@ -117,8 +116,6 @@ class _TasksListSessionData: class TasksListOutputFormatter(Protocol): """Formatter interface for the tasks list command.""" - include_filenames: bool - def prepare(self, data: TasksListRenderInput) -> PreparedOutput: """Prepare tasks list output for rendering.""" ... @@ -169,8 +166,6 @@ def _prepare_detailed_task_list(nodes: list[Heading], out_theme: str) -> Prepare class OrgTasksListOutputFormatter: """Org output formatter for tasks list command.""" - include_filenames = True - def prepare(self, data: TasksListRenderInput) -> PreparedOutput: """Prepare tasks list output in org or short-list form.""" if not data.nodes: @@ -204,8 +199,6 @@ def prepare(self, data: TasksListRenderInput) -> PreparedOutput: class PandocTasksListOutputFormatter: """Pandoc-based output formatter for tasks list command.""" - include_filenames = False - def __init__(self, output_format: str, pandoc_args: str | None) -> None: """Initialize formatter options for pandoc-based task rendering.""" self.output_format = output_format @@ -235,8 +228,6 @@ def prepare(self, data: TasksListRenderInput) -> PreparedOutput: class JsonTasksListOutputFormatter: """JSON output formatter for tasks list command.""" - include_filenames = False - def prepare(self, data: TasksListRenderInput) -> PreparedOutput: """Prepare tasks list output as JSON.""" payload = _json_output_payload(list(data.nodes)) @@ -354,43 +345,16 @@ def _run_tasks_list_static( print_prepared_output(console, prepared_output) -def _filter_nodes_by_search(nodes: list[Heading], search_text: str) -> list[Heading]: - """Filter nodes by case-insensitive substring match over one node's own text.""" - return filter_nodes_by_search(nodes, search_text) - - def _run_tasks_list_interactive( - console: Console, args: ListArgs, data: _TasksListSessionData, ) -> None: - """Run interactive tasks-list UI session.""" + """Run interactive tasks-list UI session via Textual.""" if not data.nodes: + console = build_console(data.color_enabled, args.width) console.print("No results", markup=False) return - - session = events.create_tasks_list_session(args, data) - - run_external: list[Callable[[Callable[[], None]], None]] = [events.passthrough_run_external] - - def _bind_run_external(callback: Callable[[Callable[[], None]], None]) -> None: - run_external[0] = callback - session.run_external = callback - - session.run_external = run_external[0] - - interactive_loop( - console=console, - render=lambda: layout.interactive_tasks_list_renderable(console, session), - on_event=lambda event: events.handle_interactive_event(session, event, run_external[0]), - bind_run_external=_bind_run_external, - timeout_seconds=None, - ) - - -def _is_cli_option_present(argv: list[str], option: str) -> bool: - """Return True when a long option token is present in argv.""" - return any(token == option or token.startswith(f"{option}=") for token in argv) + run_tasks_list_app(args, data) def _is_interactive_tty() -> bool: @@ -398,12 +362,6 @@ def _is_interactive_tty() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() -def _effective_noninteractive_mode(args: ListArgs) -> bool: - """Resolve whether tasks list should run in non-interactive mode.""" - out_is_org = args.out.strip().lower() == OutputFormat.ORG - return args.noninteractive or args.details or not out_is_org or not _is_interactive_tty() - - def run_tasks_list(args: ListArgs) -> None: """Run the tasks list command.""" color_enabled = setup_output(args) @@ -423,11 +381,16 @@ def run_tasks_list(args: ListArgs) -> None: color_enabled=color_enabled, ) - if not _effective_noninteractive_mode(args): - _run_tasks_list_interactive(console, args, session_data) + if args.noninteractive: + _run_tasks_list_static(console, args, session_data) return - _run_tasks_list_static(console, args, session_data) + if not _is_interactive_tty(): + raise click.UsageError( + "org tasks list requires a TTY unless --details or --out is provided", + ) + + _run_tasks_list_interactive(args, session_data) def register(app: typer.Typer) -> None: @@ -438,7 +401,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, help=interactive_help_command_text( "List tasks matching filters.", - events.TASKS_LIST_HELP_ENTRIES, + actions.TASKS_LIST_HELP_ENTRIES, ), ) def tasks_list( # noqa: PLR0913 @@ -583,8 +546,8 @@ def tasks_list( # noqa: PLR0913 metavar="N", help="Number of results to skip before displaying", ), - details: bool = typer.Option( - False, + details: bool | None = typer.Option( + None, "--details", help="Show full org node details", ), @@ -623,8 +586,8 @@ def tasks_list( # noqa: PLR0913 "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), - out: str = typer.Option( - OutputFormat.ORG, + out: str | None = typer.Option( + None, "--out", help="Output format: org, json, or any pandoc writer format", ), @@ -641,6 +604,8 @@ def tasks_list( # noqa: PLR0913 ), ) -> None: """List tasks matching filters.""" + details_switch_present = details is not None + out_switch_present = out is not None args = ListArgs( files=files, config=config, @@ -665,7 +630,7 @@ def tasks_list( # noqa: PLR0913 color_flag=color_flag, width=width, max_results=max_results, - details=details, + details=False if details is None else details, offset=offset, order_by_level=order_by_level, order_by_file_order=order_by_file_order, @@ -674,16 +639,12 @@ def tasks_list( # noqa: PLR0913 order_by_timestamp_asc=order_by_timestamp_asc, order_by_timestamp_desc=order_by_timestamp_desc, with_tags_as_category=with_tags_as_category, - out=out, + out=OutputFormat.ORG if out is None else out, out_theme=out_theme, pandoc_args=pandoc_args, ) - config_module.apply_config_defaults(args) - details_switch_present = _is_cli_option_present(sys.argv[1:], "--details") - out_switch_present = _is_cli_option_present(sys.argv[1:], "--out") - args.noninteractive = ( - details_switch_present or out_switch_present or not _is_interactive_tty() - ) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks list") - config_module.log_command_arguments(args, "tasks list") + org.config.app.apply_config_defaults(args) + args.noninteractive = details_switch_present or out_switch_present + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks list") + org.logging.log_command_arguments(args, "tasks list") run_tasks_list(args) diff --git a/src/org/commands/tasks/list/layout.py b/src/org/commands/tasks/list/layout.py deleted file mode 100644 index a543417..0000000 --- a/src/org/commands/tasks/list/layout.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tasks list interactive and static layout helpers.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from rich.console import Console, Group -from rich.rule import Rule -from rich.table import Table -from rich.text import Text - -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - build_footer_prompt_text, - render_interactive_help_modal, -) -from org.tui import TaskLineConfig, format_task_line - -from .events import TASKS_LIST_HELP_ENTRIES, TasksListSession, ensure_selection_bounds - - -if TYPE_CHECKING: - from org_parser.document import Heading - - -_HIGHLIGHT_ROW_STYLE = "on grey23" - - -def build_task_row_text( - node: Heading, - session: TasksListSession, - *, - line_width: int, -) -> Text: - """Build one interactive row using the static task line format.""" - line = format_task_line( - node, - TaskLineConfig( - color_enabled=session.color_enabled, - done_states=session.done_states, - todo_states=session.todo_states, - line_width=line_width, - ), - ) - if session.color_enabled: - return Text.from_markup(line) - return Text(line) - - -def _sync_scroll(session: TasksListSession, viewport_height: int) -> None: - """Keep selected row inside the current viewport window.""" - max_offset = max(0, len(session.visible_nodes) - viewport_height) - session.scroll_offset = min(max(session.scroll_offset, 0), max_offset) - - if not session.visible_nodes: - return - - if session.selected_index < session.scroll_offset: - session.scroll_offset = session.selected_index - elif session.selected_index >= session.scroll_offset + viewport_height: - session.scroll_offset = session.selected_index - viewport_height + 1 - - session.scroll_offset = min(max(session.scroll_offset, 0), max_offset) - - -def interactive_tasks_list_renderable(console: Console, session: TasksListSession) -> Group: - """Build scrollable interactive tasks list renderable.""" - if session.show_help_modal: - return Group( - render_interactive_help_modal( - TASKS_LIST_HELP_ENTRIES, - color_enabled=session.color_enabled, - ), - ) - - viewport_height = max(5, console.size.height - 3) - ensure_selection_bounds(session) - _sync_scroll(session, viewport_height) - - window = session.visible_nodes[session.scroll_offset : session.scroll_offset + viewport_height] - viewport_table = Table.grid(expand=True) - viewport_table.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - for index, node in enumerate(window, start=session.scroll_offset): - row_style = _HIGHLIGHT_ROW_STYLE if index == session.selected_index else "" - viewport_table.add_row( - build_task_row_text(node, session, line_width=console.size.width), - style=row_style, - ) - - for _ in range(viewport_height - len(window)): - viewport_table.add_row(Text("")) - - selected_row = session.selected_index + 1 if session.visible_nodes else 0 - total_rows = len(session.visible_nodes) - search_text = session.search_text or "-" - row_text = f"Rows {selected_row}/{total_rows} | Search: {search_text}" - prompt_line = None - active_prompt = session.active_prompt - if active_prompt is not None: - prompt_line = build_footer_prompt_text(active_prompt.prompt) - status = session.status_message or "" - footer_style = "dim" if session.color_enabled else "" - - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(row_text, style=footer_style, no_wrap=True, overflow="ellipsis"), - Text( - INTERACTIVE_HELP_FOOTER_HINT, - style=footer_style, - no_wrap=True, - overflow="ellipsis", - ), - ) - - status_text = Text(status, style=footer_style, no_wrap=True, overflow="ellipsis") - if prompt_line is None: - return Group(viewport_table, Rule(style=footer_style), footer_line, status_text) - return Group(viewport_table, Rule(style=footer_style), footer_line, prompt_line, status_text) diff --git a/src/org/commands/tasks/list/styles/app.tcss b/src/org/commands/tasks/list/styles/app.tcss new file mode 100644 index 0000000..f3a371a --- /dev/null +++ b/src/org/commands/tasks/list/styles/app.tcss @@ -0,0 +1,13 @@ +Screen { + layout: vertical; +} + +#tasks-body { + height: 1fr; +} + +#tasks-footer, +#tasks-footer-rule, +#tasks-status { + height: auto; +} diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index 960ffef..c702b5d 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -16,9 +16,9 @@ from org_parser.time import Timestamp from rich.syntax import Syntax -from org import config as config_module -from org.cli_common import load_root_data -from org.output_format import ( +import org.config.app +import org.logging +from org.pipeline.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -32,14 +32,9 @@ _prepare_output, print_prepared_output, ) -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) -from org.tui import build_console, processing_status, setup_output +from org.pipeline.load import load_root_data +from org.query.runner import compile_query_or_raise, execute_query_or_raise +from org.tui.bits import build_console, processing_status, setup_output if TYPE_CHECKING: @@ -49,8 +44,6 @@ class QueryOutputFormatter(Protocol): """Formatter interface for the query command.""" - include_filenames: bool - def prepare( self, values: list[object], @@ -83,8 +76,6 @@ def _format_org_block(value: object) -> str: class OrgQueryOutputFormatter: """Org output formatter for query command.""" - include_filenames = True - def prepare( self, values: list[object], @@ -145,8 +136,6 @@ def _prepare_org_values(self, values: list[object], out_theme: str) -> PreparedO class PandocQueryOutputFormatter: """Pandoc-based output formatter for query command.""" - include_filenames = False - def __init__(self, output_format: str, pandoc_args: str | None) -> None: """Initialize formatter options for pandoc-based rendering.""" self.output_format = output_format @@ -178,8 +167,6 @@ def prepare( class JsonQueryOutputFormatter: """JSON output formatter for query command.""" - include_filenames = False - def prepare( self, values: list[object], @@ -247,26 +234,17 @@ def run_tasks_query(args: TasksQueryArgs) -> None: raise click.UsageError(str(exc)) from exc with processing_status(console, color_enabled): - try: - compiled_query = compile_query_text(args.query) - except QueryParseError as exc: - raise click.UsageError(str(exc)) from exc + compiled_query = compile_query_or_raise(args.query, click.UsageError) roots, todo_states, done_states = load_root_data(args) - context = EvalContext( - { - "offset": args.offset, - "limit": args.max_results, - "todo_states": todo_states, - "done_states": done_states, - }, - ) - try: - stream_nodes = Stream([roots]) - results = compiled_query(stream_nodes, context) - except QueryRuntimeError as exc: - raise click.UsageError(str(exc)) from exc + context = { + "offset": args.offset, + "limit": args.max_results, + "todo_states": todo_states, + "done_states": done_states, + } + results = execute_query_or_raise(compiled_query, [roots], context, click.UsageError) first_result = results[0] if results else None if len(results) == 1 and isinstance(first_result, list | tuple | set): @@ -389,7 +367,7 @@ def query_command( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks query") - config_module.log_command_arguments(args, "tasks query") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks query") + org.logging.log_command_arguments(args, "tasks query") run_tasks_query(args) diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index 5725e3f..85f5288 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -11,14 +11,15 @@ from rich.console import Console from rich.prompt import Confirm -from org import config as config_module -from org.cli_common import resolve_input_paths -from org.color import should_use_color +import org.config.app +import org.logging from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, save_document, ) +from org.pipeline.load import resolve_input_paths +from org.tui.color import should_use_color logger = logging.getLogger("org") @@ -162,7 +163,7 @@ def tasks_remove( # noqa: PLR0913 yes=yes, color_flag=color_flag, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks remove") - config_module.log_command_arguments(args, "tasks remove") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks remove") + org.logging.log_command_arguments(args, "tasks remove") run_tasks_remove(args) diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 75fe751..3970709 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -14,9 +14,8 @@ from rich.console import Console from rich.prompt import Confirm -from org import config as config_module -from org.cli_common import resolve_input_paths -from org.color import should_use_color +import org.config.app +import org.logging from org.commands.tasks.common import ( iter_descendants, load_document, @@ -32,6 +31,8 @@ resolve_task_selector_query, save_document, ) +from org.pipeline.load import resolve_input_paths +from org.tui.color import should_use_color if TYPE_CHECKING: @@ -685,7 +686,7 @@ def tasks_update( # noqa: PLR0913 yes=yes, color_flag=color_flag, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks update") - config_module.log_command_arguments(args, "tasks update") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks update") + org.logging.log_command_arguments(args, "tasks update") run_tasks_update(args) diff --git a/src/org/config/__init__.py b/src/org/config/__init__.py new file mode 100644 index 0000000..aee9045 --- /dev/null +++ b/src/org/config/__init__.py @@ -0,0 +1 @@ +"""Configuration package for the org CLI.""" diff --git a/src/org/config.py b/src/org/config/app.py similarity index 93% rename from src/org/config.py rename to src/org/config/app.py index 9a05f7d..796dcf6 100644 --- a/src/org/config.py +++ b/src/org/config/app.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import logging import re from dataclasses import dataclass from datetime import datetime @@ -75,6 +74,91 @@ CONFIG_BOARD_VIEWS: dict[str, BoardViewConfig] = {} CONFIG_AGENDA_VIEWS: dict[str, AgendaViewConfig] = {} +MAP: dict[str, str] = {} + +DEFAULT_VERBOSE: dict[str, bool] = {"value": False} + +TAGS: set[str] = set() + +HEADING = { + "the", + "to", + "a", + "for", + "in", + "of", + "and", + "on", + "with", + "some", + "out", + "&", + "up", + "from", + "an", + "into", + "new", + "why", + "do", + "ways", + "say", + "it", + "this", + "is", + "no", + "not", + "that", + "all", + "but", + "be", + "use", + "now", + "will", + "i", + "as", + "or", + "by", + "did", + "can", + "are", + "was", + "more", + "until", + "using", + "when", + "only", + "at", + "it's", + "have", + "about", + "just", + "get", + "didn't", + "can't", + "my", + "does", + "etc", + "there", + "yet", + "nope", + "should", + "i'll", + "nah", +} + +DEFAULT_EXCLUDE = TAGS.union(HEADING).union( + { + "end", + "logbook", + "cancelled", + "scheduled", + "suspended", + "", + }, +) + +CATEGORY_NAMES = {"tags": "tags", "heading": "heading words", "body": "body words"} + DEST_TO_OPTION_NAME: dict[str, str] = { "date": "--date", @@ -129,14 +213,18 @@ } -logger = logging.getLogger("org") - - def normalize_exclude_values(values: list[str]) -> set[str]: """Normalize exclude values to match file-based behavior.""" return {line.strip() for line in values if line.strip()} +def resolve_verbose(verbose: bool | None) -> bool: + """Resolve effective verbose setting from CLI flag and config defaults.""" + if verbose is None: + return DEFAULT_VERBOSE["value"] + return verbose + + def load_exclude_list(filepath: str | None) -> set[str]: """Load exclude list from a file (one word per line). @@ -199,6 +287,24 @@ def load_mapping(filepath: str | None) -> dict[str, str]: return mapping +def resolve_mapping(args: object) -> dict[str, str]: + """Resolve mapping based on inline or file-based configuration.""" + mapping_inline = getattr(args, "mapping_inline", None) + if mapping_inline is not None: + return mapping_inline or MAP + mapping_file = getattr(args, "mapping", None) + return load_mapping(mapping_file) or MAP + + +def resolve_exclude_set(args: object) -> set[str]: + """Resolve exclude set based on inline or file-based configuration.""" + exclude_inline = getattr(args, "exclude_inline", None) + if exclude_inline is not None: + return normalize_exclude_values(exclude_inline) or DEFAULT_EXCLUDE + exclude_file = getattr(args, "exclude", None) + return load_exclude_list(exclude_file) or DEFAULT_EXCLUDE + + @dataclass class ConfigOptions: """Config option mapping metadata.""" @@ -1147,82 +1253,6 @@ def build_default_map(defaults: dict[str, object]) -> CliDefaultMap: } -def _format_default_log_entry(option_name: str, value: object) -> str: - """Format one option/value pair for config-default logging.""" - return f"{option_name}={value!r}" - - -def _format_argument_log_entry(arg_name: str, value: object) -> str: - """Format one argument/value pair for command argument logging.""" - return f"{arg_name}={value!r}" - - -def _redact_inline_config_value(option_name: str, value: object) -> object: - """Redact inline mapping/exclude values in default logs.""" - if option_name in {"--mapping", "--exclude"} and isinstance(value, (dict, list)): - return "" - return value - - -def log_applied_config_defaults(_args: object, _argv: list[str], command_name: str) -> None: - """Log config defaults loaded from config file.""" - if not logger.isEnabledFor(logging.INFO): - return - - entries: list[str] = [] - - for dest, default_value in sorted(CONFIG_DEFAULTS.items(), key=lambda item: item[0]): - option_name = DEST_TO_OPTION_NAME.get(dest) - if option_name is None: - continue - entries.append( - _format_default_log_entry( - option_name, - _redact_inline_config_value(option_name, default_value), - ), - ) - - for dest, values in sorted(CONFIG_APPEND_DEFAULTS.items(), key=lambda item: item[0]): - option_name = DEST_TO_OPTION_NAME.get(dest) - if option_name is None: - continue - entries.append( - _format_default_log_entry( - option_name, - _redact_inline_config_value(option_name, values), - ), - ) - - for dest, option_name in ( - ("mapping_inline", "--mapping"), - ("exclude_inline", "--exclude"), - ): - inline_value = CONFIG_INLINE_DEFAULTS.get(dest) - if inline_value is None: - continue - entries.append(_format_default_log_entry(option_name, "")) - - if entries: - logger.info("Config defaults applied (%s): %s", command_name, ", ".join(entries)) - - -def log_command_arguments(args: object, command_name: str) -> None: - """Log all final argument values used to run a command.""" - if not logger.isEnabledFor(logging.INFO): - return - - try: - arg_items = vars(args).items() - except TypeError: - return - - entries = [ - _format_argument_log_entry(arg_name, arg_value) - for arg_name, arg_value in sorted(arg_items, key=lambda item: item[0]) - ] - logger.info("Command arguments (%s): %s", command_name, ", ".join(entries)) - - def apply_config_defaults(args: object) -> None: """Apply config-provided defaults for append and inline values.""" for dest, values in CONFIG_APPEND_DEFAULTS.items(): diff --git a/src/org/cli_common.py b/src/org/config/cli.py similarity index 54% rename from src/org/cli_common.py rename to src/org/config/cli.py index 26201e3..400ae86 100644 --- a/src/org/cli_common.py +++ b/src/org/config/cli.py @@ -1,222 +1,18 @@ -"""Shared CLI helpers and data processing utilities.""" +"""CLI-driven query pipeline configuration and stage planning.""" from __future__ import annotations import json -import logging import re -import sys from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Protocol, cast +from typing import Protocol import click -import typer -from org_parser.document import Heading - -from org import config as config_module -from org.analyze import TimeRange, normalize -from org.parse import load_root_nodes -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) -from org.timestamp import extract_timestamp_any -from org.validation import ( - parse_date_argument, - parse_group_values, - parse_property_filter, - validate_and_parse_keys, - validate_global_arguments, -) - - -if TYPE_CHECKING: - from datetime import datetime - - from org_parser import Document - - from org.query_language.compiler import CompiledQuery - - -MAP: dict[str, str] = {} - -logger = logging.getLogger("org") - -TAGS: set[str] = set() - -HEADING = { - "the", - "to", - "a", - "for", - "in", - "of", - "and", - "on", - "with", - "some", - "out", - "&", - "up", - "from", - "an", - "into", - "new", - "why", - "do", - "ways", - "say", - "it", - "this", - "is", - "no", - "not", - "that", - "all", - "but", - "be", - "use", - "now", - "will", - "i", - "as", - "or", - "by", - "did", - "can", - "are", - "was", - "more", - "until", - "using", - "when", - "only", - "at", - "it's", - "have", - "about", - "just", - "get", - "didn't", - "can't", - "my", - "does", - "etc", - "there", - "yet", - "nope", - "should", - "i'll", - "nah", -} - - -DEFAULT_EXCLUDE = TAGS.union(HEADING).union( - { - "end", - "logbook", - "cancelled", - "scheduled", - "suspended", - "", - }, -) - - -CATEGORY_NAMES = {"tags": "tags", "heading": "heading words", "body": "body words"} - - -class FilterArgs(Protocol): - """Protocol for filter-related CLI arguments.""" - - filter_priority: str | None - filter_level: int | None - filter_repeats_above: int | None - filter_repeats_below: int | None - filter_date_from: str | None - filter_date_until: str | None - filter_properties: list[str] | None - filter_tags: list[str] | None - filter_headings: list[str] | None - filter_bodies: list[str] | None - filter_completed: bool - filter_not_completed: bool - - -class WithArgs(Protocol): - """Protocol for built-in enrichment CLI arguments.""" - - with_tags_as_category: bool - - -class QueryBuildArgs(FilterArgs, WithArgs, Protocol): - """Protocol for arguments used to build query pipelines.""" - - -def is_valid_regex(pattern: str, *, use_multiline: bool = False) -> bool: - """Check if a string is a valid regex pattern.""" - try: - if use_multiline: - re.compile(pattern, re.MULTILINE) - else: - re.compile(pattern) - except re.error: - return False - return True - - -def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: - """Extract top day and its count from TimeRange. - - Args: - time_range: TimeRange object or None - - Returns: - Tuple of (date_string, count) or None if no timeline data - """ - if not time_range or not time_range.timeline: - return None - max_count = max(time_range.timeline.values()) - top_day = min(d for d, count in time_range.timeline.items() if count == max_count) - return (top_day.isoformat(), max_count) - - -def get_most_recent_timestamp(node: Heading) -> datetime | None: - """Get the most recent timestamp from a node. - - Args: - node: Org-mode node - - Returns: - Most recent datetime or None if no timestamps found - """ - timestamps = extract_timestamp_any(node) - return max(timestamps) if timestamps else None - - -def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: - """Get top N nodes sorted by most recent timestamp. - - Args: - nodes: List of org-mode nodes - max_results: Maximum number of results to return - Returns: - List of nodes sorted by most recent timestamp (descending) - """ - nodes_with_timestamps: list[tuple[Heading, datetime]] = [] - for node in nodes: - timestamp = get_most_recent_timestamp(node) - if timestamp: - nodes_with_timestamps.append((node, timestamp)) - - sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) - - return [node for node, _ in sorted_nodes[:max_results]] +import org.config.app +from org.logic.time import parse_date_argument +from org.logic.validation import parse_property_filter +from org.query.runner import build_custom_stage FILTER_OPTIONS_WITH_VALUE = { @@ -260,6 +56,33 @@ def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: } +class FilterArgs(Protocol): + """Protocol for filter-related CLI arguments.""" + + filter_priority: str | None + filter_level: int | None + filter_repeats_above: int | None + filter_repeats_below: int | None + filter_date_from: str | None + filter_date_until: str | None + filter_properties: list[str] | None + filter_tags: list[str] | None + filter_headings: list[str] | None + filter_bodies: list[str] | None + filter_completed: bool + filter_not_completed: bool + + +class WithArgs(Protocol): + """Protocol for built-in enrichment CLI arguments.""" + + with_tags_as_category: bool + + +class QueryBuildArgs(FilterArgs, WithArgs, Protocol): + """Protocol for arguments used to build query pipelines.""" + + @dataclass(frozen=True) class CustomStageInvocation: """One custom switch occurrence parsed from CLI arguments.""" @@ -293,37 +116,32 @@ def _resolve_custom_option(option: str) -> tuple[str, bool] | None: """Resolve configured custom option to (query, requires_arg).""" filter_name = _custom_option_name(option, "filter") if filter_name is not None: - query = config_module.CONFIG_CUSTOM_FILTERS.get(filter_name) + query = org.config.app.CONFIG_CUSTOM_FILTERS.get(filter_name) if query is not None: return (query, _query_uses_arg(query)) order_name = _custom_option_name(option, "order-by") if order_name is not None: - query = config_module.CONFIG_CUSTOM_ORDER_BY.get(order_name) + query = org.config.app.CONFIG_CUSTOM_ORDER_BY.get(order_name) if query is not None: return (query, _query_uses_arg(query)) with_name = _custom_option_name(option, "with") if with_name is not None: - query = config_module.CONFIG_CUSTOM_WITH.get(with_name) + query = org.config.app.CONFIG_CUSTOM_WITH.get(with_name) if query is not None: return (query, _query_uses_arg(query)) return None -def _required_custom_arg_error(option: str) -> typer.BadParameter: +def _required_custom_arg_error(option: str) -> click.BadParameter: """Return the standard required custom argument error.""" - return typer.BadParameter(f"{option} requires exactly one argument") + return click.BadParameter(f"{option} requires exactly one argument") def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str] | None: - """Remove configured custom switch tokens from FILE argument values. - - Commands use ``ignore_unknown_options`` to allow config-defined switches. Click still - parses those unknown switch tokens as FILE values first, so this helper strips them - before input path resolution. - """ + """Remove configured custom switch tokens from FILE argument values.""" if files is None: return None @@ -396,12 +214,10 @@ def _coerce_custom_arg_value(value: str | None) -> object: return value_map[lowered] if re.fullmatch(r"-?\d+", value): - parsed_value: object = int(value) - elif re.fullmatch(r"-?\d+\.\d+", value): - parsed_value = float(value) - else: - parsed_value = value - return parsed_value + return int(value) + if re.fullmatch(r"-?\d+\.\d+", value): + return float(value) + return value def _build_custom_invocation( @@ -418,36 +234,17 @@ def _build_custom_invocation( ) -def _custom_stage(query: str, arg_value: object) -> str: - """Build one custom query stage preserving input item stream values.""" - arg_literal = _query_literal(arg_value) - return f"let {arg_literal} as $arg in ({query})" - - -def _query_literal(value: object) -> str: - """Render a Python value as a query-language literal.""" - if value is None: - return "null" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int | float): - return str(value) - if isinstance(value, str): - return json.dumps(value) - return json.dumps(value) - - def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: """Validate prefixed custom switches against configured/built-in options.""" builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( - {f"--filter-{name}" for name in config_module.CONFIG_CUSTOM_FILTERS}, + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, ) allowed_order_options = builtin_order_options.union( - {f"--order-by-{name}" for name in config_module.CONFIG_CUSTOM_ORDER_BY}, + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, ) allowed_with_options = WITH_OPTIONS_FLAGS.union( - {f"--with-{name}" for name in config_module.CONFIG_CUSTOM_WITH}, + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, ) for index, token in enumerate(argv): @@ -476,9 +273,7 @@ def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> raise _required_custom_arg_error(option) -def parse_filter_entries_from_argv( - argv: list[str], -) -> list[str | CustomStageInvocation]: +def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: """Parse built-in and custom filter switch occurrences in argv order.""" entries: list[str | CustomStageInvocation] = [] index = 0 @@ -493,19 +288,15 @@ def parse_filter_entries_from_argv( continue name = _custom_option_name(option, "filter") - if name is None or name not in config_module.CONFIG_CUSTOM_FILTERS or option in builtins: + if name is None or name not in org.config.app.CONFIG_CUSTOM_FILTERS or option in builtins: index += 1 continue - query = config_module.CONFIG_CUSTOM_FILTERS[name] + query = org.config.app.CONFIG_CUSTOM_FILTERS[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=token.split("=", 1)[1], - ), + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), ) index += 1 continue @@ -516,13 +307,7 @@ def parse_filter_entries_from_argv( option, requires_arg, ) - entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=custom_arg, - ), - ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) index = consumed_index + 1 return entries @@ -549,21 +334,17 @@ def parse_order_entries_from_argv( name = _custom_option_name(option, "order-by") if ( name is None - or name not in config_module.CONFIG_CUSTOM_ORDER_BY + or name not in org.config.app.CONFIG_CUSTOM_ORDER_BY or option in builtin_options ): index += 1 continue - query = config_module.CONFIG_CUSTOM_ORDER_BY[name] + query = org.config.app.CONFIG_CUSTOM_ORDER_BY[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=token.split("=", 1)[1], - ), + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), ) index += 1 continue @@ -574,25 +355,16 @@ def parse_order_entries_from_argv( option, requires_arg, ) - entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=custom_arg, - ), - ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) index = consumed_index + 1 return entries -def parse_with_entries_from_argv( - argv: list[str], -) -> list[str | CustomStageInvocation]: +def parse_with_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: """Parse built-in and custom enrichment switch occurrences in argv order.""" entries: list[str | CustomStageInvocation] = [] index = 0 - builtins = WITH_OPTIONS_FLAGS while index < len(argv): token = argv[index] option = _extract_option_token(token) @@ -602,19 +374,15 @@ def parse_with_entries_from_argv( continue name = _custom_option_name(option, "with") - if name is None or name not in config_module.CONFIG_CUSTOM_WITH or option in builtins: + if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH: index += 1 continue - query = config_module.CONFIG_CUSTOM_WITH[name] + query = org.config.app.CONFIG_CUSTOM_WITH[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=token.split("=", 1)[1], - ), + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), ) index += 1 continue @@ -625,13 +393,7 @@ def parse_with_entries_from_argv( option, requires_arg, ) - entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=custom_arg, - ), - ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) index = consumed_index + 1 return entries @@ -643,29 +405,20 @@ def extend_with_entries_with_defaults( ) -> list[str | CustomStageInvocation]: """Extend with-entry order to include config-provided built-in enrichments.""" expected_counts = {"--with-tags-as-category": 1 if args.with_tags_as_category else 0} - full_entries = list(with_entries) for option_name, expected in expected_counts.items(): existing = sum(1 for entry in full_entries if entry == option_name) missing = expected - existing if missing > 0: full_entries.extend([option_name] * missing) - return full_entries -def count_filter_values(value: list[str] | None) -> int: - """Count filter values for append-style filters.""" - return len(value) if value else 0 - - def extend_filter_order_with_defaults( filter_order: list[str | CustomStageInvocation], args: FilterArgs, ) -> list[str | CustomStageInvocation]: """Extend filter order to include config-provided filters.""" - filter_headings = getattr(args, "filter_headings", None) - filter_bodies = getattr(args, "filter_bodies", None) expected_counts = { "--filter-priority": 1 if args.filter_priority is not None else 0, "--filter-level": 1 if args.filter_level is not None else 0, @@ -673,10 +426,10 @@ def extend_filter_order_with_defaults( "--filter-repeats-below": 1 if args.filter_repeats_below is not None else 0, "--filter-date-from": 1 if args.filter_date_from is not None else 0, "--filter-date-until": 1 if args.filter_date_until is not None else 0, - "--filter-property": count_filter_values(args.filter_properties), - "--filter-tag": count_filter_values(args.filter_tags), - "--filter-heading": count_filter_values(filter_headings), - "--filter-body": count_filter_values(filter_bodies), + "--filter-property": len(args.filter_properties) if args.filter_properties else 0, + "--filter-tag": len(args.filter_tags) if args.filter_tags else 0, + "--filter-heading": len(args.filter_headings) if args.filter_headings else 0, + "--filter-body": len(args.filter_bodies) if args.filter_bodies else 0, "--filter-completed": 1 if args.filter_completed else 0, "--filter-not-completed": 1 if args.filter_not_completed else 0, } @@ -687,21 +440,9 @@ def extend_filter_order_with_defaults( missing = expected - existing if missing > 0: full_order.extend([arg_name] * missing) - return full_order -def resolve_date_filters(args: FilterArgs) -> tuple[datetime | None, datetime | None]: - """Resolve date filter arguments into parsed datetime values.""" - date_from = None - date_until = None - if args.filter_date_from is not None: - date_from = parse_date_argument(args.filter_date_from, "--filter-date-from") - if args.filter_date_until is not None: - date_until = parse_date_argument(args.filter_date_until, "--filter-date-until") - return date_from, date_until - - def _quote_string(value: str) -> str: """Quote a value as query-language string literal.""" return json.dumps(value) @@ -710,8 +451,7 @@ def _quote_string(value: str) -> str: def _timestamp_source_literal(value: str, arg_name: str) -> str: """Quote one parsed date argument as org timestamp source.""" parsed = parse_date_argument(value, arg_name).replace(second=0, microsecond=0) - source = f"<{parsed:%Y-%m-%d %a %H:%M}>" - return _quote_string(source) + return _quote_string(f"<{parsed:%Y-%m-%d %a %H:%M}>") def _simple_filter_stage(arg_name: str, args: FilterArgs) -> str | None: @@ -722,22 +462,18 @@ def _simple_filter_stage(arg_name: str, args: FilterArgs) -> str | None: elif arg_name == "--filter-level" and args.filter_level is not None: stage = f"select(.level == {args.filter_level})" elif arg_name == "--filter-repeats-above" and args.filter_repeats_above is not None: - threshold = args.filter_repeats_above - stage = f"select(.repeats | length > {threshold})" + stage = f"select(.repeats | length > {args.filter_repeats_above})" elif arg_name == "--filter-repeats-below" and args.filter_repeats_below is not None: - threshold = args.filter_repeats_below - stage = f"select(.repeats | length < {threshold})" + stage = f"select(.repeats | length < {args.filter_repeats_below})" elif arg_name == "--filter-date-from" and args.filter_date_from is not None: - timestamp_value = _timestamp_source_literal(args.filter_date_from, arg_name) stage = ( "select(.repeats + .deadline + .closed + .scheduled " - f"| max >= timestamp({timestamp_value}))" + f"| max >= timestamp({_timestamp_source_literal(args.filter_date_from, arg_name)}))" ) elif arg_name == "--filter-date-until" and args.filter_date_until is not None: - timestamp_value = _timestamp_source_literal(args.filter_date_until, arg_name) stage = ( "select(.repeats + .deadline + .closed + .scheduled " - f"| max <= timestamp({timestamp_value}))" + f"| max <= timestamp({_timestamp_source_literal(args.filter_date_until, arg_name)}))" ) elif arg_name == "--filter-completed" and args.filter_completed: stage = ( @@ -772,9 +508,8 @@ def _indexed_filter_stage( ) index_trackers["property"] += 1 return ( - "select(.properties[" - f"{_quote_string(property_name)}" - f"] == {_quote_string(property_value)})" + f"select(.properties[{_quote_string(property_name)}] == " + f"{_quote_string(property_value)})" ) if ( @@ -824,9 +559,8 @@ def build_filter_stages( index_trackers = {"property": 0, "tag": 0, "heading": 0, "body": 0} for entry in filter_order: if isinstance(entry, CustomStageInvocation): - filter_stages.append(_custom_stage(entry.query, entry.arg_value)) + filter_stages.append(build_custom_stage(entry.query, entry.arg_value)) continue - stage = _filter_stage(entry, args, index_trackers) if stage is not None: filter_stages.append(stage) @@ -852,17 +586,30 @@ def extend_order_values_with_defaults(order_values: list[str], args: object) -> return full_order -def build_order_stages( - args: object, - argv: list[str], - include_builtin_ordering: bool, -) -> list[str]: +def _builtin_order_stages(value: str) -> list[str]: + """Build query stages for one built-in ordering value.""" + timestamp_key_expr = ".repeats + .deadline + .closed + .scheduled | max" + order_stages: dict[str, list[str]] = { + "file-order": ["."], + "file-order-reversed": ["reverse"], + "priority": ["sort_by(.priority)"], + "level": ["sort_by(.level)"], + "timestamp-asc": [ + f"sort_by({timestamp_key_expr})", + "reverse", + f"sort_by(({timestamp_key_expr}) != null)", + ], + "timestamp-desc": [f"sort_by({timestamp_key_expr})"], + } + return order_stages.get(value, []) + + +def build_order_stages(args: object, argv: list[str], include_builtin_ordering: bool) -> list[str]: """Build query stages for ordering pipeline.""" order_entries = parse_order_entries_from_argv(argv, include_builtin_ordering) order_values: list[str | CustomStageInvocation] if include_builtin_ordering: builtin_order_values = [entry for entry in order_entries if isinstance(entry, str)] - expected_builtins = extend_order_values_with_defaults(builtin_order_values, args) remaining_builtin_counts: dict[str, int] = {} for value in builtin_order_values: @@ -885,31 +632,12 @@ def build_order_stages( order_stages: list[str] = [] for order_value in order_values: if isinstance(order_value, CustomStageInvocation): - order_stages.append(_custom_stage(order_value.query, order_value.arg_value)) + order_stages.append(build_custom_stage(order_value.query, order_value.arg_value)) continue - order_stages.extend(_builtin_order_stages(order_value)) return order_stages -def _builtin_order_stages(value: str) -> list[str]: - """Build query stages for one built-in ordering value.""" - timestamp_key_expr = ".repeats + .deadline + .closed + .scheduled | max" - order_stages: dict[str, list[str]] = { - "file-order": ["."], - "file-order-reversed": ["reverse"], - "priority": ["sort_by(.priority)"], - "level": ["sort_by(.level)"], - "timestamp-asc": [ - f"sort_by({timestamp_key_expr})", - "reverse", - f"sort_by(({timestamp_key_expr}) != null)", - ], - "timestamp-desc": ["sort_by(.repeats + .deadline + .closed + .scheduled | max)"], - } - return order_stages.get(value, []) - - def build_with_stages(args: WithArgs, argv: list[str]) -> list[str]: """Build query stages for built-in and custom enrichment pipeline.""" entries = parse_with_entries_from_argv(argv) @@ -917,7 +645,7 @@ def build_with_stages(args: WithArgs, argv: list[str]) -> list[str]: stages: list[str] = [] for entry in entries: if isinstance(entry, CustomStageInvocation): - stages.append(_custom_stage(entry.query, entry.arg_value)) + stages.append(build_custom_stage(entry.query, entry.arg_value)) continue if entry == "--with-tags-as-category" and args.with_tags_as_category: stages.append("(if .tags | length > 0 then .heading_category = .tags[0] else null); .") @@ -934,258 +662,16 @@ def collect_custom_context_vars( return {} -def build_query_text( +def build_pipeline_stages( args: QueryBuildArgs, argv: list[str], include_ordering: bool, - include_slice: bool, -) -> str: - """Build query text for the configured filter/ordering pipeline.""" +) -> list[str]: + """Build the ordered query pipeline stages for CLI/config state.""" validate_custom_switches(argv, include_ordering) - - filter_order = parse_filter_entries_from_argv(argv) - filter_order = extend_filter_order_with_defaults(filter_order, args) + filter_order = extend_filter_order_with_defaults(parse_filter_entries_from_argv(argv), args) filter_stages = build_filter_stages(args, filter_order) with_stages = build_with_stages(args, argv) - stages = [*with_stages, *filter_stages] stages.extend(build_order_stages(args, argv, include_builtin_ordering=include_ordering)) - - pipeline_body = " | ".join(stages) - base_query = f"[ .[] | {pipeline_body} ]" if pipeline_body else "[ .[] ]" - - if include_slice: - return f"{base_query}[$offset:($offset + $limit)]" - return base_query - - -def build_query( - args: QueryBuildArgs, - argv: list[str], - include_ordering: bool, - include_slice: bool, -) -> CompiledQuery: - """Compile query for configured filter/ordering pipeline.""" - query_text = build_query_text(args, argv, include_ordering, include_slice) - logger.info("Query: %s", query_text) - return compile_query_text(query_text) - - -def normalize_show_value(value: str, mapping: dict[str, str]) -> str: - """Normalize a single show value to match heading/body analysis.""" - normalized = normalize({value}, mapping) - return next(iter(normalized), "") - - -def dedupe_values(values: list[str]) -> list[str]: - """Deduplicate values while preserving order.""" - seen: set[str] = set() - deduped: list[str] = [] - for value in values: - if value in seen: - continue - seen.add(value) - deduped.append(value) - return deduped - - -def resolve_group_values( - groups: list[str] | None, - mapping: dict[str, str], - category: str, -) -> list[list[str]] | None: - """Resolve explicit group values from CLI arguments.""" - if groups is None: - return None - - resolved_groups: list[list[str]] = [] - for group_value in groups: - raw_values = parse_group_values(group_value) - if category == "tags": - group_items = [mapping.get(value, value) for value in raw_values] - else: - group_items = [] - for value in raw_values: - normalized_value = normalize_show_value(value, mapping) - if normalized_value: - group_items.append(normalized_value) - group_items = dedupe_values(group_items) - if group_items: - resolved_groups.append(group_items) - - return resolved_groups - - -def resolve_input_paths(inputs: list[str] | None) -> list[str]: # noqa: C901 - """Resolve CLI inputs into a list of org files to process. - - Args: - inputs: List of CLI path arguments (files and directories) - - Returns: - List of file paths to process - - Raises: - typer.BadParameter: If a path does not exist or no org files are found - """ - resolved_files: list[str] = [] - searched_dirs: list[Path] = [] - missing_paths: list[str] = [] - - targets = inputs or ["."] - for raw_path in targets: - path = Path(raw_path) - if not path.exists(): - missing_paths.append(raw_path) - continue - - if path.is_dir(): - searched_dirs.append(path) - for file_path in sorted(path.glob("*.org")): - if file_path.exists(): - resolved_files.append(str(file_path)) - else: - missing_paths.append(str(file_path)) - continue - - if path.is_file(): - resolved_files.append(str(path)) - continue - - raise typer.BadParameter(f"Path '{raw_path}' is not a file or directory") - - for raw_path in missing_paths: - logger.info("Warning: file '%s' not found", raw_path) - - if not resolved_files: - if missing_paths: - missing_list = ", ".join(missing_paths) - raise typer.BadParameter(f"All input paths are missing: {missing_list}") - if searched_dirs: - searched_list = ", ".join(str(path) for path in searched_dirs) - raise typer.BadParameter(f"No .org files found in: {searched_list}") - raise typer.BadParameter("No .org files found") - - return resolved_files - - -def resolve_mapping(args: object) -> dict[str, str]: - """Resolve mapping based on inline or file-based configuration.""" - mapping_inline = getattr(args, "mapping_inline", None) - if mapping_inline is not None: - return mapping_inline or MAP - mapping_file = getattr(args, "mapping", None) - return config_module.load_mapping(mapping_file) or MAP - - -def resolve_exclude_set(args: object) -> set[str]: - """Resolve exclude set based on inline or file-based configuration.""" - exclude_inline = getattr(args, "exclude_inline", None) - if exclude_inline is not None: - return config_module.normalize_exclude_values(exclude_inline) or DEFAULT_EXCLUDE - exclude_file = getattr(args, "exclude", None) - return config_module.load_exclude_list(exclude_file) or DEFAULT_EXCLUDE - - -class DataLoadArgs(QueryBuildArgs, Protocol): - """Protocol for loading and preprocessing data.""" - - files: list[str] | None - todo_states: str - done_states: str - width: int | None - - -class SlicedDataLoadArgs(Protocol): - """Protocol for args that support query slicing.""" - - offset: int - max_results: int - - -class RootDataLoadArgs(Protocol): - """Protocol for loading root nodes without filters or enrichment.""" - - files: list[str] | None - todo_states: str - done_states: str - width: int | None - - -def _resolve_and_load_roots( - args: RootDataLoadArgs, -) -> tuple[list[Document], list[str], list[str]]: - """Resolve inputs and load org roots after validating todo/done keys.""" - todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") - done_states = validate_and_parse_keys(args.done_states, "--done-states") - return _load_roots_for_inputs(args.files, todo_states, done_states) - - -def _load_roots_for_inputs( - files: list[str] | None, - todo_states: list[str], - done_states: list[str], -) -> tuple[list[Document], list[str], list[str]]: - """Resolve file inputs and load all org root nodes.""" - filenames = resolve_input_paths(files) - return load_root_nodes(filenames, todo_states, done_states) - - -def load_root_data( - args: RootDataLoadArgs, -) -> tuple[list[Document], list[str], list[str]]: - """Load org root nodes without filters, enrichment, or ordering.""" - return _resolve_and_load_roots(args) - - -def load_and_process_data( - args: DataLoadArgs, -) -> tuple[list[Heading], list[str], list[str]]: - """Load nodes, preprocess, and apply query-based filters/ordering.""" - include_ordering = hasattr(args, "order_by_level") - validate_custom_switches(sys.argv, include_ordering) - - normalized_files = normalize_cli_files_for_custom_switches(args.files) - args.files = normalized_files - - todo_states, done_states = validate_global_arguments(args) - roots, todo_states, done_states = _load_roots_for_inputs( - normalized_files, - todo_states, - done_states, - ) - nodes = [node for root in roots for node in list(root)] - - include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") - query = build_query( - args, - sys.argv, - include_ordering=include_ordering, - include_slice=include_slice, - ) - - context_vars: dict[str, object] = { - "todo_states": todo_states, - "done_states": done_states, - } - context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) - if include_slice: - sliced_args = cast("SlicedDataLoadArgs", args) - context_vars["offset"] = sliced_args.offset - context_vars["limit"] = sliced_args.max_results - - logger.info("Query context: %s", context_vars) - - try: - results = query(Stream([nodes]), EvalContext(context_vars)) - except (QueryParseError, QueryRuntimeError) as exc: - raise typer.BadParameter(str(exc)) from exc - - flattened: list[object] - if len(results) == 1 and isinstance(results[0], list): - flattened = cast("list[object]", results[0]) - else: - flattened = list(results) - nodes = [value for value in flattened if isinstance(value, Heading)] - - return nodes, todo_states, done_states + return stages diff --git a/src/org/logging.py b/src/org/logging.py new file mode 100644 index 0000000..7c0995b --- /dev/null +++ b/src/org/logging.py @@ -0,0 +1,114 @@ +"""Logging configuration and command logging helpers.""" + +from __future__ import annotations + +import logging +import sys + +import org.config.app + + +LOGGER_NAME = "org" +logger = logging.getLogger(LOGGER_NAME) + + +def configure_logging(verbose: bool) -> None: + """Configure logging output based on verbosity. + + Args: + verbose: Whether to enable INFO logging to stdout + """ + logger.propagate = False + + if verbose: + logger.setLevel(logging.INFO) + if not any(isinstance(handler, logging.StreamHandler) for handler in logger.handlers): + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(handler) + else: + logger.setLevel(logging.WARNING) + logger.handlers.clear() + + +def _format_default_log_entry(option_name: str, value: object) -> str: + """Format one option/value pair for config-default logging.""" + return f"{option_name}={value!r}" + + +def _format_argument_log_entry(arg_name: str, value: object) -> str: + """Format one argument/value pair for command argument logging.""" + return f"{arg_name}={value!r}" + + +def _redact_inline_config_value(option_name: str, value: object) -> object: + """Redact inline mapping/exclude values in default logs.""" + if option_name in {"--mapping", "--exclude"} and isinstance(value, (dict, list)): + return "" + return value + + +def log_applied_config_defaults(_args: object, _argv: list[str], command_name: str) -> None: + """Log config defaults loaded from config file.""" + if not logger.isEnabledFor(logging.INFO): + return + + entries: list[str] = [] + + for dest, default_value in sorted( + org.config.app.CONFIG_DEFAULTS.items(), + key=lambda item: item[0], + ): + option_name = org.config.app.DEST_TO_OPTION_NAME.get(dest) + if option_name is None: + continue + entries.append( + _format_default_log_entry( + option_name, + _redact_inline_config_value(option_name, default_value), + ), + ) + + for dest, values in sorted( + org.config.app.CONFIG_APPEND_DEFAULTS.items(), + key=lambda item: item[0], + ): + option_name = org.config.app.DEST_TO_OPTION_NAME.get(dest) + if option_name is None: + continue + entries.append( + _format_default_log_entry( + option_name, + _redact_inline_config_value(option_name, values), + ), + ) + + for dest, option_name in ( + ("mapping_inline", "--mapping"), + ("exclude_inline", "--exclude"), + ): + inline_value = org.config.app.CONFIG_INLINE_DEFAULTS.get(dest) + if inline_value is None: + continue + entries.append(_format_default_log_entry(option_name, "")) + + if entries: + logger.info("Config defaults applied (%s): %s", command_name, ", ".join(entries)) + + +def log_command_arguments(args: object, command_name: str) -> None: + """Log all final argument values used to run a command.""" + if not logger.isEnabledFor(logging.INFO): + return + + try: + arg_items = vars(args).items() + except TypeError: + return + + entries = [ + _format_argument_log_entry(arg_name, arg_value) + for arg_name, arg_value in sorted(arg_items, key=lambda item: item[0]) + ] + logger.info("Command arguments (%s): %s", command_name, ", ".join(entries)) diff --git a/src/org/logging_config.py b/src/org/logging_config.py deleted file mode 100644 index c4bc7ef..0000000 --- a/src/org/logging_config.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Logging configuration for the org CLI.""" - -from __future__ import annotations - -import logging -import sys - - -LOGGER_NAME = "org" - - -def configure_logging(verbose: bool) -> None: - """Configure logging output based on verbosity. - - Args: - verbose: Whether to enable INFO logging to stdout - """ - logger = logging.getLogger(LOGGER_NAME) - logger.propagate = False - - if verbose: - logger.setLevel(logging.INFO) - if not any(isinstance(handler, logging.StreamHandler) for handler in logger.handlers): - handler = logging.StreamHandler(sys.stdout) - handler.setLevel(logging.INFO) - handler.setFormatter(logging.Formatter("%(message)s")) - logger.addHandler(handler) - else: - logger.setLevel(logging.WARNING) - logger.handlers.clear() diff --git a/src/org/logic/__init__.py b/src/org/logic/__init__.py new file mode 100644 index 0000000..2cef564 --- /dev/null +++ b/src/org/logic/__init__.py @@ -0,0 +1 @@ +"""Core logic modules for org.""" diff --git a/src/org/commands/archive.py b/src/org/logic/archive.py similarity index 100% rename from src/org/commands/archive.py rename to src/org/logic/archive.py diff --git a/src/org/commands/editor.py b/src/org/logic/edit.py similarity index 53% rename from src/org/commands/editor.py rename to src/org/logic/edit.py index 5c40cf5..c7806bf 100644 --- a/src/org/commands/editor.py +++ b/src/org/logic/edit.py @@ -8,12 +8,13 @@ from typing import TYPE_CHECKING import click -import org_parser import typer +from org.pipeline.load import load_document_from_text + if TYPE_CHECKING: - from org_parser.document import Document, Heading + from org_parser.document import Heading def _editor_program() -> str: @@ -46,17 +47,6 @@ class DocumentEditResult: changed: bool -def _load_document_from_text(document_text: str, filename: str | None) -> Document: - """Parse full document text and preserve original filename when present.""" - try: - document = org_parser.loads(document_text) - except (TypeError, ValueError) as err: - raise typer.BadParameter(f"Edited document content is invalid: {err}") from err - - document.filename = "" if filename is None else filename - return document - - def _run_editor_at_line(filename: str, line: int) -> int: """Open one file in the configured editor at a given line.""" editor = _editor_program() @@ -67,18 +57,6 @@ def _run_editor_at_line(filename: str, line: int) -> int: return 0 -def _confirm_temporary_file_edit(prompt: str) -> bool: - """Ask whether a temporary-file fallback edit should be opened.""" - return typer.confirm(prompt, default=False) - - -def _edit_via_temporary_file(document_text: str, prompt: str) -> str | None: - """Prompt for and run the temporary-file fallback editor flow.""" - if not _confirm_temporary_file_edit(prompt): - return None - return edit_text_in_external_editor(document_text) - - def _read_document_text(path: Path, filename: str) -> str: """Read one document file from disk.""" try: @@ -97,49 +75,22 @@ def _write_document_text(path: Path, filename: str, document_text: str) -> None: raise typer.BadParameter(f"Permission denied for '{filename}'") from err -def _edit_document_without_filename(heading: Heading) -> DocumentEditResult: - """Edit a document that does not have a backing filename.""" - original_text = heading.document.render() - edited_text = _edit_via_temporary_file( - original_text, - "This task is not associated with a file. Edit a temporary copy instead?", - ) - if edited_text is None or edited_text == original_text: - return DocumentEditResult(changed=False) - - return DocumentEditResult(changed=True) - +def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: + """Edit one file-backed task subtree in the configured external editor.""" + filename = heading.document.filename or None + if filename is None: + raise typer.BadParameter( + "This task is not associated with a file and cannot be edited.", + ) -def _edit_document_with_filename(heading: Heading, filename: str) -> DocumentEditResult: - """Edit a document backed by a filename, preferring in-place file edits.""" path = Path(filename) original_text = _read_document_text(path, filename) - return_code = _run_editor_at_line(filename, 0 if heading.line is None else heading.line) if return_code == 0: edited_text = _read_document_text(path, filename) if edited_text == original_text: return DocumentEditResult(changed=False) - _load_document_from_text(edited_text, filename) + load_document_from_text(edited_text, filename) return DocumentEditResult(changed=True) - fallback_text = _edit_via_temporary_file( - original_text, - "Opening the original file at the task line failed. Edit a temporary copy instead?", - ) - if fallback_text is None: - raise typer.BadParameter("Editor failed to open") - if fallback_text == original_text: - return DocumentEditResult(changed=False) - - _write_document_text(path, filename, fallback_text) - _load_document_from_text(fallback_text, filename) - return DocumentEditResult(changed=True) - - -def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: - """Edit the original task document, preferring in-place file editing at task line.""" - filename = heading.document.filename or None - if filename is None: - return _edit_document_without_filename(heading) - return _edit_document_with_filename(heading, filename) + raise typer.BadParameter("Editor failed to open") diff --git a/src/org/commands/search_common.py b/src/org/logic/search.py similarity index 100% rename from src/org/commands/search_common.py rename to src/org/logic/search.py diff --git a/src/org/analyze.py b/src/org/logic/stats.py similarity index 61% rename from src/org/analyze.py rename to src/org/logic/stats.py index be63992..c36c5df 100644 --- a/src/org/analyze.py +++ b/src/org/logic/stats.py @@ -4,8 +4,8 @@ from datetime import date, datetime from typing import TYPE_CHECKING -from org.histogram import Histogram -from org.timestamp import extract_timestamp_any +from org.logic.time import extract_timestamp_any +from org.logic.validation import parse_group_values if TYPE_CHECKING: @@ -14,23 +14,12 @@ @dataclass class Frequency: # noqa: PLW1641 - """Represents frequency statistics for a tag/word. - - Attributes: - total: Total count for this item - """ + """Represents frequency statistics for a tag/word.""" total: int = 0 def __eq__(self, other: object) -> bool: - """Compare with another Frequency or int. - - Args: - other: Object to compare with (Frequency or int) - - Returns: - True if equal, False otherwise - """ + """Compare with another Frequency or int.""" if isinstance(other, Frequency): return self.total == other.total if isinstance(other, int): @@ -38,35 +27,20 @@ def __eq__(self, other: object) -> bool: return NotImplemented def __int__(self) -> int: - """Convert to int for backward compatibility and comparison. - - Returns: - The total count as an integer - """ + """Convert to int for backward compatibility and comparison.""" return self.total @dataclass class TimeRange: - """Represents time range for a tag/word occurrence. - - Attributes: - earliest: Earliest timestamp this tag/word was encountered - latest: Latest timestamp this tag/word was encountered - timeline: Dictionary mapping dates to occurrence counts - """ + """Represents time range for a tag/word occurrence.""" earliest: datetime | None = None latest: datetime | None = None timeline: dict[date, int] = field(default_factory=dict) def __repr__(self) -> str: - """Return string representation of TimeRange including top day. - - Returns: - String representation with earliest, latest, and top_day (most intense day). - Dates are formatted as ISO strings. None values are printed as None. - """ + """Return string representation of TimeRange including top day.""" top_day = None if self.timeline: max_count = max(self.timeline.values()) @@ -81,11 +55,7 @@ def __repr__(self) -> str: ) def update(self, timestamp: datetime | date | None) -> None: - """Update time range with a new timestamp. - - Args: - timestamp: Timestamp to incorporate into the range (datetime, date, or None) - """ + """Update time range with a new timestamp.""" if timestamp is None: return @@ -103,29 +73,26 @@ def update(self, timestamp: datetime | date | None) -> None: @dataclass class Relations: - """Represents pair-wise co-occurrence relationships for a tag/word. - - Attributes: - name: The tag or word name - relations: Dictionary mapping related tags/words to co-occurrence counts - """ + """Represents pair-wise co-occurrence relationships for a tag/word.""" name: str relations: dict[str, int] @dataclass -class Tag: - """Represents complete statistics for a single tag. +class Distribution: + """Represents a distribution of values.""" + + values: dict[str, int] = field(default_factory=dict) - Attributes: - name: Tag name - total_tasks: Total number of tasks with this tag - avg_tasks_per_day: Average tasks per day for this tag - max_single_day_count: Maximum tasks on a single day for this tag - relations: Co-occurrence relationships with other tags - time_range: Temporal data (earliest, latest, timeline) - """ + def update(self, key: str, amount: int) -> None: + """Update the count for a given key by the specified amount.""" + self.values[key] = self.values.get(key, 0) + amount + + +@dataclass +class Tag: + """Represents complete statistics for a single tag.""" name: str total_tasks: int @@ -137,15 +104,7 @@ class Tag: @dataclass class Group: - """Represents a group of related tags (strongly connected component). - - Attributes: - tags: List of tag names in this group, sorted alphabetically - time_range: Combined time range for all tags in the group - total_tasks: Total number of tasks with any tag in this group - avg_tasks_per_day: Average tasks per day for the group - max_single_day_count: Maximum tasks on a single day for the group - """ + """Represents a group of related tags (strongly connected component).""" tags: list[str] time_range: TimeRange @@ -156,32 +115,14 @@ class Group: @dataclass class AnalysisResult: - """Represents the complete result of analyzing Org-mode nodes. - - Attributes: - total_tasks: Total number of tasks analyzed (including repeats) - unique_tasks: Number of unique nodes (without repeats) - task_states: Histogram of task states (TODO, DONE, DELEGATED, CANCELLED, SUSPENDED, other) - task_categories: Histogram of task categories by gamify_exp (simple, regular, hard) - task_priorities: Histogram of task priorities (A, B, C, etc.) - task_days: Histogram of tasks by day of week - timerange: Global time range for all tasks - avg_tasks_per_day: Average tasks per day (total tasks / days spanned) - max_single_day_count: Highest number of tasks on a single day - max_repeat_count: Highest number of repeats for any individual task - tags: Dictionary mapping tag/item names to their Tag objects - tag_groups: List of tag groups (strongly connected components) - - Note: Despite the 'tag' naming, tags field holds data for whichever - category was analyzed (tags, heading, or body). - """ + """Represents the complete result of analyzing Org-mode nodes.""" total_tasks: int unique_tasks: int - task_states: Histogram - task_categories: Histogram - task_priorities: Histogram - task_days: Histogram + task_states: Distribution + task_categories: Distribution + task_priorities: Distribution + task_days: Distribution timerange: TimeRange avg_tasks_per_day: float max_single_day_count: int @@ -190,45 +131,43 @@ class AnalysisResult: tag_groups: list[Group] -def weekday_to_string(weekday: int) -> str: - """Map Python weekday integer to capitalized day name. +def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: + """Extract top day and its count from TimeRange.""" + if not time_range or not time_range.timeline: + return None + max_count = max(time_range.timeline.values()) + top_day = min(d for d, count in time_range.timeline.items() if count == max_count) + return (top_day.isoformat(), max_count) + - Args: - weekday: Integer from datetime.weekday() (0=Monday, 6=Sunday) +def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list[tuple[Heading, datetime]] = [] + for node in nodes: + latest_timestamp = node.latest_timestamp + if latest_timestamp is None: + continue + nodes_with_timestamps.append((node, latest_timestamp.start)) - Returns: - Capitalized day name (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday) - """ + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) + return [node for node, _ in sorted_nodes[:max_results]] + + +def weekday_to_string(weekday: int) -> str: + """Map Python weekday integer to capitalized day name.""" days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] return days[weekday] def mapped(mapping: dict[str, str], t: str) -> str: - """Map a tag to its canonical form using the provided mapping. - - Args: - mapping: Dictionary mapping tags to their canonical forms - t: Tag to map - - Returns: - Canonical form if found in mapping, otherwise the original tag - """ + """Map a tag to its canonical form using the provided mapping.""" if t in mapping: return mapping[t] return t def normalize(tags: set[str], mapping: dict[str, str]) -> set[str]: - """Normalize tags by lowercasing, stripping whitespace, removing punctuation, - and mapping to canonical forms. - - Args: - tags: Set of tags to normalize - mapping: Dictionary mapping tags to canonical forms - - Returns: - Set of normalized and mapped tags - """ + """Normalize tags by lowercasing, stripping whitespace, removing punctuation.""" norm = { t.lower() .strip() @@ -244,16 +183,7 @@ def normalize(tags: set[str], mapping: dict[str, str]) -> set[str]: def _extract_items(node: Heading, mapping: dict[str, str], category: str) -> set[str]: - """Extract and normalize items from a node based on category. - - Args: - node: Org-mode node to extract items from - mapping: Dictionary mapping items to canonical forms - category: Which datum to extract - "tags", "heading", or "body" - - Returns: - Set of normalized and mapped items (tags are not normalized, only mapped) - """ + """Extract and normalize items from a node based on category.""" if category == "tags": stripped_tags = {t.strip() for t in node.tags} return {mapped(mapping, t) for t in stripped_tags} @@ -263,14 +193,7 @@ def _extract_items(node: Heading, mapping: dict[str, str], category: str) -> set def compute_task_stats(nodes: list[Heading]) -> tuple[int, int]: - """Compute total task count and maximum repeat count. - - Args: - nodes: List of org-mode nodes - - Returns: - Tuple of (total_tasks, max_repeat_count) - """ + """Compute total task count and maximum repeat count.""" total = 0 max_repeat_count = 0 @@ -282,29 +205,14 @@ def compute_task_stats(nodes: list[Heading]) -> tuple[int, int]: def compute_max_single_day(timerange: TimeRange) -> int: - """Get the maximum number of tasks completed on a single day. - - Args: - timerange: TimeRange object with timeline data - - Returns: - Maximum count for any single day, or 0 if no timeline data - """ + """Get the maximum number of tasks completed on a single day.""" if not timerange.timeline: return 0 return max(timerange.timeline.values()) def compute_avg_tasks_per_day(timerange: TimeRange, total_count: int) -> float: - """Compute average tasks per day. - - Args: - timerange: TimeRange with earliest and latest dates - total_count: Total number of tasks - - Returns: - Average tasks per day, or 0.0 if timerange is empty or invalid - """ + """Compute average tasks per day.""" if timerange.earliest is None or timerange.latest is None: return 0.0 @@ -315,18 +223,9 @@ def compute_avg_tasks_per_day(timerange: TimeRange, total_count: int) -> float: return total_count / days_spanned -def compute_task_state_histogram(nodes: list[Heading]) -> Histogram: - """Compute histogram of task states across all nodes. - - Counts states from repeated tasks if present, otherwise uses node.todo. - - Args: - nodes: List of org-mode nodes - - Returns: - Histogram with counts for each task state - """ - task_states = Histogram(values={}) +def compute_task_state_histogram(nodes: list[Heading]) -> Distribution: + """Compute histogram of task states across all nodes.""" + task_states = Distribution(values={}) for node in nodes: if node.repeats: @@ -340,19 +239,9 @@ def compute_task_state_histogram(nodes: list[Heading]) -> Histogram: return task_states -def compute_day_of_week_histogram(nodes: list[Heading]) -> Histogram: - """Compute histogram of task completion days across all tasks. - - Extracts timestamps from all tasks and counts by day of week. - Tasks without timestamps are counted as "unknown". - - Args: - nodes: List of org-mode nodes - - Returns: - Histogram with counts for each day of week (Monday-Sunday, unknown) - """ - task_days = Histogram(values={}) +def compute_day_of_week_histogram(nodes: list[Heading]) -> Distribution: + """Compute histogram of task completion days across all tasks.""" + task_days = Distribution(values={}) for node in nodes: count = max(1, len(node.repeats)) @@ -368,9 +257,9 @@ def compute_day_of_week_histogram(nodes: list[Heading]) -> Histogram: return task_days -def compute_category_histogram(nodes: list[Heading]) -> Histogram: +def compute_category_histogram(nodes: list[Heading]) -> Distribution: """Compute histogram based on effective heading category values.""" - task_categories = Histogram(values={}) + task_categories = Distribution(values={}) for node in nodes: count = max(1, len(node.repeats)) @@ -384,19 +273,9 @@ def compute_category_histogram(nodes: list[Heading]) -> Histogram: return task_categories -def compute_priority_histogram(nodes: list[Heading]) -> Histogram: - """Compute histogram of task priorities across all nodes. - - Counts priorities from all tasks (including repeats). - Tasks without priority are counted as "null". - - Args: - nodes: List of org-mode nodes - - Returns: - Histogram with counts for each priority value - """ - task_priorities = Histogram(values={}) +def compute_priority_histogram(nodes: list[Heading]) -> Distribution: + """Compute histogram of task priorities across all nodes.""" + task_priorities = Distribution(values={}) for node in nodes: count = max(1, len(node.repeats)) @@ -408,16 +287,7 @@ def compute_priority_histogram(nodes: list[Heading]) -> Histogram: def compute_global_timerange(nodes: list[Heading]) -> TimeRange: - """Compute global time range across all tasks. - - Extracts timestamps from all tasks and builds a unified TimeRange. - - Args: - nodes: List of org-mode nodes - - Returns: - TimeRange spanning all task timestamps - """ + """Compute global time range across all tasks.""" global_timerange = TimeRange() for node in nodes: @@ -433,19 +303,7 @@ def compute_frequencies( mapping: dict[str, str], category: str, ) -> dict[str, Frequency]: - """Compute frequency statistics for all nodes in a given category. - - For each node, extracts items based on category (tags/heading/body), - normalizes them, and counts by the node's repeat count. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - Dictionary mapping items to their Frequency objects - """ + """Compute frequency statistics for all nodes in a given category.""" frequencies: dict[str, Frequency] = {} for node in nodes: @@ -465,19 +323,7 @@ def compute_relations( mapping: dict[str, str], category: str, ) -> dict[str, Relations]: - """Compute pair-wise relations for all nodes in a given category. - - For each node with 2+ items, computes all pair-wise relations - weighted by the node's repeat count. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - Dictionary mapping items to their Relations objects - """ + """Compute pair-wise relations for all nodes in a given category.""" relations_dict: dict[str, Relations] = {} for node in nodes: @@ -510,19 +356,7 @@ def compute_time_ranges( mapping: dict[str, str], category: str, ) -> dict[str, TimeRange]: - """Compute time ranges for all tasks in a given category. - - For each node, extracts items and timestamps, then builds TimeRange - objects tracking earliest/latest timestamps and timeline. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - Dictionary mapping items to their TimeRange objects - """ + """Compute time ranges for all tasks in a given category.""" time_ranges: dict[str, TimeRange] = {} for node in nodes: @@ -543,15 +377,7 @@ def compute_time_ranges( def _combine_time_ranges(tag_time_ranges: dict[str, TimeRange], tags: list[str]) -> TimeRange: - """Combine time ranges from multiple tags into a single TimeRange. - - Args: - tag_time_ranges: Dictionary mapping tags to their TimeRange objects - tags: List of tag names to combine - - Returns: - Combined TimeRange with merged earliest/latest/timeline data - """ + """Combine time ranges from multiple tags into a single TimeRange.""" combined = TimeRange() for tag in tags: @@ -581,21 +407,7 @@ def compute_per_tag_statistics( relations: dict[str, Relations], time_ranges: dict[str, TimeRange], ) -> dict[str, Tag]: - """Compute complete Tag objects with all statistics. - - For each tag: - - total_tasks equals frequency.total (total task count including repeats) - - avg_tasks_per_day computed from time_range and frequency - - max_single_day_count computed from time_range.timeline - - Args: - frequencies: Pre-computed frequency data - relations: Pre-computed relations data - time_ranges: Pre-computed time range data - - Returns: - Dictionary mapping tag names to Tag objects - """ + """Compute complete Tag objects with all statistics.""" tags: dict[str, Tag] = {} for tag_name, frequency in frequencies.items(): @@ -624,18 +436,7 @@ def compute_groups( # noqa: C901 mapping: dict[str, str], category: str, ) -> list[Group]: - """Compute strongly connected components from tag relations using Tarjan's algorithm. - - Args: - tags: Dictionary mapping tag names to their Tag objects - max_relations: Maximum number of relations to consider per tag - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - List of Group objects representing strongly connected components - """ + """Compute strongly connected components from tag relations using Tarjan's algorithm.""" if not tags: return [] @@ -695,7 +496,6 @@ def strongconnect(node: str) -> None: ), ) - # NOTE: Nodes is much larger, so iterate it only once for better performance. for org_node in nodes: node_items = _extract_items(org_node, mapping, category) @@ -719,18 +519,7 @@ def compute_explicit_groups( group_items: list[list[str]], tag_time_ranges: dict[str, TimeRange], ) -> list[Group]: - """Compute group statistics based on explicit tag lists. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - group_items: Explicit group item lists to evaluate - tag_time_ranges: Precomputed time ranges for tags/items - - Returns: - List of Group objects representing explicit groups - """ + """Compute group statistics based on explicit tag lists.""" groups: list[Group] = [] for group in group_items: @@ -772,17 +561,7 @@ def analyze( category: str, max_relations: int, ) -> AnalysisResult: - """Analyze org-mode nodes and extract task statistics. - - Args: - nodes: List of org-mode nodes - mapping: Dictionary mapping tags to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - max_relations: Maximum number of relations to consider for grouping - - Returns: - AnalysisResult containing task counts and Tag objects for the selected category - """ + """Analyze org-mode nodes and extract task statistics.""" tag_frequencies = compute_frequencies(nodes, mapping, category) tag_relations = compute_relations(nodes, mapping, category) tag_time_ranges = compute_time_ranges(nodes, mapping, category) @@ -815,14 +594,51 @@ def analyze( def clean(disallowed: set[str], tags: dict[str, Tag]) -> dict[str, Tag]: - """Remove tags from the disallowed set (stop words). - - Args: - disallowed: Set of tags to filter out (stop words, lowercase) - tags: Dictionary of Tag objects - - Returns: - Dictionary with disallowed tags removed (case-insensitive comparison) - """ + """Remove tags from the disallowed set (stop words).""" disallowed_lower = {d.lower() for d in disallowed} return {t: tags[t] for t in tags if t.lower() not in disallowed_lower} + + +def normalize_show_value(value: str, mapping: dict[str, str]) -> str: + """Normalize a single show value to match heading/body analysis.""" + normalized = normalize({value}, mapping) + return next(iter(normalized), "") + + +def dedupe_values(values: list[str]) -> list[str]: + """Deduplicate values while preserving order.""" + seen: set[str] = set() + deduped: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + deduped.append(value) + return deduped + + +def resolve_group_values( + groups: list[str] | None, + mapping: dict[str, str], + category: str, +) -> list[list[str]] | None: + """Resolve explicit group values from CLI arguments.""" + if groups is None: + return None + + resolved_groups: list[list[str]] = [] + for group_value in groups: + raw_values = parse_group_values(group_value) + if category == "tags": + group_items = [mapping.get(value, value) for value in raw_values] + else: + group_items = [] + for value in raw_values: + normalized_value = normalize_show_value(value, mapping) + if normalized_value: + group_items.append(normalized_value) + group_items = dedupe_values(group_items) + if group_items: + resolved_groups.append(group_items) + + return resolved_groups diff --git a/src/org/logic/tasks.py b/src/org/logic/tasks.py new file mode 100644 index 0000000..3526b30 --- /dev/null +++ b/src/org/logic/tasks.py @@ -0,0 +1,97 @@ +"""Shared task manipulation helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from org_parser.element import Repeat +from org_parser.time import Timestamp + + +if TYPE_CHECKING: + from datetime import datetime + + from org_parser.document import Heading + + +@dataclass(frozen=True) +class HeadingLocator: + """Stable heading locator used to restore selection across reloads.""" + + filename: str + heading_id: str | None + title: str + + +def shift_priority(priority: str | None, *, increase: bool) -> str | None: + """Shift priority one step across A/B/C/none.""" + order: list[str | None] = ["A", "B", "C", None] + normalized = priority if priority in {"A", "B", "C"} else None + index = order.index(normalized) + if increase: + return order[max(0, index - 1)] + return order[min(len(order) - 1, index + 1)] + + +def heading_locator(node: Heading) -> HeadingLocator: + """Build a stable heading locator for selection restoration.""" + return HeadingLocator( + filename=node.document.filename or "", + heading_id=node.id, + title=node.title_text, + ) + + +def resolve_heading_locator( + candidates: list[Heading], + locator: HeadingLocator | None, +) -> Heading | None: + """Resolve a preserved heading locator against a candidate heading list.""" + if locator is None or not candidates: + return None + + document = None + for candidate in candidates: + if (candidate.document.filename or "") == locator.filename: + document = candidate.document + break + if document is None: + return None + + resolved = ( + document.heading_by_id(locator.heading_id) + if locator.heading_id is not None + else document.heading_by_title(locator.title) + ) + if resolved is None: + return None + + for candidate in candidates: + if candidate is resolved: + return candidate + return None + + +def append_repeat_transition( + heading: Heading, + before: str | None, + after: str | None, + now: datetime, +) -> None: + """Append one repeat transition entry at current time.""" + transition_time = now.replace(second=0, microsecond=0) + heading.repeats.append( + Repeat( + before=before, + after=after, + timestamp=Timestamp.from_datetime(transition_time, is_active=False), + ), + ) + + +def detail_org_block(node: Heading) -> str: + """Return one heading subtree rendered back to Org text.""" + parts = [str(node)] + parts.extend(detail_org_block(child) for child in node.children) + return "".join(parts) diff --git a/src/org/logic/time.py b/src/org/logic/time.py new file mode 100644 index 0000000..d837474 --- /dev/null +++ b/src/org/logic/time.py @@ -0,0 +1,237 @@ +"""Timestamp and time-related logic.""" + +from __future__ import annotations + +import calendar +from datetime import UTC, date, datetime, timedelta +from typing import TYPE_CHECKING + +import typer + + +if TYPE_CHECKING: + from org_parser.document import Heading + from org_parser.time import Timestamp + + +def local_now() -> datetime: + """Return local timezone-aware current datetime.""" + return datetime.now(tz=UTC).astimezone() + + +def parse_date_argument(date_str: str, arg_name: str) -> datetime: + """Parse and validate timestamp argument in multiple supported formats.""" + if not date_str or not date_str.strip(): + supported_formats = [ + "YYYY-MM-DD", + "YYYY-MM-DDThh:mm", + "YYYY-MM-DDThh:mm:ss", + "YYYY-MM-DD hh:mm", + "YYYY-MM-DD hh:mm:ss", + ] + formats_str = ", ".join(supported_formats) + raise typer.BadParameter( + f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", + ) + + try: + return datetime.fromisoformat(date_str) + except ValueError: + pass + + try: + return datetime.fromisoformat(date_str.replace(" ", "T")) + except ValueError: + pass + + supported_formats = [ + "YYYY-MM-DD", + "YYYY-MM-DDThh:mm", + "YYYY-MM-DDThh:mm:ss", + "YYYY-MM-DD hh:mm", + "YYYY-MM-DD hh:mm:ss", + ] + formats_str = ", ".join(supported_formats) + raise typer.BadParameter( + f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", + ) + + +def normalize_timestamp(ts: datetime | date) -> datetime: + """Normalize all timestamps to use the datetime format.""" + if not isinstance(ts, datetime): + return datetime.combine(ts, datetime.min.time()) + return ts + + +def extract_timestamp(node: Heading, done_states: list[str]) -> list[datetime]: + """Extract timestamps from a node following priority rules.""" + timestamps = [] + + if node.repeats and any(rt.after in done_states for rt in node.repeats): + timestamps.extend([rt.timestamp.start for rt in node.repeats if rt.after in done_states]) + elif node.closed: + timestamps.append(node.closed.start) + elif node.scheduled: + timestamps.append(node.scheduled.start) + elif node.deadline: + timestamps.append(node.deadline.start) + elif node.timestamps: + timestamps.extend([timestamp.start for timestamp in node.timestamps]) + + return [normalize_timestamp(t) for t in timestamps] + + +def extract_timestamp_any(node: Heading) -> list[datetime]: + """Extract timestamps from a node without filtering by completion state.""" + timestamps = [] + + if node.repeats: + timestamps.extend([rt.timestamp.start for rt in node.repeats]) + elif node.closed: + timestamps.append(node.closed.start) + elif node.scheduled: + timestamps.append(node.scheduled.start) + elif node.deadline: + timestamps.append(node.deadline.start) + elif node.timestamps: + timestamps.extend([timestamp.start for timestamp in node.timestamps]) + + return [normalize_timestamp(t) for t in timestamps] + + +def resolve_date_filters(args: object) -> tuple[datetime | None, datetime | None]: + """Resolve date filter arguments into parsed datetime values.""" + date_from_value = getattr(args, "filter_date_from", None) + date_until_value = getattr(args, "filter_date_until", None) + date_from = ( + parse_date_argument(date_from_value, "--filter-date-from") + if date_from_value is not None + else None + ) + date_until = ( + parse_date_argument(date_until_value, "--filter-date-until") + if date_until_value is not None + else None + ) + return date_from, date_until + + +def set_timestamp_fields(timestamp: Timestamp, start: datetime, end: datetime | None) -> None: + """Set timestamp date/time fields while preserving active/repeater metadata.""" + timestamp.start_year = start.year + timestamp.start_month = start.month + timestamp.start_day = start.day + timestamp.start_dayname = start.strftime("%a") + if timestamp.start_hour is not None: + timestamp.start_hour = start.hour + timestamp.start_minute = start.minute + + if end is None or timestamp.end is None: + return + + timestamp.end_year = end.year + timestamp.end_month = end.month + timestamp.end_day = end.day + timestamp.end_dayname = end.strftime("%a") + if timestamp.end_hour is not None: + timestamp.end_hour = end.hour + timestamp.end_minute = end.minute + + +def add_months(value: datetime, months: int) -> datetime: + """Add months to a datetime while clamping day to month length.""" + year = value.year + (value.month - 1 + months) // 12 + month = (value.month - 1 + months) % 12 + 1 + day = min(value.day, calendar.monthrange(year, month)[1]) + return value.replace(year=year, month=month, day=day) + + +def shift_datetimes_by_unit( + start: datetime, + end: datetime | None, + *, + value: int, + unit: str, +) -> tuple[datetime, datetime | None]: + """Shift start/end datetimes by one repeater unit.""" + if unit == "d": + delta = timedelta(days=value) + return start + delta, None if end is None else end + delta + if unit == "w": + delta = timedelta(weeks=value) + return start + delta, None if end is None else end + delta + if unit == "h": + delta = timedelta(hours=value) + return start + delta, None if end is None else end + delta + if unit == "m": + return add_months(start, value), None if end is None else add_months(end, value) + if unit == "y": + months = value * 12 + return add_months(start, months), None if end is None else add_months(end, months) + raise ValueError(f"Unsupported repeater unit: {unit}") + + +def now_aligned_for_datetime(start: datetime, now: datetime) -> datetime: + """Normalize current datetime to match timezone-awareness of start.""" + if start.tzinfo is None: + return now.replace(tzinfo=None) + if now.tzinfo is None: + return now.replace(tzinfo=start.tzinfo) + return now.astimezone(start.tzinfo) + + +def advance_timestamp_by_repeater(timestamp: Timestamp) -> bool: + """Advance timestamp once by its repeater marker, when present.""" + if timestamp.repeater is None: + return False + + mark = timestamp.repeater.mark + value = timestamp.repeater.value + unit = timestamp.repeater.unit + if value <= 0: + return False + + start = timestamp.start + end = timestamp.end + + try: + if mark == "+": + shifted_start, shifted_end = shift_datetimes_by_unit( + start, + end, + value=value, + unit=unit, + ) + elif mark == "++": + now = now_aligned_for_datetime(start, local_now()) + shifted_start, shifted_end = shift_datetimes_by_unit( + start, + end, + value=value, + unit=unit, + ) + while shifted_start <= now: + shifted_start, shifted_end = shift_datetimes_by_unit( + shifted_start, + shifted_end, + value=value, + unit=unit, + ) + elif mark == ".+": + now = now_aligned_for_datetime(start, local_now()) + base_start = start.replace(year=now.year, month=now.month, day=now.day) + base_end = None if end is None else base_start + (end - start) + shifted_start, shifted_end = shift_datetimes_by_unit( + base_start, + base_end, + value=value, + unit=unit, + ) + else: + return False + except ValueError: + return False + + set_timestamp_fields(timestamp, shifted_start, shifted_end) + return True diff --git a/src/org/validation.py b/src/org/logic/validation.py similarity index 57% rename from src/org/validation.py rename to src/org/logic/validation.py index 2a20d54..4d0e4e1 100644 --- a/src/org/validation.py +++ b/src/org/logic/validation.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -from datetime import datetime from typing import Protocol import typer @@ -31,76 +30,8 @@ class StatsArgs(Protocol): min_group_size: int -def parse_date_argument(date_str: str, arg_name: str) -> datetime: - """Parse and validate timestamp argument in multiple supported formats. - - Supported formats: - - YYYY-MM-DD - - YYYY-MM-DDThh:mm - - YYYY-MM-DDThh:mm:ss - - YYYY-MM-DD hh:mm - - YYYY-MM-DD hh:mm:ss - - Args: - date_str: Date/timestamp string to parse - arg_name: Argument name for error messages - - Returns: - Parsed datetime object - - Raises: - typer.BadParameter: If format is invalid - """ - if not date_str or not date_str.strip(): - supported_formats = [ - "YYYY-MM-DD", - "YYYY-MM-DDThh:mm", - "YYYY-MM-DDThh:mm:ss", - "YYYY-MM-DD hh:mm", - "YYYY-MM-DD hh:mm:ss", - ] - formats_str = ", ".join(supported_formats) - raise typer.BadParameter( - f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", - ) - - try: - return datetime.fromisoformat(date_str) - except ValueError: - pass - - try: - return datetime.fromisoformat(date_str.replace(" ", "T")) - except ValueError: - pass - - supported_formats = [ - "YYYY-MM-DD", - "YYYY-MM-DDThh:mm", - "YYYY-MM-DDThh:mm:ss", - "YYYY-MM-DD hh:mm", - "YYYY-MM-DD hh:mm:ss", - ] - formats_str = ", ".join(supported_formats) - raise typer.BadParameter( - f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", - ) - - def parse_property_filter(property_str: str) -> tuple[str, str]: - """Parse property filter argument in KEY=VALUE format. - - Splits on first '=' to support values containing '='. - - Args: - property_str: Property filter string - - Returns: - Tuple of (property_name, property_value) - - Raises: - typer.BadParameter: If format is invalid (no '=' found) - """ + """Parse property filter argument in KEY=VALUE format.""" if "=" not in property_str: raise typer.BadParameter( f"--filter-property must be in KEY=VALUE format, got '{property_str}'", @@ -111,18 +42,7 @@ def parse_property_filter(property_str: str) -> tuple[str, str]: def validate_and_parse_keys(keys_str: str, option_name: str) -> list[str]: - """Parse and validate comma-separated keys. - - Args: - keys_str: Comma-separated string of keys - option_name: Name of the option for error messages - - Returns: - List of validated keys - - Raises: - typer.BadParameter: If validation fails - """ + """Parse and validate comma-separated keys.""" keys = [k.strip() for k in keys_str.split(",") if k.strip()] if not keys: raise typer.BadParameter(f"{option_name} cannot be empty") @@ -140,16 +60,7 @@ def validate_pattern( *, use_multiline: bool = False, ) -> None: - """Validate that a string is a valid regex pattern. - - Args: - pattern: Regex pattern string to validate - option_name: Name of the option for error messages - use_multiline: Whether to validate with re.MULTILINE flag - - Raises: - typer.BadParameter: If pattern is not a valid regex - """ + """Validate that a string is a valid regex pattern.""" try: if use_multiline: re.compile(pattern, re.MULTILINE) @@ -170,17 +81,7 @@ def parse_group_values(value: str) -> list[str]: def validate_global_arguments(args: GlobalArgs) -> tuple[list[str], list[str]]: - """Validate shared command-line arguments. - - Args: - args: Parsed command-line arguments - - Returns: - Tuple of (todo_states, done_states) - - Raises: - typer.BadParameter: If validation fails - """ + """Validate shared command-line arguments.""" todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") done_states = validate_and_parse_keys(args.done_states, "--done-states") diff --git a/src/org/parse.py b/src/org/parse.py deleted file mode 100644 index 2506ab0..0000000 --- a/src/org/parse.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Parsing utilities for org-mode files.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -import typer -from org_parser import loads - - -if TYPE_CHECKING: - from org_parser.document import Document, Heading - - -class FilterSpec(Protocol): - """Protocol for filtering node lists.""" - - def filter(self, nodes: list[Heading]) -> list[Heading]: - """Filter nodes and return the filtered list.""" - raise NotImplementedError - - -logger = logging.getLogger("org") - - -def _read_org_file(name: str) -> str: - """Read one org file and normalize unsupported time values.""" - path = Path(name) - try: - with path.open(encoding="utf-8") as f: - logger.info("Processing %s...", name) - return f.read().replace("24:00", "00:00") - except FileNotFoundError as err: - raise typer.BadParameter(f"File '{name}' not found") from err - except PermissionError as err: - raise typer.BadParameter(f"Permission denied for '{name}'") from err - - -def _prepend_todo_config(contents: str, todo_states: list[str], done_states: list[str]) -> str: - """Prepend TODO keyword configuration to file contents.""" - todo_config = f"#+TODO: {' '.join(todo_states)} | {' '.join(done_states)}\n\n" - return todo_config + contents - - -def _merge_state_order(existing: list[str], discovered: list[str]) -> list[str]: - """Merge discovered states into existing list while preserving order.""" - merged = list(existing) - seen = set(merged) - for state in discovered: - if state in seen: - continue - merged.append(state) - seen.add(state) - return merged - - -def load_root_nodes( - filenames: list[str], - todo_states: list[str], - done_states: list[str], -) -> tuple[list[Document], list[str], list[str]]: - """Load org-mode files and return root nodes with merged todo/done keys.""" - roots: list[Document] = [] - all_todo_states = list(todo_states) - all_done_states = list(done_states) - - for name in filenames: - contents = _read_org_file(name) - contents = _prepend_todo_config(contents, todo_states, done_states) - root = loads(contents, filename=name) - all_todo_states = _merge_state_order(all_todo_states, list(root.todo_states)) - all_done_states = _merge_state_order(all_done_states, list(root.done_states)) - roots.append(root) - - return roots, all_todo_states, all_done_states diff --git a/src/org/pipeline/__init__.py b/src/org/pipeline/__init__.py new file mode 100644 index 0000000..da9d24e --- /dev/null +++ b/src/org/pipeline/__init__.py @@ -0,0 +1 @@ +"""Data loading, query execution, and output pipeline modules.""" diff --git a/src/org/output_format.py b/src/org/pipeline/format.py similarity index 98% rename from src/org/output_format.py rename to src/org/pipeline/format.py index 9dd850b..7187285 100644 --- a/src/org/output_format.py +++ b/src/org/pipeline/format.py @@ -19,9 +19,8 @@ from org_parser.time import Clock, Timestamp from rich.syntax import Syntax -from org.analyze import AnalysisResult, Group, Tag, TimeRange -from org.histogram import Histogram -from org.tui import print_output +from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange +from org.tui.bits import print_output if TYPE_CHECKING: @@ -353,7 +352,7 @@ def _org_object_to_json_dict(value: object, seen: set[int]) -> dict[str, object] def _analysis_object_to_json_dict( - value: AnalysisResult | Tag | Group | TimeRange | Histogram, + value: AnalysisResult | Tag | Group | TimeRange | Distribution, seen: set[int], ) -> dict[str, object]: """Serialize analysis result dataclass into a JSON object with a type field.""" @@ -395,7 +394,7 @@ def _to_json_compatible(value: object, seen: set[int] | None = None) -> object: elif isinstance(value, Timestamp | Clock | Repeat | Heading | Document | Element): seen.add(obj_id) result = _org_object_to_json_dict(value, seen) - elif isinstance(value, AnalysisResult | Tag | Group | TimeRange | Histogram): + elif isinstance(value, AnalysisResult | Tag | Group | TimeRange | Distribution): seen.add(obj_id) result = _analysis_object_to_json_dict(value, seen) elif isinstance(value, Mapping): diff --git a/src/org/pipeline/load.py b/src/org/pipeline/load.py new file mode 100644 index 0000000..01add86 --- /dev/null +++ b/src/org/pipeline/load.py @@ -0,0 +1,229 @@ +"""Pipeline helpers for loading and preprocessing org-mode data.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, cast + +import typer +from org_parser import loads +from org_parser.document import Heading + +from org.config.cli import ( + QueryBuildArgs, + build_pipeline_stages, + collect_custom_context_vars, + normalize_cli_files_for_custom_switches, + validate_custom_switches, +) +from org.logic.validation import validate_and_parse_keys, validate_global_arguments +from org.query.engine.errors import QueryParseError +from org.query.runner import ( + build_query_from_stages, + execute_query_or_raise, + flatten_query_results, +) + + +if TYPE_CHECKING: + from org_parser.document import Document + + +logger = logging.getLogger("org") + + +def load_document_from_text(document_text: str, filename: str | None) -> Document: + """Parse full document text and preserve original filename when present.""" + try: + document = loads(document_text) + except (TypeError, ValueError) as err: + raise typer.BadParameter(f"Edited document content is invalid: {err}") from err + + document.filename = "" if filename is None else filename + return document + + +def _read_org_file(name: str) -> str: + """Read one org file and normalize unsupported time values.""" + path = Path(name) + try: + with path.open(encoding="utf-8") as f: + logger.info("Processing %s...", name) + return f.read().replace("24:00", "00:00") + except FileNotFoundError as err: + raise typer.BadParameter(f"File '{name}' not found") from err + except PermissionError as err: + raise typer.BadParameter(f"Permission denied for '{name}'") from err + + +def _prepend_todo_config(contents: str, todo_states: list[str], done_states: list[str]) -> str: + """Prepend TODO keyword configuration to file contents.""" + todo_config = f"#+TODO: {' '.join(todo_states)} | {' '.join(done_states)}\n\n" + return todo_config + contents + + +def _merge_state_order(existing: list[str], discovered: list[str]) -> list[str]: + """Merge discovered states into existing list while preserving order.""" + merged = list(existing) + seen = set(merged) + for state in discovered: + if state in seen: + continue + merged.append(state) + seen.add(state) + return merged + + +def load_root_nodes( + filenames: list[str], + todo_states: list[str], + done_states: list[str], +) -> tuple[list[Document], list[str], list[str]]: + """Load org-mode files and return root nodes with merged todo/done keys.""" + roots: list[Document] = [] + all_todo_states = list(todo_states) + all_done_states = list(done_states) + + for name in filenames: + contents = _read_org_file(name) + contents = _prepend_todo_config(contents, todo_states, done_states) + root = loads(contents, filename=name) + all_todo_states = _merge_state_order(all_todo_states, list(root.todo_states)) + all_done_states = _merge_state_order(all_done_states, list(root.done_states)) + roots.append(root) + + return roots, all_todo_states, all_done_states + + +def resolve_input_paths(inputs: list[str] | None) -> list[str]: # noqa: C901 + """Resolve CLI inputs into a list of org files to process.""" + resolved_files: list[str] = [] + searched_dirs: list[Path] = [] + missing_paths: list[str] = [] + + targets = inputs or ["."] + for raw_path in targets: + path = Path(raw_path) + if not path.exists(): + missing_paths.append(raw_path) + continue + + if path.is_dir(): + searched_dirs.append(path) + for file_path in sorted(path.glob("*.org")): + if file_path.exists(): + resolved_files.append(str(file_path)) + else: + missing_paths.append(str(file_path)) + continue + + if path.is_file(): + resolved_files.append(str(path)) + continue + + raise typer.BadParameter(f"Path '{raw_path}' is not a file or directory") + + for raw_path in missing_paths: + logger.info("Warning: file '%s' not found", raw_path) + + if not resolved_files: + if missing_paths: + missing_list = ", ".join(missing_paths) + raise typer.BadParameter(f"All input paths are missing: {missing_list}") + if searched_dirs: + searched_list = ", ".join(str(path) for path in searched_dirs) + raise typer.BadParameter(f"No .org files found in: {searched_list}") + raise typer.BadParameter("No .org files found") + + return resolved_files + + +class RootDataLoadArgs(Protocol): + """Protocol for loading root nodes without filters or enrichment.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +class DataLoadArgs(QueryBuildArgs, Protocol): + """Protocol for loading and preprocessing data.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +class SlicedDataLoadArgs(Protocol): + """Protocol for args that support query slicing.""" + + offset: int + max_results: int + + +def _load_roots_for_inputs( + files: list[str] | None, + todo_states: list[str], + done_states: list[str], +) -> tuple[list[Document], list[str], list[str]]: + """Resolve file inputs and load all org root nodes.""" + return load_root_nodes(resolve_input_paths(files), todo_states, done_states) + + +def _resolve_and_load_roots(args: RootDataLoadArgs) -> tuple[list[Document], list[str], list[str]]: + """Resolve inputs and load org roots after validating todo/done keys.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + return _load_roots_for_inputs(args.files, todo_states, done_states) + + +def load_root_data(args: RootDataLoadArgs) -> tuple[list[Document], list[str], list[str]]: + """Load org root nodes without filters, enrichment, or ordering.""" + return _resolve_and_load_roots(args) + + +def load_and_process_data(args: DataLoadArgs) -> tuple[list[Heading], list[str], list[str]]: + """Load nodes, preprocess, and apply query-based filters/ordering.""" + include_ordering = hasattr(args, "order_by_level") + validate_custom_switches(sys.argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + roots, todo_states, done_states = _load_roots_for_inputs( + normalized_files, + todo_states, + done_states, + ) + nodes = [node for root in roots for node in list(root)] + + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + try: + query = build_query_from_stages( + build_pipeline_stages(args, sys.argv, include_ordering), + include_slice, + ) + except QueryParseError as exc: + raise typer.BadParameter(str(exc)) from exc + + context_vars: dict[str, object] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) + if include_slice: + sliced_args = cast("SlicedDataLoadArgs", args) + context_vars["offset"] = sliced_args.offset + context_vars["limit"] = sliced_args.max_results + + logger.info("Query context: %s", context_vars) + + results = execute_query_or_raise(query, [nodes], context_vars, typer.BadParameter) + + flattened = flatten_query_results(results) + return [value for value in flattened if isinstance(value, Heading)], todo_states, done_states diff --git a/src/org/query/__init__.py b/src/org/query/__init__.py new file mode 100644 index 0000000..c09a6fb --- /dev/null +++ b/src/org/query/__init__.py @@ -0,0 +1 @@ +"""Query packages and runner helpers.""" diff --git a/src/org/query/engine/__init__.py b/src/org/query/engine/__init__.py new file mode 100644 index 0000000..8d1c6e9 --- /dev/null +++ b/src/org/query/engine/__init__.py @@ -0,0 +1 @@ +"""Query engine implementation.""" diff --git a/src/org/query_language/ast.py b/src/org/query/engine/ast.py similarity index 100% rename from src/org/query_language/ast.py rename to src/org/query/engine/ast.py diff --git a/src/org/query_language/compiler.py b/src/org/query/engine/compiler.py similarity index 79% rename from src/org/query_language/compiler.py rename to src/org/query/engine/compiler.py index b51c161..cfb45b7 100644 --- a/src/org/query_language/compiler.py +++ b/src/org/query/engine/compiler.py @@ -5,12 +5,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from org.query_language.parser import parse_query -from org.query_language.runtime import EvalContext, Stream, evaluate_expr +from org.query.engine.interpreter import EvalContext, Stream, evaluate_expr +from org.query.engine.parser import parse_query if TYPE_CHECKING: - from org.query_language.ast import Expr + from org.query.engine.ast import Expr type CompiledQuery = Callable[[Stream, EvalContext], Stream] diff --git a/src/org/query_language/errors.py b/src/org/query/engine/errors.py similarity index 100% rename from src/org/query_language/errors.py rename to src/org/query/engine/errors.py diff --git a/src/org/query_language/runtime.py b/src/org/query/engine/interpreter.py similarity index 99% rename from src/org/query_language/runtime.py rename to src/org/query/engine/interpreter.py index 4234db3..0fda1ac 100644 --- a/src/org/query_language/runtime.py +++ b/src/org/query/engine/interpreter.py @@ -29,8 +29,8 @@ from org_parser.text import RichText from org_parser.time import Clock, Timestamp -from org.analyze import analyze as _analyze_nodes -from org.query_language.ast import ( +from org.logic.stats import analyze as _analyze_nodes +from org.query.engine.ast import ( AsBinding, BinaryOp, BoolLiteral, @@ -55,7 +55,7 @@ TupleExpr, Variable, ) -from org.query_language.errors import QueryRuntimeError +from org.query.engine.errors import QueryRuntimeError class Stream(list[object]): diff --git a/src/org/query_language/parser.py b/src/org/query/engine/parser.py similarity index 99% rename from src/org/query_language/parser.py rename to src/org/query/engine/parser.py index 9d05042..68a2d7d 100644 --- a/src/org/query_language/parser.py +++ b/src/org/query/engine/parser.py @@ -7,7 +7,7 @@ from parsy import ParseError, Parser, eof, forward_declaration, generate, regex, seq, string -from org.query_language.ast import ( +from org.query.engine.ast import ( AsBinding, BinaryOp, BoolLiteral, @@ -32,7 +32,7 @@ TupleExpr, Variable, ) -from org.query_language.errors import QueryParseError +from org.query.engine.errors import QueryParseError if TYPE_CHECKING: diff --git a/src/org/query/runner.py b/src/org/query/runner.py new file mode 100644 index 0000000..1fb21cc --- /dev/null +++ b/src/org/query/runner.py @@ -0,0 +1,107 @@ +"""Query text assembly, compilation, and execution helpers.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable, Sequence +from typing import cast + +from org.query.engine.compiler import CompiledQuery, compile_query_text +from org.query.engine.errors import QueryParseError, QueryRuntimeError +from org.query.engine.interpreter import EvalContext, Stream + + +logger = logging.getLogger("org") + +ErrorBuilder = Callable[[str], Exception] + + +def build_custom_stage(query: str, arg_value: object) -> str: + """Build one custom query stage preserving input item stream values.""" + return f"let {_query_literal(arg_value)} as $arg in ({query})" + + +def _query_literal(value: object) -> str: + """Render a Python value as a query-language literal.""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int | float): + return str(value) + return json.dumps(value) + + +def build_query_text_from_stages(stages: Sequence[str], include_slice: bool) -> str: + """Build query text from an ordered list of pipeline stages.""" + pipeline_body = " | ".join(stages) + base_query = f"[ .[] | {pipeline_body} ]" if pipeline_body else "[ .[] ]" + return f"{base_query}[$offset:($offset + $limit)]" if include_slice else base_query + + +def build_query_from_stages(stages: Sequence[str], include_slice: bool) -> CompiledQuery: + """Compile query from an ordered list of pipeline stages.""" + query_text = build_query_text_from_stages(stages, include_slice) + logger.info("Query: %s", query_text) + return compile_query_text(query_text) + + +def compile_query_or_raise(query_text: str, error_builder: ErrorBuilder) -> CompiledQuery: + """Compile query text and convert parse failures to caller-specific errors.""" + try: + return compile_query_text(query_text) + except QueryParseError as exc: + raise error_builder(str(exc)) from exc + + +def compile_filter_order_query(filter_query: str, order_by: str | None) -> CompiledQuery: + """Compile a `select(...)` query with optional sort stage.""" + base_query = f"select({filter_query})" + if order_by is None: + return compile_query_text(base_query) + return compile_query_text(f"{base_query} | sort_by({order_by})") + + +def execute_query( + compiled_query: CompiledQuery, + stream_values: Sequence[object], + context_vars: dict[str, object], +) -> list[object]: + """Execute a compiled query over stream input values.""" + return list(compiled_query(Stream(stream_values), EvalContext(context_vars))) + + +def execute_query_or_raise( + compiled_query: CompiledQuery, + stream_values: Sequence[object], + context_vars: dict[str, object], + error_builder: ErrorBuilder, +) -> list[object]: + """Execute query and convert runtime failures to caller-specific errors.""" + try: + return execute_query(compiled_query, stream_values, context_vars) + except QueryRuntimeError as exc: + raise error_builder(str(exc)) from exc + + +def run_query_text_or_raise( + query_text: str, + stream_values: Sequence[object], + context_vars: dict[str, object], + error_builder: ErrorBuilder, +) -> list[object]: + """Compile and execute query text with caller-specific error conversion.""" + return execute_query_or_raise( + compile_query_or_raise(query_text, error_builder), + stream_values, + context_vars, + error_builder, + ) + + +def flatten_query_results(results: list[object]) -> list[object]: + """Flatten common single-list query result shape.""" + if len(results) == 1 and isinstance(results[0], list): + return cast("list[object]", results[0]) + return list(results) diff --git a/src/org/query_language/__init__.py b/src/org/query_language/__init__.py deleted file mode 100644 index 8ce9139..0000000 --- a/src/org/query_language/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Public API for query language parser/compiler/runtime.""" - -from org.query_language.compiler import CompiledQuery, compile_expr, compile_query_text -from org.query_language.errors import QueryLanguageError, QueryParseError, QueryRuntimeError -from org.query_language.parser import parse_query -from org.query_language.runtime import EvalContext, Stream, evaluate_expr - - -__all__ = [ - "CompiledQuery", - "EvalContext", - "QueryLanguageError", - "QueryParseError", - "QueryRuntimeError", - "Stream", - "compile_expr", - "compile_query_text", - "evaluate_expr", - "parse_query", -] diff --git a/src/org/timestamp.py b/src/org/timestamp.py deleted file mode 100644 index 1a74c31..0000000 --- a/src/org/timestamp.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Timestamp extraction and normalization functions.""" - -from datetime import date, datetime -from typing import TYPE_CHECKING - - -if TYPE_CHECKING: - from org_parser.document import Heading - - -def normalize_timestamp(ts: datetime | date) -> datetime: - """Normalize all timestamps to use the datetime format. - - Args: - ts: datetime or date - - Returns: - datetime - """ - if not isinstance(ts, datetime): - return datetime.combine(ts, datetime.min.time()) - return ts - - -def extract_timestamp(node: Heading, done_states: list[str]) -> list[datetime]: - """Extract timestamps from a node following priority rules. - - Priority order: - 1. Repeated tasks (all completed tasks) - 2. Closed timestamp - 3. Scheduled timestamp - 4. Deadline timestamp - 5. Datelist (timestamps in body) - - Args: - node: Org-mode node to extract timestamps from - done_states: List of completion state keywords - - Returns: - List of datetime objects (may be empty if no timestamps found) - """ - timestamps = [] - - if node.repeats and any(rt.after in done_states for rt in node.repeats): - timestamps.extend([rt.timestamp.start for rt in node.repeats if rt.after in done_states]) - elif node.closed: - timestamps.append(node.closed.start) - elif node.scheduled: - timestamps.append(node.scheduled.start) - elif node.deadline: - timestamps.append(node.deadline.start) - elif node.timestamps: - timestamps.extend([timestamp.start for timestamp in node.timestamps]) - - return [normalize_timestamp(t) for t in timestamps] - - -def extract_timestamp_any(node: Heading) -> list[datetime]: - """Extract timestamps from a node without filtering by completion state. - - Priority order: - 1. Repeated tasks (all repeated tasks regardless of state) - 2. Closed timestamp - 3. Scheduled timestamp - 4. Deadline timestamp - 5. Datelist (timestamps in body) - - Args: - node: Org-mode node to extract timestamps from - - Returns: - List of datetime objects (may be empty if no timestamps found) - """ - timestamps = [] - - if node.repeats: - timestamps.extend([rt.timestamp.start for rt in node.repeats]) - elif node.closed: - timestamps.append(node.closed.start) - elif node.scheduled: - timestamps.append(node.scheduled.start) - elif node.deadline: - timestamps.append(node.deadline.start) - elif node.timestamps: - timestamps.extend([timestamp.start for timestamp in node.timestamps]) - - return [normalize_timestamp(t) for t in timestamps] diff --git a/src/org/tui/__init__.py b/src/org/tui/__init__.py new file mode 100644 index 0000000..db90284 --- /dev/null +++ b/src/org/tui/__init__.py @@ -0,0 +1 @@ +"""Textual and Rich UI helpers for the org CLI.""" diff --git a/src/org/tui/app.py b/src/org/tui/app.py new file mode 100644 index 0000000..49f0fa9 --- /dev/null +++ b/src/org/tui/app.py @@ -0,0 +1,38 @@ +"""Shared Textual app helpers for interactive command UIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from textual.app import App, SuspendNotSupported + + +if TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager + + +class CommandApp(App[None]): + """Shared helpers for Textual-backed interactive command apps.""" + + def suspend_for_external(self, callback: Callable[[], None]) -> None: + """Suspend the app when possible around one blocking callback.""" + suspend = getattr(self, "suspend", None) + if callable(suspend): + try: + with cast("AbstractContextManager[object]", suspend()): + callback() + except SuspendNotSupported: + callback() + else: + callback() + + def run_external_and_refresh( + self, + callback: Callable[[], None], + *, + refresh: Callable[[], None], + ) -> None: + """Suspend around one external action and refresh afterwards.""" + self.suspend_for_external(callback) + refresh() diff --git a/src/org/tui.py b/src/org/tui/bits.py similarity index 84% rename from src/org/tui.py rename to src/org/tui/bits.py index ec5ba26..f8f5cb3 100644 --- a/src/org/tui.py +++ b/src/org/tui/bits.py @@ -31,8 +31,8 @@ from rich.style import Style from rich.text import Text -from org.cli_common import get_top_tasks -from org.color import ( +from org.logic.stats import get_top_tasks +from org.tui.color import ( bright_blue, bright_white, colorize, @@ -42,73 +42,21 @@ magenta, should_use_color, ) -from org.histogram import ( - Histogram, - HistogramRenderConfig, - RenderConfig, - render_histogram, - visual_len, +from org.tui.plot import ( + TimelineFormatConfig, + format_timeline_lines, + select_earliest_date, + select_latest_date, ) -from org.plot import TimelineRenderConfig, render_timeline_chart if TYPE_CHECKING: from collections.abc import Iterator - from datetime import date, datetime + from datetime import datetime from org_parser.document import Heading - from org.analyze import Group, Tag, TimeRange - - -def select_earliest_date( - date_from: datetime | None, - global_timerange: TimeRange, - local_timerange: TimeRange, -) -> date | None: - """Select earliest date using priority: user filter > global > local. - - Args: - date_from: User-provided filter date or None - global_timerange: Global timerange across all tasks - local_timerange: Local timerange for specific item - - Returns: - Selected earliest date or None if no dates available - """ - if date_from: - return date_from.date() - if global_timerange.earliest: - return global_timerange.earliest.date() - if local_timerange.earliest: - return local_timerange.earliest.date() - return None - - -def select_latest_date( - date_until: datetime | None, - global_timerange: TimeRange, - local_timerange: TimeRange, -) -> date | None: - """Select latest date using priority: user filter > global > local. - - Args: - date_until: User-provided filter date or None - global_timerange: Global timerange across all tasks - local_timerange: Local timerange for specific item - - Returns: - Selected latest date or None if no dates available - """ - if date_until: - return date_until.date() - if global_timerange.latest: - return global_timerange.latest.date() - if local_timerange.latest: - return local_timerange.latest.date() - if local_timerange.earliest: - return local_timerange.earliest.date() - return None + from org.logic.stats import Group, Tag, TimeRange def lines_to_text(lines: list[str]) -> str: @@ -164,20 +112,6 @@ def section_header_lines(title: str, color_enabled: bool) -> list[str]: return ["", bright_white(title, color_enabled)] -@dataclass(frozen=True) -class TimelineFormatConfig: - """Configuration for rendering timeline charts.""" - - color_enabled: bool - indent: str - plot_width: int - - -def resolve_timeline_plot_width(config: TimelineFormatConfig) -> int: - """Resolve visual timeline plot width from config.""" - return max(3, config.plot_width) - - @dataclass(frozen=True) class TagBlockConfig: """Configuration for rendering a tag block.""" @@ -204,16 +138,6 @@ class GroupBlockConfig: stats_indent: str -@dataclass(frozen=True) -class HistogramSectionConfig: - """Configuration for rendering histogram sections.""" - - plot_width: int - order: list[str] - render_config: RenderConfig - indent: str - - @dataclass(frozen=True) class TopTasksSectionConfig: """Configuration for rendering the top tasks section.""" @@ -254,6 +178,11 @@ def _truncate_filename(filename: str, width: int) -> str: return _pad_to_visual_width(truncated, width) +def visual_len(text: str) -> int: + """Get visual length of text (excluding Rich markup).""" + return cell_len(Text.from_markup(text).plain) + + def _truncate_to_visual_width(text: str, max_width: int) -> str: """Return text truncated to visual display width.""" if max_width <= 0: @@ -531,26 +460,6 @@ def format_task_line( return line -def format_timeline_lines( - timeline: dict[date, int], - earliest_date: date, - latest_date: date, - config: TimelineFormatConfig, -) -> list[str]: - """Render timeline chart lines and apply configured indentation.""" - plot_width = resolve_timeline_plot_width(config) - date_line, chart_line, underline = render_timeline_chart( - timeline, - earliest_date, - latest_date, - TimelineRenderConfig( - plot_width=plot_width, - color_enabled=config.color_enabled, - ), - ) - return apply_indent([date_line, chart_line, underline], config.indent) - - def format_tag_block(name: str, tag: Tag, config: TagBlockConfig) -> list[str]: """Render one tag block with timeline, stats, and relation lines.""" lines: list[str] = [] @@ -725,23 +634,3 @@ def format_top_tasks_section( ] return lines_to_text(apply_indent(lines, config.indent)) - - -def format_histogram_section( - title: str, - histogram: Histogram, - config: HistogramSectionConfig, -) -> list[str]: - """Render one histogram section as indented output lines.""" - lines = section_header_lines(title, config.render_config.color_enabled) - histogram_plot_width = max(3, config.plot_width - 2) - histogram_lines = render_histogram( - histogram, - HistogramRenderConfig( - plot_width=histogram_plot_width, - category_order=config.order, - style=config.render_config, - ), - ) - lines.extend([f" {line}" for line in histogram_lines]) - return apply_indent(lines, config.indent) diff --git a/src/org/tui/color.py b/src/org/tui/color.py new file mode 100644 index 0000000..c40d640 --- /dev/null +++ b/src/org/tui/color.py @@ -0,0 +1,97 @@ +"""Color support for CLI output using Rich markup.""" + +import sys + +from rich.markup import escape + + +def should_use_color(color_flag: bool | None) -> bool: + """Determine if color should be used based on flag and TTY detection. + + Args: + color_flag: Explicit color preference (True/False) or None for auto-detect + + Returns: + True if colors should be used, False otherwise + """ + if color_flag is None: + return sys.stdout.isatty() + return color_flag + + +def escape_text(text: str, enabled: bool) -> str: + """Escape markup characters when color output is enabled. + + Args: + text: Text to escape + enabled: Whether coloring is enabled + + Returns: + Escaped text when enabled, original text otherwise + """ + if not enabled: + return text + return escape(text) + + +def colorize(text: str, style: str, enabled: bool) -> str: + """Apply Rich markup style to text if enabled. + + Args: + text: Text to colorize + style: Rich style string (e.g., "green", "bold white") + enabled: Whether coloring is enabled + + Returns: + Styled text if enabled, original text otherwise + """ + if not enabled: + return text + return f"[{style}]{escape(text)}[/]" + + +def bright_white(text: str, enabled: bool) -> str: + """Apply bright white color to text.""" + return colorize(text, "bold white", enabled) + + +def dim_white(text: str, enabled: bool) -> str: + """Apply dim white color to text.""" + return colorize(text, "dim white", enabled) + + +def magenta(text: str, enabled: bool) -> str: + """Apply magenta color to text.""" + return colorize(text, "magenta", enabled) + + +def bright_blue(text: str, enabled: bool) -> str: + """Apply bright blue color to text.""" + return colorize(text, "bold blue", enabled) + + +def get_state_color( + state: str, + done_states: list[str], + todo_states: list[str], + enabled: bool, +) -> str: + """Get appropriate style for a task state.""" + if not enabled: + return "" + + normalized_state = state.strip().upper() + + if normalized_state == "CANCELLED": + return "bold red" + + if normalized_state == "SUSPENDED": + return "bold yellow" + + if state in done_states: + return "bold green" + + if state in todo_states or state == "" or state.lower() == "null": + return "bold bright_black" + + return "bold yellow" diff --git a/src/org/tui/footer.py b/src/org/tui/footer.py new file mode 100644 index 0000000..c034013 --- /dev/null +++ b/src/org/tui/footer.py @@ -0,0 +1,30 @@ +"""Footer helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from rich.table import Table +from rich.text import Text + + +@dataclass +class FooterPromptState: + """Simple prompt label/value state shared by Textual prompt configs.""" + + label: str + value: str = "" + cursor_position: int = 0 + error_message: str = "" + + +def footer_renderable(left_text: str, right_text: str, *, style: str) -> Table: + """Build one footer line with a right-aligned help hint.""" + footer_line = Table.grid(expand=True) + footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") + footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") + footer_line.add_row( + Text(left_text, style=style, no_wrap=True, overflow="ellipsis"), + Text(right_text, style=style, no_wrap=True, overflow="ellipsis"), + ) + return footer_line diff --git a/src/org/tui/header.py b/src/org/tui/header.py new file mode 100644 index 0000000..b6dfc77 --- /dev/null +++ b/src/org/tui/header.py @@ -0,0 +1 @@ +"""Header helpers for interactive Textual UIs.""" diff --git a/src/org/tui/help.py b/src/org/tui/help.py new file mode 100644 index 0000000..403557b --- /dev/null +++ b/src/org/tui/help.py @@ -0,0 +1,95 @@ +"""Help text and help modal helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from rich.markup import escape +from rich.text import Text +from textual import events as textual_events +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Static + + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +INTERACTIVE_HELP_CLI_NOTE = ( + "In interactive mode, press ? to open key bindings help (press any key to close)." +) + + +@dataclass(frozen=True) +class InteractiveHelpEntry: + """One key binding entry for interactive help rendering.""" + + key: str + description: str + + +def render_interactive_help_panel_text(entries: list[InteractiveHelpEntry]) -> str: + """Render key bindings as plain help text lines.""" + key_width = max(12, max((len(entry.key) for entry in entries), default=0) + 1) + lines = ["[white not dim]Key bindings:[/]"] + for entry in entries: + key_text = escape(f"{entry.key:<{key_width}}") + description_text = escape(entry.description) + lines.append(f" [bold white not dim]{key_text}[/][white not dim]{description_text}[/]") + return "\n".join(lines) + + +def interactive_help_command_text(base_text: str, entries: list[InteractiveHelpEntry]) -> str: + """Append the shared interactive-help note and key-bindings panel to help text.""" + normalized = " ".join(base_text.split()) + panel_text = render_interactive_help_panel_text(entries) + return f"{normalized} {INTERACTIVE_HELP_CLI_NOTE}\n\n{panel_text}" + + +def _help_body_text(entries: list[InteractiveHelpEntry]) -> Text: + """Build help body text without duplicating the modal title.""" + panel_text = render_interactive_help_panel_text(entries) + body_lines = panel_text.splitlines()[1:] + return Text.from_markup("\n".join(body_lines)) + + +class HelpModalScreen(ModalScreen[None]): + """Generic modal screen that renders command key bindings.""" + + CSS_PATH = "styles/help_modal.tcss" + + def __init__(self, entries: list[InteractiveHelpEntry], *, color_enabled: bool) -> None: + """Store key binding data for modal rendering.""" + super().__init__() + self._entries = entries + self._color_enabled = color_enabled + self._ready_to_close = False + + def compose(self) -> ComposeResult: + """Render the help modal contents.""" + title_text = Text("Key bindings", style="bold") + help_text = _help_body_text(self._entries) + yield Container( + Static(title_text, id="help-title"), + Static(help_text, id="help-content"), + id="help-dialog", + ) + + def on_mount(self) -> None: + """Arm key-to-close after the opening key event finishes.""" + self.call_after_refresh(self._enable_close) + + def _enable_close(self) -> None: + """Allow subsequent key presses to close the help modal.""" + self._ready_to_close = True + + async def on_key(self, event: textual_events.Key) -> None: + """Close and forward the next key press to the underlying app.""" + event.stop() + if not self._ready_to_close: + return + forwarded_event = textual_events.Key(event.key, event.character) + self.dismiss(None) + self.app.call_after_refresh(lambda: self.app.post_message(forwarded_event)) diff --git a/src/org/histogram.py b/src/org/tui/histogram.py similarity index 63% rename from src/org/histogram.py rename to src/org/tui/histogram.py index f8c9d31..78b1353 100644 --- a/src/org/histogram.py +++ b/src/org/tui/histogram.py @@ -1,55 +1,21 @@ -"""Histogram data structure and rendering functions.""" +"""Histogram rendering functions.""" -from dataclasses import dataclass, field - -from rich.cells import cell_len -from rich.text import Text - -from org.color import bright_blue, colorize, dim_white, get_state_color - - -def visual_len(text: str) -> int: - """Get visual length of text (excluding Rich markup). - - Args: - text: Text that may contain Rich markup or ANSI codes - - Returns: - Visual length of the text - """ - return cell_len(Text.from_markup(text).plain) +from __future__ import annotations +from dataclasses import dataclass, field +from typing import TYPE_CHECKING -@dataclass -class Histogram: - """Represents a distribution of values. - - Attributes: - values: Dictionary mapping categories to their counts - """ - - values: dict[str, int] = field(default_factory=dict) +from org.tui.bits import apply_indent, section_header_lines, visual_len +from org.tui.color import bright_blue, colorize, dim_white, get_state_color - def update(self, key: str, amount: int) -> None: - """Update the count for a given key by the specified amount. - Args: - key: The category to update - amount: The amount to add - """ - self.values[key] = self.values.get(key, 0) + amount +if TYPE_CHECKING: + from org.logic.stats import Distribution @dataclass class RenderConfig: - """Configuration for histogram rendering. - - Attributes: - color_enabled: Whether to apply colors to the output - histogram_type: Type of histogram ("task_states" or "other") - done_states: List of done state keywords (for task_states coloring) - todo_states: List of todo state keywords (for task_states coloring) - """ + """Configuration for histogram rendering.""" color_enabled: bool = False histogram_type: str = "other" @@ -66,6 +32,16 @@ class HistogramRenderConfig: style: RenderConfig = field(default_factory=RenderConfig) +@dataclass(frozen=True) +class HistogramSectionConfig: + """Configuration for rendering histogram sections.""" + + plot_width: int + order: list[str] + render_config: RenderConfig + indent: str + + def _resolve_render_config( config: HistogramRenderConfig | int, category_order: list[str] | None, @@ -85,9 +61,9 @@ def _resolve_render_config( ) -def _resolve_categories(histogram: Histogram, category_order: list[str] | None) -> list[str]: +def _resolve_categories(distribution: Distribution, category_order: list[str] | None) -> list[str]: """Resolve final category sequence for rendering.""" - occurring_categories = set(histogram.values.keys()) + occurring_categories = set(distribution.values.keys()) if category_order is None: return sorted(occurring_categories) @@ -105,32 +81,22 @@ def _resolve_categories(histogram: Histogram, category_order: list[str] | None) def render_histogram( - histogram: Histogram, + distribution: Distribution, config: HistogramRenderConfig | int, category_order: list[str] | None = None, style: RenderConfig | None = None, ) -> list[str]: - """Render histogram as visual bar chart. - - Args: - histogram: Histogram object to render - config: Histogram rendering configuration - category_order: Deprecated legacy argument - style: Deprecated legacy argument - - Returns: - List of formatted strings, one per category - """ + """Render histogram as visual bar chart.""" render_config_input, legacy_total_blocks = _resolve_render_config(config, category_order, style) render_config = render_config_input.style - total_sum = sum(histogram.values.values()) - categories = _resolve_categories(histogram, render_config_input.category_order) + total_sum = sum(distribution.values.values()) + categories = _resolve_categories(distribution, render_config_input.category_order) lines = [] for category in categories: - value = histogram.values.get(category, 0) + value = distribution.values.get(category, 0) display_name = category[:8] + "." if len(category) > 9 else category if render_config.histogram_type == "task_states": state_style = get_state_color( @@ -172,3 +138,23 @@ def render_histogram( lines.append(line) return lines + + +def format_histogram_section( + title: str, + distribution: Distribution, + config: HistogramSectionConfig, +) -> list[str]: + """Render one histogram section as indented output lines.""" + lines = section_header_lines(title, config.render_config.color_enabled) + histogram_plot_width = max(3, config.plot_width - 2) + histogram_lines = render_histogram( + distribution, + HistogramRenderConfig( + plot_width=histogram_plot_width, + category_order=config.order, + style=config.render_config, + ), + ) + lines.extend([f" {line}" for line in histogram_lines]) + return apply_indent(lines, config.indent) diff --git a/src/org/plot.py b/src/org/tui/plot.py similarity index 61% rename from src/org/plot.py rename to src/org/tui/plot.py index bbd4953..b938845 100644 --- a/src/org/plot.py +++ b/src/org/tui/plot.py @@ -2,8 +2,24 @@ from dataclasses import dataclass from datetime import date, timedelta +from typing import TYPE_CHECKING -from org.color import bright_blue, dim_white, magenta +from org.tui.color import bright_blue, dim_white, magenta + + +if TYPE_CHECKING: + from datetime import datetime + + from org.logic.stats import TimeRange + + +@dataclass(frozen=True) +class TimelineFormatConfig: + """Configuration for rendering timeline charts.""" + + color_enabled: bool + indent: str + plot_width: int @dataclass(frozen=True) @@ -14,17 +30,45 @@ class TimelineRenderConfig: color_enabled: bool = False -def expand_timeline(timeline: dict[date, int], earliest: date, latest: date) -> dict[date, int]: - """Fill in missing dates with 0 activity to create a complete timeline. +def select_earliest_date( + date_from: datetime | None, + global_timerange: TimeRange, + local_timerange: TimeRange, +) -> date | None: + """Select earliest date using priority: user filter > global > local.""" + if date_from: + return date_from.date() + if global_timerange.earliest: + return global_timerange.earliest.date() + if local_timerange.earliest: + return local_timerange.earliest.date() + return None + + +def select_latest_date( + date_until: datetime | None, + global_timerange: TimeRange, + local_timerange: TimeRange, +) -> date | None: + """Select latest date using priority: user filter > global > local.""" + if date_until: + return date_until.date() + if global_timerange.latest: + return global_timerange.latest.date() + if local_timerange.latest: + return local_timerange.latest.date() + if local_timerange.earliest: + return local_timerange.earliest.date() + return None + + +def resolve_timeline_plot_width(config: TimelineFormatConfig) -> int: + """Resolve visual timeline plot width from config.""" + return max(3, config.plot_width) - Args: - timeline: Sparse timeline with only days that have activity - earliest: First date in the range - latest: Last date in the range - Returns: - Complete timeline with all dates from earliest to latest, missing dates set to 0 - """ +def expand_timeline(timeline: dict[date, int], earliest: date, latest: date) -> dict[date, int]: + """Fill in missing dates with 0 activity to create a complete timeline.""" expanded: dict[date, int] = {} current = earliest @@ -36,15 +80,7 @@ def expand_timeline(timeline: dict[date, int], earliest: date, latest: date) -> def bucket_timeline(timeline: dict[date, int], num_buckets: int) -> list[int]: - """Group timeline into N equal-sized time buckets and sum activity per bucket. - - Args: - timeline: Complete timeline dict mapping dates to activity counts - num_buckets: Number of buckets to create - - Returns: - List of bucket sums (length = num_buckets) - """ + """Group timeline into N equal-sized time buckets and sum activity per bucket.""" if not timeline: return [0] * num_buckets @@ -66,15 +102,7 @@ def bucket_timeline(timeline: dict[date, int], num_buckets: int) -> list[int]: def _map_value_to_bar(value: int, max_value: int) -> str: - """Map a value to appropriate unicode bar character based on percentage. - - Args: - value: Value to map - max_value: Maximum value for percentage calculation - - Returns: - Unicode bar character representing the percentage - """ + """Map a value to appropriate unicode bar character based on percentage.""" if max_value == 0 or value == 0: return " " @@ -101,17 +129,7 @@ def render_timeline_chart( latest: date, config: TimelineRenderConfig, ) -> tuple[str, str, str]: - """Create ASCII bar chart from timeline data. - - Args: - timeline: Timeline dict mapping dates to activity counts - earliest: First date in the range - latest: Last date in the range - config: Timeline chart rendering configuration - - Returns: - Tuple of (date_line, chart_line, underline) - """ + """Create ASCII bar chart from timeline data.""" expanded = expand_timeline(timeline, earliest, latest) num_buckets = max(1, config.plot_width - 2) buckets = bucket_timeline(expanded, num_buckets) @@ -153,3 +171,26 @@ def render_timeline_chart( underline = dim_white("‾" * chart_width, config.color_enabled) return (date_line, chart_line, underline) + + +def format_timeline_lines( + timeline: dict[date, int], + earliest_date: date, + latest_date: date, + config: TimelineFormatConfig, +) -> list[str]: + """Render timeline chart lines and apply configured indentation.""" + plot_width = resolve_timeline_plot_width(config) + date_line, chart_line, underline = render_timeline_chart( + timeline, + earliest_date, + latest_date, + TimelineRenderConfig( + plot_width=plot_width, + color_enabled=config.color_enabled, + ), + ) + return [ + f"{config.indent}{line}" if config.indent and line else line + for line in [date_line, chart_line, underline] + ] diff --git a/src/org/tui/prompt.py b/src/org/tui/prompt.py new file mode 100644 index 0000000..bf01243 --- /dev/null +++ b/src/org/tui/prompt.py @@ -0,0 +1,65 @@ +"""Prompt modal helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, Static + + +if TYPE_CHECKING: + from collections.abc import Callable + + from textual import events as textual_events + from textual.app import ComposeResult + + +class PromptModalScreen(ModalScreen[str | None]): + """Generic modal screen for single-value text prompts.""" + + CSS_PATH = "styles/prompt_modal.tcss" + + def __init__( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + ) -> None: + """Store modal prompt configuration.""" + super().__init__() + self._label = label + self._initial_value = initial_value + self._on_change = on_change + + def compose(self) -> ComposeResult: + """Render the prompt label and input field.""" + yield Container( + Static(Text(self._label, style="bold"), id="prompt-label"), + Static(Text("Enter submits. Esc cancels.", style="dim"), id="prompt-instructions"), + Input(value=self._initial_value, id="prompt-input"), + id="prompt-dialog", + ) + + def on_mount(self) -> None: + """Focus the prompt input after the modal is mounted.""" + self.query_one(Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + """Forward value changes to optional preview callback.""" + if self._on_change is not None: + self._on_change(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Return submitted input value to caller.""" + self.dismiss(event.value) + + def on_key(self, event: textual_events.Key) -> None: + """Consume escape so prompt cancel doesn't fall through to app bindings.""" + if event.key != "escape": + return + event.stop() + self.dismiss(None) diff --git a/src/org/tui/selection.py b/src/org/tui/selection.py new file mode 100644 index 0000000..6a5b99a --- /dev/null +++ b/src/org/tui/selection.py @@ -0,0 +1,74 @@ +"""Selection modal helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import OptionList, Static + + +if TYPE_CHECKING: + from textual import events as textual_events + from textual.app import ComposeResult + + +@dataclass(frozen=True) +class SelectionOption: + """One selectable value shown by the shared selection modal.""" + + value: str + label: str + + +class SelectionModalScreen(ModalScreen[str | None]): + """Generic modal screen for keyboard-only single-value selection.""" + + CSS_PATH = "styles/selection_modal.tcss" + + def __init__( + self, + label: str, + options: list[SelectionOption], + *, + initial_index: int = 0, + ) -> None: + """Store modal selection configuration.""" + super().__init__() + self._label = label + self._options = options + self._initial_index = initial_index + + def compose(self) -> ComposeResult: + """Render the selection label and option list.""" + yield Container( + Static(Text(self._label, style="bold"), id="selection-label"), + Static( + Text("Up/down moves. Enter selects. Esc cancels.", style="dim"), + id="selection-instructions", + ), + OptionList(*(option.label for option in self._options), id="selection-options"), + id="selection-dialog", + ) + + def on_mount(self) -> None: + """Focus the selection list after the modal is mounted.""" + option_list = self.query_one(OptionList) + if self._options: + option_list.highlighted = min(max(self._initial_index, 0), len(self._options) - 1) + option_list.focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Return the selected option value to the caller.""" + event.stop() + self.dismiss(self._options[event.option_index].value) + + def on_key(self, event: textual_events.Key) -> None: + """Consume escape so selection cancel doesn't fall through to app bindings.""" + if event.key != "escape": + return + event.stop() + self.dismiss(None) diff --git a/src/org/tui/styles/help_modal.tcss b/src/org/tui/styles/help_modal.tcss new file mode 100644 index 0000000..6f2986f --- /dev/null +++ b/src/org/tui/styles/help_modal.tcss @@ -0,0 +1,18 @@ +HelpModalScreen { + align: center middle; + background: $surface 60%; +} + +#help-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 80%; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#help-content { + width: 1fr; +} diff --git a/src/org/tui/styles/prompt_modal.tcss b/src/org/tui/styles/prompt_modal.tcss new file mode 100644 index 0000000..44109d5 --- /dev/null +++ b/src/org/tui/styles/prompt_modal.tcss @@ -0,0 +1,26 @@ +PromptModalScreen { + align: center middle; + background: $surface 60%; +} + +#prompt-dialog { + width: 80%; + max-width: 100; + height: auto; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#prompt-label { + margin-bottom: 1; +} + +#prompt-instructions { + margin-bottom: 1; +} + +#prompt-input { + width: 1fr; + min-width: 20; +} diff --git a/src/org/tui/styles/selection_modal.tcss b/src/org/tui/styles/selection_modal.tcss new file mode 100644 index 0000000..dfc398a --- /dev/null +++ b/src/org/tui/styles/selection_modal.tcss @@ -0,0 +1,29 @@ +SelectionModalScreen { + align: center middle; + background: $surface 60%; +} + +#selection-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 24; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#selection-label { + margin-bottom: 1; +} + +#selection-instructions { + margin-bottom: 1; +} + +#selection-options { + width: 1fr; + min-width: 20; + height: auto; + max-height: 16; +} diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 34c2250..e80a97e 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -8,7 +8,8 @@ import typer -from org import cli, config +import org.config.app +from org import cli if TYPE_CHECKING: @@ -36,9 +37,9 @@ def fake_get_command(_app: object) -> DummyCommand: return DummyCommand() monkeypatch.setattr( - config, + org.config.app, "load_cli_config", - lambda _argv: config.LoadedCliConfig( + lambda _argv: org.config.app.LoadedCliConfig( defaults={"max_results": 3}, append_defaults={}, inline_defaults={}, @@ -51,7 +52,7 @@ def fake_get_command(_app: object) -> DummyCommand: ), ) monkeypatch.setattr( - config, + org.config.app, "build_default_map", lambda _defaults: {"stats": _defaults, "agenda": {}}, ) @@ -68,28 +69,28 @@ def fake_get_command(_app: object) -> DummyCommand: def test_cli_main_updates_config_globals(monkeypatch: pytest.MonkeyPatch) -> None: """main should update config append and inline defaults.""" - original_append = dict(config.CONFIG_APPEND_DEFAULTS) - original_inline = dict(config.CONFIG_INLINE_DEFAULTS) - original_custom_filters = dict(config.CONFIG_CUSTOM_FILTERS) - original_custom_order_by = dict(config.CONFIG_CUSTOM_ORDER_BY) - original_custom_with = dict(config.CONFIG_CUSTOM_WITH) - original_capture_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) - original_board_views = dict(config.CONFIG_BOARD_VIEWS) - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_ORDER_BY.clear() - config.CONFIG_CUSTOM_WITH.clear() - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_BOARD_VIEWS.clear() + original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) + original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) + original_custom_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) + original_custom_order_by = dict(org.config.app.CONFIG_CUSTOM_ORDER_BY) + original_custom_with = dict(org.config.app.CONFIG_CUSTOM_WITH) + original_capture_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + original_board_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() + org.config.app.CONFIG_CUSTOM_WITH.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_BOARD_VIEWS.clear() def fake_get_command(_app: object) -> SimpleNamespace: return SimpleNamespace(main=lambda **_: None) monkeypatch.setattr( - config, + org.config.app, "load_cli_config", - lambda _argv: config.LoadedCliConfig( + lambda _argv: org.config.app.LoadedCliConfig( defaults={}, append_defaults={"filter_tags": ["alpha"]}, inline_defaults={"mapping_inline": {"a": "b"}}, @@ -98,45 +99,45 @@ def fake_get_command(_app: object) -> SimpleNamespace: custom_with={"my-with": "."}, capture_templates={"quick": {"file": "tasks.org", "content": "* TODO {{title}}"}}, board_views={ - "kanban": config.BoardViewConfig( + "kanban": org.config.app.BoardViewConfig( name="kanban", columns=[ - config.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), + org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), ], ), }, agenda_views={}, ), ) - monkeypatch.setattr(config, "build_default_map", lambda _defaults: {}) + monkeypatch.setattr(org.config.app, "build_default_map", lambda _defaults: {}) monkeypatch.setattr(typer.main, "get_command", fake_get_command) monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) try: cli.main() - assert config.CONFIG_APPEND_DEFAULTS == {"filter_tags": ["alpha"]} - assert config.CONFIG_INLINE_DEFAULTS == {"mapping_inline": {"a": "b"}} - assert config.CONFIG_CUSTOM_FILTERS == {"my-filter": ".[]"} - assert config.CONFIG_CUSTOM_ORDER_BY == {"my-order": "."} - assert config.CONFIG_CUSTOM_WITH == {"my-with": "."} - assert config.CONFIG_CAPTURE_TEMPLATES == { + assert org.config.app.CONFIG_APPEND_DEFAULTS == {"filter_tags": ["alpha"]} + assert org.config.app.CONFIG_INLINE_DEFAULTS == {"mapping_inline": {"a": "b"}} + assert org.config.app.CONFIG_CUSTOM_FILTERS == {"my-filter": ".[]"} + assert org.config.app.CONFIG_CUSTOM_ORDER_BY == {"my-order": "."} + assert org.config.app.CONFIG_CUSTOM_WITH == {"my-with": "."} + assert org.config.app.CONFIG_CAPTURE_TEMPLATES == { "quick": {"file": "tasks.org", "content": "* TODO {{title}}"}, } - assert set(config.CONFIG_BOARD_VIEWS) == {"kanban"} - assert config.CONFIG_BOARD_VIEWS["kanban"].name == "kanban" + assert set(org.config.app.CONFIG_BOARD_VIEWS) == {"kanban"} + assert org.config.app.CONFIG_BOARD_VIEWS["kanban"].name == "kanban" finally: - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(original_append) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(original_inline) - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_custom_filters) - config.CONFIG_CUSTOM_ORDER_BY.clear() - config.CONFIG_CUSTOM_ORDER_BY.update(original_custom_order_by) - config.CONFIG_CUSTOM_WITH.clear() - config.CONFIG_CUSTOM_WITH.update(original_custom_with) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_capture_templates) - config.CONFIG_BOARD_VIEWS.clear() - config.CONFIG_BOARD_VIEWS.update(original_board_views) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_custom_filters) + org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() + org.config.app.CONFIG_CUSTOM_ORDER_BY.update(original_custom_order_by) + org.config.app.CONFIG_CUSTOM_WITH.clear() + org.config.app.CONFIG_CUSTOM_WITH.update(original_custom_with) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_capture_templates) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_board_views) diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index 4ca3183..21140b4 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -8,7 +8,7 @@ from typer.testing import CliRunner -from org import config +import org.config.app from org.cli import app @@ -152,7 +152,7 @@ def test_cli_help_lists_specific_key_bindings_for_interactive_commands() -> None (["board", "--help"], ["Esc/q", "S-Left/S-Right", "S-Up/S-Down"]), (["agenda", "--help"], ["Esc/q", "f/b, Left/Right", "r"]), (["tasks", "list", "--help"], ["Esc/q", "/", "s / d / c"]), - (["tasks", "capture", "--help"], ["Enter", "Left/Right", "Esc or Ctrl-C"]), + (["tasks", "capture", "--help"], ["Tab / Shift-Tab", "Ctrl-S", "Esc"]), ] for argv, snippets in expectations: @@ -200,56 +200,73 @@ def test_cli_runner_tasks_list_custom_filter_without_arg() -> None: """Custom filter switches should not be parsed as FILE arguments.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "multiple_tags.org").resolve()) - original_filters = dict(config.CONFIG_CUSTOM_FILTERS) + original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) try: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update({"has-todo": "select(.todo != null)"}) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update({"has-todo": "select(.todo != null)"}) result = runner.invoke( app, - ["tasks", "list", "--no-color", "--filter-has-todo", fixture_path], + [ + "tasks", + "list", + "--no-color", + "--out", + "org", + "--filter-has-todo", + fixture_path, + ], ) assert result.exit_code == 0 assert "* TODO Refactor" in result.stdout assert ":Maintenance:" in result.stdout finally: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_filters) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) def test_cli_runner_tasks_list_custom_filter_with_arg() -> None: """Custom filter argument value should not be parsed as FILE argument.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "multiple_tags.org").resolve()) - original_filters = dict(config.CONFIG_CUSTOM_FILTERS) + original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) try: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) result = runner.invoke( app, - ["tasks", "list", "--no-color", "--filter-level-above", "0", fixture_path], + [ + "tasks", + "list", + "--no-color", + "--out", + "org", + "--filter-level-above", + "0", + fixture_path, + ], ) assert result.exit_code == 0 assert "* TODO Refactor" in result.stdout assert ":Maintenance:" in result.stdout finally: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_filters) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) def test_cli_runner_tasks_list_custom_filter_required_arg_error() -> None: """Custom filters with $arg should fail when the argument is missing.""" runner = CliRunner() - original_filters = dict(config.CONFIG_CUSTOM_FILTERS) + original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) try: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) result = runner.invoke( app, @@ -261,12 +278,12 @@ def test_cli_runner_tasks_list_custom_filter_required_arg_error() -> None: combined_output = clean_combined_output(result) assert "--filter-level-above requires exactly one argument" in combined_output finally: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_filters) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) -def test_cli_runner_board_renders_columns() -> None: - """CLI should render board columns for root board command.""" +def test_cli_runner_board_requires_tty() -> None: + """CLI should reject non-TTY board execution.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "custom_states.org").resolve()) @@ -285,10 +302,8 @@ def test_cli_runner_board_renders_columns() -> None: ], ) - assert result.exit_code == 0 - assert "Backlog" in result.stdout - assert "TODO" in result.stdout - assert "DONE" in result.stdout + assert result.exit_code != 0 + assert "org board requires a TTY" in clean_combined_output(result) def test_cli_runner_flow_subcommand_is_not_registered() -> None: @@ -301,8 +316,8 @@ def test_cli_runner_flow_subcommand_is_not_registered() -> None: assert "No such command 'flow'" in clean_combined_output(result) -def test_cli_runner_agenda_renders_day_view() -> None: - """CLI should render agenda day sections and items.""" +def test_cli_runner_agenda_requires_tty() -> None: + """CLI should reject non-TTY agenda execution.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "agenda_sample.org").resolve()) @@ -317,12 +332,8 @@ def test_cli_runner_agenda_renders_day_view() -> None: ], ) - assert result.exit_code == 0 - plain_stdout = result.stdout.replace("…", "") - assert "CATEGORY" in plain_stdout - assert "TASK" in plain_stdout - assert "2025-01-15" in plain_stdout - assert "Timed agenda task" in plain_stdout + assert result.exit_code != 0 + assert "org agenda requires a TTY" in clean_combined_output(result) def test_cli_runner_tasks_add_writes_heading(tmp_path: Path) -> None: @@ -580,7 +591,7 @@ def _fake_open_at_line(filename: str, line: int) -> int: ) return 0 - monkeypatch.setattr("org.commands.editor._run_editor_at_line", _fake_open_at_line) + monkeypatch.setattr("org.logic.edit._run_editor_at_line", _fake_open_at_line) result = runner.invoke( app, @@ -814,11 +825,11 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -827,31 +838,36 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: }, ) - result = runner.invoke(app, ["tasks", "capture", "quick"], input="Write docs\n") + result = runner.invoke(app, ["tasks", "capture", "quick", "--set", "title=Write docs"]) assert result.exit_code == 0 updated = target.read_text(encoding="utf-8") assert "* TODO Write docs" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) -def test_cli_runner_capture_live_preview_updates_between_prompts( +def test_cli_runner_capture_uses_interactive_form_runner( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """CLI capture should render live template preview for interactive placeholders.""" + """CLI capture should route interactive placeholder capture through the app runner.""" runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) - live_preview_support_path = "org.commands.tasks.capture._supports_live_template_prompt" + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + support_path = "org.commands.tasks.capture.command._is_interactive_terminal" + form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: - monkeypatch.setattr(live_preview_support_path, lambda: True) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr( + form_runner_path, + lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, + ) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -860,36 +876,13 @@ def test_cli_runner_capture_live_preview_updates_between_prompts( }, ) - result = runner.invoke( - app, - ["tasks", "capture", "quick"], - input="Write docs\nJane\n", - color=True, - ) + result = runner.invoke(app, ["tasks", "capture", "quick"], color=True) assert result.exit_code == 0 - combined_output = clean_combined_output(result) - assert "Value for 'title': Write docs" in combined_output - assert "Value for 'owner': Jane" in combined_output assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") - - result_fullscreen = runner.invoke( - app, - ["tasks", "capture", "quick"], - input="Another task\nSam\n", - color=True, - env={"TERM": "xterm-256color"}, - ) - - assert result_fullscreen.exit_code == 0 - combined_fullscreen_output = clean_combined_output(result_fullscreen) - assert "Value for 'title':" in combined_fullscreen_output - assert "Value for 'owner':" in combined_fullscreen_output - updated_text = target.read_text(encoding="utf-8") - assert "* TODO Another task @Sam" in updated_text finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: @@ -899,11 +892,11 @@ def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: override_target = tmp_path / "override.org" configured_target.write_text("* TODO Configured\n", encoding="utf-8") override_target.write_text("* TODO Override\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(configured_target), @@ -914,16 +907,23 @@ def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: result = runner.invoke( app, - ["tasks", "capture", "quick", "--file", str(override_target)], - input="Write docs\n", + [ + "tasks", + "capture", + "quick", + "--file", + str(override_target), + "--set", + "title=Write docs", + ], ) assert result.exit_code == 0 assert "Write docs" not in configured_target.read_text(encoding="utf-8") assert "Write docs" in override_target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: @@ -934,11 +934,11 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", encoding="utf-8", ) - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "child": { "file": str(target), @@ -950,8 +950,7 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: result = runner.invoke( app, - ["tasks", "capture", "child", "--parent", "two"], - input="From cli\n", + ["tasks", "capture", "child", "--parent", "two", "--set", "title=From cli"], ) assert result.exit_code == 0 @@ -959,8 +958,8 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO From cli\n" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> None: @@ -971,11 +970,11 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", encoding="utf-8", ) - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "child": { "file": str(target), @@ -987,8 +986,7 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non result = runner.invoke( app, - ["tasks", "capture", "child", "--parent", "Two"], - input="By title\n", + ["tasks", "capture", "child", "--parent", "Two", "--set", "title=By title"], ) assert result.exit_code == 0 @@ -996,8 +994,8 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO By title\n" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: @@ -1005,11 +1003,11 @@ def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1024,8 +1022,8 @@ def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: assert result.stdout.strip() == "From cli (From cli)" assert "* TODO From cli (From cli)" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> None: @@ -1033,11 +1031,11 @@ def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1052,8 +1050,8 @@ def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> assert result.stdout.strip() == "task-99" assert "* TODO From cli" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) -> None: @@ -1061,11 +1059,11 @@ def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1079,20 +1077,27 @@ def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) assert result.exit_code == 0 assert "* TODO Static" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) -def test_cli_runner_capture_accepts_empty_placeholder_value(tmp_path: Path) -> None: - """CLI capture should accept empty interactive placeholder input.""" +def test_cli_runner_capture_accepts_empty_placeholder_value( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Interactive capture app runner should accept empty placeholder values.""" runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + support_path = "org.commands.tasks.capture.command._is_interactive_terminal" + form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr(form_runner_path, lambda plan: {**plan.values, "title": ""}) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1101,45 +1106,53 @@ def test_cli_runner_capture_accepts_empty_placeholder_value(tmp_path: Path) -> N }, ) - result = runner.invoke(app, ["tasks", "capture", "quick"], input="\n") + result = runner.invoke(app, ["tasks", "capture", "quick"]) assert result.exit_code == 0 updated = target.read_text(encoding="utf-8") assert "* TODO Existing" in updated assert updated.count("* TODO") == 2 finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) -def test_cli_runner_capture_interactive_numeric_selection(tmp_path: Path) -> None: - """CLI should select capture template with numeric prompt.""" +def test_cli_runner_capture_interactive_numeric_selection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Interactive capture should use the selection app when template name is omitted.""" runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + support_path = "org.commands.tasks.capture.command._is_interactive_terminal" + selection_path = "org.commands.tasks.capture.command.run_template_selection_app" + form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr(selection_path, lambda _names: "beta") + monkeypatch.setattr( + form_runner_path, + lambda plan: {**plan.values, "title": "Captured task"}, + ) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "alpha": {"file": str(target), "content": "* TODO Alpha"}, "beta": {"file": str(target), "content": "* TODO {{title}}"}, }, ) - result = runner.invoke(app, ["tasks", "capture"], input="2\nCaptured task\n") + result = runner.invoke(app, ["tasks", "capture"]) assert result.exit_code == 0 - combined_output = clean_combined_output(result) - assert "Select capture template:" in combined_output - assert "1) alpha" in combined_output - assert "2) beta" in combined_output updated = target.read_text(encoding="utf-8") assert "* TODO Captured task" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) -> None: @@ -1147,11 +1160,11 @@ def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) - runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "alpha": {"file": str(target), "content": "* TODO Alpha"}, "beta": {"file": str(target), "content": "* TODO Beta"}, @@ -1166,5 +1179,5 @@ def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) - assert "Valid templates: alpha," in combined_output assert "beta" in combined_output finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) diff --git a/tests/cli/test_cli_smoke.py b/tests/cli/test_cli_smoke.py deleted file mode 100644 index 63e5d78..0000000 --- a/tests/cli/test_cli_smoke.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Subprocess smoke tests for the CLI entrypoint.""" - -from __future__ import annotations - -import os -import subprocess -import sys - -from typer.testing import CliRunner - -from org.cli import app - - -PROJECT_ROOT = os.path.join(os.path.dirname(__file__), "..", "..") -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") - - -def test_cli_summary_smoke() -> None: - """Ensure summary command runs via python -m org.""" - fixture_path = os.path.join(FIXTURES_DIR, "single_task.org") - - result = subprocess.run( - [sys.executable, "-m", "org", "stats", "all", "--no-color", fixture_path], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - - assert result.returncode == 0 - assert "Total tasks:" in result.stdout - - -def test_cli_summary_multiple_files_smoke() -> None: - """Ensure multiple files are processed via CLI.""" - fixture1 = os.path.join(FIXTURES_DIR, "simple.org") - fixture2 = os.path.join(FIXTURES_DIR, "single_task.org") - - result = subprocess.run( - [ - sys.executable, - "-m", - "org", - "--verbose", - "stats", - "all", - "--no-color", - fixture1, - fixture2, - ], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - - assert result.returncode == 0 - assert result.stdout.count("Processing") == 2 - - -def test_cli_stats_summary_smoke() -> None: - """Ensure stats summary runs in-process without tag sections.""" - runner = CliRunner() - fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") - - result = runner.invoke(app, ["stats", "summary", "--no-color", fixture_path]) - - assert result.exit_code == 0 - assert "Task states:" in result.stdout - assert "TAGS" not in result.stdout diff --git a/tests/cli_common/test_cli_common_helpers.py b/tests/cli_common/test_cli_common_helpers.py index c5e9f8b..1ee1c8e 100644 --- a/tests/cli_common/test_cli_common_helpers.py +++ b/tests/cli_common/test_cli_common_helpers.py @@ -8,13 +8,9 @@ import pytest import typer -from org.cli_common import ( - dedupe_values, - normalize_show_value, - resolve_group_values, - resolve_input_paths, -) -from org.validation import parse_group_values +from org.logic.stats import dedupe_values, normalize_show_value, resolve_group_values +from org.logic.validation import parse_group_values +from org.pipeline.load import resolve_input_paths if TYPE_CHECKING: diff --git a/tests/cli_common/test_file_errors.py b/tests/cli_common/test_file_errors.py index fdbdda8..2cd8ec8 100644 --- a/tests/cli_common/test_file_errors.py +++ b/tests/cli_common/test_file_errors.py @@ -16,7 +16,7 @@ def test_load_exclude_list_file_not_found() -> None: """Test that loading non-existent exclude list raises error.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list with pytest.raises(typer.BadParameter, match="Exclude list file"): load_exclude_list("/nonexistent/path/to/exclude.txt") @@ -24,7 +24,7 @@ def test_load_exclude_list_file_not_found() -> None: def test_load_exclude_list_none() -> None: """Test that None filepath returns empty set.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list result = load_exclude_list(None) assert result == set() @@ -32,7 +32,7 @@ def test_load_exclude_list_none() -> None: def test_load_exclude_list_valid(tmp_path: Path) -> None: """Test loading valid exclude list.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list exclude_file = tmp_path / "exclude.txt" exclude_file.write_text("word1\nword2\nWORD3\n") @@ -43,7 +43,7 @@ def test_load_exclude_list_valid(tmp_path: Path) -> None: def test_load_exclude_list_with_empty_lines(tmp_path: Path) -> None: """Test loading exclude list with empty lines.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list exclude_file = tmp_path / "exclude.txt" exclude_file.write_text("word1\n\nword2\n\n\nword3\n") @@ -54,7 +54,7 @@ def test_load_exclude_list_with_empty_lines(tmp_path: Path) -> None: def test_load_exclude_list_with_whitespace(tmp_path: Path) -> None: """Test loading exclude list with whitespace.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list exclude_file = tmp_path / "exclude.txt" exclude_file.write_text(" word1 \n\t word2\t\nword3 ") @@ -65,7 +65,7 @@ def test_load_exclude_list_with_whitespace(tmp_path: Path) -> None: def test_load_mapping_file_not_found() -> None: """Test that loading non-existent mapping file raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping with pytest.raises(typer.BadParameter, match="Mapping file"): load_mapping("/nonexistent/path/to/mapping.json") @@ -73,7 +73,7 @@ def test_load_mapping_file_not_found() -> None: def test_load_mapping_none() -> None: """Test that None filepath returns empty dict.""" - from org.config import load_mapping + from org.config.app import load_mapping result = load_mapping(None) assert result == {} @@ -81,7 +81,7 @@ def test_load_mapping_none() -> None: def test_load_mapping_valid(tmp_path: Path) -> None: """Test loading valid mapping file.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": "testing", "webdev": "frontend"}') @@ -92,7 +92,7 @@ def test_load_mapping_valid(tmp_path: Path) -> None: def test_load_mapping_empty_dict(tmp_path: Path) -> None: """Test loading empty mapping dict.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text("{}") @@ -103,7 +103,7 @@ def test_load_mapping_empty_dict(tmp_path: Path) -> None: def test_load_mapping_invalid_json(tmp_path: Path) -> None: """Test that invalid JSON raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": "testing",') @@ -114,7 +114,7 @@ def test_load_mapping_invalid_json(tmp_path: Path) -> None: def test_load_mapping_non_dict_json_array(tmp_path: Path) -> None: """Test that JSON array raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('["test", "testing"]') @@ -125,7 +125,7 @@ def test_load_mapping_non_dict_json_array(tmp_path: Path) -> None: def test_load_mapping_non_dict_json_string(tmp_path: Path) -> None: """Test that JSON string raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('"test string"') @@ -136,7 +136,7 @@ def test_load_mapping_non_dict_json_string(tmp_path: Path) -> None: def test_load_mapping_non_dict_json_number(tmp_path: Path) -> None: """Test that JSON number raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text("42") @@ -147,7 +147,7 @@ def test_load_mapping_non_dict_json_number(tmp_path: Path) -> None: def test_load_mapping_non_string_keys(tmp_path: Path) -> None: """Test that mapping with valid string keys works (JSON converts int keys to strings).""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"123": "testing"}') @@ -159,7 +159,7 @@ def test_load_mapping_non_string_keys(tmp_path: Path) -> None: def test_load_mapping_non_string_values(tmp_path: Path) -> None: """Test that mapping with non-string values raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": 123}') @@ -170,7 +170,7 @@ def test_load_mapping_non_string_values(tmp_path: Path) -> None: def test_load_mapping_mixed_non_string_types(tmp_path: Path) -> None: """Test that mapping with mixed non-string types raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": "testing", "another": ["array"]}') @@ -181,7 +181,7 @@ def test_load_mapping_mixed_non_string_types(tmp_path: Path) -> None: def test_load_root_nodes_valid() -> None: """Test loading valid org files as root nodes.""" - from org.parse import load_root_nodes + from org.pipeline.load import load_root_nodes fixture_path = os.path.join(FIXTURES_DIR, "simple.org") roots, _, _ = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) @@ -192,7 +192,7 @@ def test_load_root_nodes_valid() -> None: def test_load_root_nodes_todo_states() -> None: """Test loading todo keys from root nodes.""" - from org.parse import load_root_nodes + from org.pipeline.load import load_root_nodes fixture_path = os.path.join(FIXTURES_DIR, "todo_states.org") roots, todo_states, done_states = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index a6c92d7..71f2b7d 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -9,6 +9,34 @@ import typer +def _build_query_text( + args: FilterArgsStub, + argv: list[str], + *, + include_ordering: bool, + include_slice: bool, +) -> str: + from org.config.cli import build_pipeline_stages + from org.query.runner import build_query_text_from_stages + + stages = build_pipeline_stages(args, argv, include_ordering) + return build_query_text_from_stages(stages, include_slice) + + +def _build_query( + args: FilterArgsStub, + argv: list[str], + *, + include_ordering: bool, + include_slice: bool, +) -> object: + from org.config.cli import build_pipeline_stages + from org.query.runner import build_query_from_stages + + stages = build_pipeline_stages(args, argv, include_ordering) + return build_query_from_stages(stages, include_slice) + + @dataclass class FilterArgsStub: """Stub arguments for query construction tests.""" @@ -50,20 +78,16 @@ def make_args(**overrides: object) -> FilterArgsStub: def test_build_query_text_filters_only() -> None: """Query text should include filters in command order.""" - from org.cli_common import build_query_text - args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == '[ .[] | select(.tags[] matches "simple") ]' def test_build_query_text_with_ordering_and_slice() -> None: """Query text should append order stages then slice.""" - from org.cli_common import build_query_text - args = make_args(filter_tags=["work"], order_by_timestamp_desc=True, offset=5, max_results=10) argv = [ "org", @@ -75,7 +99,7 @@ def test_build_query_text_with_ordering_and_slice() -> None: "file.org", ] - query = build_query_text(args, argv, include_ordering=True, include_slice=True) + query = _build_query_text(args, argv, include_ordering=True, include_slice=True) assert query == ( '[ .[] | select(.tags[] matches "work") ' @@ -86,12 +110,10 @@ def test_build_query_text_with_ordering_and_slice() -> None: def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: """timestamp-asc should keep items with no timestamp at the end.""" - from org.cli_common import build_query_text - args = make_args(order_by_timestamp_asc=True) argv = ["org", "tasks", "list", "--order-by-timestamp-asc", "file.org"] - query = build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False) assert query == ( "[ .[] | sort_by(.repeats + .deadline + .closed + .scheduled | max) " @@ -102,36 +124,30 @@ def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: def test_build_query_text_with_priority_ordering() -> None: """priority ordering should sort by node priority value.""" - from org.cli_common import build_query_text - args = make_args(order_by_priority=True) argv = ["org", "tasks", "list", "--order-by-priority", "file.org"] - query = build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False) assert query == "[ .[] | sort_by(.priority) ]" def test_build_query_text_with_property_filter() -> None: """Property filters should quote keys and values.""" - from org.cli_common import build_query_text - args = make_args(filter_properties=["priority=A"]) argv = ["org", "stats", "all", "--filter-property", "priority=A", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == '[ .[] | select(.properties["priority"] == "A") ]' def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: """Built-in with-tags-as-category switch should add its query stage.""" - from org.cli_common import build_query_text - args = make_args(with_tags_as_category=True) argv = ["org", "stats", "summary", "--with-tags-as-category", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | (if .tags | length > 0 then .heading_category = .tags[0] else null); . ]" @@ -140,12 +156,10 @@ def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: def test_build_query_text_with_tags_as_category_default_appended_when_missing_in_argv() -> None: """Default with-tags-as-category should be appended when omitted from argv.""" - from org.cli_common import build_query_text - args = make_args(with_tags_as_category=True, filter_tags=["work"]) argv = ["org", "stats", "summary", "--filter-tag", "work", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | (if .tags | length > 0 then .heading_category = .tags[0] else null); . " @@ -155,12 +169,10 @@ def test_build_query_text_with_tags_as_category_default_appended_when_missing_in def test_build_query_text_with_filter_completed() -> None: """Completed filter should include repeated task completion states.""" - from org.cli_common import build_query_text - args = make_args(filter_completed=True) argv = ["org", "tasks", "list", "--filter-completed", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | select(if .repeats | length > 0 then .repeats | map(.is_completed) + " @@ -171,12 +183,10 @@ def test_build_query_text_with_filter_completed() -> None: def test_build_query_text_with_filter_not_completed() -> None: """Not-completed filter should include tasks with no done state.""" - from org.cli_common import build_query_text - args = make_args(filter_not_completed=True) argv = ["org", "tasks", "list", "--filter-not-completed", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | select(if .repeats | length > 0 then not(.repeats | map(.is_completed) + " @@ -187,13 +197,11 @@ def test_build_query_text_with_filter_not_completed() -> None: def test_build_query_logs_query_before_compile(caplog: pytest.LogCaptureFixture) -> None: """build_query should log the query text before compilation.""" - from org.cli_common import build_query - args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] with caplog.at_level(logging.INFO, logger="org"): - build_query(args, argv, include_ordering=False, include_slice=False) + _build_query(args, argv, include_ordering=False, include_slice=False) assert 'Query: [ .[] | select(.tags[] matches "simple") ]' in caplog.text @@ -202,19 +210,18 @@ def test_build_query_text_with_custom_filter_and_optional_arg( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom filter switches should bind $arg and support omitted values.""" - from org import config - from org.cli_common import build_query_text + import org.config.app monkeypatch.setattr( - config, + org.config.app, "CONFIG_CUSTOM_FILTERS", { "todo-state": "select(.todo == $arg)", "has-todo": "select(.todo != null)", }, ) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = [ @@ -227,7 +234,7 @@ def test_build_query_text_with_custom_filter_and_optional_arg( "file.org", ] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | let 3 as $arg in (select(.todo == $arg))" @@ -239,12 +246,12 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom arguments are embedded in query text and not added to context vars.""" - from org import config - from org.cli_common import collect_custom_context_vars + import org.config.app + from org.config.cli import collect_custom_context_vars - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(files=["file.org"]) argv = [ @@ -271,12 +278,11 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: """Custom enrichment stages should be applied before filters.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(filter_completed=True, files=["file.org"]) argv = [ @@ -290,7 +296,7 @@ def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyP "file.org", ] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query.startswith('[ .[] | let "one" as $arg in (. + {"x": $arg}) | ') assert "| let null as $arg in (select(.tag != null)) |" in query @@ -300,12 +306,11 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """Built-in and custom with stages should preserve CLI order.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(with_tags_as_category=True, files=["file.org"]) argv = [ @@ -318,7 +323,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( "file.org", ] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( '[ .[] | let "one" as $arg in (. + {"x": $arg}) ' @@ -328,24 +333,23 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) args = make_args(files=["file.org"]) argv = ["org", "stats", "all", "--order-by-weight", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == "[ .[] | let null as $arg in (sort_by(.priority)) ]" def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixture) -> None: """Data loading should log the query execution context.""" - from org.cli_common import load_and_process_data + from org.pipeline.load import load_and_process_data fixture_path = str((Path(__file__).resolve().parents[1] / "fixtures" / "simple.org").resolve()) args = make_args(files=[fixture_path], offset=0, max_results=1) @@ -361,12 +365,11 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """Built-in and custom ordering switches should follow CLI specification order.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) args = make_args(order_by_timestamp_desc=True, files=["file.org"]) argv = [ @@ -378,7 +381,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( "file.org", ] - query = build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False) assert query == ( "[ .[] | let null as $arg in (sort_by(.priority))" @@ -388,30 +391,32 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: """Unknown prefixed custom switches should fail validation.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--filter-unknown", "file.org"] with pytest.raises(click.NoSuchOption, match="No such option"): - build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False) def test_build_query_text_custom_filter_requires_exactly_one_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom filters using $arg should require exactly one argument.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"level-above": "select(.level > $arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr( + org.config.app, + "CONFIG_CUSTOM_FILTERS", + {"level-above": "select(.level > $arg)"}, + ) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--filter-level-above"] @@ -420,55 +425,53 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( typer.BadParameter, match="--filter-level-above requires exactly one argument", ): - build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False) def test_build_query_text_custom_order_by_requires_exactly_one_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom order-by switches using $arg should require exactly one argument.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--order-by-weight"] with pytest.raises(typer.BadParameter, match="--order-by-weight requires exactly one argument"): - build_query_text(args, argv, include_ordering=True, include_slice=False) + _build_query_text(args, argv, include_ordering=True, include_slice=False) def test_build_query_text_custom_with_requires_exactly_one_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom enrichment switches using $arg should require exactly one argument.""" - from org import config - from org.cli_common import build_query_text + import org.config.app - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--with-mark"] with pytest.raises(typer.BadParameter, match="--with-mark requires exactly one argument"): - build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False) def test_normalize_cli_files_consumes_required_custom_path_like_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Required custom arguments should consume path-like values.""" - from org import config - from org.cli_common import normalize_cli_files_for_custom_switches + import org.config.app + from org.config.cli import normalize_cli_files_for_custom_switches - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) files = ["--filter-value", "README.md", "file.org"] @@ -479,7 +482,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( def test_get_top_day_info_none() -> None: """get_top_day_info returns null with missing timeline.""" - from org.cli_common import get_top_day_info + from org.logic.stats import get_top_day_info result = get_top_day_info(None) diff --git a/tests/cli_common/test_top_tasks.py b/tests/cli_common/test_top_tasks.py index 16e595b..dbcef82 100644 --- a/tests/cli_common/test_top_tasks.py +++ b/tests/cli_common/test_top_tasks.py @@ -2,24 +2,25 @@ from datetime import datetime -from org.cli_common import get_most_recent_timestamp, get_top_tasks +from org.logic.stats import get_top_tasks from tests.conftest import node_from_org -def test_get_most_recent_timestamp_with_closed() -> None: - """Test get_most_recent_timestamp with closed timestamp.""" +def test_heading_latest_timestamp_with_closed() -> None: + """Heading.latest_timestamp should expose closed timestamp when present.""" org_text = """* DONE Task CLOSED: [2023-10-15 Sun 14:00] """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp == datetime(2023, 10, 15, 14, 0) + assert latest_timestamp is not None + assert latest_timestamp.start == datetime(2023, 10, 15, 14, 0) -def test_get_most_recent_timestamp_with_repeats() -> None: - """Test get_most_recent_timestamp with repeated tasks.""" +def test_heading_latest_timestamp_with_repeats() -> None: + """Heading.latest_timestamp should expose the latest repeated timestamp.""" org_text = """* DONE Task :LOGBOOK: - State "DONE" from "TODO" [2023-10-10 Tue 09:00] @@ -29,32 +30,39 @@ def test_get_most_recent_timestamp_with_repeats() -> None: """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp == datetime(2023, 10, 15, 14, 0) + assert latest_timestamp is not None + assert latest_timestamp.start == datetime(2023, 10, 15, 14, 0) -def test_get_most_recent_timestamp_with_scheduled() -> None: - """Test get_most_recent_timestamp with scheduled timestamp.""" +def test_heading_latest_timestamp_with_scheduled() -> None: + """Heading.latest_timestamp should expose scheduled timestamp when present.""" org_text = """* TODO Task SCHEDULED: <2023-10-20 Fri 10:00> """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp == datetime(2023, 10, 20, 10, 0) + assert latest_timestamp is not None + assert latest_timestamp.start == datetime(2023, 10, 20, 10, 0) -def test_get_most_recent_timestamp_no_timestamp() -> None: - """Test get_most_recent_timestamp with no timestamp.""" +def test_heading_latest_timestamp_no_timestamp() -> None: + """Heading.latest_timestamp should be None when a task has no timestamps.""" org_text = """* TODO Task """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp is None + assert latest_timestamp is None + + +def test_get_top_tasks_imports_from_stats() -> None: + """Top tasks helper should remain available from the stats module.""" + assert get_top_tasks([], 10) == [] def test_get_top_tasks_sorted_by_timestamp() -> None: diff --git a/tests/cli_common/test_validation.py b/tests/cli_common/test_validation.py index 22a714b..6a2cf70 100644 --- a/tests/cli_common/test_validation.py +++ b/tests/cli_common/test_validation.py @@ -8,10 +8,10 @@ import pytest import typer -from org.validation import ( +from org.logic.time import parse_date_argument +from org.logic.validation import ( GlobalArgs, StatsArgs, - parse_date_argument, parse_property_filter, validate_and_parse_keys, validate_global_arguments, diff --git a/tests/color/test_color.py b/tests/color/test_color.py index 8bfc68d..9af4b58 100644 --- a/tests/color/test_color.py +++ b/tests/color/test_color.py @@ -5,7 +5,7 @@ import sys from typing import TYPE_CHECKING -from org import color +from org.tui import color if TYPE_CHECKING: @@ -47,15 +47,20 @@ def test_get_state_color_done_and_cancelled() -> None: def test_get_state_color_todo_and_unknown() -> None: - """TODO/empty states should be dim white, unknown should be yellow.""" + """TODO and empty states should be bold gray, and unknown states should be yellow.""" done_states = ["DONE"] todo_states = ["TODO"] - assert color.get_state_color("TODO", done_states, todo_states, True) == "dim white" - assert color.get_state_color("", done_states, todo_states, True) == "dim white" + assert color.get_state_color("TODO", done_states, todo_states, True) == "bold bright_black" + assert color.get_state_color("", done_states, todo_states, True) == "bold bright_black" assert color.get_state_color("BLOCKED", done_states, todo_states, True) == "bold yellow" +def test_get_state_color_suspended_is_yellow() -> None: + """SUSPENDED should use the shared yellow state style.""" + assert color.get_state_color("SUSPENDED", ["DONE"], ["TODO"], True) == "bold yellow" + + def test_get_state_color_disabled_returns_empty() -> None: """When colors are disabled, no color should be returned.""" assert color.get_state_color("DONE", ["DONE"], ["TODO"], False) == "" diff --git a/tests/commands/stats/test_stats_commands.py b/tests/commands/stats/test_stats_commands.py index 2aaea85..c86a53c 100644 --- a/tests/commands/stats/test_stats_commands.py +++ b/tests/commands/stats/test_stats_commands.py @@ -8,12 +8,11 @@ import pytest import typer -from org.analyze import AnalysisResult, Group, Tag, TimeRange from org.commands.stats import all as stats_all_command from org.commands.stats import groups as stats_groups from org.commands.stats import summary as stats_summary_command from org.commands.stats import tags as stats_tags -from org.histogram import Histogram +from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") @@ -639,10 +638,10 @@ def test_format_tasks_summary_renders_histograms() -> None: result = AnalysisResult( total_tasks=2, unique_tasks=2, - task_states=Histogram(values={"DONE": 1, "TODO": 1}), - task_categories=Histogram(values={"null": 2}), - task_priorities=Histogram(values={"null": 2}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "TODO": 1}), + task_categories=Distribution(values={"null": 2}), + task_priorities=Distribution(values={"null": 2}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -673,7 +672,7 @@ def test_format_tasks_summary_orders_task_states_by_group_alphabetically() -> No result = AnalysisResult( total_tasks=6, unique_tasks=6, - task_states=Histogram( + task_states=Distribution( values={ "ZDONE": 1, "ADONE": 1, @@ -683,9 +682,9 @@ def test_format_tasks_summary_orders_task_states_by_group_alphabetically() -> No "AAA": 1, }, ), - task_categories=Histogram(values={"null": 6}), - task_priorities=Histogram(values={"null": 6}), - task_days=Histogram(values={}), + task_categories=Distribution(values={"null": 6}), + task_priorities=Distribution(values={"null": 6}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -720,10 +719,10 @@ def test_format_tasks_summary_omits_none_state_when_absent() -> None: result = AnalysisResult( total_tasks=2, unique_tasks=2, - task_states=Histogram(values={"DONE": 2}), - task_categories=Histogram(values={"null": 2}), - task_priorities=Histogram(values={"null": 2}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 2}), + task_categories=Distribution(values={"null": 2}), + task_priorities=Distribution(values={"null": 2}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -756,10 +755,10 @@ def test_format_tasks_summary_keeps_none_state_when_present() -> None: result = AnalysisResult( total_tasks=2, unique_tasks=2, - task_states=Histogram(values={"DONE": 1, "null": 1}), - task_categories=Histogram(values={"null": 2}), - task_priorities=Histogram(values={"null": 2}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "null": 1}), + task_categories=Distribution(values={"null": 2}), + task_priorities=Distribution(values={"null": 2}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, diff --git a/tests/commands/tasks/test_tasks_capture_app.py b/tests/commands/tasks/test_tasks_capture_app.py new file mode 100644 index 0000000..7a7a534 --- /dev/null +++ b/tests/commands/tasks/test_tasks_capture_app.py @@ -0,0 +1,126 @@ +"""App tests for tasks capture.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast + +from textual.widgets import OptionList, Static, TextArea + +from org.commands.tasks import capture +from org.commands.tasks.capture.app import CaptureApp, _TemplateSelectionApp + + +if TYPE_CHECKING: + from org_parser.document import Document, Heading + + +def _make_plan() -> capture.CapturePlan: + return capture.CapturePlan( + args=capture.TasksCaptureArgs( + template_name="quick", + config="unused.yaml", + file=None, + parent=None, + set_values=None, + ), + template_name="quick", + template_content="* TODO {{title}} @{{owner}}", + document=cast("Document", object()), + parent_heading=cast("Heading | None", None), + placeholders=["title", "owner"], + values={}, + unresolved_placeholders=["title", "owner"], + ) + + +def test_capture_app_preview_updates_from_field_input() -> None: + """Editing a field should update the rendered preview.""" + + async def _run() -> None: + app = CaptureApp(_make_plan()) + async with app.run_test(): + title_input = app.screen.query_one("#field-title", TextArea) + owner_input = app.screen.query_one("#field-owner", TextArea) + assert title_input.text == "" + assert owner_input.text == "" + + title_input.load_text("Write") + app.on_text_area_changed(TextArea.Changed(title_input)) + owner_input.load_text("Jane") + app.on_text_area_changed(TextArea.Changed(owner_input)) + + assert app.field_values["title"] == "Write" + assert app.field_values["owner"] == "Jane" + assert app._preview_values()["title"] == "Write" + assert app._preview_values()["owner"] == "Jane" + + asyncio.run(_run()) + + +def test_capture_app_textarea_keeps_newlines() -> None: + """Multiline field edits should be preserved in preview values.""" + + async def _run() -> None: + app = CaptureApp(_make_plan()) + async with app.run_test(): + title_input = app.screen.query_one("#field-title", TextArea) + title_input.load_text("Line1\nLine2") + app.on_text_area_changed(TextArea.Changed(title_input)) + + assert app.field_values["title"] == "Line1\nLine2" + assert app._preview_values()["title"] == "Line1\nLine2" + + asyncio.run(_run()) + + +def test_capture_app_help_modal_shows_key_bindings() -> None: + """Question mark should open the shared help modal.""" + + async def _run() -> None: + app = CaptureApp(_make_plan()) + async with app.run_test() as pilot: + app.action_show_help() + await pilot.pause() + title_widget = app.screen_stack[-1].query_one("#help-title", Static) + rendered = cast("Any", title_widget).render() + assert getattr(rendered, "plain", str(rendered)) == "Key bindings" + + asyncio.run(_run()) + + +def test_capture_app_save_collects_form_values() -> None: + """Saving the form should return the current field values.""" + + async def _run() -> None: + app = CaptureApp(_make_plan()) + async with app.run_test(): + app.field_values["title"] = "Work" + app.field_values["owner"] = "Sam" + app.action_save_capture() + + assert app.result_values is not None + assert app.result_values["title"] == "Work" + assert app.result_values["owner"] == "Sam" + + asyncio.run(_run()) + + +def test_template_selection_app_uses_shared_keyboard_selection_modal() -> None: + """Template selection should use the shared keyboard-only modal.""" + + async def _run() -> None: + app = _TemplateSelectionApp(["later", "quick"]) + + async with app.run_test() as pilot: + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert app.selected_template_name == "quick" + + asyncio.run(_run()) diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index b2e73ec..80b6546 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -9,8 +9,8 @@ import pytest import typer -from org.commands import editor as editor_command from org.commands.tasks import edit as tasks_edit +from org.logic import edit as editor_command if TYPE_CHECKING: @@ -170,26 +170,15 @@ def test_run_tasks_edit_errors_on_non_zero_editor_exit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Edit should report a clear error when line-open fallback is declined.""" + """Edit should report a clear error when opening the file in the editor fails.""" source = tmp_path / "tasks.org" source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") - prompts: list[str] = [] - - def _confirm(prompt: str) -> bool: - prompts.append(prompt) - return False - monkeypatch.setenv("EDITOR", "sh -c 'exit 7'") - monkeypatch.setattr(editor_command, "_confirm_temporary_file_edit", _confirm) with pytest.raises(typer.BadParameter, match="Editor failed to open"): tasks_edit.run_tasks_edit(make_edit_args([str(source)])) - assert prompts == [ - "Opening the original file at the task line failed. Edit a temporary copy instead?", - ] - def test_run_tasks_edit_skips_save_when_content_is_unchanged( tmp_path: Path, @@ -246,70 +235,34 @@ def _edit_file(filename: str, line: int) -> int: assert updated_heading.title_text.strip() == "Updated" -def test_edit_heading_subtree_prompts_for_temp_file_when_document_has_no_filename( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Helper should prompt before using temp-file editing without a backing file.""" +def test_edit_heading_subtree_errors_when_document_has_no_filename() -> None: + """Helper should reject editing tasks without a backing file.""" root = org_parser.loads("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n") heading = root.heading_by_id("task-1") assert heading is not None - prompts: list[str] = [] - - def _confirm(prompt: str) -> bool: - prompts.append(prompt) - return True - - monkeypatch.setattr(editor_command, "_confirm_temporary_file_edit", _confirm) - monkeypatch.setattr( - editor_command, - "edit_text_in_external_editor", - lambda _text: "* TODO Updated\n:PROPERTIES:\n:ID: task-1\n:END:\n", - ) - - result = editor_command.edit_heading_subtree_in_external_editor(heading) - - assert prompts == ["This task is not associated with a file. Edit a temporary copy instead?"] - assert result.changed is True - assert heading.title_text.strip() == "Keep" + with pytest.raises( + typer.BadParameter, + match=r"This task is not associated with a file and cannot be edited\.", + ): + editor_command.edit_heading_subtree_in_external_editor(heading) -def test_edit_heading_subtree_falls_back_to_temp_file_after_line_open_failure( +def test_edit_heading_subtree_errors_after_line_open_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Helper should prompt and write back edited temp-file content after line-open failure.""" + """Helper should error when opening the file in the editor fails.""" source = tmp_path / "tasks.org" source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") root = org_parser.load(str(source)) heading = root.heading_by_id("task-1") assert heading is not None - prompts: list[str] = [] - - def _confirm(prompt: str) -> bool: - prompts.append(prompt) - return True - monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) - monkeypatch.setattr( - editor_command, - "_confirm_temporary_file_edit", - _confirm, - ) - monkeypatch.setattr( - editor_command, - "edit_text_in_external_editor", - lambda _text: "* TODO Updated\n:PROPERTIES:\n:ID: task-1\n:END:\n", - ) - - result = editor_command.edit_heading_subtree_in_external_editor(heading) - assert prompts == [ - "Opening the original file at the task line failed. Edit a temporary copy instead?", - ] - assert result.changed is True - assert source.read_text(encoding="utf-8").startswith("* TODO Updated\n") + with pytest.raises(typer.BadParameter, match="Editor failed to open"): + editor_command.edit_heading_subtree_in_external_editor(heading) def test_edit_heading_subtree_rejects_invalid_full_document_content( @@ -323,12 +276,13 @@ def test_edit_heading_subtree_rejects_invalid_full_document_content( heading = root.heading_by_id("task-1") assert heading is not None - monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) - monkeypatch.setattr(editor_command, "_confirm_temporary_file_edit", lambda _prompt: True) - monkeypatch.setattr(editor_command, "edit_text_in_external_editor", lambda _text: "***") + def _edit_file(filename: str, _line: int) -> int: + Path(filename).write_text("***", encoding="utf-8") + return 0 + + monkeypatch.setattr(editor_command, "_run_editor_at_line", _edit_file) monkeypatch.setattr( - org_parser, - "loads", + "org.pipeline.load.loads", lambda _text: (_ for _ in ()).throw(ValueError("boom")), ) diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 0dd4d88..acf8ddb 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -7,28 +7,23 @@ import sys from contextlib import contextmanager from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING import click import pytest import typer from rich.console import Console -from org import config as config_module -from org.commands import archive as archive_command -from org.commands import editor as editor_command -from org.commands.interactive_common import ( - KeypressEvent, - handle_active_prompt_event, - heading_locator, -) -from org.commands.search_common import filter_nodes_by_search +import org.config.app from org.commands.tasks import capture as capture_command +from org.commands.tasks.list import actions from org.commands.tasks.list import command as tasks_list -from org.commands.tasks.list import events as tasks_list_events -from org.commands.tasks.list import layout as tasks_list_layout -from org.histogram import visual_len -from org.output_format import OutputFormat, OutputFormatError +from org.logic.archive import ArchiveLocation, ArchiveMoveResult, ArchiveTarget +from org.logic.edit import DocumentEditResult +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import heading_locator +from org.pipeline.format import OutputFormat, OutputFormatError +from org.tui.bits import visual_len from tests.conftest import node_from_org @@ -37,7 +32,6 @@ from pathlib import Path from org_parser.document import Document, Heading - from rich.live import Live FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") @@ -81,6 +75,7 @@ def make_list_args(files: list[str], **overrides: object) -> tasks_list.ListArgs out=OutputFormat.ORG, out_theme="github-dark", pandoc_args=None, + noninteractive=True, ) for key, value in overrides.items(): setattr(args, key, value) @@ -104,8 +99,8 @@ def _make_session( nodes: list[Heading], *, color_enabled: bool = False, -) -> tasks_list_events.TasksListSession: - return tasks_list_events.create_tasks_list_session( +) -> actions.TasksListSession: + return actions.create_tasks_list_session( make_list_args([]), _make_session_data(nodes, color_enabled=color_enabled), ) @@ -301,8 +296,6 @@ def test_run_tasks_list_markdown_pandoc_error_is_usage_error( """Markdown formatter failures should be surfaced as CLI usage errors.""" class _FailingFormatter: - include_filenames = False - def prepare(self, data: object) -> object: del data raise OutputFormatError("pandoc missing") @@ -620,11 +613,10 @@ def test_run_tasks_list_uses_interactive_mode_when_tty_and_not_blocked( ) -> None: """TTY execution should enter interactive mode when no blocking switches are explicit.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") - args = make_list_args([fixture_path]) + args = make_list_args([fixture_path], noninteractive=False) called = {"interactive": False} def _fake_interactive( - _console: Console, _args: tasks_list.ListArgs, data: object, ) -> None: @@ -650,6 +642,15 @@ def _fake_interactive( assert called["interactive"] +def test_run_tasks_list_requires_tty_without_explicit_static_mode() -> None: + """Default tasks list mode should fail without a TTY.""" + fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = make_list_args([fixture_path], noninteractive=False) + + with pytest.raises(click.UsageError, match="requires a TTY unless --details or --out"): + tasks_list.run_tasks_list(args) + + def test_run_tasks_list_details_switch_blocks_interactive_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -755,121 +756,23 @@ def test_filter_nodes_by_search_handles_non_string_property_values() -> None: assert [item.title_text for item in matches] == ["Parent"] -def test_handle_interactive_key_mouse_wheel_moves_selection() -> None: - """Mouse wheel key tokens should move interactive selection.""" - nodes = node_from_org("* TODO A\n* TODO B\n") - session = _make_session(nodes) - assert tasks_list_events._handle_keypress_event(session, "WHEEL-DOWN") is True - assert session.selected_index == 1 - assert tasks_list_events._handle_keypress_event(session, "WHEEL-UP") is True - assert session.selected_index == 0 - - -def test_interactive_rows_counter_tracks_selected_row_while_scrolling() -> None: - """Rows counter should follow selected row index in both directions.""" - nodes = node_from_org("* TODO A\n* TODO B\n* TODO C\n") - session = _make_session(nodes) - session.selected_index = 1 - total_rows = len(session.visible_nodes) - - down_capture = StringIO() - down_console = Console( - file=down_capture, - force_terminal=False, - width=80, - height=8, - no_color=True, - ) - down_console.print(tasks_list_layout.interactive_tasks_list_renderable(down_console, session)) - assert f"Rows 2/{total_rows}" in down_capture.getvalue() - assert "Type ? for help" in down_capture.getvalue() - - session.selected_index = 0 - up_capture = StringIO() - up_console = Console(file=up_capture, force_terminal=False, width=80, height=8, no_color=True) - up_console.print(tasks_list_layout.interactive_tasks_list_renderable(up_console, session)) - assert f"Rows 1/{total_rows}" in up_capture.getvalue() - assert "Type ? for help" in up_capture.getvalue() - - -def test_handle_interactive_key_question_toggles_help_modal() -> None: - """Question mark should open help modal and next key should close it.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - - assert tasks_list_events._handle_keypress_event(session, "?") is True - assert session.show_help_modal is True - - assert tasks_list_events._handle_keypress_event(session, "ENTER") is True - assert session.show_help_modal is False - - -def test_interactive_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel in task list.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - session.show_help_modal = True - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=100, height=12, no_color=True) - - console.print(tasks_list_layout.interactive_tasks_list_renderable(console, session)) - output = buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - -def test_interactive_task_row_reuses_static_line_format_with_level_prefix_and_tag_alignment() -> ( - None -): - """Interactive rows should keep static format including stars and right-aligned tags.""" - nodes = node_from_org("* TODO Task title :alpha:beta:\n") - session = _make_session(nodes) - - row = tasks_list_layout.build_task_row_text(nodes[0], session, line_width=80) - - assert "* TODO" in row.plain - assert row.plain.rstrip().endswith(":alpha:beta:") - assert visual_len(row.plain) == 80 - - -def test_interactive_task_row_parses_color_markup_without_literal_tags() -> None: - """Interactive colored row should render markup instead of showing markup literals.""" - nodes = node_from_org("* DONE Task title\n") - session = _make_session(nodes, color_enabled=True) - - row = tasks_list_layout.build_task_row_text(nodes[0], session, line_width=80) - - assert "[dim white]" not in row.plain - assert "[/]" not in row.plain - - -def test_handle_interactive_key_unsupported_sets_status_and_continues() -> None: - """Unsupported key should set status message without exiting interactive mode.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - key = "UNSUPPORTED-ESC:1b5b3939397e" - assert tasks_list_events._handle_keypress_event(session, key) is True - assert session.status_message == f"Unsupported key: {key}" - - -def test_handle_interactive_key_enter_edits_selected_task( +def test_edit_selected_task_in_external_editor_sets_no_changes_status( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enter should trigger external-editor task editing on selected row.""" + """Editing the selected task should report when nothing changed.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=False) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=False) - monkeypatch.setattr(tasks_list_events, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) - assert tasks_list_events._handle_keypress_event(session, "ENTER") is True + actions.edit_selected_task_in_external_editor(session) assert session.status_message == "No changes." -def test_handle_interactive_key_enter_reloads_using_selected_node_identity_after_changed_edit( +def test_edit_selected_task_in_external_editor_reloads_using_selected_node_identity( monkeypatch: pytest.MonkeyPatch, ) -> None: """Changed edit should reload using the originally selected session node identity.""" @@ -877,13 +780,13 @@ def test_handle_interactive_key_enter_reloads_using_selected_node_identity_after session = _make_session(nodes) source_node = nodes[0] - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=True) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=True) reloaded_identity = None def _fake_reload( - current_session: tasks_list_events.TasksListSession, + current_session: actions.TasksListSession, identity: object, ) -> bool: nonlocal reloaded_identity @@ -891,69 +794,69 @@ def _fake_reload( reloaded_identity = identity return True - monkeypatch.setattr(tasks_list_events, "edit_heading_subtree_in_external_editor", _fake_edit) - monkeypatch.setattr(tasks_list_events, "reload_session_nodes", _fake_reload) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "reload_session_nodes", _fake_reload) - assert tasks_list_events._handle_keypress_event(session, "ENTER") is True + actions.edit_selected_task_in_external_editor(session) assert reloaded_identity == heading_locator(source_node) assert session.status_message == "Task updated" -def test_handle_interactive_key_dollar_archives_selected_task( +def test_archive_selected_task_archives_selected_task( monkeypatch: pytest.MonkeyPatch, ) -> None: - """$ should archive selected task using shared archive helper.""" + """Archiving should use the shared archive helper and refresh status.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) def _fake_archive( heading: Heading, _cache: dict[str, Document], - ) -> archive_command.ArchiveMoveResult: - location = archive_command.ArchiveLocation( + ) -> ArchiveMoveResult: + location = ArchiveLocation( raw_spec="%s_archive::", file_path="tasks.org_archive", parent_title=None, ) - target = archive_command.ArchiveTarget( + target = ArchiveTarget( location=location, document=heading.document, parent_heading=None, ) - return archive_command.ArchiveMoveResult( + return ArchiveMoveResult( heading=heading, target=target, source_document=heading.document, destination_document=heading.document, ) - monkeypatch.setattr(tasks_list_events, "archive_heading_subtree_and_save", _fake_archive) + monkeypatch.setattr(actions, "archive_heading_subtree_and_save", _fake_archive) monkeypatch.setattr( - tasks_list_events, + actions, "reload_session_nodes", lambda _session, _identity: True, ) - assert tasks_list_events._handle_keypress_event(session, "$") is True + actions.archive_selected_task(session) assert session.status_message == "Task archived" -def test_handle_interactive_key_a_captures_task_and_reloads( +def test_apply_capture_task_captures_task_and_reloads( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should prompt for template then capture and reload list session.""" + """Capturing should reload the list session with the captured task identity.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) captured_node = node_from_org("* TODO Captured\n")[0] reload_args: dict[str, object] = {} monkeypatch.setattr( - config_module, + org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, ) monkeypatch.setattr( - tasks_list_events, + actions, "capture_task", lambda _args: capture_command.TasksCaptureResult( template_name="quick", @@ -963,207 +866,51 @@ def test_handle_interactive_key_a_captures_task_and_reloads( ) def _fake_reload( - current_session: tasks_list_events.TasksListSession, - preserve_identity: tuple[str, str, str, int | None] | None, + current_session: actions.TasksListSession, + preserve_identity: object, ) -> bool: reload_args["session"] = current_session reload_args["identity"] = preserve_identity return True - monkeypatch.setattr(tasks_list_events, "reload_session_nodes", _fake_reload) + monkeypatch.setattr(actions, "reload_session_nodes", _fake_reload) - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False + actions.apply_capture_task(session, "quick") assert reload_args["session"] is session assert reload_args["identity"] == heading_locator(captured_node) assert session.status_message == "Task captured" -def test_handle_interactive_key_a_capture_blank_input_cancels( +def test_apply_capture_task_reports_keyboard_interrupt_as_cancelled( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should close prompt with cancel status on blank template input.""" + """Capture should report keyboard interruption as cancellation.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert session.status_message == "Capture cancelled" + def _raise_interrupt(_args: object) -> object: + raise KeyboardInterrupt + monkeypatch.setattr(actions, "capture_task", _raise_interrupt) -def test_handle_interactive_key_a_capture_invalid_shortcut_keeps_prompt_open( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should keep capture prompt open for invalid template shortcuts.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "99" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is True - assert session.status_message == "Invalid capture template shortcut" + actions.apply_capture_task(session, "quick") + assert session.status_message == "Capture cancelled" -def test_handle_interactive_key_a_without_templates_reports_error( +def test_apply_capture_task_reports_bad_parameter_error( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should report a clear status when no capture templates are configured.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {}) - - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is None - assert session.status_message == "No capture templates configured" - - -def test_prompt_submit_capture_stops_and_restarts_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Prompt submission should pause live rendering for capture action.""" + """Capture should surface `BadParameter` errors in session status.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - tasks_list_events._handle_keypress_event(session, "a") - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - - captured_node = node_from_org("* TODO Captured\n")[0] - monkeypatch.setattr( - tasks_list_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr( - tasks_list_events, - "reload_session_nodes", - lambda _session, _identity: True, - ) - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - controller = cast("Any", _InputControllerStub()) - - def _run_prompt_external(callback: object) -> None: - controller.suspend_live(live) - try: - cast("Any", callback)() - finally: - controller.resume_live(live) - - session.run_external = _run_prompt_external - - consumed = handle_active_prompt_event(session, KeypressEvent("ENTER")) - live.update( - tasks_list_layout.interactive_tasks_list_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert events == ["input-stop", "stop", "start", "input-start", "update"] - - -def test_search_prompt_live_updates_and_escape_reverts_search() -> None: - """Search prompt should live-update results and ESC should restore prior filter.""" - nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") - session = _make_session(nodes) - assert tasks_list_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") + def _raise_bad_parameter(_args: object) -> object: + raise typer.BadParameter("Capture failed") - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - consumed = handle_active_prompt_event(session, KeypressEvent("b")) - live.update( - tasks_list_layout.interactive_tasks_list_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert [node.title_text for node in session.visible_nodes] == ["Beta"] - assert session.search_text == "b" - assert session.status_message == "1 matches" - - consumed = handle_active_prompt_event(session, KeypressEvent("ESC")) - live.update( - tasks_list_layout.interactive_tasks_list_renderable(live.console, session), - refresh=True, - ) + monkeypatch.setattr(actions, "capture_task", _raise_bad_parameter) - assert consumed is True - assert [node.title_text for node in session.visible_nodes] == ["Alpha", "Beta"] - assert session.search_text == "" - assert session.status_message == "Search cancelled" - assert events == ["update", "update"] + actions.apply_capture_task(session, "quick") + assert session.status_message == "Capture failed" def test_apply_state_change_appends_repeat_transition(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1172,12 +919,12 @@ def test_apply_state_change_appends_repeat_transition(monkeypatch: pytest.Monkey node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_state_change_with_value(session, "DONE") + actions.apply_state_change_with_value(session, "DONE") assert node.todo == "DONE" assert node.repeats @@ -1191,12 +938,12 @@ def test_apply_state_change_advances_repeater_timestamp(monkeypatch: pytest.Monk node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_state_change_with_value(session, "DONE") + actions.apply_state_change_with_value(session, "DONE") assert node.scheduled is not None assert str(node.scheduled).startswith("<2025-01-22") @@ -1208,12 +955,12 @@ def test_apply_scheduled_edit_updates_scheduled(monkeypatch: pytest.MonkeyPatch) node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_planning_timestamp_edit( + actions.apply_planning_timestamp_edit( session, field="scheduled", raw_timestamp="<2025-01-20 Mon>", @@ -1229,12 +976,12 @@ def test_apply_closed_edit_updates_closed(monkeypatch: pytest.MonkeyPatch) -> No node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_planning_timestamp_edit( + actions.apply_planning_timestamp_edit( session, field="closed", raw_timestamp="[2025-01-20 Mon 09:00]", @@ -1254,8 +1001,8 @@ def test_persist_and_reload_selected_reports_save_failures( def _raise_save(_document: object) -> None: raise typer.BadParameter("Permission denied for 'dummy.org'") - monkeypatch.setattr(tasks_list_events, "save_document_changes", _raise_save) + monkeypatch.setattr(actions, "save_document_changes", _raise_save) - tasks_list_events.persist_and_reload_selected(session, nodes[0], "updated") + actions.persist_and_reload_selected(session, nodes[0], "updated") assert session.status_message == "Permission denied for 'dummy.org'" diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py new file mode 100644 index 0000000..4cad6ef --- /dev/null +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -0,0 +1,209 @@ +"""App tests for the tasks list command.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast + +from textual.widgets import Input, OptionList, Static + +import org.config.app +from org.commands.tasks.list import command as tasks_list +from org.commands.tasks.list.app import TasksListApp +from tests.commands.tasks.test_tasks_list import make_list_args +from tests.conftest import node_from_org + + +if TYPE_CHECKING: + import pytest + from org_parser.document import Heading + + +def _make_session_data(nodes: list[Heading]) -> tasks_list._TasksListSessionData: + return tasks_list._TasksListSessionData( + nodes=nodes, + todo_states=["TODO"], + done_states=["DONE"], + color_enabled=False, + ) + + +def test_tasks_list_app_navigation_and_help_forwarding() -> None: + """Tasks list app should support navigation and help key forwarding.""" + + async def _run() -> None: + nodes = node_from_org("* TODO A\n* TODO B\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("down") + assert app.session.selected_index == 1 + + await pilot.press("up") + assert app.session.selected_index == 0 + + app.action_show_help() + await pilot.pause() + + help_title = app.screen.query_one("#help-title", Static) + help_content = app.screen.query_one("#help-content", Static) + title_rendered = cast("Any", help_title).render() + rendered = cast("Any", help_content).render() + title_text = getattr(title_rendered, "plain", str(title_rendered)) + plain_text = getattr(rendered, "plain", str(rendered)) + assert title_text == "Key bindings" + assert "Key bindings:" not in plain_text + assert "Esc/q" in plain_text + + await pilot.press("down") + await pilot.pause() + + assert app.session.selected_index == 1 + assert app.screen.query_one("#tasks-body", Static) is not None + + asyncio.run(_run()) + + +def test_tasks_list_app_search_prompt_filters_results_live() -> None: + """Search prompt should update visible tasks through Textual input flow.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\nBody\n* TODO Beta\nOther\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + app.action_prompt_search() + await pilot.pause() + + prompt_input = app.screen.query_one(Input) + assert prompt_input.value == "" + + prompt_input.value = "alpha" + prompt_screen = cast("Any", app.screen_stack[-1]) + prompt_screen.on_input_changed(Input.Changed(prompt_input, "alpha")) + assert app.session.search_text == "alpha" + assert [node.title_text for node in app.session.visible_nodes] == ["Alpha"] + + app.screen_stack[-1].dismiss("alpha") + await pilot.pause() + assert app.session.search_text == "alpha" + assert [node.title_text for node in app.session.visible_nodes] == ["Alpha"] + + asyncio.run(_run()) + + +def test_tasks_list_app_help_prompt_and_separator_widgets() -> None: + """Search prompt label and footer separators should render correctly.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + assert app.screen.query_one("#tasks-footer-rule", Static) is not None + + app.action_prompt_search() + await pilot.pause() + + prompt_label = app.screen.query_one("#prompt-label", Static) + rendered = cast("Any", prompt_label).render() + plain_text = getattr(rendered, "plain", str(rendered)) + assert "Search text" in plain_text + + asyncio.run(_run()) + + +def test_tasks_list_app_escape_cancels_prompt_without_exiting() -> None: + """Escape should dismiss the prompt modal without triggering app quit.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + app.action_prompt_search() + await pilot.pause() + assert app.screen.query_one("#prompt-label", Static) is not None + + await pilot.press("escape") + await pilot.pause() + + assert app.is_running + assert app.session.status_message == "Search cancelled" + assert app.screen.query_one("#tasks-body", Static) is not None + + asyncio.run(_run()) + + +def test_tasks_list_app_capture_selection_uses_keyboard(monkeypatch: pytest.MonkeyPatch) -> None: + """Capture selection should be fully keyboard navigable.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + called: list[str] = [] + monkeypatch.setattr(org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {}, "later": {}}) + monkeypatch.setattr( + "org.commands.tasks.list.actions.apply_capture_task", + lambda _session, template_name: called.append(template_name), + ) + + async with app.run_test() as pilot: + app.action_prompt_capture() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert called == ["quick"] + + asyncio.run(_run()) + + +def test_tasks_list_app_state_selection_uses_keyboard() -> None: + """State selection should be fully keyboard navigable.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + app.action_prompt_state() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("enter") + await pilot.pause() + + assert nodes[0].todo == "TODO" + + asyncio.run(_run()) + + +def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive tasks list path should delegate to the Textual app runner.""" + nodes = node_from_org("* TODO Alpha\n") + data = _make_session_data(nodes) + called = {"value": False} + + def _fake_run( + args: tasks_list.ListArgs, + session_data: tasks_list._TasksListSessionData, + ) -> None: + called["value"] = True + assert args.files == [] + assert session_data.nodes == nodes + + monkeypatch.setattr("org.commands.tasks.list.command.run_tasks_list_app", _fake_run) + + tasks_list._run_tasks_list_interactive( + make_list_args([]), + data, + ) + + assert called["value"] is True diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py new file mode 100644 index 0000000..0dd050e --- /dev/null +++ b/tests/commands/test_agenda_app.py @@ -0,0 +1,324 @@ +"""App tests for the agenda command.""" + +from __future__ import annotations + +import asyncio +import os +import sys +from datetime import datetime +from typing import TYPE_CHECKING, Any, cast + +import org_parser +from textual.widgets import Input, OptionList, Static + +import org.config.app +from org.commands.agenda import actions, ui +from org.commands.agenda import command as agenda_command +from org.commands.agenda.app import AgendaApp, AgendaViewport +from org.commands.agenda.views import ( + AgendaViewContext, + _compile_view_section_specs, + _fallback_agenda_view, +) +from org.commands.tasks import capture as capture_command +from tests.commands.test_agenda_command import _make_args + + +if TYPE_CHECKING: + import pytest + from org_parser.document import Heading + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") + + +def _make_app( + args: agenda_command.AgendaArgs, + nodes: list[Heading], + *, + color_enabled: bool = False, +) -> AgendaApp: + view = _fallback_agenda_view() + view_ctx = AgendaViewContext(section_specs=_compile_view_section_specs(view), name=view.name) + render = ui.RenderContext( + color_enabled=color_enabled, + done_states=["DONE"], + todo_states=["TODO"], + ) + return AgendaApp(actions.create_agenda_session(args, nodes, render, view_ctx)) + + +def _body_lines(app: AgendaApp) -> list[str]: + lines: list[str] = [] + for widget in app._body_widget().row_widgets(): + rendered = cast("Any", widget.render()) + lines.append(getattr(rendered, "plain", str(rendered))) + return lines + + +def test_agenda_app_navigation_help_and_separators() -> None: + """Agenda app should support navigation, help forwarding, and separator widgets.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test() as pilot: + assert app.screen.query_one("#agenda-header-rule", Static) is not None + assert app.screen.query_one("#agenda-footer-rule", Static) is not None + + await pilot.press("down") + assert app.session.selected_row_index == 1 + + await pilot.press("up") + assert app.session.selected_row_index == 0 + + app.action_show_help() + await pilot.pause() + + assert app.screen.query_one("#help-content", Static) is not None + await pilot.press("down") + await pilot.pause() + + assert app.session.selected_row_index == 1 + assert app.screen.query_one("#agenda-body", AgendaViewport) is not None + await pilot.press("right") + await pilot.pause() + + header = cast("Any", app.query_one("#agenda-header", Static).render()) + assert getattr(header, "plain", str(header)) == "Thursday 2025-01-16" + + asyncio.run(_run()) + + +def test_agenda_app_search_prompt_filters_results_live_and_escape_restores() -> None: + """Search modal should live-filter rows and escape should restore the previous search.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test() as pilot: + app.action_prompt_search() + await pilot.pause() + + prompt_input = app.screen.query_one(Input) + prompt_input.value = "focus" + prompt_screen = cast("Any", app.screen_stack[-1]) + prompt_screen.on_input_changed(Input.Changed(prompt_input, "focus")) + + assert app.session.search_text == "focus" + visible_titles = [ + row.node.title_text.strip() + for day_model in app.session.day_models + for row in day_model.rows + if row.kind == "task" and row.node is not None + ] + assert visible_titles == ["Timed agenda task"] + + await pilot.press("escape") + await pilot.pause() + + assert app.is_running + assert app.session.search_text == "" + assert app.session.status_message == "Search cancelled" + + asyncio.run(_run()) + + +def test_agenda_app_timer_refresh_updates_now_marker(monkeypatch: pytest.MonkeyPatch) -> None: + """Minute refresh should update the rendered NOW marker in the viewport.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + clock = {"value": datetime(2025, 1, 15, 10, 0)} + monkeypatch.setattr(actions, "local_now", lambda: clock["value"]) + monkeypatch.setattr(ui, "local_now", lambda: clock["value"]) + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test(): + assert any("10:00" in line and "NOW" in line for line in _body_lines(app)) + clock["value"] = datetime(2025, 1, 15, 10, 1) + app._refresh_for_clock_tick() + assert any("10:01" in line and "NOW" in line for line in _body_lines(app)) + + asyncio.run(_run()) + + +def test_agenda_app_enter_on_non_task_row_reports_status() -> None: + """Enter on a non-task row should keep the app running and set a status message.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + app.session.selected_row_index = next( + index + for index, (day_index, row_index) in enumerate(app.session.row_locations) + if app.session.day_models[day_index].rows[row_index].kind == "hour_marker" + ) + + async with app.run_test(): + app.action_edit_selected() + assert app.session.status_message == "Action available only on task rows" + assert app.is_running + + asyncio.run(_run()) + + +def test_agenda_app_capture_from_timed_row_schedules_task( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Capture on a timed agenda row should schedule the created task for that row time.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) + monkeypatch.setattr( + org.config.app, + "CONFIG_CAPTURE_TEMPLATES", + {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, + ) + + def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: + return capture_command.TasksCaptureResult( + template_name="quick", + heading=captured_node, + document=captured_node.document, + ) + + monkeypatch.setattr(actions, "capture_task", _fake_capture) + monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) + + app = _make_app(args, list(org_parser.load(fixture_path))) + monkeypatch.setattr(actions, "refresh_session", lambda _session, _identity: None) + app.session.selected_row_index = next( + index + for index, (day_index, row_index) in enumerate(app.session.row_locations) + if ( + app.session.day_models[day_index].rows[row_index].kind == "task" + and ":" in app.session.day_models[day_index].rows[row_index].time_text + ) + ) + selected_row = actions.selected_row(app.session) + assert selected_row is not None + expected_timestamp = f"<2025-01-15 Wed {selected_row.time_text}>" + + async with app.run_test() as pilot: + app.action_prompt_capture() + await pilot.pause() + assert app.screen.query_one(OptionList).has_focus + await pilot.press("enter") + await pilot.pause() + + assert str(captured_node.scheduled) == expected_timestamp + assert ( + app.session.status_message + == f"Task captured and scheduled for {expected_timestamp}" + ) + + asyncio.run(_run()) + + +def test_agenda_app_state_selection_uses_keyboard(monkeypatch: pytest.MonkeyPatch) -> None: + """State selection should be fully keyboard navigable.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + calls: list[str] = [] + monkeypatch.setattr(actions, "can_activate_agenda_state_prompt", lambda _session: None) + monkeypatch.setattr( + actions, + "state_choices_for_selected_row", + lambda _session: ["TODO", "DONE"], + ) + monkeypatch.setattr( + actions, + "apply_state_change_with_value", + lambda _session, state: calls.append(state), + ) + + async with app.run_test() as pilot: + app.action_prompt_state() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("enter") + await pilot.pause() + + assert calls == ["TODO"] + + asyncio.run(_run()) + + +def test_agenda_app_refile_selection_uses_loaded_files_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Refile selection should submit one of the loaded files via keyboard selection.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + second_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = _make_args([fixture_path, second_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + destinations: list[str] = [] + app.session.selected_row_index = next( + index + for index, (day_index, row_index) in enumerate(app.session.row_locations) + if app.session.day_models[day_index].rows[row_index].kind == "task" + ) + monkeypatch.setattr( + actions, + "apply_refile_with_value", + lambda _session, destination: destinations.append(destination), + ) + + async with app.run_test() as pilot: + app.action_prompt_refile() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert destinations == [second_path] + + asyncio.run(_run()) + + +def test_run_agenda_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive agenda path should delegate to the Textual app runner.""" + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + called = {"value": False} + + def _fake_run( + passed_args: agenda_command.AgendaArgs, + nodes: list[Heading], + render: ui.RenderContext, + view_ctx: AgendaViewContext, + ) -> None: + called["value"] = True + assert passed_args.files == [fixture_path] + assert nodes + assert render.todo_states == ["TODO"] + assert view_ctx.name == "default" + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("org.commands.agenda.command.run_agenda_app", _fake_run) + + agenda_command.run_agenda(args) + + assert called["value"] is True diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index fbf395e..ace1204 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -6,7 +6,7 @@ import sys from datetime import datetime, timedelta from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast import org_parser import pytest @@ -14,35 +14,25 @@ from org_parser.time import Timestamp from rich.console import Console -from org import config as config_module -from org.commands import archive as archive_command -from org.commands import editor as editor_command +import org.config.app +import org.logic.time +from org.commands.agenda import actions, ui from org.commands.agenda import command as agenda_command -from org.commands.agenda import events as agenda_events -from org.commands.agenda import layout as agenda_layout from org.commands.agenda.views import ( AgendaViewContext, _compile_view_section_specs, _fallback_agenda_view, + resolve_view_context, ) -from org.commands.interactive_common import ( - InputEvent, - KeypressEvent, - TimeoutEvent, - advance_timestamp_by_repeater, - decode_escape_sequence, - detail_org_block, - handle_active_prompt_event, - heading_locator, - local_now, -) -from org.commands.tasks import capture as capture_command from org.commands.tasks.common import duration_to_org_text, parse_clock_duration +from org.logic.tasks import detail_org_block, heading_locator +from org.logic.time import advance_timestamp_by_repeater, local_now +from org.pipeline.load import load_and_process_data +from org.tui.bits import setup_output if TYPE_CHECKING: from org_parser.document import Document, Heading - from rich.live import Live FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") @@ -97,7 +87,7 @@ def _make_args(files: list[str], **overrides: object) -> agenda_command.AgendaAr def _visible_agenda_task_titles(session: object) -> list[str]: """Return visible task titles from current interactive agenda rows.""" titles: list[str] = [] - typed_session = cast("agenda_events.AgendaSession", session) + typed_session = cast("actions.AgendaSession", session) for day_model in typed_session.day_models: titles.extend( row.node.title_text.strip() @@ -114,30 +104,58 @@ def _make_session( done_states: list[str] | None = None, todo_states: list[str] | None = None, color_enabled: bool = False, -) -> agenda_events.AgendaSession: +) -> actions.AgendaSession: view = _fallback_agenda_view() view_ctx = AgendaViewContext(section_specs=_compile_view_section_specs(view), name=view.name) - render = agenda_layout.RenderContext( + render = ui.RenderContext( color_enabled=color_enabled, done_states=["DONE"] if done_states is None else done_states, todo_states=["TODO"] if todo_states is None else todo_states, ) - return agenda_events.create_agenda_session(args, nodes, render, view_ctx) + return actions.create_agenda_session(args, nodes, render, view_ctx) def _pin_agenda_now(monkeypatch: pytest.MonkeyPatch) -> datetime: """Pin agenda command current time for deterministic date-sensitive tests.""" pinned_now = datetime(2025, 1, 15, 12, 0) - monkeypatch.setattr(agenda_command, "local_now", lambda: pinned_now) - monkeypatch.setattr(agenda_events, "local_now", lambda: pinned_now) - monkeypatch.setattr(agenda_layout, "local_now", lambda: pinned_now) + monkeypatch.setattr(actions, "local_now", lambda: pinned_now) + monkeypatch.setattr(ui, "local_now", lambda: pinned_now) + monkeypatch.setattr("org.logic.time.local_now", lambda: pinned_now) return pinned_now -def test_run_agenda_renders_expected_sections( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def _render_agenda_output(args: agenda_command.AgendaArgs) -> str: + """Render agenda output through shared UI helpers for rendering tests.""" + color_enabled = setup_output(args) + args.max_results = agenda_command._resolve_tasks_limit(args.max_results) + ui.resolve_agenda_start_date(args.date) + console_output = StringIO() + console = Console( + file=console_output, + width=args.width or 140, + height=1000, + no_color=not color_enabled, + force_terminal=color_enabled, + ) + nodes, todo_states, done_states = load_and_process_data(args) + ui.render_agenda( + console, + ui.AgendaRenderInput( + args=args, + nodes=nodes, + now=org.logic.time.local_now(), + render=ui.RenderContext( + color_enabled=color_enabled, + done_states=done_states, + todo_states=todo_states, + ), + ), + resolve_view_context(args), + ) + return console_output.getvalue() + + +def test_run_agenda_renders_expected_sections(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should render the current view-based agenda for the selected day.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") @@ -148,8 +166,7 @@ def test_run_agenda_renders_expected_sections( "argv", ["org", "agenda", "--date", "2025-01-15", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) plain_output = output.replace("…", "") assert "2025-01-15" in plain_output @@ -166,7 +183,6 @@ def test_run_agenda_renders_expected_sections( def test_run_agenda_no_completed_hides_completed_and_repeats( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Agenda should hide completed states and repeat completions with --no-completed.""" @@ -174,47 +190,37 @@ def test_run_agenda_no_completed_hides_completed_and_repeats( args = _make_args([fixture_path], date="2025-01-15", no_completed=True) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--no-completed"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeated completion on day" not in output assert "Completed one-off task" not in output -def test_run_agenda_no_overdue_hides_overdue_sections( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_no_overdue_hides_overdue_sections(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should hide overdue sections and rows with --no-overdue.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15", no_overdue=True) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--no-overdue"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Overdue scheduled task" not in output assert "Overdue deadline task" not in output -def test_run_agenda_no_upcoming_hides_upcoming_section( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_no_upcoming_hides_upcoming_section(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should hide upcoming deadline section with --no-upcoming.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15", no_upcoming=True) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--no-upcoming"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Upcoming deadlines (30d)" not in output assert "Upcoming deadline task" not in output def test_run_agenda_shows_future_repeated_scheduled_by_default( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -225,15 +231,13 @@ def test_run_agenda_shows_future_repeated_scheduled_by_default( args = _make_args([fixture_path], date="2025-01-17") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-17", fixture_path]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeat scheduled" in output assert "09:30" in output def test_run_agenda_shows_future_repeated_deadline_by_default( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -244,15 +248,13 @@ def test_run_agenda_shows_future_repeated_deadline_by_default( args = _make_args([fixture_path], date="2025-01-17") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-17", fixture_path]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeat deadline" in output assert "13:15" in output def test_run_agenda_no_future_repeats_hides_projected_repeats( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -267,40 +269,31 @@ def test_run_agenda_no_future_repeats_hides_projected_repeats( "argv", ["org", "agenda", "--date", "2025-01-16", "--no-future-repeats", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeat scheduled" not in output -def test_run_agenda_days_renders_multiple_day_headers( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_days_renders_multiple_day_headers(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should render one weekday/date header per day in the requested range.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15", days=2) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--days", "2"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) plain_output = output.replace("…", "") assert "Wednesday 2025-01-15" in plain_output assert "Thursday 2025-01-16" in plain_output -def test_run_agenda_single_day_default_shows_day_header( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_single_day_default_shows_day_header(monkeypatch: pytest.MonkeyPatch) -> None: """Single-day agenda should render a weekday/date header row.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path]) monkeypatch.setattr(sys, "argv", ["org", "agenda"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) plain_output = output.replace("…", "") expected_day_header = local_now().strftime("%A %Y-%m-%d") @@ -308,10 +301,7 @@ def test_run_agenda_single_day_default_shows_day_header( assert expected_day_header in plain_output -def test_run_agenda_hides_repeat_prefix( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_hides_repeat_prefix(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should render repeated tasks without the REPEAT prefix.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") @@ -321,14 +311,12 @@ def test_run_agenda_hides_repeat_prefix( "argv", ["org", "agenda", "--date", "2025-01-15", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "REPEAT " not in output def test_run_agenda_repeat_row_uses_repeat_after_state( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -346,15 +334,13 @@ def test_run_agenda_repeat_row_uses_repeat_after_state( args = _make_args([fixture_path], date="2025-01-15") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "DONE Reopened repeat task" in output assert "TODO Reopened repeat task" not in output def test_run_agenda_excludes_completed_untimed_scheduled( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -374,15 +360,13 @@ def test_run_agenda_excludes_completed_untimed_scheduled( "argv", ["org", "agenda", "--date", "2025-01-15", "--no-completed", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Active timed task" in output assert "Completed timed task" not in output def test_run_agenda_timed_deadline_and_scheduled_tasks_appear_in_time_order( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -402,8 +386,7 @@ def test_run_agenda_timed_deadline_and_scheduled_tasks_appear_in_time_order( "argv", ["org", "agenda", "--date", "2025-01-15", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Due today timed" in output assert "Scheduled today timed" in output @@ -411,7 +394,6 @@ def test_run_agenda_timed_deadline_and_scheduled_tasks_appear_in_time_order( def test_run_agenda_deadline_with_time_is_aligned_to_timetable( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -424,30 +406,24 @@ def test_run_agenda_deadline_with_time_is_aligned_to_timetable( args = _make_args([fixture_path], date="2025-01-15") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "09:30" in output assert "Timed due" in output -def test_run_agenda_untimed_scheduled_omits_all_day_label( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_untimed_scheduled_omits_all_day_label(monkeypatch: pytest.MonkeyPatch) -> None: """Untimed scheduled rows should not include an all-day marker.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "all day" not in output def test_run_agenda_default_view_shows_overdue_scheduled_and_deadline_tasks( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -465,15 +441,13 @@ def test_run_agenda_default_view_shows_overdue_scheduled_and_deadline_tasks( args = _make_args([fixture_path], date=today.isoformat()) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", today.isoformat()]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "overdue sched" in output assert "overdue deadline" in output def test_run_agenda_same_day_timed_tasks_are_ordered_by_time( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -491,14 +465,12 @@ def test_run_agenda_same_day_timed_tasks_are_ordered_by_time( args = _make_args([fixture_path], date=today.isoformat()) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", today.isoformat()]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert output.index("earlier task") < output.index("later task") def test_run_agenda_plain_view_rows_show_relative_day_labels( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -514,11 +486,11 @@ def test_run_agenda_plain_view_rows_show_relative_day_labels( "DEADLINE: <2025-01-18 Sat>\n", ) - config_module.CONFIG_AGENDA_VIEWS.clear() - config_module.CONFIG_AGENDA_VIEWS["plain"] = config_module.AgendaViewConfig( + org.config.app.CONFIG_AGENDA_VIEWS.clear() + org.config.app.CONFIG_AGENDA_VIEWS["plain"] = org.config.app.AgendaViewConfig( name="plain", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Planning", filter=".scheduled != null or .deadline != null", order_by=None, @@ -534,8 +506,7 @@ def test_run_agenda_plain_view_rows_show_relative_day_labels( "argv", ["org", "agenda", "--date", "2025-01-15", "--view", "plain", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "2 days ago" in output assert "today" in output @@ -549,10 +520,10 @@ def test_build_view_day_model_plain_rows_choose_matching_planning_source() -> No "* TODO Scheduled row\nSCHEDULED: <2025-01-13 Mon>\n", ) nodes = list(root) - view = config_module.AgendaViewConfig( + view = org.config.app.AgendaViewConfig( name="plain", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Planning", filter=".scheduled != null or .deadline != null", order_by=None, @@ -561,7 +532,7 @@ def test_build_view_day_model_plain_rows_choose_matching_planning_source() -> No ), ], ) - day_model = agenda_layout.build_view_day_model( + day_model = ui.build_view_day_model( nodes, datetime(2025, 1, 15).date(), datetime(2025, 1, 15, 12, 0), @@ -582,7 +553,6 @@ def test_build_view_day_model_plain_rows_choose_matching_planning_source() -> No def test_run_agenda_timeline_view_appends_selected_untimed_tasks( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -596,11 +566,11 @@ def test_run_agenda_timeline_view_appends_selected_untimed_tasks( "SCHEDULED: <2025-01-15 Wed>\n", ) - config_module.CONFIG_AGENDA_VIEWS.clear() - config_module.CONFIG_AGENDA_VIEWS["timeline"] = config_module.AgendaViewConfig( + org.config.app.CONFIG_AGENDA_VIEWS.clear() + org.config.app.CONFIG_AGENDA_VIEWS["timeline"] = org.config.app.AgendaViewConfig( name="timeline", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Agenda", filter=".scheduled != null", order_by=None, @@ -616,8 +586,7 @@ def test_run_agenda_timeline_view_appends_selected_untimed_tasks( "argv", ["org", "agenda", "--date", "2025-01-15", "--view", "timeline", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "09:30" in output assert "Timed task" in output @@ -635,10 +604,10 @@ def test_build_view_day_model_timeline_rows_append_untimed_with_deadline_precede "* TODO Both row\nSCHEDULED: <2025-01-14 Tue>\nDEADLINE: <2025-01-17 Fri>\n", ) nodes = list(root) - view = config_module.AgendaViewConfig( + view = org.config.app.AgendaViewConfig( name="timeline", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Agenda", filter=".scheduled != null or .deadline != null", order_by=None, @@ -647,7 +616,7 @@ def test_build_view_day_model_timeline_rows_append_untimed_with_deadline_precede ), ], ) - day_model = agenda_layout.build_view_day_model( + day_model = ui.build_view_day_model( nodes, datetime(2025, 1, 15).date(), datetime(2025, 1, 15, 12, 0), @@ -670,7 +639,6 @@ def test_build_view_day_model_timeline_rows_append_untimed_with_deadline_precede def test_run_agenda_omits_inactive_planning_timestamps( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -693,8 +661,7 @@ def test_run_agenda_omits_inactive_planning_timestamps( args = _make_args([fixture_path], date=today.isoformat()) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", today.isoformat()]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "inactive scheduled" not in output assert "inactive deadline" not in output @@ -702,7 +669,6 @@ def test_run_agenda_omits_inactive_planning_timestamps( def test_run_agenda_now_marker_renders_after_same_time_tasks( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -720,14 +686,15 @@ def test_run_agenda_now_marker_renders_after_same_time_tasks( args = _make_args([fixture_path], date="2025-01-15") local_tz = local_now().tzinfo - monkeypatch.setattr( - agenda_command, - "local_now", - lambda: datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz), - ) + + def current_now() -> datetime: + return datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz) + + monkeypatch.setattr(actions, "local_now", current_now) + monkeypatch.setattr(ui, "local_now", current_now) + monkeypatch.setattr("org.logic.time.local_now", current_now) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out.replace("…", "") + output = _render_agenda_output(args).replace("…", "") assert output.index("Same minute task") < output.index("------ NOW ------") assert output.index("------ NOW ------") < output.index("Later task") @@ -788,28 +755,6 @@ def test_run_agenda_invalid_date_raises_bad_parameter(monkeypatch: pytest.Monkey agenda_command.run_agenda(args) -def test_decode_escape_sequence_supports_arrows() -> None: - """Escape-sequence decoder should map plain and shifted arrow keys.""" - assert decode_escape_sequence(b"\x1b") == "ESC" - assert decode_escape_sequence(b"\x1b[A") == "UP" - assert decode_escape_sequence(b"\x1b[B") == "DOWN" - assert decode_escape_sequence(b"\x1b[C") == "RIGHT" - assert decode_escape_sequence(b"\x1b[D") == "LEFT" - assert decode_escape_sequence(b"\x1b[1;2A") == "S-UP" - assert decode_escape_sequence(b"\x1b[1;2B") == "S-DOWN" - assert decode_escape_sequence(b"\x1b[1;2C") == "S-RIGHT" - assert decode_escape_sequence(b"\x1b[1;2D") == "S-LEFT" - assert decode_escape_sequence(b"\x1b[<64;40;10M") == "WHEEL-UP" - assert decode_escape_sequence(b"\x1b[<65;40;11M") == "WHEEL-DOWN" - - -def test_decode_escape_sequence_unknown_escape_is_not_exit() -> None: - """Unknown escape sequences should not decode to ESC quit token.""" - key_name = decode_escape_sequence(b"\x1b[999~") - assert key_name != "ESC" - assert key_name.startswith("UNSUPPORTED-ESC:") - - def test_parse_clock_duration_accepts_multiple_formats() -> None: """Clock duration parser should handle H:MM, minutes, and suffixed values.""" assert duration_to_org_text(parse_clock_duration("1:30")) == "1:30" @@ -834,7 +779,7 @@ def test_advance_timestamp_by_repeater_double_plus_advances_until_future( """'++' repeater should advance repeatedly until timestamp is in the future.""" timestamp = Timestamp.from_source("<2025-01-10 Fri ++1d>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 12, 0), ) @@ -848,7 +793,7 @@ def test_advance_timestamp_by_repeater_double_plus_hourly_uses_datetime( """'++' with hour unit should advance until datetime is after current time.""" timestamp = Timestamp.from_source("<2025-01-15 Wed 09:00 ++1h>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 10, 30), ) @@ -862,7 +807,7 @@ def test_advance_timestamp_by_repeater_double_plus_always_shifts_at_least_once( """'++' should still shift once when timestamp is already in the future.""" timestamp = Timestamp.from_source("<2025-01-15 Wed 23:00 ++1d>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 10, 0), ) @@ -876,7 +821,7 @@ def test_advance_timestamp_by_repeater_dot_plus_uses_current_day( """'.+' repeater should anchor at current day and then shift once by unit.""" timestamp = Timestamp.from_source("<2025-01-10 Fri 09:30 .+2d>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 18, 45), ) @@ -898,26 +843,12 @@ def test_interactive_selection_can_land_on_hour_row_and_block_task_actions() -> ) session.selected_row_index = hour_index - assert agenda_events.selected_task_row(session) is None + assert actions.selected_task_row(session) is None - agenda_events.apply_shift_date(session, day_delta=1) + actions.apply_shift_date(session, day_delta=1) assert session.status_message == "Action available only on task rows" -def test_handle_interactive_key_mouse_wheel_moves_selection() -> None: - """Mouse wheel tokens should move selection same as down/up keys.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - start = session.selected_row_index - assert agenda_events._handle_keypress_event(session, "WHEEL-DOWN") is True - assert session.selected_row_index == (start + 1) % len(session.row_locations) - - assert agenda_events._handle_keypress_event(session, "WHEEL-UP") is True - assert session.selected_row_index == start - - def test_interactive_row_locations_skip_hidden_first_day_rows() -> None: """Interactive selection should not include the hidden first day header/spacer rows.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") @@ -930,101 +861,6 @@ def test_interactive_row_locations_skip_hidden_first_day_rows() -> None: assert session.row_locations[0] == (0, 2) -def test_timeout_event_refreshes_now_marker_when_minute_changes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Timeout event should refresh session rows when local minute changes.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - clock = {"value": datetime(2025, 1, 15, 10, 0)} - monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: clock["value"]) - monkeypatch.setattr("org.commands.agenda.events.local_now", lambda: clock["value"]) - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - before = next(row.time_text for row in session.day_models[0].rows if row.kind == "now_marker") - assert before == "10:00" - - clock["value"] = datetime(2025, 1, 15, 10, 1) - assert ( - agenda_events.handle_interactive_event( - session, - TimeoutEvent(), - agenda_events.passthrough_run_external, - ) - is True - ) - - after = next(row.time_text for row in session.day_models[0].rows if row.kind == "now_marker") - assert after == "10:01" - - -def test_handle_interactive_key_unsupported_key_sets_status_and_continues() -> None: - """Unsupported key should set status message without exiting agenda loop.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - unsupported_key = "UNSUPPORTED-ESC:1b5b3939397e" - assert agenda_events._handle_keypress_event(session, unsupported_key) is True - assert session.status_message == f"Unsupported key: {unsupported_key}" - - -def test_handle_interactive_key_slash_activates_search_prompt() -> None: - """Slash should activate agenda search prompt.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - - assert agenda_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - assert session.active_prompt.prompt.label == "Search text (blank clears)" - - -def test_agenda_search_filters_rows_and_clear_restores_rows() -> None: - """Interactive search should filter visible rows and clear should restore all rows.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - all_titles = _visible_agenda_task_titles(session) - - assert "Timed agenda task" in all_titles - assert "Untimed agenda task" in all_titles - - assert agenda_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "focus" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is False - assert session.status_message == "1 matches" - assert _visible_agenda_task_titles(session) == ["Timed agenda task"] - assert session.search_text == "focus" - - assert agenda_events._handle_keypress_event(session, "x") is True - assert session.status_message == "Search cleared" - assert session.search_text == "" - assert _visible_agenda_task_titles(session) == all_titles - - -def test_interactive_renderable_footer_shows_active_search_text() -> None: - """Agenda footer should include currently active interactive search text.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.search_text = "focus" - agenda_events.refresh_visible_nodes(session, None) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=220, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - output = buffer.getvalue() - - assert "Search: focus" in output - - def test_apply_state_change_uses_current_action_time( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1040,17 +876,16 @@ def test_apply_state_change_uses_current_action_time( ) session.now = datetime(2025, 1, 15, 16, 30) action_now = datetime(2025, 1, 15, 17, 4, 33) - monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: action_now) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "local_now", lambda: action_now) + monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "local_now", lambda: action_now) monkeypatch.setattr( - agenda_events, + actions, "refresh_session", lambda _session, _preserve_identity: None, ) - agenda_events.apply_state_change_with_value(session, "DONE") + actions.apply_state_change_with_value(session, "DONE") assert heading.todo == "DONE" assert heading.repeats @@ -1074,17 +909,16 @@ def test_apply_clock_entry_uses_current_action_time( ) session.now = datetime(2025, 1, 15, 16, 30) action_now = datetime(2025, 1, 15, 17, 4, 33) - monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: action_now) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "local_now", lambda: action_now) + monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "local_now", lambda: action_now) monkeypatch.setattr( - agenda_events, + actions, "refresh_session", lambda _session, _preserve_identity: None, ) - agenda_events.apply_clock_entry_with_value(session, "30") + actions.apply_clock_entry_with_value(session, "30") assert heading.clock_entries timestamp = heading.clock_entries[-1].timestamp @@ -1112,7 +946,7 @@ def test_apply_refile_rejects_same_file_with_equivalent_path( ) destination_alias = os.path.join(tmp_path, ".", "agenda_refile_same.org") - agenda_events.apply_refile_with_value(session, destination_alias) + actions.apply_refile_with_value(session, destination_alias) assert session.status_message == "Task already in destination file" with open(fixture_path, encoding="utf-8") as handle: @@ -1144,9 +978,9 @@ def test_apply_refile_preserves_moved_task_locator( heading = root.heading_by_id("task-1") assert heading is not None monkeypatch.setattr( - agenda_events, + actions, "selected_task_row", - lambda _session: agenda_layout.AgendaRow( + lambda _session: ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, @@ -1157,19 +991,19 @@ def test_apply_refile_preserves_moved_task_locator( saved_documents: list[Document] = [] refreshed: dict[str, object] = {} - monkeypatch.setattr(agenda_events, "_save_document_changes", saved_documents.append) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "_save_document_changes", saved_documents.append) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) def _capture_refresh( - current_session: agenda_events.AgendaSession, + current_session: actions.AgendaSession, preserve_identity: object, ) -> None: refreshed["session"] = current_session refreshed["identity"] = preserve_identity - monkeypatch.setattr(agenda_events, "refresh_session", _capture_refresh) + monkeypatch.setattr(actions, "refresh_session", _capture_refresh) - agenda_events.apply_refile_with_value(session, destination_path) + actions.apply_refile_with_value(session, destination_path) assert refreshed["session"] is session assert refreshed["identity"] == heading_locator(heading) @@ -1177,491 +1011,6 @@ def _capture_refresh( assert session.status_message == f"Refiled task to {destination_path}" -def test_handle_interactive_key_enter_edits_selected_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Enter should trigger external-editor editing on selected task row.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "task" - ) - session.selected_row_index = task_index - - row = agenda_events.selected_task_row(session) - assert row is not None - assert row.node is not None - - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=False) - - monkeypatch.setattr(agenda_events, "edit_heading_subtree_in_external_editor", _fake_edit) - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.status_message == "No changes." - - -def test_handle_interactive_key_enter_reloads_using_selected_node_identity_after_changed_edit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Changed edit should reload using the selected task identity.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "task" - ) - session.selected_row_index = task_index - - row = agenda_events.selected_task_row(session) - assert row is not None - assert row.node is not None - source_node = row.node - - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=True) - - refreshed_identity = None - - def _capture_refresh(_session: agenda_events.AgendaSession, identity: object) -> None: - nonlocal refreshed_identity - refreshed_identity = identity - - monkeypatch.setattr(agenda_events, "edit_heading_subtree_in_external_editor", _fake_edit) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", _capture_refresh) - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.status_message == "Task updated" - assert refreshed_identity == heading_locator(source_node) - - -def test_handle_interactive_key_dollar_archives_selected_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """$ should archive highlighted agenda task using shared archive helper.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "task" - ) - session.selected_row_index = task_index - - def _fake_archive( - heading: Heading, - _cache: dict[str, Document], - ) -> archive_command.ArchiveMoveResult: - location = archive_command.ArchiveLocation( - raw_spec="%s_archive::", - file_path="tasks.org_archive", - parent_title=None, - ) - target = archive_command.ArchiveTarget( - location=location, - document=heading.document, - parent_heading=None, - ) - return archive_command.ArchiveMoveResult( - heading=heading, - target=target, - source_document=heading.document, - destination_document=heading.document, - ) - - monkeypatch.setattr(agenda_events, "archive_heading_subtree_and_save", _fake_archive) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) - - assert agenda_events._handle_keypress_event(session, "$") is True - assert session.status_message == "Task archived" - - -def test_handle_interactive_key_a_captures_and_schedules_on_timed_task_row( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a on timed task row should capture and schedule with row-specific time.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - selected_row = agenda_events.selected_row(session) - assert selected_row is not None - expected_time = selected_row.time_text - expected_timestamp = f"<2025-01-15 Wed {expected_time}>" - captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - saved_documents: list[Document] = [] - reloaded: dict[str, object] = {} - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - monkeypatch.setattr( - agenda_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - - def _capture_save(document: Document) -> None: - saved_documents.append(document) - - monkeypatch.setattr(agenda_events, "_save_document_changes", _capture_save) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - - def _fake_refresh( - current_session: agenda_events.AgendaSession, - preserve_identity: tuple[str, str, str, int | None] | None, - ) -> None: - reloaded["session"] = current_session - reloaded["identity"] = preserve_identity - - monkeypatch.setattr(agenda_events, "refresh_session", _fake_refresh) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert str(captured_node.scheduled) == expected_timestamp - assert saved_documents == [captured_node.document] - assert reloaded["session"] is session - assert reloaded["identity"] == heading_locator(captured_node) - assert session.status_message == f"Task captured and scheduled for {expected_timestamp}" - - -def test_handle_interactive_key_a_uses_now_marker_time( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a on NOW marker row should capture and schedule using NOW row minute.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - local_tz = local_now().tzinfo - monkeypatch.setattr( - agenda_events, - "local_now", - lambda: datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz), - ) - monkeypatch.setattr( - agenda_command, - "local_now", - lambda: datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz), - ) - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - now_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "now_marker" - ) - session.selected_row_index = now_index - captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - monkeypatch.setattr( - agenda_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert str(captured_node.scheduled) == "<2025-01-15 Wed 17:04>" - - -def test_handle_interactive_key_a_on_non_timetable_row_reports_blocked() -> None: - """a on non-timed rows should be blocked with a status message.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - blocked_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "section" - ) - session.selected_row_index = blocked_index - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.status_message == "Capture is available only on timetable time rows" - - -def test_handle_interactive_key_a_blank_template_input_cancels( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should close agenda capture prompt with cancel status on blank input.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is False - assert session.status_message == "Capture cancelled" - - -def test_handle_interactive_key_a_invalid_shortcut_keeps_prompt_open( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should keep agenda capture prompt open for invalid shortcuts.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "42" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is True - assert session.status_message == "Invalid capture template shortcut" - - -def test_prompt_submit_capture_stops_and_restarts_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Agenda prompt submission should pause live rendering for capture action.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - agenda_events._handle_keypress_event(session, "a") - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - - captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - monkeypatch.setattr( - agenda_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - controller = cast("Any", _InputControllerStub()) - - def _run_prompt_external(callback: object) -> None: - controller.suspend_live(live) - try: - cast("Any", callback)() - finally: - controller.resume_live(live) - - session.run_external = _run_prompt_external - - consumed = handle_active_prompt_event(session, KeypressEvent("ENTER")) - live.update(agenda_layout.interactive_agenda_renderable(live.console, session), refresh=True) - - assert consumed is True - assert events == ["input-stop", "stop", "start", "input-start", "update"] - - -def test_search_prompt_live_updates_and_escape_reverts_search() -> None: - """Agenda search prompt should live-update rows and ESC should restore prior filter.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - assert agenda_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - consumed = handle_active_prompt_event(session, InputEvent("focus")) - live.update(agenda_layout.interactive_agenda_renderable(live.console, session), refresh=True) - - assert consumed is True - assert _visible_agenda_task_titles(session) == ["Timed agenda task"] - assert session.search_text == "focus" - assert session.status_message == "1 matches" - - consumed = handle_active_prompt_event(session, KeypressEvent("ESC")) - live.update(agenda_layout.interactive_agenda_renderable(live.console, session), refresh=True) - - assert consumed is True - assert _visible_agenda_task_titles(session) == [ - "Timed agenda task", - "Repeated completion on day", - "Completed one-off task", - "Untimed agenda task", - "Overdue scheduled task", - "Overdue deadline task", - "Upcoming deadline task", - ] - assert session.search_text == "" - assert session.status_message == "Search cancelled" - assert events == ["update", "update"] - - -def test_handle_interactive_key_enter_on_non_task_row_stays_in_agenda() -> None: - """Enter on non-task rows should keep agenda view and report status.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - hour_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "hour_marker" - ) - session.selected_row_index = hour_index - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.status_message == "Action available only on task rows" - - -def test_handle_interactive_key_escape_quits_interactive_loop() -> None: - """Esc should quit interactive agenda loop.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - assert agenda_events._handle_keypress_event(session, "ESC") is False - - def test_detail_org_block_includes_selected_heading_children() -> None: """Detail task text should include heading subtree including child headings.""" root = org_parser.loads( @@ -1674,133 +1023,18 @@ def test_detail_org_block_includes_selected_heading_children() -> None: assert "** TODO Child task" in output -def test_interactive_renderable_footer_is_two_lines_without_status() -> None: - """Interactive render should reserve exactly two footer lines when status is empty.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.status_message = "" - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=300, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[0].strip() == "Wednesday 2025-01-15" - assert lines[-2].startswith("Lines ") - assert "Type ? for help" in lines[-2] - assert lines[-1] == "" - - -def test_interactive_renderable_footer_is_two_lines_with_status_on_narrow_width() -> None: - """Interactive render should keep footer at two lines even on narrow terminals.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.status_message = "Unsupported key: x" - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=80, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[0].strip() == "Wednesday 2025-01-15" - assert lines[-2].startswith("Lines ") - assert "Type ? for help" in lines[-2] - assert lines[-1] == "Unsupported key: x" - - -def test_interactive_renderable_updates_sticky_day_header_for_selected_day() -> None: - """Interactive render should update the sticky day header when selection moves days.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15", days=2) - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.selected_row_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if day_index == 1 and session.day_models[day_index].rows[row_index].kind == "task" - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=160, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert lines[0].strip() == "Thursday 2025-01-16" - - -def test_interactive_renderable_uses_full_dataset_column_widths() -> None: - """Interactive agenda column widths should be based on all rendered day models.""" - root = org_parser.loads( - "#+CATEGORY: short\n" - "* TODO Day one\n" - "SCHEDULED: <2025-01-15 Wed 09:30>\n\n" - "#+CATEGORY: very-long-category\n" - "* TODO Day two\n" - "SCHEDULED: <2025-01-16 Thu>\n" - ":TAGONE:TAGTWO:\n", - ) - args = _make_args(["dummy.org"], date="2025-01-15", days=2) - session = _make_session(args, list(root), color_enabled=False) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - output = buffer.getvalue() - - assert "very-long-category" not in output - assert "short" in output - assert "09:30" in output - - -def test_handle_interactive_key_question_toggles_help_modal() -> None: - """Question mark should open help modal and next key should close it.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - - assert agenda_events._handle_keypress_event(session, "?") is True - assert session.show_help_modal is True - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.show_help_modal is False - - -def test_interactive_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel for agenda.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.show_help_modal = True - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - output = buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - def test_shift_planning_time_for_row_shifts_timed_scheduled_by_one_hour() -> None: """Shifting timed scheduled rows by hour should mutate scheduled timestamp time.""" root = org_parser.loads("* TODO X\nSCHEDULED: <2025-01-15 Wed 10:30>\n") heading = next(iter(root)) - row = agenda_layout.AgendaRow( + row = ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, source="scheduled", ) - timestamp, status = agenda_events.shift_planning_time_for_row(row, hour_delta=1) + timestamp, status = actions.shift_planning_time_for_row(row, hour_delta=1) assert timestamp is not None assert status == "Shifted scheduled forward by 1 hour" assert str(timestamp).startswith("<2025-01-15 Wed 11:30") @@ -1810,14 +1044,14 @@ def test_shift_planning_time_for_row_shifts_timed_deadline_by_one_hour() -> None """Shifting timed deadline rows by hour should mutate deadline timestamp time.""" root = org_parser.loads("* TODO X\nDEADLINE: <2025-01-15 Wed 10:30>\n") heading = next(iter(root)) - row = agenda_layout.AgendaRow( + row = ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, source="deadline", ) - timestamp, status = agenda_events.shift_planning_time_for_row(row, hour_delta=1) + timestamp, status = actions.shift_planning_time_for_row(row, hour_delta=1) assert timestamp is not None assert status == "Shifted deadline forward by 1 hour" assert str(timestamp).startswith("<2025-01-15 Wed 11:30") @@ -1827,14 +1061,14 @@ def test_shift_planning_time_for_row_rejects_non_timed_rows() -> None: """Hour shifting should reject non-timed or non-planning row sources.""" root = org_parser.loads("* TODO X\nSCHEDULED: <2025-01-15 Wed>\n") heading = next(iter(root)) - row = agenda_layout.AgendaRow( + row = ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, source="scheduled_untimed", ) - timestamp, status = agenda_events.shift_planning_time_for_row(row, hour_delta=-1) + timestamp, status = actions.shift_planning_time_for_row(row, hour_delta=-1) assert timestamp is None assert status == "Time shifting is available only for timed scheduled/deadline rows" @@ -1850,19 +1084,19 @@ def test_build_timeline_section_rows_preserves_timed_entry_sources() -> None: assert root is not None scheduled, deadline, repeated = list(root) day = datetime(2025, 1, 15).date() - entries = agenda_layout._ViewTimelineEntries( + entries = ui._ViewTimelineEntries( timed=[ - agenda_layout._TimedEntry( + ui._TimedEntry( node=scheduled, when=datetime(2025, 1, 15, 9, 0), kind="scheduled", ), - agenda_layout._TimedEntry( + ui._TimedEntry( node=deadline, when=datetime(2025, 1, 15, 10, 0), kind="deadline", ), - agenda_layout._TimedEntry( + ui._TimedEntry( node=repeated, when=datetime(2025, 1, 15, 11, 0), kind="repeat", @@ -1871,7 +1105,7 @@ def test_build_timeline_section_rows_preserves_timed_entry_sources() -> None: untimed=[], ) - rows = agenda_layout._build_timeline_section_rows( + rows = ui._build_timeline_section_rows( day, datetime(2025, 1, 15, 12, 0), entries, diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index d3dcd87..a263929 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -7,25 +7,21 @@ from contextlib import contextmanager from datetime import UTC, datetime from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast import pytest import typer from rich.console import Console from rich.text import Text -from org import config as config_module -from org.commands import archive as archive_command -from org.commands import editor as editor_command +import org.config.app +from org.commands.board import actions, ui from org.commands.board import command as board_command -from org.commands.board import events as board_events -from org.commands.board import layout as board_layout -from org.commands.interactive_common import ( - KeypressEvent, - handle_active_prompt_event, - heading_locator, -) -from org.commands.tasks import capture as capture_command +from org.logic.archive import ArchiveLocation, ArchiveMoveResult, ArchiveTarget +from org.logic.edit import DocumentEditResult +from org.logic.tasks import heading_locator +from org.pipeline.load import load_and_process_data +from org.tui.bits import setup_output from tests.conftest import node_from_org @@ -33,7 +29,6 @@ from collections.abc import Iterator from org_parser.document import Document, Heading - from rich.live import Live FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") @@ -82,17 +77,17 @@ def make_board_args(files: list[str], **overrides: object) -> board_command.Boar def _visible_board_titles_by_column( - session: board_events.BoardSession, + session: actions.BoardSession, ) -> dict[str, list[str]]: """Return visible task titles grouped by board column title.""" return {column.title: [node.title_text for node in column.nodes] for column in session.columns} -def _col(title: str, nodes: list[Heading]) -> board_events.BoardColumn: - return board_events.BoardColumn(title, nodes) +def _col(title: str, nodes: list[Heading]) -> actions.BoardColumn: + return actions.BoardColumn(title, nodes) -def _default_columns(nodes: list[Heading]) -> list[board_events.BoardColumn]: +def _default_columns(nodes: list[Heading]) -> list[actions.BoardColumn]: return [_col("TODO", nodes)] @@ -100,16 +95,16 @@ def _make_session( args: board_command.BoardArgs, nodes: list[Heading], **overrides: object, -) -> board_events.BoardSession: +) -> actions.BoardSession: resolved_columns = cast( - "list[board_events.BoardColumn] | None", + "list[actions.BoardColumn] | None", overrides.pop("columns", None), ) resolved_all_columns = cast( - "list[board_events.BoardColumn] | None", + "list[actions.BoardColumn] | None", overrides.pop("all_columns", None), ) - session = board_events.BoardSession( + session = actions.BoardSession( args=args, nodes=nodes, todo_states=["TODO"], @@ -122,7 +117,6 @@ def _make_session( status_message="", all_columns=[], search_text="", - show_help_modal=False, ) session.all_columns = session.columns if resolved_all_columns is None else resolved_all_columns for key, value in overrides.items(): @@ -133,18 +127,51 @@ def _make_session( def _pin_board_now(monkeypatch: pytest.MonkeyPatch) -> None: """Pin board command current time for deterministic completed-task windows.""" monkeypatch.setattr( - board_events, + actions, "local_now", lambda: datetime(2026, 5, 9, 12, 0, tzinfo=UTC), ) +def _render_board_output(args: board_command.BoardArgs) -> str: + """Render board output through shared UI helpers for rendering tests.""" + color_enabled = setup_output(args) + args.max_results = board_command._resolve_tasks_limit(args.max_results) + console_output = StringIO() + console = Console( + file=console_output, + width=args.width or 120, + no_color=not color_enabled, + force_terminal=color_enabled, + ) + nodes, discovered_todo_states, discovered_done_states = load_and_process_data( + args, + ) + nodes = actions.filter_recent_completed_nodes(nodes, args.days) + todo_states, done_states = actions.resolved_states( + args, + discovered_todo_states, + discovered_done_states, + ) + if not nodes: + return "No results\n" + columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + ui.render_static_board( + console, + columns, + done_states=done_states, + todo_states=todo_states, + color_enabled=color_enabled, + ) + return console_output.getvalue() + + def test_filter_recent_completed_nodes_uses_latest_timestamp_window( monkeypatch: pytest.MonkeyPatch, ) -> None: """Completed tasks should be filtered by latest_timestamp recency.""" now = datetime(2026, 5, 9, 12, 0, tzinfo=UTC) - monkeypatch.setattr(board_events, "local_now", lambda: now) + monkeypatch.setattr(actions, "local_now", lambda: now) nodes = node_from_org( "* TODO Active task\n" @@ -154,7 +181,7 @@ def test_filter_recent_completed_nodes_uses_latest_timestamp_window( "CLOSED: [2026-04-01 Wed 09:00]\n", ) - filtered = board_events.filter_recent_completed_nodes(nodes, days=7) + filtered = actions.filter_recent_completed_nodes(nodes, days=7) assert [node.title_text for node in filtered] == ["Active task", "Recent done"] @@ -164,24 +191,19 @@ def test_filter_recent_completed_nodes_respects_days_override( ) -> None: """Days override should widen completed-task retention window.""" now = datetime(2026, 5, 9, 12, 0, tzinfo=UTC) - monkeypatch.setattr(board_events, "local_now", lambda: now) + monkeypatch.setattr(actions, "local_now", lambda: now) nodes = node_from_org( "* DONE Mid-age done\nCLOSED: [2026-04-25 Sat 09:00]\n", ) - assert board_events.filter_recent_completed_nodes(nodes, days=7) == [] - assert [ - node.title_text for node in board_events.filter_recent_completed_nodes(nodes, days=30) - ] == [ + assert actions.filter_recent_completed_nodes(nodes, days=7) == [] + assert [node.title_text for node in actions.filter_recent_completed_nodes(nodes, days=30)] == [ "Mid-age done", ] -def test_run_flow_board_renders_expected_columns( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_board_renders_expected_columns(monkeypatch: pytest.MonkeyPatch) -> None: """Board should render built-in selector fallback columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args( @@ -205,16 +227,14 @@ def test_run_flow_board_renders_expected_columns( "150", ], ) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Backlog" in output assert "TODO" in output assert "DONE" in output -def test_run_flow_board_column_order_follows_document_todo_order( - capsys: pytest.CaptureFixture[str], +def test_run_board_column_order_follows_document_todo_order( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -236,8 +256,7 @@ def test_run_flow_board_column_order_follows_document_todo_order( width=220, ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "220"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) pos_not_started = output.find("Backlog") pos_todo = output.find("TODO") @@ -250,8 +269,7 @@ def test_run_flow_board_column_order_follows_document_todo_order( assert pos_todo < pos_done -def test_run_flow_board_preserves_order_in_column_when_priorities_equal( - capsys: pytest.CaptureFixture[str], +def test_run_board_preserves_order_in_column_when_priorities_equal( monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should keep stable per-column order when priorities are equal.""" @@ -264,8 +282,7 @@ def test_run_flow_board_preserves_order_in_column_when_priorities_equal( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--order-by-file-order"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) first = output.find("Refactor codebase") second = output.find("Fix bug in parser") @@ -280,8 +297,8 @@ def test_build_selector_board_columns_preserves_processed_order() -> None: "* TODO [#C] Low\n* TODO Middle\n* TODO [#A] High\n* TODO [#B] Medium\n", ) - specs = board_events.resolve_column_specs(make_board_args([])) - columns = board_events.build_selector_board_columns(nodes, specs) + specs = actions.resolve_column_specs(make_board_args([])) + columns = actions.build_selector_board_columns(nodes, specs) todo_column = next(column for column in columns if column.title == "TODO") assert [node.title_text for node in todo_column.nodes] == ["Low", "Middle", "High", "Medium"] @@ -292,10 +309,10 @@ def test_build_selector_board_columns_with_order_by_overrides_processed_order() nodes = node_from_org( "* TODO a\n* TODO c\n* TODO b\n", ) - view = config_module.BoardViewConfig( + view = org.config.app.BoardViewConfig( name="ordered", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="TODO", filter=".todo != null", order_by=".title_text", @@ -303,9 +320,9 @@ def test_build_selector_board_columns_with_order_by_overrides_processed_order() ], ) - columns = board_events.build_selector_board_columns( + columns = actions.build_selector_board_columns( nodes, - board_events.compile_view_column_specs(view), + actions.compile_view_column_specs(view), ) todo_column = next(column for column in columns if column.title == "TODO") @@ -315,17 +332,17 @@ def test_build_selector_board_columns_with_order_by_overrides_processed_order() def test_build_selector_board_columns_allows_matches_in_multiple_columns() -> None: """One task can appear in multiple selector columns.""" nodes = node_from_org("* TODO Shared\n* DONE Finished\n") - view = config_module.BoardViewConfig( + view = org.config.app.BoardViewConfig( name="overlap", columns=[ - config_module.BoardColumnConfig(name="Any todo", filter=".todo != null"), - config_module.BoardColumnConfig(name="Open", filter="not(.is_completed)"), + org.config.app.BoardColumnConfig(name="Any todo", filter=".todo != null"), + org.config.app.BoardColumnConfig(name="Open", filter="not(.is_completed)"), ], ) - columns = board_events.build_selector_board_columns( + columns = actions.build_selector_board_columns( nodes, - board_events.compile_view_column_specs(view), + actions.compile_view_column_specs(view), ) any_todo_column = next(column for column in columns if column.title == "Any todo") @@ -337,16 +354,16 @@ def test_build_selector_board_columns_allows_matches_in_multiple_columns() -> No def test_build_selector_board_columns_omits_non_matching_tasks() -> None: """Tasks that match no selectors should not appear in any column.""" nodes = node_from_org("* TODO Open\n* DONE Closed\n") - view = config_module.BoardViewConfig( + view = org.config.app.BoardViewConfig( name="nomatch", columns=[ - config_module.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), ], ) - columns = board_events.build_selector_board_columns( + columns = actions.build_selector_board_columns( nodes, - board_events.compile_view_column_specs(view), + actions.compile_view_column_specs(view), ) assert len(columns) == 1 @@ -356,7 +373,7 @@ def test_build_selector_board_columns_omits_non_matching_tasks() -> None: def test_render_column_title_text_allows_rich_markup() -> None: """Column titles should support Rich markup syntax.""" - title = board_layout.render_column_title_text("[bold green]Complete[/]") + title = ui.render_column_title_text("[bold green]Complete[/]") assert title.plain == "Complete" assert title.spans @@ -364,7 +381,7 @@ def test_render_column_title_text_allows_rich_markup() -> None: def test_render_column_title_text_falls_back_on_invalid_markup() -> None: """Invalid markup in column title should render as literal text.""" - title = board_layout.render_column_title_text("[bold") + title = ui.render_column_title_text("[bold") assert title.plain == "[bold" @@ -391,59 +408,18 @@ def test_move_selection_horizontal_skips_empty_columns() -> None: ) assert session.selected_column_index == 1 - board_events.move_selection_horizontal(session, 1) + actions.move_selection_horizontal(session, 1) assert session.selected_column_index == 3 - board_events.move_selection_horizontal(session, -1) + actions.move_selection_horizontal(session, -1) assert session.selected_column_index == 1 -def test_interactive_viewport_rows_scales_with_panel_height() -> None: - """Interactive viewport should account for panel line height.""" - assert board_layout._interactive_viewport_rows(24) == 4 - assert board_layout._interactive_viewport_rows(13) == 2 - assert board_layout._interactive_viewport_rows(9) == 1 - - -def test_sync_scroll_for_selection_keeps_selected_row_visible() -> None: - """Scroll sync should move viewport so selected task stays visible.""" - args = make_board_args([]) - nodes = node_from_org("\n".join(f"* TODO Task {index}" for index in range(6))) - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=4, - scroll_offset=0, - status_message="", - ) - render = board_layout.BoardPanelRenderConfig( - width=30, - color_enabled=False, - done_states=["DONE"], - todo_states=["TODO"], - ) - row_heights = board_layout._interactive_row_heights(session, render) - _start, _end, _used = board_layout._sync_scroll_for_selection( - session, - row_heights, - available_lines=8, - ) - - assert session.scroll_offset == 3 - - def test_step_heading_state_right_from_none_uses_first_document_state() -> None: """Shift-right from empty state should use first all_states item.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* Task\n")[0] - new_state, status = board_events.step_heading_state(heading, direction=1) + new_state, status = actions.step_heading_state(heading, direction=1) assert new_state == "TODO" assert status is None @@ -453,7 +429,7 @@ def test_step_heading_state_left_from_first_moves_to_none() -> None: """Shift-left from first state should clear todo state.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* TODO Task\n")[0] - new_state, status = board_events.step_heading_state(heading, direction=-1) + new_state, status = actions.step_heading_state(heading, direction=-1) assert new_state is None assert status is None @@ -463,8 +439,8 @@ def test_step_heading_state_moves_prev_next_in_middle() -> None: """Shift-left/right should move through middle states.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* WAITING Task\n")[0] - next_state, next_status = board_events.step_heading_state(heading, direction=1) - prev_state, prev_status = board_events.step_heading_state(heading, direction=-1) + next_state, next_status = actions.step_heading_state(heading, direction=1) + prev_state, prev_status = actions.step_heading_state(heading, direction=-1) assert next_state == "DONE" assert next_status is None @@ -477,8 +453,8 @@ def test_step_heading_state_boundary_no_ops() -> None: empty_heading = node_from_org("#+TODO: TODO | DONE\n* Task\n")[0] done_heading = node_from_org("#+TODO: TODO | DONE\n* DONE Task\n")[0] - no_state, no_state_status = board_events.step_heading_state(empty_heading, direction=-1) - last_state, last_state_status = board_events.step_heading_state(done_heading, direction=1) + no_state, no_state_status = actions.step_heading_state(empty_heading, direction=-1) + last_state, last_state_status = actions.step_heading_state(done_heading, direction=1) assert no_state is None assert no_state_status == "State unchanged" @@ -490,7 +466,7 @@ def test_step_heading_state_deduplicates_document_states() -> None: """State stepping should deduplicate repeated all_states values.""" heading = node_from_org("#+TODO: TODO TODO WAITING | DONE DONE\n* TODO Task\n")[0] - new_state, status = board_events.step_heading_state(heading, direction=1) + new_state, status = actions.step_heading_state(heading, direction=1) assert new_state == "WAITING" assert status is None @@ -519,14 +495,14 @@ def test_apply_state_move_steps_state_and_reloads( def _capture_save(document: Document) -> None: saved_documents.append(document) - monkeypatch.setattr(board_events, "_save_document_changes", _capture_save) + monkeypatch.setattr(actions, "_save_document_changes", _capture_save) monkeypatch.setattr( - board_events, + actions, "reload_session", lambda _session, preserve_identity: reloaded.append(preserve_identity), ) - board_events.apply_state_move(session, direction=1) + actions.apply_state_move(session, direction=1) assert node.todo == "DONE" assert saved_documents == [node.document] @@ -543,9 +519,9 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( session = _make_session( args=args, nodes=original_nodes, - columns=board_events.build_selector_board_columns( + columns=actions.build_selector_board_columns( original_nodes, - board_events.resolve_column_specs(args), + actions.resolve_column_specs(args), ), color_enabled=False, selected_column_index=1, @@ -561,223 +537,22 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( reloaded_nodes = node_from_org("* TODO [#A] Other\n* TODO [#A] Focus\n") monkeypatch.setattr( - board_events, + actions, "load_and_process_data", lambda _args: (reloaded_nodes, ["TODO"], ["DONE"]), ) - board_events.reload_session(session, preserve_identity) + actions.reload_session(session, preserve_identity) - selected = board_events.selected_node(session) + selected = actions.selected_node(session) assert selected is not None assert selected.title_text == "Focus" -def test_interactive_renderable_keeps_footer_at_bottom() -> None: - """Interactive render should reserve bottom lines for footer and status.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="Ready", - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[-2].startswith("Rows ") - assert "Type ? for help" in lines[-2] - assert lines[-1].strip() == "Ready" - - -def test_interactive_renderable_footer_status_is_single_line() -> None: - """Interactive footer status should be normalized to one visible line.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="line one\nline two\nline three", - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[-2].startswith("Rows ") - assert "Type ? for help" in lines[-2] - assert lines[-1].strip() == "line one line two line three" - - -def test_handle_interactive_key_slash_activates_search_prompt() -> None: - """Slash should activate board search prompt.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[_col("TODO", nodes)], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=[_col("TODO", nodes)], - ) - - assert board_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - assert session.active_prompt.prompt.label == "Search text (blank clears)" - - -def test_board_search_filters_each_column_and_clear_restores_columns() -> None: - """Interactive search should filter each column's visible tasks and clear should restore.""" - args = make_board_args([]) - alpha, beta, beta_done = node_from_org( - "* TODO Alpha\n* TODO Beta\n* DONE Beta done\n", - ) - all_columns = [ - _col("Backlog", []), - _col("TODO", [alpha, beta]), - _col("DONE", [beta_done]), - ] - session = _make_session( - args=args, - nodes=[alpha, beta, beta_done], - columns=all_columns, - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=all_columns, - ) - - assert board_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "beta" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is False - assert session.status_message == "2 matches" - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Beta"], - "DONE": ["Beta done"], - } - assert session.search_text == "beta" - - assert board_events._handle_keypress_event(session, "x") is True - assert session.status_message == "Search cleared" - assert session.search_text == "" - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Alpha", "Beta"], - "DONE": ["Beta done"], - } - - -def test_interactive_renderable_footer_shows_active_search_text() -> None: - """Board footer should include currently active interactive search text.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - column = _col("TODO", nodes) - session = _make_session( - args=args, - nodes=nodes, - columns=[column], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=[column], - search_text="task", - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - output = buffer.getvalue() - - assert "Search: task" in output - - -def test_handle_interactive_key_question_toggles_help_modal() -> None: - """Question mark should open help modal and next key should close it.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[_col("TODO", nodes)], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - - assert board_events._handle_keypress_event(session, "?") is True - assert session.show_help_modal is True - - assert board_events._handle_keypress_event(session, "ENTER") is True - assert session.show_help_modal is False - - -def test_interactive_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel in body area.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[_col("TODO", nodes)], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - show_help_modal=True, - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - output = buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - -def test_handle_interactive_key_enter_edits_selected_task( +def test_edit_selected_task_in_external_editor_reports_no_changes( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enter should trigger external-editor task editing for selected panel.""" + """Editing through the shared external-editor path should report no changes.""" args = make_board_args([]) nodes = node_from_org("* TODO Task\n") session = _make_session( @@ -795,336 +570,19 @@ def test_handle_interactive_key_enter_edits_selected_task( status_message="", ) - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=False) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=False) - monkeypatch.setattr(board_events, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) - assert board_events._handle_keypress_event(session, "ENTER") is True + actions.edit_selected_task_in_external_editor(session) assert session.status_message == "No changes." -def test_handle_interactive_key_a_captures_task_and_reloads( +def test_edit_selected_task_in_external_editor_reloads_with_identity_after_change( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should prompt for template then capture and reload board selection.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - captured_node = node_from_org("* TODO Captured\n")[0] - reloaded: dict[str, object] = {} - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - monkeypatch.setattr( - board_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - - def _fake_reload( - current_session: board_events.BoardSession, - preserve_identity: tuple[str, str, str, int | None] | None, - ) -> None: - reloaded["session"] = current_session - reloaded["identity"] = preserve_identity - - monkeypatch.setattr(board_events, "reload_session", _fake_reload) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert reloaded["session"] is session - assert reloaded["identity"] == heading_locator(captured_node) - assert session.status_message == "Task captured" - - -def test_handle_interactive_key_a_capture_blank_input_cancels( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should close prompt with cancel status on blank template input.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert session.status_message == "Capture cancelled" - - -def test_handle_interactive_key_a_capture_invalid_shortcut_keeps_prompt_open( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should keep capture prompt open for invalid template shortcuts.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "99" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is True - assert session.status_message == "Invalid capture template shortcut" - - -def test_handle_interactive_key_a_without_templates_reports_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should report a clear status when no capture templates are configured.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {}) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is None - assert session.status_message == "No capture templates configured" - - -def test_prompt_submit_capture_stops_and_restarts_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Board prompt submission should pause live rendering for capture action.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - board_events._handle_keypress_event(session, "a") - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - - captured_node = node_from_org("* TODO Captured\n")[0] - monkeypatch.setattr( - board_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr(board_events, "reload_session", lambda _session, _identity: None) - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - controller = cast("Any", _InputControllerStub()) - - def _run_prompt_external(callback: object) -> None: - controller.suspend_live(live) - try: - cast("Any", callback)() - finally: - controller.resume_live(live) - - session.run_external = _run_prompt_external - - consumed = handle_active_prompt_event(session, KeypressEvent("ENTER")) - live.update( - board_layout._interactive_flow_board_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert events == ["input-stop", "stop", "start", "input-start", "update"] - - -def test_search_prompt_live_updates_and_escape_reverts_search() -> None: - """Board search prompt should live-update columns and ESC should restore prior filter.""" - args = make_board_args([]) - alpha, beta, done = node_from_org("* TODO Alpha\n* TODO Beta\n* DONE Finished\n") - all_columns = [ - _col("Backlog", []), - _col("TODO", [alpha, beta]), - _col("DONE", [done]), - ] - session = _make_session( - args=args, - nodes=[alpha, beta, done], - columns=all_columns, - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=all_columns, - ) - assert board_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - consumed = handle_active_prompt_event(session, KeypressEvent("b")) - live.update( - board_layout._interactive_flow_board_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Beta"], - "DONE": [], - } - assert session.search_text == "b" - assert session.status_message == "1 matches" - - consumed = handle_active_prompt_event(session, KeypressEvent("ESC")) - live.update( - board_layout._interactive_flow_board_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Alpha", "Beta"], - "DONE": ["Finished"], - } - assert session.search_text == "" - assert session.status_message == "Search cancelled" - assert events == ["update", "update"] - - -def test_handle_interactive_key_enter_saves_original_document_after_changed_edit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Changed edit should save the selected task's original document.""" + """Changed edit should reload using the selected task identity.""" args = make_board_args([]) nodes = node_from_org("* TODO Task\n") session = _make_session( @@ -1143,27 +601,27 @@ def test_handle_interactive_key_enter_saves_original_document_after_changed_edit ) source_node = nodes[0] - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=True) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=True) reloaded_identity = None - def _capture_reload(_session: board_events.BoardSession, identity: object) -> None: + def _capture_reload(_session: actions.BoardSession, identity: object) -> None: nonlocal reloaded_identity reloaded_identity = identity - monkeypatch.setattr(board_events, "edit_heading_subtree_in_external_editor", _fake_edit) - monkeypatch.setattr(board_events, "reload_session", _capture_reload) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "reload_session", _capture_reload) - assert board_events._handle_keypress_event(session, "ENTER") is True + actions.edit_selected_task_in_external_editor(session) assert session.status_message == "Task updated" assert reloaded_identity == heading_locator(source_node) -def test_handle_interactive_key_dollar_archives_selected_task( +def test_archive_selected_task_archives_selected_heading( monkeypatch: pytest.MonkeyPatch, ) -> None: - """$ should archive highlighted task using shared archive helper.""" + """Archiving the selected task should use the shared archive helper.""" args = make_board_args([]) nodes = node_from_org("* TODO Task\n") session = _make_session( @@ -1184,33 +642,32 @@ def test_handle_interactive_key_dollar_archives_selected_task( def _fake_archive( heading: Heading, _cache: dict[str, Document], - ) -> archive_command.ArchiveMoveResult: - location = archive_command.ArchiveLocation( + ) -> ArchiveMoveResult: + location = ArchiveLocation( raw_spec="%s_archive::", file_path="tasks.org_archive", parent_title=None, ) - target = archive_command.ArchiveTarget( + target = ArchiveTarget( location=location, document=heading.document, parent_heading=None, ) - return archive_command.ArchiveMoveResult( + return ArchiveMoveResult( heading=heading, target=target, source_document=heading.document, destination_document=heading.document, ) - monkeypatch.setattr(board_events, "archive_heading_subtree_and_save", _fake_archive) - monkeypatch.setattr(board_events, "reload_session", lambda _session, _identity: None) + monkeypatch.setattr(actions, "archive_heading_subtree_and_save", _fake_archive) + monkeypatch.setattr(actions, "reload_session", lambda _session, _identity: None) - assert board_events._handle_keypress_event(session, "$") is True + actions.archive_selected_task(session) assert session.status_message == "Task archived" -def test_run_flow_board_does_not_hide_unknown_or_empty_states( - capsys: pytest.CaptureFixture[str], +def test_run_board_does_not_hide_unknown_or_empty_states( monkeypatch: pytest.MonkeyPatch, ) -> None: """Unknown and empty task states should still be visible on the board.""" @@ -1218,71 +675,58 @@ def test_run_flow_board_does_not_hide_unknown_or_empty_states( args = make_board_args([fixture_path], width=120) monkeypatch.setattr(sys, "argv", ["org", "board"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Task without any state" in output assert "WAITING Custom todo state" in output -def test_run_flow_board_no_results( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_board_no_results(monkeypatch: pytest.MonkeyPatch) -> None: """Board should print No results when filters remove all tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], filter_tags=["nomatch$"]) monkeypatch.setattr(sys, "argv", ["org", "board", "--filter-tag", "nomatch$"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert output.strip() == "No results" -def test_run_flow_board_rejects_width_below_80(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_rejects_width_below_80(monkeypatch: pytest.MonkeyPatch) -> None: """Board should reject console widths below the minimum.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], width=79) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "79"]) with pytest.raises(typer.BadParameter, match="--width must be at least 80"): - board_command.run_flow_board(args) + board_command.run_board(args) -def test_run_flow_board_limit_applies_before_grouping( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_board_limit_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: """Board should respect --limit when selecting processed tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], max_results=1) monkeypatch.setattr(sys, "argv", ["org", "board", "--limit", "1"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Refactor codebase" in output assert "Fix bug in parser" not in output -def test_run_flow_board_offset_applies_before_grouping( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_board_offset_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: """Board should respect --offset when selecting processed tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], max_results=1, offset=1, days=100000) monkeypatch.setattr(sys, "argv", ["org", "board", "--offset", "1", "--limit", "1"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Refactor codebase" not in output assert "Fix bug in parser" in output -def test_run_flow_board_negative_max_results_raises_bad_parameter( +def test_run_board_negative_max_results_raises_bad_parameter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should reject negative --limit values.""" @@ -1291,10 +735,10 @@ def test_run_flow_board_negative_max_results_raises_bad_parameter( monkeypatch.setattr(sys, "argv", ["org", "board", "--limit", "-1"]) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - board_command.run_flow_board(args) + board_command.run_board(args) -def test_run_flow_board_negative_offset_raises_bad_parameter( +def test_run_board_negative_offset_raises_bad_parameter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should reject negative --offset values.""" @@ -1303,10 +747,10 @@ def test_run_flow_board_negative_offset_raises_bad_parameter( monkeypatch.setattr(sys, "argv", ["org", "board", "--offset", "-1"]) with pytest.raises(typer.BadParameter, match="--offset must be non-negative"): - board_command.run_flow_board(args) + board_command.run_board(args) -def test_run_flow_board_uses_pager_when_render_exceeds_console_height( +def test_run_board_uses_pager_when_render_exceeds_console_height( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -1332,17 +776,31 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: yield monkeypatch.setattr(console, "pager", _fake_pager) - monkeypatch.setattr("org.commands.board.command.build_console", lambda _color, _width: console) args = make_board_args([fixture_path], max_results=None) - monkeypatch.setattr(sys, "argv", ["org", "board"]) - board_command.run_flow_board(args) + args.max_results = board_command._resolve_tasks_limit(args.max_results) + nodes, discovered_todo_states, discovered_done_states = load_and_process_data( + args, + ) + nodes = actions.filter_recent_completed_nodes(nodes, args.days) + todo_states, done_states = actions.resolved_states( + args, + discovered_todo_states, + discovered_done_states, + ) + columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + ui.render_static_board( + console, + columns, + done_states=done_states, + todo_states=todo_states, + color_enabled=False, + ) assert pager_called["value"] -def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( - capsys: pytest.CaptureFixture[str], +def test_run_board_selector_uses_full_nodes_from_multiple_files( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -1355,28 +813,26 @@ def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( second_handle.write("* TODO Second file task\n") args = make_board_args([first_path, second_path], view="kanban", width=160) - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="TODO", filter='str(.todo) == "TODO"'), + org.config.app.BoardColumnConfig(name="TODO", filter='str(.todo) == "TODO"'), ], ) try: monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "160"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "First file task" in output assert "Second file task" in output finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_coalesce_completed_true_shows_completed_column( - capsys: pytest.CaptureFixture[str], +def test_run_board_coalesce_completed_true_shows_completed_column( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector view should include DONE column for completed tasks.""" @@ -1390,15 +846,13 @@ def test_run_flow_board_coalesce_completed_true_shows_completed_column( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "DONE" in output assert "Completed task" in output -def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( - capsys: pytest.CaptureFixture[str], +def test_run_board_coalesce_completed_true_prefixes_state_in_panel( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback DONE selector should include all completed state tasks.""" @@ -1412,16 +866,14 @@ def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Completed task" in output assert "Custom done state" in output assert "Another done state" in output -def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( - capsys: pytest.CaptureFixture[str], +def test_run_board_coalesce_completed_false_shows_individual_done_columns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector view should not render legacy COMPLETED header.""" @@ -1435,8 +887,7 @@ def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "COMPLETED" not in output assert "DONE" in output @@ -1444,8 +895,7 @@ def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( assert "Another done state" in output -def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo_columns( - capsys: pytest.CaptureFixture[str], +def test_run_board_coalesce_completed_false_done_columns_ordered_after_todo_columns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector headers should stay in Backlog/TODO/DONE order.""" @@ -1459,8 +909,7 @@ def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) pos_in_progress = output.find("TODO") pos_done = output.find("DONE") @@ -1470,8 +919,7 @@ def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo assert pos_in_progress < pos_done -def test_run_flow_board_coalesce_completed_false_tasks_in_correct_columns( - capsys: pytest.CaptureFixture[str], +def test_run_board_coalesce_completed_false_tasks_in_correct_columns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback DONE selector should show all completed-state tasks.""" @@ -1485,31 +933,27 @@ def test_run_flow_board_coalesce_completed_false_tasks_in_correct_columns( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Completed task" in output assert "Custom done state" in output assert "Another done state" in output -def test_run_flow_board_uses_configured_view_columns( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyPatch) -> None: """Requested configured view should render configured columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view="kanban", width=150) - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Backlog", filter=".todo == null", ), - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Working", filter=".todo != null and not(.is_completed)", ), @@ -1518,66 +962,68 @@ def test_run_flow_board_uses_configured_view_columns( try: monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "150"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Backlog" in output assert "Working" in output finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_missing_requested_view_raises() -> None: +def test_run_board_missing_requested_view_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Missing requested view should return explicit BadParameter.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="missing") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["other"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["other"] = org.config.app.BoardViewConfig( name="other", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="TODO", filter='.todo == "TODO"', ), ], ) try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="Requested board view not found"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_requested_view_without_configured_views_raises() -> None: +def test_run_board_requested_view_without_configured_views_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Explicit --view should fail when no configured views exist.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="no board views are configured"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_uses_default_view_from_config( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_board_uses_default_view_from_config(monkeypatch: pytest.MonkeyPatch) -> None: """Config-defaulted view value should drive board view resolution.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view=None, width=150) - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="Backlog", filter=".todo == null"), - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig( name="Working", filter=".todo != null and not(.is_completed)", ), @@ -1587,49 +1033,52 @@ def test_run_flow_board_uses_default_view_from_config( try: # Simulate Typer default_map applying defaults: --view=kanban args.view = "kanban" - specs = board_events.resolve_column_specs(args) + specs = actions.resolve_column_specs(args) assert [spec.name for spec in specs] == ["Backlog", "Working"] monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "150"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Backlog" in output finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context() -> None: +def test_run_board_invalid_filter_or_order_by_parse_error_has_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Filter/order-by parse failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="Broken", filter=".todo =="), + org.config.app.BoardColumnConfig(name="Broken", filter=".todo =="), ], ) try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( +def test_run_board_invalid_filter_or_order_by_runtime_error_has_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Filter/order-by runtime failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Broken", filter="unknown_fn(.todo)", ), @@ -1640,24 +1089,28 @@ def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( "load_and_process_data", lambda _args: ([node_from_org("* TODO Task\n")[0]], ["TODO"], ["DONE"]), ) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) try: with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_order_by_parse_error_has_context() -> None: +def test_run_board_invalid_order_by_parse_error_has_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Invalid order-by parse should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Broken", filter='.todo == "TODO"', order_by=".priority ==", @@ -1665,14 +1118,16 @@ def test_run_flow_board_invalid_order_by_parse_error_has_context() -> None: ], ) try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises( typer.BadParameter, match=r"Invalid board filter/order-by \(view=kanban, column=Broken\)", ): - board_command.run_flow_board(args) + board_command.run_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_apply_state_move_reload_reassigns_task_across_selector_columns( @@ -1681,22 +1136,22 @@ def test_apply_state_move_reload_reassigns_task_across_selector_columns( """State changes should reload and move task to new selector column.""" args = make_board_args([], view="kanban", days=100000) source_node = node_from_org("#+TODO: TODO | DONE\n* TODO Task\n")[0] - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), - config_module.BoardColumnConfig(name="DONE", filter='.todo == "DONE"'), + org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), + org.config.app.BoardColumnConfig(name="DONE", filter='.todo == "DONE"'), ], ) session = _make_session( args=args, nodes=[source_node], - columns=board_events.build_selector_board_columns( + columns=actions.build_selector_board_columns( [source_node], - board_events.resolve_column_specs(args), + actions.resolve_column_specs(args), ), color_enabled=False, selected_column_index=0, @@ -1723,19 +1178,19 @@ def _load_after_change( ) return ([source_node], ["TODO"], ["DONE"]) - monkeypatch.setattr(board_events, "_save_document_changes", _capture_save) - monkeypatch.setattr(board_events, "load_and_process_data", _load_after_change) + monkeypatch.setattr(actions, "_save_document_changes", _capture_save) + monkeypatch.setattr(actions, "load_and_process_data", _load_after_change) try: - board_events.apply_state_move(session, direction=1) + actions.apply_state_move(session, direction=1) assert saved_documents == [source_node.document] assert session.selected_column_index == 1 - selected = board_events.selected_node(session) + selected = actions.selected_node(session) assert selected is not None assert selected.todo == "DONE" finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_build_task_panel_renders_rich_title_content() -> None: @@ -1748,9 +1203,9 @@ def test_build_task_panel_renders_rich_title_content() -> None: ), ) - panel = board_layout.build_task_panel( + panel = ui.build_task_panel( nodes[0], - board_layout.BoardPanelRenderConfig( + ui.BoardPanelRenderConfig( width=60, color_enabled=True, done_states=["DONE"], @@ -1781,8 +1236,7 @@ def test_build_task_panel_renders_rich_title_content() -> None: assert "call_fn(1)" in plain -def test_run_flow_board_renders_rich_title_plain_output( - capsys: pytest.CaptureFixture[str], +def test_run_board_renders_rich_title_plain_output( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -1796,8 +1250,7 @@ def test_run_flow_board_renders_rich_title_plain_output( args = make_board_args([fixture_path], width=120) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "120"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Bold" in output assert "Italic" in output @@ -1824,9 +1277,9 @@ def test_build_task_panel_shows_colored_todo_state_prefix() -> None: """Task panels should show and colorize current TODO state.""" node = node_from_org("#+TODO: TODO WAITING | DONE\n* TODO Task\n")[0] - panel = board_layout.build_task_panel( + panel = ui.build_task_panel( node, - board_layout.BoardPanelRenderConfig( + ui.BoardPanelRenderConfig( width=60, color_enabled=True, done_states=["DONE"], @@ -1845,9 +1298,9 @@ def test_build_task_panel_without_todo_state_has_no_prefix() -> None: """Task panels without TODO state should keep heading text unchanged.""" node = node_from_org("* Task without state\n")[0] - panel = board_layout.build_task_panel( + panel = ui.build_task_panel( node, - board_layout.BoardPanelRenderConfig( + ui.BoardPanelRenderConfig( width=60, color_enabled=True, done_states=["DONE"], diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py new file mode 100644 index 0000000..bbf7289 --- /dev/null +++ b/tests/commands/test_board_app.py @@ -0,0 +1,297 @@ +"""App tests for the board command.""" + +from __future__ import annotations + +import asyncio +import os +import sys +from typing import TYPE_CHECKING, Any, cast + +from textual.widgets import Input, OptionList, Static + +import org.config.app +from org.commands.board import actions +from org.commands.board import command as board_command +from org.commands.board.app import BoardApp, BoardViewport +from org.commands.tasks import capture as capture_command +from org.logic.tasks import heading_locator +from tests.commands.test_board import make_board_args +from tests.conftest import node_from_org + + +if TYPE_CHECKING: + import pytest + from org_parser.document import Heading + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") + + +def _col(title: str, nodes: list[Heading]) -> actions.BoardColumn: + return actions.BoardColumn(title, nodes) + + +def _make_session( + args: board_command.BoardArgs, + nodes: list[Heading], + **overrides: object, +) -> actions.BoardSession: + resolved_columns = cast("list[actions.BoardColumn] | None", overrides.pop("columns", None)) + resolved_all_columns = cast( + "list[actions.BoardColumn] | None", + overrides.pop("all_columns", None), + ) + session = actions.BoardSession( + args=args, + nodes=nodes, + todo_states=["TODO"], + done_states=["DONE"], + columns=[_col("TODO", nodes)] if resolved_columns is None else resolved_columns, + color_enabled=False, + selected_column_index=0, + selected_row_index=0, + scroll_offset=0, + status_message="", + all_columns=[], + search_text="", + ) + session.all_columns = session.columns if resolved_all_columns is None else resolved_all_columns + for key, value in overrides.items(): + setattr(session, key, value) + return session + + +def _make_app(session: actions.BoardSession) -> BoardApp: + return BoardApp(session) + + +def _visible_board_titles_by_column(app: BoardApp) -> dict[str, list[str]]: + return { + column.title: [node.title_text for node in column.nodes] for column in app.session.columns + } + + +def test_board_app_navigation_help_and_separators() -> None: + """Board app should support navigation, help forwarding, and footer separators.""" + + async def _run() -> None: + first, second = node_from_org("* TODO First\n* TODO Second\n") + session = _make_session( + make_board_args([]), + [first, second], + columns=[ + _col("Backlog", []), + _col("TODO", [first]), + _col("WAITING", []), + _col("INPROGRESS", [second]), + ], + selected_column_index=1, + ) + app = _make_app(session) + + async with app.run_test() as pilot: + assert app.screen.query_one("#board-footer-rule", Static) is not None + + await pilot.press("down") + assert app.session.selected_row_index == 0 + + await pilot.press("right") + assert app.session.selected_column_index == 3 + + await pilot.press("left") + assert app.session.selected_column_index == 1 + app.action_show_help() + await pilot.pause() + + assert app.screen.query_one("#help-content", Static) is not None + await pilot.press("right") + await pilot.pause() + + assert app.session.selected_column_index == 3 + assert app.screen.query_one("#board-body", BoardViewport) is not None + + asyncio.run(_run()) + + +def test_board_app_search_prompt_filters_columns_live_and_escape_restores() -> None: + """Search modal should live-filter each column and escape should restore the previous search.""" + + async def _run() -> None: + alpha, beta, beta_done = node_from_org("* TODO Alpha\n* TODO Beta\n* DONE Beta done\n") + all_columns = [ + _col("Backlog", []), + _col("TODO", [alpha, beta]), + _col("DONE", [beta_done]), + ] + session = _make_session( + make_board_args([]), + [alpha, beta, beta_done], + columns=all_columns, + all_columns=all_columns, + selected_column_index=1, + ) + app = _make_app(session) + + async with app.run_test() as pilot: + app.action_prompt_search() + await pilot.pause() + + prompt_input = app.screen.query_one(Input) + prompt_input.value = "beta" + prompt_screen = cast("Any", app.screen_stack[-1]) + prompt_screen.on_input_changed(Input.Changed(prompt_input, "beta")) + + assert app.session.search_text == "beta" + assert _visible_board_titles_by_column(app) == { + "Backlog": [], + "TODO": ["Beta"], + "DONE": ["Beta done"], + } + + await pilot.press("escape") + await pilot.pause() + + assert app.session.search_text == "" + assert app.session.status_message == "Search cancelled" + assert _visible_board_titles_by_column(app) == { + "Backlog": [], + "TODO": ["Alpha", "Beta"], + "DONE": ["Beta done"], + } + + asyncio.run(_run()) + + +def test_board_app_enter_edits_selected_task(monkeypatch: pytest.MonkeyPatch) -> None: + """Enter should trigger external-editor handling on the selected task card.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Task\n") + monkeypatch.setattr( + actions, + "edit_selected_task_in_external_editor", + lambda _session: None, + ) + app = _make_app(_make_session(make_board_args([]), nodes)) + app.session.status_message = "No changes." + + async with app.run_test() as pilot: + await pilot.press("enter") + assert app.session.status_message == "No changes." + + asyncio.run(_run()) + + +def test_board_app_capture_prompt_submits_and_reloads(monkeypatch: pytest.MonkeyPatch) -> None: + """Capture prompt submission should create a task and reload board state.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Existing\n") + captured_node = node_from_org("* TODO Captured\n")[0] + reloaded: dict[str, object] = {} + monkeypatch.setattr( + org.config.app, + "CONFIG_CAPTURE_TEMPLATES", + {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, + ) + monkeypatch.setattr( + actions, + "capture_task", + lambda _args: capture_command.TasksCaptureResult( + template_name="quick", + heading=captured_node, + document=captured_node.document, + ), + ) + monkeypatch.setattr( + actions, + "reload_session", + lambda _session, preserve_identity: reloaded.update(identity=preserve_identity), + ) + + app = _make_app(_make_session(make_board_args([]), nodes)) + + async with app.run_test() as pilot: + app.action_prompt_capture() + await pilot.pause() + assert app.screen.query_one(OptionList).has_focus + await pilot.press("down", "enter") + await pilot.pause() + + assert reloaded["identity"] == heading_locator(captured_node) + assert app.session.status_message == "Task captured" + + asyncio.run(_run()) + + +def test_board_app_capture_without_templates_sets_status(monkeypatch: pytest.MonkeyPatch) -> None: + """Capture should report a clear status when no templates are configured.""" + + async def _run() -> None: + monkeypatch.setattr(org.config.app, "CONFIG_CAPTURE_TEMPLATES", {}) + app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) + + async with app.run_test(): + app.action_prompt_capture() + assert app.session.status_message == "No capture templates configured" + + asyncio.run(_run()) + + +def test_board_app_shift_bindings_trigger_state_and_priority_actions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shift actions should dispatch state and priority mutations.""" + + async def _run() -> None: + calls: list[str] = [] + monkeypatch.setattr( + actions, + "apply_state_move", + lambda _session, *, direction: calls.append(f"state:{direction}"), + ) + monkeypatch.setattr( + actions, + "apply_priority_shift", + lambda _session, *, increase: calls.append(f"priority:{increase}"), + ) + app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) + + async with app.run_test(): + app.action_move_state_left() + app.action_move_state_right() + app.action_increase_priority() + app.action_decrease_priority() + + assert calls == ["state:-1", "state:1", "priority:True", "priority:False"] + + asyncio.run(_run()) + + +def test_run_board_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive board path should delegate to the Textual app runner.""" + fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = make_board_args([fixture_path], days=100000) + called = {"value": False} + + def _fake_run( + passed_args: board_command.BoardArgs, + nodes: list[Heading], + todo_states: list[str], + done_states: list[str], + color_enabled: bool, + ) -> None: + called["value"] = True + assert passed_args.files == [fixture_path] + assert nodes + assert todo_states + assert done_states + assert color_enabled is False + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("org.commands.board.command.run_board_app", _fake_run) + + board_command.run_board(args) + + assert called["value"] is True diff --git a/tests/commands/test_interactive_common.py b/tests/commands/test_interactive_common.py index 616f46a..3310a4b 100644 --- a/tests/commands/test_interactive_common.py +++ b/tests/commands/test_interactive_common.py @@ -1,189 +1,19 @@ -"""Tests for shared interactive helpers.""" +"""Tests for shared interactive helpers still used in production.""" from __future__ import annotations -from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast -from rich.console import Console - -from org.commands.interactive_common import ( - BRACKETED_PASTE_DISABLE, - BRACKETED_PASTE_ENABLE, - FooterPromptState, +from org.commands.tasks.common import todo_states_for_heading +from org.logic.tasks import ( HeadingLocator, - InputEvent, - InteractiveHelpEntry, - InteractivePromptState, - KeypressEvent, - create_interactive_prompt_state, - extract_bracketed_paste_text, - handle_active_prompt_event, heading_locator, - read_input_event, - render_interactive_help_modal, - render_interactive_help_panel_text, resolve_heading_locator, - set_bracketed_paste, ) -from org.commands.tasks.common import resolve_todo_state_selection, todo_states_for_heading +from org.tui.help import InteractiveHelpEntry, render_interactive_help_panel_text from tests.conftest import node_from_org -if TYPE_CHECKING: - import pytest - - -class _PromptConfig: - def __init__(self, label: str) -> None: - self.prompt = FooterPromptState(label=label) - self.cancel_status = "Cancelled" - self.invalid_status = "Invalid" - - -class _PromptOwner: - def __init__(self, prompt: InteractivePromptState | None) -> None: - self.active_prompt = prompt - self.status_message = "" - - -def _submit_and_close(values: list[str], value: str) -> bool: - values.append(value) - return False - - -def test_create_interactive_prompt_state_uses_submit_and_preview_callbacks() -> None: - """Prompt state should forward current value to submit and preview callbacks.""" - previewed: list[str] = [] - submitted: list[str] = [] - - prompt = create_interactive_prompt_state( - _PromptConfig("Search"), - submit_value=lambda value: _submit_and_close(submitted, value), - preview_value=previewed.append, - ) - prompt.prompt.value = "needle" - - assert prompt.preview is not None - prompt.preview() - keep_open = prompt.submit_callback() - - assert previewed == ["needle"] - assert submitted == ["needle"] - assert keep_open is False - - -def test_handle_active_prompt_event_closes_prompt_on_escape() -> None: - """Escape should close active prompt and set cancel status by default.""" - owner = _PromptOwner( - InteractivePromptState( - prompt=FooterPromptState(label="Search"), - cancel_status="Cancelled", - invalid_status="Invalid", - submit_callback=lambda: False, - ), - ) - - assert handle_active_prompt_event(owner, KeypressEvent("ESC")) is True - assert owner.active_prompt is None - assert owner.status_message == "Cancelled" - - -def test_handle_active_prompt_event_applies_input_and_submit() -> None: - """Prompt event handling should update prompt text and close after submit.""" - submitted: list[str] = [] - prompt = create_interactive_prompt_state( - _PromptConfig("Search"), - submit_value=lambda value: _submit_and_close(submitted, value), - ) - owner = _PromptOwner(prompt) - - assert handle_active_prompt_event(owner, InputEvent("abc")) is True - assert owner.active_prompt is not None - assert owner.active_prompt.prompt.value == "abc" - - assert handle_active_prompt_event(owner, KeypressEvent("ENTER")) is True - assert submitted == ["abc"] - assert owner.active_prompt is None - - -def test_extract_bracketed_paste_text_decodes_payload() -> None: - """Bracketed paste payload should decode inserted text.""" - payload = b"\x1b[200~Line one\nLine two\x1b[201~" - assert extract_bracketed_paste_text(payload) == "Line one\nLine two" - - -def test_read_input_event_maps_bracketed_paste_to_text(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste input should be surfaced as a TEXT event.""" - monkeypatch.setattr("org.commands.interactive_common.sys.stdin.fileno", lambda: 0) - monkeypatch.setattr("org.commands.interactive_common.os.read", lambda _fd, _n: b"\x1b") - monkeypatch.setattr( - "org.commands.interactive_common.read_escape_sequence", - lambda _fd: b"\x1b[200~Paste value", - ) - monkeypatch.setattr( - "org.commands.interactive_common.read_bracketed_paste_payload", - lambda _fd, initial_payload: initial_payload + b"\x1b[201~", - ) - assert read_input_event(ctrl_p_as_paste=True) == ("TEXT", "Paste value") - - -def test_read_input_event_returns_none_when_timeout_expires( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Timed input reads should return None when no input arrives.""" - - def _fake_select( - _readers: object, - _writers: object, - _errors: object, - _timeout: object, - ) -> tuple[list[int], list[int], list[int]]: - return ([], [], []) - - monkeypatch.setattr("org.commands.interactive_common.select.select", _fake_select) - monkeypatch.setattr("org.commands.interactive_common.sys.stdin.fileno", lambda: 0) - - assert read_input_event(timeout_seconds=0.1) is None - - -def test_set_bracketed_paste_writes_terminal_sequences(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste toggles should emit proper terminal sequences.""" - - class _FakeStdout: - def __init__(self) -> None: - self.writes: list[str] = [] - - def isatty(self) -> bool: - return True - - def write(self, value: str) -> int: - self.writes.append(value) - return len(value) - - def flush(self) -> None: - return None - - fake_stdout = _FakeStdout() - monkeypatch.setattr("org.commands.interactive_common.sys.stdout", fake_stdout) - - set_bracketed_paste(True) - set_bracketed_paste(False) - - assert fake_stdout.writes == [BRACKETED_PASTE_ENABLE, BRACKETED_PASTE_DISABLE] - - -def test_resolve_todo_state_selection_supports_number_and_value() -> None: - """State selection should resolve numeric indexes and explicit values.""" - states = ["TODO", "NEXT", "DONE"] - - assert resolve_todo_state_selection("2", states) == "NEXT" - assert resolve_todo_state_selection("DONE", states) == "DONE" - assert resolve_todo_state_selection("", states) is None - assert resolve_todo_state_selection("99", states) is None - assert resolve_todo_state_selection("UNKNOWN", states) is None - - def test_todo_states_for_heading_returns_stable_unique_order() -> None: """TODO states helper should deduplicate while preserving first appearance order.""" @@ -198,24 +28,6 @@ class _FakeHeading: assert todo_states_for_heading(fake_heading) == ["TODO", "DONE", "WAIT"] -def test_render_interactive_help_modal_keeps_key_column_wide_without_ellipsis() -> None: - """Help modal should avoid key ellipsis and keep long key text visible.""" - entries = [ - InteractiveHelpEntry( - "CTRL-SHIFT-SUPER-LEFT", - "Very long description text that should wrap instead of being cut with ellipsis.", - ), - ] - renderable = render_interactive_help_modal(entries, color_enabled=False) - output_stream = StringIO() - output_console = Console(file=output_stream, width=40, force_terminal=False, no_color=True) - output_console.print(renderable) - output = output_stream.getvalue() - - assert "..." not in output - assert "CTRL-SHIFT-SUPER-LEFT" in output - - def test_render_interactive_help_panel_text_includes_title_and_rows() -> None: """Rendered help text should include key-bindings title and entries.""" entries = [ diff --git a/tests/commands/test_runtime.py b/tests/commands/test_runtime.py new file mode 100644 index 0000000..604bc42 --- /dev/null +++ b/tests/commands/test_runtime.py @@ -0,0 +1,88 @@ +"""Tests for shared interactive runtime helpers.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.widgets import OptionList, Static + +import org.tui.app +import org.tui.selection + + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class _SelectionHarnessApp(org.tui.app.CommandApp): + """Minimal app for exercising the shared selection modal.""" + + BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "quit_app", show=False)] + + def __init__(self) -> None: + super().__init__() + self.result: str | None = None + + def compose(self) -> ComposeResult: + yield Vertical(Static("body")) + + def on_mount(self) -> None: + self.push_screen( + org.tui.selection.SelectionModalScreen( + "Choose value", + [ + org.tui.selection.SelectionOption(value="alpha", label="alpha"), + org.tui.selection.SelectionOption(value="beta", label="beta"), + ], + ), + callback=self._store_result, + ) + + def _store_result(self, value: str | None) -> None: + self.result = value + + def action_quit_app(self) -> None: + """Exit the harness app.""" + self.exit() + + +def test_selection_modal_supports_keyboard_navigation_and_submit() -> None: + """Selection modal should support keyboard-only navigation and submit.""" + + async def _run() -> None: + app = _SelectionHarnessApp() + + async with app.run_test() as pilot: + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert app.result == "beta" + + asyncio.run(_run()) + + +def test_selection_modal_escape_cancels_without_exiting_parent_app() -> None: + """Escape should cancel the modal without triggering parent app bindings.""" + + async def _run() -> None: + app = _SelectionHarnessApp() + + async with app.run_test() as pilot: + await pilot.pause() + assert app.screen.query_one("#selection-label", Static) is not None + + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + assert app.is_running + + asyncio.run(_run()) diff --git a/tests/commands/test_tasks_capture_command.py b/tests/commands/test_tasks_capture_command.py index 5bbdd4c..0228ab4 100644 --- a/tests/commands/test_tasks_capture_command.py +++ b/tests/commands/test_tasks_capture_command.py @@ -3,22 +3,18 @@ from __future__ import annotations import re -from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING import org_parser import pytest import typer -from rich.console import Console -from org import config -from org.commands import interactive_common +import org.config.app from org.commands.tasks import capture if TYPE_CHECKING: from pathlib import Path - from typing import Self def _make_capture_args( @@ -40,16 +36,16 @@ def _make_capture_args( def _set_capture_templates(templates: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: """Replace global capture templates and return previous value.""" - previous = dict(config.CONFIG_CAPTURE_TEMPLATES) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(templates) + previous = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(templates) return previous def _restore_capture_templates(previous: dict[str, dict[str, str]]) -> None: """Restore global capture templates from previous value.""" - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(previous) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(previous) def test_run_capture_errors_when_no_templates_configured() -> None: @@ -57,7 +53,7 @@ def test_run_capture_errors_when_no_templates_configured() -> None: previous = _set_capture_templates({}) try: with pytest.raises(typer.BadParameter, match="No capture templates configured"): - capture.run_tasks_capture(_make_capture_args(template_name=None)) + capture.run_tasks_capture(_make_capture_args(template_name="quick")) finally: _restore_capture_templates(previous) @@ -72,149 +68,161 @@ def test_run_capture_errors_for_unknown_template_name() -> None: ) try: with pytest.raises(typer.BadParameter, match="Valid templates: alpha, beta"): - capture.run_tasks_capture(_make_capture_args(template_name="missing")) + capture.capture_task(_make_capture_args(template_name="missing")) finally: _restore_capture_templates(previous) -def test_run_capture_selects_template_by_numeric_prompt( +def test_capture_task_errors_when_template_omitted_in_noninteractive_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-interactive capture should require an explicit template name.""" + previous = _set_capture_templates( + {"quick": {"file": "tasks.org", "content": "* TODO Static"}}, + ) + try: + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: False, + ) + with pytest.raises(typer.BadParameter, match="template name is required"): + capture.capture_task(_make_capture_args(template_name=None)) + finally: + _restore_capture_templates(previous) + + +def test_capture_task_errors_when_placeholders_missing_in_noninteractive_mode( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Capture should resolve template selection from numeric interactive input.""" + """Non-interactive capture should fail on unresolved placeholders.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "alpha": {"file": str(target), "content": "* TODO From alpha"}, - "beta": {"file": str(target), "content": "* TODO From beta"}, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}}, ) try: - monkeypatch.setattr("org.commands.tasks.capture.typer.prompt", lambda _msg: "2") - capture.run_tasks_capture(_make_capture_args(template_name=None)) + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: False, + ) + with pytest.raises( + typer.BadParameter, + match="Missing placeholder values for: title, owner", + ): + capture.capture_task(_make_capture_args(template_name="quick")) finally: _restore_capture_templates(previous) - updated = target.read_text(encoding="utf-8") - assert "From beta" in updated - assert "From alpha" not in updated - -def test_run_capture_prompts_non_static_placeholder_once( +def test_capture_task_uses_interactive_template_selection_and_form( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Repeated non-static placeholders should be prompted once and reused.""" + """Interactive capture should use the selection app and form app helpers.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( { - "quick": { - "file": str(target), - "content": "* TODO {{title}} then {{title}}", - }, + "alpha": {"file": str(target), "content": "* TODO Alpha"}, + "beta": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}, }, ) - - calls: list[str] = [] - - def _prompt(message: str, **_kwargs: object) -> str: - calls.append(message) - return "Write docs" - try: - monkeypatch.setattr("org.commands.tasks.capture.typer.prompt", _prompt) - capture.run_tasks_capture(_make_capture_args(template_name="quick")) + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_template_selection_app", + lambda _names: "beta", + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, + ) + + result = capture.capture_task(_make_capture_args(template_name=None)) finally: _restore_capture_templates(previous) - updated = target.read_text(encoding="utf-8") - assert "* TODO Write docs then Write docs" in updated - assert calls == ["Value for 'title'"] + assert result.template_name == "beta" + assert result.interactive_used is True + assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") -def test_capture_task_returns_created_heading_metadata( +def test_capture_task_uses_form_app_for_unresolved_placeholders( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Reusable capture API should return metadata for the created heading.""" + """Interactive capture should use the form app when placeholders are unresolved.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}", - }, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, ) - try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Write docs", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "From form"}, ) + result = capture.capture_task(_make_capture_args(template_name="quick")) finally: _restore_capture_templates(previous) - assert result.template_name == "quick" - assert result.document.filename == str(target) - assert result.heading.title_text == "Write docs" assert result.interactive_used is True - assert "* TODO Write docs" in target.read_text(encoding="utf-8") + assert result.heading.title_text == "From form" -def test_run_capture_non_interactive_prints_created_task_id( - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - """Non-interactive capture should print new task ID when available.""" +def test_capture_task_returns_created_heading_metadata_noninteractive(tmp_path: Path) -> None: + """Non-interactive capture should return created heading metadata.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-42\n:END:", - }, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) + result = capture.capture_task( + _make_capture_args(template_name="quick", set_values=["title=Write docs"]), + ) finally: _restore_capture_templates(previous) - assert capsys.readouterr().out.strip() == "task-42" + assert result.template_name == "quick" + assert result.document.filename == str(target) + assert result.heading.title_text == "Write docs" + assert result.interactive_used is False -def test_run_capture_non_interactive_prints_created_task_title_when_id_missing( +def test_run_tasks_capture_noninteractive_prints_created_task_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - """Non-interactive capture should print title when task ID is absent.""" + """Non-interactive capture should print new task ID when available.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( { "quick": { "file": str(target), - "content": "* TODO {{title}}", + "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-42\n:END:", }, }, ) - try: capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) finally: _restore_capture_templates(previous) - assert capsys.readouterr().out.strip() == "From set" + assert capsys.readouterr().out.strip() == "task-42" -def test_run_capture_interactive_does_not_print_created_identifier( +def test_run_tasks_capture_interactive_does_not_print_identifier( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -223,18 +231,16 @@ def test_run_capture_interactive_does_not_print_created_identifier( target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}", - }, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, ) - try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Write docs", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Write docs"}, ) capture.run_tasks_capture(_make_capture_args(template_name="quick")) finally: @@ -251,406 +257,45 @@ def test_render_template_preview_tracks_placeholder_spans() -> None: ) assert preview.text == "* TODO Write docs [Write docs] {{owner}}" - title_spans = preview.placeholder_spans["title"] - owner_spans = preview.placeholder_spans["owner"] - - assert title_spans == [(7, 17), (19, 29)] - assert owner_spans == [(31, 40)] + assert preview.placeholder_spans["title"] == [(7, 17), (19, 29)] + assert preview.placeholder_spans["owner"] == [(31, 40)] -def test_resolve_placeholder_values_uses_live_preview_when_supported( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Interactive placeholders should route through live preview when available.""" - calls: list[tuple[str, list[str], dict[str, str]]] = [] - - def _fake_live_prompt( - template_content: str, - placeholders: list[str], - values: dict[str, str], - ) -> dict[str, str]: - calls.append((template_content, placeholders, values)) - return { - **values, - "title": "From live preview", - } - - monkeypatch.setattr("org.commands.tasks.capture._supports_live_template_prompt", lambda: True) - monkeypatch.setattr("org.commands.tasks.capture._prompt_with_live_preview", _fake_live_prompt) - - result, prompted = capture._resolve_placeholder_values( - "* TODO {{title}}", - ["title"], - set_values={}, - static_values={}, - document_values={}, - ) - - assert result == {"title": "From live preview"} - assert prompted is True - assert calls == [("* TODO {{title}}", ["title"], {})] - - -def test_prompt_with_live_preview_updates_current_field_sequence( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Live prompt should update preview in field order and finalize after input.""" - - class _FakeLive: - def __init__(self, initial_renderable: object, **kwargs: object) -> None: - self.initial_renderable = initial_renderable - self.updates: list[object] = [] - self.kwargs = kwargs - - class _FakeSize: - width = 80 - - class _FakeConsole: - size = _FakeSize() - - self.console = _FakeConsole() - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - _exc_type: type[BaseException] | None, - _exc: BaseException | None, - _tb: object, - ) -> None: - return None - - def update(self, renderable: object, *, refresh: bool) -> None: - assert refresh - self.updates.append(renderable) - - fake_live: _FakeLive | None = None - - def _live_factory(initial_renderable: object, **kwargs: object) -> _FakeLive: - nonlocal fake_live - fake_live = _FakeLive(initial_renderable, **kwargs) - return fake_live - - def _renderable( - _template_content: str, - values: dict[str, str], - footer_state: capture._FooterState, - console_width: int, - ) -> tuple[dict[str, str], capture._FooterState, int]: - return ( - dict(values), - footer_state, - console_width, - ) - - entered_values = iter(["Write docs", "Jane"]) - - def _read_value( - _live: _FakeLive, - _template_content: str, - _resolved_values: dict[str, str], - _active_field: capture._ActiveField, - ) -> str: - return next(entered_values) - - live_path = "org.commands.tasks.capture.Live" - render_path = "org.commands.tasks.capture._build_fullscreen_capture_renderable" - read_value_path = "org.commands.tasks.capture._read_live_placeholder_value" - width_path = "org.commands.tasks.capture._resolve_terminal_width" - fileno_path = "org.commands.tasks.capture.sys.stdin.fileno" - tcgetattr_path = "org.commands.tasks.capture.termios.tcgetattr" - - monkeypatch.setattr(live_path, _live_factory) - monkeypatch.setattr(render_path, _renderable) - monkeypatch.setattr(read_value_path, _read_value) - monkeypatch.setattr(width_path, lambda: 80) - monkeypatch.setattr(fileno_path, lambda: 0) - monkeypatch.setattr(tcgetattr_path, lambda _fd: [0, 0, 0, 0, 0, 0]) - - resolved = capture._prompt_with_live_preview( - "* TODO {{title}} @{{owner}}", - ["title", "owner"], - {}, - ) - - assert resolved == {"title": "Write docs", "owner": "Jane"} - assert fake_live is not None - assert fake_live.initial_renderable == ( - {}, - capture._FooterState( - current_placeholder="title", - current_input_value="", - cursor_position=0, - current_field_index=1, - total_fields=2, - ), - 80, - ) - assert fake_live.kwargs["screen"] is True - assert fake_live.kwargs["refresh_per_second"] == 12 - assert fake_live.kwargs["auto_refresh"] is False - assert fake_live.kwargs["transient"] is False - assert fake_live.updates == [ - ( - {"title": "Write docs", "owner": "Jane"}, - capture._FooterState( - current_placeholder=None, - current_input_value="", - cursor_position=0, - current_field_index=None, - total_fields=2, - ), - 80, - ), - ] - - -def test_read_live_placeholder_value_keeps_raw_mode_for_full_input( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Live placeholder input should keep raw mode enabled while reading all events.""" - - class _FakeSize: - width = 80 - - class _FakeConsole: - size = _FakeSize() - - class _FakeLive: - def __init__(self) -> None: - self.console = _FakeConsole() - self.updates: list[object] = [] - - def update(self, renderable: object, *, refresh: bool) -> None: - assert refresh - self.updates.append(renderable) - - fake_live = _FakeLive() - events = iter( - [ - ("TEXT", "Pasted text"), - ("ENTER", ""), - ], - ) - setcbreak_calls: list[int] = [] - restore_calls: list[tuple[int, int, list[int]]] = [] - - def _setcbreak(fd: int) -> None: - setcbreak_calls.append(fd) - - monkeypatch.setattr("org.commands.tasks.capture.sys.stdin.fileno", lambda: 7) - monkeypatch.setattr( - "org.commands.tasks.capture.termios.tcgetattr", - lambda _fd: [1, 2, 3, 4, 5, 6], - ) - monkeypatch.setattr("org.commands.tasks.capture.tty.setcbreak", _setcbreak) - monkeypatch.setattr( - "org.commands.tasks.capture.termios.tcsetattr", - lambda fd, when, mode: restore_calls.append((fd, when, mode)), - ) - monkeypatch.setattr( - "org.commands.tasks.capture.read_input_event", - lambda **_kwargs: next(events), - ) - - value = capture._read_live_placeholder_value( - cast("Any", fake_live), - "* TODO {{title}}", - {}, - capture._ActiveField(placeholder="title", field_index=1, total_fields=1), - ) - - assert value == "Pasted text" - assert setcbreak_calls == [7] - assert len(restore_calls) == 1 - assert restore_calls[0][0] == 7 - - -def test_build_footer_prompt_includes_field_index_marker() -> None: - """Footer prompt should render editable value input only.""" - footer = capture._build_footer_prompt( - capture._FooterState( - current_placeholder="owner", - current_input_value="Jane", - cursor_position=4, - current_field_index=2, - total_fields=5, - ), - ) - assert footer.plain.startswith("Value for 'owner': Jane") - - -def test_build_footer_status_line_displays_marker_and_controls() -> None: - """Footer status line should show left marker and right controls.""" - footer_state = capture._FooterState( - current_placeholder="owner", - current_input_value="Jane", - cursor_position=4, - current_field_index=2, - total_fields=5, - ) - status_line = capture._build_footer_status_line(footer_state) - assert status_line.columns[1].justify == "right" - assert capture._value_progress_marker(footer_state) == "Value 2/5" - - -def test_capture_help_modal_key_toggle_helper() -> None: - """Question mark should open help modal and any key should close it.""" - consumed, show = interactive_common.apply_help_modal_key("?", show_help_modal=False) - assert consumed is True - assert show is True - - consumed_close, show_close = interactive_common.apply_help_modal_key( - "ENTER", - show_help_modal=True, - ) - assert consumed_close is True - assert show_close is False - - -def test_build_fullscreen_capture_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel in capture fullscreen view.""" - renderable = capture._build_fullscreen_capture_renderable( - "* TODO {{title}}", - {}, - capture._FooterState( - current_placeholder="title", - current_input_value="", - cursor_position=0, - current_field_index=1, - total_fields=1, - show_help_modal=True, - ), - console_width=100, - ) - output_buffer = StringIO() - console = Console(file=output_buffer, force_terminal=False) - console.print(renderable) - output = output_buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - -def test_count_wrapped_prompt_lines_expands_with_long_input() -> None: - """Long prompt values should wrap to multiple footer lines.""" - prompt = capture._build_footer_prompt( - capture._FooterState( - current_placeholder="title", - current_input_value="x" * 120, - cursor_position=120, - current_field_index=1, - total_fields=2, - ), - ) - wrapped_lines = capture._count_wrapped_prompt_lines(prompt, console_width=40) - assert wrapped_lines > 1 - - -def test_apply_input_event_supports_cursor_keys_and_backspace() -> None: - """Input editing should support insertion, cursor motion, and deletion.""" - value = "" - cursor = 0 - - events = [ - ("TEXT", "a"), - ("TEXT", "c"), - ("LEFT", ""), - ("TEXT", "b"), - ("LEFT", ""), - ("DELETE", ""), - ("RIGHT", ""), - ("BACKSPACE", ""), - ("TEXT", "b"), - ("ENTER", ""), - ] - - done = False - for event_name, event_text in events: - value, cursor, done = capture._apply_input_event(value, cursor, event_name, event_text) - - assert done - assert value == "ab" - assert cursor == 2 - - -def test_extract_bracketed_paste_text_decodes_payload() -> None: - """Bracketed paste payload should decode to inserted prompt text.""" - payload = b"\x1b[200~Line one\nLine two\x1b[201~" - assert interactive_common.extract_bracketed_paste_text(payload) == "Line one\nLine two" - - -def test_read_input_event_maps_bracketed_paste_to_text(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste input should be surfaced as TEXT event.""" - monkeypatch.setattr("org.commands.interactive_common.sys.stdin.fileno", lambda: 0) - monkeypatch.setattr("org.commands.interactive_common.os.read", lambda _fd, _n: b"\x1b") - monkeypatch.setattr( - "org.commands.interactive_common.read_escape_sequence", - lambda _fd: b"\x1b[200~Paste value", - ) - monkeypatch.setattr( - "org.commands.interactive_common.read_bracketed_paste_payload", - lambda _fd, initial_payload: initial_payload + b"\x1b[201~", - ) - - assert interactive_common.read_input_event(ctrl_p_as_paste=True) == ("TEXT", "Paste value") - - -def test_set_bracketed_paste_writes_terminal_sequences(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste toggles should emit proper terminal sequences.""" - - class _FakeStdout: - def __init__(self) -> None: - self.writes: list[str] = [] - - def isatty(self) -> bool: - return True - - def write(self, value: str) -> int: - self.writes.append(value) - return len(value) - - def flush(self) -> None: - return None - - fake_stdout = _FakeStdout() - monkeypatch.setattr("org.commands.interactive_common.sys.stdout", fake_stdout) - - interactive_common.set_bracketed_paste(True) - interactive_common.set_bracketed_paste(False) - - assert fake_stdout.writes == ["\x1b[?2004h", "\x1b[?2004l"] - - -def test_run_capture_rejects_invalid_render_without_file_write( +def test_prepare_capture_plan_uses_document_and_parent_placeholders( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - """Invalid rendered heading should fail before file mutation.""" + """Prepared capture plan should resolve document and parent placeholder values.""" target = tmp_path / "tasks.org" - original = "* TODO Existing\n" - target.write_text(original, encoding="utf-8") + target.write_text( + "#+TITLE: Project File\n" + "#+AUTHOR: Jane Doe\n" + "#+DESCRIPTION: Sprint tasks\n" + "#+CATEGORY: Work\n" + "* TODO Parent\n:PROPERTIES:\n:ID: parent-1\n:CATEGORY: Projects\n:END:\n", + encoding="utf-8", + ) previous = _set_capture_templates( { - "broken": { + "child": { "file": str(target), - "content": "not a heading {{title}}", + "parent": '.id == "parent-1"', + "content": ( + "** TODO doc={{document_title}} cat={{document_category}} " + "parent={{parent_title}} parent_id={{parent_id}}" + ), }, }, ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Anything", - ) - with pytest.raises(typer.BadParameter, match="Invalid rendered capture heading"): - capture.run_tasks_capture(_make_capture_args(template_name="broken")) + plan = capture.prepare_capture_plan(_make_capture_args("child"), "child") finally: _restore_capture_templates(previous) - assert target.read_text(encoding="utf-8") == original + assert plan.values["document_title"] == "Project File" + assert plan.values["document_category"] == "Work" + assert plan.values["parent_title"] == "Parent" + assert plan.values["parent_id"] == "parent-1" + assert plan.unresolved_placeholders == [] def test_run_capture_inserts_under_parent_query( @@ -672,11 +317,14 @@ def test_run_capture_inserts_under_parent_query( }, }, ) - try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Child task", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Child task"}, ) capture.run_tasks_capture(_make_capture_args(template_name="child")) finally: @@ -707,8 +355,12 @@ def test_run_capture_cli_file_override_takes_precedence( ) try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "From override", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "From override"}, ) capture.run_tasks_capture(_make_capture_args("quick", file=str(override_target))) finally: @@ -718,81 +370,6 @@ def test_run_capture_cli_file_override_takes_precedence( assert "From override" in override_target.read_text(encoding="utf-8") -def test_run_capture_cli_parent_override_takes_precedence( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should use CLI --parent selector over template parent selector.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", - encoding="utf-8", - ) - - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": "** TODO {{title}}", - "parent": '.id == "one"', - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Override child", - ) - capture.run_tasks_capture(_make_capture_args("child", parent="two")) - finally: - _restore_capture_templates(previous) - - root = org_parser.loads(target.read_text(encoding="utf-8")) - nodes = list(root) - titles = [node.title_text.strip() for node in nodes] - assert titles == ["One", "Two", "Override child"] - - -def test_run_capture_set_values_bypass_prompt(tmp_path: Path) -> None: - """Capture should use --set values without prompting.""" - target = tmp_path / "tasks.org" - target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}} ({{title}})", - }, - }, - ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) - finally: - _restore_capture_templates(previous) - - assert "* TODO From set (From set)" in target.read_text(encoding="utf-8") - - -def test_run_capture_set_values_ignores_unknown_parameter(tmp_path: Path) -> None: - """Capture should ignore --set keys absent from template placeholders.""" - target = tmp_path / "tasks.org" - target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO Static", - }, - }, - ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["foo=bar"])) - finally: - _restore_capture_templates(previous) - - assert "* TODO Static" in target.read_text(encoding="utf-8") - - def test_run_capture_set_values_reject_invalid_format(tmp_path: Path) -> None: """Capture should reject --set entries that are not KEY=VALUE.""" target = tmp_path / "tasks.org" @@ -855,164 +432,3 @@ def test_run_capture_static_id_placeholder_uses_next_heading_number(tmp_path: Pa _restore_capture_templates(previous) assert ":ID: 3" in target.read_text(encoding="utf-8") - - -def test_run_capture_document_fields_are_available_for_templating( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should render explicit document metadata placeholders without prompting.""" - target = tmp_path / "tasks.org" - target.write_text( - "#+TITLE: Project File\n" - "#+AUTHOR: Jane Doe\n" - "#+DESCRIPTION: Sprint tasks\n" - "#+CATEGORY: Work\n" - "* TODO Existing\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": ( - "* TODO " - "cat={{document_category}} file={{document_filename}} " - "title={{document_title}} author={{document_author}} " - "desc={{document_description}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("quick")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "cat=Work" in updated - assert f"file={target}" in updated - assert "title=Project File" in updated - assert "author=Jane Doe" in updated - assert "desc=Sprint tasks" in updated - - -def test_run_capture_parent_fields_are_available_with_template_parent( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should expose parent_ placeholders when template parent is configured.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO Project Parent\n:PROPERTIES:\n:ID: project-1\n:CATEGORY: Projects\n:END:\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "parent": '.id == "project-1"', - "content": ( - "** TODO parent_cat={{parent_category}} " - "parent_title={{parent_title}} parent_todo={{parent_todo}} " - "parent_id={{parent_id}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("child")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "parent_cat=Projects" in updated - assert "parent_title=Project Parent" in updated - assert "parent_todo=TODO" in updated - assert "parent_id=project-1" in updated - - -def test_run_capture_parent_fields_are_available_with_cli_parent_override( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should expose parent_ placeholders with CLI --parent selector.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" - "* TODO Two\n:PROPERTIES:\n:ID: two\n:CATEGORY: Focus\n:END:\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": ( - "** TODO parent_cat={{parent_category}} " - "parent_title={{parent_title}} parent_todo={{parent_todo}} " - "parent_id={{parent_id}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("child", parent="two")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "parent_cat=Focus" in updated - assert "parent_title=Two" in updated - assert "parent_todo=TODO" in updated - assert "parent_id=two" in updated - - -def test_run_capture_cli_parent_override_accepts_title( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture CLI --parent should resolve exact parent title as override.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" - "* TODO Two\n:PROPERTIES:\n:ID: two\n:CATEGORY: Focus\n:END:\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": ( - "** TODO parent_cat={{parent_category}} " - "parent_title={{parent_title}} parent_todo={{parent_todo}} " - "parent_id={{parent_id}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("child", parent="Two")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "parent_cat=Focus" in updated - assert "parent_title=Two" in updated - assert "parent_todo=TODO" in updated - assert "parent_id=two" in updated diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index 90f81a9..af16624 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -14,7 +14,7 @@ from org.cli import app from org.commands.tasks.query import TasksQueryArgs, _is_org_object, run_tasks_query -from org.output_format import OutputFormat, OutputFormatError +from org.pipeline.format import OutputFormat, OutputFormatError FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") @@ -95,8 +95,8 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: return ["alpha", "beta"] monkeypatch.setattr( - "org.commands.tasks.query.compile_query_text", - lambda _query: _fake_compiled_query, + "org.commands.tasks.query.compile_query_or_raise", + lambda _query, _error_builder: _fake_compiled_query, ) monkeypatch.setattr( "org.commands.tasks.query.load_root_data", @@ -121,8 +121,8 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: return ["alpha", 1, None] monkeypatch.setattr( - "org.commands.tasks.query.compile_query_text", - lambda _query: _fake_compiled_query, + "org.commands.tasks.query.compile_query_or_raise", + lambda _query, _error_builder: _fake_compiled_query, ) monkeypatch.setattr( "org.commands.tasks.query.load_root_data", @@ -147,8 +147,8 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: return [None] monkeypatch.setattr( - "org.commands.tasks.query.compile_query_text", - lambda _query: _fake_compiled_query, + "org.commands.tasks.query.compile_query_or_raise", + lambda _query, _error_builder: _fake_compiled_query, ) monkeypatch.setattr( "org.commands.tasks.query.load_root_data", @@ -295,8 +295,6 @@ def test_run_query_markdown_pandoc_error_is_usage_error(monkeypatch: pytest.Monk """Markdown formatter failures should be surfaced as CLI usage errors.""" class _FailingFormatter: - include_filenames = False - def prepare( self, values: list[object], diff --git a/tests/config/test_config_core.py b/tests/config/test_config_core.py index 56fb1c0..e2fa2ba 100644 --- a/tests/config/test_config_core.py +++ b/tests/config/test_config_core.py @@ -9,7 +9,8 @@ import pytest import typer -from org import config +import org.config.app +import org.logging if TYPE_CHECKING: @@ -19,7 +20,7 @@ def test_load_config_missing_file_returns_empty(tmp_path: Path) -> None: """Missing config should return empty config without error.""" missing = tmp_path / "missing.yaml" - data, malformed = config.load_config(str(missing)) + data, malformed = org.config.app.load_config(str(missing)) assert data == {} assert malformed is False @@ -29,7 +30,7 @@ def test_load_config_directory_path_is_malformed(tmp_path: Path) -> None: """Directory path should be treated as malformed config.""" config_dir = tmp_path / "cfgdir" config_dir.mkdir() - data, malformed = config.load_config(str(config_dir)) + data, malformed = org.config.app.load_config(str(config_dir)) assert data == {} assert malformed is True @@ -40,7 +41,7 @@ def test_load_config_non_dict_is_malformed(tmp_path: Path) -> None: config_path = tmp_path / "config.yaml" config_path.write_text("- 1\n- 2\n- 3\n", encoding="utf-8") - data, malformed = config.load_config(str(config_path)) + data, malformed = org.config.app.load_config(str(config_path)) assert data == {} assert malformed is True @@ -48,7 +49,7 @@ def test_load_config_non_dict_is_malformed(tmp_path: Path) -> None: def test_parse_color_defaults_conflict() -> None: """Conflicting color flags should be rejected.""" - defaults, valid = config.parse_color_defaults({"--color": True, "--no-color": True}) + defaults, valid = org.config.app.parse_color_defaults({"--color": True, "--no-color": True}) assert defaults == {} assert valid is False @@ -56,7 +57,7 @@ def test_parse_color_defaults_conflict() -> None: def test_parse_color_defaults_ignores_value() -> None: """Color defaults should be driven by key presence, not YAML value.""" - defaults, valid = config.parse_color_defaults({"--color": "yes"}) + defaults, valid = org.config.app.parse_color_defaults({"--color": "yes"}) assert defaults == {"color_flag": True} assert valid is True @@ -72,7 +73,7 @@ def test_build_config_defaults_applies_values() -> None: "--pandoc-args": "--wrap=none", } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, stats_defaults, append_defaults = defaults @@ -87,7 +88,7 @@ def test_build_config_defaults_rejects_invalid_entry() -> None: """Invalid values should cause config defaults to be rejected.""" raw: dict[str, object] = {"--limit": "not-a-number"} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is None @@ -100,7 +101,7 @@ def test_build_config_defaults_accepts_ordering_flags() -> None: "--order-by-timestamp-desc": True, } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -116,7 +117,7 @@ def test_build_config_defaults_keeps_regular_boolean_values() -> None: "--order-by-level": False, } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -131,7 +132,7 @@ def test_build_config_defaults_ignores_value_for_paired_boolean_flags() -> None: "--no-color": "disabled", } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -143,7 +144,7 @@ def test_build_config_defaults_sets_false_for_negated_paired_flag() -> None: """Negated paired flags should store the negative destination value.""" raw: dict[str, object] = {"--no-future-repeats": True} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -154,7 +155,7 @@ def test_build_config_defaults_accepts_view_default() -> None: """Board view default should be accepted as a non-empty string.""" raw: dict[str, object] = {"--view": "kanban"} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -165,7 +166,7 @@ def test_build_config_defaults_rejects_non_boolean_ordering_flags() -> None: """Ordering switch defaults must be boolean values.""" raw: dict[str, object] = {"--order-by-level": "yes"} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is None @@ -174,7 +175,7 @@ def test_build_config_defaults_rejects_empty_view_default() -> None: """Board view default must not be empty.""" raw: dict[str, object] = {"--view": ""} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is None @@ -183,19 +184,19 @@ def test_parse_config_argument_prefers_cli_value() -> None: """--config argument should override default config name.""" argv = ["org", "stats", "all", "--config", "custom.yaml"] - assert config.parse_config_argument(argv) == "custom.yaml" + assert org.config.app.parse_config_argument(argv) == "custom.yaml" def test_parse_config_argument_supports_equals_form() -> None: """--config=FILE format should be parsed.""" argv = ["org", "stats", "all", "--config=inline.yaml"] - assert config.parse_config_argument(argv) == "inline.yaml" + assert org.config.app.parse_config_argument(argv) == "inline.yaml" def test_parse_config_argument_default() -> None: """Default config name should be used when not specified.""" - assert config.parse_config_argument(["org", "stats", "all"]) == ".org-cli.yaml" + assert org.config.app.parse_config_argument(["org", "stats", "all"]) == ".org-cli.yaml" def test_load_cli_config_reads_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -216,7 +217,7 @@ def test_load_cli_config_reads_defaults(tmp_path: Path, monkeypatch: pytest.Monk ) monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert loaded.defaults["max_results"] == 7 assert loaded.append_defaults == {} @@ -237,7 +238,7 @@ def test_load_cli_config_sections_are_optional( config_path.write_text("defaults:\n --limit: 7\n", encoding="utf-8") monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert loaded.defaults["max_results"] == 7 assert loaded.custom_filters == {} @@ -269,7 +270,7 @@ def test_load_cli_config_parses_capture_templates( ) monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert loaded.capture_templates == { "quick": {"file": "tasks.org", "content": "* TODO {{title}}"}, @@ -304,7 +305,7 @@ def test_load_cli_config_parses_board_views( ) monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert set(loaded.board_views) == {"kanban"} assert loaded.board_views["kanban"].name == "kanban" @@ -329,7 +330,7 @@ def test_load_cli_config_rejects_empty_board_section( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_board_views_not_list( @@ -342,7 +343,7 @@ def test_load_cli_config_rejects_board_views_not_list( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_duplicate_board_view_names( @@ -369,7 +370,7 @@ def test_load_cli_config_rejects_duplicate_board_view_names( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_columns( @@ -385,7 +386,7 @@ def test_load_cli_config_rejects_empty_board_columns( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_invalid_board_column_fields( @@ -408,7 +409,7 @@ def test_load_cli_config_rejects_invalid_board_column_fields( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_unknown_board_keys( @@ -432,7 +433,7 @@ def test_load_cli_config_rejects_unknown_board_keys( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_board_with_wrong_top_level_type( @@ -445,7 +446,7 @@ def test_load_cli_config_rejects_board_with_wrong_top_level_type( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_view_name( @@ -468,7 +469,7 @@ def test_load_cli_config_rejects_empty_board_view_name( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_missing_board_view_columns( @@ -484,7 +485,7 @@ def test_load_cli_config_rejects_missing_board_view_columns( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_missing_board_column_name( @@ -506,7 +507,7 @@ def test_load_cli_config_rejects_missing_board_column_name( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_missing_board_column_filter( @@ -522,7 +523,7 @@ def test_load_cli_config_rejects_missing_board_column_filter( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_column_filter( @@ -545,7 +546,7 @@ def test_load_cli_config_rejects_empty_board_column_filter( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_column_order_by( @@ -569,7 +570,7 @@ def test_load_cli_config_rejects_empty_board_column_order_by( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_non_string_board_column_order_by( @@ -593,7 +594,7 @@ def test_load_cli_config_rejects_non_string_board_column_order_by( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_malformed_capture_templates( @@ -609,7 +610,7 @@ def test_load_cli_config_rejects_malformed_capture_templates( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_unknown_capture_template_fields( @@ -632,7 +633,7 @@ def test_load_cli_config_rejects_unknown_capture_template_fields( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_invalid_custom_section_values( @@ -648,7 +649,7 @@ def test_load_cli_config_rejects_invalid_custom_section_values( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_malformed_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -658,56 +659,56 @@ def test_load_cli_config_malformed_yaml(tmp_path: Path, monkeypatch: pytest.Monk monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_validate_helpers() -> None: """Validation helpers should accept valid values and reject invalid ones.""" - assert config.is_valid_date_argument("2025-01-15") is True - assert config.is_valid_date_argument("2025/01/15") is False + assert org.config.app.is_valid_date_argument("2025-01-15") is True + assert org.config.app.is_valid_date_argument("2025/01/15") is False - assert config.is_valid_keys_string("TODO,DONE") is True - assert config.is_valid_keys_string("TODO|WAIT") is False + assert org.config.app.is_valid_keys_string("TODO,DONE") is True + assert org.config.app.is_valid_keys_string("TODO|WAIT") is False - assert config.validate_int_option(3, 0) == 3 - assert config.validate_int_option(-1, 0) is None - assert config.validate_int_option("nope", 0) is None + assert org.config.app.validate_int_option(3, 0) == 3 + assert org.config.app.validate_int_option(-1, 0) is None + assert org.config.app.validate_int_option("nope", 0) is None - assert config.validate_str_option("--use", "tags") == "tags" - assert config.validate_str_option("--use", "nope") is None - assert config.validate_str_option("--out", "gfm") == "gfm" - assert config.validate_str_option("--out", "") is None - assert config.validate_str_option("--date", "2025-01-15") == "2025-01-15" - assert config.validate_str_option("--date", "2025/01/15") is None - assert config.validate_str_option("--todo-states", "TODO|WAIT") is None - assert config.validate_str_option("--filter-date-from", "2025/01/15") is None + assert org.config.app.validate_str_option("--use", "tags") == "tags" + assert org.config.app.validate_str_option("--use", "nope") is None + assert org.config.app.validate_str_option("--out", "gfm") == "gfm" + assert org.config.app.validate_str_option("--out", "") is None + assert org.config.app.validate_str_option("--date", "2025-01-15") == "2025-01-15" + assert org.config.app.validate_str_option("--date", "2025/01/15") is None + assert org.config.app.validate_str_option("--todo-states", "TODO|WAIT") is None + assert org.config.app.validate_str_option("--filter-date-from", "2025/01/15") is None - assert config.validate_list_option("--filter-property", ["key=value"]) == ["key=value"] - assert config.validate_list_option("--filter-property", ["novalue"]) is None - assert config.validate_list_option("--filter-tag", ["["]) is None - assert config.validate_list_option("--filter-body", ["["]) is None + assert org.config.app.validate_list_option("--filter-property", ["key=value"]) == ["key=value"] + assert org.config.app.validate_list_option("--filter-property", ["novalue"]) is None + assert org.config.app.validate_list_option("--filter-tag", ["["]) is None + assert org.config.app.validate_list_option("--filter-body", ["["]) is None def test_apply_mapping_and_exclude_config() -> None: """Mapping/exclude config should support string and inline forms.""" defaults: dict[str, object] = {} - assert config.apply_mapping_config("mapping.json", defaults) is True + assert org.config.app.apply_mapping_config("mapping.json", defaults) is True assert defaults["mapping"] == "mapping.json" defaults.clear() - assert config.apply_mapping_config({"foo": "bar"}, defaults) is True + assert org.config.app.apply_mapping_config({"foo": "bar"}, defaults) is True assert defaults["mapping_inline"] == {"foo": "bar"} defaults.clear() - assert config.apply_mapping_config("", defaults) is False + assert org.config.app.apply_mapping_config("", defaults) is False defaults.clear() - assert config.apply_exclude_config("exclude.txt", defaults) is True + assert org.config.app.apply_exclude_config("exclude.txt", defaults) is True assert defaults["exclude"] == "exclude.txt" defaults.clear() - assert config.apply_exclude_config(["alpha", "beta"], defaults) is True + assert org.config.app.apply_exclude_config(["alpha", "beta"], defaults) is True assert defaults["exclude_inline"] == ["alpha", "beta"] @@ -724,7 +725,7 @@ def test_build_default_map_strips_command_specific_values() -> None: "groups": ["a,b"], } - default_map = config.build_default_map(defaults) + default_map = org.config.app.build_default_map(defaults) summary_defaults = default_map["stats"]["all"] assert "tags" not in summary_defaults @@ -749,7 +750,7 @@ def test_build_default_map_strips_command_specific_values() -> None: def test_build_default_map_keeps_ordering_boolean_defaults() -> None: """Tasks list and board ordering defaults should remain boolean flags.""" - default_map = config.build_default_map( + default_map = org.config.app.build_default_map( { "order_by_level": True, "order_by_timestamp_desc": False, @@ -769,9 +770,9 @@ def test_build_default_map_keeps_ordering_boolean_defaults() -> None: assert tasks_query_defaults["order_by_timestamp_desc"] is False -def test_build_default_map_strips_flow_board_unsupported_defaults() -> None: +def test_build_default_map_strips_board_unsupported_defaults() -> None: """Board default map should omit list-only output options.""" - default_map = config.build_default_map( + default_map = org.config.app.build_default_map( { "details": True, "max_results": 5, @@ -795,7 +796,7 @@ def test_build_default_map_strips_flow_board_unsupported_defaults() -> None: def test_build_default_map_includes_agenda_defaults() -> None: """Agenda default map should include agenda-specific and shared task options.""" - default_map = config.build_default_map( + default_map = org.config.app.build_default_map( { "date": "2025-01-15", "days": 3, @@ -829,43 +830,43 @@ def test_build_default_map_includes_agenda_defaults() -> None: def test_apply_config_defaults_applies_inline_and_append_defaults() -> None: """apply_config_defaults should fill append and inline values.""" - original_append = dict(config.CONFIG_APPEND_DEFAULTS) - original_inline = dict(config.CONFIG_INLINE_DEFAULTS) + original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) + original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) try: - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS["filter_tags"] = ["alpha"] - config.CONFIG_INLINE_DEFAULTS["mapping_inline"] = {"foo": "bar"} + org.config.app.CONFIG_APPEND_DEFAULTS["filter_tags"] = ["alpha"] + org.config.app.CONFIG_INLINE_DEFAULTS["mapping_inline"] = {"foo": "bar"} args = SimpleNamespace(filter_tags=None, mapping_inline=None, exclude_inline=None) - config.apply_config_defaults(args) + org.config.app.apply_config_defaults(args) assert args.filter_tags == ["alpha"] assert args.mapping_inline == {"foo": "bar"} assert args.exclude_inline is None finally: - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(original_append) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(original_inline) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) def test_log_applied_config_defaults_logs_all_config_values( caplog: pytest.LogCaptureFixture, ) -> None: """Default logging should include all values loaded from config.""" - original_defaults = dict(config.CONFIG_DEFAULTS) - original_append = dict(config.CONFIG_APPEND_DEFAULTS) - original_inline = dict(config.CONFIG_INLINE_DEFAULTS) + original_defaults = dict(org.config.app.CONFIG_DEFAULTS) + original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) + original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) try: - config.CONFIG_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_DEFAULTS.update({"max_results": 10}) - config.CONFIG_APPEND_DEFAULTS.update({"filter_tags": ["work"]}) - config.CONFIG_INLINE_DEFAULTS.update({"mapping_inline": {"foo": "bar"}}) + org.config.app.CONFIG_DEFAULTS.update({"max_results": 10}) + org.config.app.CONFIG_APPEND_DEFAULTS.update({"filter_tags": ["work"]}) + org.config.app.CONFIG_INLINE_DEFAULTS.update({"mapping_inline": {"foo": "bar"}}) args = SimpleNamespace( max_results=10, @@ -874,7 +875,7 @@ def test_log_applied_config_defaults_logs_all_config_values( ) with caplog.at_level(logging.INFO, logger="org"): - config.log_applied_config_defaults( + org.logging.log_applied_config_defaults( args, ["stats", "all", "--limit", "20"], "stats all", @@ -885,12 +886,12 @@ def test_log_applied_config_defaults_logs_all_config_values( assert "--filter-tag=['work']" in caplog.text assert "--mapping=''" in caplog.text finally: - config.CONFIG_DEFAULTS.clear() - config.CONFIG_DEFAULTS.update(original_defaults) - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(original_append) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(original_inline) + org.config.app.CONFIG_DEFAULTS.clear() + org.config.app.CONFIG_DEFAULTS.update(original_defaults) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) def test_log_command_arguments_logs_all_values(caplog: pytest.LogCaptureFixture) -> None: @@ -898,7 +899,7 @@ def test_log_command_arguments_logs_all_values(caplog: pytest.LogCaptureFixture) args = SimpleNamespace(max_results=10, filter_tags=["work"]) with caplog.at_level(logging.INFO, logger="org"): - config.log_command_arguments(args, "stats all") + org.logging.log_command_arguments(args, "stats all") assert "Command arguments (stats all):" in caplog.text assert "max_results=10" in caplog.text diff --git a/tests/conftest.py b/tests/conftest.py index 248f3b7..dc0aa18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,37 +4,11 @@ import org_parser -from org.analyze import Frequency - if TYPE_CHECKING: from org_parser.document import Heading -def freq_dict_from_ints(d: dict[str, int]) -> dict[str, Frequency]: - """Convert dict[str, int] to dict[str, Frequency] for testing. - - Args: - d: Dictionary mapping strings to integers - - Returns: - Dictionary mapping strings to Frequency objects - """ - return {k: Frequency(v) for k, v in d.items()} - - -def freq_dict_to_ints(d: dict[str, Frequency]) -> dict[str, int]: - """Convert dict[str, Frequency] to dict[str, int] for assertions. - - Args: - d: Dictionary mapping strings to Frequency objects - - Returns: - Dictionary mapping strings to integers - """ - return {k: v.total for k, v in d.items()} - - def node_from_org( org_text: str, todo_states: list[str] | None = None, diff --git a/tests/fixtures/empty-config.yaml b/tests/fixtures/empty-config.yaml new file mode 100644 index 0000000..479207d --- /dev/null +++ b/tests/fixtures/empty-config.yaml @@ -0,0 +1,2 @@ + +{} diff --git a/tests/test_analysis_result.py b/tests/test_analysis_result.py index 4e0b3f8..d9b382b 100644 --- a/tests/test_analysis_result.py +++ b/tests/test_analysis_result.py @@ -1,7 +1,6 @@ """Tests for the AnalysisResult dataclass.""" -from org.analyze import AnalysisResult, Tag, TimeRange -from org.histogram import Histogram +from org.logic.stats import AnalysisResult, Distribution, Tag, TimeRange def test_analysis_result_initialization() -> None: @@ -17,11 +16,11 @@ def test_analysis_result_initialization() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=10, - task_states=Histogram(values={"DONE": 5, "TODO": 5}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 5, "TODO": 5}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -47,11 +46,11 @@ def test_analysis_result_empty_initialization() -> None: """Test AnalysisResult with empty data.""" result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=0, - task_states=Histogram(values={"DONE": 0, "TODO": 0}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 0, "TODO": 0}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -76,11 +75,11 @@ def test_analysis_result_attributes() -> None: """Test that AnalysisResult has all expected attributes.""" result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=1, - task_states=Histogram(values={"DONE": 1, "TODO": 0}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "TODO": 0}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -120,11 +119,11 @@ def test_analysis_result_repr() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=2, - task_states=Histogram(values={"DONE": 1, "TODO": 1}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "TODO": 1}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -152,11 +151,11 @@ def test_analysis_result_equality() -> None: result1 = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=5, - task_states=Histogram(values={"DONE": 3, "TODO": 2}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 3, "TODO": 2}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -167,11 +166,11 @@ def test_analysis_result_equality() -> None: result2 = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=5, - task_states=Histogram(values={"DONE": 3, "TODO": 2}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 3, "TODO": 2}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -182,11 +181,11 @@ def test_analysis_result_equality() -> None: result3 = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=10, - task_states=Histogram(values={"DONE": 5, "TODO": 5}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 5, "TODO": 5}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -203,15 +202,15 @@ def test_analysis_result_mutable_fields() -> None: """Test that AnalysisResult fields can be modified.""" from datetime import datetime - from org.analyze import Group + from org.logic.stats import Group result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=0, - task_states=Histogram(values={"DONE": 0, "TODO": 0}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 0, "TODO": 0}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -280,11 +279,11 @@ def test_analysis_result_dict_operations() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=3, - task_states=Histogram(values={"DONE": 2, "TODO": 1}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 2, "TODO": 1}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 6c92ef5..4ae5627 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from org.analyze import analyze +from org.logic.stats import analyze from tests.conftest import node_from_org diff --git a/tests/test_clean.py b/tests/test_clean.py index 3b65991..4800abb 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -1,7 +1,7 @@ """Tests for the clean() function.""" -from org.analyze import Tag, TimeRange, clean -from org.cli_common import DEFAULT_EXCLUDE +from org.config.app import DEFAULT_EXCLUDE +from org.logic.stats import Tag, TimeRange, clean def tag_dict_from_ints(d: dict[str, int]) -> dict[str, Tag]: diff --git a/tests/test_combine_time_ranges.py b/tests/test_combine_time_ranges.py index 6f90736..d5d60b9 100644 --- a/tests/test_combine_time_ranges.py +++ b/tests/test_combine_time_ranges.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import TimeRange, _combine_time_ranges +from org.logic.stats import TimeRange, _combine_time_ranges def test_combine_time_ranges_empty_list() -> None: diff --git a/tests/test_compute_category_histogram.py b/tests/test_compute_category_histogram.py index c535965..9e7803e 100644 --- a/tests/test_compute_category_histogram.py +++ b/tests/test_compute_category_histogram.py @@ -1,6 +1,6 @@ """Tests for the compute_category_histogram() function.""" -from org.analyze import compute_category_histogram +from org.logic.stats import compute_category_histogram from tests.conftest import node_from_org diff --git a/tests/test_compute_day_of_week_histogram.py b/tests/test_compute_day_of_week_histogram.py index 597646a..5d693e6 100644 --- a/tests/test_compute_day_of_week_histogram.py +++ b/tests/test_compute_day_of_week_histogram.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.analyze import compute_day_of_week_histogram, weekday_to_string +from org.logic.stats import compute_day_of_week_histogram, weekday_to_string from tests.conftest import node_from_org diff --git a/tests/test_compute_frequencies.py b/tests/test_compute_frequencies.py index c29ae4f..a90e81f 100644 --- a/tests/test_compute_frequencies.py +++ b/tests/test_compute_frequencies.py @@ -1,6 +1,6 @@ """Tests for the compute_frequencies() function.""" -from org.analyze import compute_frequencies +from org.logic.stats import compute_frequencies from tests.conftest import node_from_org diff --git a/tests/test_compute_global_timerange.py b/tests/test_compute_global_timerange.py index b087664..af32ff8 100644 --- a/tests/test_compute_global_timerange.py +++ b/tests/test_compute_global_timerange.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.analyze import compute_global_timerange +from org.logic.stats import compute_global_timerange from tests.conftest import node_from_org diff --git a/tests/test_compute_groups.py b/tests/test_compute_groups.py index a5bd73e..99a1006 100644 --- a/tests/test_compute_groups.py +++ b/tests/test_compute_groups.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import Tag, TimeRange, compute_groups +from org.logic.stats import Tag, TimeRange, compute_groups def make_tag( diff --git a/tests/test_compute_helpers.py b/tests/test_compute_helpers.py index 667c0d1..fa2ef82 100644 --- a/tests/test_compute_helpers.py +++ b/tests/test_compute_helpers.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.analyze import TimeRange, compute_avg_tasks_per_day, compute_max_single_day +from org.logic.stats import TimeRange, compute_avg_tasks_per_day, compute_max_single_day def test_compute_max_single_day_empty_timerange() -> None: diff --git a/tests/test_compute_relations.py b/tests/test_compute_relations.py index 6117038..a68800b 100644 --- a/tests/test_compute_relations.py +++ b/tests/test_compute_relations.py @@ -1,6 +1,6 @@ """Tests for the compute_relations() function.""" -from org.analyze import compute_relations +from org.logic.stats import compute_relations from tests.conftest import node_from_org diff --git a/tests/test_compute_task_state_histogram.py b/tests/test_compute_task_state_histogram.py index c1cd6f7..2971965 100644 --- a/tests/test_compute_task_state_histogram.py +++ b/tests/test_compute_task_state_histogram.py @@ -1,6 +1,6 @@ """Tests for the compute_task_state_histogram() function.""" -from org.analyze import compute_task_state_histogram +from org.logic.stats import compute_task_state_histogram from tests.conftest import node_from_org diff --git a/tests/test_compute_task_stats.py b/tests/test_compute_task_stats.py index 79dcc73..2fc80a2 100644 --- a/tests/test_compute_task_stats.py +++ b/tests/test_compute_task_stats.py @@ -1,6 +1,6 @@ """Tests for the compute_task_stats() function.""" -from org.analyze import compute_task_stats +from org.logic.stats import compute_task_stats from tests.conftest import node_from_org diff --git a/tests/test_compute_time_ranges.py b/tests/test_compute_time_ranges.py index fcd8e99..f6e2cb4 100644 --- a/tests/test_compute_time_ranges.py +++ b/tests/test_compute_time_ranges.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import compute_time_ranges +from org.logic.stats import compute_time_ranges from tests.conftest import node_from_org diff --git a/tests/test_extract_timestamp.py b/tests/test_extract_timestamp.py index 26eb1db..657c717 100644 --- a/tests/test_extract_timestamp.py +++ b/tests/test_extract_timestamp.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.timestamp import extract_timestamp +from org.logic.time import extract_timestamp from tests.conftest import node_from_org diff --git a/tests/test_frequency.py b/tests/test_frequency.py index 5d8f5c6..d76eb6f 100644 --- a/tests/test_frequency.py +++ b/tests/test_frequency.py @@ -1,6 +1,6 @@ """Tests for the Frequency class.""" -from org.analyze import Frequency +from org.logic.stats import Frequency def test_frequency_default_initialization() -> None: diff --git a/tests/test_histogram.py b/tests/test_histogram.py index 2a5b88c..413f143 100644 --- a/tests/test_histogram.py +++ b/tests/test_histogram.py @@ -1,40 +1,40 @@ -"""Tests for the Histogram class.""" +"""Tests for the Distribution class.""" -from org.histogram import Histogram +from org.logic.stats import Distribution def test_histogram_initialization_empty() -> None: - """Test Histogram can be initialized empty.""" - hist = Histogram() + """Test Distribution can be initialized empty.""" + hist = Distribution() assert hist.values == {} def test_histogram_initialization_with_values() -> None: - """Test Histogram can be initialized with values.""" - hist = Histogram(values={"TODO": 5, "DONE": 10}) + """Test Distribution can be initialized with values.""" + hist = Distribution(values={"TODO": 5, "DONE": 10}) assert hist.values["TODO"] == 5 assert hist.values["DONE"] == 10 def test_histogram_repr() -> None: - """Test Histogram repr.""" - hist = Histogram(values={"TODO": 5, "DONE": 10}) + """Test Distribution repr.""" + hist = Distribution(values={"TODO": 5, "DONE": 10}) - assert repr(hist) == "Histogram(values={'TODO': 5, 'DONE': 10})" + assert repr(hist) == "Distribution(values={'TODO': 5, 'DONE': 10})" def test_histogram_empty_repr() -> None: - """Test Histogram repr when empty.""" - hist = Histogram() + """Test Distribution repr when empty.""" + hist = Distribution() - assert repr(hist) == "Histogram(values={})" + assert repr(hist) == "Distribution(values={})" def test_histogram_values_mutable() -> None: - """Test that histogram values can be modified.""" - hist = Histogram(values={"TODO": 5}) + """Test that distribution values can be modified.""" + hist = Distribution(values={"TODO": 5}) hist.update("TODO", 1) hist.values["DONE"] = 10 @@ -45,7 +45,7 @@ def test_histogram_values_mutable() -> None: def test_histogram_get_with_default() -> None: """Test getting values with default.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) assert hist.values.get("TODO", 0) == 5 assert hist.values.get("DONE", 0) == 0 @@ -53,7 +53,7 @@ def test_histogram_get_with_default() -> None: def test_histogram_update_existing_key() -> None: """Test update method with existing key.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) hist.update("TODO", 3) @@ -62,7 +62,7 @@ def test_histogram_update_existing_key() -> None: def test_histogram_update_new_key() -> None: """Test update method with new key.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) hist.update("DONE", 10) @@ -72,7 +72,7 @@ def test_histogram_update_new_key() -> None: def test_histogram_update_negative_amount() -> None: """Test update method with negative amount.""" - hist = Histogram(values={"TODO": 10}) + hist = Distribution(values={"TODO": 10}) hist.update("TODO", -3) @@ -81,7 +81,7 @@ def test_histogram_update_negative_amount() -> None: def test_histogram_update_zero_amount() -> None: """Test update method with zero amount.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) hist.update("TODO", 0) diff --git a/tests/test_integration.py b/tests/test_integration.py index a852156..be89af4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,7 +5,7 @@ import org_parser -from org.analyze import analyze +from org.logic.stats import analyze if TYPE_CHECKING: diff --git a/tests/test_mapped.py b/tests/test_mapped.py index e2516a4..716ffe6 100644 --- a/tests/test_mapped.py +++ b/tests/test_mapped.py @@ -1,6 +1,6 @@ """Tests for the mapped() function.""" -from org.analyze import mapped +from org.logic.stats import mapped def test_mapped_found_in_mapping() -> None: diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 31c0f4d..aceedd4 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -1,6 +1,6 @@ """Tests for the normalize() function.""" -from org.analyze import normalize +from org.logic.stats import normalize def test_normalize_basic() -> None: diff --git a/tests/test_output_format.py b/tests/test_output_format.py index 6316d4b..def7180 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -14,11 +14,10 @@ from org_parser.text import RichText from rich.syntax import Syntax -from org import output_format -from org.analyze import AnalysisResult, Group, Tag, TimeRange from org.commands.tasks import query as query_command from org.commands.tasks.list import command as tasks_list_command -from org.histogram import Histogram +from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange +from org.pipeline import format as output_format if TYPE_CHECKING: @@ -48,7 +47,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs return SimpleNamespace(returncode=0, stdout=b"# converted", stderr=b"pandoc warning text\n") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) caplog.set_level(logging.INFO, logger="org") markdown_text = output_format._org_to_pandoc_format("* TODO test", "markdown", []) @@ -68,7 +67,7 @@ def _fake_run(command: list[str], **kwargs: object) -> SimpleNamespace: seen["kwargs"] = kwargs return SimpleNamespace(returncode=0, stdout=b"converted", stderr=b"") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) rendered = output_format._org_to_pandoc_format( "* TODO test", @@ -95,7 +94,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs return SimpleNamespace(returncode=22, stdout=b"", stderr=b"Unknown output format\n") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="Unknown output format"): output_format._org_to_pandoc_format("* TODO test", "invalid-format", []) @@ -111,7 +110,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs raise FileNotFoundError("pandoc not found") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="pandoc not found"): output_format._org_to_pandoc_format("* TODO test", "gfm", []) @@ -260,13 +259,13 @@ def __str__(self) -> str: def test_to_json_compatible_serializes_histogram_with_type_field() -> None: - """Histogram should serialize to a JSON object with a type field.""" - histogram = Histogram(values={"DONE": 3, "TODO": 2}) + """Distribution should serialize to a JSON object with a type field.""" + histogram = Distribution(values={"DONE": 3, "TODO": 2}) result = output_format._to_json_compatible(histogram) assert isinstance(result, dict) - assert result["type"] == "Histogram" + assert result["type"] == "Distribution" assert result["values"] == {"DONE": 3, "TODO": 2} @@ -350,10 +349,10 @@ def test_to_json_compatible_serializes_analysis_result_with_type_field() -> None result = AnalysisResult( total_tasks=4, unique_tasks=4, - task_states=Histogram(values={"DONE": 2, "TODO": 2}), - task_categories=Histogram(values={}), - task_priorities=Histogram(values={"A": 1}), - task_days=Histogram(values={"Monday": 2}), + task_states=Distribution(values={"DONE": 2, "TODO": 2}), + task_categories=Distribution(values={}), + task_priorities=Distribution(values={"A": 1}), + task_days=Distribution(values={"Monday": 2}), timerange=tr, avg_tasks_per_day=0.2, max_single_day_count=2, @@ -372,7 +371,7 @@ def test_to_json_compatible_serializes_analysis_result_with_type_field() -> None task_states = serialized["task_states"] assert isinstance(task_states, dict) - assert task_states["type"] == "Histogram" + assert task_states["type"] == "Distribution" assert task_states["values"] == {"DONE": 2, "TODO": 2} timerange = serialized["timerange"] diff --git a/tests/test_priority.py b/tests/test_priority.py index 7886719..e0dcbb2 100644 --- a/tests/test_priority.py +++ b/tests/test_priority.py @@ -4,9 +4,8 @@ from rich.text import Text -from org.analyze import compute_priority_histogram -from org.histogram import visual_len -from org.tui import TaskLineConfig, format_task_line +from org.logic.stats import compute_priority_histogram +from org.tui.bits import TaskLineConfig, format_task_line, visual_len from tests.conftest import node_from_org diff --git a/tests/test_query_language_compiler.py b/tests/test_query_language_compiler.py index 54534e6..9b7263c 100644 --- a/tests/test_query_language_compiler.py +++ b/tests/test_query_language_compiler.py @@ -2,9 +2,9 @@ from __future__ import annotations -from org.query_language import EvalContext, Stream, compile_query_text -from org.query_language.ast import NumberLiteral -from org.query_language.compiler import compile_expr +from org.query.engine.ast import NumberLiteral +from org.query.engine.compiler import compile_expr, compile_query_text +from org.query.engine.interpreter import EvalContext, Stream def test_compile_expr_returns_executable_callable() -> None: diff --git a/tests/test_query_language_parser.py b/tests/test_query_language_parser.py index 1a1d686..c37e4ad 100644 --- a/tests/test_query_language_parser.py +++ b/tests/test_query_language_parser.py @@ -4,8 +4,7 @@ import pytest -from org.query_language import parse_query -from org.query_language.ast import ( +from org.query.engine.ast import ( AsBinding, BinaryOp, DictAssignment, @@ -22,7 +21,8 @@ StringLiteral, Variable, ) -from org.query_language.errors import QueryParseError +from org.query.engine.errors import QueryParseError +from org.query.engine.parser import parse_query @pytest.mark.parametrize( diff --git a/tests/test_query_language_runtime.py b/tests/test_query_language_runtime.py index 0629633..3f2bf4f 100644 --- a/tests/test_query_language_runtime.py +++ b/tests/test_query_language_runtime.py @@ -13,8 +13,10 @@ from org_parser.text import RichText from org_parser.time import Clock, Timestamp -import org.query_language.runtime as runtime_module -from org.query_language import EvalContext, QueryRuntimeError, Stream, compile_query_text +import org.query.engine.interpreter as runtime_module +from org.query.engine.compiler import compile_query_text +from org.query.engine.errors import QueryRuntimeError +from org.query.engine.interpreter import EvalContext, Stream from tests.conftest import node_from_org @@ -1222,8 +1224,8 @@ def test_runtime_sort_by_all_null_keys_preserves_input_order() -> None: def test_runtime_unsupported_expression_type_raises_runtime_error() -> None: """Evaluating unknown AST nodes should raise runtime error.""" - from org.query_language.ast import Expr - from org.query_language.compiler import compile_expr + from org.query.engine.ast import Expr + from org.query.engine.compiler import compile_expr compiled = compile_expr(Expr()) with pytest.raises(QueryRuntimeError): @@ -1232,8 +1234,8 @@ def test_runtime_unsupported_expression_type_raises_runtime_error() -> None: def test_runtime_unsupported_function_name_raises_runtime_error() -> None: """Unknown function names in AST should raise runtime error.""" - from org.query_language.ast import FunctionCall - from org.query_language.compiler import compile_expr + from org.query.engine.ast import FunctionCall + from org.query.engine.compiler import compile_expr compiled = compile_expr(FunctionCall("unknown", None)) with pytest.raises(QueryRuntimeError): diff --git a/tests/test_relations.py b/tests/test_relations.py index 1bdb99a..4a883d8 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,6 +1,6 @@ """Tests for the Relations dataclass.""" -from org.analyze import Relations +from org.logic.stats import Relations def test_relations_initialization() -> None: diff --git a/tests/test_render_histogram.py b/tests/test_render_histogram.py index d088fe8..ee62444 100644 --- a/tests/test_render_histogram.py +++ b/tests/test_render_histogram.py @@ -1,11 +1,12 @@ """Tests for the render_histogram() function.""" -from org.histogram import Histogram, render_histogram +from org.logic.stats import Distribution +from org.tui.histogram import render_histogram def test_render_histogram_empty() -> None: """Test empty histogram with no values.""" - histogram = Histogram(values={}) + histogram = Distribution(values={}) category_order = ["Some", "Value", "unknown"] lines = render_histogram(histogram, 40, category_order) @@ -18,7 +19,7 @@ def test_render_histogram_empty() -> None: def test_render_histogram_single_value_100_percent() -> None: """Test one category with all the count.""" - histogram = Histogram(values={"Value": 100}) + histogram = Distribution(values={"Value": 100}) category_order = ["Some", "Value"] lines = render_histogram(histogram, 40, category_order) @@ -30,7 +31,7 @@ def test_render_histogram_single_value_100_percent() -> None: def test_render_histogram_all_equal_values() -> None: """Test multiple categories with equal counts.""" - histogram = Histogram(values={"Some": 50, "Value": 50}) + histogram = Distribution(values={"Some": 50, "Value": 50}) category_order = ["Some", "Value"] lines = render_histogram(histogram, 40, category_order) @@ -42,7 +43,7 @@ def test_render_histogram_all_equal_values() -> None: def test_render_histogram_normal_distribution() -> None: """Test realistic distribution.""" - histogram = Histogram( + histogram = Distribution( values={ "Monday": 3526, "Tuesday": 3273, @@ -79,7 +80,7 @@ def test_render_histogram_normal_distribution() -> None: def test_render_histogram_zero_values_shown() -> None: """Test explicit categories render even when keys are missing.""" - histogram = Histogram(values={"DONE": 30}) + histogram = Distribution(values={"DONE": 30}) category_order = ["DONE", "TODO", "CANCELLED"] lines = render_histogram(histogram, 40, category_order) @@ -92,7 +93,7 @@ def test_render_histogram_zero_values_shown() -> None: def test_render_histogram_zero_value_key_included() -> None: """Test zero-value key renders when present in histogram.""" - histogram = Histogram(values={"DONE": 30, "TODO": 0}) + histogram = Distribution(values={"DONE": 30, "TODO": 0}) category_order = ["DONE", "TODO", "CANCELLED"] lines = render_histogram(histogram, 40, category_order) @@ -105,7 +106,7 @@ def test_render_histogram_zero_value_key_included() -> None: def test_render_histogram_category_truncation() -> None: """Test long category names truncated.""" - histogram = Histogram(values={"verylongname": 50, "short": 50}) + histogram = Distribution(values={"verylongname": 50, "short": 50}) category_order = ["verylongname", "short"] lines = render_histogram(histogram, 40, category_order) @@ -117,7 +118,7 @@ def test_render_histogram_category_truncation() -> None: def test_render_histogram_category_padding() -> None: """Test short names padded.""" - histogram = Histogram(values={"a": 50, "ab": 25, "abc": 25}) + histogram = Distribution(values={"a": 50, "ab": 25, "abc": 25}) category_order = ["a", "ab", "abc"] lines = render_histogram(histogram, 40, category_order) @@ -130,7 +131,7 @@ def test_render_histogram_category_padding() -> None: def test_render_histogram_custom_order() -> None: """Test respects category_order parameter.""" - histogram = Histogram(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) + histogram = Distribution(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) category_order = ["TODO", "DONE", "CANCELLED"] lines = render_histogram(histogram, 40, category_order) @@ -143,7 +144,7 @@ def test_render_histogram_custom_order() -> None: def test_render_histogram_no_order_alphabetical() -> None: """Test alphabetical when no order given.""" - histogram = Histogram(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) + histogram = Distribution(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) lines = render_histogram(histogram, 40, None) @@ -155,7 +156,7 @@ def test_render_histogram_no_order_alphabetical() -> None: def test_render_histogram_different_bucket_widths() -> None: """Test various bucket sizes.""" - histogram = Histogram(values={"Value": 100}) + histogram = Distribution(values={"Value": 100}) lines_20 = render_histogram(histogram, 20, ["Value"]) lines_50 = render_histogram(histogram, 50, ["Value"]) @@ -168,7 +169,7 @@ def test_render_histogram_different_bucket_widths() -> None: def test_render_histogram_proper_rounding() -> None: """Test bar lengths round correctly.""" - histogram = Histogram(values={"A": 33, "B": 33, "C": 34}) + histogram = Distribution(values={"A": 33, "B": 33, "C": 34}) lines = render_histogram(histogram, 30, ["A", "B", "C"]) @@ -184,7 +185,7 @@ def test_render_histogram_proper_rounding() -> None: def test_render_histogram_with_unknown_category() -> None: """Test unknown category handling.""" - histogram = Histogram(values={"Monday": 5, "Tuesday": 5, "Wednesday": 7, "unknown": 2}) + histogram = Distribution(values={"Monday": 5, "Tuesday": 5, "Wednesday": 7, "unknown": 2}) category_order = ["Monday", "Tuesday", "Wednesday", "unknown"] lines = render_histogram(histogram, 40, category_order) @@ -195,7 +196,7 @@ def test_render_histogram_with_unknown_category() -> None: def test_render_histogram_wednesday_no_truncation() -> None: """Test Wednesday (9 chars) fits exactly without truncation.""" - histogram = Histogram(values={"Wednesday": 100}) + histogram = Distribution(values={"Wednesday": 100}) lines = render_histogram(histogram, 40, ["Wednesday"]) @@ -204,7 +205,7 @@ def test_render_histogram_wednesday_no_truncation() -> None: def test_render_histogram_exact_nine_chars() -> None: """Test category with exactly 9 characters.""" - histogram = Histogram(values={"ExactNine": 100}) + histogram = Distribution(values={"ExactNine": 100}) lines = render_histogram(histogram, 40, ["ExactNine"]) @@ -213,7 +214,7 @@ def test_render_histogram_exact_nine_chars() -> None: def test_render_histogram_ten_chars_truncated() -> None: """Test category with 10 characters gets truncated.""" - histogram = Histogram(values={"TenCharact": 100}) + histogram = Distribution(values={"TenCharact": 100}) lines = render_histogram(histogram, 40, ["TenCharact"]) @@ -222,7 +223,7 @@ def test_render_histogram_ten_chars_truncated() -> None: def test_render_histogram_proportional_to_sum() -> None: """Test bars are proportional to sum, not max value.""" - histogram = Histogram(values={"A": 10, "B": 90}) + histogram = Distribution(values={"A": 10, "B": 90}) lines = render_histogram(histogram, 100, ["A", "B"]) @@ -232,7 +233,7 @@ def test_render_histogram_proportional_to_sum() -> None: def test_render_histogram_preserves_category_order_with_missing() -> None: """Test that explicit categories render before histogram extras.""" - histogram = Histogram(values={"B": 50}) + histogram = Distribution(values={"B": 50}) category_order = ["A", "B", "C"] lines = render_histogram(histogram, 40, category_order) @@ -245,7 +246,7 @@ def test_render_histogram_preserves_category_order_with_missing() -> None: def test_render_histogram_ordered_categories_then_remaining_alphabetical() -> None: """Render order should prefer provided categories, then append remaining sorted.""" - histogram = Histogram(values={"C": 5, "A": 3, "B": 4, "Z": 1}) + histogram = Distribution(values={"C": 5, "A": 3, "B": 4, "Z": 1}) lines = render_histogram(histogram, 40, ["B", "missing", "A"]) @@ -254,7 +255,7 @@ def test_render_histogram_ordered_categories_then_remaining_alphabetical() -> No def test_render_histogram_empty_with_no_order() -> None: """Test empty histogram with no order produces empty list.""" - histogram = Histogram(values={}) + histogram = Distribution(values={}) lines = render_histogram(histogram, 40, None) @@ -263,7 +264,7 @@ def test_render_histogram_empty_with_no_order() -> None: def test_render_histogram_single_bar_with_small_value() -> None: """Test that small values still show correctly.""" - histogram = Histogram(values={"A": 1, "B": 99}) + histogram = Distribution(values={"A": 1, "B": 99}) lines = render_histogram(histogram, 100, ["A", "B"]) diff --git a/tests/test_time_range.py b/tests/test_time_range.py index b5e313a..5498d04 100644 --- a/tests/test_time_range.py +++ b/tests/test_time_range.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import TimeRange +from org.logic.stats import TimeRange def test_time_range_initialization() -> None: diff --git a/tests/test_timeline_bucketing.py b/tests/test_timeline_bucketing.py index 230f147..39435e6 100644 --- a/tests/test_timeline_bucketing.py +++ b/tests/test_timeline_bucketing.py @@ -2,7 +2,7 @@ from datetime import date -from org.plot import bucket_timeline +from org.tui.plot import bucket_timeline def test_bucket_timeline_evenly_divisible() -> None: diff --git a/tests/test_timeline_chart.py b/tests/test_timeline_chart.py index 809424b..0a50c4b 100644 --- a/tests/test_timeline_chart.py +++ b/tests/test_timeline_chart.py @@ -2,7 +2,7 @@ from datetime import date -from org.plot import TimelineRenderConfig, _map_value_to_bar, render_timeline_chart +from org.tui.plot import TimelineRenderConfig, _map_value_to_bar, render_timeline_chart def test_map_value_to_bar_zero_max() -> None: diff --git a/tests/test_timeline_expansion.py b/tests/test_timeline_expansion.py index b517c09..2d18def 100644 --- a/tests/test_timeline_expansion.py +++ b/tests/test_timeline_expansion.py @@ -2,7 +2,7 @@ from datetime import date -from org.plot import expand_timeline +from org.tui.plot import expand_timeline def test_expand_timeline_single_day() -> None: diff --git a/tests/tui/test_tui_display.py b/tests/tui/test_tui_display.py index b937c53..94012fb 100644 --- a/tests/tui/test_tui_display.py +++ b/tests/tui/test_tui_display.py @@ -2,8 +2,8 @@ from __future__ import annotations -from org.analyze import Group, TimeRange -from org.tui import ( +from org.logic.stats import Group, TimeRange +from org.tui.bits import ( TopTasksSectionConfig, format_groups_section, format_top_tasks_section,