Compare commits
2 Commits
6d14441b5d
...
4c0f174beb
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c0f174beb | |||
| b7c625fdab |
BIN
RTF-Auditor
LFS
Executable file
BIN
RTF-Auditor
LFS
Executable file
Binary file not shown.
BIN
RTF-Workday-Logger
LFS
Executable file
BIN
RTF-Workday-Logger
LFS
Executable file
Binary file not shown.
423
RTF-audit.py
Normal file
423
RTF-audit.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user