Signals: feeds made from your subscriptions, picked by Jev (#254)

A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.

A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.

The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 16:54:50 +00:00
parent 30622fd3e1
commit 70990c70c7
21 changed files with 874 additions and 27 deletions

View File

@@ -143,3 +143,14 @@ test('a Patreon feed is told by its address', () => {
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
assert.equal(run('isPatreon(null)'), false);
});
test('a Signal is a place of its own, found by its id', () => {
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
assert.equal(run(`signalId(':signal:7')`), 7);
assert.equal(run(`signalId(':all')`), null);
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
assert.equal(run(`signalOf(':signal:8')`), undefined);
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
run(`S.signals = []`);
});

View File

@@ -1141,6 +1141,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
() => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(),
() => page.locator('#feedlist .place', { hasText: 'New signal' }).click(),
];
for (const open of dialogs) {
await open();
@@ -2068,3 +2069,36 @@ test('the page comes compressed, and the compressed copies cannot be asked for b
expect((await page.request.get(p)).status(), p).toBe(404);
}
});
test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
await page.reload();
await page.locator('#feedlist .place', { hasText: 'New signal' }).click();
await page.locator('#sgname').fill('F1');
await page.locator('#sgkwin').fill('monaco');
await page.keyboard.press('Enter');
await page.locator('#sgsave').click();
const row = page.locator('#feedlist .place', { hasText: 'F1' });
await expect(row).toHaveClass(/sel/);
const signals = async () => (await (await page.request.get('/api/signals')).json());
const sg = (await signals()).find(s => s.name === 'F1');
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
await page.reload();
await row.click();
await page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
{ timeout: 5_000 }).toBe(true);
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
await page.request.delete(`/api/signals/${sg.id}`);
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
await page.request.patch('/api/me', { data: { typesafe_key: null } });
});

View File

@@ -7,6 +7,30 @@ const dir = __dirname;
const port = Number(process.env.FIXTURE_PORT || 8792);
http.createServer((req, res) => {
// Jev, for Signals (#254): sure of an item whose title has a question's keyword in it.
if (req.method === 'POST' && req.url === '/jev') {
let body = '';
req.on('data', c => { body += c; });
req.on('end', () => {
const { state, questions } = JSON.parse(body);
const title = (state.title || '').toLowerCase();
const answers = {};
for (const [id, q] of Object.entries(questions)) {
const words = q.instructions.replace(/^.*: /, '').replace(/\?$/, '').split(', ');
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
}
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
});
return;
}
// Dated today, since a Signal looks at the last 30 days only.
if (req.url === '/signal.xml') {
const now = new Date().toUTCString();
const item = (guid, title) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
return;
}
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
const file = path.join(dir, path.basename(name));
fs.readFile(file, (err, body) => {