600 lines
36 KiB
Python
Executable File
600 lines
36 KiB
Python
Executable File
#!/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'>"
|
||
f"<button type='button' class='feedback-btn' data-feedback='up' aria-pressed='false' title='Prefer more {esc(topic)} stories'>👍</button>"
|
||
f"<button type='button' class='feedback-btn' data-feedback='down' aria-pressed='false' title='Less like this — hide this story'>👎</button>"
|
||
f"</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:
|
||
triage=item.get('triage',{}) or {}
|
||
current=item.get('current',{}) or {}
|
||
urgency=triage.get('urgency','none')
|
||
summary=strip_markup(triage.get('summary',''))
|
||
reason=strip_markup(triage.get('reason',''))
|
||
meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x]
|
||
meta=' • '.join(str(x) for x in meta_parts)
|
||
status_class=' acknowledged' if acknowledged_card else ''
|
||
return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
|
||
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><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></div>
|
||
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
|
||
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
|
||
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
|
||
{f"<p class='paperless-reason'>{esc(reason)}</p>" if reason else ''}
|
||
</article>"""
|
||
visible_active=active[:3]
|
||
overflow_active=active[3:]
|
||
active_cards=''.join(render_card(item,False) for item in visible_active)
|
||
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 overflow_active:
|
||
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
|
||
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>3:
|
||
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</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_text=(x.get('subtitle') or '').strip()
|
||
chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')}
|
||
subtitle_parts=[]
|
||
for part in [p.strip() for p in subtitle_text.split('•') if p.strip()]:
|
||
if part.lower() in chip_names:
|
||
continue
|
||
subtitle_parts.append(part)
|
||
subtitle=' • '.join(subtitle_parts)
|
||
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-state-dot' aria-hidden='true'></div><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(subtitle)}</div>' if 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 HIDDEN_CARD_KEY='doris-dashboard-hidden-cards-v1';
|
||
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
|
||
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"hidden":[]}')}catch(e){return {hidden:[]}}};
|
||
const safeHiddenCards=()=>{try{return JSON.parse(localStorage.getItem(HIDDEN_CARD_KEY)||'[]')}catch(e){return []}};
|
||
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
|
||
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({hidden:[...new Set(prefs.hidden||[])]}));
|
||
const saveHiddenCards=(ids)=>localStorage.setItem(HIDDEN_CARD_KEY, JSON.stringify([...new Set(ids||[])]));
|
||
const prefs=safeRead();
|
||
let hiddenCards=safeHiddenCards();
|
||
const collapsed=safeCollapse();
|
||
const cards=[...document.querySelectorAll('[data-topic]')];
|
||
const sections=[...document.querySelectorAll('details[data-section-key]')];
|
||
const toast=document.createElement('div');
|
||
toast.className='dashboard-toast';
|
||
toast.hidden=true;
|
||
document.body.appendChild(toast);
|
||
let lastAction=null;
|
||
let toastTimer=null;
|
||
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 cardId=(card)=>card.querySelector('h3 a')?.getAttribute('href')||card.querySelector('h3 a')?.textContent||card.dataset.topic||Math.random().toString(36).slice(2);
|
||
cards.forEach(card=>{card.dataset.cardId=cardId(card);});
|
||
const setToast=(html)=>{
|
||
toast.innerHTML=html;
|
||
toast.hidden=false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer=setTimeout(()=>{toast.hidden=true;},4500);
|
||
};
|
||
const sync=()=>{
|
||
cards.forEach(card=>{
|
||
const topic=card.dataset.topic||'other';
|
||
const hidden=(prefs.hidden||[]).includes(topic) || hiddenCards.includes(card.dataset.cardId||'');
|
||
card.style.display=hidden?'none':'';
|
||
card.classList.remove('topic-liked');
|
||
card.querySelectorAll('[data-feedback]').forEach(btn=>{
|
||
btn.setAttribute('aria-pressed', 'false');
|
||
btn.classList.toggle('active-up', false);
|
||
btn.classList.toggle('active-down', false);
|
||
});
|
||
});
|
||
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';
|
||
const id=card.dataset.cardId||'';
|
||
if(btn.dataset.feedback==='down'){
|
||
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||
hiddenCards.push(id);
|
||
saveHiddenCards(hiddenCards);
|
||
setToast(`Right — less <strong>${topic}</strong> like this, and this story’s off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||
} else {
|
||
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||
hiddenCards.push(id);
|
||
saveHiddenCards(hiddenCards);
|
||
setToast(`Right — I’ll look for more <strong>${topic}</strong> like this, and I’ve cleared this story off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||
}
|
||
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 undo=ev.target.closest('[data-undo-topic]');
|
||
if(!undo || !lastAction) return;
|
||
prefs.hidden=[...(lastAction.prefs.hidden||[])];
|
||
hiddenCards=[...(lastAction.hiddenCards||[])];
|
||
save(prefs); saveHiddenCards(hiddenCards); sync();
|
||
setToast('Undid the last topic/story action.');
|
||
});
|
||
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=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden topics and local story hides.'); 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 · auto-refreshes at :05 / :35</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=False)}
|
||
{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'][:5],'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'][:3],'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())
|