const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)];
const LIBRARY_SORTS=['recent','title','author','time','progress'],LIBRARY_FILTERS=['all','unread','progress','completed'];
const storedChoice=(key,choices,fallback)=>{const value=localStorage.getItem(key);return choices.includes(value)?value:fallback};
const state={books:[],dashboard:null,csrf:null,pairPoll:null,currentBook:null,status:null,rangeKey:'year',calendar:null,calendarMonth:null,calendarSelectedDay:null,coverChooserBookId:null,librarySort:storedChoice('kovi-library-sort',LIBRARY_SORTS,'recent'),libraryFilter:storedChoice('kovi-library-filter',LIBRARY_FILTERS,'all'),libraryQuery:''};
const VIEW_PATHS={home:'/',library:'/library',calendar:'/calendar',devices:'/devices',status:'/status'};
const RANGE_LABELS={week:'Last week',month:'Last month','three-months':'Last three months','six-months':'Last six months',year:'Last year',custom:'Custom dates'};
const RANGE_DAYS={week:7,month:30,'three-months':90,'six-months':180,year:365};
const HEATMAP_REFERENCE_WEEKS=53;
const fmtSeconds=s=>{s=Math.max(0,Number(s||0));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);return h?`${h}h ${m}m`:`${m}m`};
const fmtCompactSeconds=s=>{s=Math.max(0,Number(s||0));const m=Math.max(1,Math.round(s/60));return m>=60?`${(m/60).toFixed(m%60?1:0)}h`:`${m}m`};
const esc=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
const progress=b=>{const page=Number(b.last_page||0),total=Number(b.last_total_pages||b.pages||0);return total>0?Math.min(100,Math.round(page/total*100)):0};
const bookIsRead=b=>b.is_read==null?progress(b)===100:Boolean(Number(b.is_read));
const localDateKey=d=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const parseLocalDateKey=key=>{const m=/^(\d{4})-(\d{2})-(\d{2})$/.exec(String(key||''));if(!m)return null;const d=new Date(Number(m[1]),Number(m[2])-1,Number(m[3]),12,0,0,0);return d.getFullYear()===Number(m[1])&&d.getMonth()===Number(m[2])-1&&d.getDate()===Number(m[3])?d:null};
const formatDate=(key,opts={month:'short',day:'numeric',year:'numeric'})=>parseLocalDateKey(key)?.toLocaleDateString(undefined,opts)||key;
function toast(msg){const el=$('#toast');el.textContent=msg;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),3200)}
async function api(url,opts={},retryCsrf=true){
const method=(opts.method||'GET').toUpperCase();
const request={...opts,headers:{...(opts.headers||{})}};
if(method!=='GET'&&!state.csrf){const session=await api('/api/session',{},false);state.csrf=session.csrf}
if(method!=='GET'&&state.csrf)request.headers['X-Kovi-CSRF']=state.csrf;
const r=await fetch(url,request);const data=await r.json().catch(()=>({}));
if(!r.ok&&r.status===403&&retryCsrf&&method!=='GET'&&data.error==='Missing browser request token.'){const session=await api('/api/session',{},false);state.csrf=session.csrf;return api(url,opts,false)}
if(!r.ok)throw new Error(data.error||`Request failed (${r.status})`);return data;
}
function coverImageSrc(b){if(!b.cover_path)return '';const version=b.updated_at||b.cover_checked_at||'';return version?`${b.cover_path}${b.cover_path.includes('?')?'&':'?'}v=${encodeURIComponent(version)}`:b.cover_path}
function coverMarkup(b){return b.cover_path?` `:`
${esc(b.title)} ${esc(b.authors||'Unknown author')}
`}
function bookCard(b){const p=progress(b),read=bookIsRead(b);return `${coverMarkup(b)}${read?'Read ':''}
${esc(b.title)} ${esc(b.authors||'Unknown author')}
${p}% `}
function bookReadingState(book){if(bookIsRead(book))return 'completed';if(book.read_override!=null&&Number(book.read_override)===0)return 'unread';return progress(book)===0?'unread':'progress'}
function renderLibrary(){
const collator=new Intl.Collator(undefined,{sensitivity:'base',numeric:true}),title=(a,b)=>collator.compare(a.title||'',b.title||'');
const comparators={recent:(a,b)=>Number(b.recent_read||b.last_open||0)-Number(a.recent_read||a.last_open||0)||title(a,b),title,author:(a,b)=>collator.compare(a.authors||'Unknown author',b.authors||'Unknown author')||title(a,b),time:(a,b)=>Number(b.total_read_time||0)-Number(a.total_read_time||0)||title(a,b),progress:(a,b)=>progress(b)-progress(a)||title(a,b)};
const query=state.libraryQuery.trim().toLowerCase();
const books=state.books.filter(book=>(state.libraryFilter==='all'||bookReadingState(book)===state.libraryFilter)&&(!query||`${book.title} ${book.authors||''}`.toLowerCase().includes(query))).sort(comparators[state.librarySort]);
$('#bookSort').value=state.librarySort;$('#bookFilter').value=state.libraryFilter;
$('#librarySummary').textContent=books.length===state.books.length?`${books.length} book${books.length===1?'':'s'}`:`Showing ${books.length} of ${state.books.length} books`;
$('#bookGrid').innerHTML=books.map(bookCard).join('')||`${state.books.length?'No books match these filters.':'No books yet.'}
`;
}
function rangeLabel(){return RANGE_LABELS[state.rangeKey]||'Custom dates'}
function rangeDescription(range){return `${formatDate(range.from)} – ${formatDate(range.to)}`}
function buildRangeSeries(days,range){
const by=new Map((days||[]).map(x=>[x.day,Number(x.seconds||0)]));
const start=parseLocalDateKey(range?.from),end=parseLocalDateKey(range?.to);if(!start||!end)return [];
const out=[];for(const d=new Date(start);d<=end;d.setDate(d.getDate()+1)){const day=localDateKey(d);out.push({day,seconds:by.get(day)||0})}return out;
}
function render(){
const d=state.dashboard,b=state.books;const empty=!b.length;
$('#emptyState').classList.toggle('hidden',!empty);$('#dashboardContent').classList.toggle('hidden',empty);
if(!empty&&d){
$('#metrics').innerHTML=[
['Books',d.books,'in your reading history'],
['Books read',Number(d.range?.read_books||0).toLocaleString(),`started and completed · ${rangeLabel()}`],
['Reading time',fmtSeconds(d.all_time_reading_seconds??d.total_read_time??d.session_seconds),'all-time KOReader total'],
['Pages',Number(d.total_read_pages||0).toLocaleString(),'pages turned'],
['Highlights',Number(d.highlights||0).toLocaleString(),'ideas kept'],
].map(x=>`${x[0]} ${x[1]} ${x[2]} `).join('');
renderRangeUi(d.range);renderHeatmap(d.days||[],d.range);renderLineChart(d.days||[],d.range,d);
}
renderLibrary();
}
function renderRangeUi(range){
if(!range)return;
const label=rangeLabel();
$('#rangeSummary').textContent=state.rangeKey==='custom'?rangeDescription(range):label;
$('#rhythmRangeLabel').textContent=label;$('#trendRangeLabel').textContent=label;
$$('.range-button').forEach(button=>button.classList.toggle('active',button.dataset.range===state.rangeKey));
$('#customFrom').value=range.from;$('#customTo').value=range.to;
const today=localDateKey(new Date());$('#customFrom').max=today;$('#customTo').max=today;
}
function heatLevel(sec,active){
if(!sec)return 0;if(!active.length)return 1;
const sorted=[...active].sort((a,b)=>a-b),q=p=>sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*p))];
if(sec<=q(.25))return 1;if(sec<=q(.5))return 2;if(sec<=q(.75))return 3;return 4;
}
function renderHeatmap(days,range){
const from=parseLocalDateKey(range?.from),to=parseLocalDateKey(range?.to);if(!from||!to)return;
const by=new Map(days.map(x=>[x.day,Number(x.seconds||0)])),active=[...by.values()].filter(Boolean);
const start=new Date(from);start.setDate(start.getDate()-start.getDay());
const end=new Date(to);end.setDate(end.getDate()+(6-end.getDay()));
const cellCount=Math.round((end-start)/86400000)+1,weeks=Math.ceil(cellCount/7);
let html='',activeDays=0,total=0;
for(let i=0;ito;const key=localDateKey(dt),sec=outside?0:(by.get(key)||0);
if(sec){activeDays++;total+=sec}const level=heatLevel(sec,active);
html+=` `;
}
const heatmap=$('#heatmap'),months=$('#heatmapMonths'),canvas=heatmap.closest('.heatmap-scroll'),layout=heatmap.closest('.heatmap-layout');heatmap.innerHTML=html;
const monthCells=[];let prior='';
for(let week=0;week180 ? mid.getMonth()===0 : weeks>90 ? (mid.getMonth()===0||mid.getMonth()===6) : true;
monthCells.push(`${changed&&sparse?esc(mid.toLocaleDateString(undefined,{month:weeks>90?'short':'short'})):''} `);prior=monthKey;
}
months.innerHTML=monthCells.join('');
// Keep the visual scale stable across filters: shorter ranges should reveal fewer
// GitHub-style columns, not stretch those columns to fill the whole panel.
// The selected range therefore uses the same cell size a full 53-week year
// would use at the current viewport. On narrow screens that reference size
// naturally shrinks, so even the full year still fits without scrolling.
const available=Math.max(1,canvas?.clientWidth||0),gap=available<480?1.5:3;
const referenceCell=Math.max(.25,(available-gap*(HEATMAP_REFERENCE_WEEKS-1))/HEATMAP_REFERENCE_WEEKS);
const cell=Math.min(referenceCell,(available-gap*Math.max(0,weeks-1))/Math.max(1,weeks));
[canvas,layout].filter(Boolean).forEach(el=>{el.style.setProperty('--heat-weeks',String(weeks));el.style.setProperty('--heat-gap',`${gap}px`);el.style.setProperty('--heat-cell',`${cell}px`)});
$('#heatmapCaption').textContent=activeDays?`${activeDays} reading day${activeDays===1?'':'s'} · ${fmtSeconds(total)} tracked from ${formatDate(range.from)} to ${formatDate(range.to)}.`:`No dated reading sessions in ${rangeDescription(range)}.`;
}
function niceStep(value){
if(value<=0)return 15;const power=10**Math.floor(Math.log10(value));const scaled=value/power;
const factor=scaled<=1?1:scaled<=2?2:scaled<=5?5:10;return factor*power;
}
function axisMinutes(minutes){if(minutes>=60){const hours=minutes/60;return Number.isInteger(hours)?`${hours}h`:`${hours.toFixed(1)}h`}return `${Math.round(minutes)}m`}
function chartDateLabel(key,span){const d=parseLocalDateKey(key);if(!d)return key;if(span<=14)return d.toLocaleDateString(undefined,{weekday:'short'});if(span<=120)return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});return d.toLocaleDateString(undefined,{month:'short',year:'2-digit'})}
function chartKeyPointIndices(series){
const active=series.map((item,index)=>({index,seconds:item.seconds})).filter(x=>x.seconds>0);
if(!active.length)return new Set();
if(series.length<=45)return new Set(active.map(x=>x.index));
const values=active.map(x=>x.seconds).sort((a,b)=>a-b),threshold=values[Math.max(0,Math.floor(values.length*.75)-1)]||0;
const keys=new Set();let max=active[0];
for(const point of active){
if(point.seconds>max.seconds)max=point;
const prev=series[point.index-1]?.seconds||0,next=series[point.index+1]?.seconds||0;
if(point.seconds>=threshold&&point.seconds>=prev&&point.seconds>=next)keys.add(point.index);
}
keys.add(max.index);return keys;
}
function renderLineChart(days,range,dashboard){
const series=buildRangeSeries(days,range),total=series.reduce((sum,x)=>sum+x.seconds,0),readingDays=series.filter(x=>x.seconds>0).length;
$('#trendTotal').textContent=fmtSeconds(total);$('#trendDays').textContent=String(readingDays);
const chart=$('#lineChart');
if(!series.length||!series.some(x=>x.seconds>0)){
chart.innerHTML='No reading sessions in this range.
';
chart.setAttribute('aria-label',`No dated reading sessions from ${formatDate(range.from)} to ${formatDate(range.to)}.`);
}else{
const width=Math.max(320,Math.round(chart.clientWidth||900)),height=270,left=54,right=18,top=18,bottom=42,plotW=width-left-right,plotH=height-top-bottom;
const maxMinutes=Math.max(...series.map(x=>x.seconds/60)),step=niceStep(maxMinutes/4),yMax=Math.max(step*4,Math.ceil(maxMinutes/step)*step);
const x=i=>left+(series.length===1?plotW/2:(i/(series.length-1))*plotW),y=seconds=>top+plotH-(seconds/60/yMax)*plotH;
const grid=[];for(let i=0;i<=4;i++){const min=(yMax/4)*i,yy=top+plotH-(i/4)*plotH;grid.push(`${axisMinutes(min)} `)}
const points=series.map((item,i)=>`${x(i).toFixed(2)},${y(item.seconds).toFixed(2)}`).join(' ');
const tickIndices=[0,.25,.5,.75,1].map(p=>Math.round((series.length-1)*p)).filter((v,i,a)=>a.indexOf(v)===i);
const ticks=tickIndices.map(i=>`${esc(chartDateLabel(series[i].day,series.length))} `).join('');
const keyPoints=chartKeyPointIndices(series);
const dots=[...keyPoints].sort((a,b)=>a-b).map(i=>`${esc(`${formatDate(series[i].day)}: ${fmtSeconds(series[i].seconds)}`)} `).join('');
chart.innerHTML=`${grid.join('')} ${ticks} ${dots}
`;
const svg=$('svg',chart),tooltip=$('.chart-tooltip',chart),guide=$('.chart-hover-guide',chart),hoverDot=$('.chart-hover-dot',chart);let pinnedIndex=null;
const showAt=index=>{
index=Math.max(0,Math.min(series.length-1,index));const item=series[index],xx=x(index),yy=y(item.seconds),rect=svg.getBoundingClientRect();
guide.setAttribute('x1',xx);guide.setAttribute('x2',xx);guide.classList.remove('hidden');hoverDot.setAttribute('cx',xx);hoverDot.setAttribute('cy',yy);hoverDot.classList.remove('hidden');
$('strong',tooltip).textContent=formatDate(item.day,{weekday:'short',month:'short',day:'numeric',year:'numeric'});$('span',tooltip).textContent=item.seconds?fmtSeconds(item.seconds):'No reading';tooltip.classList.remove('hidden');
const cssX=(xx/width)*rect.width,cssY=(yy/height)*rect.height;tooltip.style.left=`${Math.max(70,Math.min(rect.width-70,cssX))}px`;tooltip.style.top=`${Math.max(20,cssY-8)}px`;
};
const hide=()=>{if(pinnedIndex!==null)return;tooltip.classList.add('hidden');guide.classList.add('hidden');hoverDot.classList.add('hidden')};
svg.addEventListener('pointermove',event=>{if(pinnedIndex!==null)return;const rect=svg.getBoundingClientRect(),px=(event.clientX-rect.left)*(width/Math.max(1,rect.width)),ratio=(px-left)/Math.max(1,plotW),index=Math.round(Math.max(0,Math.min(1,ratio))*(series.length-1));showAt(index)});
svg.addEventListener('pointerleave',hide);
svg.addEventListener('click',event=>{const rect=svg.getBoundingClientRect(),px=(event.clientX-rect.left)*(width/Math.max(1,rect.width)),ratio=(px-left)/Math.max(1,plotW),index=Math.round(Math.max(0,Math.min(1,ratio))*(series.length-1));if(pinnedIndex===index){pinnedIndex=null;hide()}else{pinnedIndex=index;showAt(index)}});
svg.addEventListener('focusin',event=>{const dot=event.target.closest?.('[data-chart-index]');if(dot){pinnedIndex=Number(dot.dataset.chartIndex);showAt(pinnedIndex)}});
svg.addEventListener('focusout',()=>{pinnedIndex=null;hide()});
chart.setAttribute('aria-label',`Daily reading time from ${formatDate(range.from)} to ${formatDate(range.to)}. Hover or tap the chart to inspect a day. ${fmtSeconds(total)} across ${readingDays} reading days.`);
}
const allTime=Number(dashboard.all_time_reading_seconds||0),sessionAll=Number(dashboard.session_seconds||0),gap=Math.abs(allTime-sessionAll);
let caption=`Daily session history for ${rangeDescription(range)}. Hover or tap the graph to inspect individual days.`;
if(gap>=60)caption+=` The all-time Reading time card uses KOReader’s per-book total (${fmtSeconds(allTime)}), while dated session rows add up to ${fmtSeconds(sessionAll)} across all dates; this is why the figures can differ.`;
else caption+=' The Reading time card is all-time; this graph is limited to the selected dates.';
$('#trendCaption').textContent=caption;
}
function presetRange(key){const days=RANGE_DAYS[key];if(!days)return null;const to=new Date();to.setHours(12,0,0,0);const from=new Date(to);from.setDate(from.getDate()-(days-1));return {from:localDateKey(from),to:localDateKey(to)}}
async function applyDashboardRange(key,range){
if(!range)return;state.rangeKey=key;
const params=new URLSearchParams({from:range.from,to:range.to});
try{state.dashboard=await api(`/api/dashboard?${params}`);render()}catch(e){toast(e.message)}
}
let coverPoll=null;
async function refresh(){
const dashboardUrl=state.dashboard?.range?`/api/dashboard?${new URLSearchParams({from:state.dashboard.range.from,to:state.dashboard.range.to})}`:'/api/dashboard';
const [books,dashboard]=await Promise.all([api('/api/books'),api(dashboardUrl)]);
state.books=books.books;state.dashboard=dashboard;render();
if(coverPoll){clearTimeout(coverPoll);coverPoll=null}
if(state.books.some(b=>b.cover_status==='pending'))coverPoll=setTimeout(()=>refresh().catch(()=>{}),5000);
}
function monthAnchor(value=new Date()){const d=value instanceof Date?new Date(value):parseLocalDateKey(value)||new Date();return new Date(d.getFullYear(),d.getMonth(),1,12,0,0,0)}
function calendarMonthRange(value){const first=monthAnchor(value),last=new Date(first.getFullYear(),first.getMonth()+1,0,12,0,0,0);return {from:localDateKey(first),to:localDateKey(last)}}
function calendarLevel(seconds,active){return heatLevel(seconds,active)}
function renderCalendarDetail(dayKey){
const detail=$('#calendarDetail'),data=state.calendar,day=(data?.days||[]).find(x=>x.day===dayKey);state.calendarSelectedDay=dayKey;
const dateInput=$('#calendarDateInput');if(dateInput)dateInput.value=dayKey;
$$('.calendar-day').forEach(el=>el.classList.toggle('selected',el.dataset.day===dayKey));
const date=formatDate(dayKey,{weekday:'long',month:'long',day:'numeric',year:'numeric'});
if(!day){detail.innerHTML=`${esc(date)}
No reading logged. There are no dated KOReader sessions for this day.
`;return}
const books=(day.books||[]).map(book=>`${book.cover_path?` `:`${esc((book.title||'?').slice(0,1))} `} ${esc(book.title)} ${esc(book.authors||'Unknown author')} ${fmtSeconds(book.seconds)} `).join('');
detail.innerHTML=`${esc(date)}
Reading time ${fmtSeconds(day.seconds)}
Books ${day.books.length}
${books}
`;
}
function renderCalendar(){
const data=state.calendar;if(!data)return;const range=data.range,from=parseLocalDateKey(range.from),to=parseLocalDateKey(range.to);if(!from||!to)return;
$('#calendarMonthTitle').textContent=from.toLocaleDateString(undefined,{month:'long',year:'numeric'});
const total=(data.days||[]).reduce((sum,day)=>sum+Number(day.seconds||0),0),readingDays=(data.days||[]).filter(day=>Number(day.seconds||0)>0).length;
$('#calendarMonthSummary').textContent=readingDays?`${readingDays} reading day${readingDays===1?'':'s'} · ${fmtSeconds(total)}`:'No reading sessions this month';
const by=new Map((data.days||[]).map(day=>[day.day,day])),active=(data.days||[]).map(day=>Number(day.seconds||0)).filter(Boolean);
const gridStart=new Date(from);gridStart.setDate(gridStart.getDate()-gridStart.getDay());const gridEnd=new Date(to);gridEnd.setDate(gridEnd.getDate()+(6-gridEnd.getDay()));
const today=localDateKey(new Date());let html='';
for(const d=new Date(gridStart);d<=gridEnd;d.setDate(d.getDate()+1)){
const key=localDateKey(d),item=by.get(key),outside=d.getMonth()!==from.getMonth(),seconds=Number(item?.seconds||0),level=calendarLevel(seconds,active),books=item?.books?.length||0;
const label=`${formatDate(key,{weekday:'long',month:'long',day:'numeric',year:'numeric'})}${seconds?`, ${fmtSeconds(seconds)}, ${books} book${books===1?'':'s'}`:', no reading logged'}`;
html+=`${d.getDate()} ${seconds?`${fmtCompactSeconds(seconds)} ${books} book${books===1?'':'s'} `:'· '} `;
}
const grid=$('#calendarGrid');grid.innerHTML=html;grid.setAttribute('aria-label',`Reading calendar for ${from.toLocaleDateString(undefined,{month:'long',year:'numeric'})}`);
let selected=state.calendarSelectedDay;if(!selected||selectedrange.to)selected=(data.days||[]).find(day=>day.day===today)?.day||(data.days||[])[0]?.day||range.from;
renderCalendarDetail(selected);
}
async function loadCalendar(value=state.calendarMonth||new Date(),selectDay=null){
const month=monthAnchor(value),range=calendarMonthRange(month);state.calendarMonth=month;if(selectDay)state.calendarSelectedDay=selectDay;
$('#calendarGrid').innerHTML='Loading reading days…
';
try{state.calendar=await api(`/api/calendar?${new URLSearchParams(range)}`);renderCalendar()}catch(e){toast(e.message);$('#calendarGrid').innerHTML='Could not load this month.
'}
}
function moveCalendarMonth(delta){const base=state.calendarMonth||monthAnchor();const next=new Date(base.getFullYear(),base.getMonth()+delta,1,12,0,0,0);state.calendarSelectedDay=null;loadCalendar(next)}
function jumpToCalendarDate(dayKey){const date=parseLocalDateKey(dayKey);if(!date)return toast('Choose a valid date.');loadCalendar(date,dayKey)}
function setView(name){
$$('.view').forEach(v=>v.classList.remove('active'));
$$('.nav-item').forEach(v=>v.classList.toggle('active',v.dataset.view===name));
const target=$(`#${name}View`);if(target)target.classList.add('active');
$('#pageTitle').textContent=name==='home'?'Good to see you.':name==='library'?'Your library.':name==='calendar'?'Your calendar.':name==='devices'?'Your devices.':name==='status'?'kovi status.':'Book details.';
if(name==='home'&&state.dashboard?.range)requestAnimationFrame(()=>{renderHeatmap(state.dashboard.days||[],state.dashboard.range);renderLineChart(state.dashboard.days||[],state.dashboard.range,state.dashboard)});
if(name==='calendar')loadCalendar();
if(name==='devices')loadDevices();
if(name==='status')loadStatus();
}
function switchView(name,{push=true}={}){setView(name);if(push&&VIEW_PATHS[name]&&location.pathname!==VIEW_PATHS[name])history.pushState({view:name},'',VIEW_PATHS[name]);window.scrollTo(0,0)}
async function openBook(id,{push=true}={}){
try{
const b=await api(`/api/books/${encodeURIComponent(id)}`);if(state.coverChooserBookId&&state.coverChooserBookId!==b.id)state.coverChooserBookId=null;state.currentBook=b;renderBookPage(b);setView('book');
if(push&&location.pathname!==`/books/${encodeURIComponent(id)}`)history.pushState({bookId:id},'',`/books/${encodeURIComponent(id)}`);
window.scrollTo(0,0);
}catch(error){toast(error.message);switchView('library',{push})}
}
function annotationMarkup(a){
const text=a.text||'',note=a.note||'',location=[a.chapter,a.pageno?`page ${a.pageno}`:a.page?`page ${a.page}`:''].filter(Boolean).join(' · ');
return `${a.annotation_type==='note'?'Note':'Highlight'} ${a.annotation_datetime?`${esc(a.annotation_datetime)} `:''}
${text?`${esc(text)} `:''}${note?`${esc(note)}
`:''}${location?`${esc(location)}
`:''} `;
}
let bookCoverPoll=null;
function stopBookCoverPoll(){if(bookCoverPoll){clearTimeout(bookCoverPoll);bookCoverPoll=null}}
function coverSourceLabel(source){return ({'koreader-embedded':'KOReader embedded','manual-upload':'Manual upload',openlibrary:'Open Library',googlebooks:'Google Books','openlibrary-via-googlebooks':'Open Library via Google Books','googlebooks-via-calibre-id':'Google Books via Calibre',calibre:'Calibre'}[source]||source||'No cover')}
function coverJobLabel(job){if(!job)return '';if(job.state==='queued')return 'Queued for online lookup…';if(job.state==='running')return 'Looking for an online cover…';if(job.state==='error')return `Lookup failed${job.last_error?`: ${job.last_error}`:''}`;if(job.state==='done'&&job.result_status==='none-kept')return 'No better cover found — kept the existing cover.';if(job.state==='done'&&job.result_status==='matched')return `Online cover updated${job.result_source?` via ${coverSourceLabel(job.result_source)}`:''}.`;return ''}
function coverOption(c,{history=false}={}){const selected=history&&Number(c.selected)===1;return `${esc(history?coverSourceLabel(c.source):(c.title||coverSourceLabel(c.source)))} ${esc(history?(c.strategy||new Date(c.created_at).toLocaleString()):([c.authors,coverSourceLabel(c.source),Number(c.score||0)?`${Math.round(Number(c.score)*100)}% match`:null].filter(Boolean).join(' · ')))}
${selected?'Current ':`Use this `} `}
async function pollBookCoverJob(bookId,attempt=0){
stopBookCoverPoll();
try{
const b=await api(`/api/books/${encodeURIComponent(bookId)}`);state.currentBook=b;renderBookPage(b);
if(b.cover_job&&['queued','running'].includes(b.cover_job.state)&&attempt<90){bookCoverPoll=setTimeout(()=>pollBookCoverJob(bookId,attempt+1),1000);return}
await refresh();
}catch{if(attempt<20)bookCoverPoll=setTimeout(()=>pollBookCoverJob(bookId,attempt+1),1500)}
}
function renderBookPage(b){
const p=progress(b),read=bookIsRead(b),annotations=b.annotations||[];let highlights='';
if(annotations.length)highlights=`${annotations.map(annotationMarkup).join('')}
`;
else if(Number(b.highlights||0)>0)highlights=`${Number(b.highlights)} highlight${Number(b.highlights)===1?'':'s'} counted by KOReader The statistics database stores only the count. Sync the kovi plugin to import the actual highlight text from KOReader’s book sidecars.
`;
else highlights='No highlights have been synced for this book.
';
const jobText=coverJobLabel(b.cover_job),jobBusy=b.cover_job&&['queued','running'].includes(b.cover_job.state);
const candidates=b.cover_candidates||[],history=b.cover_history||[],showCoverChoices=state.coverChooserBookId===b.id;
const statusLabel=read?'Read':b.read_override!=null&&Number(b.read_override)===0?'Unread':p>0?'Reading':'Unread';
const statusDetail=b.read_override!=null?`Manually marked ${Number(b.read_override)?'read':'unread'} in kovi.`:b.read_source==='koreader'?'Marked read by KOReader.':b.read_source==='progress'?'Completed from document progress.':'Completion is tracked separately from document progress.';
const readControl=`${statusLabel} ${esc(statusDetail)}
Mark as ${read?'unread':'read'} ${b.read_override!=null?'Use synced status ':''}
`;
const coverManager=`Cover manager
Choose the edition you want to see ${esc(coverSourceLabel(b.cover_source))} ${jobText?`${jobBusy?' ':''}${esc(jobText)}
`:''}Find alternatives
${showCoverChoices&&candidates.length?`Online candidates ${candidates.map(c=>coverOption(c)).join('')}
`:''}${showCoverChoices&&history.length>1?`Cover history ${history.map(c=>coverOption(c,{history:true})).join('')}
`:''} `;
$('#bookPage').innerHTML=`Book
${esc(b.title)} ${esc(b.authors||'Unknown author')}${b.series?' · '+esc(b.series):''}
${readControl}
${coverManager}
Highlights
Passages worth keeping ${annotations.length?`${annotations.length} synced`:''} ${highlights} `;
const setRead=async value=>{try{await api(`/api/books/${encodeURIComponent(b.id)}/read`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({read:value})});toast(value===null?'Using synced reading status.':value?'Book marked as read.':'Book marked as unread.');await refresh();await openBook(b.id,{push:false})}catch(e){toast(e.message)}};
$('#toggleBookRead')?.addEventListener('click',()=>setRead(!read));
$('#clearBookRead')?.addEventListener('click',()=>setRead(null));
$('#retryBookCover')?.addEventListener('click',async()=>{try{state.coverChooserBookId=null;await api(`/api/books/${encodeURIComponent(b.id)}/cover/retry`,{method:'POST'});toast('Online cover lookup queued.');pollBookCoverJob(b.id)}catch(e){toast(e.message)}});
$('#findCoverCandidates')?.addEventListener('click',async e=>{const button=e.currentTarget;button.disabled=true;button.textContent='Searching catalogues…';try{const d=await api(`/api/books/${encodeURIComponent(b.id)}/cover/candidates`,{method:'POST'});state.coverChooserBookId=b.id;toast(d.candidates?.length?`Found ${d.candidates.length} cover option${d.candidates.length===1?'':'s'}.`:'No useful alternatives found.');await openBook(b.id,{push:false})}catch(error){toast(error.message);button.disabled=false;button.textContent='Find alternatives'}});
$$('[data-cover-candidate]').forEach(button=>button.addEventListener('click',async()=>{try{await api(`/api/books/${encodeURIComponent(b.id)}/cover/candidates/${encodeURIComponent(button.dataset.coverCandidate)}`,{method:'PUT'});state.coverChooserBookId=null;toast('Cover selected.');await refresh();await openBook(b.id,{push:false})}catch(error){toast(error.message)}}));
$$('[data-cover-history]').forEach(button=>button.addEventListener('click',async()=>{try{await api(`/api/books/${encodeURIComponent(b.id)}/cover/history/${encodeURIComponent(button.dataset.coverHistory)}`,{method:'PUT'});state.coverChooserBookId=null;toast('Previous cover restored.');await refresh();await openBook(b.id,{push:false})}catch(error){toast(error.message)}}));
const coverInput=$('#manualCoverInput');
$('#uploadBookCover')?.addEventListener('click',()=>coverInput?.click());
coverInput?.addEventListener('change',async()=>{
const file=coverInput.files?.[0];if(!file)return;
if(file.size>4*1024*1024){coverInput.value='';return toast('Cover images must be 4 MB or smaller.')}
try{await api(`/api/books/${encodeURIComponent(b.id)}/cover`,{method:'PUT',headers:{'Content-Type':file.type||'application/octet-stream','X-Kovi-Filename':file.name},body:file});state.coverChooserBookId=null;stopBookCoverPoll();toast('Cover uploaded.');await refresh();await openBook(b.id,{push:false})}catch(e){toast(e.message)}finally{coverInput.value=''}
});
}
function routeFromLocation(){
const book=/^\/books\/([^/]+)$/.exec(location.pathname);if(book)return openBook(decodeURIComponent(book[1]),{push:false});
if(location.pathname==='/library')return switchView('library',{push:false});
if(location.pathname==='/calendar')return switchView('calendar',{push:false});
if(location.pathname==='/devices')return switchView('devices',{push:false});
if(location.pathname==='/status')return switchView('status',{push:false});
return switchView('home',{push:false});
}
$$('[data-view]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view)));
$$('[data-view-jump]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.viewJump)));
$('[data-route-view]')?.addEventListener('click',e=>{e.preventDefault();switchView(e.currentTarget.dataset.routeView)});
$('#bookBack').addEventListener('click',()=>switchView('library'));
$('#calendarPrev').addEventListener('click',()=>moveCalendarMonth(-1));$('#calendarNext').addEventListener('click',()=>moveCalendarMonth(1));$('#calendarToday').addEventListener('click',()=>{state.calendarSelectedDay=localDateKey(new Date());loadCalendar(new Date(),state.calendarSelectedDay)});
$('#calendarDateForm').addEventListener('submit',e=>{e.preventDefault();const day=$('#calendarDateInput').value;if(!day)return toast('Choose a date to jump to.');jumpToCalendarDate(day)});
window.addEventListener('popstate',routeFromLocation);
let chartResizeTimer=null;window.addEventListener('resize',()=>{clearTimeout(chartResizeTimer);chartResizeTimer=setTimeout(()=>{if(state.dashboard?.range&&state.books.length&&$('#homeView').classList.contains('active')){renderHeatmap(state.dashboard.days||[],state.dashboard.range);renderLineChart(state.dashboard.days||[],state.dashboard.range,state.dashboard)}},120)});
document.addEventListener('click',e=>{
const day=e.target.closest('.calendar-day');if(day){const key=day.dataset.day;if(state.calendar?.range&&(keystate.calendar.range.to))loadCalendar(parseLocalDateKey(key),key);else renderCalendarDetail(key);return}
const calendarBook=e.target.closest('[data-calendar-book-id]');if(calendarBook){openBook(calendarBook.dataset.calendarBookId);return}
const card=e.target.closest('.book-card');if(!card||!card.dataset.bookId)return;e.preventDefault();openBook(card.dataset.bookId)
});
$$('.range-button').forEach(button=>button.addEventListener('click',()=>{
const key=button.dataset.range;
if(key==='custom'){
state.rangeKey='custom';$('#customRange').classList.remove('hidden');renderRangeUi(state.dashboard?.range||presetRange('year'));$('#customFrom').focus();return;
}
$('#customRange').classList.add('hidden');applyDashboardRange(key,presetRange(key));
}));
$('#customRange').addEventListener('submit',e=>{
e.preventDefault();const from=$('#customFrom').value,to=$('#customTo').value;
if(!from||!to)return toast('Choose both custom dates.');
if(from>to)return toast('The start date must be before the end date.');
applyDashboardRange('custom',{from,to});
});
const importDialog=$('#importDialog'),fileInput=$('#fileInput');
function openImport(){importDialog.showModal();$('#importResult').classList.add('hidden');$('#importProgress').classList.add('hidden')}
$('#importButton').addEventListener('click',openImport);$$('[data-open-import]').forEach(b=>b.addEventListener('click',openImport));fileInput.addEventListener('change',()=>fileInput.files[0]&&uploadFile(fileInput.files[0]));
const dz=$('#dropZone');['dragenter','dragover'].forEach(e=>dz.addEventListener(e,x=>{x.preventDefault();dz.classList.add('drag')}));['dragleave','drop'].forEach(e=>dz.addEventListener(e,x=>{x.preventDefault();dz.classList.remove('drag')}));dz.addEventListener('drop',e=>{const f=e.dataTransfer.files[0];if(f)uploadFile(f)});
async function ensureCsrf(force=false){if(state.csrf&&!force)return state.csrf;const session=await api('/api/session',{},false);state.csrf=session.csrf;return state.csrf}
async function uploadFile(file){
const p=$('#importProgress'),bar=$('#progressBar'),txt=$('#progressText'),result=$('#importResult');p.classList.remove('hidden');result.classList.add('hidden');bar.style.width='8%';txt.textContent=`Preparing ${file.name}…`;
const send=async(csrf,retried=false)=>{txt.textContent=`Uploading ${file.name}…`;const xhr=new XMLHttpRequest();xhr.open('PUT','/api/import/sqlite');xhr.setRequestHeader('Content-Type','application/vnd.sqlite3');xhr.setRequestHeader('X-Kovi-Filename',file.name);xhr.setRequestHeader('X-Kovi-CSRF',csrf);xhr.upload.onprogress=e=>{if(e.lengthComputable)bar.style.width=`${Math.max(8,Math.round(e.loaded/e.total*82))}%`};xhr.onload=async()=>{bar.style.width='100%';let data={};try{data=JSON.parse(xhr.responseText)}catch{};if(xhr.status===403&&!retried&&data.error==='Missing browser request token.'){try{bar.style.width='8%';const fresh=await ensureCsrf(true);return send(fresh,true)}catch(error){txt.textContent='Upload failed';result.classList.remove('hidden');result.textContent=error.message||'Could not refresh the browser session.';return}}if(xhr.status>=200&&xhr.status<300){txt.textContent='Import complete';result.classList.remove('hidden');result.innerHTML=`${esc(data.message||'Import complete.')} ${data.booksSeen||0} books found · ${data.newBooks||0} new${data.excludedBooks?` · ${data.excludedBooks} KOReader manual excluded`:''} ${data.sessionsSeen||0} reading rows · ${data.newSessions||0} new${data.newBooks?'Covers are being matched automatically. ':''}`;await refresh();setTimeout(refresh,3000)}else{txt.textContent='Import failed';result.classList.remove('hidden');result.textContent=data.error||'Upload failed.'}};xhr.onerror=()=>{txt.textContent='Upload failed';result.classList.remove('hidden');result.textContent='Could not reach kovi.'};xhr.send(file)};
try{const csrf=await ensureCsrf(true);await send(csrf)}catch(error){txt.textContent='Upload failed';result.classList.remove('hidden');result.textContent=error.message||'Could not prepare the upload.'}
}
$('#bookSearch').addEventListener('input',e=>{state.libraryQuery=e.target.value;renderLibrary()});
$('#bookSort').addEventListener('change',e=>{state.librarySort=LIBRARY_SORTS.includes(e.target.value)?e.target.value:'recent';localStorage.setItem('kovi-library-sort',state.librarySort);renderLibrary()});
$('#bookFilter').addEventListener('change',e=>{state.libraryFilter=LIBRARY_FILTERS.includes(e.target.value)?e.target.value:'all';localStorage.setItem('kovi-library-filter',state.libraryFilter);renderLibrary()});
const pairOriginHint=$('#pairOriginHint');if(pairOriginHint){const h=location.hostname;pairOriginHint.textContent=(h==='localhost'||h==='127.0.0.1'||h==='0.0.0.0')?'You opened kovi locally, so use this computer’s LAN IP instead (for example http://192.168.1.23:3000).':`This page is at ${location.origin}; if your KOReader can reach that hostname/address, use it.`}
const pairDialog=$('#pairDialog');
function stopPairPoll(){if(state.pairPoll){clearTimeout(state.pairPoll);state.pairPoll=null}}
async function pollPairing(requestId){
try{
const status=await api(`/api/pairing-codes/${encodeURIComponent(requestId)}`);
if(status.status==='paired'){stopPairPoll();$('#pairStatusText').textContent='Paired successfully.';await loadDevices();pairDialog.close();toast(`Paired ${status.device?.name||status.device?.model||'KOReader'} successfully.`);return}
if(status.status==='expired'){stopPairPoll();$('#pairStatusText').textContent='This code expired. Generate another one.';return}
state.pairPoll=setTimeout(()=>pollPairing(requestId),1500);
}catch{state.pairPoll=setTimeout(()=>pollPairing(requestId),2500)}
}
$('#pairButton').addEventListener('click',()=>{stopPairPoll();pairDialog.showModal();$('#pairCodeBox').classList.add('hidden')});
pairDialog.addEventListener('close',stopPairPoll);
$('#generatePair').addEventListener('click',async()=>{try{stopPairPoll();const d=await api('/api/pairing-codes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label:$('#pairLabel').value})});$('#pairCode').textContent=d.code;$('#pairExpiry').textContent='Expires in 10 minutes · one use only';$('#pairStatusText').textContent='Waiting for your KOReader…';$('#pairCodeBox').classList.remove('hidden');pollPairing(d.requestId)}catch(e){toast(e.message)}});
async function loadDevices(){try{const d=await api('/api/devices');$('#deviceList').innerHTML=d.devices.map(x=>`▤ ${esc(x.name||x.model||'KOReader')} ${x.needs_update&&!x.revoked_at?'Plugin update needed ':''}
${esc(x.model||x.id)} · plugin ${esc(x.plugin_version||'unknown')} · ${x.last_seen_at?'last synced '+new Date(x.last_seen_at).toLocaleString():'paired, not synced yet'}${x.revoked_at?' · revoked':''}
${x.needs_update&&!x.revoked_at?`
${x.supports_cover_sync?'':'Embedded covers and ISBN enrichment require plugin '+esc(x.min_cover_plugin_version)+'. '}${x.supports_incremental_sync?'':'Incremental statistics and annotation sync require plugin '+esc(x.min_incremental_plugin_version)+'. '}${x.plugin_version&&x.plugin_version!==x.latest_plugin_version?'Update to the current plugin for the latest sync improvements.':''}
Download current plugin ${esc(x.latest_plugin_version)} .
`:''}
${x.revoked_at?'':`
Revoke `}
`).join('')||'No KOReader devices paired yet.
';$$('[data-revoke]').forEach(b=>b.addEventListener('click',async()=>{await api(`/api/devices/${encodeURIComponent(b.dataset.revoke)}/revoke`,{method:'POST'});toast('Device access revoked.');loadDevices()}))}catch(e){toast(e.message)}}
const fmtBytes=value=>{let n=Math.max(0,Number(value||0));const units=['B','KB','MB','GB'];let i=0;while(n>=1024&&i`${x[0]} ${x[1]} ${x[2]} `).join('');
$('#statusSyncs').innerHTML=(d.recentSyncs||[]).slice(0,3).map(x=>`${esc(x.device_name||x.device_model||'KOReader')} ${new Date(x.created_at).toLocaleString()} · ${esc(x.mode)} sync · cursor ${Number(x.cursor_after||0).toLocaleString()}
${Number(x.new_sessions||0)} new / ${Number(x.sessions_seen||0)} sent${Number(x.annotation_sets||0)} changed annotation book${Number(x.annotation_sets||0)===1?'':'s'} `).join('')||'No plugin syncs recorded yet.
';
$('#statusImports').innerHTML=(d.recentImports||[]).slice(0,3).map(x=>`${esc(x.filename||x.kind||'Import')} ${new Date(x.created_at).toLocaleString()} · ${fmtBytes(x.file_size)}
${Number(x.new_books||0)} new books${Number(x.new_sessions||0)} new sessions${x.warnings?.length?` · ${x.warnings.length} warning${x.warnings.length===1?'':'s'}`:''} `).join('')||'No manual imports recorded yet.
';
}
async function loadStatus({feedback=false}={}){
const button=$('#refreshStatus');const original=button?.textContent||'Refresh';
if(button){button.disabled=true;button.textContent='Refreshing…'}
try{
state.status=await api(`/api/status?fresh=${Date.now()}`,{cache:'no-store'});renderStatus();
if(feedback)toast(`Status refreshed at ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}.`);
}catch(e){toast(e.message)}
finally{if(button){button.disabled=false;button.textContent=original}}
}
$('#refreshStatus')?.addEventListener('click',()=>loadStatus({feedback:true}));
$('#retryCovers').addEventListener('click',async()=>{try{const d=await api('/api/covers/retry',{method:'POST'});toast(d.queued?`${d.queued} cover${d.queued===1?'':'s'} queued.`:'No missing covers to retry.');state.books.forEach(b=>{if(b.cover_status==='none'||b.cover_status==='error')b.cover_status='pending'});coverPoll=setTimeout(()=>refresh().catch(()=>{}),3000)}catch(e){toast(e.message)}});
function setTheme(dark){document.documentElement.classList.toggle('dark',dark);localStorage.setItem('kovi-theme',dark?'dark':'light');const meta=$('#themeColor');if(meta)meta.setAttribute('content',dark?'#0c1320':'#f6f3ec');const button=$('#themeButton');button.textContent=dark?'☀':'◐';button.setAttribute('aria-label',dark?'Switch to light theme':'Switch to dark theme');button.setAttribute('aria-pressed',String(dark))}
const savedTheme=localStorage.getItem('kovi-theme');setTheme(savedTheme==='dark'||(!savedTheme&&matchMedia('(prefers-color-scheme: dark)').matches));
$('#themeButton').addEventListener('click',()=>setTheme(!document.documentElement.classList.contains('dark')));
api('/api/session').then(s=>{state.csrf=s.csrf;return refresh()}).then(routeFromLocation).catch(e=>toast(e.message));