Skip to content

Instantly share code, notes, and snippets.

@Mrfiregem
Last active June 28, 2024 05:03
Show Gist options
  • Save Mrfiregem/67ccfea9f3182745c6d6f588ee79bead to your computer and use it in GitHub Desktop.
Save Mrfiregem/67ccfea9f3182745c6d6f588ee79bead to your computer and use it in GitHub Desktop.
A one-file note taking program with colored output
#!/usr/bin/env python3
from pathlib import Path
from typing import Any
import argparse
import datetime
import json
import os
import sys
import textwrap
import uuid
Json = Any
class Colors:
RESET: str = "\x1b[0m"
BODY: str = "\x1b[32m"
DUE: str = "\x1b[33;1m"
OVERDUE: str = "\x1b[31;1m"
ID: str= "\x1b[30;1m"
POST: str = "\x1b[4m"
DIM: str = "\x1b[30m"
BOLD: str = "\x1b[1m"
def dateStr(userDate) -> str:
return datetime.date.fromisoformat(userDate).isoformat()
def getConfirmation(prompt: str) -> bool:
while True:
confirm: str = input(f"{prompt}. Continue? [y/N]: ")
letter: str = confirm.strip().lower()[0]
if letter in ("y", "n", ""):
return letter == "y"
print("Invalid answer...")
def getXdgDataHome() -> str:
return os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
def getNoteFilePath() -> str:
return os.getenv("NOTE_FILE", os.path.join(getXdgDataHome(), "user_notes.json"))
def readNoteFile() -> Json | None:
# Get path to json file
note_file = Path(getNoteFilePath())
# Ensure directory containing file exists
Path(getXdgDataHome()).mkdir(parents=True, exist_ok=True)
# If json file exists, parse into object; else return empty array
if note_file.exists():
try:
with open(getNoteFilePath(), "a+") as f:
f.seek(0)
return json.load(f)
except Exception as e:
print(e, file=sys.stderr)
return None
else:
return json.loads("[]")
def groupNotesByDueDate(data: Json) -> dict[str, list[Json]]:
result: dict[str, list[Json]] = {"NotDue": [], "Overdue": [], "Due": []}
curdate = datetime.date.today()
for note in data:
duedate: str | None = note.get("due_date", None)
if not duedate:
result["NotDue"].append(note)
elif datetime.date.fromisoformat(duedate) < curdate:
result["Overdue"].append(note)
elif datetime.date.fromisoformat(duedate) == curdate:
result["Due"].append(note)
else:
if duedate in result:
result[duedate].append(note)
else:
result[duedate] = [note]
return result
def printNote(note: Json) -> None:
nbody = textwrap.wrap(note['body'])
nmeta: str = f"{Colors.DIM}({Colors.RESET} Posted: {Colors.POST}{note['post_date']}{Colors.RESET}{Colors.DIM}; {Colors.RESET}ID: {Colors.POST}{note['id']}{Colors.RESET} {Colors.DIM})"
print(
f"\t{Colors.DIM}┌─{Colors.RESET}{nmeta}{Colors.RESET}",
end=f"\n\t{Colors.DIM}├─╼\n",
)
for line in nbody:
print(f"\t{Colors.DIM}│ {Colors.BODY}{line}{Colors.RESET}")
print(f"{Colors.DIM}\t└─╼{Colors.RESET}\n")
def printNotesList(args) -> None:
# Read notes from file into dict
groupedNotes: dict[str, list[Json]] = groupNotesByDueDate(readNoteFile())
# Print expired notes
if len(groupedNotes["Overdue"]) > 0:
print(
f"\n{Colors.BOLD}{Colors.DIM}{'*'*18:^21}{Colors.OVERDUE}{'OVERDUE NOTES':^21}{Colors.DIM}{'*'*18:^21}{Colors.RESET}\n"
)
for n in sorted(groupedNotes["Overdue"], key=lambda item: item['post_date']):
printNote(n)
# Exit if overdue-only flag is passed
if args.overdue:
return
# Print notes due today
if len(groupedNotes["Due"]) > 0:
print(
f"\n{Colors.BOLD}{Colors.DIM}{'*'*18:^21}{Colors.DUE}{'NOTES DUE TODAY':^21}{Colors.DIM}{'*'*18:^21}{Colors.RESET}\n"
)
for n in sorted(groupedNotes["Due"], key=lambda item: item['post_date']):
printNote(n)
# Print notes with a due date
for date, notes in sorted(groupedNotes.items()):
if date not in ["Due", "Overdue", "NotDue"] and len(notes) > 0:
print(
f"\n{Colors.BOLD}{Colors.DIM}{'*'*18:^21}{Colors.RESET}{Colors.BOLD}{f"NOTES DUE {date}":^21}{Colors.DIM}{'*'*18:^21}{Colors.RESET}\n"
)
for n in notes:
printNote(n)
# Print notes without a due date
if len(groupedNotes["NotDue"]) > 0:
print(
f"\n{Colors.BOLD}{Colors.DIM}{'*'*18:^21}{Colors.RESET}{Colors.BOLD}{'OTHER NOTES':^21}{Colors.DIM}{'*'*18:^21}{Colors.RESET}\n"
)
for n in sorted(groupedNotes["NotDue"], key=lambda item: item['post_date']):
printNote(n)
def addNote(args) -> None:
# Initialize temp dictionary to store note data
userNote: dict[str, str] = {
"id": f"{uuid.uuid4()}",
"post_date": datetime.date.today().isoformat(),
"body": args.msg,
}
if args.due:
userNote["due_date"] = args.due
# Get note list as Json object
noteList: Json = readNoteFile()
if noteList is None:
sys.exit(1)
# Add new note to json array
noteList.append(userNote)
# Write new list back to file
with open(getNoteFilePath(), "w") as f:
json.dump(noteList, f)
def removeNote(args) -> None:
# Read in note array
noteList: Json = readNoteFile()
# Find and delete the matching ID
for i in range(len(noteList)):
if noteList[i]["id"] == args.id:
noteList.pop(i)
break
# Save back the file
with open(getNoteFilePath(), "w") as f:
json.dump(noteList, f)
def removeAll(args) -> None:
if args.confirm or getConfirmation("Removing all notes"):
with open(getNoteFilePath(), "w") as f:
json.dump([], f)
def main() -> None:
# Build command-line argument parser
parser = argparse.ArgumentParser(description="A simple note-taking program")
subcmds = parser.add_subparsers(title="subcommands", required=True)
parser.add_argument(
"-n",
"--nocolor",
help="remove color from output",
action="store_false",
dest="color",
)
# Add sub-command to view notes file
parser_list = subcmds.add_parser(
"ls",
help="List notes",
aliases=["list", "show"],
description="show every note you've created",
)
parser_list.add_argument(
"-o",
"--overdue",
action="store_true",
help="only show notes whose due date expired",
)
parser_list.set_defaults(func=printNotesList)
# Add sub-command to add a new note to the notes file
parser_add = subcmds.add_parser(
"add",
help="create a new note",
aliases=["new"],
description="create a new note with an optional due date",
)
parser_add.add_argument("msg", help="the body of the new note")
parser_add.add_argument(
"-d",
"--due",
type=dateStr,
required=False,
default=None,
help="group notes by due date (YYYY-MM-DD)",
)
parser_add.set_defaults(func=addNote)
# Add a sub-command to remove a note by ID or interactively
parser_del = subcmds.add_parser(
"rm",
help="remove note by id",
aliases=["remove", "del"],
description="remove a specific note",
)
parser_del.add_argument("id", type=str, help="the unique ID to remove")
parser_del.set_defaults(func=removeNote)
# Add command to remove all notes
parser_clear = subcmds.add_parser(
"clear",
help="remove all notes",
aliases=["purge"],
description="remove every note you've created",
)
parser_clear.add_argument(
"-c", "--confirm", action="store_true", help="bypass confirmation"
)
parser_clear.set_defaults(func=removeAll)
# Parse user arguments into object
args = parser.parse_args()
# Disable color output if stdout is not the terminal
if os.fstat(0) != os.fstat(1):
args.color = False
# Remove color output if user desires
if not args.color:
Colors.RESET = ""
Colors.BODY = ""
Colors.DUE = ""
Colors.ID = ""
Colors.POST = ""
Colors.OVERDUE = ""
Colors.DIM = ""
Colors.BOLD = ""
# Run associated function of subcommand
args.func(args)
if __name__ == "__main__":
main()
@Mrfiregem
Copy link
Author

Output of note -h

usage: note.py [-h] [-n] {ls,list,show,add,new,rm,remove,del,clear,purge} ...

A simple note-taking program

options:
  -h, --help            show this help message and exit
  -n, --nocolor         remove color from output

subcommands:
  {ls,list,show,add,new,rm,remove,del,clear,purge}
    ls (list, show)     List notes
    add (new)           create a new note
    rm (remove, del)    remove note by id
    clear (purge)       remove all notes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment