News Headlines Fetcher

import json

import os

import textwrap

import webbrowser

from datetime import datetime, timedelta

from pathlib import Path


try:

    import requests

    REQUESTS_OK = True

except ImportError:

    REQUESTS_OK = False


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

# CONFIGURATION

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


# Get your FREE API key at: https://newsapi.org/register

# Free tier: 100 requests/day, last 30 days of news

API_KEY_FILE  = "newsapi_key.txt"

CACHE_FILE    = "news_cache.json"

SAVED_FILE    = "saved_articles.json"

HISTORY_FILE  = "news_history.json"

CACHE_EXPIRY  = 900      # seconds (15 minutes)


BASE_URL      = "https://newsapi.org/v2/"


CATEGORIES = [

    "general", "business", "technology",

    "science", "health", "sports", "entertainment"

]


COUNTRIES = {

    "us": "United States",

    "in": "India",

    "gb": "United Kingdom",

    "au": "Australia",

    "ca": "Canada",

    "de": "Germany",

    "fr": "France",

    "jp": "Japan",

    "sg": "Singapore",

    "ae": "UAE",

}


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

# API KEY MANAGEMENT

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


def load_api_key():

    if Path(API_KEY_FILE).exists():

        key = Path(API_KEY_FILE).read_text().strip()

        if key:

            return key

    return None



def save_api_key(key):

    Path(API_KEY_FILE).write_text(key.strip())

    print(f"  API key saved to {API_KEY_FILE}")



def setup_api_key():

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

    print("  NEWSAPI KEY SETUP")

    print("="*55)

    print("\n  This program uses NewsAPI (newsapi.org).")

    print("  Free tier: 100 requests/day | No credit card needed.\n")

    print("  Steps:")

    print("  1. Go to: https://newsapi.org/register")

    print("  2. Sign up for free")

    print("  3. Copy your API key")

    print("  4. Paste it below\n")


    key = input("  Paste your API key: ").strip()

    if key:

        save_api_key(key)

        return key

    return None



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

# CACHE MANAGEMENT

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


def load_cache():

    if Path(CACHE_FILE).exists():

        try:

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

                return json.load(f)

        except:

            pass

    return {}



def save_cache(cache):

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

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



def get_cache_key(endpoint, params):

    key_parts = [endpoint] + [f"{k}={v}" for k, v in sorted(params.items())]

    return "|".join(key_parts)



def is_cache_valid(cache, key):

    if key not in cache:

        return False

    age = datetime.now().timestamp() - cache[key].get("timestamp", 0)

    return age < CACHE_EXPIRY



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

# API REQUESTS

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


def api_request(endpoint, params, api_key):

    """

    Make a NewsAPI request with caching.

    Returns articles list or None on failure.

    """

    cache     = load_cache()

    cache_key = get_cache_key(endpoint, params)


    # Return cached if fresh

    if is_cache_valid(cache, cache_key):

        age = int(datetime.now().timestamp() - cache[cache_key]["timestamp"])

        print(f"  Using cached results (age: {age}s)")

        return cache[cache_key]["articles"]


    if not REQUESTS_OK:

        print("  requests not installed.")

        return None


    params["apiKey"] = api_key

    url = BASE_URL + endpoint


    try:

        print("  Fetching from NewsAPI...")

        resp = requests.get(url, params=params, timeout=12)


        if resp.status_code == 401:

            print("  Invalid API key. Please check your key (Option 7).")

            return None

        if resp.status_code == 429:

            print("  Rate limit reached. Please wait or upgrade your plan.")

            return None

        resp.raise_for_status()


        data     = resp.json()

        articles = data.get("articles", [])


        # Save to cache

        cache[cache_key] = {

            "timestamp": datetime.now().timestamp(),

            "articles":  articles,

            "total":     data.get("totalResults", 0),

        }

        save_cache(cache)


        print(f"  Fetched {len(articles)} article(s)")

        return articles


    except requests.exceptions.ConnectionError:

        print("  No internet connection.")

        # Try returning stale cache

        if cache_key in cache:

            print("  Using stale cached results as fallback.")

            return cache[cache_key]["articles"]

        return None

    except Exception as e:

        print(f"  API error: {e}")

        return None



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

# FETCH TOP HEADLINES

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


def fetch_top_headlines(api_key, category="general",

                        country="us", page_size=10):

    params = {

        "category": category,

        "country":  country,

        "pageSize": page_size,

    }

    return api_request("top-headlines", params, api_key)



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

# FETCH BY KEYWORD

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


def fetch_by_keyword(api_key, keyword, from_days=1,

                     sort_by="publishedAt", page_size=10,

                     language="en"):

    from_date = (datetime.now() - timedelta(days=from_days)

                 ).strftime("%Y-%m-%d")

    params = {

        "q":        keyword,

        "from":     from_date,

        "sortBy":   sort_by,

        "language": language,

        "pageSize": page_size,

    }

    return api_request("everything", params, api_key)



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

# FETCH BY SOURCE

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


def fetch_by_source(api_key, source_id, page_size=10):

    params = {

        "sources":  source_id,

        "pageSize": page_size,

    }

    return api_request("top-headlines", params, api_key)



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

# FORMAT & DISPLAY

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


def format_date(date_str):

    if not date_str:

        return ""

    try:

        dt = datetime.strptime(date_str[:19], "%Y-%m-%dT%H:%M:%S")

        return dt.strftime("%d %b %Y  %H:%M")

    except:

        return date_str[:10]



def display_headlines(articles, title="TOP HEADLINES"):

    if not articles:

        print("\n  No articles found.")

        return


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

    print(f"  {title}  ({len(articles)} articles)")

    print("="*65)


    for i, art in enumerate(articles, 1):

        source    = art.get("source", {}).get("name", "Unknown")

        headline  = art.get("title", "No title") or "No title"

        pub_date  = format_date(art.get("publishedAt", ""))

        author    = art.get("author", "") or ""


        # Clean up "[Removed]" articles

        if "[Removed]" in headline:

            continue


        print(f"\n  [{i:02d}] {headline[:70]}")

        print(f"        {source:<25} {pub_date}")

        if author and author != source:

            print(f"        By: {author[:50]}")


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



def display_article_detail(article):

    """Show full details of one article."""

    w = 65

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

    headline = article.get("title", "No title") or "No title"

    print(f"  {headline}")

    print("="*w)


    source   = article.get("source", {}).get("name", "")

    author   = article.get("author", "") or ""

    pub_date = format_date(article.get("publishedAt", ""))

    desc     = article.get("description", "") or ""

    content  = article.get("content", "") or ""

    url      = article.get("url", "") or ""


    if source:

        print(f"  Source  : {source}")

    if author and author != source:

        print(f"  Author  : {author[:60]}")

    if pub_date:

        print(f"  Date    : {pub_date}")

    print("-"*w)


    if desc:

        print("\n  DESCRIPTION:")

        print(textwrap.fill(desc, width=62,

                            initial_indent="  ",

                            subsequent_indent="  "))


    if content:

        # NewsAPI truncates content at 200 chars

        content_clean = content.split("[+")[0].strip()

        if content_clean:

            print("\n  CONTENT PREVIEW:")

            print(textwrap.fill(content_clean, width=62,

                                initial_indent="  ",

                                subsequent_indent="  "))


    if url:

        print(f"\n  Full article: {url}")


    print("="*w)



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

# SAVED ARTICLES

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


def save_article(article):

    saved = load_saved_articles()

    url   = article.get("url", "")

    if any(s.get("url") == url for s in saved):

        print("  Already saved.")

        return

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

    saved.append(article)

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

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

    print(f"  Saved: {article.get('title','')[:50]}")



def load_saved_articles():

    if Path(SAVED_FILE).exists():

        try:

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

                return json.load(f)

        except:

            pass

    return []



def view_saved_articles():

    saved = load_saved_articles()

    if not saved:

        print("\n  No saved articles yet.")

        return


    display_headlines(saved, "SAVED ARTICLES")


    choice = input("\n  View details? (number/Enter to back): ").strip()

    if choice.isdigit():

        idx = int(choice) - 1

        if 0 <= idx < len(saved):

            display_article_detail(saved[idx])

            open_browser = input("\n  Open in browser? (y/n): ").strip().lower()

            if open_browser == "y" and saved[idx].get("url"):

                webbrowser.open(saved[idx]["url"])



def remove_saved_article():

    saved = load_saved_articles()

    if not saved:

        print("\n  No saved articles.")

        return

    display_headlines(saved, "SAVED ARTICLES")

    try:

        idx = int(input("\n  Enter number to remove: ").strip()) - 1

        if 0 <= idx < len(saved):

            removed = saved.pop(idx)

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

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

            print(f"  Removed: {removed.get('title','')[:50]}")

        else:

            print("  Invalid number.")

    except ValueError:

        print("  Invalid input.")



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

# SEARCH HISTORY LOG

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


def log_search(query_type, query_value):

    history = []

    if Path(HISTORY_FILE).exists():

        try:

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

                history = json.load(f)

        except:

            pass

    history.append({

        "type":      query_type,

        "query":     query_value,

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

    })

    history = history[-30:]

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

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



def view_history():

    if not Path(HISTORY_FILE).exists():

        print("\n  No search history yet.")

        return

    try:

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

            history = json.load(f)

    except:

        print("  Could not load history.")

        return


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

    print(f"  SEARCH HISTORY  ({len(history)} entries)")

    print("="*55)

    for h in reversed(history[-15:]):

        print(f"  {h['timestamp']}  [{h['type']}]  {h['query']}")

    print("="*55)



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

# INTERACTIVE ARTICLE VIEWER

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


def browse_articles(articles, title="HEADLINES"):

    """Let user browse, view details, save, open articles."""

    if not articles:

        return


    display_headlines(articles, title)


    while True:

        print("\n  Options:")

        print("  [number] = View article details")

        print("  s[num]   = Save article (e.g. s3)")

        print("  o[num]   = Open in browser (e.g. o2)")

        print("  b        = Back to menu")


        cmd = input("\n  > ").strip().lower()


        if cmd == "b" or cmd == "":

            break


        elif cmd.startswith("s") and cmd[1:].isdigit():

            idx = int(cmd[1:]) - 1

            if 0 <= idx < len(articles):

                save_article(articles[idx])

            else:

                print("  Invalid number.")


        elif cmd.startswith("o") and cmd[1:].isdigit():

            idx = int(cmd[1:]) - 1

            if 0 <= idx < len(articles):

                url = articles[idx].get("url", "")

                if url:

                    webbrowser.open(url)

                    print(f"  Opened in browser: {url[:60]}")

                else:

                    print("  No URL available.")

            else:

                print("  Invalid number.")


        elif cmd.isdigit():

            idx = int(cmd) - 1

            if 0 <= idx < len(articles):

                display_article_detail(articles[idx])

                sub = input("\n  Save? (s) | Open browser? (o) | Back (Enter): ").strip().lower()

                if sub == "s":

                    save_article(articles[idx])

                elif sub == "o":

                    url = articles[idx].get("url", "")

                    if url:

                        webbrowser.open(url)

            else:

                print("  Invalid number.")


        else:

            print("  Unknown command.")



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

# POPULAR SOURCES

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


POPULAR_SOURCES = {

    "1":  ("bbc-news",          "BBC News"),

    "2":  ("reuters",           "Reuters"),

    "3":  ("the-hindu",         "The Hindu"),

    "4":  ("the-times-of-india","Times of India"),

    "5":  ("techcrunch",        "TechCrunch"),

    "6":  ("wired",             "Wired"),

    "7":  ("the-verge",         "The Verge"),

    "8":  ("espn",              "ESPN"),

    "9":  ("national-geographic","National Geographic"),

    "10": ("bloomberg",         "Bloomberg"),

}



def fetch_from_source(api_key):

    print("\n  POPULAR SOURCES:")

    print("  " + "-"*35)

    for num, (src_id, name) in POPULAR_SOURCES.items():

        print(f"  {num:>3}. {name}")

    print("  " + "-"*35)


    choice = input("\n  Select source (number): ").strip()

    if choice not in POPULAR_SOURCES:

        print("  Invalid choice.")

        return


    src_id, name = POPULAR_SOURCES[choice]

    n = input(f"  Number of articles (default 10): ").strip()

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


    articles = fetch_by_source(api_key, src_id, n)

    if articles:

        log_search("source", name)

        browse_articles(articles, f"FROM: {name.upper()}")



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

# MAIN MENU

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


def print_menu(api_key, country, category):

    saved_count = len(load_saved_articles())

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

    print(f"  NEWS HEADLINES FETCHER")

    print(f"  Country: {COUNTRIES.get(country, country)}  "

          f"| Category: {category.capitalize()}")

    print("-"*52)

    print("  1.  Top headlines (current settings)")

    print("  2.  Headlines by category")

    print("  3.  Search by keyword")

    print("  4.  Headlines by country")

    print("  5.  News from specific source")

    print("  6.  Trending (multiple categories)")

    print(f"  7.  Saved articles  [{saved_count}]")

    print("  8.  Search history")

    print("  9.  Change API key")

    print("  0.  Exit")

    print("-"*52)



def main():

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

    print("     NEWS HEADLINES FETCHER")

    print("="*55)


    if not REQUESTS_OK:

        print("\n  requests not installed!")

        print("  Install it:  pip install requests")

        return


    # Load or setup API key

    api_key = load_api_key()

    if not api_key:

        print("\n  No API key found.")

        api_key = setup_api_key()

        if not api_key:

            print("  Cannot continue without API key.")

            return


    print(f"\n  API key loaded.")

    print("  Free tier: 100 requests/day from newsapi.org\n")


    current_country  = "us"

    current_category = "general"


    while True:

        print_menu(api_key, current_country, current_category)

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


        # ── Top Headlines ────────────────────────────────────

        if choice == "1":

            n = input("  Number of articles (default 10): ").strip()

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

            articles = fetch_top_headlines(

                api_key, current_category, current_country, n

            )

            if articles:

                log_search("top-headlines",

                           f"{current_category}/{current_country}")

                browse_articles(

                    articles,

                    f"TOP {current_category.upper()} — "

                    f"{COUNTRIES.get(current_country, current_country).upper()}"

                )


        # ── By Category ──────────────────────────────────────

        elif choice == "2":

            print("\n  Categories:")

            for i, cat in enumerate(CATEGORIES, 1):

                marker = " ← current" if cat == current_category else ""

                print(f"  {i}. {cat.capitalize()}{marker}")

            cat_choice = input("\n  Select (number): ").strip()

            if cat_choice.isdigit():

                idx = int(cat_choice) - 1

                if 0 <= idx < len(CATEGORIES):

                    current_category = CATEGORIES[idx]

                    n = input("  Number of articles (default 10): ").strip()

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

                    articles = fetch_top_headlines(

                        api_key, current_category, current_country, n

                    )

                    if articles:

                        log_search("category", current_category)

                        browse_articles(

                            articles,

                            f"{current_category.upper()} NEWS"

                        )


        # ── Keyword Search ───────────────────────────────────

        elif choice == "3":

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

            if not keyword:

                continue

            days = input("  Search last N days (default 1): ").strip()

            days = int(days) if days.isdigit() else 1

            n    = input("  Number of articles (default 10): ").strip()

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


            print("\n  Sort by: 1=Latest  2=Relevancy  3=Popularity")

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

            sort_map = {"1": "publishedAt", "2": "relevancy",

                        "3": "popularity"}

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


            articles = fetch_by_keyword(api_key, keyword, days,

                                        sort_by, n)

            if articles:

                log_search("keyword", keyword)

                browse_articles(articles,

                                f"NEWS: '{keyword.upper()}'")


        # ── By Country ───────────────────────────────────────

        elif choice == "4":

            print("\n  Available countries:")

            for i, (code, name) in enumerate(COUNTRIES.items(), 1):

                marker = " ← current" if code == current_country else ""

                print(f"  {i:>3}. {name} ({code}){marker}")

            country_choice = input("\n  Select (number): ").strip()

            if country_choice.isdigit():

                idx = int(country_choice) - 1

                codes = list(COUNTRIES.keys())

                if 0 <= idx < len(codes):

                    current_country = codes[idx]

                    articles = fetch_top_headlines(

                        api_key, current_category, current_country

                    )

                    if articles:

                        log_search("country", current_country)

                        browse_articles(

                            articles,

                            f"TOP NEWS — "

                            f"{COUNTRIES[current_country].upper()}"

                        )


        # ── By Source ────────────────────────────────────────

        elif choice == "5":

            fetch_from_source(api_key)


        # ── Trending (Multi-category) ────────────────────────

        elif choice == "6":

            print("\n  Fetching trending headlines across categories...")

            all_articles = []

            for cat in ["technology", "business", "science", "health"]:

                arts = fetch_top_headlines(api_key, cat, current_country, 3)

                if arts:

                    for a in arts:

                        a["_category"] = cat

                    all_articles.extend(arts)

            if all_articles:

                log_search("trending", "multi-category")

                browse_articles(all_articles, "TRENDING ACROSS CATEGORIES")


        # ── Saved Articles ───────────────────────────────────

        elif choice == "7":

            print("\n  1. View saved  2. Remove article")

            sub = input("  Choice: ").strip()

            if sub == "1":

                view_saved_articles()

            elif sub == "2":

                remove_saved_article()


        # ── History ──────────────────────────────────────────

        elif choice == "8":

            view_history()


        # ── Change API Key ───────────────────────────────────

        elif choice == "9":

            new_key = input("\n  Enter new API key: ").strip()

            if new_key:

                save_api_key(new_key)

                api_key = new_key


        elif choice == "0":

            print("\n  Goodbye! Stay informed!\n")

            break


        else:

            print("  Invalid choice.")



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

# RUN

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


if __name__ == "__main__":

    main()

Wikipedia Summary Fetcher

import json

import re

import os

import textwrap

from datetime import datetime

from pathlib import Path

from collections import defaultdict


try:

    import requests

    REQUESTS_OK = True

except ImportError:

    REQUESTS_OK = False


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

# CONFIGURATION

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


HISTORY_FILE  = "wiki_history.json"

SAVED_FILE    = "wiki_saved.json"

WIKI_API      = "https://en.wikipedia.org/api/rest_v1/page/summary/{}"

WIKI_SEARCH   = "https://en.wikipedia.org/w/api.php"

WIKI_SECTIONS = "https://en.wikipedia.org/api/rest_v1/page/mobile-sections/{}"

WRAP_WIDTH    = 72


# Language support

LANG_APIS = {

    "en": "https://en.wikipedia.org/api/rest_v1/page/summary/{}",

    "hi": "https://hi.wikipedia.org/api/rest_v1/page/summary/{}",

    "ta": "https://ta.wikipedia.org/api/rest_v1/page/summary/{}",

    "fr": "https://fr.wikipedia.org/api/rest_v1/page/summary/{}",

    "de": "https://de.wikipedia.org/api/rest_v1/page/summary/{}",

    "es": "https://es.wikipedia.org/api/rest_v1/page/summary/{}",

    "ja": "https://ja.wikipedia.org/api/rest_v1/page/summary/{}",

    "zh": "https://zh.wikipedia.org/api/rest_v1/page/summary/{}",

}


current_lang = "en"


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

# HELPERS

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


def clean_text(text):

    """Remove wiki markup artifacts."""

    if not text:

        return ""

    text = re.sub(r'\s+', ' ', text)

    text = text.strip()

    return text



def wrap_text(text, width=WRAP_WIDTH, indent="  "):

    """Word-wrap text with indent."""

    paragraphs = text.split("\n")

    result     = []

    for para in paragraphs:

        if para.strip():

            wrapped = textwrap.fill(para.strip(), width=width,

                                    initial_indent=indent,

                                    subsequent_indent=indent)

            result.append(wrapped)

        else:

            result.append("")

    return "\n".join(result)



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

# LOAD & SAVE HISTORY / SAVED ARTICLES

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


def load_history():

    if Path(HISTORY_FILE).exists():

        try:

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

                return json.load(f)

        except:

            pass

    return []



def save_to_history(title, url, lang="en"):

    history = load_history()

    # Avoid duplicate consecutive entries

    if history and history[-1].get("title") == title:

        return

    history.append({

        "title":     title,

        "url":       url,

        "lang":      lang,

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

    })

    history = history[-50:]

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

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



def load_saved():

    if Path(SAVED_FILE).exists():

        try:

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

                return json.load(f)

        except:

            pass

    return []



def save_article(article):

    saved = load_saved()

    if any(s["title"] == article["title"] for s in saved):

        print("  Already saved.")

        return

    article["saved_at"] = datetime.now().strftime("%d-%m-%Y %H:%M:%S")

    saved.append(article)

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

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

    print(f"  Saved: {article['title']}")



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

# SEARCH WIKIPEDIA

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


def search_wikipedia(query, limit=8):

    """

    Search Wikipedia and return list of matching titles.

    Uses Wikipedia's opensearch API.

    """

    params = {

        "action":     "opensearch",

        "search":     query,

        "limit":      limit,

        "namespace":  0,

        "format":     "json",

    }

    try:

        resp = requests.get(WIKI_SEARCH, params=params, timeout=10)

        resp.raise_for_status()

        data    = resp.json()

        titles  = data[1] if len(data) > 1 else []

        urls    = data[3] if len(data) > 3 else []

        return list(zip(titles, urls))

    except Exception as e:

        print(f"  Search error: {e}")

        return []



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

# FETCH SUMMARY

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


def fetch_summary(title, lang="en"):

    """

    Fetch Wikipedia summary for a given title.

    Returns article dict or None.

    """

    api_base = LANG_APIS.get(lang, LANG_APIS["en"])

    url      = api_base.format(title.replace(" ", "_"))


    try:

        resp = requests.get(url, timeout=10,

                            headers={"User-Agent": "WikiFetcher/1.0"})


        if resp.status_code == 404:

            return None

        resp.raise_for_status()


        data    = resp.json()

        article = {

            "title":       data.get("title", title),

            "description": data.get("description", ""),

            "summary":     clean_text(data.get("extract", "")),

            "url":         data.get("content_urls", {})

                               .get("desktop", {})

                               .get("page", ""),

            "thumbnail":   data.get("originalimage", {}).get("source", ""),

            "lang":        lang,

            "type":        data.get("type", ""),

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

        }

        return article


    except requests.exceptions.ConnectionError:

        print("  No internet connection.")

        return None

    except Exception as e:

        print(f"  Fetch error: {e}")

        return None



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

# DISPLAY ARTICLE

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


def display_article(article, full=False):

    w = 70


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

    print(f"  {article['title'].upper()}")

    if article.get("description"):

        print(f"  {article['description']}")

    print("="*w)


    summary = article.get("summary", "")


    if not summary:

        print("  No summary available.")

        return


    if full:

        print(wrap_text(summary))

    else:

        # Show first 3 sentences

        sentences = re.split(r'(?<=[.!?])\s+', summary)

        preview   = " ".join(sentences[:3])

        print(wrap_text(preview))


        if len(sentences) > 3:

            remaining = len(sentences) - 3

            print(f"\n  ... ({remaining} more sentence(s))")

            expand = input("\n  Read full summary? (y/n): ").strip().lower()

            if expand == "y":

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

                print(wrap_text(summary))


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

    if article.get("url"):

        print(f"  Read more: {article['url']}")

    if article.get("thumbnail"):

        print(f"  Image    : {article['thumbnail']}")

    print("="*w)



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

# FETCH RELATED TOPICS

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


def fetch_related(title):

    """Fetch related/linked articles using search."""

    results = search_wikipedia(title, limit=6)

    # Filter out exact match

    related = [(t, u) for t, u in results if t.lower() != title.lower()]

    return related[:5]



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

# WORD COUNT & STATS

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


def article_stats(article):

    summary   = article.get("summary", "")

    words     = len(summary.split())

    sentences = len(re.split(r'(?<=[.!?])\s+', summary))

    chars     = len(summary)

    read_time = max(1, round(words / 200))  # avg 200 wpm


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

    print(f"  ARTICLE STATS: {article['title']}")

    print("="*45)

    print(f"  Words      : {words:,}")

    print(f"  Sentences  : {sentences:,}")

    print(f"  Characters : {chars:,}")

    print(f"  Read time  : ~{read_time} min")

    print(f"  Language   : {article.get('lang','en')}")

    print(f"  Type       : {article.get('type','')}")

    print("="*45)



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

# COMPARE TWO ARTICLES

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


def compare_articles(article1, article2):

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

    print("  COMPARISON")

    print("="*70)


    w = 32

    print(f"  {'TOPIC':<14} {article1['title'][:w]:<{w}}  "

          f"{article2['title'][:w]}")

    print("  " + "-"*65)


    print(f"  {'Description':<14} "

          f"{article1.get('description','')[:w]:<{w}}  "

          f"{article2.get('description','')[:w]}")


    words1 = len(article1.get("summary","").split())

    words2 = len(article2.get("summary","").split())

    print(f"  {'Word count':<14} {words1:<{w}}  {words2}")


    print(f"  {'URL':<14} {article1.get('url','')[:w]:<{w}}  "

          f"{article2.get('url','')[:w]}")

    print("="*70)


    # Show both summaries

    for art in [article1, article2]:

        print(f"\n  [{art['title']}]")

        sentences = re.split(r'(?<=[.!?])\s+', art.get("summary",""))

        preview   = " ".join(sentences[:2])

        print(wrap_text(preview))



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

# VIEW HISTORY

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


def view_history():

    history = load_history()

    if not history:

        print("\n  No search history yet.")

        return


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

    print(f"  SEARCH HISTORY  ({len(history)} articles)")

    print("="*60)

    print(f"  {'#':<4} {'TITLE':<30} {'LANG':<6} VIEWED AT")

    print("  " + "-"*55)

    for i, h in enumerate(reversed(history[-20:]), 1):

        print(f"  {i:<4} {h['title']:<30} {h.get('lang','en'):<6} "

              f"{h['viewed_at']}")

    print("="*60)



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

# VIEW SAVED ARTICLES

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


def view_saved():

    saved = load_saved()

    if not saved:

        print("\n  No saved articles yet.")

        return


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

    print(f"  SAVED ARTICLES  ({len(saved)})")

    print("="*60)


    for i, a in enumerate(saved, 1):

        print(f"\n  [{i}] {a['title']}")

        if a.get("description"):

            print(f"       {a['description']}")

        print(f"       Saved: {a.get('saved_at','')}  "

              f"URL: {a.get('url','')[:40]}")


    print("="*60)


    choice = input("\n  View an article? (number or Enter to back): ").strip()

    if choice.isdigit():

        idx = int(choice) - 1

        if 0 <= idx < len(saved):

            display_article(saved[idx], full=True)



def remove_saved():

    saved = load_saved()

    if not saved:

        print("\n  No saved articles.")

        return


    view_saved()

    try:

        idx = int(input("\n  Enter number to remove: ").strip()) - 1

        if 0 <= idx < len(saved):

            removed = saved.pop(idx)

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

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

            print(f"  Removed: {removed['title']}")

        else:

            print("  Invalid number.")

    except ValueError:

        print("  Invalid input.")



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

# MAIN SEARCH FLOW

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


def do_search():

    global current_lang


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

    if not query:

        return None


    print(f"  Searching...")

    results = search_wikipedia(query)


    if not results:

        print("  No results found.")

        return None


    # Show results

    print(f"\n  Results for '{query}':")

    print("  " + "-"*50)

    for i, (title, url) in enumerate(results, 1):

        print(f"  [{i}] {title}")

        print(f"       {url}")


    print("\n  [0] Back to menu")


    choice = input("\n  Select article number: ").strip()

    if not choice.isdigit() or int(choice) == 0:

        return None


    idx = int(choice) - 1

    if idx < 0 or idx >= len(results):

        print("  Invalid selection.")

        return None


    title = results[idx][0]

    print(f"\n  Fetching: {title}...")

    article = fetch_summary(title, current_lang)


    if not article:

        print(f"  Could not fetch article for: {title}")

        return None


    display_article(article)

    save_to_history(article["title"], article.get("url",""), current_lang)


    # Post-article options

    print("\n  Options:")

    print("  1. Read full summary")

    print("  2. Article statistics")

    print("  3. Save this article")

    print("  4. Show related topics")

    print("  5. Back")


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


    if sub == "1":

        display_article(article, full=True)

    elif sub == "2":

        article_stats(article)

    elif sub == "3":

        save_article(article)

    elif sub == "4":

        related = fetch_related(title)

        if related:

            print(f"\n  Related to '{title}':")

            for i, (t, u) in enumerate(related, 1):

                print(f"  [{i}] {t}")

                print(f"       {u}")

            rel_choice = input("\n  Open related article? (number/Enter): ").strip()

            if rel_choice.isdigit():

                ri = int(rel_choice) - 1

                if 0 <= ri < len(related):

                    rel_article = fetch_summary(related[ri][0], current_lang)

                    if rel_article:

                        display_article(rel_article)

                        save_to_history(rel_article["title"],

                                        rel_article.get("url",""),

                                        current_lang)

        else:

            print("  No related articles found.")


    return article



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

# LANGUAGE SELECTOR

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


def select_language():

    global current_lang

    print("\n  Available languages:")

    langs = list(LANG_APIS.keys())

    lang_names = {

        "en": "English", "hi": "Hindi", "ta": "Tamil",

        "fr": "French",  "de": "German", "es": "Spanish",

        "ja": "Japanese","zh": "Chinese"

    }

    for i, lang in enumerate(langs, 1):

        marker = " ← current" if lang == current_lang else ""

        print(f"  {i}. {lang_names.get(lang, lang)} ({lang}){marker}")


    choice = input("\n  Select language (number): ").strip()

    if choice.isdigit():

        idx = int(choice) - 1

        if 0 <= idx < len(langs):

            current_lang = langs[idx]

            print(f"  Language set to: {lang_names.get(current_lang, current_lang)}")



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

# MAIN MENU

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


def print_menu():

    lang_names = {

        "en": "English", "hi": "Hindi", "ta": "Tamil",

        "fr": "French",  "de": "German", "es": "Spanish",

        "ja": "Japanese","zh": "Chinese"

    }

    saved_count   = len(load_saved())

    history_count = len(load_history())


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

    print(f"  WIKIPEDIA SUMMARY FETCHER  "

          f"[{lang_names.get(current_lang, current_lang)}]")

    print("-"*50)

    print("  1. Search & fetch article")

    print("  2. Fetch by exact title")

    print("  3. Compare two articles")

    print(f"  4. Saved articles  [{saved_count}]")

    print(f"  5. Search history  [{history_count}]")

    print("  6. Change language")

    print("  7. Random article")

    print("  0. Exit")

    print("-"*50)



def fetch_random():

    """Fetch a random Wikipedia article."""

    try:

        url  = f"https://{current_lang}.wikipedia.org/api/rest_v1/page/random/summary"

        resp = requests.get(url, timeout=10,

                            headers={"User-Agent": "WikiFetcher/1.0"})

        resp.raise_for_status()

        data    = resp.json()

        article = {

            "title":       data.get("title", "Random"),

            "description": data.get("description", ""),

            "summary":     clean_text(data.get("extract", "")),

            "url":         data.get("content_urls", {})

                               .get("desktop", {}).get("page", ""),

            "thumbnail":   data.get("originalimage", {}).get("source", ""),

            "lang":        current_lang,

            "type":        data.get("type", ""),

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

        }

        display_article(article)

        save_to_history(article["title"], article.get("url",""), current_lang)


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

        if save == "y":

            save_article(article)


    except Exception as e:

        print(f"  Could not fetch random article: {e}")



def main():

    global current_lang


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

    print("     WIKIPEDIA SUMMARY FETCHER")

    print("="*55)


    if not REQUESTS_OK:

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

        print("  Install it:  pip install requests")

        return


    print("\n  Fetch Wikipedia summaries for any topic.")

    print("  Supports 8 languages, save & compare articles.\n")


    last_article = None


    while True:

        print_menu()

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


        if choice == "1":

            last_article = do_search() or last_article


        elif choice == "2":

            title = input("\n  Exact Wikipedia title: ").strip()

            if title:

                print(f"  Fetching: {title}...")

                article = fetch_summary(title, current_lang)

                if article:

                    display_article(article)

                    save_to_history(article["title"],

                                    article.get("url",""), current_lang)

                    last_article = article

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

                    if save == "y":

                        save_article(article)

                else:

                    print(f"  Article not found: '{title}'")

                    print("  Try option 1 (Search) for suggestions.")


        elif choice == "3":

            print("\n  Compare two Wikipedia articles")

            t1 = input("  First topic : ").strip()

            t2 = input("  Second topic: ").strip()

            if t1 and t2:

                print("  Fetching...")

                a1 = fetch_summary(t1, current_lang)

                a2 = fetch_summary(t2, current_lang)

                if a1 and a2:

                    compare_articles(a1, a2)

                else:

                    print("  Could not fetch one or both articles.")


        elif choice == "4":

            view_saved()


        elif choice == "5":

            view_history()


        elif choice == "6":

            select_language()


        elif choice == "7":

            print("\n  Fetching random article...")

            fetch_random()


        elif choice == "0":

            print("\n  Goodbye! Keep exploring!\n")

            break


        else:

            print("  Invalid choice.")



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

# RUN

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


if __name__ == "__main__":

    main()

2048 Game (Terminal)

import os

import random

import json

import copy

from datetime import datetime

from pathlib import Path


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

# CONFIGURATION

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


SAVE_FILE      = "2048_save.json"

HIGH_SCORE_FILE = "2048_scores.json"

GRID_SIZE      = 4


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

# COLORS (ANSI — works on Linux/Mac and Windows 10+)

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


TILE_COLORS = {

    0:     "\033[90m",      # dark gray

    2:     "\033[97m",      # white

    4:     "\033[93m",      # yellow

    8:     "\033[33m",      # dark yellow

    16:    "\033[91m",      # light red

    32:    "\033[31m",      # red

    64:    "\033[95m",      # magenta

    128:   "\033[94m",      # light blue

    256:   "\033[34m",      # blue

    512:   "\033[96m",      # cyan

    1024:  "\033[36m",      # dark cyan

    2048:  "\033[92m",      # bright green

}

RESET  = "\033[0m"

BOLD   = "\033[1m"


def tile_color(val):

    return TILE_COLORS.get(val, "\033[92m")


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

# HIGH SCORE

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


def load_high_scores():

    if Path(HIGH_SCORE_FILE).exists():

        try:

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

                return json.load(f)

        except:

            pass

    return []



def save_high_score(score, moves):

    scores = load_high_scores()

    scores.append({

        "score": score,

        "moves": moves,

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

    })

    scores = sorted(scores, key=lambda x: x["score"], reverse=True)[:10]

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

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



def show_high_scores():

    scores = load_high_scores()

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

    print("  TOP 10 HIGH SCORES")

    print("="*45)

    if not scores:

        print("  No scores yet. Play a game!")

    else:

        print(f"  {'#':<4} {'SCORE':>10}  {'MOVES':>6}  DATE")

        print("  " + "-"*40)

        for i, s in enumerate(scores, 1):

            print(f"  {i:<4} {s['score']:>10,}  {s['moves']:>6}  {s['date']}")

    print("="*45)



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

# SAVE / LOAD GAME

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


def save_game(board, score, moves):

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

        json.dump({"board": board, "score": score, "moves": moves}, f)

    print("  Game saved!")



def load_game():

    if Path(SAVE_FILE).exists():

        try:

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

                data = json.load(f)

            return data["board"], data["score"], data["moves"]

        except:

            pass

    return None, None, None



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

# BOARD OPERATIONS

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


def new_board():

    board = [[0] * GRID_SIZE for _ in range(GRID_SIZE)]

    board = add_tile(board)

    board = add_tile(board)

    return board



def add_tile(board):

    empty = [(r, c) for r in range(GRID_SIZE)

             for c in range(GRID_SIZE) if board[r][c] == 0]

    if empty:

        r, c = random.choice(empty)

        board[r][c] = 4 if random.random() < 0.1 else 2

    return board



def slide_row_left(row):

    """Slide and merge a single row to the left. Returns (new_row, score_gained)."""

    tiles  = [x for x in row if x != 0]

    merged = []

    score  = 0

    i = 0

    while i < len(tiles):

        if i + 1 < len(tiles) and tiles[i] == tiles[i + 1]:

            val = tiles[i] * 2

            merged.append(val)

            score += val

            i += 2

        else:

            merged.append(tiles[i])

            i += 1

    merged += [0] * (GRID_SIZE - len(merged))

    return merged, score



def move_left(board):

    new_board = []

    score = 0

    for row in board:

        new_row, s = slide_row_left(row)

        new_board.append(new_row)

        score += s

    return new_board, score



def move_right(board):

    flipped = [row[::-1] for row in board]

    moved, score = move_left(flipped)

    return [row[::-1] for row in moved], score



def transpose(board):

    return [list(row) for row in zip(*board)]



def move_up(board):

    t = transpose(board)

    moved, score = move_left(t)

    return transpose(moved), score



def move_down(board):

    t = transpose(board)

    moved, score = move_right(t)

    return transpose(moved), score



def board_changed(old, new):

    return any(old[r][c] != new[r][c]

               for r in range(GRID_SIZE)

               for c in range(GRID_SIZE))



def is_game_over(board):

    """Check if no moves are possible."""

    # Any empty cell?

    for r in range(GRID_SIZE):

        for c in range(GRID_SIZE):

            if board[r][c] == 0:

                return False

    # Any adjacent equal cells?

    for r in range(GRID_SIZE):

        for c in range(GRID_SIZE):

            val = board[r][c]

            if c + 1 < GRID_SIZE and board[r][c + 1] == val:

                return False

            if r + 1 < GRID_SIZE and board[r + 1][c] == val:

                return False

    return True



def has_won(board):

    return any(board[r][c] >= 2048

               for r in range(GRID_SIZE)

               for c in range(GRID_SIZE))



def max_tile(board):

    return max(board[r][c]

               for r in range(GRID_SIZE)

               for c in range(GRID_SIZE))



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

# DISPLAY BOARD

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


def clear():

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



def display_board(board, score, best, moves):

    clear()

    cell_w = 7


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

    print(f"  {BOLD}2048{RESET}         "

          f"Score: {BOLD}{score:>7,}{RESET}  Best: {best:>7,}")

    print(f"  Moves: {moves:<6}   Max tile: {max_tile(board)}")

    print("="*38)


    # Top border

    border = "  +" + (("-" * cell_w + "+") * GRID_SIZE)

    print(border)


    for row in board:

        # Tile values

        line = "  |"

        for val in row:

            color = tile_color(val)

            if val == 0:

                line += f"{' ':^{cell_w}}|"

            else:

                val_str = str(val)

                padded  = val_str.center(cell_w)

                line   += f"{color}{BOLD}{padded}{RESET}|"

        print(line)

        print(border)


    print()

    print("  W/↑=Up  S/↓=Down  A/←=Left  D/→=Right")

    print("  P=Save  Q=Quit  N=New Game  H=High Scores")



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

# UNDO SUPPORT

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


class UndoStack:

    def __init__(self, max_size=5):

        self.stack    = []

        self.max_size = max_size


    def push(self, board, score):

        self.stack.append((copy.deepcopy(board), score))

        if len(self.stack) > self.max_size:

            self.stack.pop(0)


    def pop(self):

        if self.stack:

            return self.stack.pop()

        return None, None


    def can_undo(self):

        return len(self.stack) > 0



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

# GAME LOOP

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


def game_loop(board=None, score=0, moves=0, best=0):

    if board is None:

        board = new_board()


    undo = UndoStack()


    while True:

        display_board(board, score, max(score, best), moves)


        # Win check

        if has_won(board):

            print(f"\n  YOU REACHED 2048! Congratulations!")

            cont = input("  Keep playing? (y/n): ").strip().lower()

            if cont != "y":

                save_high_score(score, moves)

                return score


        # Game over check

        if is_game_over(board):

            print(f"\n  GAME OVER! Final score: {score:,}")

            save_high_score(score, moves)

            return score


        # Can undo hint

        if undo.can_undo():

            print(f"  U=Undo ({len(undo.stack)} available)", end="  ")

        print()


        try:

            key = input("  Move: ").strip().lower()

        except (EOFError, KeyboardInterrupt):

            print("\n  Game interrupted.")

            save_high_score(score, moves)

            return score


        if key in ("q", "quit"):

            print(f"\n  Final score: {score:,}  Moves: {moves}")

            save_high_score(score, moves)

            return score


        elif key == "n":

            confirm = input("  Start new game? (y/n): ").strip().lower()

            if confirm == "y":

                save_high_score(score, moves)

                return game_loop(best=max(score, best))


        elif key == "p":

            save_game(board, score, moves)

            continue


        elif key == "h":

            show_high_scores()

            input("  Press Enter to continue...")

            continue


        elif key == "u":

            prev_board, prev_score = undo.pop()

            if prev_board:

                board = prev_board

                score = prev_score

                moves = max(0, moves - 1)

                print("  Undone!")

            else:

                print("  Nothing to undo.")

            continue


        # Moves

        move_map = {

            "w": move_up,    "up":    move_up,

            "s": move_down,  "down":  move_down,

            "a": move_left,  "left":  move_left,

            "d": move_right, "right": move_right,

        }


        move_fn = move_map.get(key)

        if not move_fn:

            continue


        # Save state for undo

        undo.push(board, score)


        new_b, gained = move_fn(board)


        if board_changed(board, new_b):

            board  = add_tile(new_b)

            score += gained

            moves += 1

        else:

            undo.pop()   # no change — remove saved state


    return score



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

# MAIN MENU

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


def main():

    best = 0

    scores = load_high_scores()

    if scores:

        best = scores[0]["score"]


    while True:

        clear()

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

        print(f"  {BOLD}  2048  —  Terminal Edition{RESET}")

        print("="*38)

        print(f"  Best score: {best:,}\n")

        print("  1. New Game")

        print("  2. Continue saved game")

        print("  3. High Scores")

        print("  4. How to play")

        print("  0. Exit")

        print("="*38)


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


        if choice == "1":

            result = game_loop(best=best)

            best   = max(best, result)


        elif choice == "2":

            board, score, moves = load_game()

            if board:

                print(f"\n  Loaded game! Score: {score:,}  Moves: {moves}")

                input("  Press Enter to continue...")

                result = game_loop(board, score, moves, best)

                best   = max(best, result)

            else:

                print("  No saved game found.")

                input("  Press Enter...")


        elif choice == "3":

            show_high_scores()

            input("\n  Press Enter to return...")


        elif choice == "4":

            clear()

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

            print("  HOW TO PLAY 2048")

            print("="*50)

            print("""

  Goal: Combine tiles to reach the 2048 tile!


  Controls:

    W / ↑   — Slide tiles UP

    S / ↓   — Slide tiles DOWN

    A / ←   — Slide tiles LEFT

    D / →   — Slide tiles RIGHT

    U       — Undo last move (up to 5 times)

    P       — Save game

    N       — New game

    H       — High scores

    Q       — Quit


  Rules:

    • Tiles with the same number merge when they collide.

    • Each merge doubles the tile value and adds to score.

    • A new tile (2 or 4) appears after each move.

    • Game ends when no moves are possible.

    • Reach 2048 to WIN — but keep going for a higher score!


  Scoring:

    • Each merge = merged tile value added to score.

    • e.g. merging two 64s = +128 points.

            """)

            input("  Press Enter to return...")


        elif choice == "0":

            print("\n  Thanks for playing! Goodbye!\n")

            break


        else:

            print("  Invalid choice.")



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

# RUN

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


if __name__ == "__main__":

    main()

Habit Tracker

import json

import os

from datetime import datetime, date, timedelta

from pathlib import Path

from collections import defaultdict


try:

    import matplotlib.pyplot as plt

    import matplotlib.colors as mcolors

    import numpy as np

    MATPLOTLIB_OK = True

except ImportError:

    MATPLOTLIB_OK = False


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

# CONFIGURATION

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


DATA_FILE = "habits.json"


DAYS_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

MONTHS     = ["Jan","Feb","Mar","Apr","May","Jun",

              "Jul","Aug","Sep","Oct","Nov","Dec"]


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

# LOAD & SAVE

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


def load_data():

    if Path(DATA_FILE).exists():

        try:

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

                return json.load(f)

        except:

            pass

    return {"habits": [], "logs": {}}



def save_data(data):

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

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



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

# HELPERS

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


def today_str():

    return str(date.today())



def date_range(start_str, end_str):

    """Yield date strings from start to end inclusive."""

    start = datetime.strptime(start_str, "%Y-%m-%d").date()

    end   = datetime.strptime(end_str,   "%Y-%m-%d").date()

    cur   = start

    while cur <= end:

        yield str(cur)

        cur += timedelta(days=1)



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)



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

# STREAK CALCULATION

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


def calculate_streak(habit_id, logs):

    """Return current streak and longest streak for a habit."""

    today      = date.today()

    cur_streak = 0

    max_streak = 0

    tmp_streak = 0


    # Collect all logged dates for this habit

    logged_dates = sorted(

        [d for d, h in logs.items() if habit_id in h and h[habit_id]],

        reverse=True

    )


    if not logged_dates:

        return 0, 0


    # Current streak: count back from today

    check = today

    for _ in range(len(logged_dates) + 1):

        d_str = str(check)

        if d_str in logs and habit_id in logs[d_str] and logs[d_str][habit_id]:

            cur_streak += 1

            check -= timedelta(days=1)

        else:

            # Allow today to not be checked yet

            if check == today:

                check -= timedelta(days=1)

                continue

            break


    # Longest streak: scan all logged dates

    all_dates = sorted(

        [datetime.strptime(d, "%Y-%m-%d").date()

         for d in logs if habit_id in logs[d] and logs[d][habit_id]]

    )


    if all_dates:

        tmp_streak = 1

        max_streak = 1

        for i in range(1, len(all_dates)):

            if (all_dates[i] - all_dates[i-1]).days == 1:

                tmp_streak += 1

                max_streak = max(max_streak, tmp_streak)

            else:

                tmp_streak = 1


    return cur_streak, max_streak



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

# COMPLETION RATE

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


def completion_rate(habit_id, logs, days=30):

    """Return completion % over last N days."""

    today = date.today()

    count = 0

    total = 0


    for i in range(days):

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

        # Only count days since habit was created

        total += 1

        if d in logs and habit_id in logs[d] and logs[d][habit_id]:

            count += 1


    return round((count / total * 100) if total else 0, 1)



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

# ADD HABIT

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


def add_habit(data):

    print("\n  ADD NEW HABIT")

    print("  " + "-"*40)


    name = input("  Habit name*  : ").strip()

    if not name:

        print("  Name is required.")

        return


    if any(h["name"].lower() == name.lower() for h in data["habits"]):

        print(f"  Habit '{name}' already exists.")

        return


    desc   = input("  Description  : ").strip()

    icon   = input("  Icon/emoji   : ").strip() or "•"


    print("  Frequency:")

    print("  1. Daily  2. Weekdays only  3. Weekends only  4. Custom days")

    freq_choice = input("  Choice (default 1): ").strip() or "1"


    freq_map = {"1": "daily", "2": "weekdays", "3": "weekends", "4": "custom"}

    frequency = freq_map.get(freq_choice, "daily")


    custom_days = []

    if frequency == "custom":

        print("  Enter days (1=Mon, 2=Tue, ..., 7=Sun), comma separated:")

        raw = input("  Days: ").strip()

        try:

            custom_days = [int(d.strip()) for d in raw.split(",")

                           if d.strip().isdigit() and 1 <= int(d.strip()) <= 7]

        except:

            custom_days = list(range(1, 8))


    goal = input("  Daily goal/target (optional, e.g. '8 glasses' or '30 min'): ").strip()


    habit = {

        "id":          f"h{len(data['habits']) + 1}_{datetime.now().strftime('%H%M%S')}",

        "name":        name,

        "description": desc,

        "icon":        icon,

        "frequency":   frequency,

        "custom_days": custom_days,

        "goal":        goal,

        "active":      True,

        "created":     today_str(),

        "color":       ["green", "blue", "orange", "purple", "red", "teal"][

                           len(data["habits"]) % 6

                       ],

    }


    data["habits"].append(habit)

    save_data(data)

    print(f"\n  Habit added: {icon} {name}")



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

# DAILY CHECK-IN

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


def is_scheduled(habit, date_str):

    """Check if a habit is scheduled for a given date."""

    freq = habit.get("frequency", "daily")

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

    dow  = d.isoweekday()   # 1=Mon, 7=Sun


    if freq == "daily":

        return True

    elif freq == "weekdays":

        return dow <= 5

    elif freq == "weekends":

        return dow >= 6

    elif freq == "custom":

        return dow in habit.get("custom_days", list(range(1, 8)))

    return True



def daily_checkin(data):

    today     = today_str()

    scheduled = [h for h in data["habits"]

                 if h["active"] and is_scheduled(h, today)]


    if not scheduled:

        print("\n  No habits scheduled for today!")

        return


    if today not in data["logs"]:

        data["logs"][today] = {}


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

    print(f"  DAILY CHECK-IN  —  {today}")

    print(f"  {datetime.now().strftime('%A, %d %B %Y')}")

    print("="*55)


    completed = 0

    for habit in scheduled:

        hid      = habit["id"]

        already  = data["logs"][today].get(hid, None)

        status   = "✓" if already else "○"

        goal_str = f"  Goal: {habit['goal']}" if habit.get("goal") else ""

        streak, _ = calculate_streak(hid, data["logs"])


        print(f"\n  {habit['icon']} {habit['name']}{goal_str}")

        print(f"     Current streak: {streak} day(s)")

        if already is not None:

            print(f"     Status: {'DONE' if already else 'SKIPPED'} (already logged)")

            if already:

                completed += 1

            relog = input("     Change? (y/n): ").strip().lower()

            if relog != "y":

                continue


        ans = input(f"     Completed today? (y/n/s=skip): ").strip().lower()

        if ans == "y":

            data["logs"][today][hid] = True

            completed += 1

            print(f"     Marked DONE! Streak: {streak + 1} day(s)")

        elif ans == "n":

            data["logs"][today][hid] = False

            print(f"     Marked as not done.")

        # 's' or anything else = skip (don't log)


    save_data(data)


    total = len(scheduled)

    pct   = round(completed / total * 100) if total else 0

    bar   = draw_bar(completed, total)


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

    print(f"  Today's progress: {completed}/{total}  ({pct}%)")

    print(f"  {bar}")

    if pct == 100:

        print("  PERFECT DAY! All habits completed!")

    print("="*55)



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

# VIEW ALL HABITS

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


def view_habits(data):

    habits = [h for h in data["habits"] if h["active"]]

    if not habits:

        print("\n  No active habits. Add one!")

        return


    today = today_str()

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

    print(f"  YOUR HABITS  ({len(habits)} active)")

    print("="*70)

    print(f"  {'HABIT':<22} {'STREAK':>7} {'BEST':>6} {'7D%':>6} "

          f"{'30D%':>6}  TODAY")

    print("  " + "-"*65)


    for h in habits:

        hid          = h["id"]

        streak, best = calculate_streak(hid, data["logs"])

        rate_7       = completion_rate(hid, data["logs"], 7)

        rate_30      = completion_rate(hid, data["logs"], 30)


        today_status = data["logs"].get(today, {}).get(hid)

        if today_status is True:

            today_icon = "✓ DONE"

        elif today_status is False:

            today_icon = "✗ MISS"

        elif is_scheduled(h, today):

            today_icon = "○ DUE"

        else:

            today_icon = "- REST"


        name_str = f"{h['icon']} {h['name']}"[:20]

        print(f"  {name_str:<22} {streak:>6}d {best:>5}d "

              f"{rate_7:>5.0f}% {rate_30:>5.0f}%  {today_icon}")


    print("="*70)



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

# HABIT DETAIL

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


def habit_detail(data, habit):

    hid          = habit["id"]

    streak, best = calculate_streak(hid, data["logs"])

    rate_7       = completion_rate(hid, data["logs"], 7)

    rate_30      = completion_rate(hid, data["logs"], 30)

    rate_all     = completion_rate(hid, data["logs"], 365)


    # Count total completions

    total_done = sum(

        1 for d, h in data["logs"].items()

        if hid in h and h[hid]

    )


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

    print(f"  {habit['icon']} {habit['name']}")

    print("="*52)

    if habit.get("description"):

        print(f"  {habit['description']}")

    if habit.get("goal"):

        print(f"  Goal: {habit['goal']}")

    print(f"  Frequency   : {habit['frequency'].capitalize()}")

    print(f"  Started     : {habit['created']}")

    print("-"*52)

    print(f"  Current streak : {streak} day(s)")

    print(f"  Longest streak : {best} day(s)")

    print(f"  Total done     : {total_done} time(s)")

    print(f"  Last 7 days    : {rate_7}%  {draw_bar(rate_7, 100, 20)}")

    print(f"  Last 30 days   : {rate_30}%  {draw_bar(rate_30, 100, 20)}")

    print(f"  Last 365 days  : {rate_all}%  {draw_bar(rate_all, 100, 20)}")


    # Last 14 days mini-calendar

    print(f"\n  Last 14 days:")

    today = date.today()

    row1  = "  "

    row2  = "  "

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

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

        val = data["logs"].get(d, {}).get(hid)

        if val is True:

            row1 += "█ "

        elif val is False:

            row1 += "░ "

        else:

            row1 += "· "

        day_label = (today - timedelta(days=i)).strftime("%d")

        row2 += f"{day_label} "

    print(row1)

    print(row2)

    print("  (█=done  ░=missed  ·=not logged)")

    print("="*52)



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

# ASCII HEATMAP (last 12 weeks)

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


def ascii_heatmap(data, habit=None):

    today   = date.today()

    weeks   = 12

    start   = today - timedelta(weeks=weeks)


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

    if habit:

        print(f"  HEATMAP: {habit['icon']} {habit['name']}")

    else:

        print("  HEATMAP: All Habits Combined")

    print("="*65)


    # Build week grid

    # Start from Monday of start week

    start_monday = start - timedelta(days=start.weekday())


    grid = []   # list of weeks, each week = list of 7 day completion %

    cur  = start_monday


    while cur <= today:

        week = []

        for d in range(7):

            day    = cur + timedelta(days=d)

            d_str  = str(day)

            if day > today:

                week.append(None)

                continue


            if habit:

                # Single habit

                val = data["logs"].get(d_str, {}).get(habit["id"])

                pct = 100 if val is True else (0 if val is False else None)

            else:

                # All habits

                scheduled = [h for h in data["habits"]

                             if h["active"] and is_scheduled(h, d_str)]

                if not scheduled:

                    pct = None

                else:

                    done = sum(1 for h in scheduled

                               if data["logs"].get(d_str, {}).get(h["id"]) is True)

                    pct  = round(done / len(scheduled) * 100)


            week.append(pct)

        grid.append(week)

        cur += timedelta(days=7)


    # Print day labels

    print("       " + "  ".join(DAYS_SHORT))

    print("       " + "-" * (len(DAYS_SHORT) * 5 - 1))


    # Print month labels and heatmap rows

    for w_idx, week in enumerate(grid):

        # Week label (show month when it changes)

        first_day = start_monday + timedelta(weeks=w_idx)

        week_label = first_day.strftime("%d%b")


        row = f"  {week_label:<5} "

        for pct in week:

            if pct is None:

                row += "·   "

            elif pct == 0:

                row += "░   "

            elif pct < 50:

                row += "▒   "

            elif pct < 100:

                row += "▓   "

            else:

                row += "█   "

        print(row)


    print("\n  Legend: · = no data  ░ = 0%  ▒ = <50%  ▓ = <100%  █ = 100%")

    print("="*65)



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

# WEEKLY REPORT

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


def weekly_report(data):

    today  = date.today()

    monday = today - timedelta(days=today.weekday())

    habits = [h for h in data["habits"] if h["active"]]


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

    print(f"  WEEKLY REPORT  —  Week of {monday.strftime('%d %b %Y')}")

    print("="*65)


    days_of_week = [(monday + timedelta(days=i)) for i in range(7)]

    day_labels   = [d.strftime("%a %d") for d in days_of_week]


    # Header

    print(f"  {'HABIT':<20}", end="")

    for label in day_labels:

        print(f"  {label:<8}", end="")

    print(f"  {'%':>5}")

    print("  " + "-"*80)


    for h in habits:

        print(f"  {(h['icon'] + ' ' + h['name'])[:19]:<20}", end="")

        done_count = 0

        sched_count = 0

        for d in days_of_week:

            d_str = str(d)

            if is_scheduled(h, d_str):

                sched_count += 1

                val = data["logs"].get(d_str, {}).get(h["id"])

                if val is True:

                    done_count += 1

                    print("  ✓       ", end="")

                elif val is False:

                    print("  ✗       ", end="")

                else:

                    print("  ○       ", end="")

            else:

                print("  -       ", end="")


        pct = round(done_count / sched_count * 100) if sched_count else 0

        print(f"  {pct:>4}%")


    print("="*65)



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

# MATPLOTLIB HEATMAP

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


def plot_heatmap(data, habit=None):

    if not MATPLOTLIB_OK:

        print("\n  matplotlib not installed.")

        print("  Install: pip install matplotlib numpy")

        return


    today  = date.today()

    weeks  = 20

    start  = today - timedelta(weeks=weeks)

    start  = start - timedelta(days=start.weekday())


    # Build grid

    grid_data = []

    week      = []

    cur       = start


    while cur <= today + timedelta(days=6 - today.weekday()):

        d_str = str(cur)

        if cur.weekday() == 0 and week:

            grid_data.append(week)

            week = []


        if cur > today:

            week.append(np.nan)

        else:

            if habit:

                val = data["logs"].get(d_str, {}).get(habit["id"])

                pct = 1.0 if val is True else (0.0 if val is False else np.nan)

            else:

                scheduled = [h for h in data["habits"]

                             if h["active"] and is_scheduled(h, d_str)]

                if not scheduled:

                    pct = np.nan

                else:

                    done = sum(1 for h in scheduled

                               if data["logs"].get(d_str, {}).get(h["id"]) is True)

                    pct  = done / len(scheduled)

            week.append(pct)

        cur += timedelta(days=1)


    if week:

        while len(week) < 7:

            week.append(np.nan)

        grid_data.append(week)


    matrix = np.array(grid_data).T   # shape: (7 days, N weeks)


    fig, ax = plt.subplots(figsize=(max(12, weeks // 2), 4))

    cmap    = plt.cm.Greens

    cmap.set_bad(color="#f0f0f0")


    im = ax.imshow(matrix, cmap=cmap, vmin=0, vmax=1,

                   aspect="auto", interpolation="nearest")


    ax.set_yticks(range(7))

    ax.set_yticklabels(DAYS_SHORT, fontsize=9)

    ax.set_xticks([])


    title = f"Habit Heatmap: {habit['name']}" if habit else "All Habits Combined"

    ax.set_title(title, fontsize=12, fontweight="bold")


    plt.colorbar(im, ax=ax, label="Completion rate",

                 fraction=0.03, pad=0.04)

    plt.tight_layout()

    fname = "habit_heatmap.png"

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

    print(f"\n  Heatmap saved: {fname}")

    plt.show()



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

# EDIT / ARCHIVE HABIT

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


def edit_habit(data, habit):

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

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


    new_name = input(f"  Name [{habit['name']}]: ").strip()

    if new_name:

        habit["name"] = new_name


    new_desc = input(f"  Description [{habit.get('description','')}]: ").strip()

    if new_desc:

        habit["description"] = new_desc


    new_icon = input(f"  Icon [{habit['icon']}]: ").strip()

    if new_icon:

        habit["icon"] = new_icon


    new_goal = input(f"  Goal [{habit.get('goal','')}]: ").strip()

    if new_goal:

        habit["goal"] = new_goal


    for i, h in enumerate(data["habits"]):

        if h["id"] == habit["id"]:

            data["habits"][i] = habit

            break


    save_data(data)

    print(f"  Updated: {habit['icon']} {habit['name']}")



def archive_habit(data, habit):

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

    if confirm == "yes":

        for h in data["habits"]:

            if h["id"] == habit["id"]:

                h["active"] = False

                break

        save_data(data)

        print(f"  Archived: {habit['name']}")

    else:

        print("  Cancelled.")



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

# SELECT HABIT

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


def select_habit(data):

    habits = [h for h in data["habits"] if h["active"]]

    if not habits:

        print("  No active habits.")

        return None


    print()

    for i, h in enumerate(habits, 1):

        streak, _ = calculate_streak(h["id"], data["logs"])

        print(f"  [{i}] {h['icon']} {h['name']}  (streak: {streak}d)")


    try:

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

        if 0 <= idx < len(habits):

            return habits[idx]

    except ValueError:

        pass

    return None



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

# MAIN MENU

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


def print_menu(data):

    habits  = [h for h in data["habits"] if h["active"]]

    today   = today_str()

    done    = sum(

        1 for h in habits

        if data["logs"].get(today, {}).get(h["id"]) is True

    )

    due = sum(1 for h in habits if is_scheduled(h, today))


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

    print(f"  HABIT TRACKER  [{len(habits)} habits | "

          f"Today: {done}/{due}]")

    print("-"*50)

    print("  1.  Daily check-in (today)")

    print("  2.  View all habits & stats")

    print("  3.  Habit detail")

    print("  4.  Add new habit")

    print("  5.  Edit habit")

    print("  6.  Archive habit")

    print("  7.  Weekly report")

    print("  8.  ASCII heatmap (terminal)")

    print("  9.  Plot heatmap chart (matplotlib)")

    print("  0.  Exit")

    print("-"*50)



def main():

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

    print("     HABIT TRACKER")

    print("="*55)

    print("\n  Build habits, track streaks, visualize progress.")


    data = load_data()


    if not data["habits"]:

        print("\n  No habits yet! Let's add your first habit.")

        add_habit(data)

    else:

        h_count = len([h for h in data["habits"] if h["active"]])

        print(f"\n  Loaded {h_count} active habit(s).")


    while True:

        print_menu(data)

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


        if choice == "1":

            daily_checkin(data)


        elif choice == "2":

            view_habits(data)


        elif choice == "3":

            habit = select_habit(data)

            if habit:

                habit_detail(data, habit)


        elif choice == "4":

            add_habit(data)


        elif choice == "5":

            habit = select_habit(data)

            if habit:

                edit_habit(data, habit)


        elif choice == "6":

            habit = select_habit(data)

            if habit:

                archive_habit(data, habit)


        elif choice == "7":

            weekly_report(data)


        elif choice == "8":

            print("\n  Show heatmap for:")

            print("  1. All habits combined")

            print("  2. Specific habit")

            sub = input("  Choice: ").strip()

            if sub == "2":

                habit = select_habit(data)

                ascii_heatmap(data, habit)

            else:

                ascii_heatmap(data)


        elif choice == "9":

            print("\n  Plot heatmap for:")

            print("  1. All habits combined")

            print("  2. Specific habit")

            sub = input("  Choice: ").strip()

            if sub == "2":

                habit = select_habit(data)

                plot_heatmap(data, habit)

            else:

                plot_heatmap(data)


        elif choice == "0":

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

            break


        else:

            print("  Invalid choice.")



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

# RUN

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


if __name__ == "__main__":

    main()