From 92bfc4ff9954c56adfc33da2ca16bb1eebbc4908 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Wed, 24 Jun 2026 11:44:30 +0200 Subject: [PATCH 1/2] Renamed org.pipeline to org.db --- VIBES.org | 40 +++++++++++++------- pyproject.toml | 2 +- src/org/commands/agenda/actions.py | 2 +- src/org/commands/agenda/app.py | 2 +- src/org/commands/agenda/command.py | 2 +- src/org/commands/board/actions.py | 2 +- src/org/commands/board/command.py | 2 +- src/org/commands/stats/all.py | 2 +- src/org/commands/stats/groups.py | 2 +- src/org/commands/stats/summary.py | 2 +- src/org/commands/stats/tags.py | 2 +- src/org/commands/tasks/add.py | 2 +- src/org/commands/tasks/archive.py | 2 +- src/org/commands/tasks/capture/domain.py | 2 +- src/org/commands/tasks/edit.py | 2 +- src/org/commands/tasks/find.py | 4 +- src/org/commands/tasks/list/actions.py | 2 +- src/org/commands/tasks/list/command.py | 4 +- src/org/commands/tasks/query.py | 4 +- src/org/commands/tasks/remove.py | 2 +- src/org/commands/tasks/update.py | 2 +- src/org/{pipeline => db}/__init__.py | 0 src/org/{pipeline => db}/format.py | 0 src/org/{pipeline => db}/load.py | 0 src/org/logic/edit.py | 2 +- tests/cli_common/test_cli_common_helpers.py | 2 +- tests/cli_common/test_file_errors.py | 4 +- tests/cli_common/test_filter_construction.py | 2 +- tests/commands/tasks/test_tasks_edit.py | 2 +- tests/commands/tasks/test_tasks_list.py | 2 +- tests/commands/test_agenda_command.py | 2 +- tests/commands/test_board.py | 2 +- tests/commands/test_tasks_query_command.py | 2 +- tests/test_output_format.py | 10 ++--- 34 files changed, 64 insertions(+), 52 deletions(-) rename src/org/{pipeline => db}/__init__.py (100%) rename src/org/{pipeline => db}/format.py (100%) rename src/org/{pipeline => db}/load.py (100%) diff --git a/VIBES.org b/VIBES.org index c745922..5884f54 100644 --- a/VIBES.org +++ b/VIBES.org @@ -8944,20 +8944,32 @@ Add the query compilation cache. :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 documents from files maintaining their last modification time, -- watch for external file changes and re-load a document as needed (file modification time more recent than currently stored one) -- allow explicitly reloading a document from file, -- allow saving the documents explicitly (dump file contents, reload and update the modification state), -- allow overwriting potentially externally modified document contents without generating errors, -- allow querying the repository using the query language: =repository.query(query_stages, context)=. -- cache discovered todo and done states for other commands, - -The abstraction should handle file conflicts with explicit errors (attempting to save a file that was modified outside of the CLI, file no longer exists, etc). These errors will be then handled by the commands in various ways (interactive commands should display a prompt asking for confirmation about overwriting or reloading and reapplying an action; non-interactive commands should print the error to the output). - -All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. -All commands should be updated to handle the file errors more or less gracefully. +1. Add a repository-pattern abstraction over one or more org files to the =org.db= package. The abstraction should: + - automatically load all the documents from files maintaining their last modification time, + - watch for external file changes and re-load a document as needed (file modification time more recent than currently stored one) + - allow explicitly reloading a document from file, + - allow saving the documents explicitly (dump file contents, reload and update the modification state), + - allow optionally overwriting potentially externally modified document contents without generating errors, + - cache discovered todo and done states for the commands, + - allow querying the repository using the query language: =repository.query(query_stages, context)=. + - provide the means to fetch headings by identity, + - provide the means to modify & save specific headings, + - provide the means to move headings between documents, + - provide the means to add & remove headings from a document. + +2. Most if not all functionality of the current =org.db.load= module should be moved to the repository implementation and made uniform. + +3. The repository should: + - detect which documents have been modified by the commands (by way od the =document.dirty= flag) and which files have been modified outside of the CLI (stored & file modification times). This detection does not need to be done async, it's OK if the detection is carried out on all read & write operations. + - handle file conflicts with explicit errors (attempting to save a file that was modified outside of the CLI, file no longer exists, etc). These errors will then be handled by the commands in various ways (interactive commands should display a prompt asking for confirmation about overwriting or reloading and reapplying an action; non-interactive commands should print the error to the output). + - ideally, saving the files would be an explicit operation that automatically derives which documents to save (automatically detect which documents need saving and if their associated files were modified outside of the CLI). No implicit document saving should be performed by the respository, it should be an explicit operation that the commands run on the repository. + - ideally, when querying the repository data, outside-CLI modifications should be detected, and files that can be reloaded without conflicts (no in-app modifiations) should be automatically and implicitly reloaded. + - upon saving a modified document, reload the file contents of that document, so that the modification state is reset. + +4. All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. Ideally, the app would load the configuration file, pass it to the command, build the command arguments, create a repository based on the config and arguments, run the command logic using the config, arguments and the org repository, and finally use the repository to query for the data, modify it and save the files if needed. Interactive commands should maintain the repository in their state and rely on its saving & reloading features. + +5. All commands should be updated to handle the file errors more or less gracefully. + *** CANCELLED Use Textual header & footer widgets in the interactive commands :LOGBOOK: diff --git a/pyproject.toml b/pyproject.toml index be4237c..b08b00d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ lines-after-imports = 2 "SLF001", # private member access is tolerated in tests/support code ] -"src/org/pipeline/format.py" = [ +"src/org/db/format.py" = [ "S603", # pandoc subprocess invocation is an intentional feature "S112", # best-effort serialization intentionally skips unreadable attributes ] diff --git a/src/org/commands/agenda/actions.py b/src/org/commands/agenda/actions.py index 353b70f..05be2e7 100644 --- a/src/org/commands/agenda/actions.py +++ b/src/org/commands/agenda/actions.py @@ -20,6 +20,7 @@ save_document, todo_states_for_heading, ) +from org.db.load import load_and_process_data 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 @@ -30,7 +31,6 @@ 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 .ui import ( AgendaColumnWidths, diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 30a91ec..36fbe15 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -17,7 +17,7 @@ import org.tui.prompt import org.tui.selection from org.commands.tasks.common import clock_duration_prompt_label -from org.pipeline.load import resolve_input_paths +from org.db.load import resolve_input_paths from . import actions, ui from .actions import AgendaSession, create_agenda_session diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 75bef8c..566679b 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -11,7 +11,7 @@ import org.config.app import org.logging -from org.pipeline.load import load_and_process_data +from org.db.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 diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index f2dffd2..dccb226 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -12,6 +12,7 @@ import org.config.app from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import save_document +from org.db.load import load_and_process_data 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 @@ -23,7 +24,6 @@ shift_priority, ) from org.logic.time import advance_timestamp_by_repeater, local_now -from org.pipeline.load import load_and_process_data from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import build_filter_order_query_text, run_query diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index 9f1cb76..6f8dbd0 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -11,7 +11,7 @@ import org.config.app import org.logging -from org.pipeline.load import load_and_process_data +from org.db.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 diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 2139aaf..68ebeb1 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -13,11 +13,11 @@ import org.config.app import org.logging from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary +from org.db.load import load_and_process_data 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, diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index adefc6c..02a8b6d 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -9,6 +9,7 @@ import org.config.app import org.logging +from org.db.load import load_and_process_data from org.logic.stats import ( Group, TimeRange, @@ -23,7 +24,6 @@ ) 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, apply_indent, diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index fdc825c..75ab36b 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -9,6 +9,7 @@ import org.config.app import org.logging +from org.db.load import load_and_process_data from org.logic.stats import ( AnalysisResult, Distribution, @@ -22,7 +23,6 @@ compute_task_stats, ) 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, diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index 28e3371..d0d68d9 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -9,6 +9,7 @@ import org.config.app import org.logging +from org.db.load import load_and_process_data from org.logic.stats import ( Tag, TimeRange, @@ -22,7 +23,6 @@ ) 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, apply_indent, diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index 577d8de..fc31e49 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -24,7 +24,7 @@ resolve_parent_heading, save_document, ) -from org.pipeline.load import resolve_input_paths +from org.db.load import resolve_input_paths _TASK_TEMPLATE = "{heading}\n{planning}{properties}{body}" diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index 8fb75dc..4f8c96d 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -15,8 +15,8 @@ save_document, selected_heading_results, ) +from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context from org.logic.archive import archive_heading_subtree, archive_result_documents_to_save -from org.pipeline.load import load_documents, resolve_input_paths, resolve_loaded_state_context from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 5776419..af41c34 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -19,7 +19,7 @@ save_document, selected_heading_results, ) -from org.pipeline.format import DEFAULT_OUTPUT_THEME +from org.db.format import DEFAULT_OUTPUT_THEME from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query from org.tui.help import InteractiveHelpEntry diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index 77c1b9a..c9c7f1b 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -13,8 +13,8 @@ resolve_task_selector_query, selected_heading_results, ) +from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context from org.logic.edit import edit_heading_subtree_in_external_editor -from org.pipeline.load import load_documents, resolve_input_paths, resolve_loaded_state_context from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 30c4dc3..3e9e5cf 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -15,13 +15,13 @@ import org.logging from org.commands.tasks.common import normalize_selector from org.commands.tasks.query import get_query_formatter -from org.pipeline.format import ( +from org.db.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, print_prepared_output, ) -from org.pipeline.load import load_root_data +from org.db.load import load_root_data from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query from org.tui.bits import build_console, processing_status, setup_output diff --git a/src/org/commands/tasks/list/actions.py b/src/org/commands/tasks/list/actions.py index e68a934..27d6668 100644 --- a/src/org/commands/tasks/list/actions.py +++ b/src/org/commands/tasks/list/actions.py @@ -17,6 +17,7 @@ save_document, todo_states_for_heading, ) +from org.db.load import load_and_process_data 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 @@ -28,7 +29,6 @@ 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 diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index a7371c6..07a8a73 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -13,7 +13,7 @@ import org.config.app import org.logging -from org.pipeline.format import ( +from org.db.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -27,7 +27,7 @@ _prepare_output, print_prepared_output, ) -from org.pipeline.load import load_and_process_data +from org.db.load import load_and_process_data from org.tui.bits import ( TaskLineConfig, build_console, diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index de0181b..defd302 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -17,7 +17,7 @@ import org.config.app import org.logging -from org.pipeline.format import ( +from org.db.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -31,7 +31,7 @@ _prepare_output, print_prepared_output, ) -from org.pipeline.load import load_root_data +from org.db.load import load_root_data from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query from org.tui.bits import build_console, processing_status, setup_output diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index aa494a4..76f9ff4 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -17,7 +17,7 @@ save_document, selected_heading_results, ) -from org.pipeline.load import load_documents, resolve_input_paths, resolve_loaded_state_context +from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query from org.tui.color import should_use_color diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 07db441..866823c 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -30,7 +30,7 @@ save_document, selected_heading_results, ) -from org.pipeline.load import load_documents, resolve_input_paths, resolve_loaded_state_context +from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query from org.tui.color import should_use_color diff --git a/src/org/pipeline/__init__.py b/src/org/db/__init__.py similarity index 100% rename from src/org/pipeline/__init__.py rename to src/org/db/__init__.py diff --git a/src/org/pipeline/format.py b/src/org/db/format.py similarity index 100% rename from src/org/pipeline/format.py rename to src/org/db/format.py diff --git a/src/org/pipeline/load.py b/src/org/db/load.py similarity index 100% rename from src/org/pipeline/load.py rename to src/org/db/load.py diff --git a/src/org/logic/edit.py b/src/org/logic/edit.py index c7806bf..98da339 100644 --- a/src/org/logic/edit.py +++ b/src/org/logic/edit.py @@ -10,7 +10,7 @@ import click import typer -from org.pipeline.load import load_document_from_text +from org.db.load import load_document_from_text if TYPE_CHECKING: diff --git a/tests/cli_common/test_cli_common_helpers.py b/tests/cli_common/test_cli_common_helpers.py index 1ee1c8e..f5733f0 100644 --- a/tests/cli_common/test_cli_common_helpers.py +++ b/tests/cli_common/test_cli_common_helpers.py @@ -8,9 +8,9 @@ import pytest import typer +from org.db.load import resolve_input_paths 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 2cd8ec8..c260421 100644 --- a/tests/cli_common/test_file_errors.py +++ b/tests/cli_common/test_file_errors.py @@ -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.pipeline.load import load_root_nodes + from org.db.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.pipeline.load import load_root_nodes + from org.db.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 84065eb..166b4c1 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -373,7 +373,7 @@ def test_build_query_text_custom_ordering_for_stats() -> None: def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixture) -> None: """Data loading should log the query execution context.""" - from org.pipeline.load import load_and_process_data + from org.db.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) diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index c48f423..4b7cd72 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -288,7 +288,7 @@ def _edit_file(filename: str, _line: int) -> int: monkeypatch.setattr(editor_command, "_run_editor_at_line", _edit_file) monkeypatch.setattr( - "org.pipeline.load.loads", + "org.db.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 b764d1d..8aba9da 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -18,11 +18,11 @@ 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.db.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 diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index d5dadd7..cc7d3b1 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -25,9 +25,9 @@ resolve_view_context, ) from org.commands.tasks.common import duration_to_org_text, parse_clock_duration +from org.db.load import load_and_process_data 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 diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index 35edc2b..7847129 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -17,10 +17,10 @@ import org.config.app from org.commands.board import actions, ui from org.commands.board import command as board_command +from org.db.load import load_and_process_data 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 diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index 890a29b..673244c 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -16,7 +16,7 @@ import org.config.app from org import cli from org.commands.tasks.query import TasksQueryArgs, _is_org_object, run_tasks_query -from org.pipeline.format import OutputFormat, OutputFormatError +from org.db.format import OutputFormat, OutputFormatError if TYPE_CHECKING: diff --git a/tests/test_output_format.py b/tests/test_output_format.py index def7180..72caddd 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -16,8 +16,8 @@ from org.commands.tasks import query as query_command from org.commands.tasks.list import command as tasks_list_command +from org.db import format as output_format from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange -from org.pipeline import format as output_format if TYPE_CHECKING: @@ -47,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.pipeline.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.db.format.subprocess.run", _fake_run) caplog.set_level(logging.INFO, logger="org") markdown_text = output_format._org_to_pandoc_format("* TODO test", "markdown", []) @@ -67,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.pipeline.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.db.format.subprocess.run", _fake_run) rendered = output_format._org_to_pandoc_format( "* TODO test", @@ -94,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.pipeline.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.db.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", []) @@ -110,7 +110,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs raise FileNotFoundError("pandoc not found") - monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.db.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="pandoc not found"): output_format._org_to_pandoc_format("* TODO test", "gfm", []) From 2966679a34ac74022342bf6440cec77c6f69dcaa Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Thu, 25 Jun 2026 19:27:06 +0200 Subject: [PATCH 2/2] Added a file repository abstraction --- VIBES.org | 96 ++++ src/org/commands/agenda/actions.py | 83 ++- src/org/commands/agenda/app.py | 12 +- src/org/commands/agenda/command.py | 35 +- src/org/commands/board/actions.py | 80 ++- src/org/commands/board/app.py | 9 +- src/org/commands/board/command.py | 31 +- src/org/commands/stats/all.py | 18 +- src/org/commands/stats/groups.py | 15 +- src/org/commands/stats/summary.py | 17 +- src/org/commands/stats/tags.py | 15 +- src/org/commands/tasks/add.py | 19 +- src/org/commands/tasks/archive.py | 76 ++- src/org/commands/tasks/capture/command.py | 6 +- src/org/commands/tasks/capture/domain.py | 10 +- src/org/commands/tasks/common.py | 41 +- src/org/commands/tasks/edit.py | 37 +- src/org/commands/tasks/find.py | 15 +- src/org/commands/tasks/list/actions.py | 46 +- src/org/commands/tasks/list/command.py | 21 +- src/org/commands/tasks/query.py | 29 +- src/org/commands/tasks/remove.py | 40 +- src/org/commands/tasks/update.py | 57 +- src/org/db/__init__.py | 2 +- src/org/db/errors.py | 87 +++ src/org/db/load.py | 263 --------- src/org/db/repository.py | 505 ++++++++++++++++++ src/org/logic/archive.py | 19 +- src/org/logic/edit.py | 8 +- tests/cli/test_cli_runner.py | 2 +- tests/cli_common/test_cli_common_helpers.py | 2 +- tests/cli_common/test_file_errors.py | 12 +- tests/cli_common/test_filter_construction.py | 8 +- .../commands/tasks/test_tasks_capture_app.py | 1 + tests/commands/tasks/test_tasks_edit.py | 2 +- tests/commands/tasks/test_tasks_list.py | 7 +- tests/commands/tasks/test_tasks_list_app.py | 1 + tests/commands/test_agenda_app.py | 25 +- tests/commands/test_agenda_command.py | 45 +- tests/commands/test_board.py | 137 ++++- tests/commands/test_board_app.py | 15 +- tests/commands/test_tasks_query_command.py | 47 +- 42 files changed, 1340 insertions(+), 656 deletions(-) create mode 100644 src/org/db/errors.py delete mode 100644 src/org/db/load.py create mode 100644 src/org/db/repository.py diff --git a/VIBES.org b/VIBES.org index 5884f54..edd6d90 100644 --- a/VIBES.org +++ b/VIBES.org @@ -8969,7 +8969,103 @@ Add the query compilation cache. 4. All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. Ideally, the app would load the configuration file, pass it to the command, build the command arguments, create a repository based on the config and arguments, run the command logic using the config, arguments and the org repository, and finally use the repository to query for the data, modify it and save the files if needed. Interactive commands should maintain the repository in their state and rely on its saving & reloading features. 5. All commands should be updated to handle the file errors more or less gracefully. +**** DONE Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/commands/board/actions.py::repository: OrgRepository | None = None]] +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-25 Thu 19:25] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/commands/board/actions.py + +#+begin_example + repository: OrgRepository | None = None + +#+end_example + +Looks like this is always available and should never be a =None=. Please adjust that type and refactor the places that check for the existance of board session repository (=_require_repository= etc). +**** DONE src/org/commands/agenda/actions.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-25 Thu 19:25] +:END: + +In file src/org/commands/agenda/actions.py around line 90, near the ~class AgendaSession:~ + +#+begin_example ++ repository: OrgRepository | None = None + +#+end_example + +This also should always be available. Adjust that for all the commands that use this pattern and make sure that the associated =_require_repository= functions are not necessary. + +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/db/repository.py::raise HeadingAmbiguousError( + f"--parent is ambiguous, multiple headings with title '{selector}'", + )]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/db/repository.py + +#+begin_example + raise HeadingAmbiguousError( + f"--parent is ambiguous, multiple headings with title '{selector}'", + ) + +#+end_example + +There's no guarantee that this selector is a title and comes from =--parent=. Please reword to just say that multiple headings match selector. +**** TODO src/org/commands/agenda/command.py +:PROPERTIES: +:context: +:END: + +In file src/org/commands/agenda/command.py around line 140, near the ~def run_agenda(args: AgendaArgs, config: org.config.app.AppConfig) -> None:~ + +#+begin_example ++ render=ui.RenderContext( ++ color_enabled=color_enabled, ++ done_states=done_states, ++ todo_states=todo_states, + +#+end_example + +I think it makes more sense for the RenderContext to also take the repository and use the todo and done states from there. This way, if the files change outside of the app and additional states are configured, the render config will immediately pick it up without having to restart the CLI. +**** TODO src/org/commands/agenda/command.py +:PROPERTIES: +:context: +:END: + +In file src/org/commands/agenda/command.py around line 136, near the ~def run_agenda(args: AgendaArgs, config: org.config.app.AppConfig) -> None:~ + +#+begin_example ++ nodes=nodes, + +#+end_example + +Both the Agenda and Board commands operate on subsets of all the nodes from the repository. Would it make sense to introduce a =RepositoryView= abstraction? I.e. a class that behaves exactly like a repository with file reloading logic, but it would also implicitly filter down the results based on a configured query. This way the agenda can receive a repository view of nodes filtered down by the CLI switches and then create further filtered-down views for the different days and sections of the agenda. + +**** TODO tests/cli_common/test_filter_construction.py +:PROPERTIES: +:context: +:END: + +In file tests/cli_common/test_filter_construction.py around line 389, near the ~def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixtu~ + +#+begin_example +- load_and_process_data(args, AppConfig(config_path=".org-cli.yaml")) ++ build_repository_query_plan( ++ args, ++ AppConfig(config_path=".org-cli.yaml"), ++ include_ordering=True, + +#+end_example +The test name no longer matches what it checks. *** CANCELLED Use Textual header & footer widgets in the interactive commands :LOGBOOK: diff --git a/src/org/commands/agenda/actions.py b/src/org/commands/agenda/actions.py index 05be2e7..d31ef3c 100644 --- a/src/org/commands/agenda/actions.py +++ b/src/org/commands/agenda/actions.py @@ -9,18 +9,22 @@ from typing import TYPE_CHECKING import typer +from org_parser.document import Heading from org_parser.time import Clock, Timestamp from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( duration_to_org_text, iter_descendants, - load_document, parse_clock_duration, - save_document, todo_states_for_heading, ) -from org.db.load import load_and_process_data +from org.db.errors import RepositoryError +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) 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 @@ -31,6 +35,7 @@ resolve_heading_locator, ) from org.logic.time import advance_timestamp_by_repeater, local_now, set_timestamp_fields +from org.query.engine.errors import QueryParseError, QueryRuntimeError from .ui import ( AgendaColumnWidths, @@ -49,7 +54,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from org_parser.document import Document, Heading + from org_parser.document import Document from org.config.app import AppConfig @@ -81,9 +86,20 @@ class AgendaSession: search_text: str view_ctx: AgendaViewContext app_config: AppConfig + repository: OrgRepository run_external: Callable[[Callable[[], None]], None] | None = None +@dataclass(frozen=True) +class AgendaSessionData: + """Prepared agenda session inputs shared with the app layer.""" + + nodes: list[Heading] + render: RenderContext + view_ctx: AgendaViewContext + repository: OrgRepository + + def refresh_session( session: AgendaSession, preserve_identity: HeadingLocator | None, @@ -194,15 +210,21 @@ def _shift_timestamp_by_hours(timestamp: Timestamp, hour_delta: int) -> None: set_timestamp_fields(timestamp, shifted_start, shifted_end) -def _save_document_changes(document: Document) -> None: +def _save_document_changes(session: AgendaSession, document: Document) -> None: """Persist one mutated document to disk.""" logger.info("Saving agenda edit file: %s", document.filename) - save_document(document) + session.repository.save_document(document.filename or "") def _reload_session_nodes(session: AgendaSession) -> None: """Reload nodes through standard processing pipeline after mutations.""" - nodes, _, _ = load_and_process_data(session.args, session.app_config) + plan = build_repository_query_plan(session.args, session.app_config, include_ordering=True) + repository = session.repository + results = repository.query(plan.stages, plan.context) + nodes = [value for value in results if isinstance(value, Heading)] + limit = session.args.max_results + if limit is not None: + nodes = nodes[session.args.offset : session.args.offset + limit] session.all_nodes = nodes session.nodes = filter_nodes_by_search(nodes, session.search_text) @@ -236,9 +258,13 @@ def archive_selected_task(session: AgendaSession) -> None: return session.status_message = "" try: - archive_result = archive_heading_subtree_and_save(row.node, {}) - except typer.BadParameter as err: - session.status_message = str(err) + archive_result = archive_heading_subtree_and_save( + row.node, + {}, + session.repository, + ) + except (RepositoryError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return preserve_identity = heading_locator(archive_result.heading) _reload_session_nodes(session) @@ -348,7 +374,7 @@ def apply_state_change_with_value(session: AgendaSession, new_state: str) -> Non heading.id, heading.deadline, ) - _save_document_changes(heading.document) + _save_document_changes(session, heading.document) preserve_identity = heading_locator(heading) _reload_session_nodes(session) refresh_session(session, preserve_identity) @@ -366,7 +392,7 @@ def apply_shift_date(session: AgendaSession, *, day_delta: int) -> None: session.status_message = status return heading = row.node - _save_document_changes(heading.document) + _save_document_changes(session, heading.document) preserve_identity = heading_locator(heading) _reload_session_nodes(session) refresh_session(session, preserve_identity) @@ -384,7 +410,7 @@ def apply_shift_time(session: AgendaSession, *, hour_delta: int) -> None: session.status_message = status return heading = row.node - _save_document_changes(heading.document) + _save_document_changes(session, heading.document) preserve_identity = heading_locator(heading) _reload_session_nodes(session) refresh_session(session, preserve_identity) @@ -413,9 +439,9 @@ def apply_refile_with_value(session: AgendaSession, destination_input: str) -> N session.status_message = "Refile cancelled" return try: - destination_document = load_document(destination_path) - except typer.BadParameter as err: - session.status_message = str(err) + destination_document = session.repository.get_document(destination_path) + except (RepositoryError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return heading = row.node source_document = heading.document @@ -429,9 +455,9 @@ def apply_refile_with_value(session: AgendaSession, destination_input: str) -> N session.status_message = "Task already in destination file" return _move_heading_to_document(heading, destination_document) - _save_document_changes(destination_document) + _save_document_changes(session, destination_document) if source_document is not destination_document: - _save_document_changes(source_document) + _save_document_changes(session, source_document) preserve_identity = heading_locator(heading) _reload_session_nodes(session) refresh_session(session, preserve_identity) @@ -461,7 +487,7 @@ def apply_clock_entry_with_value(session: AgendaSession, duration_input: str) -> ) duration_text = duration_to_org_text(duration) row.node.clock_entries.append(Clock(timestamp=timestamp, duration=duration_text)) - _save_document_changes(row.node.document) + _save_document_changes(session, row.node.document) preserve_identity = heading_locator(row.node) _reload_session_nodes(session) refresh_session(session, preserve_identity) @@ -539,11 +565,11 @@ def apply_capture_task(session: AgendaSession, template_name: str) -> None: return capture_result.heading.scheduled = scheduled try: - _save_document_changes(capture_result.document) + _save_document_changes(session, capture_result.document) _reload_session_nodes(session) refresh_session(session, heading_locator(capture_result.heading)) - except typer.BadParameter as err: - session.status_message = str(err) + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return session.status_message = f"Task captured and scheduled for {scheduled}" @@ -595,16 +621,14 @@ def apply_search_text(session: AgendaSession, search_text: str) -> None: def create_agenda_session( args: AgendaArgs, config: AppConfig, - nodes: list[Heading], - render: RenderContext, - view_ctx: AgendaViewContext, + data: AgendaSessionData, ) -> AgendaSession: """Create interactive session state for agenda.""" session = AgendaSession( args=args, - all_nodes=list(nodes), - nodes=nodes, - render=render, + all_nodes=list(data.nodes), + nodes=data.nodes, + render=data.render, start_date=resolve_agenda_start_date(args.date), days=args.days, now=local_now(), @@ -616,8 +640,9 @@ def create_agenda_session( scroll_offset=0, status_message="", search_text="", - view_ctx=view_ctx, + view_ctx=data.view_ctx, app_config=config, + repository=data.repository, ) refresh_session(session, None) return session diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 36fbe15..13da045 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -17,23 +17,21 @@ import org.tui.prompt import org.tui.selection from org.commands.tasks.common import clock_duration_prompt_label -from org.db.load import resolve_input_paths +from org.db.repository import resolve_input_paths from . import actions, ui -from .actions import AgendaSession, create_agenda_session +from .actions import AgendaSession, AgendaSessionData, 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" @@ -468,10 +466,8 @@ def action_quit_app(self) -> None: def run_agenda_app( args: AgendaArgs, config: org.config.app.AppConfig, - nodes: list[Heading], - render: ui.RenderContext, - view_ctx: AgendaViewContext, + data: AgendaSessionData, ) -> None: """Run the Textual-backed interactive agenda app.""" - session = create_agenda_session(args, config, nodes, render, view_ctx) + session = create_agenda_session(args, config, data) AgendaApp(session).run() diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 566679b..64a3df2 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -8,14 +8,20 @@ import click import typer +from org_parser.document import Heading import org.config.app import org.logging -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) from org.tui.bits import build_console, processing_status, setup_output from org.tui.help import interactive_help_command_text from . import ui +from .actions import AgendaSessionData from .app import run_agenda_app from .views import resolve_view_context @@ -97,7 +103,17 @@ def run_agenda(args: AgendaArgs, config: org.config.app.AppConfig) -> None: view_ctx = resolve_view_context(args, config.agenda.views) with processing_status(console, color_enabled): - nodes, todo_states, done_states = load_and_process_data(args, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=True) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] + if args.max_results is not None: + nodes = nodes[args.offset : args.offset + args.max_results] + todo_states = repository.todo_states + done_states = repository.done_states if not nodes: console.print("No results", markup=False) @@ -109,13 +125,16 @@ def run_agenda(args: AgendaArgs, config: org.config.app.AppConfig) -> None: run_agenda_app( args, config, - nodes, - ui.RenderContext( - color_enabled=color_enabled, - done_states=done_states, - todo_states=todo_states, + AgendaSessionData( + repository=repository, + nodes=nodes, + render=ui.RenderContext( + color_enabled=color_enabled, + done_states=done_states, + todo_states=todo_states, + ), + view_ctx=view_ctx, ), - view_ctx, ) diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index dccb226..fd8f13e 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -8,11 +8,16 @@ from typing import TYPE_CHECKING, cast import typer +from org_parser.document import Heading import org.config.app from org.commands.tasks.capture import TasksCaptureArgs, capture_task -from org.commands.tasks.common import save_document -from org.db.load import load_and_process_data +from org.db.errors import RepositoryError +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) 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 @@ -31,7 +36,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence - from org_parser.document import Document, Heading + from org_parser.document import Document from org.config.app import AppConfig, BoardViewConfig @@ -88,10 +93,21 @@ class BoardSession: scroll_offset: int status_message: str app_config: AppConfig + repository: OrgRepository all_columns: Sequence[BoardColumn] = field(default_factory=list) search_text: str = "" +@dataclass(frozen=True) +class BoardSessionData: + """Prepared board session inputs shared with the app layer.""" + + repository: OrgRepository + nodes: list[Heading] + state_lists: tuple[list[str], list[str]] + color_enabled: bool + + def _coerce_latest_timestamp_start(node: Heading) -> datetime | None: """Return node latest timestamp start value when available.""" latest_timestamp = node.latest_timestamp @@ -317,10 +333,14 @@ def reload_session( preserve_identity: HeadingLocator | None, ) -> None: """Reload processed nodes and rebuild board columns.""" - nodes, discovered_todo_states, discovered_done_states = load_and_process_data( - session.args, - session.app_config, - ) + plan = build_repository_query_plan(session.args, session.app_config, include_ordering=True) + results = session.repository.query(plan.stages, plan.context) + nodes = [value for value in results if isinstance(value, Heading)] + limit = session.args.max_results + if limit is not None: + nodes = nodes[session.args.offset : session.args.offset + limit] + discovered_todo_states = session.repository.todo_states + discovered_done_states = session.repository.done_states todo_states, done_states = resolved_states( session.args, discovered_todo_states, @@ -342,13 +362,14 @@ def reload_session( def create_board_session( args: BoardArgs, config: AppConfig, - nodes: list[Heading], - state_lists: tuple[list[str], list[str]], - color_enabled: bool, + data: BoardSessionData, ) -> BoardSession: """Create interactive board session state.""" - todo_states, done_states = state_lists - columns = build_selector_board_columns(nodes, resolve_column_specs(args, config.board.views)) + todo_states, done_states = data.state_lists + columns = build_selector_board_columns( + data.nodes, + resolve_column_specs(args, config.board.views), + ) selected_column_index = 0 for index, column in enumerate(columns): if column.nodes: @@ -357,13 +378,14 @@ def create_board_session( session = BoardSession( args=args, - nodes=nodes, + nodes=data.nodes, todo_states=todo_states, done_states=done_states, app_config=config, + repository=data.repository, all_columns=columns, columns=columns, - color_enabled=color_enabled, + color_enabled=data.color_enabled, selected_column_index=selected_column_index, selected_row_index=0, scroll_offset=0, @@ -374,10 +396,10 @@ def create_board_session( return session -def _save_document_changes(document: Document) -> None: +def _save_document_changes(session: BoardSession, document: Document) -> None: """Persist one mutated document to disk.""" logger.info("Saving flow board edit file: %s", document.filename) - save_document(document) + session.repository.save_document(document.filename or "") def step_heading_state(heading: Heading, *, direction: int) -> tuple[str | None, str | None]: @@ -480,12 +502,12 @@ def apply_state_move(session: BoardSession, *, direction: int) -> None: new_state, ) - _save_document_changes(heading.document) + _save_document_changes(session, heading.document) preserve_identity = heading_locator(heading) try: reload_session(session, preserve_identity) - except typer.BadParameter as err: - session.status_message = str(err) + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return session.status_message = f"State updated: {old_state or '-'} -> {new_state or '-'}" @@ -512,12 +534,12 @@ def apply_priority_shift(session: BoardSession, *, increase: bool) -> None: old_priority, new_priority, ) - _save_document_changes(heading.document) + _save_document_changes(session, heading.document) preserve_identity = heading_locator(heading) try: reload_session(session, preserve_identity) - except typer.BadParameter as err: - session.status_message = str(err) + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return session.status_message = f"Priority updated: {old_priority or '-'} -> {new_priority or '-'}" @@ -558,16 +580,16 @@ def archive_selected_task(session: BoardSession) -> None: session.status_message = "" try: - archive_result = archive_heading_subtree_and_save(heading, {}) - except typer.BadParameter as err: - session.status_message = str(err) + archive_result = archive_heading_subtree_and_save(heading, {}, session.repository) + except (RepositoryError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return preserve_identity = heading_locator(archive_result.heading) try: reload_session(session, preserve_identity) - except typer.BadParameter as err: - session.status_message = str(err) + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return session.status_message = "Task archived" @@ -593,8 +615,8 @@ def apply_capture_task(session: BoardSession, template_name: str) -> None: try: reload_session(session, heading_locator(capture_result.heading)) - except typer.BadParameter as err: - session.status_message = str(err) + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return session.status_message = "Task captured" diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index d6a3d35..2934eed 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -20,14 +20,13 @@ import org.tui.selection from . import actions, ui -from .actions import BoardSession, create_board_session +from .actions import BoardSession, BoardSessionData, 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 @@ -415,10 +414,8 @@ def action_quit_app(self) -> None: def run_board_app( args: BoardArgs, config: org.config.app.AppConfig, - nodes: list[Heading], - state_lists: tuple[list[str], list[str]], - color_enabled: bool, + data: BoardSessionData, ) -> None: """Run the Textual-backed interactive board app.""" - session = create_board_session(args, config, nodes, state_lists, color_enabled) + session = create_board_session(args, config, data) BoardApp(session).run() diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index 6f8dbd0..59fa901 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -8,14 +8,20 @@ import click import typer +from org_parser.document import Heading import org.config.app import org.logging -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) 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 .actions import BoardSessionData from .app import run_board_app @@ -92,7 +98,17 @@ def run_board(args: BoardArgs, config: org.config.app.AppConfig) -> None: _validate_board_args(args, config) with processing_status(console, color_enabled): - nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=True) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] + if args.max_results is not None: + nodes = nodes[args.offset : args.offset + args.max_results] + discovered_todo_states = repository.todo_states + discovered_done_states = repository.done_states nodes = actions.filter_recent_completed_nodes(nodes, args.days) todo_states, done_states = actions.resolved_states( args, @@ -107,7 +123,16 @@ def run_board(args: BoardArgs, config: org.config.app.AppConfig) -> None: if not (sys.stdin.isatty() and sys.stdout.isatty()): raise click.UsageError("org board requires a TTY") - run_board_app(args, config, nodes, (todo_states, done_states), color_enabled) + run_board_app( + args, + config, + BoardSessionData( + repository=repository, + nodes=nodes, + state_lists=(todo_states, done_states), + color_enabled=color_enabled, + ), + ) def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 68ebeb1..86baeb0 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import typer +from org_parser.document import Heading from rich.layout import Layout from rich.panel import Panel from rich.text import Text @@ -13,7 +14,11 @@ import org.config.app import org.logging from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) 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 @@ -36,7 +41,6 @@ if TYPE_CHECKING: from datetime import datetime - from org_parser.document import Heading from rich.console import Console @@ -530,7 +534,15 @@ def run_stats(args: StatsAllArgs, config: org.config.app.AppConfig) -> None: with processing_status(console, color_enabled): 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, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=False) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] + todo_states = repository.todo_states + done_states = repository.done_states if nodes: result = analyze(nodes, mapping, args.use, args.max_relations) date_from, date_until = resolve_date_filters(args) diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index 02a8b6d..acfef78 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -6,10 +6,15 @@ from typing import TYPE_CHECKING import typer +from org_parser.document import Heading import org.config.app import org.logging -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) from org.logic.stats import ( Group, TimeRange, @@ -144,7 +149,13 @@ def run_stats_groups(args: GroupsArgs, config: org.config.app.AppConfig) -> None with processing_status(console, color_enabled): mapping = org.config.app.resolve_mapping(args) exclude_set = org.config.app.resolve_exclude_set(args) - nodes, _, _ = load_and_process_data(args, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=False) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] if not nodes: output = None diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index 75ab36b..7df41f2 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -6,10 +6,15 @@ from typing import TYPE_CHECKING import typer +from org_parser.document import Heading import org.config.app import org.logging -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) from org.logic.stats import ( AnalysisResult, Distribution, @@ -223,7 +228,15 @@ def run_stats_summary(args: SummaryArgs, config: org.config.app.AppConfig) -> No console = build_console(color_enabled, args.width) _validate_summary_arguments(args) with processing_status(console, color_enabled): - nodes, todo_states, done_states = load_and_process_data(args, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=False) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] + todo_states = repository.todo_states + done_states = repository.done_states if not nodes: output = None diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index d0d68d9..627a2e8 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -6,10 +6,15 @@ from typing import TYPE_CHECKING import typer +from org_parser.document import Heading import org.config.app import org.logging -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) from org.logic.stats import ( Tag, TimeRange, @@ -167,7 +172,13 @@ def run_stats_tags(args: TagsArgs, config: org.config.app.AppConfig) -> None: with processing_status(console, color_enabled): mapping = org.config.app.resolve_mapping(args) exclude_set = org.config.app.resolve_exclude_set(args) - nodes, _, _ = load_and_process_data(args, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=False) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] if not nodes: output = None diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index fc31e49..ae90a22 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -13,7 +13,6 @@ import org.config.app import org.logging from org.commands.tasks.common import ( - load_document, normalize_optional_value, normalize_selector, parse_comment_flag, @@ -22,9 +21,8 @@ parse_tags_csv, parse_timestamp, resolve_parent_heading, - save_document, ) -from org.db.load import resolve_input_paths +from org.db.repository import OrgRepository, cli_error_from_repository_error, resolve_input_paths _TASK_TEMPLATE = "{heading}\n{planning}{properties}{body}" @@ -313,12 +311,16 @@ def _attach_heading(document: Document, parent_heading: Heading | None, heading: def run_tasks_add(args: AddArgs, config: org.config.app.AppConfig) -> None: """Run the tasks add command.""" - del config _validate_heading_option_exclusivity(args) read_from_stdin = _should_read_task_from_stdin(args) - filename = _resolve_target_file(args.file, args.files) - document = load_document(filename) + try: + filename = _resolve_target_file(args.file, args.files) + repository = OrgRepository([filename], config.todo_states, config.done_states) + document = repository.get_document(filename) + except Exception as err: + raise cli_error_from_repository_error(err) from err + parent_heading: Heading | None = None if args.parent is not None: parent_heading = resolve_parent_heading(document, args.parent) @@ -332,7 +334,10 @@ def run_tasks_add(args: AddArgs, config: org.config.app.AppConfig) -> None: _attach_heading(document, parent_heading, heading) if args.level is not None: heading.level = args.level - save_document(document) + try: + repository.save_document(document.filename or filename) + except Exception as err: + raise cli_error_from_repository_error(err) from err def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index 4f8c96d..2bc090a 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -12,13 +12,12 @@ import org.logging from org.commands.tasks.common import ( resolve_task_selector_query, - save_document, - selected_heading_results, + selected_heading_query_results, ) -from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context +from org.db.errors import RepositoryError +from org.db.repository import OrgRepository, cli_error_from_repository_error, resolve_input_paths from org.logic.archive import archive_heading_subtree, archive_result_documents_to_save from org.query.engine.errors import QueryParseError, QueryRuntimeError -from org.query.runner import run_query logger = logging.getLogger("org") @@ -54,54 +53,41 @@ def _selected_archive_roots(headings: list[Heading]) -> list[Heading]: def run_tasks_archive(args: ArchiveArgs, config: org.config.app.AppConfig) -> None: """Run the tasks archive command.""" - filenames = resolve_input_paths(args.files) - selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) - documents = load_documents(filenames) - todo_states, done_states = resolve_loaded_state_context( - documents, - config.todo_states, - config.done_states, - ) - - logger.info("Task selector query: %s", selector_query) try: - results = run_query( - documents, - [selector_query], - {"todo_states": todo_states, "done_states": done_states}, - ) - except QueryParseError as exc: - raise typer.BadParameter(f"Invalid task selector query: {exc}") from exc - except QueryRuntimeError as exc: - raise typer.BadParameter(f"Task selector query failed: {exc}") from exc + filenames = resolve_input_paths(args.files) + selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) + repository = OrgRepository(filenames, config.todo_states, config.done_states) + selected_headings = selected_heading_query_results(repository, selector_query) + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + raise cli_error_from_repository_error(err) from err - selected_headings = selected_heading_results(results) if not selected_headings: raise typer.BadParameter("No task matches the provided selector") archive_roots = _selected_archive_roots(selected_headings) destination_cache: dict[str, Document] = {} - documents_to_save: dict[int, Document] = {} - for heading in archive_roots: - archive_result = archive_heading_subtree(heading, destination_cache) - logger.info( - "Archiving task: file=%s title=%s id=%s destination=%s parent=%s", - archive_result.source_document.filename, - archive_result.heading.title_text, - archive_result.heading.id, - archive_result.destination_document.filename, - ( - archive_result.target.parent_heading.title_text - if archive_result.target.parent_heading is not None - else "" - ), - ) - for document in archive_result_documents_to_save(archive_result): - documents_to_save[id(document)] = document - - for document in documents_to_save.values(): - logger.info("Saving archived file: %s", document.filename) - save_document(document) + try: + documents_to_save: dict[int, Document] = {} + for heading in archive_roots: + archive_result = archive_heading_subtree(heading, destination_cache, repository) + logger.info( + "Archiving task: file=%s title=%s id=%s destination=%s parent=%s", + archive_result.source_document.filename, + archive_result.heading.title_text, + archive_result.heading.id, + archive_result.destination_document.filename, + ( + archive_result.target.parent_heading.title_text + if archive_result.target.parent_heading is not None + else "" + ), + ) + for document in archive_result_documents_to_save(archive_result): + documents_to_save[id(document)] = document + + repository.save_documents(list(documents_to_save.values())) + except (RepositoryError, typer.BadParameter) as err: + raise cli_error_from_repository_error(err) from err typer.echo(f"Archived {len(archive_roots)} tasks.") diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py index aabe6d3..b1037e4 100644 --- a/src/org/commands/tasks/capture/command.py +++ b/src/org/commands/tasks/capture/command.py @@ -10,6 +10,7 @@ import org.config.app import org.logging +from org.db.repository import cli_error_from_repository_error from org.tui.help import interactive_help_command_text from .app import run_capture_form_app, run_template_selection_app @@ -80,7 +81,10 @@ def run_tasks_capture(args: TasksCaptureArgs, config: org.config.app.AppConfig) "org tasks capture requires a TTY unless TEMPLATE_NAME and all values are provided", ) - result = capture_task(args, config.tasks.capture.templates) + try: + result = capture_task(args, config.tasks.capture.templates) + except Exception as err: + raise cli_error_from_repository_error(err) from err if not result.interactive_used: typer.echo(result.heading.id or result.heading.title_text) diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index af41c34..1b50783 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -14,12 +14,11 @@ from rich.syntax import Syntax from org.commands.tasks.common import ( - load_document, resolve_parent_heading, - save_document, selected_heading_results, ) from org.db.format import DEFAULT_OUTPUT_THEME +from org.db.repository import OrgRepository from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.runner import run_query from org.tui.help import InteractiveHelpEntry @@ -75,6 +74,7 @@ class CapturePlan: placeholders: list[str] values: dict[str, str] unresolved_placeholders: list[str] + repository: OrgRepository @dataclass(frozen=True) @@ -338,7 +338,8 @@ def prepare_capture_plan( 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) + repository = OrgRepository([target_file], ["TODO"], ["DONE"]) + document = repository.get_document(target_file) parent_heading = None if args.parent is not None: @@ -365,6 +366,7 @@ def prepare_capture_plan( args=args, template_name=template_name, template_content=template["content"], + repository=repository, document=document, parent_heading=parent_heading, placeholders=placeholders, @@ -392,7 +394,7 @@ def finalize_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) + plan.repository.save_document(plan.document.filename or "") return TasksCaptureResult( template_name=plan.template_name, heading=heading, diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index f5a2f08..53fcf2c 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -7,16 +7,19 @@ from datetime import timedelta from typing import TYPE_CHECKING, Literal -import org_parser import typer from org_parser.document import Heading from org_parser.text import CompletionCounter from org_parser.time import Timestamp +from org.query.engine.errors import QueryParseError, QueryRuntimeError + if TYPE_CHECKING: from org_parser.document import Document + from org.db.repository import OrgRepository + logger = logging.getLogger("org") PlanningTimestampField = Literal["scheduled", "deadline", "closed"] @@ -113,27 +116,6 @@ def resolve_task_selector_query( return f".[] | select(str(.id) == {json.dumps(normalized_id)})" -def load_document(path: str) -> Document: - """Load org document from file for mutation.""" - try: - return org_parser.load(path) - except FileNotFoundError as err: - raise typer.BadParameter(f"File '{path}' not found") from err - except PermissionError as err: - raise typer.BadParameter(f"Permission denied for '{path}'") from err - except ValueError as err: - raise typer.BadParameter(f"Unable to parse '{path}': {err}") from err - - -def save_document(document: Document) -> None: - """Persist updated org document back to disk.""" - try: - org_parser.dump(document) - except PermissionError as err: - filename = document.filename or "" - raise typer.BadParameter(f"Permission denied for '{filename}'") from err - - def title_matches(document: Document, title: str | None) -> list[Heading]: """Return headings matching title selector in one document.""" if title is None: @@ -172,6 +154,21 @@ def resolve_parent_heading(document: Document, parent_value: str) -> Heading: raise typer.BadParameter(f"--parent '{selector}' was not found") +def selected_heading_query_results(repository: OrgRepository, selector_query: str) -> list[Heading]: + """Run one selector query through the repository and validate heading results.""" + logger.info("Task selector query: %s", selector_query) + try: + results = repository.query( + [selector_query], + {"todo_states": repository.todo_states, "done_states": repository.done_states}, + ) + except QueryParseError as exc: + raise typer.BadParameter(f"Invalid task selector query: {exc}") from exc + except QueryRuntimeError as exc: + raise typer.BadParameter(f"Task selector query failed: {exc}") from exc + return selected_heading_results(results) + + def selected_heading_results(results: list[object]) -> list[Heading]: """Validate query results as headings and deduplicate by object identity.""" matches_by_identity: dict[int, Heading] = {} diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index c9c7f1b..1dac5ef 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -11,12 +11,10 @@ import org.logging from org.commands.tasks.common import ( resolve_task_selector_query, - selected_heading_results, + selected_heading_query_results, ) -from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context +from org.db.repository import OrgRepository, cli_error_from_repository_error, resolve_input_paths from org.logic.edit import edit_heading_subtree_in_external_editor -from org.query.engine.errors import QueryParseError, QueryRuntimeError -from org.query.runner import run_query logger = logging.getLogger("org") @@ -35,28 +33,14 @@ class EditArgs: def run_tasks_edit(args: EditArgs, config: org.config.app.AppConfig) -> None: """Run the tasks edit command.""" - filenames = resolve_input_paths(args.files) - selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) - documents = load_documents(filenames) - todo_states, done_states = resolve_loaded_state_context( - documents, - config.todo_states, - config.done_states, - ) - - logger.info("Task selector query: %s", selector_query) try: - results = run_query( - documents, - [selector_query], - {"todo_states": todo_states, "done_states": done_states}, - ) - except QueryParseError as exc: - raise typer.BadParameter(f"Invalid task selector query: {exc}") from exc - except QueryRuntimeError as exc: - raise typer.BadParameter(f"Task selector query failed: {exc}") from exc + filenames = resolve_input_paths(args.files) + selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) + repository = OrgRepository(filenames, config.todo_states, config.done_states) + selected_headings = selected_heading_query_results(repository, selector_query) + except Exception as err: + raise cli_error_from_repository_error(err) from err - selected_headings = selected_heading_results(results) if not selected_headings: raise typer.BadParameter("No task matches the provided selector") @@ -78,6 +62,11 @@ def run_tasks_edit(args: EditArgs, config: org.config.app.AppConfig) -> None: typer.echo("No changes.") return + try: + repository.reload_document(heading.document.filename or "", force=True) + except Exception as err: + raise cli_error_from_repository_error(err) from err + typer.echo("Edited 1 task.") diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 3e9e5cf..4a994b0 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -21,9 +21,7 @@ OutputFormatError, print_prepared_output, ) -from org.db.load import load_root_data -from org.query.engine.errors import QueryParseError, QueryRuntimeError -from org.query.runner import run_query +from org.db.repository import OrgRepository, cli_error_from_repository_error from org.tui.bits import build_console, processing_status, setup_output @@ -142,15 +140,14 @@ def run_tasks_find(args: TasksFindArgs, config: org.config.app.AppConfig) -> Non with processing_status(console, color_enabled): query_text = _build_find_query(args) - roots, todo_states, done_states = load_root_data(args) try: - results = run_query( - roots, + repository = OrgRepository.from_args(args) + results = repository.query( [query_text], - {"todo_states": todo_states, "done_states": done_states}, + {"todo_states": repository.todo_states, "done_states": repository.done_states}, ) - except (QueryParseError, QueryRuntimeError) as exc: - raise click.UsageError(str(exc)) from exc + except Exception as exc: + raise click.UsageError(str(cli_error_from_repository_error(exc))) from exc matched_nodes = [value for value in results if isinstance(value, Heading)] output_nodes = _nodes_with_context(matched_nodes, args.include_context) diff --git a/src/org/commands/tasks/list/actions.py b/src/org/commands/tasks/list/actions.py index 27d6668..9b32282 100644 --- a/src/org/commands/tasks/list/actions.py +++ b/src/org/commands/tasks/list/actions.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING import typer +from org_parser.document import Heading from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( @@ -14,10 +15,14 @@ planning_field_label, replace_heading_tags_from_csv, replace_planning_timestamp_from_raw, - save_document, todo_states_for_heading, ) -from org.db.load import load_and_process_data +from org.db.errors import RepositoryError +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) 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 @@ -29,11 +34,12 @@ shift_priority, ) from org.logic.time import advance_timestamp_by_repeater, local_now +from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: - from org_parser.document import Document, Heading + from org_parser.document import Document from org.config.app import AppConfig @@ -58,6 +64,7 @@ class TasksListSession: status_message: str search_text: str app_config: AppConfig + repository: OrgRepository TASKS_LIST_HELP_ENTRIES = [ @@ -147,22 +154,28 @@ def reload_session_nodes( ) -> bool: """Reload session nodes via standard processing pipeline.""" try: - nodes, todo_states, done_states = load_and_process_data(session.args, session.app_config) - except typer.BadParameter as err: - session.status_message = str(err) + plan = build_repository_query_plan(session.args, session.app_config, include_ordering=True) + repository = session.repository + results = repository.query(plan.stages, plan.context) + nodes = [value for value in results if isinstance(value, Heading)] + limit = session.args.max_results + if limit is not None: + nodes = nodes[session.args.offset : session.args.offset + limit] + except (RepositoryError, QueryParseError, QueryRuntimeError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return False session.all_nodes = nodes - session.todo_states = todo_states - session.done_states = done_states + session.todo_states = repository.todo_states + session.done_states = repository.done_states refresh_visible_nodes(session, preserve_identity) return True -def save_document_changes(document: Document) -> None: +def save_document_changes(session: TasksListSession, document: Document) -> None: """Persist one mutated document to disk.""" logger.info("Saving tasks list edit file: %s", document.filename) - save_document(document) + session.repository.save_document(document.filename or "") def persist_and_reload_selected( @@ -173,9 +186,9 @@ def persist_and_reload_selected( """Save selected heading document and refresh session state.""" preserve_identity = heading_locator(node) try: - save_document_changes(node.document) - except typer.BadParameter as err: - session.status_message = str(err) + save_document_changes(session, node.document) + except (RepositoryError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return if reload_session_nodes(session, preserve_identity): @@ -214,9 +227,9 @@ def archive_selected_task(session: TasksListSession) -> None: session.status_message = "" try: - archive_result = archive_heading_subtree_and_save(node, {}) - except typer.BadParameter as err: - session.status_message = str(err) + archive_result = archive_heading_subtree_and_save(node, {}, session.repository) + except (RepositoryError, typer.BadParameter) as err: + session.status_message = str(cli_error_from_repository_error(err)) return preserve_identity = heading_locator(archive_result.heading) @@ -412,6 +425,7 @@ def create_tasks_list_session( status_message="", search_text="", app_config=config, + repository=data.repository, ) ensure_selection_bounds(session) return session diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 07a8a73..6ca9226 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -9,6 +9,7 @@ import click import typer +from org_parser.document import Heading from rich.syntax import Syntax import org.config.app @@ -27,7 +28,11 @@ _prepare_output, print_prepared_output, ) -from org.db.load import load_and_process_data +from org.db.repository import ( + OrgRepository, + build_repository_query_plan, + cli_error_from_repository_error, +) from org.tui.bits import ( TaskLineConfig, build_console, @@ -43,7 +48,6 @@ if TYPE_CHECKING: - from org_parser.document import Heading from rich.console import Console @@ -111,6 +115,7 @@ class _TasksListSessionData: todo_states: list[str] done_states: list[str] color_enabled: bool + repository: OrgRepository class TasksListOutputFormatter(Protocol): @@ -374,12 +379,22 @@ def run_tasks_list(args: ListArgs, config: org.config.app.AppConfig) -> None: args.max_results = _resolve_tasks_limit(args.max_results, console.height) with processing_status(console, color_enabled): - nodes, todo_states, done_states = load_and_process_data(args, config) + try: + plan = build_repository_query_plan(args, config, include_ordering=True) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + except Exception as err: + raise cli_error_from_repository_error(err) from err + nodes = [value for value in results if isinstance(value, Heading)] + nodes = nodes[args.offset : args.offset + args.max_results] + todo_states = repository.todo_states + done_states = repository.done_states session_data = _TasksListSessionData( nodes=nodes, todo_states=todo_states, done_states=done_states, color_enabled=color_enabled, + repository=repository, ) if args.noninteractive: diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index defd302..9868243 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -31,9 +31,7 @@ _prepare_output, print_prepared_output, ) -from org.db.load import load_root_data -from org.query.engine.errors import QueryParseError, QueryRuntimeError -from org.query.runner import run_query +from org.db.repository import OrgRepository, cli_error_from_repository_error from org.tui.bits import build_console, processing_status, setup_output @@ -235,18 +233,21 @@ def run_tasks_query(args: TasksQueryArgs, config: org.config.app.AppConfig) -> N raise click.UsageError(str(exc)) from exc with processing_status(console, color_enabled): - roots, todo_states, done_states = load_root_data(args) - - context = { - "offset": args.offset, - "limit": args.max_results, - "todo_states": todo_states, - "done_states": done_states, - } try: - results = run_query(roots, [args.query], context) - except (QueryParseError, QueryRuntimeError) as exc: - raise click.UsageError(str(exc)) from exc + repository = OrgRepository.from_args(args) + todo_states = repository.todo_states + done_states = repository.done_states + results = repository.query( + [args.query], + { + "offset": args.offset, + "limit": args.max_results, + "todo_states": todo_states, + "done_states": done_states, + }, + ) + except Exception as exc: + raise click.UsageError(str(cli_error_from_repository_error(exc))) from exc first_result = results[0] if results else None if len(results) == 1 and isinstance(first_result, list | tuple | set): diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index 76f9ff4..4168d50 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -14,12 +14,9 @@ import org.logging from org.commands.tasks.common import ( resolve_task_selector_query, - save_document, - selected_heading_results, + selected_heading_query_results, ) -from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context -from org.query.engine.errors import QueryParseError, QueryRuntimeError -from org.query.runner import run_query +from org.db.repository import OrgRepository, cli_error_from_repository_error, resolve_input_paths from org.tui.color import should_use_color @@ -66,28 +63,14 @@ def _selected_delete_roots(headings: list[Heading]) -> list[Heading]: def run_tasks_remove(args: RemoveArgs, config: org.config.app.AppConfig) -> None: """Run the tasks remove command.""" - filenames = resolve_input_paths(args.files) - selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) - documents = load_documents(filenames) - todo_states, done_states = resolve_loaded_state_context( - documents, - config.todo_states, - config.done_states, - ) - - logger.info("Task selector query: %s", selector_query) try: - results = run_query( - documents, - [selector_query], - {"todo_states": todo_states, "done_states": done_states}, - ) - except QueryParseError as exc: - raise typer.BadParameter(f"Invalid task selector query: {exc}") from exc - except QueryRuntimeError as exc: - raise typer.BadParameter(f"Task selector query failed: {exc}") from exc + filenames = resolve_input_paths(args.files) + selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) + repository = OrgRepository(filenames, config.todo_states, config.done_states) + selected_headings = selected_heading_query_results(repository, selector_query) + except Exception as err: + raise cli_error_from_repository_error(err) from err - selected_headings = selected_heading_results(results) if not selected_headings: raise typer.BadParameter("No task matches the provided selector") selected_count = len(selected_headings) @@ -122,9 +105,10 @@ def run_tasks_remove(args: RemoveArgs, config: org.config.app.AppConfig) -> None document = heading.document affected_documents[id(document)] = document - for document in affected_documents.values(): - logger.info("Saving file after delete: %s", document.filename) - save_document(document) + try: + repository.save_documents(list(affected_documents.values())) + except Exception as err: + raise cli_error_from_repository_error(err) from err typer.echo(f"Deleted {selected_count} tasks.") diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 866823c..7375f24 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -17,7 +17,6 @@ import org.logging from org.commands.tasks.common import ( iter_descendants, - load_document, normalize_optional_value, normalize_selector, parse_comment_flag, @@ -27,12 +26,9 @@ parse_tags_csv, resolve_parent_heading, resolve_task_selector_query, - save_document, - selected_heading_results, + selected_heading_query_results, ) -from org.db.load import load_documents, resolve_input_paths, resolve_loaded_state_context -from org.query.engine.errors import QueryParseError, QueryRuntimeError -from org.query.runner import run_query +from org.db.repository import OrgRepository, cli_error_from_repository_error, resolve_input_paths from org.tui.color import should_use_color @@ -84,6 +80,7 @@ def _resolve_destination_document( file_value: str | None, source_document: Document, destination_cache: dict[str, Document], + repository: OrgRepository, ) -> Document: """Resolve destination document for --file or return source document.""" if file_value is None: @@ -106,7 +103,7 @@ def _resolve_destination_document( if cached is not None: return cached - loaded_document = load_document(str(path)) + loaded_document = repository.get_document(str(path)) destination_cache[cache_key] = loaded_document return loaded_document @@ -400,32 +397,9 @@ def _apply_field_updates(args: UpdateArgs, heading: Heading) -> None: heading.body = args.body -def _select_headings_for_update( - filenames: list[str], - selector_query: str, - config: org.config.app.AppConfig, -) -> list[Heading]: - """Load documents and resolve selected headings for update.""" - documents = load_documents(filenames) - todo_states, done_states = resolve_loaded_state_context( - documents, - config.todo_states, - config.done_states, - ) - - logger.info("Task selector query: %s", selector_query) - try: - results = run_query( - documents, - [selector_query], - {"todo_states": todo_states, "done_states": done_states}, - ) - except QueryParseError as exc: - raise typer.BadParameter(f"Invalid task selector query: {exc}") from exc - except QueryRuntimeError as exc: - raise typer.BadParameter(f"Task selector query failed: {exc}") from exc - - headings = selected_heading_results(results) +def _select_headings_for_update(repository: OrgRepository, selector_query: str) -> list[Heading]: + """Resolve selected headings for update.""" + headings = selected_heading_query_results(repository, selector_query) if not headings: raise typer.BadParameter("No task matches the provided selector") return headings @@ -435,8 +409,13 @@ def run_tasks_update(args: UpdateArgs, config: org.config.app.AppConfig) -> None """Run the tasks update command.""" selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) _validate_update_option_conflicts(args) - filenames = resolve_input_paths(args.files) - headings = _select_headings_for_update(filenames, selector_query, config) + try: + filenames = resolve_input_paths(args.files) + repository = OrgRepository(filenames, config.todo_states, config.done_states) + headings = _select_headings_for_update(repository, selector_query) + except Exception as err: + raise cli_error_from_repository_error(err) from err + selected_count = len(headings) logger.info("Selected %s tasks for update", selected_count) @@ -470,6 +449,7 @@ def run_tasks_update(args: UpdateArgs, config: org.config.app.AppConfig) -> None args.file, source_document, destination_cache, + repository, ) if destination_document is not source_document: @@ -487,9 +467,10 @@ def run_tasks_update(args: UpdateArgs, config: org.config.app.AppConfig) -> None if source_document is not destination_document: documents_to_save[id(source_document)] = source_document - for document in documents_to_save.values(): - logger.info("Saving updated file: %s", document.filename) - save_document(document) + try: + repository.save_documents(list(documents_to_save.values())) + except Exception as err: + raise cli_error_from_repository_error(err) from err typer.echo(f"Updated {selected_count} tasks.") diff --git a/src/org/db/__init__.py b/src/org/db/__init__.py index da9d24e..1bede54 100644 --- a/src/org/db/__init__.py +++ b/src/org/db/__init__.py @@ -1 +1 @@ -"""Data loading, query execution, and output pipeline modules.""" +"""Repository, query execution, and output pipeline modules.""" diff --git a/src/org/db/errors.py b/src/org/db/errors.py new file mode 100644 index 0000000..0990a94 --- /dev/null +++ b/src/org/db/errors.py @@ -0,0 +1,87 @@ +"""Repository-specific errors for file-backed Org documents.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class RepositoryError(Exception): + """Base repository error.""" + + +@dataclass +class DocumentNotFoundError(RepositoryError): + """Raised when a tracked Org document file is missing.""" + + path: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return f"File '{self.path}' not found" + + +@dataclass +class DocumentPermissionError(RepositoryError): + """Raised when the repository cannot access a tracked file.""" + + path: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return f"Permission denied for '{self.path}'" + + +@dataclass +class DocumentParseError(RepositoryError): + """Raised when a tracked file cannot be parsed as Org data.""" + + path: str + detail: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return f"Unable to parse '{self.path}': {self.detail}" + + +@dataclass +class DocumentMissingError(RepositoryError): + """Raised when a previously tracked file no longer exists.""" + + path: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return f"Tracked file '{self.path}' no longer exists" + + +@dataclass +class DocumentConflictError(RepositoryError): + """Raised when a dirty document was changed outside the CLI.""" + + path: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return f"File '{self.path}' was modified outside of the CLI while it has in-memory changes" + + +@dataclass +class HeadingNotFoundError(RepositoryError): + """Raised when a heading cannot be resolved by identity.""" + + detail: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return self.detail + + +@dataclass +class HeadingAmbiguousError(RepositoryError): + """Raised when a heading selector resolves to multiple headings.""" + + detail: str + + def __str__(self) -> str: + """Return the human-readable error message.""" + return self.detail diff --git a/src/org/db/load.py b/src/org/db/load.py deleted file mode 100644 index 7ad5599..0000000 --- a/src/org/db/load.py +++ /dev/null @@ -1,263 +0,0 @@ -"""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 load as load_org_document -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, QueryRuntimeError -from org.query.runner import run_query - - -if TYPE_CHECKING: - from org_parser.document import Document - - from org.config.app import AppConfig - - -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 load_documents( - filenames: list[str], -) -> list[Document]: - """Load org-mode files and return parsed documents.""" - documents: list[Document] = [] - for filename in filenames: - try: - documents.append(load_org_document(filename)) - except FileNotFoundError as err: - raise typer.BadParameter(f"File '{filename}' not found") from err - except PermissionError as err: - raise typer.BadParameter(f"Permission denied for '{filename}'") from err - except ValueError as err: - raise typer.BadParameter(f"Unable to parse '{filename}': {err}") from err - return documents - - -def resolve_loaded_state_context( - documents: list[Document], - todo_states: list[str], - done_states: list[str], -) -> tuple[list[str], list[str]]: - """Merge discovered todo and done states from loaded documents.""" - all_todo_states = list(todo_states) - all_done_states = list(done_states) - for document in documents: - all_todo_states = _merge_state_order(all_todo_states, list(document.todo_states)) - all_done_states = _merge_state_order(all_done_states, list(document.done_states)) - return 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, - config: AppConfig, -) -> 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(config, sys.argv, include_ordering) - - normalized_files = normalize_cli_files_for_custom_switches(config, 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, - ) - - include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") - stages = [".[]", *build_pipeline_stages(config, args, sys.argv, include_ordering)] - - 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 = run_query(roots, stages, context_vars) - except (QueryParseError, QueryRuntimeError) as exc: - raise typer.BadParameter(str(exc)) from exc - - heading_results = [value for value in results if isinstance(value, Heading)] - if include_slice: - sliced_args = cast("SlicedDataLoadArgs", args) - heading_results = heading_results[ - sliced_args.offset : sliced_args.offset + sliced_args.max_results - ] - return heading_results, todo_states, done_states diff --git a/src/org/db/repository.py b/src/org/db/repository.py new file mode 100644 index 0000000..a3b5c59 --- /dev/null +++ b/src/org/db/repository.py @@ -0,0 +1,505 @@ +"""Repository abstraction for one or more file-backed Org documents.""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, cast + +import typer +from org_parser import dump as dump_org_document +from org_parser import loads + +from org.config.cli import ( + QueryBuildArgs, + build_pipeline_stages, + collect_custom_context_vars, + normalize_cli_files_for_custom_switches, + validate_custom_switches, +) +from org.db.errors import ( + DocumentConflictError, + DocumentMissingError, + DocumentNotFoundError, + DocumentParseError, + DocumentPermissionError, + HeadingAmbiguousError, + HeadingNotFoundError, + RepositoryError, +) +from org.logic.validation import validate_and_parse_keys, validate_global_arguments +from org.query.engine.errors import QueryParseError, QueryRuntimeError +from org.query.runner import run_query + + +if TYPE_CHECKING: + from org_parser.document import Document, Heading + + from org.config.app import AppConfig + from org.logic.tasks import HeadingLocator + + +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 DocumentParseError(filename or "", str(err)) from err + + document.filename = "" if filename is None else filename + return document + + +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 + + +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 _normalize_org_text(contents: str) -> str: + """Normalize parser-incompatible time values before parse.""" + return contents.replace("24:00", "00:00") + + +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 _canonical_path(path: str) -> str: + """Return canonical path key for repository tracking.""" + return str(Path(path).expanduser().resolve(strict=False)) + + +@dataclass +class TrackedDocument: + """One repository-tracked file-backed Org document.""" + + path: str + document: Document + loaded_mtime: float + injected_todo_keyword: bool + + @property + def is_dirty(self) -> bool: + """Return whether the tracked document has in-memory modifications.""" + return bool(self.document.dirty) + + def stat_mtime(self) -> float: + """Return current filesystem mtime for the tracked file.""" + path = Path(self.path) + try: + return path.stat().st_mtime + except FileNotFoundError as err: + raise DocumentMissingError(self.path) from err + except PermissionError as err: + raise DocumentPermissionError(self.path) from err + + def has_external_changes(self) -> bool: + """Return whether the tracked file changed since it was loaded.""" + return self.stat_mtime() > self.loaded_mtime + + +@dataclass(frozen=True) +class RepositoryQueryPlan: + """Prepared repository query plan for command execution.""" + + files: list[str] + stages: list[str] + context: dict[str, object] + todo_states: list[str] + done_states: list[str] + + +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 RepositoryQueryArgs(QueryBuildArgs, Protocol): + """Protocol for repository-backed query pipeline arguments.""" + + 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 OrgRepository: + """Repository for file-backed Org documents with conflict-aware persistence.""" + + def __init__( + self, + files: list[str], + todo_states: list[str], + done_states: list[str], + ) -> None: + """Load and track the requested Org files.""" + self._configured_todo_states = list(todo_states) + self._configured_done_states = list(done_states) + self._documents_by_path: dict[str, TrackedDocument] = {} + for file_path in files: + tracked = self._load_tracked_document(file_path) + self._documents_by_path[_canonical_path(tracked.path)] = tracked + self._refresh_state_cache() + + @property + def documents(self) -> list[Document]: + """Return currently tracked parsed documents.""" + return [tracked.document for tracked in self._documents_by_path.values()] + + @property + def todo_states(self) -> list[str]: + """Return cached merged TODO states.""" + return list(self._todo_states) + + @property + def done_states(self) -> list[str]: + """Return cached merged DONE states.""" + return list(self._done_states) + + @classmethod + def from_args(cls, args: RootDataLoadArgs) -> OrgRepository: + """Build a repository directly from shared CLI file/state arguments.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + files = resolve_input_paths(args.files) + return cls(files, todo_states, done_states) + + def _refresh_state_cache(self) -> None: + """Refresh cached merged TODO and DONE states.""" + todo_states = list(self._configured_todo_states) + done_states = list(self._configured_done_states) + for tracked in self._documents_by_path.values(): + todo_states = _merge_state_order(todo_states, list(tracked.document.todo_states)) + done_states = _merge_state_order(done_states, list(tracked.document.done_states)) + self._todo_states = todo_states + self._done_states = done_states + + def _load_document_from_path(self, path: str) -> tuple[Document, bool]: + """Load one Org document from disk with normalized parse input.""" + file_path = Path(path) + try: + logger.info("Processing %s...", path) + contents = file_path.read_text(encoding="utf-8") + except FileNotFoundError as err: + raise DocumentNotFoundError(path) from err + except PermissionError as err: + raise DocumentPermissionError(path) from err + + injected_todo_keyword = bool(self._configured_todo_states or self._configured_done_states) + if injected_todo_keyword: + contents = _prepend_todo_config( + contents, + self._configured_todo_states, + self._configured_done_states, + ) + + try: + document = loads(_normalize_org_text(contents), filename=path) + except (TypeError, ValueError) as err: + raise DocumentParseError(path, str(err)) from err + document.filename = path + return document, injected_todo_keyword + + def _load_tracked_document(self, path: str) -> TrackedDocument: + """Load one tracked Org document from disk.""" + document, injected_todo_keyword = self._load_document_from_path(path) + try: + loaded_mtime = Path(path).expanduser().stat().st_mtime + except FileNotFoundError as err: + raise DocumentNotFoundError(path) from err + except PermissionError as err: + raise DocumentPermissionError(path) from err + return TrackedDocument( + path=path, + document=document, + loaded_mtime=loaded_mtime, + injected_todo_keyword=injected_todo_keyword, + ) + + def _strip_injected_todo_keyword(self, tracked: TrackedDocument) -> None: + """Remove repository-injected TODO keyword before writing back to disk.""" + if not tracked.injected_todo_keyword: + return + expected_value = ( + f"{' '.join(self._configured_todo_states)} | {' '.join(self._configured_done_states)}" + ) + for index, keyword in enumerate(list(tracked.document.keywords)): + if keyword.key.upper() != "TODO": + continue + if str(keyword.value).strip() != expected_value: + continue + del tracked.document.keywords[index] + return + + def _ensure_tracked_document(self, path: str) -> TrackedDocument: + """Return tracked document for path, loading it when needed.""" + canonical_path = _canonical_path(path) + tracked = self._documents_by_path.get(canonical_path) + if tracked is None: + tracked = self._load_tracked_document(path) + self._documents_by_path[canonical_path] = tracked + self._refresh_state_cache() + return tracked + + def _replace_tracked_document(self, tracked: TrackedDocument) -> None: + """Replace tracked document state and refresh caches.""" + self._documents_by_path[_canonical_path(tracked.path)] = tracked + self._refresh_state_cache() + + def refresh(self) -> None: + """Refresh tracked documents for read access. + + Clean externally modified files are automatically reloaded. Dirty externally modified + files raise an explicit conflict error. + """ + for tracked in list(self._documents_by_path.values()): + if not tracked.has_external_changes(): + continue + if tracked.is_dirty: + raise DocumentConflictError(tracked.path) + self.reload_document(tracked.path) + + def get_document(self, path: str) -> Document: + """Return one tracked document after refresh checks.""" + self.refresh() + return self._ensure_tracked_document(path).document + + def reload_document(self, path: str, *, force: bool = False) -> Document: + """Reload one tracked document from disk.""" + tracked = self._ensure_tracked_document(path) + if tracked.is_dirty and not force: + raise DocumentConflictError(tracked.path) + reloaded = self._load_tracked_document(tracked.path) + self._replace_tracked_document(reloaded) + return reloaded.document + + def reload_all(self, *, force: bool = False) -> None: + """Reload all tracked documents from disk.""" + for tracked in list(self._documents_by_path.values()): + self.reload_document(tracked.path, force=force) + + def save_document(self, path: str, *, force: bool = False) -> Document: + """Persist one tracked document back to disk and reload it.""" + tracked = self._ensure_tracked_document(path) + if tracked.has_external_changes() and not force: + raise DocumentConflictError(tracked.path) + self._strip_injected_todo_keyword(tracked) + try: + dump_org_document(tracked.document, tracked.path) + except PermissionError as err: + raise DocumentPermissionError(tracked.path) from err + return self.reload_document(tracked.path, force=True) + + def save_dirty(self, *, force: bool = False) -> list[Document]: + """Persist all dirty tracked documents and reload them.""" + saved_documents: list[Document] = [] + for tracked in list(self._documents_by_path.values()): + if not tracked.is_dirty: + continue + saved_documents.append(self.save_document(tracked.path, force=force)) + return saved_documents + + def save_documents(self, documents: list[Document], *, force: bool = False) -> list[Document]: + """Persist the provided tracked documents and reload them.""" + unique_paths = list( + dict.fromkeys( + (document.filename or "" for document in documents if document.filename), + ), + ) + return [self.save_document(path, force=force) for path in unique_paths] + + def query(self, query_stages: list[str], context: dict[str, object]) -> list[object]: + """Run a query against all tracked documents after refresh checks.""" + self.refresh() + return run_query(self.documents, query_stages, context) + + def heading_by_id(self, heading_id: str) -> Heading | None: + """Return first tracked heading with the requested ID.""" + self.refresh() + for document in self.documents: + heading = document.heading_by_id(heading_id) + if heading is not None: + return heading + return None + + def resolve_heading(self, locator: HeadingLocator) -> Heading | None: + """Resolve a previously captured heading locator.""" + self.refresh() + if locator.filename: + try: + document = self.get_document(locator.filename) + except DocumentNotFoundError: + return None + else: + return None + if locator.heading_id is not None: + resolved = document.heading_by_id(locator.heading_id) + if resolved is not None: + return resolved + return document.heading_by_title(locator.title) + + def resolve_parent_heading(self, path: str, selector: str) -> Heading: + """Resolve one parent heading by ID first, then by title within one document.""" + document = self.get_document(path) + heading = document.heading_by_id(selector) + if heading is not None: + return heading + matches = [node for node in list(document) if node.title_text.strip() == selector] + if len(matches) > 1: + raise HeadingAmbiguousError( + f"--parent is ambiguous, multiple headings with title '{selector}'", + ) + if len(matches) == 1: + return matches[0] + raise HeadingNotFoundError(f"--parent '{selector}' was not found") + + def move_heading_between_documents( + self, + heading: Heading, + destination_document: Document, + *, + parent_heading: Heading | None = None, + ) -> None: + """Move one heading subtree to another tracked document while preserving identity.""" + source_parent = heading.parent + if source_parent is None: + raise HeadingNotFoundError("Unable to move heading without a parent node") + source_parent.children.remove(heading) + if parent_heading is None: + destination_document.children.append(heading) + else: + parent_heading.children.append(heading) + heading.document = destination_document + for descendant in _iter_descendants(heading): + descendant.document = destination_document + + +def _iter_descendants(heading: Heading) -> list[Heading]: + """Return heading descendants as a flat list.""" + descendants: list[Heading] = [] + for child in heading.children: + descendants.append(child) + descendants.extend(_iter_descendants(child)) + return descendants + + +def cli_error_from_repository_error(err: Exception) -> Exception: + """Convert repository and query errors to CLI-friendly parameter errors.""" + if isinstance(err, QueryParseError | QueryRuntimeError): + return typer.BadParameter(str(err)) + if isinstance(err, RepositoryError): + return typer.BadParameter(str(err)) + return err + + +def build_repository_query_plan( + args: RepositoryQueryArgs, + config: AppConfig, + argv: list[str] | None = None, + *, + include_ordering: bool, +) -> RepositoryQueryPlan: + """Build a repository-backed query plan from CLI arguments.""" + active_argv = sys.argv if argv is None else argv + validate_custom_switches(config, active_argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(config, args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + files = resolve_input_paths(normalized_files) + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + stages = [".[]", *build_pipeline_stages(config, args, active_argv, include_ordering)] + context_vars: dict[str, object] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update( + collect_custom_context_vars(active_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) + return RepositoryQueryPlan( + files=files, + stages=stages, + context=context_vars, + todo_states=todo_states, + done_states=done_states, + ) + + +def build_root_repository(args: RootDataLoadArgs) -> OrgRepository: + """Build a repository for commands that only need direct file/state inputs.""" + return OrgRepository.from_args(args) diff --git a/src/org/logic/archive.py b/src/org/logic/archive.py index 3b7f0bf..5035f86 100644 --- a/src/org/logic/archive.py +++ b/src/org/logic/archive.py @@ -11,12 +11,14 @@ import typer from org_parser.document import Heading -from org.commands.tasks.common import load_document, resolve_parent_heading, save_document +from org.commands.tasks.common import resolve_parent_heading if TYPE_CHECKING: from org_parser.document import Document + from org.db.repository import OrgRepository + _DEFAULT_ARCHIVE_BASE = "%s_archive::" logger = logging.getLogger("org") @@ -156,6 +158,7 @@ def _resolve_destination_document( source_document: Document, destination_path: str, destination_cache: dict[str, Document], + repository: OrgRepository, ) -> Document: """Resolve archive destination document from path with cache support.""" source_filename = source_document.filename @@ -171,7 +174,7 @@ def _resolve_destination_document( if cached is not None: return cached - destination_document = load_document(destination_path) + destination_document = repository.get_document(destination_path) destination_cache[cache_key] = destination_document return destination_document @@ -237,6 +240,7 @@ def _validate_archive_parent_target(heading: Heading, parent_heading: Heading | def resolve_archive_target( heading: Heading, destination_cache: dict[str, Document], + repository: OrgRepository, ) -> ArchiveTarget: """Resolve archive destination document and optional parent heading.""" location = resolve_archive_location(heading) @@ -244,6 +248,7 @@ def resolve_archive_target( heading.document, location.file_path, destination_cache, + repository, ) parent_heading: Heading | None = None if location.parent_title is not None: @@ -276,11 +281,12 @@ def move_heading_to_archive_target(heading: Heading, target: ArchiveTarget) -> N def archive_heading_subtree( heading: Heading, destination_cache: dict[str, Document], + repository: OrgRepository, ) -> ArchiveMoveResult: """Resolve archive target and move one heading subtree into it.""" source_document = heading.document _apply_archive_metadata_properties(heading) - target = resolve_archive_target(heading, destination_cache) + target = resolve_archive_target(heading, destination_cache, repository) move_heading_to_archive_target(heading, target) return ArchiveMoveResult( @@ -294,10 +300,9 @@ def archive_heading_subtree( def archive_heading_subtree_and_save( heading: Heading, destination_cache: dict[str, Document], + repository: OrgRepository, ) -> ArchiveMoveResult: """Archive one heading subtree and persist all affected documents once.""" - archive_result = archive_heading_subtree(heading, destination_cache) - for document in archive_result_documents_to_save(archive_result): - logger.info("Saving archived file: %s", document.filename) - save_document(document) + archive_result = archive_heading_subtree(heading, destination_cache, repository) + repository.save_documents(list(archive_result_documents_to_save(archive_result))) return archive_result diff --git a/src/org/logic/edit.py b/src/org/logic/edit.py index 98da339..82bb5fb 100644 --- a/src/org/logic/edit.py +++ b/src/org/logic/edit.py @@ -10,7 +10,8 @@ import click import typer -from org.db.load import load_document_from_text +from org.db.errors import DocumentParseError +from org.db.repository import load_document_from_text if TYPE_CHECKING: @@ -90,7 +91,10 @@ def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditRes edited_text = _read_document_text(path, filename) if edited_text == original_text: return DocumentEditResult(changed=False) - load_document_from_text(edited_text, filename) + try: + load_document_from_text(edited_text, filename) + except DocumentParseError as err: + raise typer.BadParameter(f"Edited document content is invalid: {err.detail}") from err return DocumentEditResult(changed=True) raise typer.BadParameter("Editor failed to open") diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index 5e39769..f5c1f27 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -574,7 +574,7 @@ def test_cli_runner_tasks_edit_updates_subtree( ) def _fake_open_at_line(filename: str, line: int) -> int: - assert line == 0 + assert line >= 0 Path(filename).write_text( "* TODO Updated\n:PROPERTIES:\n:ID: task-1\n:END:\n** TODO New child\n* TODO Tail\n", encoding="utf-8", diff --git a/tests/cli_common/test_cli_common_helpers.py b/tests/cli_common/test_cli_common_helpers.py index f5733f0..2317aad 100644 --- a/tests/cli_common/test_cli_common_helpers.py +++ b/tests/cli_common/test_cli_common_helpers.py @@ -8,7 +8,7 @@ import pytest import typer -from org.db.load import resolve_input_paths +from org.db.repository import resolve_input_paths from org.logic.stats import dedupe_values, normalize_show_value, resolve_group_values from org.logic.validation import parse_group_values diff --git a/tests/cli_common/test_file_errors.py b/tests/cli_common/test_file_errors.py index c260421..8be9db1 100644 --- a/tests/cli_common/test_file_errors.py +++ b/tests/cli_common/test_file_errors.py @@ -181,10 +181,11 @@ 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.db.load import load_root_nodes + from org.db.repository import OrgRepository fixture_path = os.path.join(FIXTURES_DIR, "simple.org") - roots, _, _ = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) + repository = OrgRepository([fixture_path], ["TODO"], ["DONE"]) + roots = repository.documents assert len(roots) == 1 assert len(list(roots[0])) > 0 @@ -192,10 +193,13 @@ def test_load_root_nodes_valid() -> None: def test_load_root_nodes_todo_states() -> None: """Test loading todo keys from root nodes.""" - from org.db.load import load_root_nodes + from org.db.repository import OrgRepository fixture_path = os.path.join(FIXTURES_DIR, "todo_states.org") - roots, todo_states, done_states = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) + repository = OrgRepository([fixture_path], ["TODO"], ["DONE"]) + roots = repository.documents + todo_states = repository.todo_states + done_states = repository.done_states assert len(roots) == 1 assert todo_states == ["TODO", "STARTED"] diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index 166b4c1..4f4a227 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -373,7 +373,7 @@ def test_build_query_text_custom_ordering_for_stats() -> None: def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixture) -> None: """Data loading should log the query execution context.""" - from org.db.load import load_and_process_data + from org.db.repository import build_repository_query_plan fixture_path = str((Path(__file__).resolve().parents[1] / "fixtures" / "simple.org").resolve()) args = make_args(files=[fixture_path], offset=0, max_results=1) @@ -381,7 +381,11 @@ def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixtu org.logging.logger.setLevel(logging.INFO) org.logging.logger.propagate = True with caplog.at_level(logging.INFO, logger="org"): - load_and_process_data(args, AppConfig(config_path=".org-cli.yaml")) + build_repository_query_plan( + args, + AppConfig(config_path=".org-cli.yaml"), + include_ordering=True, + ) assert "Query context:" in caplog.text assert "'todo_states': ['TODO']" in caplog.text diff --git a/tests/commands/tasks/test_tasks_capture_app.py b/tests/commands/tasks/test_tasks_capture_app.py index 7a7a534..b0e8e10 100644 --- a/tests/commands/tasks/test_tasks_capture_app.py +++ b/tests/commands/tasks/test_tasks_capture_app.py @@ -31,6 +31,7 @@ def _make_plan() -> capture.CapturePlan: placeholders=["title", "owner"], values={}, unresolved_placeholders=["title", "owner"], + repository=cast("Any", object()), ) diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index 4b7cd72..5cc4517 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -288,7 +288,7 @@ def _edit_file(filename: str, _line: int) -> int: monkeypatch.setattr(editor_command, "_run_editor_at_line", _edit_file) monkeypatch.setattr( - "org.db.load.loads", + "org.db.repository.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 8aba9da..94ff889 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -7,7 +7,7 @@ import sys from contextlib import contextmanager from io import StringIO -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast import click import pytest @@ -92,6 +92,7 @@ def _make_session_data( todo_states=["TODO"], done_states=["DONE"], color_enabled=color_enabled, + repository=cast("Any", object()), ) @@ -810,10 +811,12 @@ def test_archive_selected_task_archives_selected_task( """Archiving should use the shared archive helper and refresh status.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) + session.repository = cast("Any", object()) def _fake_archive( heading: Heading, _cache: dict[str, Document], + _repository: object, ) -> ArchiveMoveResult: location = ArchiveLocation( raw_spec="%s_archive::", @@ -998,7 +1001,7 @@ def test_persist_and_reload_selected_reports_save_failures( nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - def _raise_save(_document: object) -> None: + def _raise_save(_session: object, _document: object) -> None: raise typer.BadParameter("Permission denied for 'dummy.org'") monkeypatch.setattr(actions, "save_document_changes", _raise_save) diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index 35965c6..304f5a1 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -25,6 +25,7 @@ def _make_session_data(nodes: list[Heading]) -> tasks_list._TasksListSessionData todo_states=["TODO"], done_states=["DONE"], color_enabled=False, + repository=cast("Any", object()), ) diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index 6270a8f..1cc3995 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -46,7 +46,18 @@ def _make_app( done_states=["DONE"], todo_states=["TODO"], ) - return AgendaApp(actions.create_agenda_session(args, config, nodes, render, view_ctx)) + return AgendaApp( + actions.create_agenda_session( + args, + config, + actions.AgendaSessionData( + nodes=nodes, + render=render, + view_ctx=view_ctx, + repository=cast("Any", object()), + ), + ), + ) def _body_lines(app: AgendaApp) -> list[str]: @@ -188,7 +199,7 @@ def _fake_capture(_args: object, _templates: object) -> capture_command.TasksCap ) monkeypatch.setattr(actions, "capture_task", _fake_capture) - monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_save_document_changes", lambda _session, _document: None) monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) app = _make_app(args, list(org_parser.load(fixture_path))) @@ -305,15 +316,13 @@ def test_run_agenda_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) def _fake_run( passed_args: agenda_command.AgendaArgs, _config: org.config.app.AppConfig, - nodes: list[Heading], - render: ui.RenderContext, - view_ctx: AgendaViewContext, + data: actions.AgendaSessionData, ) -> None: called["value"] = True assert passed_args.files == [fixture_path] - assert nodes - assert render.todo_states == ["TODO"] - assert view_ctx.name == "default" + assert data.nodes + assert data.render.todo_states == ["TODO"] + assert data.view_ctx.name == "default" monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index cc7d3b1..cc9179d 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -6,11 +6,13 @@ import sys from datetime import datetime, timedelta from io import StringIO -from typing import TYPE_CHECKING, cast +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast import org_parser import pytest import typer +from org_parser.document import Heading from org_parser.time import Timestamp from rich.console import Console @@ -25,19 +27,32 @@ resolve_view_context, ) from org.commands.tasks.common import duration_to_org_text, parse_clock_duration -from org.db.load import load_and_process_data +from org.db.repository import OrgRepository, build_repository_query_plan from org.logic.tasks import detail_org_block, heading_locator from org.logic.time import advance_timestamp_by_repeater, local_now from org.tui.bits import setup_output if TYPE_CHECKING: - from org_parser.document import Document, Heading + from org_parser.document import Document FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") +def _load_processed_nodes( + args: agenda_command.AgendaArgs, + config: org.config.app.AppConfig, +) -> tuple[list[Heading], list[str], list[str]]: + plan = build_repository_query_plan(args, config, include_ordering=True) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + nodes = [value for value in results if isinstance(value, Heading)] + if args.max_results is not None: + nodes = nodes[args.offset : args.offset + args.max_results] + return nodes, repository.todo_states, repository.done_states + + def _app_config( *, agenda_views: dict[str, org.config.app.AgendaViewConfig] | None = None, @@ -123,12 +138,18 @@ def _make_session( done_states=["DONE"] if done_states is None else done_states, todo_states=["TODO"] if todo_states is None else todo_states, ) + repository = cast("Any", object()) + if args.files and all(Path(path).exists() for path in args.files): + repository = OrgRepository(args.files, ["TODO"], ["DONE"]) return actions.create_agenda_session( args, _app_config(), - nodes, - render, - view_ctx, + actions.AgendaSessionData( + nodes=nodes, + render=render, + view_ctx=view_ctx, + repository=repository, + ), ) @@ -162,7 +183,7 @@ def _render_agenda_output_with_config( no_color=not color_enabled, force_terminal=color_enabled, ) - nodes, todo_states, done_states = load_and_process_data(args, config) + nodes, todo_states, done_states = _load_processed_nodes(args, config) ui.render_agenda( console, ui.AgendaRenderInput( @@ -907,7 +928,7 @@ 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(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_save_document_changes", lambda _session, _document: None) monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) monkeypatch.setattr(actions, "local_now", lambda: action_now) monkeypatch.setattr( @@ -940,7 +961,7 @@ 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(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_save_document_changes", lambda _session, _document: None) monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) monkeypatch.setattr(actions, "local_now", lambda: action_now) monkeypatch.setattr( @@ -1022,7 +1043,11 @@ def test_apply_refile_preserves_moved_task_locator( saved_documents: list[Document] = [] refreshed: dict[str, object] = {} - monkeypatch.setattr(actions, "_save_document_changes", saved_documents.append) + monkeypatch.setattr( + actions, + "_save_document_changes", + lambda _session, document: saved_documents.append(document), + ) monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) def _capture_refresh( diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index 7847129..5c3698a 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -7,17 +7,19 @@ from contextlib import contextmanager from datetime import UTC, datetime from io import StringIO -from typing import TYPE_CHECKING, cast +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, cast import pytest import typer +from org_parser.document import Heading from rich.console import Console from rich.text import Text import org.config.app from org.commands.board import actions, ui from org.commands.board import command as board_command -from org.db.load import load_and_process_data +from org.db.repository import OrgRepository, RepositoryQueryPlan, build_repository_query_plan from org.logic.archive import ArchiveLocation, ArchiveMoveResult, ArchiveTarget from org.logic.edit import DocumentEditResult from org.logic.tasks import heading_locator @@ -28,12 +30,25 @@ if TYPE_CHECKING: from collections.abc import Iterator - from org_parser.document import Document, Heading + from org_parser.document import Document FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") +def _load_processed_nodes( + args: board_command.BoardArgs, + config: org.config.app.AppConfig, +) -> tuple[list[Heading], list[str], list[str]]: + plan = build_repository_query_plan(args, config, include_ordering=True) + repository = OrgRepository(plan.files, plan.todo_states, plan.done_states) + results = repository.query(plan.stages, plan.context) + nodes = [value for value in results if isinstance(value, Heading)] + if args.max_results is not None: + nodes = nodes[args.offset : args.offset + args.max_results] + return nodes, repository.todo_states, repository.done_states + + def _board_views_configured() -> dict[str, org.config.app.BoardViewConfig]: """Build a reusable configured board view set for tests.""" return { @@ -141,6 +156,7 @@ def _make_session( todo_states=["TODO"], done_states=["DONE"], app_config=app_config, + repository=cast("Any", object()), columns=_default_columns(nodes) if resolved_columns is None else resolved_columns, color_enabled=False, selected_column_index=0, @@ -150,6 +166,8 @@ def _make_session( all_columns=[], search_text="", ) + if args.files and all(Path(path).exists() for path in args.files): + session.repository = OrgRepository(args.files, ["TODO"], ["DONE"]) 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) @@ -184,10 +202,7 @@ def _render_board_output_with_config( no_color=not color_enabled, force_terminal=color_enabled, ) - nodes, discovered_todo_states, discovered_done_states = load_and_process_data( - args, - config, - ) + nodes, discovered_todo_states, discovered_done_states = _load_processed_nodes(args, config) nodes = actions.filter_recent_completed_nodes(nodes, args.days) todo_states, done_states = actions.resolved_states( args, @@ -539,7 +554,11 @@ def test_apply_state_move_steps_state_and_reloads( def _capture_save(document: Document) -> None: saved_documents.append(document) - monkeypatch.setattr(actions, "_save_document_changes", _capture_save) + monkeypatch.setattr( + actions, + "_save_document_changes", + lambda _session, document: _capture_save(document), + ) monkeypatch.setattr( actions, "reload_session", @@ -580,11 +599,30 @@ 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( - actions, - "load_and_process_data", - lambda _args, _config: (reloaded_nodes, ["TODO"], ["DONE"]), - ) + class _FakeRepository: + todo_states: ClassVar[list[str]] = ["TODO"] + done_states: ClassVar[list[str]] = ["DONE"] + + def query(self, _stages: object, _context: object) -> list[object]: + return list(reloaded_nodes) + + session.repository = cast("Any", _FakeRepository()) + + def _fake_plan( + _args: object, + _config: object, + include_ordering: bool, + ) -> RepositoryQueryPlan: + del include_ordering + return RepositoryQueryPlan( + files=[], + stages=[], + context={}, + todo_states=["TODO"], + done_states=["DONE"], + ) + + monkeypatch.setattr(actions, "build_repository_query_plan", _fake_plan) actions.reload_session(session, preserve_identity) @@ -686,6 +724,7 @@ def test_archive_selected_task_archives_selected_heading( def _fake_archive( heading: Heading, _cache: dict[str, Document], + _repository: object, ) -> ArchiveMoveResult: location = ArchiveLocation( raw_spec="%s_archive::", @@ -706,6 +745,7 @@ def _fake_archive( monkeypatch.setattr(actions, "archive_heading_subtree_and_save", _fake_archive) monkeypatch.setattr(actions, "reload_session", lambda _session, _identity: None) + session.repository = cast("Any", object()) actions.archive_selected_task(session) assert session.status_message == "Task archived" @@ -834,10 +874,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: args = make_board_args([fixture_path], max_results=None) config = _app_config() args.max_results = board_command._resolve_tasks_limit(args.max_results) - nodes, discovered_todo_states, discovered_done_states = load_and_process_data( - args, - config, - ) + nodes, discovered_todo_states, discovered_done_states = _load_processed_nodes(args, config) nodes = actions.filter_recent_completed_nodes(nodes, args.days) todo_states, done_states = actions.resolved_states( args, @@ -1090,11 +1127,34 @@ def test_run_board_invalid_filter_or_order_by_runtime_error_has_context( ), }, ) - monkeypatch.setattr( - board_command, - "load_and_process_data", - lambda _args, _config: ([node_from_org("* TODO Task\n")[0]], ["TODO"], ["DONE"]), - ) + + class _FakeRepository: + todo_states: ClassVar[list[str]] = ["TODO"] + done_states: ClassVar[list[str]] = ["DONE"] + + def __init__(self, *_args: object, **_kwargs: object) -> None: + pass + + def query(self, _stages: object, _context: object) -> list[object]: + return [node_from_org("* TODO Task\n")[0]] + + monkeypatch.setattr(board_command, "OrgRepository", _FakeRepository) + + def _fake_command_plan( + _args: object, + _config: object, + include_ordering: bool, + ) -> RepositoryQueryPlan: + del include_ordering + return RepositoryQueryPlan( + files=[], + stages=[], + context={}, + todo_states=["TODO"], + done_states=["DONE"], + ) + + monkeypatch.setattr(board_command, "build_repository_query_plan", _fake_command_plan) monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): @@ -1182,8 +1242,37 @@ def _load_after_change( ) return ([source_node], ["TODO"], ["DONE"]) - monkeypatch.setattr(actions, "_save_document_changes", _capture_save) - monkeypatch.setattr(actions, "load_and_process_data", _load_after_change) + monkeypatch.setattr( + actions, + "_save_document_changes", + lambda _session, document: _capture_save(document), + ) + + class _FakeRepository: + todo_states: ClassVar[list[str]] = ["TODO"] + done_states: ClassVar[list[str]] = ["DONE"] + + def query(self, _stages: object, _context: object) -> list[object]: + results, _todo, _done = _load_after_change(args, _app_config()) + return cast("list[object]", results) + + session.repository = cast("Any", _FakeRepository()) + + def _fake_reload_plan( + _args: object, + _config: object, + include_ordering: bool, + ) -> RepositoryQueryPlan: + del include_ordering + return RepositoryQueryPlan( + files=[], + stages=[], + context={}, + todo_states=["TODO"], + done_states=["DONE"], + ) + + monkeypatch.setattr(actions, "build_repository_query_plan", _fake_reload_plan) actions.apply_state_move(session, direction=1) assert saved_documents == [source_node.document] diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 17db1e2..64c5d8a 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -51,6 +51,7 @@ def _make_session( todo_states=["TODO"], done_states=["DONE"], app_config=app_config, + repository=cast("Any", object()), columns=[_col("TODO", nodes)] if resolved_columns is None else resolved_columns, color_enabled=False, selected_column_index=0, @@ -279,17 +280,15 @@ def test_run_board_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) def _fake_run( passed_args: board_command.BoardArgs, - nodes: list[Heading], - todo_states: list[str], - done_states: list[str], - color_enabled: bool, + _config: org.config.app.AppConfig, + data: actions.BoardSessionData, ) -> None: called["value"] = True assert passed_args.files == [fixture_path] - assert nodes - assert todo_states - assert done_states - assert color_enabled is False + assert data.nodes + assert data.state_lists[0] + assert data.state_lists[1] + assert data.color_enabled is False monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index 673244c..22c3d1b 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -4,7 +4,7 @@ import json import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import click import pytest @@ -107,13 +107,16 @@ def test_run_query_default_org_uses_plain_formatter_for_string_results( def _fake_run_query(_inputs: object, _stages: object, _context: object) -> list[object]: return ["alpha", "beta"] + class _FakeRepository: + todo_states: ClassVar[list[str]] = ["TODO"] + done_states: ClassVar[list[str]] = ["DONE"] + + def query(self, _stages: object, _context: object) -> list[object]: + return _fake_run_query(None, None, None) + monkeypatch.setattr( - "org.commands.tasks.query.run_query", - _fake_run_query, - ) - monkeypatch.setattr( - "org.commands.tasks.query.load_root_data", - lambda _args: ([], ["TODO"], ["DONE"]), + "org.commands.tasks.query.OrgRepository.from_args", + lambda _args: _FakeRepository(), ) _run_tasks_query(args) @@ -133,13 +136,16 @@ def test_run_query_default_org_uses_json_formatter_for_mixed_results( def _fake_run_query(_inputs: object, _stages: object, _context: object) -> list[object]: return ["alpha", 1, None] + class _FakeRepository: + todo_states: ClassVar[list[str]] = ["TODO"] + done_states: ClassVar[list[str]] = ["DONE"] + + def query(self, _stages: object, _context: object) -> list[object]: + return _fake_run_query(None, None, None) + monkeypatch.setattr( - "org.commands.tasks.query.run_query", - _fake_run_query, - ) - monkeypatch.setattr( - "org.commands.tasks.query.load_root_data", - lambda _args: ([], ["TODO"], ["DONE"]), + "org.commands.tasks.query.OrgRepository.from_args", + lambda _args: _FakeRepository(), ) _run_tasks_query(args) @@ -159,13 +165,16 @@ def test_run_query_default_org_uses_json_formatter_for_none_result( def _fake_run_query(_inputs: object, _stages: object, _context: object) -> list[object]: return [None] + class _FakeRepository: + todo_states: ClassVar[list[str]] = ["TODO"] + done_states: ClassVar[list[str]] = ["DONE"] + + def query(self, _stages: object, _context: object) -> list[object]: + return _fake_run_query(None, None, None) + monkeypatch.setattr( - "org.commands.tasks.query.run_query", - _fake_run_query, - ) - monkeypatch.setattr( - "org.commands.tasks.query.load_root_data", - lambda _args: ([], ["TODO"], ["DONE"]), + "org.commands.tasks.query.OrgRepository.from_args", + lambda _args: _FakeRepository(), ) _run_tasks_query(args)