#!/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 agedict[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"
Why: {esc(why(i))}
" 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"
" f"" f"" f"
" ) return f"""
{esc(label(i.get('category','')))}{esc(i.get('source'))}{esc(when)} UTC

{esc(i.get('title'))}

{esc(summary)}

{feedback_html}{why_html}
""" 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"

{esc(empty)}

" style='news-list' if compact else 'grid' return collapsible_section(title,sub,f"
{body}
",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"

{esc(sub)}

" if sub else '' return f"

{esc(title)}

{sub_html}
{inner_html}
" 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.','

No documents waiting.

','paperless-review',open_by_default=False,extra_class='panel') urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'} active=[]; needs_attention=[] for item in items: status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower() if status in {'dismissed','good_to_go'}: continue if status=='acknowledged': status='needs_attention' (needs_attention if status=='needs_attention' else active).append(item) def render_card(item:dict[str,Any], needs_attention_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=' needs-attention' if needs_attention_card else '' primary_label='Clear flag' if needs_attention_card else 'Needs attention' primary_action='needs_attention' return f"""
{esc(str(urgency).upper())}{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}
{f"View in Paperless" if item.get('archive_url') else ''}

{esc(item.get('title') or triage.get('suggested_title','Untitled'))}

{f"

{esc(summary)}

" if summary else ''} {f"

{esc(meta)}

" if meta else ''} {f"

{esc(reason)}

" if reason else ''}
""" visible_active=active[:3] overflow_active=active[3:] active_cards=''.join(render_card(item,False) for item in visible_active) flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8]) count=len(active) flag_count=len(needs_attention) body=f"
{active_cards or '

No active documents waiting.

'}
" if overflow_active: body += f"
Show {esc(str(len(overflow_active)))} more active items
{''.join(render_card(item,False) for item in overflow_active[:8])}
" if flag_count: body += f"
Needs attention {esc(str(flag_count))}
{flagged_cards}

Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.

" if count+flag_count>3: body += f"

Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.

" return collapsible_section(f'๐Ÿ“„ Paperless Review ({count} active)',f'{flag_count} flagged for follow-up ยท review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=(count+flag_count)>0,extra_class='panel') def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str: if not items: body=f"
  • {esc(empty)}
  • " 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"
  • {esc(kicker)}{f'{esc(assignee)}' if assignee else ''}
    {f'
    {esc(headline)}
    ' if headline else ''}{f'
    {esc(detail)}
    ' if detail else ''}
  • ") body=''.join(rows) return f"

    {esc(title)}

    " def donetick_panel(items:list[dict[str,Any]],empty:str)->str: if not items: return f"

    Donetick

    {esc(empty)}
    " rows=[] for x in items[:6]: chips=''.join(f"{esc(c.get('name',''))}" 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"
    {esc(x.get('due_title') or x.get('time') or 'No due date')}
    {f'{esc(x.get("assignee") or "")}' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}
    {esc(x.get('title') or 'Untitled task')}
    {f'
    {chips}
    ' if chips else ''}{f'
    {esc(subtitle)}
    ' if subtitle else ''}
    ") return f"

    Donetick

    {''.join(rows)}
    " def feedback_bootstrap()->str: return """""" def metric_card(title:str,value:str,sub:str,cls:str='')->str: return f"
    {esc(title)}{esc(value)}{esc(sub)}
    " 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"""
    {esc(label)} {esc(str(rain))} mm rain {esc(rise)} sunrise ยท {esc(set_)} sunset
    """) if not cards: return "" return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"
    {''.join(cards)}
    ",'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"""Doris Dashboard

    PC Doris Thatcher

    Doris Dashboard

    Morning briefing, sky watch, homelab pulse, and local signal โ€” refreshed on NOMAD.

    Hidden topics: 0
    {esc(now.strftime('%a %b %-d, %-I:%M %p'))}Last generated Central ยท auto-refreshes at :05 / :35
    {len(candidates)}Candidates
    {len(set(i.get('source','') for i in candidates))}Sources
    {collapsible_section('At a glance','Weather and sky cards.',f"
    {weather_metrics}{moon_metrics}
    ",'at-a-glance',open_by_default=False)} {collapsible_section('Today','Calendar, Donetick, and notes.',f"
    {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.')}
    ",'today-overview',open_by_default=True)} {forecast_html} {paperless_review_section()} {collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"
    {pulse_metrics}
    ",'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)}
    {feedback_bootstrap()}""" 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())