journal/journal.py

226 lines
No EOL
7.2 KiB
Python
Executable file

#!/usr/bin/env python3
from typing import Dict, Any, Optional, List
from datetime import datetime, date
from pathlib import Path
from dataclasses import dataclass
import subprocess
import getpass
import argparse
import os
import shlex
import shutil
import logging
import sys
import textwrap
FRONTMATTER_SEPARATOR = "+++"
NAMESPACE = "com.sofiaritz.Journal"
_DATA_HOME = Path(
os.getenv("JOURNAL_DATA_HOME",
os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
)
JOURNAL_DIR = Path(_DATA_HOME / NAMESPACE / "entries")
EDITOR = os.environ.get("EDITOR", "nano"),
logger = logging.getLogger(__name__)
def confirm(statement: str, default: Optional[bool] = None) -> Optional[bool]:
if default is True:
prompt = f"{statement} (Y/n): "
elif default is False:
prompt = f"{statement} (y/N): "
else:
prompt = f"{statement} (y/n): "
confirm = input(prompt).strip().lower()
if confirm in ("y", "yes"):
return True
elif confirm in ("n", "no"):
return False
elif confirm == "" and default is not None:
return default
else:
return None
def input_with_confirmation(question: str, default: Optional[bool] = None) -> str:
while True:
answer = input(question).strip()
if not answer:
print("Input cannot be empty.")
continue
confirmation = confirm(f"You entered '{answer}'. Is this correct?")
if confirmation == True:
return answer
elif confirmation == False:
print("Let's try again")
continue
else:
print("Please answer with 'y' or 'n'.")
continue
@dataclass
class Entry:
plaintext_path: Path
encrypted_path: Path
def title(self):
return self.encrypted_path.stem
def decrypt(self, passphrase: str):
result = subprocess.run([
"gpg", "--batch", "--yes", "--passphrase", passphrase,
"-o", str(self.plaintext_path), "-d", str(self.encrypted_path),
], capture_output=True, text=True)
combined_output = (result.stdout + result.stderr).lower()
if "decryption failed" in combined_output.lower():
logger.error("FATAL | Decryption failed: check your passphrase and try again.")
logger.debug(result.stderr.strip())
sys.exit(1)
if result.returncode != 0:
logger.error(f"FATAL | Decryption failed: exited with code {result.returncode}\n\n{result.stderr.strip()}")
sys.exit(1)
def encrypt(self, passphrase: str):
subprocess.run([
"gpg", "--symmetric", "--cipher-algo", "AES256", "--batch",
"--yes", "--passphrase", passphrase, "-o", str(self.encrypted_path),
str(self.plaintext_path),
], check=True)
self.plaintext_path.unlink()
def edit(self, passphrase: str, editor: str):
self.decrypt(passphrase)
editor_cmd = shlex.split(editor) if isinstance(editor, str) else list(editor)
subprocess.run(editor_cmd + [str(self.plaintext_path)])
self.encrypt(passphrase)
def delete(self):
self.plaintext_path.unlink(missing_ok=True)
self.encrypted_path.unlink(missing_ok=True)
@classmethod
def create(cls, base_path: Path, passphrase: str, title: str, strftime="%d-%m-%Y", frontmatter_separator=FRONTMATTER_SEPARATOR):
date = datetime.now().strftime(strftime)
ydate = datetime.now().strftime("%Y-%m-%d")
filename = f"{date} - {title}"
plaintext_path = base_path / filename
encrypted_path = plaintext_path.with_suffix(plaintext_path.suffix + ".gpg")
initial_content = f'{frontmatter_separator}\ndate = "{ydate}"\n{frontmatter_separator}\n\n# {title}\n\n'
plaintext_path.write_text(initial_content, encoding="utf-8")
entry = cls(plaintext_path=plaintext_path, encrypted_path=encrypted_path)
entry.encrypt(passphrase)
return entry
def load_entries(base_path: Path) -> List[Entry]:
entries = []
for encrypted_file in base_path.glob("*.gpg"):
plaintext_path = encrypted_file.with_suffix("")
entries.append(Entry(plaintext_path=plaintext_path, encrypted_path=encrypted_file))
return sorted(entries, key=lambda e: e.title())
def choose_entry(entries: List[Entry]) -> Optional[Entry]:
print("== Available entries:")
for i, entry in enumerate(entries):
print(f"* [{i}] {entry.title()}")
print("")
while True:
selection = input("Select an entry by number (or 'q' to cancel): ").strip().lower()
if selection == "q":
print("Cancelled")
return None
elif not selection.isdigit():
print("Please enter a valid number or press 'q' to quit.")
continue
idx = int(selection)
if 0 <= idx < len(entries):
return entries[idx]
else:
print(f"Selection out of range. Please enter between 0 and {len(entries)-1}.")
def create_entry(passphrase: str, base_path: Path, editor: str):
title = input_with_confirmation("Enter entry title: ", default=True)
entry = Entry.create(base_path, passphrase, title)
entry.edit(passphrase, editor)
def edit_entry(passphrase: str, base_path: Path, editor: str):
entries = load_entries(base_path)
if len(entries) == 0:
print("No entries found.")
return
entry = choose_entry(entries)
if not entry:
print("No entry chosen. Quitting.")
return
entry.edit(passphrase, editor)
def delete_entry(base_path: Path):
entries = load_entries(base_path)
if len(entries) == 0:
print("No entries found.")
return
entry = choose_entry(entries)
if not entry:
print("No entry chosen. Quitting.")
return
confirmation = confirm(f"Are you sure you want to delete entry '{entry.title()}'?", default=False)
if confirmation:
entry.delete()
else:
print("No changes were made. Quitting.")
return
def main():
description = "Journal, safely write down your thoughts."
epiloge = """environment variables:
- The journal data directory defaults to $JOURNAL_DATA_HOME, or if unset, $XDG_DATA_HOME,
or ~/.local/share/com.sofiaritz.Journal/entries by default.
- The editor used defaults to $EDITOR environment variable or falls back to 'nano'.
"""
parser = argparse.ArgumentParser(description=description, epilog=epiloge, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"command",
choices=["create", "edit", "delete"],
help="Command to run (create a new entry, or view and edit an existing one)",
)
args = parser.parse_args()
base_path = JOURNAL_DIR
base_path.mkdir(parents=True, exist_ok=True)
if args.command == "create":
passphrase = getpass.getpass("Enter passphrase: ")
create_entry(passphrase, base_path, EDITOR)
elif args.command == "edit":
passphrase = getpass.getpass("Enter passphrase: ")
edit_entry(passphrase, base_path, EDITOR)
elif args.command == "delete":
delete_entry(base_path)
else:
parser.print_help()
if __name__ == "__main__":
main()