initial impl
This commit is contained in:
parent
947bb17c64
commit
af72a6dc31
1 changed files with 170 additions and 0 deletions
170
journal.py
Executable file
170
journal.py
Executable file
|
@ -0,0 +1,170 @@
|
|||
#!/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
|
||||
|
||||
FRONTMATTER_SEPARATOR = "+++"
|
||||
|
||||
JOURNAL_DIR = Path(os.path.expanduser(os.environ.get("JOURNAL_DIR", "~/.journal")))
|
||||
EDITOR = os.environ.get("EDITOR", "nano"),
|
||||
|
||||
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
|
||||
|
||||
if default is True:
|
||||
prompt = f"You entered '{answer}'. Is this correct? (Y/n): "
|
||||
elif default is False:
|
||||
prompt = f"You entered '{answer}'. Is this correct? (y/N): "
|
||||
else:
|
||||
prompt = f"You entered '{answer}'. Is this correct? (y/n): "
|
||||
|
||||
confirm = input(prompt).strip().lower()
|
||||
|
||||
if confirm in ("y", "yes"):
|
||||
return answer
|
||||
elif confirm in ("n", "no"):
|
||||
print("Let's try again.")
|
||||
continue
|
||||
elif confirm == "" and default is not None:
|
||||
if default:
|
||||
return answer
|
||||
else:
|
||||
print("Let's try again.")
|
||||
continue
|
||||
else:
|
||||
print("Please answer with 'y' or 'n'.")
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
plaintext_path: Path
|
||||
encrypted_path: Path
|
||||
|
||||
def decrypt(self, passphrase: str):
|
||||
subprocess.run([
|
||||
"gpg", "--batch", "--yes", "--passphrase", passphrase,
|
||||
"-o", str(self.plaintext_path), "-d", str(self.encrypted_path),
|
||||
], check=True)
|
||||
|
||||
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)
|
||||
|
||||
@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.encrypted_path.name)
|
||||
|
||||
def choose_entry(entries: List[Entry]) -> Optional[Entry]:
|
||||
for i, entry in enumerate(entries):
|
||||
print(f"* [{i}] {entry.encrypted_path.name}")
|
||||
|
||||
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 main():
|
||||
parser = argparse.ArgumentParser(description="Journal")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["create", "edit"],
|
||||
help="Command to run (create a new entry, or view and edit an existing one)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--journal-dir",
|
||||
type=Path,
|
||||
default=JOURNAL_DIR,
|
||||
help="Path to your journal directory (default from JOURNAL_DIR env or ~/.journal)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--editor",
|
||||
default=EDITOR,
|
||||
help="Editor to use (default $EDITOR or nano)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
passphrase = getpass.getpass("Enter passphrase: ")
|
||||
base_path = args.journal_dir
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.command == "create":
|
||||
create_entry(passphrase, base_path, args.editor)
|
||||
elif args.command == "edit":
|
||||
edit_entry(passphrase, base_path, args.editor)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
Loading…
Reference in a new issue