Compare commits

...

3 Commits

4 changed files with 1054 additions and 121 deletions

BIN
RTF-Auditor LFS Executable file

Binary file not shown.

BIN
RTF-Workday-Logger LFS Executable file

Binary file not shown.

423
RTF-audit.py Normal file
View File

@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
RTF-AUDIT // HASHBROWNS VALIDATOR
Validates a hashbrowns.yaml audit trail produced by RTF-workday-logger.
Stdlib only (tkinter, hmac, hashlib). No dependencies.
Run with: python RTF-audit.py
"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import hashlib
import hmac as _hmac
import os
import sys
# ─── COLOUR PALETTE (matches logger) ─────────────────────────────────────────
BG_DARK = "#0a0a0f"
BG_PANEL = "#12121a"
BG_ELEV = "#1a1a25"
NEON_PINK = "#ff00ff"
NEON_CYAN = "#00ffff"
NEON_LIME = "#39ff14"
NEON_YELL = "#ffff00"
NEON_RED = "#ff0033"
NEON_ORNG = "#ff8800"
NEON_PURP = "#bf00ff"
TEXT_DIM = "#888899"
TEXT_MAIN = "#e0e0e0"
FONT_MONO = ("Courier", 11)
FONT_MONO_SM = ("Courier", 9)
FONT_BIG = ("Courier", 13, "bold")
# ─── PARSER ───────────────────────────────────────────────────────────────────
def parse_hashbrowns(path: str) -> tuple[dict, list[dict]]:
"""
Parse hashbrowns.yaml into (header_dict, entries_list).
Expected format:
rtf_hashbrowns:
created: "..."
auditor: "..."
auditor_org: "..."
key_half_a: "..." # optional
file_hmac: "..." # optional
entries:
- operator: "..."
org: "..."
created: "..."
hash: "..."
"""
header: dict = {}
entries: list[dict] = []
current_entry = None
section = None # 'header' | 'entries'
with open(path, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.rstrip("\n")
stripped = line.strip()
if not stripped:
continue
if line.startswith("rtf_hashbrowns:"):
section = "header"
continue
if line.startswith("entries:"):
if current_entry is not None:
entries.append(current_entry)
current_entry = None
section = "entries"
continue
if section == "header":
if ": " in stripped:
k, _, v = stripped.partition(": ")
header[k.strip()] = v.strip().strip('"')
elif section == "entries":
if stripped.startswith("- "):
if current_entry is not None:
entries.append(current_entry)
current_entry = {}
rest = stripped[2:]
if ": " in rest:
k, _, v = rest.partition(": ")
current_entry[k.strip()] = v.strip().strip('"')
elif stripped and ": " in stripped and current_entry is not None:
k, _, v = stripped.partition(": ")
current_entry[k.strip()] = v.strip().strip('"')
if current_entry is not None:
entries.append(current_entry)
return header, entries
def verify_file_hmac(full_key: str, auditor: str,
auditor_org: str, created: str, stored: str) -> bool:
msg = f"{auditor}{auditor_org}{created}".encode()
computed = _hmac.new(full_key.encode(), msg, hashlib.sha256).hexdigest()[:32]
return _hmac.compare_digest(computed, stored)
# ─── KEY B DIALOG ─────────────────────────────────────────────────────────────
class KeyBDialog(tk.Toplevel):
def __init__(self, parent):
super().__init__(parent)
self.withdraw() # hide while building
if parent.winfo_viewable():
self.transient(parent)
self.title("> ENTER AUDITOR KEY B <")
self.configure(bg=BG_PANEL)
self.resizable(False, False)
self.config(highlightbackground=NEON_YELL, highlightthickness=3)
self.result = ""
self.minsize(460, 210)
frame = tk.Frame(self, bg=BG_PANEL, padx=22, pady=18)
frame.pack(fill="both", expand=True)
tk.Label(frame, text="> ENTER AUDITOR KEY B <",
font=("Courier", 13, "bold"), fg=NEON_YELL, bg=BG_PANEL)\
.pack(pady=(0, 8))
tk.Label(frame,
text="Enter the auditor's key half (Key B)\n"
"to verify this file's HMAC signature.",
font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL, justify="center")\
.pack(pady=(0, 10))
self.var_key = tk.StringVar()
tk.Entry(frame, textvariable=self.var_key,
font=("Courier", 13),
bg=BG_ELEV, fg=NEON_YELL, insertbackground=NEON_YELL,
relief="flat", highlightbackground=NEON_YELL,
highlightthickness=1)\
.pack(fill="x", pady=(0, 14))
btn_row = tk.Frame(frame, bg=BG_PANEL)
btn_row.pack(fill="x")
btn_row.columnconfigure(0, weight=1)
btn_row.columnconfigure(1, weight=1)
tk.Button(btn_row, text="> VERIFY <", command=self._verify,
font=("Courier", 12, "bold"), fg=NEON_YELL, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_YELL, highlightthickness=2,
pady=6, cursor="hand2")\
.grid(row=0, column=0, padx=(0, 4), sticky="ew")
tk.Button(btn_row, text="> SKIP <", command=self.destroy,
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
pady=6, cursor="hand2")\
.grid(row=0, column=1, padx=(4, 0), sticky="ew")
self.bind("<Return>", lambda e: self._verify())
self.bind("<Escape>", lambda e: self.destroy())
self._center(parent)
self.deiconify() # reveal now content is fully built
self.focus_set()
self.wait_visibility() # wait for WM to map the window
self.grab_set() # only now safe to grab
def _center(self, parent):
self.update_idletasks()
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = parent.winfo_x() + (parent.winfo_width() - w) // 2
y = parent.winfo_y() + (parent.winfo_height() - h) // 2
self.geometry(f"+{x}+{y}")
def _verify(self):
self.result = self.var_key.get().strip()
self.destroy()
# ─── MAIN AUDIT APP ───────────────────────────────────────────────────────────
class RTFAudit(tk.Tk):
def __init__(self):
super().__init__()
self.title("RTF-AUDIT // HASHBROWNS VALIDATOR")
self.configure(bg=BG_DARK)
self.minsize(760, 580)
self.hb_path = None
self.header: dict = {}
self.entries: list[dict] = []
self._build_ui()
# Auto-load if a hashbrowns.yaml sits next to this script
auto_path = os.path.join(
os.path.dirname(os.path.abspath(sys.argv[0])), "hashbrowns.yaml")
if os.path.isfile(auto_path):
self._load_from_path(auto_path)
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
root = tk.Frame(self, bg=BG_DARK, padx=16, pady=16)
root.grid(row=0, column=0, sticky="nsew")
root.columnconfigure(0, weight=1)
root.rowconfigure(3, weight=1)
# Header
hdr = tk.Frame(root, bg=BG_PANEL, padx=12, pady=10,
highlightbackground=NEON_PURP, highlightthickness=2)
hdr.grid(row=0, column=0, sticky="ew", pady=(0, 10))
tk.Label(hdr, text="RTF-AUDIT", font=("Courier", 22, "bold"),
fg=NEON_PURP, bg=BG_PANEL).pack()
tk.Label(hdr, text="HASHBROWNS TRAIL VALIDATOR // REMEMBER TO FORGET",
font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL).pack()
# Load bar
load_frame = tk.Frame(root, bg=BG_DARK)
load_frame.grid(row=1, column=0, sticky="ew", pady=(0, 8))
load_frame.columnconfigure(1, weight=1)
tk.Button(load_frame, text="> LOAD HASHBROWNS.YAML <",
command=self._pick_file,
font=("Courier", 12, "bold"), fg=NEON_PURP, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_PURP, highlightthickness=2,
padx=10, pady=8, cursor="hand2")\
.grid(row=0, column=0, padx=(0, 10))
self.lbl_path = tk.Label(load_frame, text="No file loaded",
font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_DARK)
self.lbl_path.grid(row=0, column=1, sticky="w")
# Info panel
info_outer = tk.Frame(root, bg=BG_PANEL,
highlightbackground=NEON_PURP, highlightthickness=1)
info_outer.grid(row=2, column=0, sticky="ew", pady=(0, 8))
info_outer.columnconfigure(1, weight=1)
self._info = {}
rows = [
("created", "CREATED", NEON_CYAN),
("auditor", "AUDITOR", NEON_PURP),
("auditor_org", "AUDITOR ORG", NEON_PURP),
("key_status", "KEY STATUS", NEON_YELL),
("hmac_status", "FILE HMAC", NEON_YELL),
]
for i, (key, label, color) in enumerate(rows):
tk.Label(info_outer, text=f" {label}:", font=FONT_MONO_SM,
fg=color, bg=BG_PANEL, width=16, anchor="w")\
.grid(row=i, column=0, sticky="w", padx=(8, 4), pady=3)
lbl = tk.Label(info_outer, text="", font=FONT_MONO_SM,
fg=TEXT_DIM, bg=BG_PANEL, anchor="w")
lbl.grid(row=i, column=1, sticky="w", pady=3, padx=(0, 8))
self._info[key] = lbl
# Entries area
entries_frame = tk.Frame(root, bg=BG_PANEL,
highlightbackground=NEON_CYAN, highlightthickness=2)
entries_frame.grid(row=3, column=0, sticky="nsew")
entries_frame.columnconfigure(0, weight=1)
entries_frame.rowconfigure(1, weight=1)
entries_hdr = tk.Frame(entries_frame, bg=BG_PANEL)
entries_hdr.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 2))
tk.Label(entries_hdr, text="// AUDIT ENTRIES", font=FONT_BIG,
fg=NEON_CYAN, bg=BG_PANEL).pack(side="left")
self.lbl_count = tk.Label(entries_hdr, text="", font=FONT_MONO_SM,
fg=TEXT_DIM, bg=BG_PANEL)
self.lbl_count.pack(side="right")
self.txt = tk.Text(entries_frame, font=FONT_MONO,
bg=BG_ELEV, fg=TEXT_MAIN,
relief="flat", padx=10, pady=10,
state="disabled", wrap="none",
highlightthickness=0)
self.txt.grid(row=1, column=0, sticky="nsew", padx=2, pady=2)
sy = tk.Scrollbar(entries_frame, command=self.txt.yview,
bg=BG_DARK, troughcolor=BG_ELEV)
sy.grid(row=1, column=1, sticky="ns")
sx = tk.Scrollbar(entries_frame, orient="horizontal",
command=self.txt.xview,
bg=BG_DARK, troughcolor=BG_ELEV)
sx.grid(row=2, column=0, sticky="ew")
self.txt.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
# Text colour tags
self.txt.tag_config("ok", foreground=NEON_LIME)
self.txt.tag_config("err", foreground=NEON_RED)
self.txt.tag_config("dim", foreground=TEXT_DIM)
self.txt.tag_config("hdr", foreground=NEON_CYAN)
self.txt.tag_config("key", foreground=NEON_YELL)
# ── FILE LOADING ──────────────────────────────────────────────────────────
def _pick_file(self):
path = filedialog.askopenfilename(
title="Select hashbrowns.yaml",
filetypes=[("YAML files", "*.yaml"), ("All files", "*.*")],
initialfile="hashbrowns.yaml"
)
if path:
self._load_from_path(path)
def _load_from_path(self, path: str):
try:
header, entries = parse_hashbrowns(path)
except Exception as exc:
messagebox.showerror("Parse Error",
f"Could not parse file:\n{exc}", parent=self)
return
self.hb_path = path
self.header = header
self.entries = entries
self.lbl_path.config(text=os.path.basename(path), fg=NEON_LIME)
# Populate info panel
self._info["created"].config(
text=header.get("created", "") or "", fg=TEXT_MAIN)
self._info["auditor"].config(
text=header.get("auditor", "") or "", fg=TEXT_MAIN)
self._info["auditor_org"].config(
text=header.get("auditor_org", "") or "", fg=TEXT_MAIN)
has_key = bool(header.get("key_half_a", "").strip())
if has_key:
self._info["key_status"].config(
text="Split key present — enter Key B to verify", fg=NEON_YELL)
self._info["hmac_status"].config(text="Pending Key B …", fg=TEXT_DIM)
self._prompt_key_b()
else:
self._info["key_status"].config(
text="No split key (file created without auditor)", fg=TEXT_DIM)
self._info["hmac_status"].config(text="N/A", fg=TEXT_DIM)
self._display_entries(verified=None)
# ── KEY B & VERIFICATION ──────────────────────────────────────────────────
def _prompt_key_b(self):
dlg = KeyBDialog(self)
self.wait_window(dlg)
if not dlg.result:
self._info["hmac_status"].config(text="Skipped by user", fg=TEXT_DIM)
self._display_entries(verified=None)
return
key_half_a = self.header.get("key_half_a", "")
stored_hmac = self.header.get("file_hmac", "")
auditor = self.header.get("auditor", "")
auditor_org = self.header.get("auditor_org", "")
created = self.header.get("created", "")
full_key = key_half_a + dlg.result
ok = verify_file_hmac(full_key, auditor, auditor_org, created, stored_hmac)
if ok:
self._info["hmac_status"].config(
text="✓ VERIFIED — file is authentic", fg=NEON_LIME)
else:
self._info["hmac_status"].config(
text="✗ FAILED — key incorrect or file tampered", fg=NEON_RED)
self._display_entries(verified=ok)
# ── ENTRY DISPLAY ─────────────────────────────────────────────────────────
def _display_entries(self, verified: bool | None):
self.txt.config(state="normal")
self.txt.delete("1.0", "end")
if not self.entries:
self.txt.insert("end", " No entries found in this file.\n", "dim")
self.lbl_count.config(text="0 entries", fg=TEXT_DIM)
self.txt.config(state="disabled")
return
count = len(self.entries)
self.lbl_count.config(text=f"{count} entr{'y' if count == 1 else 'ies'}",
fg=NEON_CYAN)
# Column header
col_hdr = (f" {'#':<4} {'OPERATOR':<22} "
f"{'ORG':<22} {'CREATED':<22} HASH\n")
self.txt.insert("end", col_hdr, "hdr")
self.txt.insert("end", " " + "" * 90 + "\n", "dim")
entry_tag = "ok" if verified is True else \
"err" if verified is False else "dim"
for i, entry in enumerate(self.entries, 1):
op = entry.get("operator", "?")
org = entry.get("org", "?")
created = entry.get("created", "?")
h = entry.get("hash", "?")
line = f" {i:<4} {op:<22} {org:<22} {created:<22} {h}\n"
self.txt.insert("end", line, entry_tag)
# Summary footer
self.txt.insert("end", "\n")
if verified is True:
self.txt.insert("end",
" ✓ File HMAC verified — this audit trail is authentic.\n"
" Key A (in file) + Key B (auditor) → HMAC matches.\n",
"ok")
elif verified is False:
self.txt.insert("end",
" ✗ HMAC mismatch.\n"
" Either Key B is incorrect, or the file has been tampered with.\n",
"err")
else:
self.txt.insert("end",
" — No HMAC verification performed.\n"
" Entries listed for reference only.\n",
"dim")
self.txt.config(state="disabled")
# ─── ENTRY POINT ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
app = RTFAudit()
app.mainloop()

View File

@@ -2,12 +2,17 @@
""" """
REMEMBER TO FORGET // WORKDAY LOGGER REMEMBER TO FORGET // WORKDAY LOGGER
Standalone Python app — stdlib only (tkinter). Standalone Python app — stdlib only (tkinter).
Run with: python workday_logger.py Run with: python RTF-workday-logger.py
""" """
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog from tkinter import ttk, messagebox, filedialog
from datetime import datetime from datetime import datetime
import os
import sys
import hashlib
import hmac as _hmac
import secrets
# ─── COLOUR PALETTE ────────────────────────────────────────────────────────── # ─── COLOUR PALETTE ──────────────────────────────────────────────────────────
@@ -45,6 +50,10 @@ def fmt_time(dt: datetime) -> str:
return dt.strftime("%H:%M") return dt.strftime("%H:%M")
def fmt_ts(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S")
def today_key() -> str: def today_key() -> str:
return datetime.now().strftime("%Y-%m-%d") return datetime.now().strftime("%Y-%m-%d")
@@ -54,21 +63,47 @@ def calc_hours(start: datetime, end: datetime) -> float:
return round(diff * 2) / 2 return round(diff * 2) / 2
def script_dir() -> str:
"""Directory of the running script; PyInstaller-aware."""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(sys.argv[0]))
def compute_file_hmac(full_key: str, auditor: str,
auditor_org: str, created: str) -> str:
"""HMAC-SHA256 over header identity fields, keyed with the full split key."""
msg = f"{auditor}{auditor_org}{created}".encode()
return _hmac.new(full_key.encode(), msg, hashlib.sha256).hexdigest()[:32]
# ─── MAIN APP ───────────────────────────────────────────────────────────────── # ─── MAIN APP ─────────────────────────────────────────────────────────────────
class WorkdayLogger(tk.Tk): class WorkdayLogger(tk.Tk):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.title("REMEMBER TO FORGET // WORKLOG") self.title("REMEMBER TO FORGET // WORKLOG")
self.configure(bg=BG_DARK) self.configure(bg=BG_DARK)
self.minsize(750, 620) self.minsize(750, 680)
# State # Core state
self.current_task = None # dict or None self.open_ts = datetime.now()
self.days: list[dict] = [] # [{ key, date, lines:[] }] self.current_task = None
self.days: list[dict] = []
self.active_day_idx = 0 self.active_day_idx = 0
self.custom_cats: list[str] = [] self.custom_cats: list[str] = []
# Journal state: {"text": str, "timestamp": datetime|None, "locked": bool}
self.journal_morning = {"text": "", "timestamp": None, "locked": False}
self.journal_afternoon = {"text": "", "timestamp": None, "locked": False}
# Hashbrowns consent: None = not yet asked, True = yes, False = no
self.hashbrowns_consent: bool | None = None
# Track whether the log has been downloaded this session
self.exported = False
self._build_ui() self._build_ui()
self.protocol("WM_DELETE_WINDOW", self._on_close)
self._ensure_today() self._ensure_today()
self._refresh_log() self._refresh_log()
@@ -97,7 +132,7 @@ class WorkdayLogger(tk.Tk):
id_frame.columnconfigure(3, weight=1) id_frame.columnconfigure(3, weight=1)
tk.Label(id_frame, text="OPERATOR:", font=FONT_MONO_SM, tk.Label(id_frame, text="OPERATOR:", font=FONT_MONO_SM,
fg=NEON_CYAN, bg=BG_DARK).grid(row=0, column=0, sticky="w", padx=(0,6)) fg=NEON_CYAN, bg=BG_DARK).grid(row=0, column=0, sticky="w", padx=(0, 6))
self.var_name = tk.StringVar() self.var_name = tk.StringVar()
tk.Entry(id_frame, textvariable=self.var_name, font=FONT_MONO, tk.Entry(id_frame, textvariable=self.var_name, font=FONT_MONO,
bg=BG_ELEV, fg="white", insertbackground="white", bg=BG_ELEV, fg="white", insertbackground="white",
@@ -105,26 +140,26 @@ class WorkdayLogger(tk.Tk):
highlightthickness=1).grid(row=0, column=1, sticky="ew") highlightthickness=1).grid(row=0, column=1, sticky="ew")
tk.Label(id_frame, text=" ORG:", font=FONT_MONO_SM, tk.Label(id_frame, text=" ORG:", font=FONT_MONO_SM,
fg=NEON_CYAN, bg=BG_DARK).grid(row=0, column=2, sticky="w", padx=(10,6)) fg=NEON_CYAN, bg=BG_DARK).grid(row=0, column=2, sticky="w", padx=(10, 6))
self.var_org = tk.StringVar() self.var_org = tk.StringVar()
tk.Entry(id_frame, textvariable=self.var_org, font=FONT_MONO, tk.Entry(id_frame, textvariable=self.var_org, font=FONT_MONO,
bg=BG_ELEV, fg="white", insertbackground="white", bg=BG_ELEV, fg="white", insertbackground="white",
relief="flat", highlightbackground=NEON_CYAN, relief="flat", highlightbackground=NEON_CYAN,
highlightthickness=1).grid(row=0, column=3, sticky="ew") highlightthickness=1).grid(row=0, column=3, sticky="ew")
# BUTTONS # ACTION BUTTONS
btn_frame = tk.Frame(root_frame, bg=BG_DARK) btn_frame = tk.Frame(root_frame, bg=BG_DARK)
btn_frame.grid(row=2, column=0, sticky="ew", pady=(0, 8)) btn_frame.grid(row=2, column=0, sticky="ew", pady=(0, 8))
for col in range(4): for col in range(4):
btn_frame.columnconfigure(col, weight=1) btn_frame.columnconfigure(col, weight=1)
def styled_btn(parent, text, cmd, fg, **kwargs): def styled_btn(parent, text, cmd, fg, **kwargs):
b = tk.Button(parent, text=text, command=cmd, font=("Courier", 12, "bold"), return tk.Button(parent, text=text, command=cmd,
font=("Courier", 12, "bold"),
fg=fg, bg=BG_PANEL, activeforeground="white", fg=fg, bg=BG_PANEL, activeforeground="white",
activebackground=BG_ELEV, relief="flat", activebackground=BG_ELEV, relief="flat",
highlightbackground=fg, highlightthickness=2, highlightbackground=fg, highlightthickness=2,
padx=10, pady=8, cursor="hand2", **kwargs) padx=10, pady=8, cursor="hand2", **kwargs)
return b
styled_btn(btn_frame, "> START TASK <", self._on_start, NEON_LIME)\ styled_btn(btn_frame, "> START TASK <", self._on_start, NEON_LIME)\
.grid(row=0, column=0, padx=4, sticky="ew") .grid(row=0, column=0, padx=4, sticky="ew")
@@ -151,32 +186,49 @@ class WorkdayLogger(tk.Tk):
fg=NEON_CYAN, bg=BG_PANEL, padx=8) fg=NEON_CYAN, bg=BG_PANEL, padx=8)
self.lbl_current.grid(row=0, column=2, sticky="e") self.lbl_current.grid(row=0, column=2, sticky="e")
# OUTPUT # ── NOTEBOOK ──────────────────────────────────────────────────────────
out_frame = tk.Frame(root_frame, bg=BG_PANEL, nb_style = ttk.Style()
highlightbackground=NEON_PINK, highlightthickness=2) nb_style.theme_use("default")
out_frame.grid(row=4, column=0, sticky="nsew", pady=(0, 8)) nb_style.configure("RTF.TNotebook",
out_frame.columnconfigure(0, weight=1) background=BG_DARK, borderwidth=0,
out_frame.rowconfigure(1, weight=1) tabmargins=[0, 0, 0, 0])
nb_style.configure("RTF.TNotebook.Tab",
background=BG_PANEL, foreground=TEXT_DIM,
font=("Courier", 11, "bold"), padding=[14, 6],
borderwidth=0)
nb_style.map("RTF.TNotebook.Tab",
background=[("selected", BG_ELEV)],
foreground=[("selected", NEON_PINK)])
notebook = ttk.Notebook(root_frame, style="RTF.TNotebook")
notebook.grid(row=4, column=0, sticky="nsew", pady=(0, 8))
root_frame.rowconfigure(4, weight=1) root_frame.rowconfigure(4, weight=1)
out_hdr = tk.Frame(out_frame, bg=BG_PANEL) # ── TAB 1: WORK LOG ───────────────────────────────────────────────────
out_hdr.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 2)) log_tab = tk.Frame(notebook, bg=BG_PANEL,
tk.Label(out_hdr, text="// WORK LOG OUTPUT", font=FONT_BIG, highlightbackground=NEON_PINK, highlightthickness=2)
log_tab.columnconfigure(0, weight=1)
log_tab.rowconfigure(1, weight=1)
notebook.add(log_tab, text=" // WORK LOG ")
log_hdr = tk.Frame(log_tab, bg=BG_PANEL)
log_hdr.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 2))
tk.Label(log_hdr, text="// WORK LOG OUTPUT", font=FONT_BIG,
fg=NEON_PINK, bg=BG_PANEL).pack(side="left") fg=NEON_PINK, bg=BG_PANEL).pack(side="left")
btn_area = tk.Frame(out_hdr, bg=BG_PANEL) btn_area = tk.Frame(log_hdr, bg=BG_PANEL)
btn_area.pack(side="right") btn_area.pack(side="right")
def small_btn(text, cmd, fg): def small_btn(parent, text, cmd, fg):
return tk.Button(btn_area, text=text, command=cmd, font=FONT_MONO_SM, return tk.Button(parent, text=text, command=cmd, font=FONT_MONO_SM,
fg=fg, bg=BG_PANEL, relief="flat", fg=fg, bg=BG_PANEL, relief="flat",
highlightbackground=fg, highlightthickness=1, highlightbackground=fg, highlightthickness=1,
padx=6, pady=3, cursor="hand2") padx=6, pady=3, cursor="hand2")
small_btn("Copy", self._on_copy, NEON_CYAN).pack(side="left", padx=3) small_btn(btn_area, "Copy .md", self._on_copy, NEON_CYAN).pack(side="left", padx=3)
small_btn("Export .md", self._on_export, NEON_LIME).pack(side="left", padx=3) small_btn(btn_area, "Download .md", self._on_export, NEON_LIME).pack(side="left", padx=3)
self.txt_output = tk.Text(out_frame, font=FONT_MONO, self.txt_output = tk.Text(log_tab, font=FONT_MONO,
bg=BG_ELEV, fg=TEXT_MAIN, bg=BG_ELEV, fg=TEXT_MAIN,
insertbackground=NEON_CYAN, insertbackground=NEON_CYAN,
relief="flat", padx=10, pady=10, relief="flat", padx=10, pady=10,
@@ -184,22 +236,92 @@ class WorkdayLogger(tk.Tk):
highlightthickness=0) highlightthickness=0)
self.txt_output.grid(row=1, column=0, sticky="nsew", padx=2, pady=2) self.txt_output.grid(row=1, column=0, sticky="nsew", padx=2, pady=2)
scroll_y = tk.Scrollbar(out_frame, command=self.txt_output.yview, scroll_y = tk.Scrollbar(log_tab, command=self.txt_output.yview,
bg=BG_DARK, troughcolor=BG_ELEV) bg=BG_DARK, troughcolor=BG_ELEV)
scroll_y.grid(row=1, column=1, sticky="ns") scroll_y.grid(row=1, column=1, sticky="ns")
scroll_x = tk.Scrollbar(out_frame, orient="horizontal", scroll_x = tk.Scrollbar(log_tab, orient="horizontal",
command=self.txt_output.xview, command=self.txt_output.xview,
bg=BG_DARK, troughcolor=BG_ELEV) bg=BG_DARK, troughcolor=BG_ELEV)
scroll_x.grid(row=2, column=0, sticky="ew") scroll_x.grid(row=2, column=0, sticky="ew")
self.txt_output.configure(yscrollcommand=scroll_y.set, self.txt_output.configure(yscrollcommand=scroll_y.set,
xscrollcommand=scroll_x.set) xscrollcommand=scroll_x.set)
# ── TAB 2: JOURNAL ────────────────────────────────────────────────────
jnl_tab = tk.Frame(notebook, bg=BG_PANEL,
highlightbackground=NEON_PURP, highlightthickness=2)
jnl_tab.columnconfigure(0, weight=1)
jnl_tab.rowconfigure(1, weight=1)
jnl_tab.rowconfigure(4, weight=1)
notebook.add(jnl_tab, text=" // JOURNAL ")
self._build_journal_section(jnl_tab, text_row=1, label="◈ MORNING JOURNAL",
color=NEON_CYAN, key="morning", top_row=0)
tk.Frame(jnl_tab, bg=NEON_PURP, height=1).grid(
row=3, column=0, columnspan=2, sticky="ew", padx=10, pady=2)
self._build_journal_section(jnl_tab, text_row=4, label="◈ AFTERNOON JOURNAL",
color=NEON_ORNG, key="afternoon", top_row=3)
# FOOTER # FOOTER
tk.Label(root_frame, tk.Label(root_frame,
text="⚠ DATA IS LOST WHEN APP CLOSES — Export before quitting ⚠", text="⚠ DATA IS LOST WHEN APP CLOSES — Export before quitting ⚠",
font=FONT_MONO_SM, fg=NEON_YELL, bg=BG_DARK)\ font=FONT_MONO_SM, fg=NEON_YELL, bg=BG_DARK)\
.grid(row=5, column=0, pady=(4, 0)) .grid(row=5, column=0, pady=(4, 0))
def _build_journal_section(self, parent, text_row: int, label: str,
color: str, key: str, top_row: int):
hdr = tk.Frame(parent, bg=BG_PANEL)
hdr.grid(row=top_row, column=0, columnspan=2, sticky="ew", padx=8, pady=(8, 2))
hdr.columnconfigure(0, weight=1)
tk.Label(hdr, text=label, font=FONT_BIG, fg=color, bg=BG_PANEL)\
.grid(row=0, column=0, sticky="w")
ts_lbl = tk.Label(hdr, text="", font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL)
ts_lbl.grid(row=0, column=1, padx=(10, 6), sticky="e")
complete_btn = tk.Button(hdr, text="[ COMPLETE ]",
font=FONT_MONO_SM, fg=color, bg=BG_PANEL,
relief="flat", highlightbackground=color,
highlightthickness=1, padx=8, pady=3, cursor="hand2")
complete_btn.grid(row=0, column=2, sticky="e")
txt = tk.Text(parent, font=FONT_MONO,
bg=BG_ELEV, fg=TEXT_MAIN, insertbackground=color,
relief="flat", padx=8, pady=8, wrap="word",
highlightthickness=1, highlightbackground=color, height=6)
txt.grid(row=text_row, column=0, sticky="nsew", padx=(8, 0), pady=(0, 6))
sb = tk.Scrollbar(parent, command=txt.yview, bg=BG_DARK, troughcolor=BG_ELEV)
sb.grid(row=text_row, column=1, sticky="ns", padx=(0, 4), pady=(0, 6))
txt.configure(yscrollcommand=sb.set)
def on_complete(t=txt, ts=ts_lbl, k=key, btn=complete_btn, c=color):
state = getattr(self, f"journal_{k}")
if state["locked"]:
return
content = t.get("1.0", "end-1c").strip()
if not content:
messagebox.showwarning("Empty",
f"Nothing to lock in the {k} journal.",
parent=self)
return
now = datetime.now()
state["text"] = content
state["timestamp"] = now
state["locked"] = True
t.config(state="disabled", highlightbackground=TEXT_DIM, fg=TEXT_DIM)
ts.config(text=f"✓ locked {fmt_ts(now)}", fg=c)
btn.config(text="[ LOCKED ]", fg=TEXT_DIM,
highlightbackground=TEXT_DIM, cursor="arrow",
activeforeground=TEXT_DIM)
complete_btn.config(command=on_complete)
setattr(self, f"_jnl_{key}_txt", txt)
setattr(self, f"_jnl_{key}_ts", ts_lbl)
setattr(self, f"_jnl_{key}_btn", complete_btn)
setattr(self, f"_jnl_{key}_clr", color)
# ── HELPERS ──────────────────────────────────────────────────────────────── # ── HELPERS ────────────────────────────────────────────────────────────────
def _ensure_today(self): def _ensure_today(self):
key = today_key() key = today_key()
@@ -207,7 +329,12 @@ class WorkdayLogger(tk.Tk):
if d["key"] == key: if d["key"] == key:
self.active_day_idx = i self.active_day_idx = i
return False return False
self.days.append({"key": key, "date": fmt_date(datetime.now()), "lines": []}) self.days.append({
"key": key,
"date": fmt_date(datetime.now()),
"lines": [],
"journals": {"morning": None, "afternoon": None},
})
self.active_day_idx = len(self.days) - 1 self.active_day_idx = len(self.days) - 1
return True return True
@@ -238,10 +365,33 @@ class WorkdayLogger(tk.Tk):
lines.append("\n| BEGIN | TASK/ACTIVITY | END | HRS |\n") lines.append("\n| BEGIN | TASK/ACTIVITY | END | HRS |\n")
lines.append("| :-- | :-- | :-- | :-: |\n") lines.append("| :-- | :-- | :-- | :-: |\n")
for ln in day["lines"]: for ln in day["lines"]:
lines.append(f"| {ln['start']} | {ln['desc']} | {ln['end']} | {ln['hours']} |\n") lines.append(
f"| {ln['start']} | {ln['desc']} | {ln['end']} | {ln['hours']} |\n")
if idx == self.active_day_idx and self.current_task: if idx == self.active_day_idx and self.current_task:
ct = self.current_task ct = self.current_task
lines.append(f"| {ct['start_time']} | {ct['category']} - {ct['desc']} | ... | ... |\n") lines.append(
f"| {ct['start_time']} | {ct['category']} - {ct['desc']} | ... | ... |\n")
# ── Journal entries for this day ──────────────────────────────
# Past days: read from stored snapshot; active day: read live state.
if idx == self.active_day_idx:
src = {
"morning": self.journal_morning,
"afternoon": self.journal_afternoon,
}
else:
src = day.get("journals", {"morning": None, "afternoon": None})
for jkey, jlabel in (("morning", "MORNING JOURNAL"),
("afternoon", "AFTERNOON JOURNAL")):
j = src.get(jkey) if src else None
if j and j.get("locked"):
ts_str = fmt_ts(j["timestamp"]) if j.get("timestamp") else "?"
lines.append(f"\n{jlabel}{ts_str}\n")
lines.append(j["text"] + "\n")
elif idx < self.active_day_idx:
# Past day with no locked journal entry
lines.append(f"\n{jlabel} — NOT THIS ONE, BUD :P\n")
content = "".join(lines) content = "".join(lines)
self.txt_output.config(state="normal") self.txt_output.config(state="normal")
@@ -252,6 +402,138 @@ class WorkdayLogger(tk.Tk):
def _get_log_text(self) -> str: def _get_log_text(self) -> str:
return self.txt_output.get("1.0", "end-1c") return self.txt_output.get("1.0", "end-1c")
# ── HASH & FRONTMATTER ────────────────────────────────────────────────────
def _compute_hash(self, name: str, org: str, export_ts: str) -> str:
seed = f"{name}{org}{fmt_ts(self.open_ts)}{export_ts}"
return hashlib.sha256(seed.encode()).hexdigest()[:16]
def _build_frontmatter(self, export_ts: str, hash_val: str) -> str:
name = self.var_name.get().strip() or "Unknown"
org = self.var_org.get().strip() or "Unknown"
return "\n".join([
"---",
f'app_opened: "{fmt_ts(self.open_ts)}"',
f'exported_at: "{export_ts}"',
f'operator: "{name}"',
f'org: "{org}"',
f'validation_hash: "{hash_val}"',
"---",
"",
])
# ── HASHBROWNS FILE ───────────────────────────────────────────────────────
def _init_hashbrowns_file(self, path: str, auditor: str, auditor_org: str,
created_ts: str, key_half_a: str, file_hmac: str):
"""Write the header block of a new hashbrowns.yaml."""
lines = [
"rtf_hashbrowns:\n",
f' created: "{created_ts}"\n',
f' auditor: "{auditor}"\n',
f' auditor_org: "{auditor_org}"\n',
]
if key_half_a:
lines.append(f' key_half_a: "{key_half_a}"\n')
lines.append(f' file_hmac: "{file_hmac}"\n')
lines.append("entries:\n")
with open(path, "w", encoding="utf-8") as f:
f.writelines(lines)
def _append_hashbrowns_entry(self, path: str, name: str,
org: str, export_ts: str, h: str):
entry = (
f' - operator: "{name}"\n'
f' org: "{org}"\n'
f' created: "{export_ts}"\n'
f' hash: "{h}"\n'
)
with open(path, "a", encoding="utf-8") as f:
f.write(entry)
def _handle_hashbrowns(self, name: str, org: str, export_ts: str) -> str:
"""Manage hashbrowns.yaml. Returns hash string or 'skipped'."""
hb_path = os.path.join(script_dir(), "hashbrowns.yaml")
file_exists = os.path.isfile(hb_path)
if self.hashbrowns_consent is False:
return "skipped"
# ── File absent and not yet asked ──────────────────────────────────
if not file_exists and self.hashbrowns_consent is None:
answer = messagebox.askyesno(
"Hashbrowns // Audit Trail",
"No hashbrowns.yaml found next to this script.\n\n"
"Create one? It keeps a sequential audit trail:\n"
" operator · org · timestamp · validation hash\n\n"
"Choosing No skips hashing for this entire session.",
parent=self
)
self.hashbrowns_consent = answer
if not answer:
return "skipped"
# ── Audit setup ────────────────────────────────────────────────
setup = HashbrownsSetupDialog(self)
self.wait_window(setup)
auditor = setup.result_auditor
auditor_org = setup.result_org
created_ts = fmt_ts(datetime.now())
key_half_a = ""
file_hmac = ""
if auditor or auditor_org:
full_key = secrets.token_hex(32) # 64 hex chars
key_half_a = full_key[:32]
key_half_b = full_key[32:]
file_hmac = compute_file_hmac(
full_key, auditor, auditor_org, created_ts)
# Show Key B — modal, requires explicit acknowledgment
kd = KeyDisplayDialog(self, key_half_b)
self.wait_window(kd)
self._init_hashbrowns_file(
hb_path, auditor, auditor_org, created_ts, key_half_a, file_hmac)
# ── Compute entry hash and append ──────────────────────────────────
h = self._compute_hash(name, org, export_ts)
try:
self._append_hashbrowns_entry(hb_path, name, org, export_ts, h)
except OSError as exc:
messagebox.showwarning("Hashbrowns Error",
f"Could not write to hashbrowns.yaml:\n{exc}",
parent=self)
return h
def _build_full_md(self) -> str:
"""YAML frontmatter + work log + all days' journal entries."""
name = self.var_name.get().strip() or "Unknown"
org = self.var_org.get().strip() or "Unknown"
export_ts = fmt_ts(datetime.now())
# Snapshot live journals into the active day before exporting
self._save_journals_to_day()
hash_val = self._handle_hashbrowns(name, org, export_ts)
fm = self._build_frontmatter(export_ts, hash_val)
body = self._get_log_text().strip()
# Collect locked journal entries from every day
journal_lines = []
for day in self.days:
for jkey, jlabel in (("morning", "Morning Journal"),
("afternoon", "Afternoon Journal")):
j = (day.get("journals") or {}).get(jkey)
if j and j.get("locked"):
ts_str = fmt_ts(j["timestamp"]) if j.get("timestamp") else "?"
journal_lines.append(f"\n## {day['date']}{jlabel}\n")
journal_lines.append(f"*Completed: {ts_str}*\n\n")
journal_lines.append(j["text"] + "\n")
if journal_lines:
body += "\n\n---\n" + "".join(journal_lines)
return fm + "\n" + body
# ── ACTIONS ─────────────────────────────────────────────────────────────── # ── ACTIONS ───────────────────────────────────────────────────────────────
def _on_start(self): def _on_start(self):
if not self.var_name.get().strip() or not self.var_org.get().strip(): if not self.var_name.get().strip() or not self.var_org.get().strip():
@@ -273,21 +555,54 @@ class WorkdayLogger(tk.Tk):
if not self.var_name.get().strip() or not self.var_org.get().strip(): if not self.var_name.get().strip() or not self.var_org.get().strip():
messagebox.showwarning("Missing info", "Please enter Name and Organization.") messagebox.showwarning("Missing info", "Please enter Name and Organization.")
return return
key = today_key() # Snapshot the current journals into the outgoing day before clearing
self.days.append({"key": key, "date": fmt_date(datetime.now()), "lines": []}) self._save_journals_to_day()
self.days.append({
"key": today_key(),
"date": fmt_date(datetime.now()),
"lines": [],
"journals": {"morning": None, "afternoon": None},
})
self.active_day_idx = len(self.days) - 1 self.active_day_idx = len(self.days) - 1
self.current_task = None self.current_task = None
self._set_status(False) self._set_status(False)
self._refresh_log() self._refresh_log()
self._reset_journals()
def _save_journals_to_day(self):
"""Copy live journal state into the active day's dict."""
day = self._active_day()
if "journals" not in day:
day["journals"] = {"morning": None, "afternoon": None}
for key in ("morning", "afternoon"):
state = getattr(self, f"journal_{key}")
day["journals"][key] = dict(state) # shallow copy is enough
def _reset_journals(self):
for key in ("morning", "afternoon"):
state = getattr(self, f"journal_{key}")
state["text"] = ""
state["timestamp"] = None
state["locked"] = False
txt = getattr(self, f"_jnl_{key}_txt")
ts = getattr(self, f"_jnl_{key}_ts")
btn = getattr(self, f"_jnl_{key}_btn")
clr = getattr(self, f"_jnl_{key}_clr")
txt.config(state="normal", highlightbackground=clr,
fg=TEXT_MAIN, insertbackground=clr)
txt.delete("1.0", "end")
ts.config(text="")
btn.config(text="[ COMPLETE ]", fg=clr,
highlightbackground=clr, cursor="hand2")
def _on_copy(self): def _on_copy(self):
text = self._get_log_text() text = self._build_full_md()
self.clipboard_clear() self.clipboard_clear()
self.clipboard_append(text) self.clipboard_append(text)
messagebox.showinfo("Copied", "Worklog copied to clipboard.") messagebox.showinfo("Copied", "Worklog (with YAML frontmatter) copied to clipboard.")
def _on_export(self): def _on_export(self):
text = self._get_log_text().strip() text = self._build_full_md().strip()
if not text: if not text:
messagebox.showwarning("Empty", "Nothing to export yet.") messagebox.showwarning("Empty", "Nothing to export yet.")
return return
@@ -295,26 +610,48 @@ class WorkdayLogger(tk.Tk):
default_name = f"worklog_{name}_{today_key()}.md" default_name = f"worklog_{name}_{today_key()}.md"
path = filedialog.asksaveasfilename( path = filedialog.asksaveasfilename(
defaultextension=".md", defaultextension=".md",
filetypes=[("Markdown files", "*.md"), ("Text files", "*.txt"), ("All files", "*.*")], filetypes=[("Markdown files", "*.md"),
("Text files", "*.txt"),
("All files", "*.*")],
initialfile=default_name, initialfile=default_name,
title="Export Worklog" title="Download Worklog"
) )
if path: if path:
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write(text) f.write(text)
self.exported = True
messagebox.showinfo("Exported", f"Saved to:\n{path}") messagebox.showinfo("Exported", f"Saved to:\n{path}")
def _on_close(self):
"""WM_DELETE_WINDOW handler — warn if there's unsaved log content."""
has_content = any(day["lines"] for day in self.days)
if has_content and not self.exported:
answer = messagebox.askyesno(
"Close without downloading?",
"You have logged tasks that haven't been downloaded.\n\n"
"All data will be lost when the app closes.\n\n"
"Close anyway?",
icon="warning",
parent=self
)
if not answer:
return
self.destroy()
# ─── DIALOGS ──────────────────────────────────────────────────────────────────
# ─── BASE DIALOG ──────────────────────────────────────────────────────────────
class BaseDialog(tk.Toplevel): class BaseDialog(tk.Toplevel):
def __init__(self, app: WorkdayLogger, title: str, border_color: str = NEON_PINK): def __init__(self, app: WorkdayLogger, title: str, border_color: str = NEON_PINK):
super().__init__(app) super().__init__(app)
self.app = app self.app = app
# Associate with parent so the dialog stays in front and isn't a
# separate taskbar entry; only when the parent is actually visible.
if app.winfo_viewable():
self.transient(app)
self.title(title) self.title(title)
self.configure(bg=BG_PANEL) self.configure(bg=BG_PANEL)
self.resizable(False, False) self.resizable(False, False)
self.grab_set() self.grab_set()
# Border effect via highlight
self.config(highlightbackground=border_color, highlightthickness=3) self.config(highlightbackground=border_color, highlightthickness=3)
self.bind("<Escape>", lambda e: self.destroy()) self.bind("<Escape>", lambda e: self.destroy())
self._center() self._center()
@@ -327,6 +664,175 @@ class BaseDialog(tk.Toplevel):
self.geometry(f"+{x}+{y}") self.geometry(f"+{x}+{y}")
# ─── HASHBROWNS SETUP DIALOG ──────────────────────────────────────────────────
class HashbrownsSetupDialog(BaseDialog):
"""Collect optional auditor info when creating a new hashbrowns.yaml."""
def __init__(self, app: WorkdayLogger):
self.result_auditor = ""
self.result_org = ""
super().__init__(app, "> HASHBROWNS // AUDIT SETUP <", border_color=NEON_PURP)
self.minsize(460, 310)
self.protocol("WM_DELETE_WINDOW", self._skip)
frame = tk.Frame(self, bg=BG_PANEL, padx=22, pady=18)
frame.pack(fill="both", expand=True)
frame.columnconfigure(1, weight=1)
tk.Label(frame, text="> HASHBROWNS // AUDIT SETUP <",
font=("Courier", 13, "bold"), fg=NEON_PURP, bg=BG_PANEL)\
.grid(row=0, column=0, columnspan=2, pady=(0, 8))
tk.Label(frame,
text="Optionally assign an auditor to this trail.\n"
"If either field is filled, a split verification\n"
"key will be generated for independent audit.\n"
"Leave both blank to create the file without a key.",
font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL, justify="left")\
.grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 14))
for row, (label, attr) in enumerate(
(("AUDITOR NAME:", "var_auditor"), ("AUDITOR ORG:", "var_aud_org")),
start=2
):
tk.Label(frame, text=label, font=FONT_MONO_SM, fg=NEON_PURP,
bg=BG_PANEL, anchor="w", width=16)\
.grid(row=row, column=0, sticky="w", pady=5)
var = tk.StringVar()
setattr(self, attr, var)
tk.Entry(frame, textvariable=var, font=FONT_MONO,
bg=BG_ELEV, fg="white", insertbackground="white",
relief="flat", highlightbackground=NEON_PURP,
highlightthickness=1)\
.grid(row=row, column=1, sticky="ew", pady=5)
tk.Frame(frame, bg=NEON_PURP, height=1)\
.grid(row=4, column=0, columnspan=2, sticky="ew", pady=12)
btn_row = tk.Frame(frame, bg=BG_PANEL)
btn_row.grid(row=5, column=0, columnspan=2, sticky="ew")
btn_row.columnconfigure(0, weight=1)
btn_row.columnconfigure(1, weight=1)
tk.Button(btn_row, text="> CONFIRM <", command=self._confirm,
font=("Courier", 12, "bold"), fg=NEON_PURP, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_PURP, highlightthickness=2,
pady=6, cursor="hand2")\
.grid(row=0, column=0, padx=(0, 4), sticky="ew")
tk.Button(btn_row, text="> SKIP <", command=self._skip,
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
pady=6, cursor="hand2")\
.grid(row=0, column=1, padx=(4, 0), sticky="ew")
self.bind("<Return>", lambda e: self._confirm())
self._center()
def _confirm(self):
self.result_auditor = self.var_auditor.get().strip()
self.result_org = self.var_aud_org.get().strip()
self.destroy()
def _skip(self):
self.result_auditor = ""
self.result_org = ""
self.destroy()
# ─── KEY DISPLAY DIALOG ───────────────────────────────────────────────────────
class KeyDisplayDialog(tk.Toplevel):
"""
Shows Key Half B. Cannot be dismissed with Escape or the window manager —
requires the explicit acknowledgment button.
Follows the tkinter.simpledialog pattern:
withdraw → build → deiconify → wait_visibility → grab_set
so the window is fully mapped before the grab is set.
"""
def __init__(self, app: WorkdayLogger, key_half_b: str):
super().__init__(app)
self.app = app
# ── Hide immediately while we build, then show in one clean pass ──
self.withdraw()
if app.winfo_viewable():
self.transient(app)
self.title("> AUDITOR KEY B — STORE THIS NOW <")
self.configure(bg=BG_PANEL)
self.resizable(False, False)
self.config(highlightbackground=NEON_YELL, highlightthickness=3)
self.protocol("WM_DELETE_WINDOW", lambda: None) # no X-close
self.bind("<Escape>", lambda e: None) # no Escape
self.minsize(520, 350)
frame = tk.Frame(self, bg=BG_PANEL, padx=22, pady=18)
frame.pack(fill="both", expand=True)
tk.Label(frame, text="⚠ AUDITOR KEY B ⚠",
font=("Courier", 15, "bold"), fg=NEON_YELL, bg=BG_PANEL)\
.pack(pady=(0, 8))
tk.Label(frame,
text="This key will NEVER be shown again.\n"
"Copy it and store it separately from hashbrowns.yaml.\n"
"Both halves are required to validate the audit trail.",
font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL, justify="center")\
.pack(pady=(0, 12))
# Selectable key box
key_box = tk.Frame(frame, bg=BG_ELEV,
highlightbackground=NEON_YELL, highlightthickness=2)
key_box.pack(fill="x", pady=(0, 8))
key_txt = tk.Text(key_box, font=("Courier", 14, "bold"),
bg=BG_ELEV, fg=NEON_YELL,
height=1, relief="flat", padx=12, pady=12,
wrap="none", cursor="xterm")
key_txt.insert("1.0", key_half_b)
key_txt.pack(fill="x")
def copy_key():
app.clipboard_clear()
app.clipboard_append(key_half_b)
copy_btn.config(text="[ COPIED ✓ ]", fg=NEON_LIME,
highlightbackground=NEON_LIME)
copy_btn = tk.Button(frame, text="[ COPY KEY B ]",
command=copy_key, font=FONT_MONO_SM,
fg=NEON_YELL, bg=BG_PANEL, relief="flat",
highlightbackground=NEON_YELL, highlightthickness=1,
padx=8, pady=4, cursor="hand2")
copy_btn.pack(pady=(0, 12))
tk.Frame(frame, bg=NEON_YELL, height=1).pack(fill="x", pady=(0, 12))
tk.Button(frame, text="> I HAVE STORED KEY B — CONTINUE <",
command=self.destroy,
font=("Courier", 11, "bold"), fg=NEON_LIME, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_LIME, highlightthickness=2,
pady=8, cursor="hand2")\
.pack(fill="x")
# Position window, then reveal and grab in the correct order.
# grab_set() must come AFTER wait_visibility() or the grab may be set
# before the window manager has mapped the window, leaving a blank shell.
self._center()
self.deiconify() # reveal now that content is fully built
self.focus_set()
self.wait_visibility() # block until WM confirms window is on screen
self.grab_set() # only now is it safe to capture all input
def _center(self):
self.update_idletasks()
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = self.app.winfo_x() + (self.app.winfo_width() - w) // 2
y = self.app.winfo_y() + (self.app.winfo_height() - h) // 2
self.geometry(f"+{x}+{y}")
# ─── TASK DIALOGS ─────────────────────────────────────────────────────────────
class StartTaskDialog(BaseDialog): class StartTaskDialog(BaseDialog):
def __init__(self, app: WorkdayLogger): def __init__(self, app: WorkdayLogger):
super().__init__(app, "> START NEW TASK <") super().__init__(app, "> START NEW TASK <")
@@ -338,9 +844,8 @@ class StartTaskDialog(BaseDialog):
tk.Label(frame, text="> START NEW TASK <", font=("Courier", 14, "bold"), tk.Label(frame, text="> START NEW TASK <", font=("Courier", 14, "bold"),
fg=NEON_PINK, bg=BG_PANEL).pack(pady=(0, 12)) fg=NEON_PINK, bg=BG_PANEL).pack(pady=(0, 12))
# Category tk.Label(frame, text="CATEGORY:", font=FONT_MONO_SM,
tk.Label(frame, text="CATEGORY:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL, fg=NEON_CYAN, bg=BG_PANEL, anchor="w").pack(fill="x")
anchor="w").pack(fill="x")
self.var_cat = tk.StringVar() self.var_cat = tk.StringVar()
cats = app._all_cats() cats = app._all_cats()
cat_cb = ttk.Combobox(frame, textvariable=self.var_cat, values=cats, cat_cb = ttk.Combobox(frame, textvariable=self.var_cat, values=cats,
@@ -348,16 +853,14 @@ class StartTaskDialog(BaseDialog):
cat_cb.pack(fill="x", pady=(2, 10)) cat_cb.pack(fill="x", pady=(2, 10))
self._style_combo(cat_cb) self._style_combo(cat_cb)
# Description tk.Label(frame, text="TASK DESCRIPTION:", font=FONT_MONO_SM,
tk.Label(frame, text="TASK DESCRIPTION:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL, fg=NEON_CYAN, bg=BG_PANEL, anchor="w").pack(fill="x")
anchor="w").pack(fill="x")
self.var_desc = tk.StringVar() self.var_desc = tk.StringVar()
tk.Entry(frame, textvariable=self.var_desc, font=FONT_MONO, tk.Entry(frame, textvariable=self.var_desc, font=FONT_MONO,
bg=BG_ELEV, fg="white", insertbackground="white", bg=BG_ELEV, fg="white", insertbackground="white",
relief="flat", highlightbackground=NEON_CYAN, relief="flat", highlightbackground=NEON_CYAN,
highlightthickness=1).pack(fill="x", pady=(2, 14)) highlightthickness=1).pack(fill="x", pady=(2, 14))
# Buttons
btn_row = tk.Frame(frame, bg=BG_PANEL) btn_row = tk.Frame(frame, bg=BG_PANEL)
btn_row.pack(fill="x") btn_row.pack(fill="x")
btn_row.columnconfigure(0, weight=1) btn_row.columnconfigure(0, weight=1)
@@ -366,11 +869,11 @@ class StartTaskDialog(BaseDialog):
tk.Button(btn_row, text="> COMMIT <", command=self._commit, tk.Button(btn_row, text="> COMMIT <", command=self._commit,
font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL, font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_CYAN, highlightthickness=2, relief="flat", highlightbackground=NEON_CYAN, highlightthickness=2,
pady=6, cursor="hand2").grid(row=0, column=0, padx=(0,4), sticky="ew") pady=6, cursor="hand2").grid(row=0, column=0, padx=(0, 4), sticky="ew")
tk.Button(btn_row, text="> CANCEL <", command=self.destroy, tk.Button(btn_row, text="> CANCEL <", command=self.destroy,
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL, font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2, relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
pady=6, cursor="hand2").grid(row=0, column=1, padx=(4,0), sticky="ew") pady=6, cursor="hand2").grid(row=0, column=1, padx=(4, 0), sticky="ew")
cat_cb.focus_set() cat_cb.focus_set()
self._center() self._center()
@@ -386,8 +889,12 @@ class StartTaskDialog(BaseDialog):
def _commit(self): def _commit(self):
cat = self.var_cat.get().strip() cat = self.var_cat.get().strip()
desc = self.var_desc.get().strip() desc = self.var_desc.get().strip()
if not cat: messagebox.showwarning("Missing", "Please select a category.", parent=self); return if not cat:
if not desc: messagebox.showwarning("Missing", "Please enter a task description.", parent=self); return messagebox.showwarning("Missing", "Please select a category.", parent=self)
return
if not desc:
messagebox.showwarning("Missing", "Please enter a task description.", parent=self)
return
now = datetime.now() now = datetime.now()
day = self.app._active_day() day = self.app._active_day()
@@ -399,14 +906,14 @@ class StartTaskDialog(BaseDialog):
"start": ct["start_time"], "start": ct["start_time"],
"desc": f"{ct['category']} - {ct['desc']} (switched at {fmt_time(now)})", "desc": f"{ct['category']} - {ct['desc']} (switched at {fmt_time(now)})",
"end": fmt_time(now), "end": fmt_time(now),
"hours": hrs "hours": hrs,
}) })
self.app.current_task = { self.app.current_task = {
"start_dt": now, "start_dt": now,
"start_time": fmt_time(now), "start_time": fmt_time(now),
"category": cat, "category": cat,
"desc": desc "desc": desc,
} }
self.app._set_status(True, f"{cat} - {desc}") self.app._set_status(True, f"{cat} - {desc}")
self.app._refresh_log() self.app._refresh_log()
@@ -431,10 +938,10 @@ class EndTaskDialog(BaseDialog):
def info_row(label, value, color): def info_row(label, value, color):
row = tk.Frame(frame, bg=BG_PANEL) row = tk.Frame(frame, bg=BG_PANEL)
row.pack(fill="x", pady=2) row.pack(fill="x", pady=2)
tk.Label(row, text=f"{label}:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL, tk.Label(row, text=f"{label}:", font=FONT_MONO_SM, fg=NEON_CYAN,
width=14, anchor="w").pack(side="left") bg=BG_PANEL, width=14, anchor="w").pack(side="left")
tk.Label(row, text=value, font=FONT_MONO, fg=color, bg=BG_PANEL, tk.Label(row, text=value, font=FONT_MONO, fg=color,
anchor="w").pack(side="left") bg=BG_PANEL, anchor="w").pack(side="left")
info_row("TASK", f"{ct['category']} - {ct['desc']}", NEON_CYAN) info_row("TASK", f"{ct['category']} - {ct['desc']}", NEON_CYAN)
info_row("STARTED", ct["start_time"], NEON_LIME) info_row("STARTED", ct["start_time"], NEON_LIME)
@@ -451,11 +958,11 @@ class EndTaskDialog(BaseDialog):
tk.Button(btn_row, text="> COMMIT <", command=self._commit, tk.Button(btn_row, text="> COMMIT <", command=self._commit,
font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL, font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_CYAN, highlightthickness=2, relief="flat", highlightbackground=NEON_CYAN, highlightthickness=2,
pady=6, cursor="hand2").grid(row=0, column=0, padx=(0,4), sticky="ew") pady=6, cursor="hand2").grid(row=0, column=0, padx=(0, 4), sticky="ew")
tk.Button(btn_row, text="> CANCEL <", command=self.destroy, tk.Button(btn_row, text="> CANCEL <", command=self.destroy,
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL, font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2, relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
pady=6, cursor="hand2").grid(row=0, column=1, padx=(4,0), sticky="ew") pady=6, cursor="hand2").grid(row=0, column=1, padx=(4, 0), sticky="ew")
self._now = now self._now = now
self._hours = hrs self._hours = hrs
@@ -467,7 +974,7 @@ class EndTaskDialog(BaseDialog):
"start": ct["start_time"], "start": ct["start_time"],
"desc": f"{ct['category']} - {ct['desc']}", "desc": f"{ct['category']} - {ct['desc']}",
"end": fmt_time(self._now), "end": fmt_time(self._now),
"hours": self._hours "hours": self._hours,
}) })
self.app.current_task = None self.app.current_task = None
self.app._set_status(False) self.app._set_status(False)
@@ -475,6 +982,7 @@ class EndTaskDialog(BaseDialog):
self.destroy() self.destroy()
# ─── CATEGORIES DIALOG ────────────────────────────────────────────────────────
class CategoriesDialog(BaseDialog): class CategoriesDialog(BaseDialog):
def __init__(self, app: WorkdayLogger): def __init__(self, app: WorkdayLogger):
super().__init__(app, "> CATEGORIES <", border_color=NEON_ORNG) super().__init__(app, "> CATEGORIES <", border_color=NEON_ORNG)
@@ -486,9 +994,9 @@ class CategoriesDialog(BaseDialog):
frame.columnconfigure(0, weight=1) frame.columnconfigure(0, weight=1)
tk.Label(frame, text="> MANAGE CATEGORIES <", font=("Courier", 13, "bold"), tk.Label(frame, text="> MANAGE CATEGORIES <", font=("Courier", 13, "bold"),
fg=NEON_ORNG, bg=BG_PANEL).grid(row=0, column=0, columnspan=2, pady=(0,10)) fg=NEON_ORNG, bg=BG_PANEL)\
.grid(row=0, column=0, columnspan=2, pady=(0, 10))
# Add row
add_frame = tk.Frame(frame, bg=BG_PANEL) add_frame = tk.Frame(frame, bg=BG_PANEL)
add_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8)) add_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))
add_frame.columnconfigure(0, weight=1) add_frame.columnconfigure(0, weight=1)
@@ -497,13 +1005,13 @@ class CategoriesDialog(BaseDialog):
tk.Entry(add_frame, textvariable=self.var_new, font=FONT_MONO, tk.Entry(add_frame, textvariable=self.var_new, font=FONT_MONO,
bg=BG_ELEV, fg="white", insertbackground="white", bg=BG_ELEV, fg="white", insertbackground="white",
relief="flat", highlightbackground=NEON_ORNG, relief="flat", highlightbackground=NEON_ORNG,
highlightthickness=1, width=24).grid(row=0, column=0, sticky="ew", padx=(0,6)) highlightthickness=1, width=24)\
.grid(row=0, column=0, sticky="ew", padx=(0, 6))
tk.Button(add_frame, text="+ ADD", command=self._add, tk.Button(add_frame, text="+ ADD", command=self._add,
font=FONT_MONO_SM, fg=NEON_ORNG, bg=BG_PANEL, font=FONT_MONO_SM, fg=NEON_ORNG, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_ORNG, highlightthickness=1, relief="flat", highlightbackground=NEON_ORNG, highlightthickness=1,
padx=8, pady=4, cursor="hand2").grid(row=0, column=1) padx=8, pady=4, cursor="hand2").grid(row=0, column=1)
# List
listbox_frame = tk.Frame(frame, bg=BG_ELEV, listbox_frame = tk.Frame(frame, bg=BG_ELEV,
highlightbackground=NEON_ORNG, highlightthickness=1) highlightbackground=NEON_ORNG, highlightthickness=1)
listbox_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(0, 8)) listbox_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(0, 8))
@@ -520,13 +1028,13 @@ class CategoriesDialog(BaseDialog):
sb = tk.Scrollbar(listbox_frame, command=self.listbox.yview, bg=BG_DARK) sb = tk.Scrollbar(listbox_frame, command=self.listbox.yview, bg=BG_DARK)
sb.grid(row=0, column=1, sticky="ns") sb.grid(row=0, column=1, sticky="ns")
self.listbox.config(yscrollcommand=sb.set) self.listbox.config(yscrollcommand=sb.set)
self._populate_list() self._populate_list()
tk.Button(frame, text="[ DELETE SELECTED CUSTOM CAT ]", command=self._delete, tk.Button(frame, text="[ DELETE SELECTED CUSTOM CAT ]", command=self._delete,
font=FONT_MONO_SM, fg=NEON_RED, bg=BG_PANEL, font=FONT_MONO_SM, fg=NEON_RED, bg=BG_PANEL,
relief="flat", highlightbackground=NEON_RED, highlightthickness=1, relief="flat", highlightbackground=NEON_RED, highlightthickness=1,
pady=4, cursor="hand2").grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0,6)) pady=4, cursor="hand2")\
.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 6))
tk.Label(frame, text="⚠ Custom categories are session-only", tk.Label(frame, text="⚠ Custom categories are session-only",
font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL)\ font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL)\
@@ -535,7 +1043,8 @@ class CategoriesDialog(BaseDialog):
tk.Button(frame, text="> CLOSE <", command=self.destroy, tk.Button(frame, text="> CLOSE <", command=self.destroy,
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL, font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2, relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
pady=6, cursor="hand2").grid(row=5, column=0, columnspan=2, sticky="ew", pady=(8,0)) pady=6, cursor="hand2")\
.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(8, 0))
self.var_new.trace_add("write", lambda *_: None) self.var_new.trace_add("write", lambda *_: None)
self.bind("<Return>", lambda e: self._add()) self.bind("<Return>", lambda e: self._add())
@@ -547,15 +1056,11 @@ class CategoriesDialog(BaseDialog):
self.listbox.insert("end", f" {c} [built-in]") self.listbox.insert("end", f" {c} [built-in]")
for c in self.app.custom_cats: for c in self.app.custom_cats:
self.listbox.insert("end", f" {c} [custom]") self.listbox.insert("end", f" {c} [custom]")
idx = self.listbox.size() - 1 self.listbox.itemconfig(self.listbox.size() - 1, fg=NEON_ORNG)
self.listbox.itemconfig(idx, fg=NEON_ORNG)
def _add(self): def _add(self):
val = self.var_new.get().strip().replace(" ", "-") val = self.var_new.get().strip().replace(" ", "-")
if not val: if not val or val in DEFAULT_CATS + self.app.custom_cats:
return
all_cats = DEFAULT_CATS + self.app.custom_cats
if val in all_cats:
self.var_new.set("") self.var_new.set("")
return return
self.app.custom_cats.append(val) self.app.custom_cats.append(val)
@@ -567,12 +1072,11 @@ class CategoriesDialog(BaseDialog):
if not sel: if not sel:
return return
idx = sel[0] idx = sel[0]
n_builtin = len(DEFAULT_CATS) if idx < len(DEFAULT_CATS):
if idx < n_builtin: messagebox.showinfo("Built-in",
messagebox.showinfo("Built-in", "Built-in categories cannot be deleted.", parent=self) "Built-in categories cannot be deleted.", parent=self)
return return
custom_idx = idx - n_builtin del self.app.custom_cats[idx - len(DEFAULT_CATS)]
del self.app.custom_cats[custom_idx]
self._populate_list() self._populate_list()