commit 3a845bf532f4ec976625d9c55cec8a73846efdfb Author: Zaine Arch Date: Sat Jan 17 09:10:20 2026 +0000 initial commit diff --git a/countdown.py b/countdown.py new file mode 100644 index 0000000..756950f --- /dev/null +++ b/countdown.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import requests +from datetime import datetime + +NIKAH_DAY = datetime(2026, 8, 2) +WALIMAH_DAY = datetime(2026, 8, 9) + +docstring = """ +This script sends daily countdown reminders via a Discord webhook about +the number of days remaining until two significant events: Nikah Day and +Walimah Day. It calculates the days left for each event and formats a +message to be sent to a specified Discord channel. +""" + +DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1461100056580067684/BAxF4ndrugLJX8L6WyUtbzOtqoTR0GyxMWhg6jzkdCy1CC8yLfDb7e5erz_FoMUHmviw" + +if __name__ == "__main__": + today = datetime.now() + days_to_nikah = (NIKAH_DAY - today).days + days_to_walimah = (WALIMAH_DAY - today).days + + days_to_nikah += 1 + days_to_walimah += 1 + + message = f"πŸ“… Countdown Update:\n\n" + message += f"πŸ’ Nikah Day: {days_to_nikah} days remaining!\n" + message += f"πŸŽ‰ Walimah Day: {days_to_walimah} days remaining!\n" + + payload = { + "content": message + } + + r = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) + r.raise_for_status() diff --git a/countdown.py~ b/countdown.py~ new file mode 100644 index 0000000..967d099 --- /dev/null +++ b/countdown.py~ @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import os, random, requests, discord +from discord import app_commands +from discord.ext import commands +from datetime import datetime +from pathlib import Path + +NIKAH_DAY = datetime(2026, 08, 02) +WALIMAH_DAY = datetime(2026, 08, 09) + +docstring = """ +This script sends daily countdown reminders via a Discord webhook about +the number of days remaining until two significant events: Nikah Day and +Walimah Day. It calculates the days left for each event and formats a +message to be sent to a specified Discord channel. +""" + +# ----------------------------- +# CONFIG +# ----------------------------- +DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/146000414 diff --git a/cover_to_pdf.py b/cover_to_pdf.py new file mode 100644 index 0000000..3d83671 --- /dev/null +++ b/cover_to_pdf.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 + +import sys +from pathlib import Path +from PIL import Image +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas +from reportlab.lib.units import mm + +def image_to_pdf(image_path, output_pdf=None): + image_path = Path(image_path) + + if not image_path.exists(): + raise FileNotFoundError(f"File not found: {image_path}") + + if output_pdf is None: + output_pdf = image_path.with_suffix(".pdf") + + img = Image.open(image_path) + img_width, img_height = img.size + + # A4 page size (points) + page_width, page_height = A4 + + # Convert pixels to points assuming 300 DPI + dpi = img.info.get("dpi", (300, 300))[0] + img_width_pt = img_width * 72 / dpi + img_height_pt = img_height * 72 / dpi + + # Scale to fit page while preserving aspect ratio + scale = min(page_width / img_width_pt, page_height / img_height_pt) + draw_width = img_width_pt * scale + draw_height = img_height_pt * scale + + x = (page_width - draw_width) / 2 + y = (page_height - draw_height) / 2 + + c = canvas.Canvas(str(output_pdf), pagesize=A4) + c.drawImage(str(image_path), x, y, draw_width, draw_height) + c.showPage() + c.save() + + print(f"βœ” PDF created: {output_pdf}") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: cover_to_pdf.py [output.pdf]") + sys.exit(1) + + image = sys.argv[1] + output = sys.argv[2] if len(sys.argv) > 2 else None + image_to_pdf(image, output) diff --git a/gen-hash/generate-hash.js b/gen-hash/generate-hash.js new file mode 100644 index 0000000..0f287c2 --- /dev/null +++ b/gen-hash/generate-hash.js @@ -0,0 +1,7 @@ +const bcrypt = require('bcrypt'); +const password = 'Shakkal123!'; // Replace with the exact password you want to use +const saltRounds = 10; +bcrypt.hash(password, saltRounds, (err, hash) => { + if (err) console.error('Error:', err); + else console.log('Hash:', hash); +}); diff --git a/gen-hash/package-lock.json b/gen-hash/package-lock.json new file mode 100644 index 0000000..40bd058 --- /dev/null +++ b/gen-hash/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "bcrypt": "^6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + } + } +} diff --git a/gen-hash/package.json b/gen-hash/package.json new file mode 100644 index 0000000..8da26fb --- /dev/null +++ b/gen-hash/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "bcrypt": "^6.0.0" + } +} diff --git a/halifax/clean-halifax-actual-2.py b/halifax/clean-halifax-actual-2.py new file mode 100644 index 0000000..134932b --- /dev/null +++ b/halifax/clean-halifax-actual-2.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +import csv +from decimal import Decimal, InvalidOperation +import sys +from pathlib import Path +import json +from datetime import datetime + +SCRIPT_DIR = Path(__file__).resolve().parent +STATE_FILE = SCRIPT_DIR / "halifax_state.json" + +def load_state(): + if STATE_FILE.exists(): + try: + return json.loads(STATE_FILE.read_text()) + except json.JSONDecodeError: + # If it ever gets corrupted, start fresh + return {"seen_keys": []} + return {"seen_keys": []} + + +def save_state(state): + STATE_FILE.write_text(json.dumps(state, indent=2)) + + +def normalize_text(s: str) -> str: + # collapse whitespace + lowercase + return " ".join(s.split()).lower() + + +def make_key(date_str: str, payee: str, desc: str, amount: Decimal) -> str: + """ + Build a dedupe key: normalized date + payee + description + amount. + We normalize: + - date into ISO (YYYY-MM-DD) + - payee/desc into lowercased, collapsed whitespace + - amount to 2 decimal places + """ + + # Halifax gives dd/mm/yyyy – convert to ISO for consistency + date_str = date_str.strip() + try: + # If it's already ISO, this will work too + try: + d = datetime.fromisoformat(date_str).date() + except ValueError: + d = datetime.strptime(date_str, "%d/%m/%Y").date() + norm_date = d.isoformat() + except Exception: + # If parsing fails for some reason, just use the raw string + norm_date = date_str + + norm_payee = normalize_text(payee) + norm_desc = normalize_text(desc) + norm_amount = amount.quantize(Decimal("0.01")) + + return f"{norm_date}|{norm_payee}|{norm_desc}|{norm_amount}" + + +def convert(in_path, out_path): + # Load state + state = load_state() + seen_keys = set(state.get("seen_keys", [])) + + written_count = 0 + skipped_count = 0 + + # Read Halifax CSV and write a cleaned CSV for Actual + with open(in_path, newline="", encoding="utf-8-sig") as f_in, \ + open(out_path, "w", newline="", encoding="utf-8") as f_out: + + reader = csv.DictReader(f_in) + + # Output columns tailored for Actual’s CSV import + fieldnames = ["Date", "Payee", "Description", "Amount"] + writer = csv.DictWriter(f_out, fieldnames=fieldnames) + writer.writeheader() + + for row in reader: + if not row: + continue + + # Date in Halifax export (dd/mm/yyyy) + date = (row.get("Transaction Date") or "").strip() + if not date: + # Skip lines without a date + continue + + desc = (row.get("Transaction Description") or "").strip() + payee = desc or (row.get("Transaction Type") or "").strip() + + # Halifax has separate Debit / Credit columns + debit_str = (row.get("Debit Amount") or "").replace(",", "").strip() + credit_str = (row.get("Credit Amount") or "").replace(",", "").strip() + + amount = None + + # Debits (money going out) β†’ negative + if debit_str: + try: + amount = -Decimal(debit_str) + except InvalidOperation: + amount = None + + # Credits (money coming in) β†’ positive + elif credit_str: + try: + amount = Decimal(credit_str) + except InvalidOperation: + amount = None + + # If we still don't have an amount, skip the row + if amount is None: + continue + + key = make_key(date, payee, desc, amount) + if key in seen_keys: + skipped_count += 1 + continue + + seen_keys.add(key) + written_count += 1 + + writer.writerow({ + "Date": date, + "Payee": payee, + "Description": desc, + "Amount": f"{amount:.2f}", + }) + + # Keep the state from growing forever – remember last N keys + max_keys = 50000 + seen_list = list(seen_keys) + if len(seen_list) > max_keys: + seen_list = seen_list[-max_keys:] + + save_state({"seen_keys": seen_list}) + + print(f"Written cleaned file to {out_path}") + print(f" New transactions written: {written_count}") + print(f" Transactions skipped as duplicates: {skipped_count}") + + +def main(): + if len(sys.argv) < 2: + print("Usage: python clean-halifax-actual.py input.csv [output.csv]") + sys.exit(1) + + in_path = Path(sys.argv[1]) + + if len(sys.argv) >= 3: + out_path = Path(sys.argv[2]) + else: + # Default to inputname_actual.csv + out_path = in_path.with_name(in_path.stem + "_actual.csv") + + convert(in_path, out_path) + + +if __name__ == "__main__": + main() diff --git a/halifax/clean-halifax-actual.py b/halifax/clean-halifax-actual.py new file mode 100644 index 0000000..c0242b8 --- /dev/null +++ b/halifax/clean-halifax-actual.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 + +import csv +from decimal import Decimal, InvalidOperation +import sys +from pathlib import Path + + +def convert(in_path, out_path): + # Read Halifax CSV and write a cleaned CSV for Actual + with open(in_path, newline="", encoding="utf-8-sig") as f_in, \ + open(out_path, "w", newline="", encoding="utf-8") as f_out: + + reader = csv.DictReader(f_in) + + # Output columns tailored for Actual’s CSV import + fieldnames = ["Date", "Payee", "Description", "Amount"] + writer = csv.DictWriter(f_out, fieldnames=fieldnames) + writer.writeheader() + + for row in reader: + if not row: + continue + + # Date in Halifax export (dd/mm/yyyy) + date = (row.get("Transaction Date") or "").strip() + if not date: + # Skip lines without a date + continue + + desc = (row.get("Transaction Description") or "").strip() + payee = desc or (row.get("Transaction Type") or "").strip() + + # Halifax has separate Debit / Credit columns + debit_str = (row.get("Debit Amount") or "").replace(",", "").strip() + credit_str = (row.get("Credit Amount") or "").replace(",", "").strip() + + amount = None + + # Debits (money going out) β†’ negative + if debit_str: + try: + amount = -Decimal(debit_str) + except InvalidOperation: + amount = None + + # Credits (money coming in) β†’ positive + elif credit_str: + try: + amount = Decimal(credit_str) + except InvalidOperation: + amount = None + + # If we still don't have an amount, skip the row + if amount is None: + continue + + writer.writerow({ + "Date": date, + "Payee": payee, + "Description": desc, + "Amount": f"{amount:.2f}", + }) + + +def main(): + if len(sys.argv) < 2: + print("Usage: python clean_halifax_actual.py input.csv [output.csv]") + sys.exit(1) + + in_path = Path(sys.argv[1]) + + if len(sys.argv) >= 3: + out_path = Path(sys.argv[2]) + else: + # Default to inputname_actual.csv + out_path = in_path.with_name(in_path.stem + "_actual.csv") + + convert(in_path, out_path) + print(f"Written cleaned file to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/halifax/halifax_state.json b/halifax/halifax_state.json new file mode 100644 index 0000000..8b22c42 --- /dev/null +++ b/halifax/halifax_state.json @@ -0,0 +1,29 @@ +{ + "seen_keys": [ + "2025-11-03|naveed qayyum|naveed qayyum|-100.00", + "2025-11-13|mfg new john stree|mfg new john stree|-20.00", + "2025-11-28|naveed qayyum|naveed qayyum|-50.00", + "2025-11-27|mfg new john stree|mfg new john stree|-15.01", + "2025-11-27|subway @mfg new jo|subway @mfg new jo|-1.49", + "2025-11-20|n qayyum|n qayyum|10.00", + "2025-11-21|wm morrisons store|wm morrisons store|-11.65", + "2025-11-03|mrs farzana k qayy|mrs farzana k qayy|-100.00", + "2025-11-18|hockley service st|hockley service st|-30.01", + "2025-11-26|refresh vending|refresh vending|-3.00", + "2025-11-25|hockley service st|hockley service st|-35.01", + "2025-11-17|n qayyum|n qayyum|30.00", + "2025-11-28|microlise limited|microlise limited|2058.30", + "2025-11-11|sky digital|sky digital|-27.00", + "2025-11-03|ummah welfare trus|ummah welfare trus|-50.00", + "2025-11-20|www.voxi.co.uk|www.voxi.co.uk|-10.00", + "2025-11-20|mfg new john stree|mfg new john stree|-24.15", + "2025-11-05|hockley service st|hockley service st|-30.01", + "2025-11-03|amina qayyum|amina qayyum|-300.00", + "2025-11-06|the gym ltd|the gym ltd|-17.99", + "2025-11-28|mrs farzana k qayy|mrs farzana k qayy|-100.00", + "2025-11-17|naveed qayyum|naveed qayyum|-100.00", + "2025-11-24|p.o. 107 lozells r|p.o. 107 lozells r|-300.00", + "2025-11-10|morr birmingham ca|morr birmingham ca|-40.00", + "2025-11-03|lnk notemachine|lnk notemachine|-250.00" + ] +} \ No newline at end of file diff --git a/halifax/new-cleaned.py b/halifax/new-cleaned.py new file mode 100644 index 0000000..221036d --- /dev/null +++ b/halifax/new-cleaned.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +import csv +from decimal import Decimal, InvalidOperation +import sys +from pathlib import Path +import hashlib +from collections import defaultdict + + +def transaction_hash(date, payee, desc, amount): + """Generate a stable hash for each transaction.""" + base = f"{date}|{payee}|{desc}|{amount}" + return hashlib.md5(base.encode("utf-8")).hexdigest()[:10] # short ID + + +def convert(in_path, out_path): + with open(in_path, newline="", encoding="utf-8-sig") as f_in, \ + open(out_path, "w", newline="", encoding="utf-8") as f_out: + + reader = csv.DictReader(f_in) + + # Add UniqueID field to prevent Actual from treating repeated merchants as duplicates + fieldnames = ["Date", "Payee", "Description", "Amount", "UniqueID"] + writer = csv.DictWriter(f_out, fieldnames=fieldnames) + writer.writeheader() + + seen_hashes = defaultdict(int) + skipped = 0 + + for row in reader: + if not row: + continue + + date = (row.get("Transaction Date") or "").strip() + if not date: + continue + + desc = (row.get("Transaction Description") or "").strip() + payee = desc or (row.get("Transaction Type") or "").strip() + + debit_str = (row.get("Debit Amount") or "").replace(",", "").strip() + credit_str = (row.get("Credit Amount") or "").replace(",", "").strip() + amount = None + + if debit_str: + try: + amount = -Decimal(debit_str) + except InvalidOperation: + continue + elif credit_str: + try: + amount = Decimal(credit_str) + except InvalidOperation: + continue + else: + continue + + # Generate transaction fingerprint + unique_id = transaction_hash(date, payee, desc, amount) + + # Check if we've seen this transaction hash already + seen_hashes[unique_id] += 1 + if seen_hashes[unique_id] > 1: + # Optional: skip true duplicates or just warn + print(f"⚠️ Duplicate detected: {date} | {payee} | {amount}") + skipped += 1 + continue + + writer.writerow({ + "Date": date, + "Payee": payee, + "Description": desc, + "Amount": f"{amount:.2f}", + "UniqueID": unique_id, + }) + + print(f"\nβœ… Written cleaned file to {out_path}") + if skipped: + print(f"⚠️ Skipped {skipped} duplicate entries based on content hash.") + + +def main(): + if len(sys.argv) < 2: + print("Usage: python clean_halifax_actual.py input.csv [output.csv]") + sys.exit(1) + + in_path = Path(sys.argv[1]) + + if len(sys.argv) >= 3: + out_path = Path(sys.argv[2]) + else: + out_path = in_path.with_name(in_path.stem + "_actual.csv") + + convert(in_path, out_path) + + +if __name__ == "__main__": + main() diff --git a/legacy/auto-org.sh b/legacy/auto-org.sh new file mode 100755 index 0000000..282e881 --- /dev/null +++ b/legacy/auto-org.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +TL_DIR="/home/zaine/master-folder/org_files/todo" +MASTER_TL="/home/zaine/master-folder/org_files/todo/master-tl.org" + +# Determine the weekly file +WEEKLY_FILE="$TL_DIR/$(date +%Y)-week-$(date +%V).org" + +# Ensure weekly file exists +if [[ ! -f "$WEEKLY_FILE" ]]; then + touch "$WEEKLY_FILE" + echo "Created new weekly todo file: $WEEKLY_FILE" +fi + +inotifywait -m "$TL_DIR" -e create -e moved_to -e modify --format '%w%f' | +while read filepath; do + if [[ "$filepath" == "$MASTER_TL" || "$filepath" =~ ^.*\/\.goutputstream-.*$ || "$filepath" =~ ^.*\/\..* ]]; then + continue + fi + + filename=$(basename "$filepath") + + echo "Processing $filepath..." + + # Ensure it's a valid, non-empty file + if [[ -f "$filepath" && -s "$filepath" ]]; then + TMP_FILE=$(mktemp) + { + echo "* START $filename *" + cat "$filepath" + echo "* END $filename *" + } > "$TMP_FILE" + + # Remove old content for this file in `master-tl.org` + sed -i "/\* START $filename \*/,/\* END $filename \*/d" "$MASTER_TL" + + # Append new content + cat "$TMP_FILE" >> "$MASTER_TL" + rm "$TMP_FILE" + + echo "Updated master task list with $filename." + else + echo "Skipping empty or unreadable file: $filepath" + fi +done diff --git a/legacy/crawler.py b/legacy/crawler.py new file mode 100644 index 0000000..194d13a --- /dev/null +++ b/legacy/crawler.py @@ -0,0 +1,38 @@ +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin, urlparse +import pandas as pd + +# Starting URL +base_url = 'https://zainezq.com/' +visited = set() +to_visit = [base_url] +found_urls = [] + +def is_internal(link): + return urlparse(link).netloc == urlparse(base_url).netloc or urlparse(link).netloc == '' + +while to_visit: + url = to_visit.pop(0) + if url in visited: + continue + try: + print(f'Crawling: {url}') + response = requests.get(url, timeout=10) + visited.add(url) + if response.status_code == 200: + soup = BeautifulSoup(response.text, 'html.parser') + found_urls.append(url) + for a_tag in soup.find_all('a', href=True): + href = a_tag['href'] + full_url = urljoin(url, href.split('#')[0]) + if is_internal(full_url) and full_url not in visited and full_url not in to_visit: + to_visit.append(full_url) + except Exception as e: + print(f'Error fetching {url}: {e}') + +# Export to Excel +df = pd.DataFrame(found_urls, columns=["URL"]) +df.to_excel("website_pages.xlsx", index=False) + +print("βœ… Sitemap saved to website_pages.xlsx") diff --git a/legacy/fetch.py b/legacy/fetch.py new file mode 100644 index 0000000..4cdd2e2 --- /dev/null +++ b/legacy/fetch.py @@ -0,0 +1,89 @@ +import json +import subprocess + +# Function to fetch JSON data using curl +def fetch_json_data(curl_command): + try: + # Run the curl command and capture output + result = subprocess.run(curl_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + raise Exception(f"curl command failed: {result.stderr}") + # Parse the JSON output + return json.loads(result.stdout) + except json.JSONDecodeError as e: + print(f"Error parsing JSON: {e}") + return [] + except Exception as e: + print(f"Error executing curl: {e}") + return [] + +# Function to clean and simplify container names +def clean_container_name(name): + # Remove leading slash and any suffixes like '-1' or '--1' + name = name.lstrip('/') + # Map container names to simplified service names + name_mapping = { + 'nextcloud-app-1': 'nextcloud', + 'pgadmin': 'pgadmin', + 'code-server': 'code', + 'glance': 'glance', + 'stirling-pdf-stirling-pdf-1': 'pdf', + 'technitium-dns': 'dns', + 'portainer-portainer-1': 'portainer', + 'browserfile-filebrowser-1': 'filebrowser', + 'calibre-web': 'calibre', + 'miniflux_db': 'miniflux-db', # Note: This is the DB container, may not need a link + 'miniflux': 'miniflux', + 'jupyterlab': 'jupyter' + } + return name_mapping.get(name, name) + +# Function to generate links +def generate_links(data): + base_url = "zserver.zapto.org" + links = [] + + for container in data: + # Get the first name from the Names list + if container.get("Names"): + container_name = container["Names"][0] + service_name = clean_container_name(container_name) + # Skip internal services like miniflux_db that typically don't have public links + if service_name.endswith('-db'): + continue + # Check if the container has exposed ports + if container.get("Ports"): + for port in container["Ports"]: + # Only include containers with public ports (bound to 0.0.0.0 or ::) + if port.get("IP") in ["0.0.0.0", "::"] and port.get("PublicPort"): + link = f"{base_url}/{service_name}" + links.append({"Service": service_name.capitalize(), "Link": link}) + break # Only need one public port per container + + return links + +# Main function +def main(): + # Example curl command to fetch container data (adjust as needed) + # For Docker API, you might use: curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json + # For Portainer, you might use: curl -s -H "Authorization: Bearer " http:///api/endpoints//docker/containers/json + curl_command = 'curl -X GET "https://zserver.zapto.org/portainer/api/endpoints/2/docker/containers/json?all=true" -H "X-API-Key: ptr_KFQqKse9K4Nc9M5jnpc61fpAvGzdTOXTzswz9CwOF74=" -u "admin:shakkal123"' + + # Fetch JSON data + data = fetch_json_data(curl_command) + + if not data: + print("No data fetched or invalid JSON.") + return + + # Generate links + links = generate_links(data) + + # Print links in a table format + print("| Service | Link |") + print("|---------|------|") + for link in links: + print(f"| {link['Service']} | {link['Link']} |") + +if __name__ == "__main__": + main() diff --git a/legacy/insert_services.py b/legacy/insert_services.py new file mode 100644 index 0000000..f60b306 --- /dev/null +++ b/legacy/insert_services.py @@ -0,0 +1,90 @@ +import pyodbc +import subprocess +import re +from datetime import datetime + +conn = pyodbc.connect( + 'DRIVER={ODBC Driver 18 for SQL Server};' + 'SERVER=localhost;' + 'DATABASE=HomelabDB;' + 'UID=sa;' + 'PWD=Helloadmin123!;' + 'Encrypt=no;' +) + +cursor = conn.cursor() + +output = subprocess.check_output( + ["docker", "ps", "--format", "{{.Names}} {{.Ports}} {{.Status}}"] +) + +lines = output.decode().splitlines() + +for line in lines: + parts = line.split(" ", 2) + name = parts[0] + ports = parts[1] if len(parts) > 1 else "" + raw_status = parts[2] if len(parts) > 2 else "Unknown" + + # Extract main status (Up, Exited, etc.) + status_match = re.search(r"\b(Up|Exited|Restarting|Paused|Created|Dead)\b", raw_status) + status = status_match.group(1) if status_match else "Unknown" + + # Extract uptime text (e.g. "7 days", "2 hours", etc.) + uptime_match = re.search(r"(?:Up|Exited)\s+(.*?)(?:\s*\(|$)", raw_status) + uptime = uptime_match.group(1).strip() if uptime_match else None + + # Extract health (inside parentheses like "(healthy)" or "(unhealthy)") + health_match = re.search(r"\((healthy|unhealthy)\)", raw_status, re.IGNORECASE) + health = health_match.group(1).lower() if health_match else "none" + + # Get creation date from `docker inspect` + # Get creation date from `docker inspect` + try: + inspect_out = subprocess.check_output( + ["docker", "inspect", "-f", "{{.Created}}", name] + ) + created_str = inspect_out.decode().strip() # e.g. "2025-11-07T09:25:03.548912345Z" + + # Normalize the timestamp (strip Z, keep only first 6 digits of microseconds) + created_str = created_str.rstrip("Z") + if "." in created_str: + created_str = re.sub(r"\.(\d+)", lambda m: "." + m.group(1)[:6], created_str) + + created_dt = datetime.fromisoformat(created_str) + except Exception as e: + print(f"Warning: could not parse creation date for {name}: {e}") + created_dt = None + + # Extract all port numbers + port_matches = re.findall(r"(\d+)->", ports) + if not port_matches: + port_matches = re.findall(r"(\d+)/tcp", ports) + + if not port_matches: + cursor.execute(""" + IF EXISTS (SELECT 1 FROM Services WHERE ServiceName = ? AND Port IS NULL) + UPDATE Services + SET Status = ?, HealthStatus = ?, Uptime = ?, InstalledAt = ? + WHERE ServiceName = ? AND Port IS NULL + ELSE + INSERT INTO Services (ServerID, ServiceName, Port, Status, HealthStatus, Uptime, InstalledAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (name, status, health, uptime, created_dt, name, + 1, name, None, status, health, uptime, created_dt)) + else: + for port in port_matches: + cursor.execute(""" + IF EXISTS (SELECT 1 FROM Services WHERE ServiceName = ? AND Port = ?) + UPDATE Services + SET Status = ?, HealthStatus = ?, Uptime = ?, InstalledAt = ? + WHERE ServiceName = ? AND Port = ? + ELSE + INSERT INTO Services (ServerID, ServiceName, Port, Status, HealthStatus, Uptime, InstalledAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (name, int(port), status, health, uptime, created_dt, + name, int(port), + 1, name, int(port), status, health, uptime, created_dt)) + +conn.commit() +conn.close() diff --git a/legacy/move_rss.py b/legacy/move_rss.py new file mode 100644 index 0000000..93a3841 --- /dev/null +++ b/legacy/move_rss.py @@ -0,0 +1,17 @@ +import os +import shutil + +# Define source and destination paths +downloads_path = os.path.join(os.path.expanduser('~'), 'Downloads', 'rss-feed.xml') +destination_path = '/home/zaine/Documents/projects/web-port/src/assets/rss-feed.xml' + +# Check if the file exists in Downloads +if os.path.exists(downloads_path): + try: + # Move and overwrite the file + shutil.move(downloads_path, destination_path) + print(f"Successfully moved and overwrote: {destination_path}") + except Exception as e: + print(f"Error moving file: {e}") +else: + print("rss.xml not found in Downloads folder.") diff --git a/legacy/pc_runner.sh b/legacy/pc_runner.sh new file mode 100755 index 0000000..443cbf1 --- /dev/null +++ b/legacy/pc_runner.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Bash Script To Power On PC and SSH into it. + +MAC_ADDRESS="00:d8:61:c5:e8:69" +USER="zaine" +IP="192.168.0.135" + +echo "Hang tight..." +# Call wakeonlan and capture the output +OUTPUT=$(wakeonlan "$MAC_ADDRESS" 2>&1) # 2>&1 captures both stdout and stderr + +echo "wakeonlan output: $OUTPUT" + +# Analyze the output (simple example) +if [[ $OUTPUT == *"Sending magic packet"* ]]; then + echo "Wake-on-LAN packet sent successfully." +else + echo "Error sending magic packet or no response: $OUTPUT" +fi + +# Sleep for 15 seconds so that the PC can wake up +sleep 45 + +echo "Attempting to SSH into $USER@$IP..." +# Start SSH and let it take over the terminal +ssh -X "$USER@$IP" diff --git a/legacy/script_for_wol_and_ssh.sh b/legacy/script_for_wol_and_ssh.sh new file mode 100755 index 0000000..5997201 --- /dev/null +++ b/legacy/script_for_wol_and_ssh.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Bash Script To Power On PC and SSH into it. +# AUTHOR: Zaine Qayyum + +# replace with mac address of pc you want to ssh into +MAC_ADDRESS="xx:xx:xx:xx:xx:xx" +# replace with username of the pc you want to ssh into +USER="" +# replace with ip address of the pc you want to ssh into +IP="xxx.xxx.xxx.xxx" + +echo "Hang tight..." +# Call wakeonlan and capture the output +OUTPUT=$(wakeonlan "$MAC_ADDRESS" 2>&1) # 2>&1 captures both stdout and stderr + +echo "wakeonlan output: $OUTPUT" + +# Ouput analysis, with if-else checks +if [[ $OUTPUT == *"Sending magic packet"* ]]; then + echo "Wake-on-LAN packet sent successfully." +else + echo "Error sending magic packet or no response: $OUTPUT" +fi + +# Sleep for x seconds so that the PC can wake up +# Replace with the time it'll take for pc to boot, 15 sec is set as default +sleep 15 + +echo "Attempting to SSH into $USER@$IP..." +# Start SSH and let it take over the terminal +ssh -X "$USER@$IP" diff --git a/legacy/website_pages.xlsx b/legacy/website_pages.xlsx new file mode 100644 index 0000000..eb7adc9 Binary files /dev/null and b/legacy/website_pages.xlsx differ diff --git a/miniflux-discord.py b/miniflux-discord.py new file mode 100755 index 0000000..689bcaa --- /dev/null +++ b/miniflux-discord.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +import os, random, requests, discord +from discord import app_commands +from discord.ext import commands +from datetime import datetime +from pathlib import Path + +docstring = """ +This script connects to a Miniflux RSS feed reader instance and a Discord bot. +It allows users to fetch random unread articles from specified RSS feeds +via Discord commands, mark them as read in Miniflux, and append their details +to an Org-mode file for personal knowledge management. + +These are the available commands: +- /rss [feed]: Fetch a random unread article from the specified feed. +- /rss-any: Fetch a random unread article from any feed. +- /next: Fetch another article from the same feed. +- /done: Mark the current article as read and save it to the Org file. +""" + +# ----------------------------- +# CONFIG +# ----------------------------- +MINIFLUX_URL = "https://miniflux.zainezq.com" +MINIFLUX_TOKEN = "Q_tDLF7Ht-aPjub2Xsbn6JCKM1WbNUEDfjHPYAIzT0I=" +DISCORD_TOKEN = "MTQ1OTYwNzg2Mjg1Nzk1NzY0OQ.GBTRdl.iqfwD3kMN277z7rhm6lG5EgwSyqyPoCF1t8NVI" +ORG_RSS_FILE = "/home/zaine/master-folder/org_files/org_roam/20260111204228-rss_articles.org" +GUILD_ID = 1459545736961327116 + +HEADERS = { + "X-Auth-Token": MINIFLUX_TOKEN +} + +intents = discord.Intents.default() +bot = commands.Bot(command_prefix="!", intents=intents) + +tree = bot.tree + +current_state = {} + + + +def append_to_org(entry): + org_path = Path(ORG_RSS_FILE) + org_path.parent.mkdir(parents=True, exist_ok=True) + + title = entry["title"].replace("\n", " ").strip() + url = entry["url"] + feed = entry["feed"]["title"] + entry_id = entry["id"] + date_read = datetime.now().strftime("%Y-%m-%d %H:%M") + + org_block = f"""* [[{url}][{title}]] + :PROPERTIES: + :FEED: {feed} + :MINIFLUX_ID: {entry_id} + :READ_AT: {date_read} + :END: + +""" + + with org_path.open("a", encoding="utf-8") as f: + f.write(org_block) + + +def get_feeds(): + r = requests.get(f"{MINIFLUX_URL}/v1/feeds", headers=HEADERS, timeout=10) + r.raise_for_status() + return r.json() + + +def get_unread_entries(feed_id=None, limit=100): + params = {"status": "unread", "limit": limit} + if feed_id: + params["feed_id"] = feed_id + + r = requests.get( + f"{MINIFLUX_URL}/v1/entries", + headers=HEADERS, + params=params, + timeout=10 + ) + r.raise_for_status() + return r.json()["entries"] + + +def mark_as_read(entry_id): + r = requests.put( + f"{MINIFLUX_URL}/v1/entries", + headers=HEADERS, + json={"entry_ids": [entry_id], "status": "read"}, + timeout=10 + ) + r.raise_for_status() + +async def send_random_entry( + interaction: discord.Interaction, + feed_id: int, +): + entries = get_unread_entries(feed_id=feed_id) + + if not entries: + await interaction.followup.send( + "πŸŽ‰ No unread articles in this feed!" + ) + return + + entry = random.choice(entries) + + current_state[interaction.channel_id] = { + "entry": entry, + "feed_id": feed_id + } + + embed = discord.Embed( + title=entry["title"], + url=entry["url"], + description=(entry.get("summary") or "")[:500], + color=0x3498DB + ) + + embed.set_footer( + text=f"{entry['feed']['title']} β€’ /next or /done" + ) + + await interaction.followup.send(embed=embed) + + + +async def feed_autocomplete( + interaction: discord.Interaction, + current: str, +): + feeds = get_feeds() + return [ + app_commands.Choice(name=f["title"], value=str(f["id"])) + for f in feeds + if current.lower() in f["title"].lower() + ][:25] + +@tree.command(name="rss", description="Get a random unread RSS article") +@app_commands.describe(feed="Which feed to read from") +@app_commands.autocomplete(feed=feed_autocomplete) +async def rss(interaction: discord.Interaction, feed: str): + await interaction.response.defer() + await send_random_entry(interaction, feed_id=int(feed)) + +@tree.command(name="rss-any", description="Get a random unread RSS article from any feed") +async def rss_any(interaction: discord.Interaction): + await interaction.response.defer() + await send_random_entry(interaction, feed_id=None) + + +@tree.command(name="next", description="Get another article from the same feed") +async def next_article(interaction: discord.Interaction): + state = current_state.get(interaction.channel_id) + + if not state: + await interaction.response.send_message( + "⚠️ No active feed. Use /rss first.", + ephemeral=True + ) + return + + await interaction.response.defer() + await send_random_entry( + interaction, + feed_id=state.get("feed_id") + + ) + +@tree.command(name="done", description="Mark the current article as read and save to Org") +async def done(interaction: discord.Interaction): + state = current_state.get(interaction.channel_id) + + if not state: + await interaction.response.send_message( + "⚠️ No article to mark as read.", + ephemeral=True + ) + return + + if "entry" not in state: + await interaction.response.send_message( + "⚠️ This article was queued before the last update. Please use /rss again.", + ephemeral=True + ) + del current_state[interaction.channel_id] + return + + entry = state["entry"] + + mark_as_read(entry["id"]) + + append_to_org(entry) + + del current_state[interaction.channel_id] + + await interaction.response.send_message( + "βœ… Article marked as read and added to Org" + ) + + +@bot.event +async def on_ready(): + guild = discord.Object(id=GUILD_ID) + tree.copy_global_to(guild=guild) + await tree.sync(guild=guild) + print(f"βœ… Logged in as {bot.user} (guild-only sync)") + + +if __name__ == "__main__": + if not all([MINIFLUX_URL, MINIFLUX_TOKEN, DISCORD_TOKEN]): + raise RuntimeError("Missing environment variables") + + bot.run(DISCORD_TOKEN) diff --git a/miniflux-discord.py~ b/miniflux-discord.py~ new file mode 100755 index 0000000..6b0d110 --- /dev/null +++ b/miniflux-discord.py~ @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import os, random, requests, discord +from discord import app_commands +from discord.ext import commands +from datetime import datetime +from pathlib import Path + +# ----------------------------- +# CONFIG +# ----------------------------- +MINIFLUX_URL = "https://miniflux.zainezq.com" +MINIFLUX_TOKEN = "Q_tDLF7Ht-aPjub2Xsbn6JCKM1WbNUEDfjHPYAIzT0I=" +DISCORD_TOKEN = "MTQ1OTYwNzg2Mjg1Nzk1NzY0OQ.GBTRdl.iqfwD3kMN277z7rhm6lG5EgwSyqyPoCF1t8NVI" +ORG_RSS_FILE = "/home/zaine/master-folder/org_files/org_roam/20260111204228-rss_articles.org" +GUILD_ID = 1459545736961327116 + +HEADERS = { + "X-Auth-Token": MINIFLUX_TOKEN +} + +intents = discord.Intents.default() +bot = commands.Bot(command_prefix="!", intents=intents) + +tree = bot.tree + +current_state = {} + + + +def append_to_org(entry): + org_path = Path(ORG_RSS_FILE) + org_path.parent.mkdir(parents=True, exist_ok=True) + + title = entry["title"].replace("\n", " ").strip() + url = entry["url"] + feed = entry["feed"]["title"] + entry_id = entry["id"] + date_read = datetime.now().strftime("%Y-%m-%d %H:%M") + + org_block = f"""* [[{url}][{title}]] + :PROPERTIES: + :FEED: {feed} + :MINIFLUX_ID: {entry_id} + :READ_AT: {date_read} + :END: + +""" + + with org_path.open("a", encoding="utf-8") as f: + f.write(org_block) + + +def get_feeds(): + r = requests.get(f"{MINIFLUX_URL}/v1/feeds", headers=HEADERS, timeout=10) + r.raise_for_status() + return r.json() + + +def get_unread_entries(feed_id=None, limit=100): + params = {"status": "unread", "limit": limit} + if feed_id: + params["feed_id"] = feed_id + + r = requests.get( + f"{MINIFLUX_URL}/v1/entries", + headers=HEADERS, + params=params, + timeout=10 + ) + r.raise_for_status() + return r.json()["entries"] + + +def mark_as_read(entry_id): + r = requests.put( + f"{MINIFLUX_URL}/v1/entries", + headers=HEADERS, + json={"entry_ids": [entry_id], "status": "read"}, + timeout=10 + ) + r.raise_for_status() + +async def send_random_entry( + interaction: discord.Interaction, + feed_id: int, +): + entries = get_unread_entries(feed_id=feed_id) + + if not entries: + await interaction.followup.send( + "πŸŽ‰ No unread articles in this feed!" + ) + return + + entry = random.choice(entries) + + current_state[interaction.channel_id] = { + "entry": entry, + "feed_id": feed_id + } + + embed = discord.Embed( + title=entry["title"], + url=entry["url"], + description=(entry.get("summary") or "")[:500], + color=0x3498DB + ) + + embed.set_footer( + text=f"{entry['feed']['title']} β€’ /next or /done" + ) + + await interaction.followup.send(embed=embed) + + + +async def feed_autocomplete( + interaction: discord.Interaction, + current: str, +): + feeds = get_feeds() + return [ + app_commands.Choice(name=f["title"], value=str(f["id"])) + for f in feeds + if current.lower() in f["title"].lower() + ][:25] + +@tree.command(name="rss", description="Get a random unread RSS article") +@app_commands.describe(feed="Which feed to read from") +@app_commands.autocomplete(feed=feed_autocomplete) +async def rss(interaction: discord.Interaction, feed: str): + await interaction.response.defer() + await send_random_entry(interaction, feed_id=int(feed)) + +@tree.command(name="rss-any", description="Get a random unread RSS article from any feed") +async def rss_any(interaction: discord.Interaction): + await interaction.response.defer() + await send_random_entry(interaction, feed_id=None) + + +@tree.command(name="next", description="Get another article from the same feed") +async def next_article(interaction: discord.Interaction): + state = current_state.get(interaction.channel_id) + + if not state: + await interaction.response.send_message( + "⚠️ No active feed. Use /rss first.", + ephemeral=True + ) + return + + await interaction.response.defer() + await send_random_entry( + interaction, + feed_id=state.get("feed_id") + + ) + +@tree.command(name="done", description="Mark the current article as read and save to Org") +async def done(interaction: discord.Interaction): + state = current_state.get(interaction.channel_id) + + if not state: + await interaction.response.send_message( + "⚠️ No article to mark as read.", + ephemeral=True + ) + return + + if "entry" not in state: + await interaction.response.send_message( + "⚠️ This article was queued before the last update. Please use /rss again.", + ephemeral=True + ) + del current_state[interaction.channel_id] + return + + entry = state["entry"] + + mark_as_read(entry["id"]) + + append_to_org(entry) + + del current_state[interaction.channel_id] + + await interaction.response.send_message( + "βœ… Article marked as read and added to Org" + ) + + +@bot.event +async def on_ready(): + guild = discord.Object(id=GUILD_ID) + tree.copy_global_to(guild=guild) + await tree.sync(guild=guild) + print(f"βœ… Logged in as {bot.user} (guild-only sync)") + + +if __name__ == "__main__": + if not all([MINIFLUX_URL, MINIFLUX_TOKEN, DISCORD_TOKEN]): + raise RuntimeError("Missing environment variables") + + bot.run(DISCORD_TOKEN) diff --git a/org-books-calibre.py b/org-books-calibre.py new file mode 100755 index 0000000..7436d6c --- /dev/null +++ b/org-books-calibre.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 + +import sqlite3 +from datetime import datetime +from pathlib import Path +import tempfile +import os +import json +import sys, time + +docstring = """ +This script reads metadata and application state from a Calibre library, +merges the information, and generates an Org-mode file suitable for +org-roam, categorising books by shelves and including read status. If the +output file already exists, it preserves the existing org-roam header, and +writes the updated book list below it. + +This script runs on a cron schedule at midnight every day and outputs a JSON summary of its execution, in the form of +{"script": "calibre_org_sync", "status": "success", "duration_ms": 3, "output": "/home/zaine/master-folder/org_files/org_roam/20250724230557-books_org_agenda.org", "timestamp": "2026-01-14T00:00:02.077267"}. +""" + +# ───────────────────────────────────────────────────────────── +# Configuration +# ───────────────────────────────────────────────────────────── + +METADATA_DB = Path("/home/zaine/master-folder/projects/calibre/library/metadata.db") +APP_DB = Path("/home/zaine/master-folder/projects/calibre/data/app.db") + +CALIBRE_LIBRARY_ROOT = Path("/home/zaine/master-folder/projects/calibre/library") + +OUTPUT_ORG = Path("/home/zaine/master-folder/org_files/org_roam/20250724230557-books_org_agenda.org") + +UNSORTED_SHELF = "Unsorted" + + +# ───────────────────────────────────────────────────────────── +# Database readers +# ───────────────────────────────────────────────────────────── + +def read_metadata(db_path): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + query = """ + SELECT + b.id AS book_id, + b.title AS title, + b.path AS calibre_path, + GROUP_CONCAT(a.name, ', ') AS authors + FROM books b + LEFT JOIN books_authors_link bal ON b.id = bal.book + LEFT JOIN authors a ON bal.author = a.id + GROUP BY b.id + ORDER BY b.title COLLATE NOCASE; + """ + + cur.execute(query) + rows = cur.fetchall() + conn.close() + + books = {} + for row in rows: + books[row["book_id"]] = { + "id": row["book_id"], + "title": row["title"], + "authors": row["authors"] or "Unknown", + "calibre_path": row["calibre_path"], + } + + return books + + +def read_app_state(db_path): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # --- Shelves per book --- + shelf_query = """ + SELECT + bsl.book_id AS book_id, + s.name AS shelf + FROM book_shelf_link bsl + JOIN shelf s ON bsl.shelf = s.id; + """ + cur.execute(shelf_query) + shelf_rows = cur.fetchall() + + shelves_by_book = {} + for row in shelf_rows: + shelves_by_book.setdefault(row["book_id"], []).append(row["shelf"]) + + # --- Read status + completion time --- + status_query = """ + SELECT + book_id, + read_status, + last_modified + FROM book_read_link; + """ + cur.execute(status_query) + status_rows = cur.fetchall() + + status_by_book = {} + completed_at_by_book = {} + + for r in status_rows: + if r["read_status"] == 1: + status_by_book[r["book_id"]] = "Read" + completed_at_by_book[r["book_id"]] = r["last_modified"] + else: + status_by_book[r["book_id"]] = "Unread" + + conn.close() + + return shelves_by_book, status_by_book, completed_at_by_book + + +# ───────────────────────────────────────────────────────────── +# Merge logic +# ───────────────────────────────────────────────────────────── + +def merge_books(metadata, shelves, statuses, completed): + library = {} + + for book_id, book in metadata.items(): + book_shelves = shelves.get(book_id, [UNSORTED_SHELF]) + status = statuses.get(book_id, "Unread") + completed_at = completed.get(book_id) + + for shelf in book_shelves: + library.setdefault(shelf, []).append({ + **book, + "status": status, + "completed_at": completed_at, + "abs_path": CALIBRE_LIBRARY_ROOT / book["calibre_path"], + }) + + return library + +def read_org_roam_header(path): + """ + Returns the org-roam header (inclusive of first :END:) + or None if file does not exist. + """ + if not path.exists(): + return None + + lines = path.read_text(encoding="utf-8").splitlines() + for i, line in enumerate(lines): + if line.strip() == ":END:": + return "\n".join(lines[: i + 1]).rstrip() + raise RuntimeError("No org-roam PROPERTIES drawer found") + + +# ───────────────────────────────────────────────────────────── +# Org generation +# ───────────────────────────────────────────────────────────── +def emit_org_body(library): + lines = [] + + lines.append("#+TITLE: Library") + lines.append("#+AUTHOR: Auto-generated") + lines.append("#+filetags: :books:org:index:") + lines.append("#+DATE: 2025-05-19") + lines.append("#+STARTUP: content") + lines.append(f"#+PROPERTY: GENERATED_AT {datetime.now().isoformat()}") + lines.append("") + + for shelf in sorted(library.keys(), key=str.lower): + lines.append(f"* {shelf}") + + books = sorted( + library[shelf], + key=lambda b: b["title"].lower() + ) + + for b in books: + lines.append(f"** {b['title']} :{b['status']}:") + lines.append(":PROPERTIES:") + lines.append(f":AUTHOR: {b['authors']}") + lines.append(f":STATUS: {b['status']}") + lines.append(f":CALIBRE_ID: {b['id']}") + if b.get("completed_at"): + lines.append(f":COMPLETED_AT: {b['completed_at']}") + lines.append(f":PATH: {b['abs_path']}") + lines.append(":END:") + lines.append("") + + return "\n".join(lines) + + +def write_library_org(path, library): + header = read_org_roam_header(path) + + if header is None: + raise RuntimeError( + "library.org must already exist and contain an org-roam ID" + ) + + body = emit_org_body(library) + + content = header + "\n\n" + body + atomic_write(path, content) + +# ───────────────────────────────────────────────────────────── +# Atomic write +# ───────────────────────────────────────────────────────────── + +def atomic_write(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile( + "w", + delete=False, + dir=str(path.parent), + encoding="utf-8" + ) as tmp: + tmp.write(content) + temp_name = tmp.name + + os.replace(temp_name, path) + + +# ───────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────── + +def main(): + metadata = read_metadata(METADATA_DB) + shelves, statuses, completed = read_app_state(APP_DB) + library = merge_books(metadata, shelves, statuses, completed) + write_library_org(OUTPUT_ORG, library) + +if __name__ == "__main__": + start = time.time() + try: + main() + duration_ms = int((time.time() - start) * 1000) + + print(json.dumps({ + "script": "calibre_org_sync", + "status": "success", + "duration_ms": duration_ms, + "output": str(OUTPUT_ORG), + "timestamp": datetime.now().isoformat() + })) + + sys.exit(0) + + except Exception as e: + duration_ms = int((time.time() - start) * 1000) + + print(json.dumps({ + "script": "calibre_org_sync", + "status": "failure", + "duration_ms": duration_ms, + "error": str(e) + }), file=sys.stderr) + + sys.exit(1) diff --git a/org_logs_to_discord.py b/org_logs_to_discord.py new file mode 100755 index 0000000..d12f63d --- /dev/null +++ b/org_logs_to_discord.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +import json +import requests +from pathlib import Path +from datetime import datetime + +docstring = """ +This script reads log files generated by various Org automation scripts, +parses their contents to determine success or failure statuses, and sends +a summary report as an embed message to a specified Discord webhook URL. + +It runs on a cron schedule daily at 6 AM. +""" + +# ----------------------------- +# CONFIG +# ----------------------------- +LOG_DIR = Path("/home/zaine/logs") + +CALIBRE_LOG = LOG_DIR / "org-books-calibre.log" +ROAM_LOG = LOG_DIR / "org-roam.log" +WEB_LOG = LOG_DIR / "org-web.log" + +DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/1460004143870513464/kRSTsNMXU8AL3Pnj-t1EUKxrLb6cBKROdMVyowX02P46CPmayiB-RHi4UH5g6tCZQf5l" + +# ----------------------------- +# HELPERS +# ----------------------------- +def send_discord_embed(embed: dict): + payload = { + "embeds": [embed] + } + r = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) + r.raise_for_status() + + +def parse_calibre_log(path: Path): + successes = 0 + failures = 0 + last_ts = None + + if not path.exists(): + return successes, failures, last_ts + + for line in path.read_text().splitlines(): + try: + entry = json.loads(line) + status = entry.get("status") + ts = entry.get("timestamp") + + if status == "success": + successes += 1 + else: + failures += 1 + + if ts: + last_ts = ts + except json.JSONDecodeError: + failures += 1 + + return successes, failures, last_ts + + +def parse_roam_log(path: Path): + if not path.exists(): + return False + return "βœ… Full rebuild complete" in path.read_text() + + +def parse_web_log(path: Path): + if not path.exists(): + return False + + text = path.read_text() + return ( + "Successfully installed" in text + and "JSON saved to" in text + ) + + +def format_ts(ts): + if not ts: + return "Unknown" + try: + return datetime.fromisoformat(ts).strftime("%Y-%m-%d %H:%M") + except ValueError: + return ts + + +# ----------------------------- +# MAIN +# ----------------------------- +def main(): + cal_ok, cal_fail, cal_last = parse_calibre_log(CALIBRE_LOG) + roam_ok = parse_roam_log(ROAM_LOG) + web_ok = parse_web_log(WEB_LOG) + + all_ok = (cal_fail == 0) and roam_ok and web_ok + + embed = { + "title": "Org Automation Status", + "color": 0x2ECC71 if all_ok else 0xE74C3C, + "fields": [ + { + "name": "Calibre β†’ Org", + "value": ( + f"βœ… Successes: **{cal_ok}**\n" + f"❌ Failures: **{cal_fail}**\n" + f"⏱ Last run: `{format_ts(cal_last)}`" + ), + "inline": False + }, + { + "name": "Org-Roam", + "value": "βœ… Successful rebuild" if roam_ok else "❌ No success marker found", + "inline": True + }, + { + "name": "Org-Web", + "value": "βœ… Build + index complete" if web_ok else "❌ Build or index incomplete", + "inline": True + } + ], + "footer": { + "text": "Generated automatically from ~/logs" + }, + "timestamp": datetime.utcnow().isoformat() + } + + send_discord_embed(embed) + + +if __name__ == "__main__": + main() diff --git a/org_logs_to_discord.py~ b/org_logs_to_discord.py~ new file mode 100755 index 0000000..28b9ec9 --- /dev/null +++ b/org_logs_to_discord.py~ @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +import json +import requests +from pathlib import Path +from datetime import datetime + +# ----------------------------- +# CONFIG +# ----------------------------- +LOG_DIR = Path("/home/zaine/logs") + +CALIBRE_LOG = LOG_DIR / "org-books-calibre.log" +ROAM_LOG = LOG_DIR / "org-roam.log" +WEB_LOG = LOG_DIR / "org-web.log" + +DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/1460004143870513464/kRSTsNMXU8AL3Pnj-t1EUKxrLb6cBKROdMVyowX02P46CPmayiB-RHi4UH5g6tCZQf5l" + +# ----------------------------- +# HELPERS +# ----------------------------- +def send_discord_embed(embed: dict): + payload = { + "embeds": [embed] + } + r = requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) + r.raise_for_status() + + +def parse_calibre_log(path: Path): + successes = 0 + failures = 0 + last_ts = None + + if not path.exists(): + return successes, failures, last_ts + + for line in path.read_text().splitlines(): + try: + entry = json.loads(line) + status = entry.get("status") + ts = entry.get("timestamp") + + if status == "success": + successes += 1 + else: + failures += 1 + + if ts: + last_ts = ts + except json.JSONDecodeError: + failures += 1 + + return successes, failures, last_ts + + +def parse_roam_log(path: Path): + if not path.exists(): + return False + return "βœ… Full rebuild complete" in path.read_text() + + +def parse_web_log(path: Path): + if not path.exists(): + return False + + text = path.read_text() + return ( + "Successfully installed" in text + and "JSON saved to" in text + ) + + +def format_ts(ts): + if not ts: + return "Unknown" + try: + return datetime.fromisoformat(ts).strftime("%Y-%m-%d %H:%M") + except ValueError: + return ts + + +# ----------------------------- +# MAIN +# ----------------------------- +def main(): + cal_ok, cal_fail, cal_last = parse_calibre_log(CALIBRE_LOG) + roam_ok = parse_roam_log(ROAM_LOG) + web_ok = parse_web_log(WEB_LOG) + + all_ok = (cal_fail == 0) and roam_ok and web_ok + + embed = { + "title": "Org Automation Status", + "color": 0x2ECC71 if all_ok else 0xE74C3C, + "fields": [ + { + "name": "Calibre β†’ Org", + "value": ( + f"βœ… Successes: **{cal_ok}**\n" + f"❌ Failures: **{cal_fail}**\n" + f"⏱ Last run: `{format_ts(cal_last)}`" + ), + "inline": False + }, + { + "name": "Org-Roam", + "value": "βœ… Successful rebuild" if roam_ok else "❌ No success marker found", + "inline": True + }, + { + "name": "Org-Web", + "value": "βœ… Build + index complete" if web_ok else "❌ Build or index incomplete", + "inline": True + } + ], + "footer": { + "text": "Generated automatically from ~/logs" + }, + "timestamp": datetime.utcnow().isoformat() + } + + send_discord_embed(embed) + + +if __name__ == "__main__": + main()