feat(home): import doris dashboard from workspace repo
Migrates workspace-only dashboard work from commits 6f0c5cd, c851073, and d04eff3 into the canonical truenas-stacks repo.
This commit is contained in:
13
home/doris-dashboard/.gitignore
vendored
Normal file
13
home/doris-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
logs/
|
||||
public/index.html
|
||||
public/style.css.bak.*
|
||||
bin/generate_dashboard.py.bak.*
|
||||
data/articles.json
|
||||
data/dashboard.json
|
||||
data/homelab.json
|
||||
data/sky.json
|
||||
data/todos.json
|
||||
data/weather.json
|
||||
data/paperless_review.json
|
||||
data/paperless_review_state.json
|
||||
238
home/doris-dashboard/README.md
Normal file
238
home/doris-dashboard/README.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Doris Dashboard
|
||||
|
||||
Local static homepage for John’s morning briefing and peek-anytime news dashboard.
|
||||
|
||||
Canonical path:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
```
|
||||
|
||||
Generated page:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html
|
||||
```
|
||||
|
||||
## What it shows
|
||||
|
||||
- Morning briefing with fleshed-out cards
|
||||
- Today panel for calendar placeholders
|
||||
- To-do panel for Donetick/to-do placeholders
|
||||
- Notes panel
|
||||
- Sky / Space
|
||||
- Homelab / Smart Home / Ham Radio
|
||||
- Local news
|
||||
- Other signal
|
||||
- Source/candidate stats
|
||||
|
||||
## Generate manually
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
bin/generate_dashboard.py
|
||||
```
|
||||
|
||||
## Preview locally
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public
|
||||
python3 -m http.server 8787
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:8787/
|
||||
```
|
||||
|
||||
If viewing from another machine on the LAN, use NOMAD’s LAN IP instead of localhost and make sure the firewall allows the port.
|
||||
|
||||
## Cron refresh
|
||||
|
||||
Installed in `fizzlepoof`’s crontab:
|
||||
|
||||
```cron
|
||||
# BEGIN DORIS DASHBOARD
|
||||
DORIS_DASHBOARD_DIR=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
# Refresh local dashboard after digest collection windows.
|
||||
5,35 6-23 * * * cd $DORIS_DASHBOARD_DIR && bin/generate_dashboard.py >> logs/generate.log 2>&1
|
||||
# END DORIS DASHBOARD
|
||||
```
|
||||
|
||||
Inspect with:
|
||||
|
||||
```bash
|
||||
crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p'
|
||||
```
|
||||
|
||||
## Data inputs
|
||||
|
||||
Main news source:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/doris-digest/data/candidates.json
|
||||
/home/fizzlepoof/.openclaw/workspace/doris-digest/data/preferences.json
|
||||
```
|
||||
|
||||
Local dashboard placeholders:
|
||||
|
||||
```bash
|
||||
data/todos.json
|
||||
data/calendar.json
|
||||
data/notes.json
|
||||
```
|
||||
|
||||
These are intentionally simple JSON arrays so calendar/Donetick integrations can be added later without rewriting the page.
|
||||
|
||||
## Next integrations to add
|
||||
|
||||
1. Donetick tasks
|
||||
2. Calendar events
|
||||
3. Weather / severe weather
|
||||
4. Moon phase / ISS passes / meteor shower calendar
|
||||
5. Homelab health pulse
|
||||
6. Article feedback buttons or quick links back to Doris
|
||||
|
||||
## Privacy/deployment note
|
||||
|
||||
This dashboard is local-only on NOMAD unless John explicitly asks to expose it. Do not publish it externally without asking.
|
||||
|
||||
## LAN service
|
||||
|
||||
The dashboard is exposed on NOMAD's LAN IP via systemd:
|
||||
|
||||
```text
|
||||
http://10.5.1.16:8787/
|
||||
```
|
||||
|
||||
Service file:
|
||||
|
||||
```bash
|
||||
/etc/systemd/system/doris-dashboard.service
|
||||
```
|
||||
|
||||
Manage it:
|
||||
|
||||
```bash
|
||||
sudo systemctl status doris-dashboard.service
|
||||
sudo systemctl restart doris-dashboard.service
|
||||
sudo systemctl stop doris-dashboard.service
|
||||
```
|
||||
|
||||
The service runs as `fizzlepoof`, serves `public/`, binds `0.0.0.0:8787`, and regenerates the page before starting.
|
||||
|
||||
At setup time UFW was inactive, so no firewall rule was required.
|
||||
|
||||
## Buildout added 2026-05-12
|
||||
|
||||
The dashboard generator now builds a richer local homepage directly on NOMAD.
|
||||
|
||||
Added sections/data:
|
||||
|
||||
- Weather metrics for Clarksville via Open-Meteo (`data/weather.json`)
|
||||
- Moon phase / sky note calculated locally (`data/sky.json`)
|
||||
- Homelab pulse checks (`data/homelab.json`): dashboard service, cron, digest freshness, workspace disk
|
||||
- Article export from Doris Digest candidates (`data/articles.json`)
|
||||
- Dashboard metadata (`data/dashboard.json`)
|
||||
|
||||
The generated page still lives at:
|
||||
|
||||
```bash
|
||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html
|
||||
```
|
||||
|
||||
And the LAN service remains:
|
||||
|
||||
```text
|
||||
http://10.5.1.16:8787/
|
||||
```
|
||||
|
||||
Verification commands:
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
python3 -m py_compile bin/generate_dashboard.py
|
||||
bin/generate_dashboard.py
|
||||
curl -fsS http://127.0.0.1:8787/ | grep 'Doris Dashboard'
|
||||
systemctl is-active doris-dashboard.service
|
||||
crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p'
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Weather.gov and NASA API are not used; Open-Meteo worked reliably without an API key.
|
||||
- Donetick live task import is still not enabled in the dashboard; `data/todos.json` remains the safe placeholder until the exact read-only endpoint is confirmed.
|
||||
- Raw secrets are not written to dashboard output.
|
||||
|
||||
## Compact news layout
|
||||
|
||||
2026-05-13: News/article sections were changed from large cards to compact rows so the dashboard can keep plenty of articles without making John scroll forever.
|
||||
|
||||
Implementation:
|
||||
|
||||
- `generate_dashboard.py` renders article sections with `compact-card` rows by default.
|
||||
- `public/style.css` contains the compact layout rules.
|
||||
- Metrics/weather/homelab pulse remain large cards.
|
||||
|
||||
## Donetick integration
|
||||
|
||||
2026-05-13: The dashboard now imports open Donetick chores read-only during generation.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Secrets remain encrypted in `/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc`.
|
||||
- `bin/generate_dashboard.py` decrypts secrets at runtime using the local encryption key and logs in with the Doris service account.
|
||||
- It reads `/api/v1/chores` and `/api/v1/users` only.
|
||||
- It writes sanitized task data to `data/todos.json` for the dashboard.
|
||||
- It does not expose tokens, passwords, or raw secrets in generated HTML/JSON.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
bin/generate_dashboard.py
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
for item in json.loads(Path('data/todos.json').read_text()):
|
||||
print(item.get('time'), item.get('title'), item.get('assignee'))
|
||||
PY
|
||||
```
|
||||
|
||||
## Donetick compact board + topic feedback
|
||||
|
||||
2026-05-14: The dashboard's Donetick section was reworked to match the tighter PD Dakboard pattern more closely.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- Donetick renders compact task cards with due cue, task title, label chips, and a short subtitle.
|
||||
- Tasks are sorted by urgency first (overdue/today/upcoming/no due date), then due time.
|
||||
- The dashboard injects a pinned Paperless follow-up card into the Donetick area so that cleanup work stays visible.
|
||||
- News cards now show `👍 / 👎` topic buttons.
|
||||
- Topic feedback is persisted through the local dashboard server into `../doris-digest/data/feedback.json` with `origin: "dashboard"`.
|
||||
- `Reset hidden topics` clears both browser state and dashboard-origin feedback entries.
|
||||
- Paperless review cards now have `Acknowledge` / `Dismiss` actions persisted to `data/paperless_review_state.json`.
|
||||
- Major dashboard sections are collapsible and remember collapsed state in the browser.
|
||||
|
||||
Important note:
|
||||
|
||||
- The interactive actions require the dashboard to be served by `bin/dashboard_server.py`, not plain `python -m http.server`.
|
||||
|
||||
## Cron fix — 2026-05-13
|
||||
|
||||
The dashboard cron now uses `bin/cron_generate_dashboard.sh`, which checks America/Chicago daytime internally. This avoids relying on `CRON_TZ`, which NOMAD's cron did not honor consistently.
|
||||
|
||||
## Paperless review queue contract
|
||||
|
||||
The dashboard displays sanitized Paperless intake triage items written by n8n at `data/paperless_review.json`. Example contract: `data/paperless_review.example.json`.
|
||||
|
||||
Expected shape is a JSON array. Each item may include:
|
||||
|
||||
- `id`, `title`, `original_filename`, `added`, `archive_url`
|
||||
- `current.correspondent`, `current.document_type`, `current.tags`
|
||||
- `triage.summary`, `triage.suggested_title`, `triage.document_type`, `triage.suggested_tags`, `triage.due_date`, `triage.needs_review`, `triage.urgency`, `triage.reason`, `triage.confidence`
|
||||
- `safety.forced_review`, `safety.eligible_for_safe_apply`, `safety.apply_safe_enabled`
|
||||
- `queued_at`
|
||||
|
||||
Do not write tokens, raw OCR text, API responses, or credentials into this file. It is display-only derived data for local review.
|
||||
6
home/doris-dashboard/bin/cron_generate_dashboard.sh
Executable file
6
home/doris-dashboard/bin/cron_generate_dashboard.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
hour="$(TZ=America/Chicago date +%H)"
|
||||
if (( 10#$hour < 6 || 10#$hour > 23 )); then exit 0; fi
|
||||
bin/generate_dashboard.py
|
||||
105
home/doris-dashboard/bin/dashboard_server.py
Executable file
105
home/doris-dashboard/bin/dashboard_server.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import partial
|
||||
from http import HTTPStatus
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PUBLIC = ROOT / 'public'
|
||||
DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||
DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json'
|
||||
PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json'
|
||||
HOST = os.environ.get('HOST', '0.0.0.0')
|
||||
PORT = int(os.environ.get('PORT', '8787'))
|
||||
|
||||
|
||||
def load_json(path: Path, default):
|
||||
try:
|
||||
return json.loads(path.read_text()) if path.exists() else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path: Path, value) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2) + '\n')
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, directory=None, **kwargs):
|
||||
super().__init__(*args, directory=str(PUBLIC), **kwargs)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('Content-Length', '0') or '0')
|
||||
raw = self.rfile.read(length) if length else b'{}'
|
||||
try:
|
||||
payload = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'invalid_json'})
|
||||
|
||||
if self.path == '/api/topic-feedback':
|
||||
return self._topic_feedback(payload)
|
||||
if self.path == '/api/topic-feedback/reset':
|
||||
return self._topic_reset()
|
||||
if self.path == '/api/paperless-action':
|
||||
return self._paperless_action(payload)
|
||||
return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'})
|
||||
|
||||
def _topic_feedback(self, payload: dict):
|
||||
term = str(payload.get('term', '')).strip()
|
||||
vote = str(payload.get('vote', '')).strip().lower()
|
||||
if not term or vote not in {'up', 'down'}:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
|
||||
items = load_json(DIGEST_FEEDBACK, [])
|
||||
items = [x for x in items if not (str(x.get('origin', '')) == 'dashboard' and str(x.get('term', '')).strip().lower() == term.lower())]
|
||||
items.append({
|
||||
'term': term,
|
||||
'vote': vote,
|
||||
'note': f"Dashboard {'hide' if vote == 'down' else 'prefer'} topic: {term}",
|
||||
'origin': 'dashboard',
|
||||
'source': str(payload.get('source', '')).strip(),
|
||||
'domain': str(payload.get('domain', '')).strip(),
|
||||
})
|
||||
save_json(DIGEST_FEEDBACK, items)
|
||||
return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)})
|
||||
|
||||
def _topic_reset(self):
|
||||
items = load_json(DIGEST_FEEDBACK, [])
|
||||
items = [x for x in items if str(x.get('origin', '')) != 'dashboard']
|
||||
save_json(DIGEST_FEEDBACK, items)
|
||||
return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)})
|
||||
|
||||
def _paperless_action(self, payload: dict):
|
||||
item_id = str(payload.get('id', '')).strip()
|
||||
action = str(payload.get('action', '')).strip().lower()
|
||||
if not item_id or action not in {'acknowledge', 'dismiss'}:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
|
||||
state = load_json(PAPERLESS_STATE, {})
|
||||
current = state.get(item_id, {}) if isinstance(state, dict) else {}
|
||||
if action == 'acknowledge':
|
||||
new_status = 'active' if current.get('status') == 'acknowledged' else 'acknowledged'
|
||||
else:
|
||||
new_status = 'dismissed'
|
||||
state[item_id] = {'status': new_status}
|
||||
save_json(PAPERLESS_STATE, state)
|
||||
return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status})
|
||||
|
||||
def _json(self, status: HTTPStatus, payload: dict):
|
||||
body = json.dumps(payload).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 log_message(self, fmt, *args):
|
||||
print('%s - - [%s] %s' % (self.address_string(), self.log_date_time_string(), fmt % args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
handler = partial(Handler, directory=str(PUBLIC))
|
||||
ThreadingHTTPServer((HOST, PORT), handler).serve_forever()
|
||||
541
home/doris-dashboard/bin/generate_dashboard.py
Executable file
541
home/doris-dashboard/bin/generate_dashboard.py
Executable file
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Any
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||
PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
|
||||
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
|
||||
LAT=36.53; LON=-87.36
|
||||
LOCAL_TZ=ZoneInfo('America/Chicago')
|
||||
|
||||
LABELS={
|
||||
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
|
||||
'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'),
|
||||
'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')}
|
||||
WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'}
|
||||
LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'}
|
||||
PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'}
|
||||
|
||||
def esc(v:Any)->str: return html.escape(str(v or ''),quote=True)
|
||||
def load_json(p:Path,d:Any)->Any:
|
||||
try: return json.loads(p.read_text()) if p.exists() else d
|
||||
except Exception: return d
|
||||
|
||||
def strip_markup(text:str)->str:
|
||||
text=re.sub(r'<[^>]+>',' ',text or '')
|
||||
return re.sub(r'\s+',' ',html.unescape(text)).strip()
|
||||
|
||||
def parse_time(v:str)->datetime:
|
||||
try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc)
|
||||
except Exception: return datetime.now(timezone.utc)
|
||||
|
||||
def parse_dt(v:str|None)->datetime|None:
|
||||
if not v: return None
|
||||
try: return datetime.fromisoformat(v.replace('Z','+00:00'))
|
||||
except Exception: return None
|
||||
|
||||
def c_to_f(c:float|int|None)->str:
|
||||
if c is None: return '—'
|
||||
return f"{round(c*9/5+32)}°F"
|
||||
|
||||
def kmh_to_mph(k:float|int|None)->str:
|
||||
if k is None: return '—'
|
||||
return f"{round(k*0.621371)} mph"
|
||||
|
||||
def moon_phase(dt:datetime)->dict[str,str]:
|
||||
# Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days.
|
||||
known=datetime(2000,1,6,18,14,tzinfo=timezone.utc)
|
||||
days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400
|
||||
age=days%29.53058867
|
||||
illum=(1-math.cos(2*math.pi*age/29.53058867))/2
|
||||
names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')]
|
||||
name,emoji='New Moon','🌑'
|
||||
for limit,n,e in names:
|
||||
if age<limit: name,emoji=n,e; break
|
||||
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
|
||||
|
||||
def fetch_weather()->dict[str,Any]:
|
||||
url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}'
|
||||
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature'
|
||||
'&hourly=temperature_2m,precipitation_probability'
|
||||
'&daily=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3')
|
||||
try:
|
||||
with urllib.request.urlopen(url,timeout=12) as r: raw=json.load(r)
|
||||
cur=raw.get('current',{}); daily=raw.get('daily',{}); hourly=raw.get('hourly',{})
|
||||
probs=hourly.get('precipitation_probability') or []
|
||||
return {'ok':True,'current':cur,'daily':daily,'max_precip':max(probs[:24] or [0]),'condition':WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))),'source':'Open-Meteo'}
|
||||
except Exception as e:
|
||||
return {'ok':False,'error':str(e),'source':'Open-Meteo'}
|
||||
|
||||
def decrypt_donetick()->dict[str,Any]|None:
|
||||
secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc')
|
||||
keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key')
|
||||
if not secret.exists() or not keyfile.exists(): return None
|
||||
try:
|
||||
res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True)
|
||||
return json.loads(res.stdout).get('donetick')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def fetch_donetick_todos()->list[dict[str,Any]]:
|
||||
sec=decrypt_donetick()
|
||||
if not sec: return []
|
||||
try:
|
||||
login=urllib.request.Request(sec['instance']+sec['jwt_endpoint'],data=json.dumps({'username':sec['service_account']['username'],'password':sec['service_account']['password']}).encode(),headers={'Content-Type':'application/json','Accept':'application/json'},method='POST')
|
||||
with urllib.request.urlopen(login,timeout=12) as r: token=json.loads(r.read())['access_token']
|
||||
headers={'Authorization':'Bearer '+token,'Accept':'application/json'}
|
||||
req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers)
|
||||
with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[])
|
||||
users={}
|
||||
try:
|
||||
ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers)
|
||||
with urllib.request.urlopen(ureq,timeout=12) as r:
|
||||
users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])}
|
||||
except Exception: pass
|
||||
out=[]
|
||||
for c in chores:
|
||||
if c.get('status') not in (0,None): continue
|
||||
due=c.get('nextDueDate') or ''
|
||||
assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}")
|
||||
due_title,state,due_local=due_title_and_state(due)
|
||||
out.append({
|
||||
'title':c.get('name','Untitled task'),
|
||||
'time':due[:16].replace('T',' '),
|
||||
'body':strip_markup(c.get('description','')),
|
||||
'assignee':assignee,
|
||||
'priority':priority_num(c.get('priority')) or 0,
|
||||
'priority_text':priority_text(c.get('priority')) or '',
|
||||
'due':due,
|
||||
'due_title':due_title,
|
||||
'state':state,
|
||||
'subtitle':subtitle_text(c),
|
||||
'chips':visible_label_chips(c),
|
||||
'source':'Donetick',
|
||||
'id':c.get('id'),
|
||||
'_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1),
|
||||
'_sort_missing':0 if due_local else 1,
|
||||
'_sort_due':due_local.timestamp() if due_local else 99999999999,
|
||||
'_sort_name':str(c.get('name','')).lower(),
|
||||
})
|
||||
out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name','')))
|
||||
for item in out:
|
||||
for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'):
|
||||
item.pop(k,None)
|
||||
return out[:10]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def priority_text(priority:Any)->str|None:
|
||||
try:
|
||||
p=int(priority)
|
||||
return f'P{p}' if p in (1,2,3,4) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def priority_num(priority:Any)->int|None:
|
||||
try:
|
||||
p=int(priority)
|
||||
return p if p in (1,2,3,4) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def recurrence_text(task:dict[str,Any])->str|None:
|
||||
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:str|None)->tuple[str,str,datetime|None]:
|
||||
dt=parse_dt(due_raw)
|
||||
if not dt: return ('No due date','nodue',None)
|
||||
local=dt.astimezone(LOCAL_TZ)
|
||||
now=datetime.now(LOCAL_TZ)
|
||||
days=(local.date()-now.date()).days
|
||||
time_text=local.strftime('%-I:%M %p')
|
||||
if days < 0:
|
||||
if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local)
|
||||
return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local)
|
||||
if days == 0: return (f'Due today • {time_text}','today',local)
|
||||
if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local)
|
||||
if days <= 6: return (f"Due {local.strftime('%a')} • {time_text}",'upcoming',local)
|
||||
return (f"Due {local.strftime('%b %-d')} • {time_text}",'upcoming',local)
|
||||
|
||||
def dedupe_parts(parts:list[str])->list[str]:
|
||||
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:dict[str,Any])->str:
|
||||
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])
|
||||
return ' • '.join(dedupe_parts(parts))
|
||||
|
||||
def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]:
|
||||
chips=[]
|
||||
for lbl in task.get('labelsV2') or []:
|
||||
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 run(cmd:list[str])->str:
|
||||
try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip()
|
||||
except Exception: return ''
|
||||
|
||||
def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
|
||||
usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace')
|
||||
disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)'
|
||||
dash=run(['systemctl','is-active','doris-dashboard.service']) or 'unknown'
|
||||
cron='present' if 'DORIS DASHBOARD' in run(['bash','-lc','crontab -l 2>/dev/null']) else 'missing'
|
||||
newest=max((parse_time(i.get('collected_at') or i.get('published_at') or '') for i in candidates), default=datetime.now(timezone.utc))
|
||||
age=(datetime.now(timezone.utc)-newest).total_seconds()/3600
|
||||
return [
|
||||
{'label':'Dashboard service','value':dash,'state':'ok' if dash=='active' else 'warn'},
|
||||
{'label':'Dashboard cron','value':cron,'state':'ok' if cron=='present' else 'warn'},
|
||||
{'label':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'},
|
||||
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
|
||||
]
|
||||
|
||||
def score_item(item:dict[str,Any],prefs:dict[str,Any])->float:
|
||||
score=float(item.get('priority',1)); text=f"{item.get('title','')} {item.get('summary','')}".lower()
|
||||
if item.get('category') in prefs.get('boost_categories',[]): score+=2
|
||||
if item.get('category') in prefs.get('quiet_categories',[]): score-=3
|
||||
for kw in prefs.get('boost_keywords',[]):
|
||||
if kw.lower() in text: score+=1.5
|
||||
for kw in prefs.get('mute_keywords',[]):
|
||||
if kw.lower() in text: score-=3
|
||||
if item.get('source')=='CISA Alerts':
|
||||
terms=[t.lower() for t in prefs.get('homelab_relevance_keywords',[])]
|
||||
if terms and not any(t in text for t in terms): score-=8
|
||||
return score
|
||||
|
||||
def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)->list[dict[str,Any]]:
|
||||
now=datetime.now(timezone.utc); recent=[]
|
||||
for i in items:
|
||||
if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i)
|
||||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||||
out=[]; seen=set(); sources=Counter()
|
||||
for i in recent:
|
||||
url=i.get('url',''); src=i.get('source','')
|
||||
if url in seen or sources[src]>=2: continue
|
||||
out.append(i); seen.add(url); sources[src]+=1
|
||||
if len(out)>=max_items: break
|
||||
return out
|
||||
|
||||
def label(cat:str)->str:
|
||||
e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}'
|
||||
|
||||
def slugify(text:str)->str:
|
||||
return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section'
|
||||
|
||||
def why(i:dict[str,Any])->str:
|
||||
cat=i.get('category'); src=i.get('source','')
|
||||
if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.'
|
||||
if cat=='homelab': return 'Practical self-hosting / infrastructure signal.'
|
||||
if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.'
|
||||
if cat=='local': return 'Local or regional relevance.'
|
||||
if cat=='radio': return 'Ham-radio interest signal.'
|
||||
if cat=='security': return 'Kept only because it appears practically relevant.'
|
||||
return f'Interesting signal from {src}.'
|
||||
|
||||
def card(i:dict[str,Any], compact:bool=False)->str:
|
||||
summary=(i.get('summary') or '').strip()[:170 if compact else 300]
|
||||
when=(i.get('published_at','')[:16].replace('T',' '))
|
||||
cls='compact-card' if compact else 'card'
|
||||
why_html='' if compact else f"<div class='why'>Why: {esc(why(i))}</div>"
|
||||
topic=(i.get('category') or 'other').strip() or 'other'
|
||||
source=(i.get('source') or '').strip()
|
||||
domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.')
|
||||
feedback_html='' if not compact else f"<div class='feedback'><button type='button' class='feedback-btn' data-feedback='up' title='More like this topic'>👍</button><button type='button' class='feedback-btn' data-feedback='down' title='Hide this topic'>👎</button></div>"
|
||||
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
|
||||
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
|
||||
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
|
||||
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
|
||||
|
||||
def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str:
|
||||
body=''.join(card(i,compact=compact) for i in items) if items else f"<p class='empty'>{esc(empty)}</p>"
|
||||
style='news-list' if compact else 'grid'
|
||||
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",slugify(title),open_by_default=True)
|
||||
|
||||
def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='')->str:
|
||||
open_attr=' open' if open_by_default else ''
|
||||
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
|
||||
return f"<details class='collapsible {esc(extra_class)}' data-section-key='{esc(key)}'{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||||
|
||||
def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]:
|
||||
out=list(items or [])
|
||||
title='Paperless notes/tags audit'
|
||||
if not any((x.get('title') or '').strip().lower()==title.lower() for x in out):
|
||||
out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"})
|
||||
return out
|
||||
|
||||
def load_paperless_review()->list[dict[str,Any]]:
|
||||
p=DATA/'paperless_review.json'
|
||||
remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json')
|
||||
ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys')
|
||||
if remote:
|
||||
tmp=DATA/'paperless_review.remote.tmp'
|
||||
try:
|
||||
cmd=['scp','-q','-o','ConnectTimeout=8']
|
||||
if ssh_key and Path(ssh_key).exists():
|
||||
cmd.extend(['-i',ssh_key])
|
||||
cmd.extend([remote,str(tmp)])
|
||||
res=subprocess.run(cmd,capture_output=True,text=True,timeout=12)
|
||||
if res.returncode==0 and tmp.exists(): tmp.replace(p)
|
||||
elif tmp.exists(): tmp.unlink()
|
||||
except Exception:
|
||||
try: tmp.unlink()
|
||||
except Exception: pass
|
||||
try: return json.loads(p.read_text())
|
||||
except Exception: return []
|
||||
|
||||
def load_paperless_state()->dict[str,Any]:
|
||||
return load_json(PAPERLESS_STATE_PATH,{}) or {}
|
||||
|
||||
def paperless_review_section()->str:
|
||||
items=load_paperless_review()
|
||||
state=load_paperless_state()
|
||||
if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel')
|
||||
urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'}
|
||||
active=[]; acknowledged=[]
|
||||
for item in items:
|
||||
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
|
||||
if status=='dismissed':
|
||||
continue
|
||||
(acknowledged if status=='acknowledged' else active).append(item)
|
||||
def render_card(item:dict[str,Any], acknowledged_card:bool=False)->str:
|
||||
status_class=' acknowledged' if acknowledged_card else ''
|
||||
return f"""<article class='paperless-card {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
|
||||
<div class='meta'><span class='urgency {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}' data-urgency='{esc(item.get('triage',{}).get('urgency','none'))}'>{esc(item.get('triage',{}).get('urgency','none').upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div>
|
||||
<h4>{esc(item.get('title') or item.get('triage',{}).get('suggested_title','Untitled'))}</h4>
|
||||
<p class='summary'>{esc(item.get('triage',{}).get('summary',''))}</p>
|
||||
<p class='why'>Type: {esc(item.get('triage',{}).get('document_type',''))} | Correspondent: {esc(item.get('current',{}).get('correspondent',''))} | {esc(item.get('triage',{}).get('reason',''))}</p>
|
||||
<div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='acknowledge'>{'Unack' if acknowledged_card else 'Acknowledge'}</button><button type='button' class='paperless-btn danger' data-paperless-action='dismiss'>Dismiss</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div>
|
||||
</article>"""
|
||||
active_cards=''.join(render_card(item,False) for item in active[:8])
|
||||
acked_cards=''.join(render_card(item,True) for item in acknowledged[:8])
|
||||
count=len(active)
|
||||
ack_count=len(acknowledged)
|
||||
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
|
||||
if ack_count:
|
||||
body += f"<details class='paperless-acked'><summary>Acknowledged <span class='badge'>{esc(str(ack_count))}</span></summary><div class='paperless-grid acked-grid'>{acked_cards}</div></details>"
|
||||
if count+ack_count>8:
|
||||
body += f"<p class='empty'>Showing {esc(str(min(count,8)))} active and {esc(str(min(ack_count,8)))} acknowledged items from the local queue snapshot.</p>"
|
||||
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{ack_count} acknowledged · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=count>0,extra_class='panel')
|
||||
|
||||
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
|
||||
if not items:
|
||||
body=f"<li class='empty'>{esc(empty)}</li>"
|
||||
else:
|
||||
rows=[]
|
||||
for x in items[:8]:
|
||||
kicker=x.get('time') or x.get('title') or x.get('label')
|
||||
headline=x.get('title') if x.get('time') else ''
|
||||
assignee=x.get('assignee') or ''
|
||||
detail=x.get('body') or x.get('summary') or x.get('value') or ''
|
||||
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{f'<span class=\'muted\'>{esc(assignee)}</span>' if assignee else ''}</div>{f'<div class=\'panel-item-title\'>{esc(headline)}</div>' if headline else ''}{f'<div class=\'panel-item-body\'>{esc(detail)}</div>' if detail else ''}</li>")
|
||||
body=''.join(rows)
|
||||
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||||
|
||||
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||||
if not items:
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||||
rows=[]
|
||||
for x in items[:6]:
|
||||
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
|
||||
subtitle=' • '.join([p for p in [x.get('subtitle') or '', x.get('assignee') or ''] if p])
|
||||
state=esc(x.get('state') or 'upcoming')
|
||||
due_style=''
|
||||
if state=='overdue': due_style=' style="color:#ff6b6b;"'
|
||||
elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"'
|
||||
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(x.get("subtitle") or "")}</div>' if x.get('subtitle') else ''}</article>")
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||||
|
||||
def feedback_bootstrap()->str:
|
||||
return """<script>
|
||||
(()=>{
|
||||
const KEY='doris-dashboard-topic-prefs-v2';
|
||||
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
|
||||
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"liked":[],"hidden":[]}')}catch(e){return {liked:[],hidden:[]}}};
|
||||
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
|
||||
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({liked:[...new Set(prefs.liked||[])], hidden:[...new Set(prefs.hidden||[])]}));
|
||||
const prefs=safeRead();
|
||||
const collapsed=safeCollapse();
|
||||
const cards=[...document.querySelectorAll('[data-topic]')];
|
||||
const sections=[...document.querySelectorAll('details[data-section-key]')];
|
||||
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
|
||||
const sync=()=>{
|
||||
cards.forEach(card=>{
|
||||
const topic=card.dataset.topic||'other';
|
||||
const hidden=(prefs.hidden||[]).includes(topic);
|
||||
const liked=(prefs.liked||[]).includes(topic);
|
||||
card.style.display=hidden?'none':'';
|
||||
card.classList.toggle('topic-liked', liked);
|
||||
});
|
||||
const badge=document.querySelector('[data-hidden-topics-count]');
|
||||
if (badge) badge.textContent=String((prefs.hidden||[]).length);
|
||||
};
|
||||
sections.forEach(sec=>{
|
||||
const key=sec.dataset.sectionKey;
|
||||
if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key];
|
||||
sec.addEventListener('toggle',()=>{
|
||||
collapsed[key]=!sec.open;
|
||||
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', (ev)=>{
|
||||
const btn=ev.target.closest('[data-feedback]');
|
||||
if(!btn) return;
|
||||
const card=btn.closest('[data-topic]');
|
||||
if(!card) return;
|
||||
const topic=card.dataset.topic||'other';
|
||||
if(btn.dataset.feedback==='down'){
|
||||
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
|
||||
prefs.hidden.push(topic);
|
||||
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
|
||||
} else {
|
||||
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
|
||||
prefs.liked.push(topic);
|
||||
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
|
||||
}
|
||||
save(prefs); sync();
|
||||
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
|
||||
});
|
||||
document.addEventListener('click',(ev)=>{
|
||||
const btn=ev.target.closest('[data-paperless-action]');
|
||||
if(!btn) return;
|
||||
const card=btn.closest('[data-paperless-id]');
|
||||
if(!card) return;
|
||||
const id=card.dataset.paperlessId;
|
||||
const action=btn.dataset.paperlessAction;
|
||||
btn.disabled=true;
|
||||
postJson('/api/paperless-action',{id,action}).then(()=>{
|
||||
if(action==='dismiss'){
|
||||
card.remove();
|
||||
} else {
|
||||
card.classList.toggle('acknowledged');
|
||||
}
|
||||
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
|
||||
});
|
||||
const reset=document.querySelector('[data-reset-topic-prefs]');
|
||||
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; prefs.liked=[]; save(prefs); sync(); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
|
||||
sync();
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
|
||||
return f"<div class='metric {esc(cls)}'><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
|
||||
|
||||
def forecast_panel(weather:dict[str,Any])->str:
|
||||
if not weather.get('ok'):
|
||||
return ""
|
||||
daily=weather.get('daily',{}) or {}
|
||||
days=daily.get('time') or []
|
||||
precip=daily.get('precipitation_sum') or []
|
||||
sunrise=daily.get('sunrise') or []
|
||||
sunset=daily.get('sunset') or []
|
||||
cards=[]
|
||||
for idx, day in enumerate(days[:3]):
|
||||
try:
|
||||
label=datetime.fromisoformat(day).strftime('%a')
|
||||
except Exception:
|
||||
label=day
|
||||
rain=precip[idx] if idx < len(precip) else '—'
|
||||
rise=(sunrise[idx][-5:] if idx < len(sunrise) and sunrise[idx] else '—')
|
||||
set_=(sunset[idx][-5:] if idx < len(sunset) and sunset[idx] else '—')
|
||||
cards.append(f"""<article class='forecast-card'>
|
||||
<span class='forecast-day'>{esc(label)}</span>
|
||||
<strong>{esc(str(rain))} mm rain</strong>
|
||||
<em>{esc(rise)} sunrise · {esc(set_)} sunset</em>
|
||||
</article>""")
|
||||
if not cards:
|
||||
return ""
|
||||
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=False,extra_class='panel forecast-panel')
|
||||
|
||||
def render()->str:
|
||||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
|
||||
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
||||
for i in selected:
|
||||
if i.get('url','') in top_urls: continue
|
||||
cat=i.get('category')
|
||||
if cat=='astronomy': groups['space'].append(i)
|
||||
elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i)
|
||||
elif cat=='local': groups['local'].append(i)
|
||||
else: groups['signals'].append(i)
|
||||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates)
|
||||
now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates)
|
||||
if weather['ok']:
|
||||
cur=weather['current']; daily=weather.get('daily',{})
|
||||
weather_metrics=''.join([
|
||||
metric_card('Clarksville',c_to_f(cur.get('temperature_2m')),weather.get('condition',''), 'weather'),
|
||||
metric_card('Feels / humidity',f"{c_to_f(cur.get('apparent_temperature'))} / {cur.get('relative_humidity_2m','—')}%",'right now','weather'),
|
||||
metric_card('Wind',kmh_to_mph(cur.get('wind_speed_10m')),'Open-Meteo','weather'),
|
||||
metric_card('Rain chance',f"{weather.get('max_precip',0)}%",'next 24h','weather'),
|
||||
metric_card('Sunrise / sunset',f"{(daily.get('sunrise') or ['—'])[0][-5:]} / {(daily.get('sunset') or ['—'])[0][-5:]}",'local time','weather')])
|
||||
else:
|
||||
weather_metrics=metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')
|
||||
moon_metrics=''.join([
|
||||
metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'),
|
||||
metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')])
|
||||
pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse)
|
||||
forecast_html=forecast_panel(weather)
|
||||
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='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
|
||||
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
|
||||
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=True)}
|
||||
{collapsible_section('Today','Calendar, Donetick, and notes.',f"<section class='today'>{list_panel('Today',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}</section>",'today-overview',open_by_default=True)}
|
||||
{forecast_html}
|
||||
{paperless_review_section()}
|
||||
{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False)}
|
||||
{section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True)}
|
||||
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:6],'No sky signal right now.',compact=True)}
|
||||
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
|
||||
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:4],'No local signal right now.',compact=True)}
|
||||
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
|
||||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||||
|
||||
def main()->int:
|
||||
PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True)
|
||||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos())
|
||||
generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))}
|
||||
(DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n')
|
||||
(DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n')
|
||||
(DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n')
|
||||
(DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n')
|
||||
(DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n')
|
||||
if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n')
|
||||
(PUBLIC/'index.html').write_text(render(),encoding='utf-8')
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
1
home/doris-dashboard/data/calendar.json
Normal file
1
home/doris-dashboard/data/calendar.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
12
home/doris-dashboard/data/notes.json
Normal file
12
home/doris-dashboard/data/notes.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"title": "Dashboard MVP",
|
||||
"body": "To-do and calendar sections are wired as local JSON placeholders until Donetick/calendar integrations are added.",
|
||||
"tag": "setup"
|
||||
},
|
||||
{
|
||||
"title": "Paperless notes/tags audit",
|
||||
"body": "Explicit follow-up: review and correct all Paperless notes and tags, not just school-intake edge cases. Keep this visible until the broader cleanup pass is actually finished.",
|
||||
"tag": "paperless"
|
||||
}
|
||||
]
|
||||
32
home/doris-dashboard/data/paperless_review.example.json
Normal file
32
home/doris-dashboard/data/paperless_review.example.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": 12345,
|
||||
"title": "scan_2026_05_12",
|
||||
"original_filename": "utility_statement_may.pdf",
|
||||
"added": "2026-05-13T02:14:00Z",
|
||||
"archive_url": "https://paperless.paccoco.com/documents/12345/details",
|
||||
"current": {
|
||||
"correspondent": "CDE Lightband",
|
||||
"document_type": "Statement",
|
||||
"tags": ["inbox"]
|
||||
},
|
||||
"triage": {
|
||||
"summary": "Monthly utility bill from CDE Lightband for $142.17 due on 2026-06-05.",
|
||||
"suggested_title": "CDE Lightband Utility Bill - May 2026",
|
||||
"document_type": "bill",
|
||||
"suggested_correspondent": "CDE Lightband",
|
||||
"suggested_tags": ["paperless-triage", "utility", "bill"],
|
||||
"due_date": "2026-06-05",
|
||||
"needs_review": true,
|
||||
"urgency": "medium",
|
||||
"reason": "Contains a payment due date; safety gate requires human review.",
|
||||
"confidence": "high"
|
||||
},
|
||||
"safety": {
|
||||
"forced_review": true,
|
||||
"eligible_for_safe_apply": false,
|
||||
"apply_safe_enabled": false
|
||||
},
|
||||
"queued_at": "2026-05-13T03:22:00Z"
|
||||
}
|
||||
]
|
||||
17
home/doris-dashboard/doris-dashboard.service
Normal file
17
home/doris-dashboard/doris-dashboard.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Doris Dashboard local LAN web server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=fizzlepoof
|
||||
Group=fizzlepoof
|
||||
WorkingDirectory=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
||||
ExecStartPre=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/generate_dashboard.py
|
||||
ExecStart=/usr/bin/python3 /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/dashboard_server.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
11
home/doris-dashboard/public/style.css
Normal file
11
home/doris-dashboard/public/style.css
Normal file
@@ -0,0 +1,11 @@
|
||||
:root{color-scheme:dark;--bg:#0c111b;--panel:#151b27;--panel2:#202939;--text:#eef4ff;--muted:#9cabbe;--line:#2d394b;--accent:#8fd3ff;--space:#9f8cff;--home:#4fd1a5;--local:#ffd166;--warn:#ff6b6b;--ok:#5ee787}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#213455,#0c111b 42rem);color:var(--text)}a{color:inherit}main{max-width:1220px;margin:0 auto;padding:18px}.hero{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:end;margin-bottom:18px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:.78rem;margin:0 0 8px}h1{font-size:clamp(2.2rem,6vw,4.5rem);line-height:.94;margin:0}.sub{color:var(--muted);font-size:1rem;max-width:760px}.hero-actions{display:flex;gap:12px;align-items:center;margin-top:10px;flex-wrap:wrap}.reset-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:8px 12px;font:inherit;cursor:pointer}.reset-btn:hover{border-color:var(--accent)}.status,.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.status div,.metric,.panel,.card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.status strong,.metric strong{display:block;font-size:1.1rem}.status span,.metric span,.metric em,.meta,footer,.empty{color:var(--muted)}.metric{min-height:92px;border-top:4px solid var(--accent)}.metric strong{font-size:1.35rem;margin:6px 0}.metric em{font-style:normal;font-size:.84rem}.metric.weather{border-top-color:var(--local)}.metric.space{border-top-color:var(--space)}.metric.ok{border-top-color:var(--ok)}.metric.warn{border-top-color:var(--warn)}.today{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:12px;margin:0}.panel h2,section h2{margin:0 0 8px}ul{padding-left:18px;margin:0}.panel ul{list-style:none;padding-left:0;display:grid;gap:8px}.panel li{margin:0}section{margin:18px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{min-height:245px;display:flex;flex-direction:column;gap:10px;border-top:4px solid var(--accent)}.card.astronomy{border-top-color:var(--space)}.card.homelab,.card.smart_home,.card.radio{border-top-color:var(--home)}.card.local{border-top-color:var(--local)}.card.security{border-top-color:var(--warn)}.meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.74rem}.meta span{padding:3px 8px;border-radius:999px;background:var(--panel2)}h3{font-size:1rem;line-height:1.2;margin:0}h3 a{text-decoration:none}h3 a:hover{text-decoration:underline}p{line-height:1.35}.why{margin-top:auto;color:#c8d5ea;background:rgba(143,211,255,.08);padding:10px;border-radius:12px;font-size:.88rem}footer{border-top:1px solid var(--line);padding:18px 0 26px}.collapsible{margin:16px 0;border:1px solid var(--line);border-radius:20px;background:rgba(21,27,39,.9);box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px);overflow:hidden}.collapsible>summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;justify-content:space-between;gap:12px;align-items:center}.collapsible>summary::-webkit-details-marker{display:none}.collapsible-heading h2{margin:0}.collapsible-heading p{margin:4px 0 0;color:var(--muted)}.collapsible-body{padding:0 14px 14px}.collapse-indicator{color:var(--muted);transition:transform .15s ease}.collapsible[open] .collapse-indicator{transform:rotate(180deg)}@media(max-width:1050px){.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr 1fr}.hero{grid-template-columns:1fr}}@media(max-width:650px){main{padding:14px}.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr}.card{min-height:auto}}
|
||||
|
||||
/* Compact article layout: keep the number of stories, reduce scroll debt. */
|
||||
.news-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.compact-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px;box-shadow:0 6px 18px rgba(0,0,0,.14)}.compact-card.astronomy{border-left-color:var(--space)}.compact-card.homelab,.compact-card.smart_home,.compact-card.radio{border-left-color:var(--home)}.compact-card.local{border-left-color:var(--local)}.compact-card.security{border-left-color:var(--warn)}.compact-card .meta{margin-bottom:4px}.compact-card h3{font-size:.98rem;line-height:1.18;margin:0 0 3px}.compact-card p{margin:0;color:#c8d5ea;font-size:.88rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.compact-card .meta span{padding:2px 7px}.compact-card:nth-child(n+7){display:none}section header{display:flex;justify-content:space-between;gap:12px;align-items:end}section header p{margin:0}.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}.feedback-btn{background:transparent;border:1px solid var(--line);border-radius:999px;color:var(--text);padding:2px 8px;font:inherit;cursor:pointer}.feedback-btn:hover{border-color:var(--accent);transform:translateY(-1px)}.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),0 6px 18px rgba(0,0,0,.14)}@media(max-width:650px){.compact-card p{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}section header{display:block}}
|
||||
.muted{color:var(--muted);font-size:.86rem;margin:0 .25rem}.panel-item{background:rgba(32,41,57,.55);border:1px solid var(--line);border-radius:14px;padding:10px 12px}.panel-item-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.panel-item-title{margin-top:2px;font-weight:600;color:#dbe7f7}.panel-item-body{margin-top:4px;color:#c8d5ea;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.panel .empty{padding:12px 0}.donetick-panel{padding:12px 14px}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.task-card{background:rgba(32,41,57,.58);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.task-top{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:4px}.task-due{font-size:.8rem;font-weight:800;letter-spacing:.02em;text-transform:none;color:#dbe7f7}.task-assignee{font-size:.72rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--muted)}.task-name{font-size:1rem;font-weight:700;line-height:1.08;color:var(--text);margin-bottom:6px}.task-subtitle{font-size:.78rem;line-height:1.16;color:var(--muted)}.task-chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:5px}.task-chip{display:inline-block;padding:3px 8px;border-radius:999px;color:#fff;font-size:.72rem;font-weight:700;line-height:1.05}.task-card.overdue{border-color:rgba(255,107,107,.55)}.task-card.today{border-color:rgba(255,209,102,.5)}.task-card.tomorrow{border-color:rgba(142,202,230,.45)}.task-card.nodue{opacity:.92}
|
||||
|
||||
/* Forecast */
|
||||
.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem}
|
||||
|
||||
/* Paperless review queue */
|
||||
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:12px 14px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.acknowledged{opacity:.7}.paperless-card h4{margin:.4rem 0;font-size:1rem}.paperless-card .summary{margin:.35rem 0;color:#d7e3f5}.paperless-card .link{display:inline-block;margin-top:.25rem;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:6px 10px;font:inherit;cursor:pointer}.paperless-btn.danger{border-color:rgba(255,107,107,.4);color:#ffd9d9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:650px){.paperless-grid{grid-template-columns:1fr}}
|
||||
Reference in New Issue
Block a user