434 lines
19 KiB
YAML
434 lines
19 KiB
YAML
networks:
|
|
home-net:
|
|
driver: bridge
|
|
ix-databases_shared-databases:
|
|
external: true
|
|
pangolin:
|
|
external: true
|
|
|
|
services:
|
|
|
|
kitchenowl:
|
|
container_name: kitchenowl
|
|
image: tombursch/kitchenowl:latest
|
|
restart: unless-stopped
|
|
networks:
|
|
- home-net
|
|
- pangolin
|
|
ports:
|
|
- "8086:8080"
|
|
environment:
|
|
TZ: ${TZ}
|
|
FRONT_URL: ${KITCHENOWL_URL}
|
|
JWT_SECRET_KEY: ${KITCHENOWL_JWT_SECRET}
|
|
STORAGE_PATH: /data
|
|
DEBUG: "False"
|
|
volumes:
|
|
- /mnt/tank/docker/appdata/kitchenowl:/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/ || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 30s
|
|
|
|
donetick:
|
|
container_name: donetick
|
|
image: donetick/donetick:latest
|
|
restart: unless-stopped
|
|
networks:
|
|
- home-net
|
|
- ix-databases_shared-databases
|
|
- pangolin
|
|
ports:
|
|
- "2021:2021"
|
|
environment:
|
|
TZ: ${TZ}
|
|
DT_ENV: selfhosted
|
|
DT_JWT_SECRET: ${DT_JWT_SECRET}
|
|
DT_DB_DRIVER: ${DT_DB_DRIVER}
|
|
volumes:
|
|
- /mnt/tank/docker/appdata/donetick/data:/donetick-data
|
|
- /mnt/tank/docker/appdata/donetick/config:/config
|
|
- /mnt/tank/docker/appdata/donetick/assets:/app/assets
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:2021/ >/dev/null 2>&1 || exit 1"]
|
|
interval: 1m
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 1m
|
|
|
|
donetick-dakboard-bridge:
|
|
container_name: donetick-dakboard-bridge
|
|
image: python:3.12-alpine
|
|
restart: unless-stopped
|
|
networks:
|
|
- home-net
|
|
- pangolin
|
|
ports:
|
|
- "5087:8080"
|
|
environment:
|
|
TZ: ${TZ}
|
|
BRIDGE_TOKEN: ${DONETICK_BRIDGE_TOKEN}
|
|
DONETICK_API_KEY: ${DONETICK_API_KEY}
|
|
DONETICK_BASE_URL: ${DONETICK_BASE_URL}
|
|
TASKS_PATH: /eapi/v1/chore
|
|
MAX_ITEMS: "20"
|
|
SHOW_COMPLETED: "false"
|
|
HIDE_INACTIVE: "true"
|
|
COMPLETED_STATUS_VALUE: "10"
|
|
REFRESH_SECONDS: "300"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 15s
|
|
command:
|
|
- python
|
|
- "-c"
|
|
- |
|
|
import json, os, html, urllib.request, urllib.error
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from urllib.parse import urlparse, parse_qs
|
|
from datetime import datetime
|
|
|
|
HOST = "0.0.0.0"
|
|
PORT = 8080
|
|
|
|
DONETICK_BASE_URL = os.environ["DONETICK_BASE_URL"].rstrip("/")
|
|
DONETICK_API_KEY = os.environ["DONETICK_API_KEY"]
|
|
BRIDGE_TOKEN = os.environ["BRIDGE_TOKEN"].strip()
|
|
TASKS_PATH = os.environ.get("TASKS_PATH", "/eapi/v1/chore")
|
|
|
|
MAX_ITEMS = int(os.environ.get("MAX_ITEMS", "20"))
|
|
SHOW_COMPLETED = os.environ.get("SHOW_COMPLETED", "false").lower() in ("1", "true", "yes", "on")
|
|
HIDE_INACTIVE = os.environ.get("HIDE_INACTIVE", "true").lower() in ("1", "true", "yes", "on")
|
|
COMPLETED_STATUS_VALUE = os.environ.get("COMPLETED_STATUS_VALUE", "10").strip()
|
|
REFRESH_SECONDS = int(os.environ.get("REFRESH_SECONDS", "300"))
|
|
|
|
TASKS_URL = f"{DONETICK_BASE_URL}{TASKS_PATH}"
|
|
|
|
LOW_VALUE_LABELS = {"shared", "duesoon", "submitted", "waitingsubmission", "notes"}
|
|
|
|
PRIORITY_COLORS = {1: "#ff6b6b", 2: "#ff9f1c", 3: "#ffd166", 4: "#8ecae6"}
|
|
|
|
def parse_dt(value):
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
|
|
def has_label(task, label_name):
|
|
if not label_name:
|
|
return False
|
|
wanted = label_name.strip().lower()
|
|
labels = task.get("labelsV2") or []
|
|
return any(str(lbl.get("name", "")).strip().lower() == wanted for lbl in labels)
|
|
|
|
def priority_text(priority):
|
|
if priority is None:
|
|
return None
|
|
try:
|
|
p = int(priority)
|
|
except Exception:
|
|
return None
|
|
return f"P{p}" if p in (1, 2, 3, 4) else None
|
|
|
|
def priority_num(priority):
|
|
try:
|
|
p = int(priority)
|
|
if p in (1, 2, 3, 4):
|
|
return p
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def recurrence_text(task):
|
|
ftype = str(task.get("frequencyType", "") or "").strip().lower()
|
|
freq = task.get("frequency")
|
|
try:
|
|
freq_num = int(freq) if freq is not None else None
|
|
except Exception:
|
|
freq_num = None
|
|
if not ftype:
|
|
return None
|
|
if ftype == "daily":
|
|
return "Daily" if freq_num in (None, 1) else f"Every {freq_num} days"
|
|
if ftype == "weekly":
|
|
return "Weekly" if freq_num in (None, 1) else f"Every {freq_num} weeks"
|
|
if ftype == "monthly":
|
|
return "Monthly" if freq_num in (None, 1) else f"Every {freq_num} months"
|
|
if ftype == "yearly":
|
|
return "Yearly" if freq_num in (None, 1) else f"Every {freq_num} years"
|
|
if ftype == "once":
|
|
return "One-time"
|
|
return ftype.capitalize()
|
|
|
|
def due_title_and_state(due_raw):
|
|
dt = parse_dt(due_raw)
|
|
if not dt:
|
|
return ("No due date", "nodue", None)
|
|
local = dt.astimezone()
|
|
now = datetime.now().astimezone()
|
|
days = (local.date() - now.date()).days
|
|
time_text = local.strftime("%-I:%M %p")
|
|
if days < 0:
|
|
overdue_days = abs(days)
|
|
if overdue_days == 1:
|
|
return (f"Overdue \u2022 Yesterday {time_text}", "overdue", local)
|
|
return (f"Overdue \u2022 {local.strftime('%a, %b %-d')} {time_text}", "overdue", local)
|
|
if days == 0:
|
|
return (f"Due today \u2022 {time_text}", "today", local)
|
|
if days == 1:
|
|
return (f"Due tomorrow \u2022 {time_text}", "tomorrow", local)
|
|
if days <= 6:
|
|
return (f"Due {local.strftime('%a')} \u2022 {time_text}", "upcoming", local)
|
|
return (f"Due {local.strftime('%b %-d')} \u2022 {time_text}", "upcoming", local)
|
|
|
|
def dedupe_parts(parts):
|
|
seen = set()
|
|
out = []
|
|
for p in parts:
|
|
if not p:
|
|
continue
|
|
key = p.strip().lower()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(p)
|
|
return out
|
|
|
|
def subtitle_text(task):
|
|
parts = []
|
|
labels = task.get("labelsV2") or []
|
|
label_names = [str(lbl.get("name", "")).strip() for lbl in labels if lbl.get("name")]
|
|
prominent = [x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS]
|
|
low = [x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS]
|
|
parts.extend(prominent[:3])
|
|
ptxt = priority_text(task.get("priority"))
|
|
if ptxt:
|
|
parts.append(ptxt)
|
|
rtxt = recurrence_text(task)
|
|
if rtxt:
|
|
label_keys = {x.strip().lower() for x in label_names}
|
|
if rtxt.strip().lower() not in label_keys:
|
|
parts.append(rtxt)
|
|
if not prominent and low:
|
|
parts.extend(low[:1])
|
|
parts = dedupe_parts(parts)
|
|
return " \u2022 ".join(parts)
|
|
|
|
def visible_label_chips(task):
|
|
chips = []
|
|
labels = task.get("labelsV2") or []
|
|
for lbl in labels:
|
|
name = str(lbl.get("name", "")).strip()
|
|
if not name or name.lower() in LOW_VALUE_LABELS:
|
|
continue
|
|
color = str(lbl.get("color", "")).strip() or "#607d8b"
|
|
chips.append({"name": name, "color": color})
|
|
return chips[:4]
|
|
|
|
def fetch_tasks(include_label=None, exclude_label=None):
|
|
req = urllib.request.Request(TASKS_URL, headers={"secretkey": DONETICK_API_KEY, "Accept": "application/json", "User-Agent": "donetick-dakboard-bridge/embed-v5"}, method="GET")
|
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
if not isinstance(data, list):
|
|
raise ValueError("DoneTick API did not return a JSON array")
|
|
items = []
|
|
now = datetime.now().astimezone()
|
|
for task in data:
|
|
if HIDE_INACTIVE and not task.get("isActive", True):
|
|
continue
|
|
status = str(task.get("status", ""))
|
|
if not SHOW_COMPLETED and status == COMPLETED_STATUS_VALUE:
|
|
continue
|
|
if include_label and not has_label(task, include_label):
|
|
continue
|
|
if exclude_label and has_label(task, exclude_label):
|
|
continue
|
|
name = str(task.get("name", "")).strip() or "Untitled task"
|
|
due_raw = task.get("nextDueDate")
|
|
due_title, state, due_local = due_title_and_state(due_raw)
|
|
subtitle = subtitle_text(task)
|
|
priority = priority_num(task.get("priority"))
|
|
label_chips = visible_label_chips(task)
|
|
if due_local:
|
|
overdue_rank = 0 if due_local < now else 1
|
|
due_rank = due_local.timestamp()
|
|
missing_rank = 0
|
|
else:
|
|
overdue_rank = 2
|
|
due_rank = 99999999999
|
|
missing_rank = 1
|
|
items.append({"value": due_title, "title": name, "subtitle": subtitle, "state": state, "priority": priority, "priority_color": PRIORITY_COLORS.get(priority), "chips": label_chips, "_sort_overdue": overdue_rank, "_sort_due": due_rank, "_sort_missing": missing_rank, "_sort_name": name.lower()})
|
|
items.sort(key=lambda x: (x["_sort_overdue"], x["_sort_missing"], x["_sort_due"], x["_sort_name"]))
|
|
items = items[:MAX_ITEMS]
|
|
for item in items:
|
|
for k in ("_sort_overdue", "_sort_due", "_sort_missing", "_sort_name"):
|
|
item.pop(k, None)
|
|
return items
|
|
|
|
def render_embed(items, header_title):
|
|
cards = []
|
|
for item in items:
|
|
state = html.escape(item.get("state", "upcoming"))
|
|
due = html.escape(item.get("value", ""))
|
|
title = html.escape(item.get("title", ""))
|
|
subtitle = html.escape(item.get("subtitle", ""))
|
|
priority_color = item.get("priority_color")
|
|
due_style = ""
|
|
if state == "overdue":
|
|
due_style = "color:#ff6b6b;"
|
|
elif priority_color:
|
|
due_style = f"color:{html.escape(priority_color)};"
|
|
chips_html = ""
|
|
chips = item.get("chips") or []
|
|
if chips:
|
|
chip_bits = [f'<span class="chip" style="background:{html.escape(c.get("color","#607d8b"))};">{html.escape(c.get("name",""))}</span>' for c in chips]
|
|
chips_html = f'<div class="chips">{"".join(chip_bits)}</div>'
|
|
subtitle_html = f'<div class="subtitle">{subtitle}</div>' if subtitle else ""
|
|
cards.append(f'<div class="task {state}"><div class="due" style="{due_style}">{due}</div><div class="name">{title}</div>{chips_html}{subtitle_html}</div>')
|
|
tasks_html = "\n".join(cards) if cards else '<div class="empty">No tasks right now</div>'
|
|
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="{REFRESH_SECONDS}"><title>DoneTick Tasks</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Shadows+Into+Light&display=swap" rel="stylesheet"><style>:root{{--card:rgba(18,22,30,0.48);--line:rgba(255,255,255,0.10);--text:#f4f7fb;--muted:rgba(244,247,251,0.72);--overdue:#ff6b6b;--today:#ffd166;--tomorrow:#8ecae6;--upcoming:#f4f7fb;--nodue:#adb5bd}}*{{box-sizing:border-box}}html,body{{margin:0;padding:0;width:100%;height:100%;background:transparent;color:var(--text);font-family:Inter,Segoe UI,Roboto,Arial,sans-serif}}body{{padding:10px 12px}}.wrap{{width:100%;height:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;align-content:start}}.header{{grid-column:1/-1;font-family:"Shadows Into Light",cursive;font-size:2.35rem;line-height:1;margin-bottom:2px;color:var(--text);text-shadow:0 1px 10px rgba(0,0,0,0.35)}}.task{{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:12px 14px 10px;box-shadow:0 4px 18px rgba(0,0,0,0.14);backdrop-filter:blur(4px);min-width:0}}.due{{font-family:"Shadows Into Light",cursive;font-size:1.45rem;font-weight:700;line-height:1.02;margin-bottom:2px;letter-spacing:0.01em}}.name{{font-family:"Shadows Into Light",cursive;font-size:2rem;font-weight:600;line-height:1.0;margin-bottom:8px;color:var(--text);word-break:break-word}}.chips{{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:5px}}.chip{{display:inline-block;padding:4px 10px;border-radius:999px;color:#ffffff;font-size:0.80rem;font-weight:700;letter-spacing:0.02em;line-height:1.05;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.08)}}.subtitle{{font-size:0.78rem;line-height:1.15;color:var(--muted);letter-spacing:0.02em;word-break:break-word}}.task.overdue .due{{color:var(--overdue)}}.task.today .due{{color:var(--today)}}.task.tomorrow .due{{color:var(--tomorrow)}}.task.upcoming .due{{color:var(--upcoming)}}.task.nodue .due{{color:var(--nodue)}}.empty{{grid-column:1/-1;font-size:1.2rem;color:var(--muted);padding:20px 4px}}</style></head><body><div class="wrap"><div class="header">{html.escape(header_title)}</div>{tasks_html}</div></body></html>"""
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _json(self, status, payload):
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _html(self, status, payload):
|
|
body = payload.encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _authorized(self):
|
|
if not BRIDGE_TOKEN:
|
|
return True
|
|
auth = self.headers.get("Authorization", "")
|
|
if auth == f"Bearer {BRIDGE_TOKEN}":
|
|
return True
|
|
if self.headers.get("X-Bridge-Token", "") == BRIDGE_TOKEN:
|
|
return True
|
|
qs = parse_qs(urlparse(self.path).query)
|
|
if qs.get("token", [None])[0] == BRIDGE_TOKEN:
|
|
return True
|
|
return False
|
|
|
|
def do_GET(self):
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path
|
|
qs = parse_qs(parsed.query)
|
|
if path == "/healthz":
|
|
return self._json(200, {"ok": True})
|
|
if path not in ("/", "/dakboard/tasks.json", "/embed"):
|
|
return self._json(404, {"error": "not_found"})
|
|
if not self._authorized():
|
|
return self._json(401, {"error": "unauthorized"})
|
|
include_label = (qs.get("label", [""])[0] or "").strip()
|
|
exclude_label = (qs.get("exclude_label", [""])[0] or "").strip()
|
|
if include_label:
|
|
header = include_label
|
|
elif exclude_label:
|
|
header = f"Not {exclude_label}"
|
|
else:
|
|
header = "Tasks"
|
|
try:
|
|
items = fetch_tasks(include_label=include_label, exclude_label=exclude_label)
|
|
if path == "/embed":
|
|
return self._html(200, render_embed(items, header))
|
|
return self._json(200, items)
|
|
except urllib.error.HTTPError as e:
|
|
return self._json(502, {"error": "upstream_http_error", "status": e.code})
|
|
except Exception as e:
|
|
return self._json(502, {"error": "bridge_error", "detail": str(e)})
|
|
|
|
def log_message(self, format, *args):
|
|
print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), format % args))
|
|
|
|
HTTPServer((HOST, PORT), Handler).serve_forever()
|
|
|
|
qui:
|
|
container_name: qui
|
|
image: ghcr.io/autobrr/qui:latest
|
|
restart: unless-stopped
|
|
networks:
|
|
- home-net
|
|
- pangolin
|
|
ports:
|
|
- "7476:7476"
|
|
environment:
|
|
TZ: ${TZ}
|
|
HOME: /config
|
|
XDG_CONFIG_HOME: /config
|
|
XDG_DATA_HOME: /config
|
|
volumes:
|
|
- /mnt/tank/docker/appdata/qui/qui/config:/config
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7476/ >/dev/null 2>&1 || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 30s
|
|
|
|
shlink:
|
|
container_name: shlink
|
|
image: shlinkio/shlink:stable
|
|
restart: unless-stopped
|
|
networks:
|
|
- home-net
|
|
- ix-databases_shared-databases
|
|
- pangolin
|
|
ports:
|
|
- "8087:8080"
|
|
environment:
|
|
TZ: ${TZ}
|
|
DEFAULT_DOMAIN: ${SHLINK_DOMAIN}
|
|
IS_HTTPS_ENABLED: "true"
|
|
DB_DRIVER: postgres
|
|
DB_HOST: shared-postgres
|
|
DB_PORT: 5432
|
|
DB_NAME: ${SHLINK_DB_NAME}
|
|
DB_USER: ${SHLINK_DB_USER}
|
|
DB_PASSWORD: ${SHLINK_DB_PASS}
|
|
DB_USE_ENCRYPTION: "false"
|
|
REDIRECT_STATUS_CODE: "302"
|
|
SHELL_VERBOSITY: "3"
|
|
GEOLITE_LICENSE_KEY: ""
|
|
volumes:
|
|
- /mnt/docker-ssd/docker/appdata/shlink:/etc/shlink/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/rest/health >/dev/null 2>&1 || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 30s
|
|
|
|
shlink-web-client:
|
|
container_name: shlink-web-client
|
|
image: ghcr.io/shlinkio/shlink-web-client:stable
|
|
restart: unless-stopped
|
|
networks:
|
|
- home-net
|
|
- pangolin
|
|
ports:
|
|
- "8088:8080"
|
|
environment:
|
|
TZ: ${TZ}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/ || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 15s
|