Contact Book Manager

 import json

import csv

import os

import re

from datetime import datetime

from pathlib import Path


# ============================================================

# CONFIGURATION

# ============================================================


DATA_FILE   = "contacts.json"

BACKUP_FILE = "contacts_backup.json"


# ============================================================

# LOAD & SAVE

# ============================================================


def load_contacts():

    if Path(DATA_FILE).exists():

        try:

            with open(DATA_FILE, "r", encoding="utf-8") as f:

                return json.load(f)

        except:

            pass

    return {"contacts": [], "groups": []}



def save_contacts(data):

    with open(DATA_FILE, "w", encoding="utf-8") as f:

        json.dump(data, f, indent=2, ensure_ascii=False)



def backup_contacts(data):

    with open(BACKUP_FILE, "w", encoding="utf-8") as f:

        json.dump(data, f, indent=2, ensure_ascii=False)

    print(f"  Backup saved: {BACKUP_FILE}")



# ============================================================

# VALIDATION

# ============================================================


def validate_phone(phone):

    """Accept digits, spaces, +, -, (, )"""

    return bool(re.match(r"^[\d\s\+\-\(\)]{7,20}$", phone.strip()))



def validate_email(email):

    return bool(re.match(r"^[\w\.\+\-]+@[\w\-]+\.[a-zA-Z]{2,}$", email.strip()))



# ============================================================

# ADD CONTACT

# ============================================================


def add_contact(data):

    print("\n" + "="*50)

    print("  ADD NEW CONTACT")

    print("="*50)


    # Required

    name = input("  Full Name*    : ").strip()

    if not name:

        print("  Name is required.")

        return


    # Check duplicate

    if any(c["name"].lower() == name.lower() for c in data["contacts"]):

        overwrite = input(f"  '{name}' already exists. Add anyway? (y/n): ").strip().lower()

        if overwrite != "y":

            return


    # Optional fields

    phone = input("  Phone Number  : ").strip()

    if phone and not validate_phone(phone):

        print("  Invalid phone format. Saving as-is.")


    email = input("  Email         : ").strip()

    if email and not validate_email(email):

        print("  Invalid email format. Saving as-is.")


    address   = input("  Address       : ").strip()

    birthday  = input("  Birthday (DD-MM-YYYY): ").strip()

    company   = input("  Company       : ").strip()

    notes     = input("  Notes         : ").strip()


    # Groups

    if data["groups"]:

        print(f"\n  Available groups: {', '.join(data['groups'])}")

    group = input("  Group (or new group name): ").strip()

    if group and group not in data["groups"]:

        data["groups"].append(group)


    contact = {

        "id":         len(data["contacts"]) + 1,

        "name":       name,

        "phone":      phone,

        "email":      email,

        "address":    address,

        "birthday":   birthday,

        "company":    company,

        "notes":      notes,

        "group":      group,

        "favourite":  False,

        "created_at": datetime.now().strftime("%d-%m-%Y %H:%M"),

        "updated_at": datetime.now().strftime("%d-%m-%Y %H:%M"),

    }


    data["contacts"].append(contact)

    save_contacts(data)

    print(f"\n  Contact saved: {name}")



# ============================================================

# VIEW CONTACT

# ============================================================


def view_contact(contact):

    w = 50

    fav = " ★" if contact.get("favourite") else ""

    print("\n" + "="*w)

    print(f"  {contact['name']}{fav}")

    print("="*w)


    fields = [

        ("Phone",    contact.get("phone",    "")),

        ("Email",    contact.get("email",    "")),

        ("Company",  contact.get("company",  "")),

        ("Address",  contact.get("address",  "")),

        ("Birthday", contact.get("birthday", "")),

        ("Group",    contact.get("group",    "")),

        ("Notes",    contact.get("notes",    "")),

        ("Added",    contact.get("created_at", "")),

        ("Updated",  contact.get("updated_at", "")),

    ]

    for label, value in fields:

        if value:

            print(f"  {label:<10}: {value}")


    print("="*w)



# ============================================================

# LIST CONTACTS

# ============================================================


def list_contacts(data, group_filter=None, favourites_only=False,

                  sort_by="name"):

    contacts = data["contacts"]


    if group_filter:

        contacts = [c for c in contacts

                    if c.get("group", "").lower() == group_filter.lower()]

    if favourites_only:

        contacts = [c for c in contacts if c.get("favourite")]


    if not contacts:

        print("\n  No contacts found.")

        return []


    # Sort

    if sort_by == "name":

        contacts = sorted(contacts, key=lambda c: c["name"].lower())

    elif sort_by == "company":

        contacts = sorted(contacts, key=lambda c: c.get("company", "").lower())

    elif sort_by == "group":

        contacts = sorted(contacts, key=lambda c: c.get("group", "").lower())

    elif sort_by == "recent":

        contacts = sorted(contacts, key=lambda c: c.get("updated_at", ""),

                          reverse=True)


    label = ""

    if group_filter:

        label = f"  Group: {group_filter}"

    elif favourites_only:

        label = "  Favourites"


    print("\n" + "="*65)

    print(f"  CONTACTS  ({len(contacts)} total){label}")

    print("="*65)

    print(f"  {'#':<5} {'NAME':<22} {'PHONE':<16} "

          f"{'EMAIL':<22} {'GRP'}")

    print("  " + "-"*60)


    for i, c in enumerate(contacts, 1):

        fav   = "★" if c.get("favourite") else " "

        name  = c["name"][:20]

        phone = c.get("phone", "")[:14]

        email = c.get("email", "")[:20]

        group = c.get("group", "")[:8]

        print(f"  {fav}{i:<4} {name:<22} {phone:<16} "

              f"{email:<22} {group}")


    print("="*65)

    return contacts



# ============================================================

# SEARCH CONTACTS

# ============================================================


def search_contacts(data, query):

    query = query.lower().strip()

    results = []


    for c in data["contacts"]:

        if (query in c["name"].lower()

                or query in c.get("phone", "").lower()

                or query in c.get("email", "").lower()

                or query in c.get("company", "").lower()

                or query in c.get("address", "").lower()

                or query in c.get("notes", "").lower()):

            results.append(c)


    if not results:

        print(f"\n  No contacts found for: '{query}'")

        return []


    print(f"\n  Found {len(results)} result(s) for '{query}':")

    for i, c in enumerate(results, 1):

        fav   = "★" if c.get("favourite") else " "

        phone = c.get("phone", "N/A")

        email = c.get("email", "")

        print(f"  {fav}[{i}] {c['name']}  |  {phone}  |  {email}")


    return results



# ============================================================

# EDIT CONTACT

# ============================================================


def edit_contact(data, contact):

    print(f"\n  Editing: {contact['name']}")

    print("  (Press Enter to keep current value)\n")


    fields = [

        ("name",     "Full Name",    contact.get("name", "")),

        ("phone",    "Phone",        contact.get("phone", "")),

        ("email",    "Email",        contact.get("email", "")),

        ("company",  "Company",      contact.get("company", "")),

        ("address",  "Address",      contact.get("address", "")),

        ("birthday", "Birthday",     contact.get("birthday", "")),

        ("notes",    "Notes",        contact.get("notes", "")),

        ("group",    "Group",        contact.get("group", "")),

    ]


    for key, label, current in fields:

        new_val = input(f"  {label:<12} [{current}]: ").strip()

        if new_val:

            contact[key] = new_val


    # Favourite toggle

    fav = input(f"  Favourite? (y/n) [{'+' if contact.get('favourite') else '-'}]: ").strip().lower()

    if fav == "y":

        contact["favourite"] = True

    elif fav == "n":

        contact["favourite"] = False


    contact["updated_at"] = datetime.now().strftime("%d-%m-%Y %H:%M")


    # Update in data

    for i, c in enumerate(data["contacts"]):

        if c["id"] == contact["id"]:

            data["contacts"][i] = contact

            break


    # Add new group if needed

    if contact["group"] and contact["group"] not in data["groups"]:

        data["groups"].append(contact["group"])


    save_contacts(data)

    print(f"\n  Contact updated: {contact['name']}")



# ============================================================

# DELETE CONTACT

# ============================================================


def delete_contact(data, contact):

    confirm = input(f"\n  Delete '{contact['name']}'? (yes/no): ").strip().lower()

    if confirm == "yes":

        data["contacts"] = [c for c in data["contacts"]

                            if c["id"] != contact["id"]]

        save_contacts(data)

        print(f"  Deleted: {contact['name']}")

    else:

        print("  Cancelled.")



# ============================================================

# BIRTHDAY REMINDERS

# ============================================================


def birthday_reminders(data):

    today     = datetime.today()

    upcoming  = []


    for c in data["contacts"]:

        bday = c.get("birthday", "")

        if not bday:

            continue

        try:

            bday_dt = datetime.strptime(bday, "%d-%m-%Y")

            # This year's birthday

            this_year = bday_dt.replace(year=today.year)

            if this_year < today:

                this_year = bday_dt.replace(year=today.year + 1)

            days_left = (this_year - today).days

            age = today.year - bday_dt.year

            if this_year.year > today.year:

                age -= 1

            upcoming.append((days_left, c["name"], bday, age + 1))

        except:

            continue


    upcoming.sort(key=lambda x: x[0])


    print("\n" + "="*52)

    print("  UPCOMING BIRTHDAYS")

    print("="*52)


    if not upcoming:

        print("  No birthdays found. Add birthdays when creating contacts.")

        return


    for days, name, bday, age in upcoming[:15]:

        if days == 0:

            when = "TODAY!"

        elif days == 1:

            when = "Tomorrow"

        elif days <= 7:

            when = f"In {days} days"

        elif days <= 30:

            when = f"In {days} days"

        else:

            when = f"In {days} days"


        print(f"  {when:<15} {name:<25} {bday}  (turns {age})")


    print("="*52)



# ============================================================

# EXPORT TO CSV

# ============================================================


def export_csv(data, filename=None):

    contacts = data["contacts"]

    if not contacts:

        print("  No contacts to export.")

        return


    if not filename:

        filename = f"contacts_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"


    fields = ["name", "phone", "email", "company",

              "address", "birthday", "group", "notes"]


    with open(filename, "w", newline="", encoding="utf-8") as f:

        writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")

        writer.writeheader()

        for c in contacts:

            writer.writerow({k: c.get(k, "") for k in fields})


    print(f"\n  Exported {len(contacts)} contact(s) to: {filename}")



# ============================================================

# IMPORT FROM CSV

# ============================================================


def import_csv(data, filename):

    if not Path(filename).exists():

        print(f"  File not found: {filename}")

        return


    imported = 0

    skipped  = 0


    with open(filename, "r", encoding="utf-8", newline="") as f:

        reader = csv.DictReader(f)

        for row in reader:

            name = row.get("name", "").strip()

            if not name:

                skipped += 1

                continue


            # Skip if already exists

            if any(c["name"].lower() == name.lower()

                   for c in data["contacts"]):

                skipped += 1

                continue


            contact = {

                "id":         len(data["contacts"]) + 1,

                "name":       name,

                "phone":      row.get("phone", ""),

                "email":      row.get("email", ""),

                "address":    row.get("address", ""),

                "birthday":   row.get("birthday", ""),

                "company":    row.get("company", ""),

                "notes":      row.get("notes", ""),

                "group":      row.get("group", ""),

                "favourite":  False,

                "created_at": datetime.now().strftime("%d-%m-%Y %H:%M"),

                "updated_at": datetime.now().strftime("%d-%m-%Y %H:%M"),

            }

            data["contacts"].append(contact)


            # Add group

            if contact["group"] and contact["group"] not in data["groups"]:

                data["groups"].append(contact["group"])


            imported += 1


    save_contacts(data)

    print(f"\n  Imported: {imported}  |  Skipped (duplicate/empty): {skipped}")



# ============================================================

# GROUPS

# ============================================================


def manage_groups(data):

    print("\n" + "="*45)

    print("  GROUPS")

    print("="*45)


    if not data["groups"]:

        print("  No groups yet.")

    else:

        for i, g in enumerate(data["groups"], 1):

            count = sum(1 for c in data["contacts"]

                        if c.get("group") == g)

            print(f"  [{i}] {g}  ({count} contacts)")


    print("\n  1. View contacts in a group")

    print("  2. Rename a group")

    print("  3. Delete a group")

    print("  4. Back")


    sub = input("\n  > ").strip()


    if sub == "1":

        group = input("  Group name: ").strip()

        list_contacts(data, group_filter=group)


    elif sub == "2":

        old = input("  Current group name: ").strip()

        new = input("  New group name    : ").strip()

        if old and new:

            for c in data["contacts"]:

                if c.get("group") == old:

                    c["group"] = new

            if old in data["groups"]:

                data["groups"].remove(old)

            if new not in data["groups"]:

                data["groups"].append(new)

            save_contacts(data)

            print(f"  Renamed '{old}' to '{new}'")


    elif sub == "3":

        group = input("  Group to delete: ").strip()

        confirm = input(f"  Delete group '{group}'? Contacts will be ungrouped. (yes/no): ").strip().lower()

        if confirm == "yes":

            for c in data["contacts"]:

                if c.get("group") == group:

                    c["group"] = ""

            if group in data["groups"]:

                data["groups"].remove(group)

            save_contacts(data)

            print(f"  Group deleted: {group}")



# ============================================================

# STATS

# ============================================================


def show_stats(data):

    contacts = data["contacts"]

    total    = len(contacts)


    if not total:

        print("\n  No contacts yet.")

        return


    with_phone  = sum(1 for c in contacts if c.get("phone"))

    with_email  = sum(1 for c in contacts if c.get("email"))

    with_bday   = sum(1 for c in contacts if c.get("birthday"))

    favourites  = sum(1 for c in contacts if c.get("favourite"))


    group_counts = {}

    for c in contacts:

        g = c.get("group") or "Ungrouped"

        group_counts[g] = group_counts.get(g, 0) + 1


    print("\n" + "="*48)

    print("  CONTACT BOOK STATISTICS")

    print("="*48)

    print(f"  Total contacts  : {total}")

    print(f"  With phone      : {with_phone}")

    print(f"  With email      : {with_email}")

    print(f"  With birthday   : {with_bday}")

    print(f"  Favourites      : {favourites}")

    print(f"\n  By group:")

    for g, count in sorted(group_counts.items(), key=lambda x: -x[1]):

        bar = "█" * min(count, 20)

        print(f"  {g:<20} {count:>4}  {bar}")

    print("="*48)



# ============================================================

# SELECT CONTACT HELPER

# ============================================================


def select_contact(data, prompt="  Search contact: "):

    query = input(prompt).strip()

    if not query:

        return None

    results = search_contacts(data, query)

    if not results:

        return None

    if len(results) == 1:

        return results[0]

    try:

        idx = int(input("  Select number: ").strip()) - 1

        if 0 <= idx < len(results):

            return results[idx]

    except ValueError:

        pass

    return None



# ============================================================

# MAIN MENU

# ============================================================


def print_menu(data):

    total = len(data["contacts"])

    favs  = sum(1 for c in data["contacts"] if c.get("favourite"))

    print("\n" + "-"*50)

    print(f"  CONTACT BOOK  [{total} contacts  ★{favs} favourites]")

    print("-"*50)

    print("  1.  Add new contact")

    print("  2.  View all contacts")

    print("  3.  Search contact")

    print("  4.  View contact details")

    print("  5.  Edit contact")

    print("  6.  Delete contact")

    print("  7.  View favourites")

    print("  8.  Birthday reminders")

    print("  9.  Manage groups")

    print("  10. Export to CSV")

    print("  11. Import from CSV")

    print("  12. Statistics")

    print("  13. Backup contacts")

    print("  0.  Exit")

    print("-"*50)



def main():

    print("\n" + "="*55)

    print("     CONTACT BOOK MANAGER")

    print("="*55)


    data = load_contacts()

    if data["contacts"]:

        print(f"\n  Loaded {len(data['contacts'])} contact(s) from {DATA_FILE}")

    else:

        print("\n  No contacts yet. Start by adding one!")


    while True:

        print_menu(data)

        choice = input("  > ").strip()


        if choice == "1":

            add_contact(data)


        elif choice == "2":

            print("\n  Sort by: 1=Name  2=Company  3=Group  4=Recent")

            sort_opt = input("  Sort (default Name): ").strip()

            sort_map = {"1": "name", "2": "company",

                        "3": "group", "4": "recent"}

            sort_by  = sort_map.get(sort_opt, "name")

            list_contacts(data, sort_by=sort_by)


        elif choice == "3":

            query = input("\n  Search: ").strip()

            search_contacts(data, query)


        elif choice == "4":

            contact = select_contact(data)

            if contact:

                view_contact(contact)


        elif choice == "5":

            contact = select_contact(data)

            if contact:

                edit_contact(data, contact)


        elif choice == "6":

            contact = select_contact(data)

            if contact:

                view_contact(contact)

                delete_contact(data, contact)


        elif choice == "7":

            list_contacts(data, favourites_only=True)


        elif choice == "8":

            birthday_reminders(data)


        elif choice == "9":

            manage_groups(data)


        elif choice == "10":

            fname = input("  Output filename (Enter=auto): ").strip() or None

            export_csv(data, fname)


        elif choice == "11":

            fname = input("  CSV file path: ").strip()

            import_csv(data, fname)


        elif choice == "12":

            show_stats(data)


        elif choice == "13":

            backup_contacts(data)


        elif choice == "0":

            print("\n  Goodbye!\n")

            break


        else:

            print("  Invalid choice.")



# ============================================================

# RUN

# ============================================================


if __name__ == "__main__":

    main()

Pomodoro Timer

import os

import json

import time

import threading

from datetime import datetime, date, timedelta

from pathlib import Path

from collections import defaultdict


try:

    import matplotlib.pyplot as plt

    MATPLOTLIB_OK = True

except ImportError:

    MATPLOTLIB_OK = False


try:

    import winsound

    SOUND_ENGINE = "winsound"

except ImportError:

    try:

        import subprocess

        SOUND_ENGINE = "subprocess"

    except:

        SOUND_ENGINE = None


# ============================================================

# CONFIGURATION

# ============================================================


DATA_FILE = "pomodoro_data.json"


DEFAULT_CONFIG = {

    "work_minutes":       25,

    "short_break":        5,

    "long_break":         15,

    "sessions_before_long": 4,

    "auto_start_break":   False,

    "sound_enabled":      True,

    "daily_goal":         8,       # pomodoros per day

}


# ============================================================

# HELPERS

# ============================================================


def clear():

    os.system("cls" if os.name == "nt" else "clear")



def fmt_time(seconds):

    m = seconds // 60

    s = seconds % 60

    return f"{m:02d}:{s:02d}"



def draw_bar(value, max_val, width=30, fill="█", empty="░"):

    if max_val == 0:

        return ""

    filled = int((min(value, max_val) / max_val) * width)

    return fill * filled + empty * (width - filled)



def beep(times=1):

    """Play a beep sound — cross platform."""

    for _ in range(times):

        if SOUND_ENGINE == "winsound":

            try:

                winsound.Beep(1000, 400)

            except:

                print("\a")

        elif SOUND_ENGINE == "subprocess":

            try:

                subprocess.run(["paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga"],

                               capture_output=True)

            except:

                print("\a")

        else:

            print("\a")

        time.sleep(0.2)



# ============================================================

# LOAD & SAVE DATA

# ============================================================


def load_data():

    if Path(DATA_FILE).exists():

        try:

            with open(DATA_FILE, "r") as f:

                return json.load(f)

        except:

            pass

    return {

        "config":   DEFAULT_CONFIG.copy(),

        "sessions": [],

        "tasks":    []

    }



def save_data(data):

    with open(DATA_FILE, "w") as f:

        json.dump(data, f, indent=2)



# ============================================================

# TIMER ENGINE

# ============================================================


class PomodoroTimer:

    def __init__(self, data):

        self.data          = data

        self.config        = data.get("config", DEFAULT_CONFIG.copy())

        self.running       = False

        self.paused        = False

        self.current_type  = "work"      # work / short_break / long_break

        self.seconds_left  = 0

        self.session_count = 0           # completed work sessions today

        self.total_today   = self._count_today()

        self.current_task  = ""

        self._thread       = None

        self._pause_event  = threading.Event()

        self._pause_event.set()


    def _count_today(self):

        today = str(date.today())

        return sum(1 for s in self.data["sessions"]

                   if s.get("date") == today and s.get("type") == "work")


    def _duration(self, session_type):

        if session_type == "work":

            return self.config["work_minutes"] * 60

        elif session_type == "short_break":

            return self.config["short_break"] * 60

        else:

            return self.config["long_break"] * 60


    def start(self, session_type="work", task=""):

        if self.running:

            print("  Timer already running.")

            return


        self.current_type = session_type

        self.seconds_left = self._duration(session_type)

        self.current_task = task

        self.running      = True

        self.paused       = False

        self._pause_event.set()


        self._thread = threading.Thread(

            target=self._countdown, daemon=True

        )

        self._thread.start()


    def _countdown(self):

        start_time = datetime.now()


        while self.seconds_left > 0 and self.running:

            self._pause_event.wait()   # blocks if paused

            if not self.running:

                break

            self._display_timer()

            time.sleep(1)

            if not self.paused:

                self.seconds_left -= 1


        if self.running and self.seconds_left <= 0:

            self._on_complete(start_time)


        self.running = False


    def _display_timer(self):

        clear()

        icons = {"work": "WORK", "short_break": "SHORT BREAK", "long_break": "LONG BREAK"}

        total = self._duration(self.current_type)

        elapsed = total - self.seconds_left

        pct = elapsed / total * 100 if total else 0

        bar = draw_bar(elapsed, total, width=35)


        print("\n" + "="*55)

        print(f"   POMODORO TIMER  —  {icons[self.current_type]}")

        print("="*55)

        print(f"\n   {fmt_time(self.seconds_left)}  remaining")

        print(f"\n   {bar}  {pct:.0f}%\n")


        if self.current_task:

            print(f"   Task: {self.current_task}")


        print(f"\n   Sessions today : {self.total_today + (1 if self.current_type == 'work' else 0)}"

              f" / {self.config['daily_goal']} goal")

        print(f"   Session #      : {self.session_count + 1}")


        status = "PAUSED" if self.paused else "RUNNING"

        print(f"\n   [{status}]  Ctrl+C or menu to stop")

        print("="*55)


    def _on_complete(self, start_time):

        end_time = datetime.now()


        if self.config.get("sound_enabled"):

            beep(3 if self.current_type == "work" else 1)


        # Log session

        session = {

            "type":       self.current_type,

            "date":       str(date.today()),

            "start":      start_time.strftime("%H:%M:%S"),

            "end":        end_time.strftime("%H:%M:%S"),

            "duration":   self._duration(self.current_type) // 60,

            "task":       self.current_task,

            "completed":  True,

        }

        self.data["sessions"].append(session)

        save_data(self.data)


        if self.current_type == "work":

            self.session_count += 1

            self.total_today   += 1


        clear()

        print("\n" + "="*55)

        icon = "WORK SESSION COMPLETE!" if self.current_type == "work" else "BREAK OVER!"

        print(f"   {icon}")

        print("="*55)


        if self.current_type == "work":

            print(f"\n   Great work! Session {self.session_count} done.")

            if self.total_today >= self.config["daily_goal"]:

                print(f"   DAILY GOAL REACHED! {self.total_today} pomodoros today!")

            # Determine next break

            if self.session_count % self.config["sessions_before_long"] == 0:

                print(f"   Next: LONG BREAK ({self.config['long_break']} min)")

            else:

                print(f"   Next: SHORT BREAK ({self.config['short_break']} min)")

        else:

            print(f"\n   Break done. Ready for the next work session!")


        print()


    def pause(self):

        if not self.running:

            return

        self.paused = True

        self._pause_event.clear()

        print("  Timer paused.")


    def resume(self):

        if not self.running:

            return

        self.paused = False

        self._pause_event.set()

        print("  Timer resumed.")


    def stop(self):

        self.running = False

        self._pause_event.set()   # unblock if paused

        print("  Timer stopped.")


    def status(self):

        if not self.running:

            print("\n  No timer running.")

            return

        state = "PAUSED" if self.paused else "RUNNING"

        icons = {"work": "Work", "short_break": "Short Break",

                 "long_break": "Long Break"}

        print(f"\n  [{state}] {icons[self.current_type]}: "

              f"{fmt_time(self.seconds_left)} remaining")



# ============================================================

# STATS & REPORTS

# ============================================================


def stats_today(data):

    today    = str(date.today())

    sessions = [s for s in data["sessions"] if s.get("date") == today]

    work     = [s for s in sessions if s.get("type") == "work"]

    breaks   = [s for s in sessions if s.get("type") != "work"]

    goal     = data["config"].get("daily_goal", 8)


    print("\n" + "="*55)

    print(f"  TODAY'S STATS  —  {today}")

    print("="*55)

    print(f"  Pomodoros    : {len(work)} / {goal}")

    bar = draw_bar(len(work), goal)

    print(f"  Progress     : {bar}")

    print(f"  Work time    : {len(work) * data['config']['work_minutes']} min")

    print(f"  Breaks       : {len(breaks)}")


    if work:

        tasks = [s["task"] for s in work if s.get("task")]

        if tasks:

            print(f"\n  Tasks worked on:")

            for t in set(tasks):

                count = tasks.count(t)

                print(f"    • {t}  ({count} pomodoro{'s' if count > 1 else ''})")


    if len(work) >= goal:

        print(f"\n  DAILY GOAL REACHED!")

    else:

        remaining = goal - len(work)

        print(f"\n  {remaining} more pomodoro(s) to reach daily goal.")


    print("="*55)



def stats_weekly(data):

    sessions = data["sessions"]

    today    = date.today()


    # Last 7 days

    daily = {}

    for i in range(6, -1, -1):

        d = str(today - timedelta(days=i))

        daily[d] = 0


    for s in sessions:

        d = s.get("date", "")

        if d in daily and s.get("type") == "work":

            daily[d] += 1


    max_count = max(daily.values()) if daily else 1

    goal = data["config"].get("daily_goal", 8)


    print("\n" + "="*55)

    print("  WEEKLY STATS  (Last 7 Days)")

    print("="*55)


    total_week = 0

    for d, count in daily.items():

        try:

            label = datetime.strptime(d, "%Y-%m-%d").strftime("%a %d %b")

        except:

            label = d

        bar   = draw_bar(count, max(max_count, goal), width=25)

        goal_marker = " GOAL!" if count >= goal else ""

        print(f"  {label:<12} {count:>3} {bar}{goal_marker}")

        total_week += count


    avg = total_week / 7

    print("="*55)

    print(f"  Total this week : {total_week} pomodoros")

    print(f"  Daily average   : {avg:.1f}")

    print(f"  Daily goal      : {goal}")

    print("="*55)



def stats_all_time(data):

    sessions = data["sessions"]

    work     = [s for s in sessions if s.get("type") == "work"]


    if not work:

        print("\n  No sessions recorded yet.")

        return


    total_pomodoros = len(work)

    total_minutes   = sum(s.get("duration", 25) for s in work)

    total_hours     = total_minutes / 60


    # Most productive day

    day_counts = defaultdict(int)

    for s in work:

        day_counts[s.get("date", "")] += 1


    best_day   = max(day_counts, key=day_counts.get)

    best_count = day_counts[best_day]


    # Most worked task

    task_counts = defaultdict(int)

    for s in work:

        t = s.get("task", "")

        if t:

            task_counts[t] += 1


    print("\n" + "="*55)

    print("  ALL-TIME STATS")

    print("="*55)

    print(f"  Total pomodoros : {total_pomodoros:,}")

    print(f"  Total work time : {total_hours:.1f} hours  ({total_minutes:,} min)")

    print(f"  Days tracked    : {len(day_counts)}")

    print(f"  Avg per day     : {total_pomodoros / max(len(day_counts), 1):.1f}")

    print(f"  Best day        : {best_day}  ({best_count} pomodoros)")


    if task_counts:

        top_task  = max(task_counts, key=task_counts.get)

        print(f"  Most worked on  : {top_task}  ({task_counts[top_task]} sessions)")


    print("="*55)



def recent_sessions(data, n=10):

    sessions = data["sessions"]

    if not sessions:

        print("\n  No sessions yet.")

        return


    print("\n" + "="*65)

    print(f"  RECENT SESSIONS  (last {n})")

    print("="*65)

    print(f"  {'DATE':<12} {'TYPE':<14} {'START':<10} {'DUR':>5}  TASK")

    print("  " + "-"*60)


    for s in reversed(sessions[-n:]):

        stype = s.get("type", "").replace("_", " ").upper()

        dur   = f"{s.get('duration', '?')}m"

        task  = s.get("task", "")[:25]

        print(f"  {s.get('date',''):<12} {stype:<14} "

              f"{s.get('start',''):<10} {dur:>5}  {task}")


    print("="*65)



# ============================================================

# MATPLOTLIB CHART

# ============================================================


def plot_weekly_chart(data):

    if not MATPLOTLIB_OK:

        print("\n  matplotlib not installed.")

        print("  Install: pip install matplotlib")

        return


    sessions = data["sessions"]

    today    = date.today()

    daily    = {}


    for i in range(13, -1, -1):

        d = str(today - timedelta(days=i))

        daily[d] = 0


    for s in sessions:

        d = s.get("date", "")

        if d in daily and s.get("type") == "work":

            daily[d] += 1


    labels = []

    values = list(daily.values())

    for d in daily.keys():

        try:

            labels.append(datetime.strptime(d, "%Y-%m-%d").strftime("%d %b"))

        except:

            labels.append(d)


    goal   = data["config"].get("daily_goal", 8)

    colors = ["#2ecc71" if v >= goal else "#3498db" for v in values]


    fig, ax = plt.subplots(figsize=(13, 5))

    bars = ax.bar(labels, values, color=colors, alpha=0.85, width=0.6)


    # Goal line

    ax.axhline(y=goal, color="#e74c3c", linestyle="--",

               linewidth=1.5, label=f"Daily goal ({goal})")


    # Value labels on bars

    for bar, val in zip(bars, values):

        if val > 0:

            ax.text(bar.get_x() + bar.get_width() / 2,

                    bar.get_height() + 0.1,

                    str(val), ha="center", va="bottom", fontsize=9)


    ax.set_title("Pomodoro Sessions — Last 14 Days",

                 fontsize=13, fontweight="bold")

    ax.set_ylabel("Pomodoros")

    ax.set_xlabel("Date")

    ax.legend()

    ax.grid(axis="y", alpha=0.3)

    plt.xticks(rotation=45, ha="right")

    plt.tight_layout()


    from io import BytesIO

    buf = BytesIO()

    plt.savefig("pomodoro_chart.png", dpi=120, bbox_inches="tight")

    print("\n  Chart saved: pomodoro_chart.png")

    plt.show()



# ============================================================

# SETTINGS

# ============================================================


def edit_settings(data):

    cfg = data["config"]

    print("\n" + "="*50)

    print("  SETTINGS")

    print("="*50)

    print(f"  1. Work duration    : {cfg['work_minutes']} min")

    print(f"  2. Short break      : {cfg['short_break']} min")

    print(f"  3. Long break       : {cfg['long_break']} min")

    print(f"  4. Sessions before long break: {cfg['sessions_before_long']}")

    print(f"  5. Daily goal       : {cfg['daily_goal']} pomodoros")

    print(f"  6. Sound enabled    : {cfg['sound_enabled']}")

    print(f"  7. Save & return")

    print("="*50)


    while True:

        sub = input("\n  Edit (1-7): ").strip()

        if sub == "1":

            v = input(f"  Work minutes [{cfg['work_minutes']}]: ").strip()

            if v.isdigit(): cfg["work_minutes"] = int(v)

        elif sub == "2":

            v = input(f"  Short break [{cfg['short_break']}]: ").strip()

            if v.isdigit(): cfg["short_break"] = int(v)

        elif sub == "3":

            v = input(f"  Long break [{cfg['long_break']}]: ").strip()

            if v.isdigit(): cfg["long_break"] = int(v)

        elif sub == "4":

            v = input(f"  Sessions before long [{cfg['sessions_before_long']}]: ").strip()

            if v.isdigit(): cfg["sessions_before_long"] = int(v)

        elif sub == "5":

            v = input(f"  Daily goal [{cfg['daily_goal']}]: ").strip()

            if v.isdigit(): cfg["daily_goal"] = int(v)

        elif sub == "6":

            cfg["sound_enabled"] = not cfg["sound_enabled"]

            print(f"  Sound: {cfg['sound_enabled']}")

        elif sub == "7":

            data["config"] = cfg

            save_data(data)

            print("  Settings saved.")

            break

        else:

            print("  Invalid option.")



# ============================================================

# MAIN MENU

# ============================================================


def print_menu(timer, data):

    cfg    = data["config"]

    today  = str(date.today())

    today_count = sum(1 for s in data["sessions"]

                      if s.get("date") == today and s.get("type") == "work")

    goal   = cfg["daily_goal"]

    status = "RUNNING" if timer.running else "IDLE"


    print("\n" + "-"*50)

    print(f"  POMODORO TIMER  [{status}]  "

          f"Today: {today_count}/{goal}")

    print("-"*50)

    print(f"  Work: {cfg['work_minutes']}m  |  "

          f"Short: {cfg['short_break']}m  |  "

          f"Long: {cfg['long_break']}m")

    print("-"*50)

    print("  1. Start WORK session")

    print("  2. Start SHORT BREAK")

    print("  3. Start LONG BREAK")

    print("  4. Pause / Resume timer")

    print("  5. Stop timer")

    print("  6. Today's stats")

    print("  7. Weekly stats")

    print("  8. All-time stats")

    print("  9. Recent sessions")

    print("  10. Plot chart (14-day)")

    print("  11. Settings")

    print("  0. Exit")

    print("-"*50)



def main():

    print("\n" + "="*55)

    print("     POMODORO TIMER WITH STATS")

    print("="*55)

    print("\n  The Pomodoro Technique:")

    print("  Work 25 min → Short break 5 min → Repeat x4 → Long break 15 min")


    data  = load_data()

    timer = PomodoroTimer(data)


    today = str(date.today())

    today_count = sum(1 for s in data["sessions"]

                      if s.get("date") == today and s.get("type") == "work")

    print(f"\n  Today so far: {today_count} pomodoro(s)")


    while True:

        print_menu(timer, data)

        choice = input("  > ").strip()


        if choice == "1":

            task = input("  What are you working on? (Enter to skip): ").strip()

            if timer.running:

                print("  Stop current timer first (option 5).")

            else:

                timer.start("work", task)

                print(f"\n  Work session started! ({data['config']['work_minutes']} min)")

                print("  Return to menu anytime — timer runs in background.")


        elif choice == "2":

            if timer.running:

                print("  Stop current timer first.")

            else:

                timer.start("short_break")

                print(f"\n  Short break started! ({data['config']['short_break']} min)")


        elif choice == "3":

            if timer.running:

                print("  Stop current timer first.")

            else:

                timer.start("long_break")

                print(f"\n  Long break started! ({data['config']['long_break']} min)")


        elif choice == "4":

            if timer.paused:

                timer.resume()

            else:

                timer.pause()


        elif choice == "5":

            timer.stop()


        elif choice == "6":

            stats_today(data)


        elif choice == "7":

            stats_weekly(data)


        elif choice == "8":

            stats_all_time(data)


        elif choice == "9":

            n = input("  Show last N sessions (default 10): ").strip()

            n = int(n) if n.isdigit() else 10

            recent_sessions(data, n)


        elif choice == "10":

            plot_weekly_chart(data)


        elif choice == "11":

            edit_settings(data)

            timer.config = data["config"]


        elif choice == "0":

            timer.stop()

            print("\n  Goodbye! Keep up the great work!\n")

            break


        else:

            print("  Invalid choice.")



# ============================================================

# RUN

# ============================================================


if __name__ == "__main__":

    main()

File Encryption / Decryption Tool

 import os

import json

import hashlib

import base64

import secrets

import getpass

from datetime import datetime

from pathlib import Path


try:

    from cryptography.fernet import Fernet, InvalidToken

    from cryptography.hazmat.primitives import hashes

    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

    CRYPTO_OK = True

except ImportError:

    CRYPTO_OK = False


# ============================================================

# CONFIGURATION

# ============================================================


KEY_FILE      = "encryption.key"

VAULT_LOG     = "encryption_vault.json"

ENC_EXTENSION = ".encrypted"

SALT_SIZE     = 16       # bytes

ITERATIONS    = 480000   # PBKDF2 iterations (NIST recommended)


# ============================================================

# HELPERS

# ============================================================


def format_size(size_bytes):

    for unit in ["B", "KB", "MB", "GB"]:

        if size_bytes < 1024:

            return f"{size_bytes:.1f} {unit}"

        size_bytes /= 1024

    return f"{size_bytes:.1f} GB"



def file_checksum(filepath):

    """Return MD5 checksum of a file."""

    h = hashlib.md5()

    with open(filepath, "rb") as f:

        while chunk := f.read(8192):

            h.update(chunk)

    return h.hexdigest()



# ============================================================

# KEY MANAGEMENT

# ============================================================


def generate_key():

    """Generate a new random Fernet key."""

    return Fernet.generate_key()



def derive_key_from_password(password: str, salt: bytes = None):

    """

    Derive a Fernet-compatible key from a password using PBKDF2-HMAC-SHA256.

    Returns (key, salt).

    """

    if salt is None:

        salt = secrets.token_bytes(SALT_SIZE)


    kdf = PBKDF2HMAC(

        algorithm=hashes.SHA256(),

        length=32,

        salt=salt,

        iterations=ITERATIONS,

    )

    key = base64.urlsafe_b64encode(kdf.derive(password.encode()))

    return key, salt



def save_key(key: bytes, filepath=KEY_FILE):

    """Save a key to a file."""

    with open(filepath, "wb") as f:

        f.write(key)

    print(f"  Key saved: {filepath}")

    print("  IMPORTANT: Keep this key file safe. Without it, decryption is impossible!")



def load_key(filepath=KEY_FILE):

    """Load a key from file."""

    if not Path(filepath).exists():

        print(f"  Key file not found: {filepath}")

        return None

    with open(filepath, "rb") as f:

        return f.read().strip()



# ============================================================

# VAULT LOG

# ============================================================


def load_vault():

    if Path(VAULT_LOG).exists():

        try:

            with open(VAULT_LOG, "r") as f:

                return json.load(f)

        except:

            pass

    return {"files": []}



def save_vault(vault):

    with open(VAULT_LOG, "w") as f:

        json.dump(vault, f, indent=2)



def log_operation(vault, operation, filepath, output_path, method):

    vault["files"].append({

        "operation":   operation,

        "source":      str(filepath),

        "output":      str(output_path),

        "method":      method,

        "timestamp":   datetime.now().strftime("%d-%m-%Y %H:%M:%S"),

        "size":        format_size(Path(output_path).stat().st_size)

                       if Path(output_path).exists() else "?"

    })

    vault["files"] = vault["files"][-100:]

    save_vault(vault)



# ============================================================

# ENCRYPT FILE

# ============================================================


def encrypt_file(filepath, key: bytes, output_path=None,

                 salt: bytes = None):

    """

    Encrypt a file using Fernet.

    If salt is provided, it's prepended to the encrypted output

    (used for password-based encryption).

    Returns output path or None on failure.

    """

    filepath = Path(filepath)

    if not filepath.exists():

        print(f"  File not found: {filepath}")

        return None


    if output_path is None:

        output_path = filepath.with_suffix(filepath.suffix + ENC_EXTENSION)

    output_path = Path(output_path)


    try:

        fernet = Fernet(key)


        with open(filepath, "rb") as f:

            data = f.read()


        encrypted = fernet.encrypt(data)


        with open(output_path, "wb") as f:

            if salt:

                # Write salt length (2 bytes) + salt + encrypted data

                f.write(len(salt).to_bytes(2, "big"))

                f.write(salt)

            f.write(encrypted)


        orig_size = format_size(filepath.stat().st_size)

        enc_size  = format_size(output_path.stat().st_size)

        checksum  = file_checksum(filepath)


        print(f"\n  Encrypted successfully!")

        print(f"  Input  : {filepath.name}  ({orig_size})")

        print(f"  Output : {output_path.name}  ({enc_size})")

        print(f"  Checksum (original): {checksum}")

        return output_path


    except Exception as e:

        print(f"  Encryption failed: {e}")

        return None



# ============================================================

# DECRYPT FILE

# ============================================================


def decrypt_file(filepath, key: bytes, output_path=None,

                 password_based=False):

    """

    Decrypt a Fernet-encrypted file.

    If password_based=True, reads the salt from the file header.

    Returns output path or None on failure.

    """

    filepath = Path(filepath)

    if not filepath.exists():

        print(f"  File not found: {filepath}")

        return None


    # Determine output path

    if output_path is None:

        name = filepath.name

        if name.endswith(ENC_EXTENSION):

            out_name = name[:-len(ENC_EXTENSION)]

        else:

            out_name = name + ".decrypted"

        output_path = filepath.parent / out_name

    output_path = Path(output_path)


    try:

        with open(filepath, "rb") as f:

            raw = f.read()


        if password_based:

            # Read salt from header

            salt_len  = int.from_bytes(raw[:2], "big")

            salt      = raw[2:2 + salt_len]

            encrypted = raw[2 + salt_len:]


            # Re-derive key from password

            password = getpass.getpass("  Password: ")

            key, _   = derive_key_from_password(password, salt)

        else:

            encrypted = raw


        fernet    = Fernet(key)

        decrypted = fernet.decrypt(encrypted)


        with open(output_path, "wb") as f:

            f.write(decrypted)


        orig_size = format_size(filepath.stat().st_size)

        dec_size  = format_size(output_path.stat().st_size)

        checksum  = file_checksum(output_path)


        print(f"\n  Decrypted successfully!")

        print(f"  Input  : {filepath.name}  ({orig_size})")

        print(f"  Output : {output_path.name}  ({dec_size})")

        print(f"  Checksum (restored): {checksum}")

        return output_path


    except InvalidToken:

        print("  Decryption failed: Wrong key or password, or file is corrupted.")

        return None

    except Exception as e:

        print(f"  Decryption failed: {e}")

        return None



# ============================================================

# ENCRYPT / DECRYPT FOLDER

# ============================================================


def process_folder(folder_path, key, operation,

                   extensions=None, password_based=False, salt=None):

    folder = Path(folder_path)

    if not folder.is_dir():

        print("  Invalid folder.")

        return


    if operation == "encrypt":

        if extensions:

            files = [f for f in folder.rglob("*")

                     if f.is_file() and f.suffix.lower() in extensions

                     and not f.name.endswith(ENC_EXTENSION)]

        else:

            files = [f for f in folder.rglob("*")

                     if f.is_file() and not f.name.endswith(ENC_EXTENSION)]

    else:

        files = [f for f in folder.rglob("*")

                 if f.is_file() and f.name.endswith(ENC_EXTENSION)]


    if not files:

        print(f"  No files found to {operation}.")

        return


    print(f"\n  Found {len(files)} file(s) to {operation}.")

    confirm = input(f"  Proceed? (y/n): ").strip().lower()

    if confirm != "y":

        print("  Cancelled.")

        return


    success = 0

    failed  = 0

    vault   = load_vault()


    for f in files:

        print(f"\n  [{operation.upper()}] {f.name}")

        if operation == "encrypt":

            result = encrypt_file(f, key, salt=salt)

        else:

            result = decrypt_file(f, key, password_based=password_based)


        if result:

            success += 1

            log_operation(vault, operation, f, result,

                          "password" if password_based else "key-file")

        else:

            failed += 1


    save_vault(vault)

    print(f"\n  Done: {success} succeeded, {failed} failed.")



# ============================================================

# ENCRYPT TEXT STRING

# ============================================================


def encrypt_text(key: bytes):

    print("\n  Enter text to encrypt (type END on new line to finish):")

    lines = []

    while True:

        line = input()

        if line.strip().upper() == "END":

            break

        lines.append(line)

    text = "\n".join(lines)


    fernet    = Fernet(key)

    encrypted = fernet.encrypt(text.encode())

    encoded   = encrypted.decode()


    print(f"\n  Encrypted text (copy this):")

    print(f"  {encoded}")


    save = input("\n  Save to file? (y/n): ").strip().lower()

    if save == "y":

        fname = f"encrypted_text_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"

        with open(fname, "w") as f:

            f.write(encoded)

        print(f"  Saved: {fname}")



def decrypt_text(key: bytes):

    print("\n  Paste encrypted text:")

    enc_text = input("  > ").strip()


    try:

        fernet    = Fernet(key)

        decrypted = fernet.decrypt(enc_text.encode()).decode()

        print(f"\n  Decrypted text:")

        print(f"  {decrypted}")

    except InvalidToken:

        print("  Decryption failed: Invalid token or wrong key.")

    except Exception as e:

        print(f"  Error: {e}")



# ============================================================

# DISPLAY VAULT LOG

# ============================================================


def display_vault():

    vault = load_vault()

    files = vault.get("files", [])


    if not files:

        print("\n  No operations logged yet.")

        return


    print("\n" + "="*65)

    print(f"  ENCRYPTION VAULT LOG  ({len(files)} entries)")

    print("="*65)

    print(f"  {'OP':<10} {'TIMESTAMP':<22} {'METHOD':<10} FILE")

    print("  " + "-"*60)


    for entry in reversed(files[-20:]):

        op     = entry["operation"].upper()

        ts     = entry["timestamp"]

        method = entry["method"]

        fname  = Path(entry["output"]).name

        print(f"  {op:<10} {ts:<22} {method:<10} {fname}")


    print("="*65)



# ============================================================

# SETUP: GET OR CREATE KEY

# ============================================================


def setup_key_menu():

    print("\n  KEY SETUP")

    print("  1. Generate new random key (save to file)")

    print("  2. Use existing key file")

    print("  3. Use password (derive key)")


    choice = input("\n  Choice (1/2/3): ").strip()


    if choice == "1":

        key = generate_key()

        fname = input(f"  Save key as [{KEY_FILE}]: ").strip() or KEY_FILE

        save_key(key, fname)

        return key, False, None


    elif choice == "2":

        fname = input(f"  Key file path [{KEY_FILE}]: ").strip() or KEY_FILE

        key = load_key(fname)

        if key:

            print(f"  Key loaded from: {fname}")

            return key, False, None

        return None, False, None


    elif choice == "3":

        password = getpass.getpass("  Enter password: ")

        confirm  = getpass.getpass("  Confirm password: ")

        if password != confirm:

            print("  Passwords do not match.")

            return None, False, None

        key, salt = derive_key_from_password(password)

        print("  Key derived from password successfully.")

        print(f"  Strength tip: Use 12+ chars with mixed letters, numbers, symbols.")

        return key, True, salt


    return None, False, None



# ============================================================

# MAIN MENU

# ============================================================


def print_menu(key_loaded, method):

    status = f"Key loaded ({method})" if key_loaded else "No key loaded"

    print("\n" + "-"*50)

    print(f"  FILE ENCRYPTION TOOL  [{status}]")

    print("-"*50)

    print("  1. Setup key (generate / load / password)")

    print("  2. Encrypt a file")

    print("  3. Decrypt a file")

    print("  4. Encrypt all files in folder")

    print("  5. Decrypt all .encrypted files in folder")

    print("  6. Encrypt text string")

    print("  7. Decrypt text string")

    print("  8. View vault log")

    print("  0. Exit")

    print("-"*50)



def main():

    print("\n" + "="*55)

    print("     FILE ENCRYPTION / DECRYPTION TOOL")

    print("="*55)


    if not CRYPTO_OK:

        print("\n  cryptography library not installed!")

        print("  Install it:  pip install cryptography")

        return


    print("\n  Uses Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256)")

    print("  Supports: key file OR password-based encryption\n")


    current_key      = None

    password_based   = False

    current_salt     = None

    method_str       = ""


    # Auto-load key if exists

    if Path(KEY_FILE).exists():

        current_key = load_key(KEY_FILE)

        method_str  = "key-file"

        print(f"  Auto-loaded key from: {KEY_FILE}")


    vault = load_vault()


    while True:

        print_menu(current_key is not None, method_str)

        choice = input("  > ").strip()


        if choice == "1":

            current_key, password_based, current_salt = setup_key_menu()

            if current_key:

                method_str = "password" if password_based else "key-file"


        elif choice in ["2", "3", "4", "5", "6", "7"]:

            if not current_key:

                print("\n  No key loaded. Please set up a key first (Option 1).")

                continue


            if choice == "2":

                path = input("\n  File to encrypt: ").strip()

                out  = input("  Output path (Enter = auto): ").strip() or None

                result = encrypt_file(path, current_key, out,

                                      salt=current_salt)

                if result:

                    log_operation(vault, "encrypt", path, result, method_str)

                    save_vault(vault)


            elif choice == "3":

                path = input("\n  File to decrypt: ").strip()

                out  = input("  Output path (Enter = auto): ").strip() or None

                result = decrypt_file(path, current_key, out,

                                      password_based=password_based)

                if result:

                    log_operation(vault, "decrypt", path, result, method_str)

                    save_vault(vault)


            elif choice == "4":

                folder = input("\n  Folder path: ").strip()

                ext_in = input("  File types to encrypt (e.g. .txt .pdf, Enter=all): ").strip()

                exts   = [e if e.startswith(".") else f".{e}"

                          for e in ext_in.split()] if ext_in else None

                process_folder(folder, current_key, "encrypt",

                               extensions=exts,

                               salt=current_salt)


            elif choice == "5":

                folder = input("\n  Folder path: ").strip()

                process_folder(folder, current_key, "decrypt",

                               password_based=password_based)


            elif choice == "6":

                encrypt_text(current_key)


            elif choice == "7":

                decrypt_text(current_key)


        elif choice == "8":

            display_vault()


        elif choice == "0":

            print("\n  Goodbye!\n")

            break


        else:

            print("  Invalid choice.")



# ============================================================

# RUN

# ============================================================


if __name__ == "__main__":

    main()

Log File Analyzer

import re

import os

import json

from datetime import datetime

from pathlib import Path

from collections import defaultdict, Counter


# ============================================================

# CONFIGURATION

# ============================================================


REPORT_FILE = "log_analysis_report.json"


# Common log level patterns

LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "WARN", "INFO", "DEBUG", "TRACE", "FATAL"]


# Built-in format patterns

LOG_FORMATS = {

    "apache_access": r'(?P<ip>\S+) \S+ \S+ \[(?P<datetime>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+)[^"]*" (?P<status>\d{3}) (?P<size>\S+)',

    "apache_error":  r'\[(?P<datetime>[^\]]+)\] \[(?P<level>\w+)\] (?P<message>.*)',

    "nginx_access":  r'(?P<ip>\S+) - \S+ \[(?P<datetime>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+)[^"]*" (?P<status>\d{3}) (?P<size>\d+)',

    "python_log":    r'(?P<datetime>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ (?P<level>\w+) (?P<logger>\S+) (?P<message>.*)',

    "syslog":        r'(?P<datetime>\w{3}\s+\d+ \d{2}:\d{2}:\d{2}) (?P<host>\S+) (?P<process>\S+): (?P<message>.*)',

    "generic":       r'(?P<datetime>\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}).*?(?P<level>CRITICAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE|FATAL).*?(?P<message>.*)',

}


# ============================================================

# HELPERS

# ============================================================


def draw_bar(value, max_val, width=25, fill="█", empty="░"):

    if max_val == 0:

        return ""

    filled = int((min(value, max_val) / max_val) * width)

    return fill * filled + empty * (width - filled)



def format_size(size_bytes):

    for unit in ["B", "KB", "MB", "GB"]:

        if size_bytes < 1024:

            return f"{size_bytes:.1f} {unit}"

        size_bytes /= 1024

    return f"{size_bytes:.1f} GB"



def safe_read(filepath, encoding="utf-8"):

    try:

        with open(filepath, "r", encoding=encoding, errors="replace") as f:

            return f.readlines()

    except Exception as e:

        print(f"  Error reading file: {e}")

        return []



# ============================================================

# AUTO-DETECT LOG FORMAT

# ============================================================


def detect_format(lines):

    """Try each format pattern on first 20 lines. Return best match."""

    sample = lines[:20]

    scores = {}

    for fmt_name, pattern in LOG_FORMATS.items():

        matches = sum(1 for line in sample if re.search(pattern, line))

        scores[fmt_name] = matches


    best = max(scores, key=scores.get)

    if scores[best] > 0:

        return best, scores[best]

    return "generic", 0



# ============================================================

# CORE PARSER

# ============================================================


def parse_log(filepath, fmt_name=None, keyword_filter=None,

              level_filter=None, max_lines=0):

    """

    Parse a log file and return structured data.

    Returns dict with entries, stats, errors.

    """

    lines = safe_read(filepath)

    if not lines:

        return None


    total_lines = len(lines)

    print(f"\n  File    : {Path(filepath).name}")

    print(f"  Size    : {format_size(Path(filepath).stat().st_size)}")

    print(f"  Lines   : {total_lines:,}")


    # Auto-detect format

    if not fmt_name or fmt_name == "auto":

        fmt_name, score = detect_format(lines)

        print(f"  Format  : {fmt_name} (auto-detected, {score}/20 lines matched)")

    else:

        print(f"  Format  : {fmt_name}")


    pattern = LOG_FORMATS.get(fmt_name, LOG_FORMATS["generic"])


    # Limit lines if requested

    if max_lines > 0:

        lines = lines[:max_lines]

        print(f"  Parsing : first {max_lines:,} lines")


    # Parse each line

    entries       = []

    level_counts  = Counter()

    hour_counts   = Counter()

    ip_counts     = Counter()

    status_counts = Counter()

    path_counts   = Counter()

    message_list  = []

    parse_errors  = 0

    keyword_hits  = []


    for i, line in enumerate(lines):

        line = line.rstrip()

        if not line:

            continue


        match = re.search(pattern, line, re.IGNORECASE)


        if match:

            entry = match.groupdict()


            # Normalize level

            level = entry.get("level", "").upper()

            if not level:

                # Try to find level anywhere in line

                for lvl in LOG_LEVELS:

                    if lvl in line.upper():

                        level = lvl

                        break

            entry["level"] = level or "UNKNOWN"

            level_counts[entry["level"]] += 1


            # Extract hour from datetime

            dt_str = entry.get("datetime", "")

            for fmt in ["%Y-%m-%d %H:%M:%S", "%d/%b/%Y:%H:%M:%S",

                        "%b %d %H:%M:%S", "%Y/%m/%d %H:%M:%S"]:

                try:

                    dt = datetime.strptime(dt_str[:len(fmt)], fmt)

                    hour_counts[dt.strftime("%H:00")] += 1

                    entry["datetime_parsed"] = dt.strftime("%d-%m-%Y %H:%M:%S")

                    break

                except:

                    continue


            # Track IPs, status codes, paths (for web logs)

            if "ip" in entry and entry["ip"]:

                ip_counts[entry["ip"]] += 1

            if "status" in entry and entry["status"]:

                status_counts[entry["status"]] += 1

            if "path" in entry and entry["path"]:

                path_counts[entry["path"]] += 1


            # Track messages

            msg = entry.get("message", line[:120])

            message_list.append(msg)


            # Apply filters

            if level_filter and entry["level"] not in [l.upper() for l in level_filter]:

                continue

            if keyword_filter and keyword_filter.lower() not in line.lower():

                continue


            entry["line_no"] = i + 1

            entry["raw"]     = line[:200]

            entries.append(entry)


            # Keyword tracking

            if keyword_filter and keyword_filter.lower() in line.lower():

                keyword_hits.append({"line": i + 1, "text": line[:200]})


        else:

            parse_errors += 1


    parse_rate = round((1 - parse_errors / max(len(lines), 1)) * 100, 1)

    print(f"  Parsed  : {len(entries):,} entries  ({parse_rate}% success rate)")


    return {

        "filepath":     str(filepath),

        "filename":     Path(filepath).name,

        "total_lines":  total_lines,

        "parsed":       len(entries),

        "parse_errors": parse_errors,

        "format":       fmt_name,

        "entries":      entries,

        "level_counts": dict(level_counts),

        "hour_counts":  dict(hour_counts),

        "ip_counts":    dict(ip_counts),

        "status_counts":dict(status_counts),

        "path_counts":  dict(path_counts),

        "message_list": message_list,

        "keyword_hits": keyword_hits,

    }



# ============================================================

# DISPLAY: LEVEL SUMMARY

# ============================================================


def display_level_summary(result):

    counts   = result["level_counts"]

    total    = sum(counts.values())

    if not counts:

        print("\n  No log level data found.")

        return


    max_val  = max(counts.values())


    print("\n" + "="*55)

    print(f"  LOG LEVEL SUMMARY  —  {result['filename']}")

    print("="*55)

    print(f"  {'LEVEL':<12} {'COUNT':>8}  {'%':>6}  BAR")

    print("  " + "-"*51)


    order = ["FATAL", "CRITICAL", "ERROR", "WARNING", "WARN",

             "INFO", "DEBUG", "TRACE", "UNKNOWN"]


    for level in order:

        count = counts.get(level, 0)

        if count == 0:

            continue

        pct = count / total * 100

        bar = draw_bar(count, max_val)

        print(f"  {level:<12} {count:>8,}  {pct:>5.1f}%  {bar}")


    print("  " + "-"*51)

    print(f"  {'TOTAL':<12} {total:>8,}")

    print("="*55)


    # Highlight issues

    errors   = counts.get("ERROR", 0) + counts.get("CRITICAL", 0) + counts.get("FATAL", 0)

    warnings = counts.get("WARNING", 0) + counts.get("WARN", 0)


    if errors > 0:

        print(f"\n  *** {errors:,} ERROR/CRITICAL/FATAL entries found!")

    if warnings > 0:

        print(f"  *** {warnings:,} WARNING entries found.")

    if errors == 0 and warnings == 0:

        print("\n  All clear — no errors or warnings detected.")



# ============================================================

# DISPLAY: HOURLY ACTIVITY

# ============================================================


def display_hourly_activity(result):

    counts = result["hour_counts"]

    if not counts:

        print("\n  No timestamp data found for hourly analysis.")

        return


    max_val = max(counts.values())


    print("\n" + "="*55)

    print("  HOURLY ACTIVITY")

    print("="*55)


    for hour in sorted(counts.keys()):

        count = counts[hour]

        bar   = draw_bar(count, max_val, width=30)

        print(f"  {hour}  {count:>6,}  {bar}")


    peak_hour  = max(counts, key=counts.get)

    quiet_hour = min(counts, key=counts.get)

    print("="*55)

    print(f"  Peak hour  : {peak_hour}  ({counts[peak_hour]:,} entries)")

    print(f"  Quiet hour : {quiet_hour}  ({counts[quiet_hour]:,} entries)")



# ============================================================

# DISPLAY: TOP IPs / PATHS / STATUS CODES

# ============================================================


def display_web_stats(result, top_n=10):

    ip_counts     = result.get("ip_counts", {})

    status_counts = result.get("status_counts", {})

    path_counts   = result.get("path_counts", {})


    if not ip_counts and not status_counts and not path_counts:

        print("\n  No web log data (IPs / status codes / paths) found.")

        return


    print("\n" + "="*55)

    print("  WEB LOG STATISTICS")

    print("="*55)


    if ip_counts:

        top_ips = sorted(ip_counts.items(), key=lambda x: x[1], reverse=True)[:top_n]

        max_val = top_ips[0][1]

        print(f"\n  TOP {top_n} IP ADDRESSES:")

        print(f"  {'IP':<18} {'REQUESTS':>10}  BAR")

        print("  " + "-"*45)

        for ip, count in top_ips:

            bar = draw_bar(count, max_val, width=15)

            print(f"  {ip:<18} {count:>10,}  {bar}")


    if status_counts:

        print(f"\n  HTTP STATUS CODES:")

        print(f"  {'CODE':<8} {'COUNT':>10}  MEANING")

        print("  " + "-"*40)

        meanings = {

            "200": "OK", "201": "Created", "301": "Moved Permanently",

            "302": "Found", "304": "Not Modified", "400": "Bad Request",

            "401": "Unauthorized", "403": "Forbidden", "404": "Not Found",

            "500": "Internal Server Error", "502": "Bad Gateway",

            "503": "Service Unavailable"

        }

        for code, count in sorted(status_counts.items()):

            meaning = meanings.get(code, "")

            print(f"  {code:<8} {count:>10,}  {meaning}")


    if path_counts:

        top_paths = sorted(path_counts.items(), key=lambda x: x[1], reverse=True)[:top_n]

        max_val   = top_paths[0][1]

        print(f"\n  TOP {top_n} REQUESTED PATHS:")

        print(f"  {'PATH':<35} {'HITS':>8}  BAR")

        print("  " + "-"*55)

        for path, count in top_paths:

            display_path = path[:33] + ".." if len(path) > 35 else path

            bar = draw_bar(count, max_val, width=10)

            print(f"  {display_path:<35} {count:>8,}  {bar}")


    print("="*55)



# ============================================================

# DISPLAY: RECENT ERRORS

# ============================================================


def display_errors(result, top_n=20):

    entries = result["entries"]

    errors  = [e for e in entries

               if e.get("level") in ("ERROR", "CRITICAL", "FATAL")]


    if not errors:

        print("\n  No ERROR / CRITICAL / FATAL entries found.")

        return


    print("\n" + "="*65)

    print(f"  ERRORS & CRITICAL ENTRIES  ({len(errors):,} total, showing last {top_n})")

    print("="*65)


    for e in errors[-top_n:]:

        dt  = e.get("datetime_parsed") or e.get("datetime", "")[:19]

        msg = e.get("message") or e.get("raw", "")

        msg = msg[:80]

        print(f"\n  [{e['level']}]  Line {e.get('line_no','?')}  {dt}")

        print(f"  {msg}")


    print("="*65)



# ============================================================

# DISPLAY: KEYWORD SEARCH RESULTS

# ============================================================


def display_keyword_hits(result, keyword):

    hits = result.get("keyword_hits", [])

    if not hits:

        print(f"\n  No matches found for: '{keyword}'")

        return


    print("\n" + "="*65)

    print(f"  KEYWORD SEARCH: '{keyword}'  ({len(hits):,} matches)")

    print("="*65)


    for hit in hits[:30]:

        print(f"\n  Line {hit['line']:>6}:  {hit['text'][:100]}")


    if len(hits) > 30:

        print(f"\n  ... and {len(hits) - 30} more matches.")

    print("="*65)



# ============================================================

# DISPLAY: COMMON PATTERNS / REPEATED MESSAGES

# ============================================================


def display_patterns(result, top_n=15):

    messages = result.get("message_list", [])

    if not messages:

        print("\n  No messages extracted.")

        return


    # Normalize messages (strip numbers/IPs for better grouping)

    def normalize(msg):

        msg = re.sub(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', '<IP>', msg)

        msg = re.sub(r'\b\d+\b', '<N>', msg)

        msg = re.sub(r'"[^"]*"', '"<VAL>"', msg)

        return msg[:80].strip()


    normalized = [normalize(m) for m in messages if m.strip()]

    counts     = Counter(normalized)

    top        = counts.most_common(top_n)


    max_val    = top[0][1] if top else 1


    print("\n" + "="*65)

    print(f"  MOST REPEATED LOG PATTERNS  (Top {top_n})")

    print("="*65)

    print(f"  {'COUNT':>7}  MESSAGE PATTERN")

    print("  " + "-"*60)


    for pattern, count in top:

        bar = draw_bar(count, max_val, width=12)

        print(f"  {count:>7,}  {bar}  {pattern}")


    print("="*65)



# ============================================================

# FULL REPORT

# ============================================================


def full_report(result):

    display_level_summary(result)

    display_hourly_activity(result)

    display_web_stats(result)

    display_errors(result)

    display_patterns(result)



# ============================================================

# SAVE REPORT TO JSON

# ============================================================


def save_report(result):

    report = {

        "filename":      result["filename"],

        "analyzed_at":   datetime.now().strftime("%d-%m-%Y %H:%M:%S"),

        "total_lines":   result["total_lines"],

        "parsed":        result["parsed"],

        "format":        result["format"],

        "level_counts":  result["level_counts"],

        "top_ips":       dict(sorted(result["ip_counts"].items(),

                                     key=lambda x: x[1], reverse=True)[:20]),

        "status_codes":  result["status_counts"],

        "top_paths":     dict(sorted(result["path_counts"].items(),

                                     key=lambda x: x[1], reverse=True)[:20]),

        "hourly":        result["hour_counts"],

    }


    with open(REPORT_FILE, "w") as f:

        json.dump(report, f, indent=2)

    print(f"\n  Report saved: {REPORT_FILE}")



# ============================================================

# BATCH ANALYZE (FOLDER)

# ============================================================


def batch_analyze(folder):

    folder = Path(folder)

    if not folder.is_dir():

        print("  Invalid folder.")

        return


    log_files = (list(folder.glob("*.log")) +

                 list(folder.glob("*.txt")) +

                 list(folder.glob("*.out")))


    if not log_files:

        print(f"  No .log / .txt / .out files in {folder}")

        return


    print(f"\n  Found {len(log_files)} log file(s).\n")

    summary = []


    for f in log_files:

        result = parse_log(f)

        if result:

            errors   = (result["level_counts"].get("ERROR", 0) +

                        result["level_counts"].get("CRITICAL", 0) +

                        result["level_counts"].get("FATAL", 0))

            warnings = (result["level_counts"].get("WARNING", 0) +

                        result["level_counts"].get("WARN", 0))

            summary.append({

                "file":     f.name,

                "lines":    result["total_lines"],

                "errors":   errors,

                "warnings": warnings,

            })


    print("\n" + "="*60)

    print(f"  BATCH SUMMARY  ({len(summary)} files)")

    print("="*60)

    print(f"  {'FILE':<30} {'LINES':>8} {'ERRORS':>8} {'WARNINGS':>10}")

    print("  " + "-"*56)


    for s in summary:

        print(f"  {s['file']:<30} {s['lines']:>8,} {s['errors']:>8,} "

              f"{s['warnings']:>10,}")


    total_errors   = sum(s["errors"]   for s in summary)

    total_warnings = sum(s["warnings"] for s in summary)

    print("  " + "-"*56)

    print(f"  {'TOTAL':<30} {'':>8} {total_errors:>8,} {total_warnings:>10,}")

    print("="*60)



# ============================================================

# MAIN MENU

# ============================================================


def print_menu():

    print("\n" + "-"*48)

    print("  LOG FILE ANALYZER")

    print("-"*48)

    print("  1. Analyze log file (full report)")

    print("  2. Log level summary")

    print("  3. Hourly activity chart")

    print("  4. Web log stats (IPs / status / paths)")

    print("  5. Show errors & critical entries")

    print("  6. Search keyword in log")

    print("  7. Repeated message patterns")

    print("  8. Batch analyze a folder")

    print("  9. Save report to JSON")

    print("  0. Exit")

    print("-"*48)



def main():

    print("\n" + "="*55)

    print("     LOG FILE ANALYZER")

    print("="*55)

    print("\n  Supports: Apache, Nginx, Python, Syslog, Generic logs")

    print("  Auto-detects format from first 20 lines.\n")


    last_result = None


    while True:

        print_menu()

        choice = input("  > ").strip()


        if choice in ["1","2","3","4","5","6","7","9"]:

            if not last_result or choice == "1":

                path = input("\n  Log file path: ").strip()

                if not path or not Path(path).exists():

                    print("  File not found.")

                    continue


                fmt_choice = input(

                    "  Format (auto/apache_access/apache_error/"

                    "nginx_access/python_log/syslog/generic): "

                ).strip() or "auto"


                kw = None

                if choice == "6":

                    kw = input("  Keyword to search: ").strip()


                lvl_filter = None

                max_lines  = 0


                ml = input("  Max lines to parse (0=all): ").strip()

                max_lines = int(ml) if ml.isdigit() else 0


                last_result = parse_log(path, fmt_choice, kw, lvl_filter, max_lines)


                if not last_result:

                    continue


        if choice == "1":

            full_report(last_result)


        elif choice == "2":

            display_level_summary(last_result)


        elif choice == "3":

            display_hourly_activity(last_result)


        elif choice == "4":

            display_web_stats(last_result)


        elif choice == "5":

            n = input("  Show last N errors (default 20): ").strip()

            n = int(n) if n.isdigit() else 20

            display_errors(last_result, n)


        elif choice == "6":

            kw = input("  Keyword to search: ").strip()

            if kw:

                # Re-parse with keyword

                last_result = parse_log(last_result["filepath"],

                                        last_result["format"],

                                        kw)

                if last_result:

                    display_keyword_hits(last_result, kw)


        elif choice == "7":

            n = input("  Top N patterns (default 15): ").strip()

            n = int(n) if n.isdigit() else 15

            display_patterns(last_result, n)


        elif choice == "8":

            folder = input("\n  Folder path: ").strip()

            batch_analyze(folder)


        elif choice == "9":

            if last_result:

                save_report(last_result)

            else:

                print("  No analysis data. Parse a file first.")


        elif choice == "0":

            print("\n  Goodbye!\n")

            break


        else:

            print("  Invalid choice.")



# ============================================================

# RUN

# ============================================================


if __name__ == "__main__":

    main()