The Directory comes from the directory service (#205)
ipx asks ipx-directory on its people's behalf, with the token an admin set in the new Admin, Directory tab, so the page never talks to it and never sees the token or a feed's address. /api/directory, /api/popular and a feed's Directory page answer from it; subscribing takes the address from the directory and goes through Add feed's steps. With no directory set up, the Directory says so. The local directory code is gone; its privacy checks are the snapshot builder's now, and their tests moved with them. With sharing on, ipx sends its snapshot after every scan, whenever the config changes (set_cfg) and every half hour: each public feed and how many here subscribe, never an OPML or Patreon creator itself or anything private. A feed can be marked private in its settings, for a paid feed whose address does not give it away; anyone subscribed may mark it, only an admin unmark it. The admin's category for a feed that names none goes with it, which the directory uses only while the feed names none, so the category an admin gives a blog still reaches the Directory. The directory lists only feeds it has read as a feed: the first snapshot after a start went before the first scan, when an OPML was not yet known to be one, and the directory listed the OPML. It now fetches what a snapshot brings in at once instead of within the minute. The browser suite runs its own ipx-directory on SQLite; the Directory's tests match feeds by title, since the directory names them by address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
let me: {name: string} | null = null;
|
||||
api('/api/me').then(u => { me = u; }).catch(() => {});
|
||||
|
||||
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
|
||||
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, directory: drawDirectorySettings, log: drawLogView};
|
||||
|
||||
function showSection(){
|
||||
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
||||
@@ -70,6 +70,67 @@ async function drawServer(){
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- directory ---------------- */
|
||||
// The directory service the Directory shows (#205): where it is, this server's token for it, and
|
||||
// whether to share. The token is sent, never shown: the server says only whether one is set.
|
||||
const ACCESS: Record<string, string> = {ro: 'browse', rw: 'browse and share', rwa: 'browse, share and administer the directory'};
|
||||
|
||||
async function drawDirectorySettings(){
|
||||
const box = $('#directory');
|
||||
const g = await api('/api/settings');
|
||||
box.innerHTML = `<h2>Directory</h2>
|
||||
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share.
|
||||
Without one, people add feeds by their address.</p>
|
||||
<div class="field"><label for="durl">Directory address</label>
|
||||
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://ipx-dir.example.net" spellcheck="false"></div>
|
||||
<div class="field"><label for="dtoken">Access token</label>
|
||||
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
|
||||
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
|
||||
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
|
||||
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
|
||||
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
|
||||
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every
|
||||
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
|
||||
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
|
||||
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`;
|
||||
const save = async (patch: Record<string, unknown>, said: string) => {
|
||||
try{
|
||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
|
||||
toast(said);
|
||||
drawDirectorySettings();
|
||||
}catch(e){ toast(e.message, true); }
|
||||
};
|
||||
$('#dsave').onclick = () => {
|
||||
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
|
||||
// Blank leaves the token as it is; Clear is the way to remove it.
|
||||
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
|
||||
save(patch, 'Directory settings saved');
|
||||
};
|
||||
const clear = $('#dclear');
|
||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||
drawDirectoryStatus();
|
||||
}
|
||||
|
||||
async function drawDirectoryStatus(){
|
||||
const el = $('#dstatus');
|
||||
let s;
|
||||
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
|
||||
if(!el.isConnected) return;
|
||||
const lines: string[] = [];
|
||||
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
|
||||
else if(s.error) lines.push(esc(s.error));
|
||||
else lines.push(`Connected as <b>${esc(s.name)}</b>, which may ${esc(ACCESS[s.access] || s.access)}.`);
|
||||
const sh = s.shared;
|
||||
if(sh){
|
||||
const when = new Date(sh.at * 1000).toLocaleString();
|
||||
lines.push(sh.error ? `Sharing failed at ${esc(when)}: ${esc(sh.error)}`
|
||||
: `Shared ${plural(sh.accepted, 'feed')} at ${esc(when)}.` +
|
||||
(sh.refused_private ? ` The directory refused ${plural(sh.refused_private, 'feed')} as private.` : '') +
|
||||
(sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : ''));
|
||||
}
|
||||
el.innerHTML = lines.join('<br>');
|
||||
}
|
||||
|
||||
/* ---------------- accounts ---------------- */
|
||||
async function drawAccounts(){
|
||||
const box = $('#accounts');
|
||||
|
||||
@@ -34,8 +34,11 @@ $('#addFeed').onclick=()=>{
|
||||
};
|
||||
};
|
||||
|
||||
// The rows carry an id, never a URL, so a key in someone's feed address never reaches this page.
|
||||
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||
// The rows come from the directory through iPX (#205), and carry its id, never a URL, so a key in
|
||||
// someone's feed address never reaches this page.
|
||||
const NONE_LISTED='<p class="hint">Nothing in the directory yet. Feeds people subscribe to show up here once their servers share them.</p>';
|
||||
// Why the directory could not be shown: none set up, or it did not answer. In iPX's words.
|
||||
let dirError='';
|
||||
|
||||
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
|
||||
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
|
||||
@@ -57,7 +60,8 @@ function listedFeed(p,cls,rank?: number){
|
||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.id); return el; }
|
||||
// Yours has an id here as well as the directory's, and opens by it.
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||
// Its own click, not the tile's as well.
|
||||
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
|
||||
@@ -66,9 +70,9 @@ function listedFeed(p,cls,rank?: number){
|
||||
|
||||
async function subscribeListed(p){
|
||||
try{
|
||||
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||
toast(`Subscribed to ${NAME(p)}`);
|
||||
await loadFeeds(true); selectFeed(p.id);
|
||||
await loadFeeds(true); selectFeed(r.id);
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}
|
||||
|
||||
@@ -99,7 +103,8 @@ const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
|
||||
async function renderDirectory(url,box){
|
||||
// Each visit starts at the front, as going back to a tab does.
|
||||
dirCat=null; dirSub=null; dirShow=null;
|
||||
try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; }
|
||||
dirError='';
|
||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; }
|
||||
drawDirectory(box);
|
||||
return dirRows.length;
|
||||
}
|
||||
@@ -107,7 +112,7 @@ async function renderDirectory(url,box){
|
||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||
function drawDirectory(box=$('#directory'), focus?: string){
|
||||
if(!box) return;
|
||||
if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; }
|
||||
if(!dirRows.length){ box.innerHTML=dirError?`<p class="hint">${esc(dirError)}</p>`:NONE_LISTED; return; }
|
||||
// Only a choice when the server has both kinds.
|
||||
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
|
||||
if(!both) dirKind='All';
|
||||
@@ -137,7 +142,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
|
||||
// Its category's page is one click away from here.
|
||||
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
|
||||
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''}
|
||||
${n?`<p class="sub">${n} ${n===1?'person subscribes':'people subscribe'}</p>`:''}
|
||||
<p class="sub dfail" id="dfail" hidden></p>
|
||||
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
|
||||
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
|
||||
@@ -153,7 +158,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
||||
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
|
||||
// Other, the feeds with no category, goes last.
|
||||
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
|
||||
html=head('Directory',`${count(rows.length)} on this server. Private feeds are never listed.`)
|
||||
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
|
||||
+(chart.length?sec('Most subscribed',board):'')
|
||||
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
|
||||
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
|
||||
@@ -227,8 +232,7 @@ async function fillShow(box,p){
|
||||
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
||||
+(text?`<p>${esc(text)}</p>`:'')
|
||||
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
|
||||
}).join(''):`<p class="hint">Nothing read from it yet.${p.subscribers||got.failing?''
|
||||
:' A feed nobody here subscribes to is checked once a day.'}</p>`;
|
||||
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';
|
||||
}
|
||||
|
||||
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
||||
@@ -539,6 +543,13 @@ function settingsModal(f, newUrl?: string){
|
||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
||||
with what Settings hides everywhere.</p></div>`}
|
||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||||
<p class="cap">${f.private_by_address
|
||||
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||||
:f.private&&!admin
|
||||
?'Never shared with the directory. Only an admin can share it again.'
|
||||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
|
||||
</div>
|
||||
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||
@@ -625,6 +636,9 @@ function settingsModal(f, newUrl?: string){
|
||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
||||
deadair_pause:Number($('#sdpause').value)};
|
||||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||
const priv=$('#sprivate');
|
||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||
if(admin) patch.url=$('#surl').value.trim();
|
||||
|
||||
Reference in New Issue
Block a user