Asked for: "Nothing here yet — try Scan now." and "Pick an item to read it." are gone. A search that finds nothing still says so, as the words typed are the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
403 lines
21 KiB
TypeScript
403 lines
21 KiB
TypeScript
/* ---------------- items ---------------- */
|
|
let loadSeq=0;
|
|
async function loadEntries(append?: boolean){
|
|
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
|
if(!S.feed || VIEWS[S.feed]?.url) return;
|
|
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}`
|
|
: `/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.
|
|
if(seq!==loadSeq) return;
|
|
S.total=r.total;
|
|
for(const e of r.entries){
|
|
const w=readWrites.get(readKey(e));
|
|
if(w && !(w.done<asked)) e.read=w.read;
|
|
}
|
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
|
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
|
// 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);
|
|
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));
|
|
entries=[...gone,...entries];
|
|
}
|
|
S.entries = entries;
|
|
renderEntries();
|
|
}
|
|
function renderEntries(){
|
|
const box=$('#eps'); if(!box) return;
|
|
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
|
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
|
$('#count').textContent=
|
|
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
|
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
|
box.innerHTML='';
|
|
if(!S.entries.length){
|
|
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
|
|
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
|
|
return;
|
|
}
|
|
for(const e of S.entries) box.appendChild(epEl(e));
|
|
syncPlayButtons();
|
|
if(pane) pane.scrollTop=top;
|
|
if(S.entries.length < S.total){
|
|
const b=document.createElement('button');
|
|
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
|
|
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
|
|
box.appendChild(b);
|
|
}
|
|
}
|
|
function epEl(e){
|
|
// 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.
|
|
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
|
|
const has=!!(enc&&enc.path);
|
|
const playable=isPlayable(enc);
|
|
const others=e.enclosures.length-1;
|
|
const el=document.createElement('div');
|
|
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
|
|
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${name.derived?' notitle':''}">${esc(name.text)}</span>
|
|
<div class="line">${[
|
|
num&&`<span>${num}</span>`,
|
|
left&&`<span>${left}</span>`,
|
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
|
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
|
</div>
|
|
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
|
<div class="file">
|
|
${enc?kindIcon(enc):''}
|
|
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
|
</div>
|
|
<span class="size">${enc?mb(enc.length):''}</span>
|
|
<span class="date">${dateOf(e.published)}</span>
|
|
<div class="rowacts">
|
|
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
|
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
|
|
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
|
|
${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)});
|
|
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){
|
|
if(!x || !x.path) return false;
|
|
const m=(x.mime||'').toLowerCase();
|
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
|
if(m) return false;
|
|
// No declared type: fall back to the file's extension.
|
|
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
|
|
.test((x.path||x.url||'').split('?')[0]);
|
|
}
|
|
|
|
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
|
function kindOf(enc){
|
|
const m=(enc.mime||'').toLowerCase();
|
|
if(m.startsWith('image/')) return 'image';
|
|
if(m.startsWith('video/')) return 'video';
|
|
if(m.startsWith('audio/')) return 'audio';
|
|
if(m.includes('pdf')) return 'pdf';
|
|
if(m.includes('torrent')) return 'torrent';
|
|
const ext=(enc.url||'').split('?')[0].split('.').pop();
|
|
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
|
|
}
|
|
|
|
/// 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){
|
|
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 [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
|
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
|
: ['',k];
|
|
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
|
|
}
|
|
|
|
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.
|
|
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
|
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
|
/// read "2010)Recorded".
|
|
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}{
|
|
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
|
const words=plainText(e.description);
|
|
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){
|
|
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
|
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
|
}
|
|
|
|
/// 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){
|
|
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`,
|
|
{method:'POST',body:JSON.stringify({read})})
|
|
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
|
|
}
|
|
|
|
/// 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){
|
|
if(e.read) return;
|
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
|
}
|
|
|
|
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
|
/// 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){
|
|
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
|
turned.clear();
|
|
if(!gone.length) return;
|
|
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
|
|
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
|
}
|
|
|
|
function selectEntry(e,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){
|
|
turned.add(S.sel);
|
|
if(!turning) dropTurned(e.guid);
|
|
}
|
|
S.sel=e.guid;
|
|
markRead(e);
|
|
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
|
swapRow(e);
|
|
showDetail(e);
|
|
const d=$('#detail'); if(d) d.scrollTop=0;
|
|
}
|
|
|
|
/// Drag the divider between the item list and the item text.
|
|
function dragSplit(pane){
|
|
const grab=$('#grab',pane);
|
|
if(!grab) return;
|
|
const move=ev=>{
|
|
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)+'%');
|
|
};
|
|
const stop=()=>{
|
|
document.removeEventListener('mousemove',move);
|
|
document.removeEventListener('mouseup',stop);
|
|
document.body.style.userSelect='';
|
|
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
|
};
|
|
grab.onmousedown=ev=>{
|
|
ev.preventDefault();
|
|
document.body.style.userSelect='none';
|
|
document.addEventListener('mousemove',move);
|
|
document.addEventListener('mouseup',stop);
|
|
};
|
|
}
|
|
|
|
/// Which item the toolbar's play, read and keep buttons act on.
|
|
// 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){
|
|
$('#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
|
|
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
|
// side said opposite things (#74). The shape carries it, not the colour.
|
|
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
|
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
|
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
|
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
|
if(e){
|
|
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
|
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
|
}
|
|
}
|
|
|
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
|
function showDetail(e){
|
|
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);
|
|
document.body.classList.toggle('reading',!!e);
|
|
syncTools(e);
|
|
if(!e){
|
|
dropTurned(S.sel);
|
|
box.innerHTML='';
|
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
|
return;
|
|
}
|
|
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
|
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
|
const encs=e.enclosures.map(encBox).join('');
|
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
|
box.innerHTML=detailHtml(e);
|
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
|
$('#dback').onclick=()=>showDetail(null);
|
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
|
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
|
syncPlayButtons();
|
|
}
|
|
|
|
/// 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){
|
|
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
|
|
// it looked as if a downloaded file could not be deleted at all (issue #21).
|
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
|
// 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>
|
|
${/* 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)]
|
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
|
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
|
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
|
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
|
</div>
|
|
${narrow?encs:''}
|
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
|
}
|
|
|
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
|
function encBox(x){
|
|
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);
|
|
const shared=f&&f.subscribers>1;
|
|
// Icons, with the words in the tooltip and for screen readers.
|
|
const delLabel=shared
|
|
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
|
: 'Delete file';
|
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
|
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
|
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
|
if(x.path && !isPlayable(x)){
|
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
|
return `<div class="encbox">
|
|
${kindIcon(x)}
|
|
<span class="meta" style="flex:1">${size}</span>
|
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
|
${saveBtn}
|
|
${shareBtn}
|
|
${delBtn}
|
|
</div>`;
|
|
}
|
|
if(x.path){
|
|
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
|
// started the bar as well, so the same file played twice at once.
|
|
return `<div class="encbox">
|
|
${kindIcon(x)}
|
|
<span class="meta" style="flex:1">${size}</span>
|
|
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
|
${saveBtn}
|
|
${shareBtn}
|
|
${delBtn}
|
|
</div>`;
|
|
}
|
|
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
|
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
|
// through here, which would make ipx a fetch-anything relay.
|
|
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
|
return `<div class="encbox">
|
|
${kindIcon(x)}
|
|
<span class="meta" style="flex:1">${size}</span>
|
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
|
${shareBtn}
|
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
|
</div>`;
|
|
}
|
|
|
|
async function epAction(a: string, e, el, 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];
|
|
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(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: '+entryName(e).text);
|
|
}
|
|
if(a==='del'){
|
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
|
const shared=f&&f.subscribers>1;
|
|
if(!confirm(shared
|
|
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
|
|
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
|
|
+`and will not be downloaded again automatically.`
|
|
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
|
|
try{
|
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
|
}catch(err){
|
|
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
|
if(!/one copy of this file/.test(err.message)) throw err;
|
|
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
|
}
|
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
|
}
|
|
}catch(err){ toast(err.message,true); }
|
|
}
|
|
|