From d238681ec12cdaaf347027de3045413b888b9c7e Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 2 Oct 2026 20:46:47 +0000 Subject: [PATCH] Name an item without a title from its own text, not "(untitled)" (#116) RSS 2.0 makes an item's title optional, and some blogs leave it out on purpose: Scripting News titles almost none of its posts. Fifty rows of "(untitled)" said nothing about any of them. entryName gives an item its title, or the opening of its text (HTML read through DOMParser, an inert document that loads nothing; cut at a word near 120 characters), or its file's name, or its show and date, with a flag for a name that is not a title. The list sets that one in the regular weight, as the text it is rather than a heading; the reader leaves out the heading so the post starts with itself; the player, the lock screen, Currently Listening, the native shell, Share and the queued toast use the same name. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + tests/ui/app.spec.js | 15 +++++++++++++++ web/app.css | 2 ++ web/src/dialogs.ts | 2 +- web/src/items.ts | 29 +++++++++++++++++++++++++---- web/src/native.ts | 2 +- web/src/player.ts | 4 ++-- 7 files changed, 47 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76835c..2d81157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text. - A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing. - The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once. - A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 8816ae9..8a7c39f 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -972,6 +972,21 @@ test('artwork comes from iPX, kept, and only as an image', async ({ page }) => { expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404); }); +test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => { + const names = await page.evaluate(() => [ + entryName({ title: 'A Title' }), + entryName({ title: ' ', description: '

I like the way AI is evolving.

' }), + entryName({ description: '

' + 'word '.repeat(40) + '

' }).text.length, + entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }), + entryName({ feed_id: 'nobody', published: 0 }), + ]); + expect(names[0]).toEqual({ text: 'A Title', derived: false }); + expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true }); + expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with … + expect(names[3]).toEqual({ text: 'KARTAS691', derived: true }); + expect(names[4]).toEqual({ text: 'nobody', derived: true }); +}); + test('a deleted file looks as if it was never downloaded', async ({ page }) => { // Other people subscribe to Picture Blog by now, so both prompts come; take them. page.on('dialog', d => d.accept()); diff --git a/web/app.css b/web/app.css index fc44f26..5717c24 100644 --- a/web/app.css +++ b/web/app.css @@ -844,6 +844,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s} @keyframes eq{from{transform:scaleY(.35)}} .ep .st:hover,.ep .fl:hover{background:var(--raise)} .ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* An item with no title shows its opening words: set as text, not as a heading. */ +.ep .t.notitle{font-weight:400} .ep.read .t{color:var(--dim);font-weight:500} .ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .line:empty{display:none} diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index f98c58a..9808776 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -140,7 +140,7 @@ async function renderListening(url,box){ el.className='childrow'; el.entry=e; el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ - `
${EQ}${esc(e.title||'(untitled)')}`+ + `
${EQ}${esc(entryName(e).text)}`+ `${esc(feedName(e.feed_id))}
`+ ``+ ``+ diff --git a/web/src/items.ts b/web/src/items.ts index f289c20..6bb6d75 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -65,13 +65,14 @@ function epEl(e){ el.dataset.guid=e.guid; const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); + const name=entryName(e); el.innerHTML=`
- ${esc(e.title||'(untitled)')} + ${esc(name.text)}
${[ num&&`${num}`, left&&`${left}`, @@ -136,6 +137,25 @@ 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; }; +/// 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 +/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of +/// them. `derived` marks a name that is not a title, which the list sets as text, not heading. +function entryName(e): {text: string, derived: boolean}{ + if(e.title&&e.title.trim()) return {text:e.title, derived:false}; + // An inert document: nothing in it loads or runs, where a detached element fetches its images. + const words=e.description + ? (new DOMParser().parseFromString(e.description,'text/html').body.textContent||'').replace(/\s+/g,' ').trim() + : ''; + if(words){ + const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words; + return {text:cut, derived:true}; + } + const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,''); + if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } } + return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true}; +} + /// 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){ @@ -271,7 +291,8 @@ function detailHtml(e){ // description was sanitized server-side with ammonia before it ever reached here return ` -

${esc(e.title||'(untitled)')}

+ ${/* A post without a title starts with its text: its own first words as a heading above + themselves would read as a mistake. */ e.title&&e.title.trim() ? `

${esc(e.title)}

` : ''}
${/* Joined, so a missing date or number leaves no stray dot behind. */ [f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)] @@ -346,13 +367,13 @@ async function epAction(a: string, e, el, encId?: number){ const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; try{ if(a==='play') play(e, encId!=null ? enc : undefined); - if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el); + if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el); if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); } if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='get'){ if(!enc) return; await api(`/api/enclosures/${enc.id}/download`,{method:'POST'}); - toast('Queued: '+(e.title||'item')); + toast('Queued: '+entryName(e).text); } if(a==='del'){ const f=S.feeds.find(x=>x.id===e.feed_id); diff --git a/web/src/native.ts b/web/src/native.ts index ab83eed..2645302 100644 --- a/web/src/native.ts +++ b/web/src/native.ts @@ -77,7 +77,7 @@ function installNativePlayback(){ realLoad.call(audio); const e = player.entry, f = player.feed; post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid, - title:(e && e.title) || '', feedTitle:feedName(f), + title:e ? entryName(e).text : '', feedTitle:feedName(f), artwork:(e && e.image) || feedArt(f) || null, // Where the host starts is not this: the seek to where you left off is player.ts's, on // loadedmetadata, so one piece of code decides it. This is for the host's now-playing diff --git a/web/src/player.ts b/web/src/player.ts index 1e62d6e..da745e4 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -32,7 +32,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){ if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); // Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art. $('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id)); - $('#ptitle').textContent=e.title||'(untitled)'; + $('#ptitle').textContent=entryName(e).text; const f=S.feeds.find(x=>x.id===e.feed_id); $('#pfeed').textContent=f?(f.title||f.id):''; $('#player').classList.add('on'); @@ -45,7 +45,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){ function mediaSession(e,f){ if(!('mediaSession' in navigator)) return; navigator.mediaSession.metadata=new MediaMetadata({ - title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'', + title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'', artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[], }); const h={play:()=>audio.play(),pause:()=>audio.pause(),