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(),