diff --git a/RTF-Auditor b/RTF-Auditor new file mode 100755 index 0000000..33dfa27 Binary files /dev/null and b/RTF-Auditor differ diff --git a/RTF-Workday-Logger b/RTF-Workday-Logger new file mode 100755 index 0000000..6004353 Binary files /dev/null and b/RTF-Workday-Logger differ diff --git a/RTF-audit.py b/RTF-audit.py new file mode 100644 index 0000000..ce0eabf --- /dev/null +++ b/RTF-audit.py @@ -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("", lambda e: self._verify()) + self.bind("", 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() diff --git a/RTF-workday-logger.py b/RTF-workday-logger.py index 351684e..827a870 100644 --- a/RTF-workday-logger.py +++ b/RTF-workday-logger.py @@ -2,12 +2,17 @@ """ REMEMBER TO FORGET // WORKDAY LOGGER Standalone Python app — stdlib only (tkinter). -Run with: python workday_logger.py +Run with: python RTF-workday-logger.py """ import tkinter as tk -from tkinter import ttk, messagebox, filedialog, simpledialog +from tkinter import ttk, messagebox, filedialog from datetime import datetime +import os +import sys +import hashlib +import hmac as _hmac +import secrets # ─── COLOUR PALETTE ────────────────────────────────────────────────────────── @@ -24,9 +29,9 @@ NEON_PURP = "#bf00ff" TEXT_DIM = "#888899" TEXT_MAIN = "#e0e0e0" -FONT_MONO = ("Courier", 11) +FONT_MONO = ("Courier", 11) FONT_MONO_SM = ("Courier", 9) -FONT_BIG = ("Courier", 13, "bold") +FONT_BIG = ("Courier", 13, "bold") # ─── DEFAULT CATEGORIES ─────────────────────────────────────────────────────── DEFAULT_CATS = [ @@ -45,6 +50,10 @@ def fmt_time(dt: datetime) -> str: return dt.strftime("%H:%M") +def fmt_ts(dt: datetime) -> str: + return dt.strftime("%Y-%m-%d %H:%M:%S") + + def today_key() -> str: return datetime.now().strftime("%Y-%m-%d") @@ -54,21 +63,47 @@ def calc_hours(start: datetime, end: datetime) -> float: 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 ───────────────────────────────────────────────────────────────── class WorkdayLogger(tk.Tk): def __init__(self): super().__init__() self.title("REMEMBER TO FORGET // WORKLOG") self.configure(bg=BG_DARK) - self.minsize(750, 620) + self.minsize(750, 680) - # State - self.current_task = None # dict or None - self.days: list[dict] = [] # [{ key, date, lines:[] }] - self.active_day_idx = 0 + # Core state + self.open_ts = datetime.now() + self.current_task = None + self.days: list[dict] = [] + self.active_day_idx = 0 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.protocol("WM_DELETE_WINDOW", self._on_close) self._ensure_today() self._refresh_log() @@ -97,7 +132,7 @@ class WorkdayLogger(tk.Tk): id_frame.columnconfigure(3, weight=1) 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() tk.Entry(id_frame, textvariable=self.var_name, font=FONT_MONO, bg=BG_ELEV, fg="white", insertbackground="white", @@ -105,34 +140,34 @@ class WorkdayLogger(tk.Tk): highlightthickness=1).grid(row=0, column=1, sticky="ew") 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() tk.Entry(id_frame, textvariable=self.var_org, font=FONT_MONO, bg=BG_ELEV, fg="white", insertbackground="white", relief="flat", highlightbackground=NEON_CYAN, highlightthickness=1).grid(row=0, column=3, sticky="ew") - # BUTTONS + # ACTION BUTTONS btn_frame = tk.Frame(root_frame, bg=BG_DARK) btn_frame.grid(row=2, column=0, sticky="ew", pady=(0, 8)) for col in range(4): btn_frame.columnconfigure(col, weight=1) def styled_btn(parent, text, cmd, fg, **kwargs): - b = tk.Button(parent, text=text, command=cmd, font=("Courier", 12, "bold"), - fg=fg, bg=BG_PANEL, activeforeground="white", - activebackground=BG_ELEV, relief="flat", - highlightbackground=fg, highlightthickness=2, - padx=10, pady=8, cursor="hand2", **kwargs) - return b + return tk.Button(parent, text=text, command=cmd, + font=("Courier", 12, "bold"), + fg=fg, bg=BG_PANEL, activeforeground="white", + activebackground=BG_ELEV, relief="flat", + highlightbackground=fg, highlightthickness=2, + padx=10, pady=8, cursor="hand2", **kwargs) - 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") - styled_btn(btn_frame, "> END TASK <", self._on_end, NEON_RED)\ + styled_btn(btn_frame, "> END TASK <", self._on_end, NEON_RED)\ .grid(row=0, column=1, padx=4, sticky="ew") - styled_btn(btn_frame, "> CATEGORIES <", self._on_cats, NEON_ORNG)\ + styled_btn(btn_frame, "> CATEGORIES <", self._on_cats, NEON_ORNG)\ .grid(row=0, column=2, padx=4, sticky="ew") - styled_btn(btn_frame, "> + NEW DAY <", self._on_new_day, NEON_YELL)\ + styled_btn(btn_frame, "> + NEW DAY <", self._on_new_day, NEON_YELL)\ .grid(row=0, column=3, padx=4, sticky="ew") # STATUS BAR @@ -142,57 +177,89 @@ class WorkdayLogger(tk.Tk): status_frame.columnconfigure(1, weight=1) self.lbl_status_dot = tk.Label(status_frame, text="●", font=("Courier", 14), - fg=NEON_YELL, bg=BG_PANEL, padx=8) + fg=NEON_YELL, bg=BG_PANEL, padx=8) self.lbl_status_dot.grid(row=0, column=0) self.lbl_status = tk.Label(status_frame, text="IDLE", font=FONT_MONO_SM, - fg=NEON_YELL, bg=BG_PANEL) + fg=NEON_YELL, bg=BG_PANEL) self.lbl_status.grid(row=0, column=1, sticky="w") self.lbl_current = tk.Label(status_frame, text="", font=FONT_MONO_SM, - 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") - # OUTPUT - out_frame = tk.Frame(root_frame, bg=BG_PANEL, - highlightbackground=NEON_PINK, highlightthickness=2) - out_frame.grid(row=4, column=0, sticky="nsew", pady=(0, 8)) - out_frame.columnconfigure(0, weight=1) - out_frame.rowconfigure(1, weight=1) + # ── NOTEBOOK ────────────────────────────────────────────────────────── + nb_style = ttk.Style() + nb_style.theme_use("default") + nb_style.configure("RTF.TNotebook", + background=BG_DARK, borderwidth=0, + 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) - out_hdr = tk.Frame(out_frame, bg=BG_PANEL) - out_hdr.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 2)) - tk.Label(out_hdr, text="// WORK LOG OUTPUT", font=FONT_BIG, + # ── TAB 1: WORK LOG ─────────────────────────────────────────────────── + log_tab = tk.Frame(notebook, bg=BG_PANEL, + 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") - btn_area = tk.Frame(out_hdr, bg=BG_PANEL) + btn_area = tk.Frame(log_hdr, bg=BG_PANEL) btn_area.pack(side="right") - def small_btn(text, cmd, fg): - return tk.Button(btn_area, text=text, command=cmd, font=FONT_MONO_SM, + def small_btn(parent, text, cmd, fg): + return tk.Button(parent, text=text, command=cmd, font=FONT_MONO_SM, fg=fg, bg=BG_PANEL, relief="flat", highlightbackground=fg, highlightthickness=1, padx=6, pady=3, cursor="hand2") - small_btn("Copy", 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, "Copy .md", self._on_copy, NEON_CYAN).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, - bg=BG_ELEV, fg=TEXT_MAIN, - insertbackground=NEON_CYAN, - relief="flat", padx=10, pady=10, - state="disabled", wrap="none", - highlightthickness=0) + self.txt_output = tk.Text(log_tab, font=FONT_MONO, + bg=BG_ELEV, fg=TEXT_MAIN, + insertbackground=NEON_CYAN, + relief="flat", padx=10, pady=10, + state="disabled", wrap="none", + highlightthickness=0) 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, - bg=BG_DARK, troughcolor=BG_ELEV) + scroll_y = tk.Scrollbar(log_tab, command=self.txt_output.yview, + bg=BG_DARK, troughcolor=BG_ELEV) scroll_y.grid(row=1, column=1, sticky="ns") - scroll_x = tk.Scrollbar(out_frame, orient="horizontal", - command=self.txt_output.xview, - bg=BG_DARK, troughcolor=BG_ELEV) + scroll_x = tk.Scrollbar(log_tab, orient="horizontal", + command=self.txt_output.xview, + bg=BG_DARK, troughcolor=BG_ELEV) scroll_x.grid(row=2, column=0, sticky="ew") 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 tk.Label(root_frame, @@ -200,6 +267,61 @@ class WorkdayLogger(tk.Tk): font=FONT_MONO_SM, fg=NEON_YELL, bg=BG_DARK)\ .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 ──────────────────────────────────────────────────────────────── def _ensure_today(self): key = today_key() @@ -207,7 +329,12 @@ class WorkdayLogger(tk.Tk): if d["key"] == key: self.active_day_idx = i 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 return True @@ -238,10 +365,33 @@ class WorkdayLogger(tk.Tk): lines.append("\n| BEGIN | TASK/ACTIVITY | END | HRS |\n") lines.append("| :-- | :-- | :-- | :-: |\n") 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: 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) self.txt_output.config(state="normal") @@ -252,6 +402,138 @@ class WorkdayLogger(tk.Tk): def _get_log_text(self) -> str: 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 ─────────────────────────────────────────────────────────────── def _on_start(self): if not self.var_name.get().strip() or not self.var_org.get().strip(): @@ -273,48 +555,103 @@ class WorkdayLogger(tk.Tk): if not self.var_name.get().strip() or not self.var_org.get().strip(): messagebox.showwarning("Missing info", "Please enter Name and Organization.") return - key = today_key() - self.days.append({"key": key, "date": fmt_date(datetime.now()), "lines": []}) + # Snapshot the current journals into the outgoing day before clearing + 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.current_task = None + self.current_task = None self._set_status(False) 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): - text = self._get_log_text() + text = self._build_full_md() self.clipboard_clear() 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): - text = self._get_log_text().strip() + text = self._build_full_md().strip() if not text: messagebox.showwarning("Empty", "Nothing to export yet.") return - name = (self.var_name.get().strip() or "worklog").replace(" ", "_") + name = (self.var_name.get().strip() or "worklog").replace(" ", "_") default_name = f"worklog_{name}_{today_key()}.md" path = filedialog.asksaveasfilename( defaultextension=".md", - filetypes=[("Markdown files", "*.md"), ("Text files", "*.txt"), ("All files", "*.*")], + filetypes=[("Markdown files", "*.md"), + ("Text files", "*.txt"), + ("All files", "*.*")], initialfile=default_name, - title="Export Worklog" + title="Download Worklog" ) if path: with open(path, "w", encoding="utf-8") as f: f.write(text) + self.exported = True 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): def __init__(self, app: WorkdayLogger, title: str, border_color: str = NEON_PINK): super().__init__(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.configure(bg=BG_PANEL) self.resizable(False, False) self.grab_set() - # Border effect via highlight self.config(highlightbackground=border_color, highlightthickness=3) self.bind("", lambda e: self.destroy()) self._center() @@ -327,6 +664,175 @@ class BaseDialog(tk.Toplevel): 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("", 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("", 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): def __init__(self, app: WorkdayLogger): super().__init__(app, "> START NEW TASK <") @@ -338,26 +844,23 @@ class StartTaskDialog(BaseDialog): tk.Label(frame, text="> START NEW TASK <", font=("Courier", 14, "bold"), fg=NEON_PINK, bg=BG_PANEL).pack(pady=(0, 12)) - # Category - tk.Label(frame, text="CATEGORY:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL, - anchor="w").pack(fill="x") + tk.Label(frame, text="CATEGORY:", font=FONT_MONO_SM, + fg=NEON_CYAN, bg=BG_PANEL, anchor="w").pack(fill="x") self.var_cat = tk.StringVar() - cats = app._all_cats() + cats = app._all_cats() cat_cb = ttk.Combobox(frame, textvariable=self.var_cat, values=cats, - font=FONT_MONO, state="readonly", width=40) + font=FONT_MONO, state="readonly", width=40) cat_cb.pack(fill="x", pady=(2, 10)) self._style_combo(cat_cb) - # Description - tk.Label(frame, text="TASK DESCRIPTION:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL, - anchor="w").pack(fill="x") + tk.Label(frame, text="TASK DESCRIPTION:", font=FONT_MONO_SM, + fg=NEON_CYAN, bg=BG_PANEL, anchor="w").pack(fill="x") self.var_desc = tk.StringVar() tk.Entry(frame, textvariable=self.var_desc, font=FONT_MONO, bg=BG_ELEV, fg="white", insertbackground="white", relief="flat", highlightbackground=NEON_CYAN, highlightthickness=1).pack(fill="x", pady=(2, 14)) - # Buttons btn_row = tk.Frame(frame, bg=BG_PANEL) btn_row.pack(fill="x") btn_row.columnconfigure(0, weight=1) @@ -366,11 +869,11 @@ class StartTaskDialog(BaseDialog): tk.Button(btn_row, text="> COMMIT <", command=self._commit, font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL, 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, 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") + pady=6, cursor="hand2").grid(row=0, column=1, padx=(4, 0), sticky="ew") cat_cb.focus_set() self._center() @@ -379,34 +882,38 @@ class StartTaskDialog(BaseDialog): style = ttk.Style() style.theme_use("default") style.configure("TCombobox", - fieldbackground=BG_ELEV, background=BG_ELEV, - foreground="white", selectforeground="white", - selectbackground=BG_ELEV) + fieldbackground=BG_ELEV, background=BG_ELEV, + foreground="white", selectforeground="white", + selectbackground=BG_ELEV) def _commit(self): cat = self.var_cat.get().strip() desc = self.var_desc.get().strip() - if not cat: messagebox.showwarning("Missing", "Please select a category.", parent=self); return - if not desc: messagebox.showwarning("Missing", "Please enter a task description.", parent=self); return + if not cat: + 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() day = self.app._active_day() if self.app.current_task: - ct = self.app.current_task + ct = self.app.current_task hrs = calc_hours(ct["start_dt"], now) day["lines"].append({ "start": ct["start_time"], - "desc": f"{ct['category']} - {ct['desc']} (switched at {fmt_time(now)})", - "end": fmt_time(now), - "hours": hrs + "desc": f"{ct['category']} - {ct['desc']} (switched at {fmt_time(now)})", + "end": fmt_time(now), + "hours": hrs, }) self.app.current_task = { - "start_dt": now, + "start_dt": now, "start_time": fmt_time(now), - "category": cat, - "desc": desc + "category": cat, + "desc": desc, } self.app._set_status(True, f"{cat} - {desc}") self.app._refresh_log() @@ -431,10 +938,10 @@ class EndTaskDialog(BaseDialog): def info_row(label, value, color): row = tk.Frame(frame, bg=BG_PANEL) row.pack(fill="x", pady=2) - tk.Label(row, text=f"{label}:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL, - width=14, anchor="w").pack(side="left") - tk.Label(row, text=value, font=FONT_MONO, fg=color, bg=BG_PANEL, - anchor="w").pack(side="left") + tk.Label(row, text=f"{label}:", font=FONT_MONO_SM, fg=NEON_CYAN, + bg=BG_PANEL, width=14, anchor="w").pack(side="left") + tk.Label(row, text=value, font=FONT_MONO, fg=color, + bg=BG_PANEL, anchor="w").pack(side="left") info_row("TASK", f"{ct['category']} - {ct['desc']}", NEON_CYAN) info_row("STARTED", ct["start_time"], NEON_LIME) @@ -451,13 +958,13 @@ class EndTaskDialog(BaseDialog): tk.Button(btn_row, text="> COMMIT <", command=self._commit, font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL, 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, 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") + pady=6, cursor="hand2").grid(row=0, column=1, padx=(4, 0), sticky="ew") - self._now = now + self._now = now self._hours = hrs self._center() @@ -465,9 +972,9 @@ class EndTaskDialog(BaseDialog): ct = self.app.current_task self.app._active_day()["lines"].append({ "start": ct["start_time"], - "desc": f"{ct['category']} - {ct['desc']}", - "end": fmt_time(self._now), - "hours": self._hours + "desc": f"{ct['category']} - {ct['desc']}", + "end": fmt_time(self._now), + "hours": self._hours, }) self.app.current_task = None self.app._set_status(False) @@ -475,6 +982,7 @@ class EndTaskDialog(BaseDialog): self.destroy() +# ─── CATEGORIES DIALOG ──────────────────────────────────────────────────────── class CategoriesDialog(BaseDialog): def __init__(self, app: WorkdayLogger): super().__init__(app, "> CATEGORIES <", border_color=NEON_ORNG) @@ -486,9 +994,9 @@ class CategoriesDialog(BaseDialog): frame.columnconfigure(0, weight=1) 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.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8)) add_frame.columnconfigure(0, weight=1) @@ -497,13 +1005,13 @@ class CategoriesDialog(BaseDialog): tk.Entry(add_frame, textvariable=self.var_new, font=FONT_MONO, bg=BG_ELEV, fg="white", insertbackground="white", 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, font=FONT_MONO_SM, fg=NEON_ORNG, bg=BG_PANEL, relief="flat", highlightbackground=NEON_ORNG, highlightthickness=1, padx=8, pady=4, cursor="hand2").grid(row=0, column=1) - # List listbox_frame = tk.Frame(frame, bg=BG_ELEV, highlightbackground=NEON_ORNG, highlightthickness=1) listbox_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(0, 8)) @@ -511,22 +1019,22 @@ class CategoriesDialog(BaseDialog): listbox_frame.columnconfigure(0, weight=1) self.listbox = tk.Listbox(listbox_frame, font=FONT_MONO_SM, - bg=BG_ELEV, fg=TEXT_MAIN, - selectbackground=BG_PANEL, - selectforeground=NEON_ORNG, - relief="flat", highlightthickness=0, - activestyle="none", height=14) + bg=BG_ELEV, fg=TEXT_MAIN, + selectbackground=BG_PANEL, + selectforeground=NEON_ORNG, + relief="flat", highlightthickness=0, + activestyle="none", height=14) self.listbox.grid(row=0, column=0, sticky="nsew") sb = tk.Scrollbar(listbox_frame, command=self.listbox.yview, bg=BG_DARK) sb.grid(row=0, column=1, sticky="ns") self.listbox.config(yscrollcommand=sb.set) - self._populate_list() tk.Button(frame, text="[ DELETE SELECTED CUSTOM CAT ]", command=self._delete, font=FONT_MONO_SM, fg=NEON_RED, bg=BG_PANEL, 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", 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, font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL, 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.bind("", lambda e: self._add()) @@ -547,15 +1056,11 @@ class CategoriesDialog(BaseDialog): self.listbox.insert("end", f" {c} [built-in]") for c in self.app.custom_cats: self.listbox.insert("end", f" {c} [custom]") - idx = self.listbox.size() - 1 - self.listbox.itemconfig(idx, fg=NEON_ORNG) + self.listbox.itemconfig(self.listbox.size() - 1, fg=NEON_ORNG) def _add(self): val = self.var_new.get().strip().replace(" ", "-") - if not val: - return - all_cats = DEFAULT_CATS + self.app.custom_cats - if val in all_cats: + if not val or val in DEFAULT_CATS + self.app.custom_cats: self.var_new.set("") return self.app.custom_cats.append(val) @@ -567,12 +1072,11 @@ class CategoriesDialog(BaseDialog): if not sel: return idx = sel[0] - n_builtin = len(DEFAULT_CATS) - if idx < n_builtin: - messagebox.showinfo("Built-in", "Built-in categories cannot be deleted.", parent=self) + if idx < len(DEFAULT_CATS): + messagebox.showinfo("Built-in", + "Built-in categories cannot be deleted.", parent=self) return - custom_idx = idx - n_builtin - del self.app.custom_cats[custom_idx] + del self.app.custom_cats[idx - len(DEFAULT_CATS)] self._populate_list()