diff --git a/docs/signals.md b/docs/signals.md index 01a38df..3cd57e4 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -6,13 +6,21 @@ mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and p about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their own feed. The rest stays noise, where it was. -Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone -without one. Jev is asked once for each item, on that person's key, with one question for each of -their Signals that has not heard about the item yet. +Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for +everyone, on this server's TypeSafe key** (`signals_on_server`): + +- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up. + Every item is asked about on the server's key, and keys people saved are kept but not used. +- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings + with a key of their own. Nothing is judged for someone without one, and their Signals folder is + not shown. + +Jev is asked once for each item, on that key, with one question for each of the person's Signals +that has not heard about the item yet. ## What someone sees -- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255, +- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255, #256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like any folder's (#265); New signal is the + on the folder's page. Inside the folder are the person's Signals, each with its items and unread counted under its name. @@ -24,13 +32,17 @@ their Signals that has not heard about the item yet. - **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own. - **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one. - **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort. - Its header says what it listens for (the sentence, or the words), and that nothing is picked - out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal + Its header says what it listens for (the sentence, or the words), and, on people's own keys, + that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal and leaves the items in their feeds. - **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way round. The counts follow without a reload (#258). -- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin - forgets it. The key is never sent back to the page, which only learns whether one is set. +- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a + Signals switch: turning it on shows a password field for the key, which saves on leaving the + field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is + not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides + the folder; the Signals are kept. The key is never sent back to the page, which only learns + whether one is set. ## How an item gets into a Signal @@ -94,7 +106,7 @@ Questions in one call are judged independently and in parallel. ### A request -Sent with `Authorization: Bearer `, for an item and two of their Signals: +Sent with `Authorization: Bearer `, the server's or the person's, for an item and two of their Signals: ```json { @@ -155,7 +167,8 @@ missing from the answer is left unjudged and asked again next pass. | TypeSafe answers | What the judge does | |---|---| -| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". | +| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". | +| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". | | 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. | | anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". | @@ -238,9 +251,18 @@ anywhere else. { "typesafe_key": "ts_…" } ``` -A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A -key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing -more. +A string is checked with TypeSafe, by one short question on that key, then saved, and the judge +tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters. +A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not +answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set": +true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used. + +### `PATCH /api/settings`, who pays + +An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and +`false` on each person's own. `GET /api/settings` has `signals_on_server` and +`typesafe_server_key`, whether the server has a key to use; without one the setting changes +nothing. A change wakes the judge and lets keys refused before be tried again. ### `POST /api/signals/icon` diff --git a/site/api.mjs b/site/api.mjs index 62b695a..ba84ed6 100644 --- a/site/api.mjs +++ b/site/api.mjs @@ -67,9 +67,11 @@ export const ipx = { }, { method: 'PATCH', path: '/api/me', slug: 'patch-me', - summary: 'Change the words that hide an item in every feed you read.', + summary: 'Change the words that hide an item in every feed you read, or your TypeSafe key for Signals.', + doc: '

A key is checked with TypeSafe, by one short question on it, before it is saved.

', body: 'MePatch', returns: '204 No Content', - errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']], + errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'], + ['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']], example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'), }, { @@ -205,6 +207,54 @@ export const ipx = { }, ], }, + { + id: 'signals', name: 'signals', title: 'Signals', + summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.', + items: [ + { + method: 'GET', path: '/api/signals', slug: 'get-signals', + summary: 'Your Signals, each with its items and unread counted.', + returns: 'Vec', + example: ipxCurl('GET', '/api/signals'), + }, + { + method: 'POST', path: '/api/signals', slug: 'post-signals', + summary: 'Make a Signal. It looks back over the last 30 days, then at each new item.', + doc: '

Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (signals_on_server), otherwise on yours, saved with PATCH /api/me. Without a key, nothing is picked out.

', + body: 'SignalBody', returns: '{ id: i64 }', + errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']], + example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'), + }, + { + method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal', + summary: 'Change a Signal. A new name, words or description reads everything again; a new icon does not.', + pathParams: [['id', 'i64', 'The Signal’s id.']], + body: 'SignalBody', returns: '204 No Content', + errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']], + example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'), + }, + { + method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal', + summary: 'Delete a Signal. Its items stay in their feeds.', + pathParams: [['id', 'i64', 'The Signal’s id.']], + returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']], + }, + { + method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries', + summary: 'A page of the items picked out for a Signal, as a feed’s list is.', + pathParams: [['id', 'i64', 'The Signal’s id.']], + query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']], + example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'), + }, + { + method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon', + summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.', + body: 'IconAsk', returns: '{ icon: String }', + errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'], + ['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']], + }, + ], + }, { id: 'files', name: 'files', title: 'Files', summary: 'Downloading, deleting and playing an episode’s file.', @@ -379,8 +429,27 @@ export const ipx = { ['sign_out', 'Option', 'Where signing out should go, for someone single sign-on signed in.'], ['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'], ['version', 'String', 'ipx’s version.'], + ['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'], + ['signals', 'String', 'Whose key Signals are judged on: server, for everyone, or own, each person’s.'], + ] }, + { name: 'MePatch', doc: 'What PATCH /api/me changes.', fields: [['blocked', 'Option>', 'Replaces the list.'], + ['typesafe_key', 'Option>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; null or empty forgets it.']] }, + { name: 'Signal', doc: 'A Signal in your sidebar.', fields: [ + ['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec', 'The words it listens for.'], + ['description', 'Option', 'What you mean, in your own words.'], + ['icon', 'Option', 'A Font Awesome icon, such as solid/robot; none shows its initials.'], + ['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'], + ] }, + { name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [ + ['name', 'Option', 'Up to 100 characters; needed for a new one.'], + ['keywords', 'Option>', '1 to 50 words, each up to 200 characters; needed for a new one.'], + ['description', 'Option>', 'Up to 500 characters; null or empty clears it.'], + ['icon', 'Option>', 'solid/, regular/ or brands/ and a name; null or empty clears it.'], + ] }, + { name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [ + ['name', 'String', ''], ['keywords', 'Vec', ''], ['description', 'Option', ''], + ['candidates', 'Vec<(String, String)>', '1 to 60 of [icon, label], the label its name and search terms.'], ] }, - { name: 'MePatch', doc: 'What PATCH /api/me changes.', fields: [['blocked', 'Option>', 'Replaces the list.']] }, { name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option', '']] }, { name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] }, { name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins ipx_. Send it as Authorization: Bearer.']] }, @@ -493,6 +562,7 @@ export const ipx = { ['download_error', '{ feed, enclosure, msg, url }', ''], ['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'], ['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'], + ['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'], ] }, { name: 'Settings', doc: 'The server’s settings.', fields: [ ['schedule', 'String', 'How often feeds are checked, such as every 60m.'], ['every_mins', 'u64', 'The same in minutes.'], @@ -502,12 +572,15 @@ export const ipx = { ['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'], ['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'], ['directory_share', 'bool', ''], + ['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'], + ['typesafe_server_key', 'bool', 'The server has a TypeSafe key (TYPESAFE_KEY); without one, signals_on_server changes nothing.'], ] }, { name: 'SettingsPatch', doc: 'What PATCH /api/settings changes. Leave out what stays.', fields: [ ['schedule', 'Option', ''], ['max_new_per_check', 'Option', ''], ['media_types', 'Option>', ''], ['max_total_gb', 'Option', ''], ['max_age_days', 'Option', ''], ['art_cache_mb', 'Option', ''], ['directory_url', 'Option', 'Empty for no directory.'], ['directory_token', 'Option', 'Replaces it; empty clears it.'], ['directory_share', 'Option', ''], + ['signals_on_server', 'Option', 'Judge everyone’s Signals on the server’s TypeSafe key.'], ] }, { name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option', ''], ['last_login', 'Option', '']] }, { name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] }, diff --git a/site/guides/install.html b/site/guides/install.html index d24df6a..201afad 100644 --- a/site/guides/install.html +++ b/site/guides/install.html @@ -50,6 +50,7 @@ docker build -f Dockerfile.web -t ipx-web . IPX_DATABASE_URLA postgres://user:password@host:5432/database address, to keep everything in Postgres instead of SQLite PUID, PGIDThe user and group that own the files ipx writes + TYPESAFE_KEYThe server's own typesafe.ai API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals IPX_LOG_FORMATjson logs one JSON object a line, for Loki and the like OTEL_EXPORTER_OTLP_ENDPOINTSends traces of requests, checks and downloads to an OpenTelemetry collector; set WEB_TRACING=on on ipx-web too IPX_UPSTREAMOn ipx-web: where ipx is, ipx:8099 by default diff --git a/site/index.html b/site/index.html index 4ec41f7..e7a3189 100644 --- a/site/index.html +++ b/site/index.html @@ -98,7 +98,7 @@

Tune out the noise

-

Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last month, and it keeps listening. To use Signals you will need an API key from typesafe.ai.

+

Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last month, and it keeps listening. To use Signals you will need an API key from typesafe.ai, unless your server's admin provides one.

A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica.