diff --git a/halig/__version__.py b/halig/__version__.py index 260c070..f9aa3e1 100644 --- a/halig/__version__.py +++ b/halig/__version__.py @@ -1 +1 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/halig/commands/import_unencrypted.py b/halig/commands/import_unencrypted.py new file mode 100644 index 0000000..39da900 --- /dev/null +++ b/halig/commands/import_unencrypted.py @@ -0,0 +1,41 @@ +from collections.abc import Generator +from pathlib import Path + +from halig.commands.base import BaseCommand +from halig.encryption import Encryptor +from halig.settings import Settings + + +class ImportCommand(BaseCommand): + def __init__(self, settings: Settings, unlink: bool = False): + super().__init__(settings) + self.encryptor = Encryptor(self.settings) + self.unlink = unlink + + def get_importables(self) -> Generator[Path, None, None]: + """Get all markdown files under self.settings.notebooks_root_path + + Yields: + a list of Path objects matching any markdown files under + the notebooks root path + """ + return self.settings.notebooks_root_path.glob("**/*.md") + + def run(self): + """For every importable file, encrypt its contents inside + `filename.age`. If `self.unlink` is `True`, unlink the original + file + """ + + for file_path in self.get_importables(): + with file_path.open("rb") as f: + contents = f.read() + + encrypted_contents = self.encryptor.encrypt(contents) + encrypted_file_path = file_path.with_suffix("").with_suffix(".age") + encrypted_file_path.touch() + with encrypted_file_path.open("wb") as f: + f.write(encrypted_contents) + + if self.unlink: + file_path.unlink() diff --git a/halig/literals.py b/halig/literals.py index baa2f28..93e944a 100644 --- a/halig/literals.py +++ b/halig/literals.py @@ -3,13 +3,16 @@ COMMANDS_NOTEBOOKS_HELP = "List all notebooks and notes, tree-style" COMMANDS_EDIT_HELP = "Edit or add a note into a notebook" COMMANDS_SHOW_HELP = "Show a note's contents" COMMANDS_VERSION = "Show halig's version" +COMMANDS_IMPORT_HELP = "Encrypt existing unencrypted files" # OPTIONS OPTION_CONFIG_HELP = "Configuration file. Must be YAML and schema compatible" OPTION_LEVEL_HELP = ( "Tree max recursion level; negative numbers indicate a value of infinity" ) - +OPTION_UNLINK_HELP = """Setting this will remove the original markdown files; +only the newly encrypted .age files will be preserved. Backup your data first +""" # ARGUMENTS ARGUMENT_EDIT_NOTE_HELP = """A valid, settings-relative path. Be aware that valid can also mean implicit notes, that is, pointing to a diff --git a/halig/main.py b/halig/main.py index 476f6dc..6a9fba3 100644 --- a/halig/main.py +++ b/halig/main.py @@ -3,11 +3,13 @@ from pathlib import Path from typing import Optional from rich import print +from rich.prompt import Prompt from typer import Argument, Option, Typer from halig import literals from halig.__version__ import __version__ from halig.commands.edit import EditCommand +from halig.commands.import_unencrypted import ImportCommand from halig.commands.notebooks import NotebooksCommand from halig.commands.show import ShowCommand from halig.settings import load_from_file @@ -87,6 +89,31 @@ def show( command.run() +@app.command(name="import", help=literals.COMMANDS_IMPORT_HELP) +@capture +def import_unencrypted( + unlink: bool = Option( # noqa: B008 + False, + help=literals.OPTION_UNLINK_HELP, + ), + config: Optional[Path] = config_option, # noqa: UP007 +): + settings = load_from_file(config) + command = ImportCommand(settings=settings, unlink=unlink) + files_to_unlink = list(command.get_importables()) + if files_to_unlink: + if unlink: + should_unlink = Prompt.ask( + f"""Unlink flag set, will delete {len(files_to_unlink)} files. + Have you backed up your data?""", + choices=["y", "Y", "N", "n"], + ) + if should_unlink in ["N", "n"]: + command.unlink = False + + command.run() + + @app.command(help=literals.COMMANDS_VERSION) @capture def version(): diff --git a/halig/settings.py b/halig/settings.py index 502e6f0..6588dd0 100644 --- a/halig/settings.py +++ b/halig/settings.py @@ -81,9 +81,7 @@ class Settings(BaseSettings): @lru_cache def load_from_file(file_path: Path | None = None) -> Settings: if file_path is None: - halig_config_home = Path( - platformdirs.user_config_dir("halig", ensure_exists=True), - ) + halig_config_home = platformdirs.user_config_path("halig", ensure_exists=True) file_path = halig_config_home / "halig.yml" file_path.touch(exist_ok=True) elif not file_path.exists(): diff --git a/pdm.lock b/pdm.lock index 69ab2f5..9f90674 100644 --- a/pdm.lock +++ b/pdm.lock @@ -243,7 +243,7 @@ dependencies = [ [[package]] name = "mkdocs-material" -version = "9.1.12" +version = "9.1.13" requires_python = ">=3.7" summary = "Documentation that simply works" dependencies = [ @@ -933,9 +933,9 @@ content_hash = "sha256:e698ce4565363b2b89ae66ebc3031f378848822332fe4f2a49019dbb0 {url = "https://files.pythonhosted.org/packages/3b/3f/9531888bc92bafb1bffddca5d9240a7bae9a479d465528883b61808ef9d6/mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {url = "https://files.pythonhosted.org/packages/fb/5c/6594400290df38f99bf8d9ef249387b56f4ad962667836266f6fe7da8597/mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] -"mkdocs-material 9.1.12" = [ - {url = "https://files.pythonhosted.org/packages/03/b5/60bbe928cde37b72908bfd25eed76738141edc3ee983eb57e26a53930dbc/mkdocs_material-9.1.12.tar.gz", hash = "sha256:d4ebe9b5031ce63a265c19fb5eab4d27ea4edadb05de206372e831b2b7570fb5"}, - {url = "https://files.pythonhosted.org/packages/57/51/090775b6b334f63cb16a02d82dc76ab46fcf7798a6874d22136ffe88e5ee/mkdocs_material-9.1.12-py3-none-any.whl", hash = "sha256:68c57d95d10104179c8c3ce9a88ee9d2322a5145b3d0f1f38ff686253fb5ec98"}, +"mkdocs-material 9.1.13" = [ + {url = "https://files.pythonhosted.org/packages/10/10/7245bfb3ae9fe673cb283259a049e75d622418fbdb1cc54d4eef77cce92f/mkdocs_material-9.1.13.tar.gz", hash = "sha256:9102e7604d73e507021847601b0a8b4fe9035422788390183f464fa3b30dd508"}, + {url = "https://files.pythonhosted.org/packages/5a/a3/00aaf7d83d44d1bac139df38d0dd30e55b799d13f565b6947f5b7a9e6c0f/mkdocs_material-9.1.13-py3-none-any.whl", hash = "sha256:5705cf8cb6c47c747606bd914bb6c01993ff141295cd259475559a1f09f07d5d"}, ] "mkdocs-material-extensions 1.1.1" = [ {url = "https://files.pythonhosted.org/packages/cd/3f/e5e3c9bfbb42e4cb661f71bcec787ae6bdf4a161b8c4bb68fd7d991c436c/mkdocs_material_extensions-1.1.1.tar.gz", hash = "sha256:9c003da71e2cc2493d910237448c672e00cefc800d3d6ae93d2fc69979e3bd93"}, diff --git a/pyproject.toml b/pyproject.toml index 0b17ea5..ffe3f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ reportMissingImports = false reportMissingTypeStubs = false [tool.ruff] -extend-select = ["W", "C90", "I", "N", "UP", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "ISC", "T20", "PT", "RSE", "RET", "SIM", "PTH", "ERA", "PGH", "PL", "TRY", "RUF"] +extend-select = ["W", "C90", "I", "N", "UP", "S", "BLE", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "ISC", "T20", "PT", "RSE", "RET", "SIM", "PTH", "ERA", "PGH", "PL", "TRY", "RUF"] extend-ignore = ["S101", "ISC002"] [tool.mypy] diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index 6f36e1c..c029170 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -29,6 +29,24 @@ def notes(notebooks_path: Path): dt = dt.subtract(days=day_offset) (dailies / f"{dt.date()}.age").touch() +@pytest.fixture() +def unencrypted_notes(notebooks_path): + unencrypted_root_path = notebooks_path / "unencrypted" + unencrypted_root_path.mkdir() + for i in range(5): + note = unencrypted_root_path / f"note_{i}.md" + note.touch() + subnote_path = unencrypted_root_path / f"inner_{i}" + subnote_path.mkdir() + for j in range(2): + subnote = subnote_path / f"note_{i}_{j}.md" + subnote.touch() + with subnote.open("w") as f: + f.write(f"subnote {i} {j}") + with note.open("w") as f: + f.write(f"note {i}") + return unencrypted_root_path + @pytest.fixture def notebooks_command(settings: Settings): diff --git a/tests/commands/test_import.py b/tests/commands/test_import.py new file mode 100644 index 0000000..3316e14 --- /dev/null +++ b/tests/commands/test_import.py @@ -0,0 +1,36 @@ +import pytest +from pathlib import Path + +from halig.commands.import_unencrypted import ImportCommand +from halig.encryption import Encryptor + +@pytest.fixture() +def command(settings) -> ImportCommand: + return ImportCommand(settings) + +def test_get_importables(unencrypted_notes: Path, command: ImportCommand): + notes = list(command.get_importables()) + assert len(notes) == 15 + assert notes == list(unencrypted_notes.glob("**/*.md")) + +def test_import(unencrypted_notes: Path, command: ImportCommand, encryptor: Encryptor): + command.run() + notes = command.get_importables() + encrypted_notes = list(unencrypted_notes.glob("**/*.age")) + assert len(encrypted_notes) == len(list(notes)) == 15 + for note in encrypted_notes: + with note.open("rb") as f: + encrypted_data = f.read() + data = encryptor.decrypt(encrypted_data) + if "inner" in str(note): + assert f'sub{note.name.replace(".age", "").replace("_"," ")}' == data.decode() + else: + assert note.name.replace(".age", "").replace("_"," ") == data.decode() + +def test_import_unlink(unencrypted_notes: Path, command: ImportCommand, encryptor: Encryptor): + command.unlink = True + command.run() + notes = command.get_importables() + encrypted_notes = list(unencrypted_notes.glob("**/*.age")) + assert len(encrypted_notes) == 15 + assert len(list(unencrypted_notes.glob("**/*.md"))) == 0