initial commit
This commit is contained in:
34
countdown.py
Normal file
34
countdown.py
Normal file
@@ -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()
|
||||
21
countdown.py~
Normal file
21
countdown.py~
Normal file
@@ -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
|
||||
52
cover_to_pdf.py
Normal file
52
cover_to_pdf.py
Normal file
@@ -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 <image.jpg/png> [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)
|
||||
7
gen-hash/generate-hash.js
Normal file
7
gen-hash/generate-hash.js
Normal file
@@ -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);
|
||||
});
|
||||
46
gen-hash/package-lock.json
generated
Normal file
46
gen-hash/package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
gen-hash/package.json
Normal file
5
gen-hash/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0"
|
||||
}
|
||||
}
|
||||
162
halifax/clean-halifax-actual-2.py
Normal file
162
halifax/clean-halifax-actual-2.py
Normal file
@@ -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()
|
||||
84
halifax/clean-halifax-actual.py
Normal file
84
halifax/clean-halifax-actual.py
Normal file
@@ -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()
|
||||
29
halifax/halifax_state.json
Normal file
29
halifax/halifax_state.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
99
halifax/new-cleaned.py
Normal file
99
halifax/new-cleaned.py
Normal file
@@ -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()
|
||||
45
legacy/auto-org.sh
Executable file
45
legacy/auto-org.sh
Executable file
@@ -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
|
||||
38
legacy/crawler.py
Normal file
38
legacy/crawler.py
Normal file
@@ -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")
|
||||
89
legacy/fetch.py
Normal file
89
legacy/fetch.py
Normal file
@@ -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 '-<service>-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 <token>" http://<portainer-host>/api/endpoints/<endpoint-id>/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()
|
||||
90
legacy/insert_services.py
Normal file
90
legacy/insert_services.py
Normal file
@@ -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()
|
||||
17
legacy/move_rss.py
Normal file
17
legacy/move_rss.py
Normal file
@@ -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.")
|
||||
26
legacy/pc_runner.sh
Executable file
26
legacy/pc_runner.sh
Executable file
@@ -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"
|
||||
31
legacy/script_for_wol_and_ssh.sh
Executable file
31
legacy/script_for_wol_and_ssh.sh
Executable file
@@ -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"
|
||||
BIN
legacy/website_pages.xlsx
Normal file
BIN
legacy/website_pages.xlsx
Normal file
Binary file not shown.
216
miniflux-discord.py
Executable file
216
miniflux-discord.py
Executable file
@@ -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)
|
||||
203
miniflux-discord.py~
Executable file
203
miniflux-discord.py~
Executable file
@@ -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)
|
||||
262
org-books-calibre.py
Executable file
262
org-books-calibre.py
Executable file
@@ -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)
|
||||
134
org_logs_to_discord.py
Executable file
134
org_logs_to_discord.py
Executable file
@@ -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()
|
||||
126
org_logs_to_discord.py~
Executable file
126
org_logs_to_discord.py~
Executable file
@@ -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()
|
||||
Reference in New Issue
Block a user