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:
@@ -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||'')+
|
||||
`<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>`+
|
||||
`<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>`+
|
||||
|
||||
@@ -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=`
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||
<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>
|
||||
<div class="body">
|
||||
<span class="t">${esc(e.title||'(untitled)')}</span>
|
||||
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||
<div class="line">${[
|
||||
num&&`<span>${num}</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; }
|
||||
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 `
|
||||
<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">
|
||||
${/* 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user