Page type-check: noImplicitAny, every parameter and variable typed (#235)

290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts
(feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run
differently in form only: the seek and volume inputs divide Number(value) where they divided
the string, and esc's table has a name. Null checks are #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:57:05 +00:00
parent af6eae9baf
commit ec2b9e87b8
11 changed files with 203 additions and 161 deletions

View File

@@ -6,7 +6,7 @@ async function loadEntries(append?: boolean){
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}`
const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one.
@@ -21,7 +21,7 @@ async function loadEntries(append?: boolean){
// tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it.
// The items turned past on the way to it are kept too, so a swipe back still finds them.
const kept=e=>e.guid===S.sel||turned.has(e.guid);
const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
@@ -53,7 +53,7 @@ function renderEntries(){
box.appendChild(b);
}
}
function epEl(e){
function epEl(e: Entry){
// An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all.
@@ -97,13 +97,13 @@ function epEl(e){
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`;
el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
$$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player.
function isPlayable(x){
function isPlayable(x: Enclosure){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
@@ -114,7 +114,7 @@ function isPlayable(x){
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){
function kindOf(enc: {mime?: string|null, url?: string}){
const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video';
@@ -128,9 +128,9 @@ function kindOf(enc){
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){
function kindIcon(enc: Enclosure){
const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k];
@@ -138,7 +138,7 @@ function kindIcon(enc){
}
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
@@ -150,7 +150,7 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
const plainText=(html?: string)=>html
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
.replace(/\s+/g,' ').trim() : '';
function entryName(e): {text: string, derived: boolean}{
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
const words=plainText(e.description);
if(words){
@@ -164,7 +164,7 @@ function entryName(e): {text: string, derived: boolean}{
/// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){
function swapRow(e: Entry){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
}
@@ -172,9 +172,9 @@ function swapRow(e){
/// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries).
const readWrites=new Map();
const readKey=e=>e.feed_id+'\n'+e.guid;
function setRead(e,read){
const readWrites=new Map<string, {read: boolean, done?: number}>();
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e: Entry,read: boolean){
e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
@@ -184,7 +184,7 @@ function setRead(e,read){
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){
function markRead(e: Entry){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
}
@@ -193,7 +193,7 @@ function markRead(e){
/// in the list until the reader closes or another item is picked from the list. Dropped as each
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
const turned=new Set<string>();
function dropTurned(keep){
function dropTurned(keep: string|null){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear();
if(!gone.length) return;
@@ -201,7 +201,7 @@ function dropTurned(keep){
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
}
function selectEntry(e,turning=false){
function selectEntry(e: Entry,turning=false){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while.
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
@@ -217,10 +217,10 @@ function selectEntry(e,turning=false){
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane){
function dragSplit(pane: HTMLElement){
const grab=$('#grab',pane);
if(!grab) return;
const move=ev=>{
const move=(ev: MouseEvent)=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
@@ -231,7 +231,7 @@ function dragSplit(pane){
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
grab.onmousedown=(ev: MouseEvent)=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
@@ -243,7 +243,7 @@ function dragSplit(pane){
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){
function syncTools(e: Entry|null){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
@@ -260,7 +260,7 @@ function syncTools(e){
}
/// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){
function showDetail(e: Entry){
const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length);
@@ -286,7 +286,7 @@ function showDetail(e){
/// What the reader shows for an item. Its own function because a phone also draws the next or
/// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){
function detailHtml(e: Entry){
const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
@@ -317,7 +317,7 @@ function detailHtml(e){
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){
function encBox(x: Enclosure){
const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id);
@@ -369,7 +369,7 @@ function encBox(x){
</div>`;
}
async function epAction(a: string, e, el, encId?: number){
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];