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 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### 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.
|
- 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.
|
- 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.
|
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
|
||||||
|
|||||||
@@ -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);
|
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: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
|
||||||
|
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).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 }) => {
|
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.
|
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
||||||
page.on('dialog', d => d.accept());
|
page.on('dialog', d => d.accept());
|
||||||
|
|||||||
@@ -844,6 +844,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
|||||||
@keyframes eq{from{transform:scaleY(.35)}}
|
@keyframes eq{from{transform:scaleY(.35)}}
|
||||||
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
.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}
|
.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.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{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
|
||||||
.ep .line:empty{display:none}
|
.ep .line:empty{display:none}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ async function renderListening(url,box){
|
|||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.entry=e;
|
el.entry=e;
|
||||||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
`<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
|
||||||
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
`<button class="iconbtn" data-a="play"></button>`+
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
|
|||||||
@@ -65,13 +65,14 @@ function epEl(e){
|
|||||||
el.dataset.guid=e.guid;
|
el.dataset.guid=e.guid;
|
||||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
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 left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||||
|
const name=entryName(e);
|
||||||
el.innerHTML=`
|
el.innerHTML=`
|
||||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||||
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
||||||
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="t">${esc(e.title||'(untitled)')}</span>
|
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||||
<div class="line">${[
|
<div class="line">${[
|
||||||
num&&`<span>${num}</span>`,
|
num&&`<span>${num}</span>`,
|
||||||
left&&`<span>${left}</span>`,
|
left&&`<span>${left}</span>`,
|
||||||
@@ -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; }
|
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=>{ 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.
|
/// 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.
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||||
function swapRow(e){
|
function swapRow(e){
|
||||||
@@ -271,7 +291,8 @@ function detailHtml(e){
|
|||||||
// description was sanitized server-side with ammonia before it ever reached here
|
// description was sanitized server-side with ammonia before it ever reached here
|
||||||
return `
|
return `
|
||||||
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
${/* 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() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
|
||||||
<div class="dmeta">
|
<div class="dmeta">
|
||||||
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
${/* 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)]
|
[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)}`;
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
try{
|
try{
|
||||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
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==='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==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
if(!enc) return;
|
if(!enc) return;
|
||||||
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
toast('Queued: '+(e.title||'item'));
|
toast('Queued: '+entryName(e).text);
|
||||||
}
|
}
|
||||||
if(a==='del'){
|
if(a==='del'){
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function installNativePlayback(){
|
|||||||
realLoad.call(audio);
|
realLoad.call(audio);
|
||||||
const e = player.entry, f = player.feed;
|
const e = player.entry, f = player.feed;
|
||||||
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
|
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,
|
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
|
// 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
|
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
|
||||||
|
|||||||
@@ -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});
|
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.
|
// 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));
|
$('#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);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
$('#pfeed').textContent=f?(f.title||f.id):'';
|
$('#pfeed').textContent=f?(f.title||f.id):'';
|
||||||
$('#player').classList.add('on');
|
$('#player').classList.add('on');
|
||||||
@@ -45,7 +45,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
function mediaSession(e,f){
|
function mediaSession(e,f){
|
||||||
if(!('mediaSession' in navigator)) return;
|
if(!('mediaSession' in navigator)) return;
|
||||||
navigator.mediaSession.metadata=new MediaMetadata({
|
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'}]:[],
|
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
|
||||||
});
|
});
|
||||||
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
|
|||||||
Reference in New Issue
Block a user