226 lines
No EOL
7.2 KiB
Python
Executable file
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() |