Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40. POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just ahead of robot 0.40. Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its words; /api/signals gives `items` beside `unread`, by the same rules as its list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -2552,6 +2552,7 @@ pub struct SignalRow {
|
|||||||
pub keywords: Vec<String>,
|
pub keywords: Vec<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
|
pub items: i64,
|
||||||
pub unread: i64,
|
pub unread: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2604,15 +2605,22 @@ impl Db {
|
|||||||
let mut a = Args::default();
|
let mut a = Args::default();
|
||||||
let user = a.p(user_id);
|
let user = a.p(user_id);
|
||||||
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
||||||
let sql = format!(
|
// The items it shows, and of those the unread: what its list counts, by the same rules.
|
||||||
"SELECT g.id, g.name, g.keywords, g.description, g.icon,
|
let hits = |unread: &str| {
|
||||||
(SELECT count(*) FROM signal_verdicts v
|
format!(
|
||||||
|
"(SELECT count(*) FROM signal_verdicts v
|
||||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||||
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
||||||
WHERE v.signal_id = g.id AND v.hit AND NOT coalesce(st.read, false) AND {}) AS unread
|
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {})",
|
||||||
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
|
|
||||||
not_hidden(&user)
|
not_hidden(&user)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT g.id, g.name, g.keywords, g.description, g.icon, {} AS items, {} AS unread
|
||||||
|
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
|
||||||
|
hits("true"),
|
||||||
|
hits("NOT coalesce(st.read, false)")
|
||||||
);
|
);
|
||||||
self.rows(&sql, a.0)
|
self.rows(&sql, a.0)
|
||||||
.await?
|
.await?
|
||||||
@@ -2624,6 +2632,7 @@ impl Db {
|
|||||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||||
description: r.try_get("", "description")?,
|
description: r.try_get("", "description")?,
|
||||||
icon: r.try_get("", "icon")?,
|
icon: r.try_get("", "icon")?,
|
||||||
|
items: r.try_get("", "items")?,
|
||||||
unread: r.try_get("", "unread")?,
|
unread: r.try_get("", "unread")?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -3403,11 +3412,12 @@ mod tests {
|
|||||||
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
||||||
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
|
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
|
||||||
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
|
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
|
||||||
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| s.unread).collect::<Vec<_>>(), [1, 1]);
|
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(), [(1, 1), (1, 1)]);
|
||||||
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
|
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal");
|
let f = db.signal(1, f1).await.unwrap().unwrap();
|
||||||
|
assert_eq!((f.items, f.unread), (1, 0), "read in its feed is read in the Signal, and still one of its items");
|
||||||
|
|
||||||
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
||||||
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
|
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
|
||||||
|
|||||||
@@ -202,6 +202,52 @@ fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
|||||||
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||||
|
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||||
|
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||||
|
/// server's page doing it, once a Signal.
|
||||||
|
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
||||||
|
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||||
|
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
|
||||||
|
async {
|
||||||
|
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||||
|
let status = r.status().as_u16();
|
||||||
|
tracing::Span::current().record("http.response.status_code", status);
|
||||||
|
if !r.status().is_success() {
|
||||||
|
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||||
|
return Err(Refused { status, body }.into());
|
||||||
|
}
|
||||||
|
let answer: serde_json::Value = r.json().await?;
|
||||||
|
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||||
|
}
|
||||||
|
.instrument(span)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
|
||||||
|
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||||
|
if let Some(d) = description {
|
||||||
|
state.push_str(&format!("\nIn their words: {d}"));
|
||||||
|
}
|
||||||
|
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||||
|
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||||
|
serde_json::json!({
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": state,
|
||||||
|
"questions": { "icon": {
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||||
|
"criteria": criteria,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jev's choice, only if it is one it was offered.
|
||||||
|
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||||
|
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||||
|
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Show notes are HTML: Jev is given their words.
|
/// Show notes are HTML: Jev is given their words.
|
||||||
fn text_of(html: &str) -> String {
|
fn text_of(html: &str) -> String {
|
||||||
let mut out = String::with_capacity(html.len());
|
let mut out = String::with_capacity(html.len());
|
||||||
@@ -231,6 +277,20 @@ mod tests {
|
|||||||
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_icon_is_one_jev_was_offered() {
|
||||||
|
let offered = vec![("solid/robot".to_string(), "Robot: ai, bot".to_string()), ("solid/laptop-code".to_string(), "Laptop Code".to_string())];
|
||||||
|
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||||
|
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||||
|
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||||
|
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||||
|
// As TypeSafe answered on 2026-10-16.
|
||||||
|
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||||
|
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||||
|
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||||
|
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||||
let item = |n: usize| Unjudged {
|
let item = |n: usize| Unjudged {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/feeds/{id}/entries", get(entries))
|
.route("/api/feeds/{id}/entries", get(entries))
|
||||||
.route("/api/entries", get(all_entries))
|
.route("/api/entries", get(all_entries))
|
||||||
.route("/api/signals", get(list_signals).post(add_signal))
|
.route("/api/signals", get(list_signals).post(add_signal))
|
||||||
|
.route("/api/signals/icon", post(pick_signal_icon))
|
||||||
.route("/api/signals/{id}", patch(patch_signal).delete(remove_signal))
|
.route("/api/signals/{id}", patch(patch_signal).delete(remove_signal))
|
||||||
.route("/api/signals/{id}/entries", get(signal_entries))
|
.route("/api/signals/{id}/entries", get(signal_entries))
|
||||||
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
||||||
@@ -499,6 +500,49 @@ async fn add_signal(
|
|||||||
Ok(Json(serde_json::json!({ "id": id })))
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct IconAsk {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
keywords: Vec<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
/// `[style/name, label]` for each icon the page found for the Signal's words.
|
||||||
|
candidates: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
|
||||||
|
/// or 404 when the server has no key.
|
||||||
|
async fn pick_signal_icon(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
_user: crate::db::User,
|
||||||
|
Json(body): Json<IconAsk>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let Ok(key) = std::env::var("TYPESAFE_KEY") else {
|
||||||
|
return Err(ApiError::not_found("this server has no TypeSafe key to pick icons with"));
|
||||||
|
};
|
||||||
|
let name = signal_name(&body.name)?;
|
||||||
|
let words = clean_words(body.keywords)?;
|
||||||
|
let description = signal_description(body.description)?;
|
||||||
|
// Each one is in the question, and comes back as the Signal's icon: only real icon names.
|
||||||
|
let mut candidates: Vec<(String, String)> = Vec::new();
|
||||||
|
for (id, label) in body.candidates {
|
||||||
|
let id = signal_icon(Some(id))?.ok_or_else(|| ApiError::bad_request("an empty icon"))?;
|
||||||
|
if !candidates.iter().any(|(c, _)| *c == id) {
|
||||||
|
candidates.push((id, label.chars().take(200).collect()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.is_empty() || candidates.len() > 60 {
|
||||||
|
return Err(ApiError::bad_request("between 1 and 60 icons to choose from"));
|
||||||
|
}
|
||||||
|
let icon = crate::signals::pick_icon(&state.ctx, &key, &name, &words, description.as_deref(), &candidates)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "Jev could not pick an icon");
|
||||||
|
ApiError::bad_gateway("Jev could not pick an icon just now")
|
||||||
|
})?;
|
||||||
|
Ok(Json(serde_json::json!({ "icon": icon })))
|
||||||
|
}
|
||||||
|
|
||||||
async fn patch_signal(
|
async fn patch_signal(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
@@ -1203,6 +1247,14 @@ impl ApiError {
|
|||||||
status: StatusCode::NOT_FOUND,
|
status: StatusCode::NOT_FOUND,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A service ipx asks on someone's behalf failed them.
|
||||||
|
fn bad_gateway(msg: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: anyhow::Error::msg(msg.into()),
|
||||||
|
status: StatusCode::BAD_GATEWAY,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ their Signals that has not heard about the item yet.
|
|||||||
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
||||||
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
||||||
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
||||||
|
**Let Jev pick** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
||||||
|
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
|
||||||
|
- **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 a + of its own.
|
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with a + of its own.
|
||||||
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
- **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
|
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
||||||
@@ -172,13 +175,14 @@ does not exist.
|
|||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
|
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
|
||||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.", "unread": 5 },
|
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||||
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "unread": 0 }
|
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
|
||||||
|
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`unread` counts the items Jev picked out that are still in a subscribed feed, not hidden, and
|
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
|
||||||
not read.
|
its list shows; `unread` those of them not read.
|
||||||
|
|
||||||
### `POST /api/signals`
|
### `POST /api/signals`
|
||||||
|
|
||||||
@@ -238,6 +242,46 @@ A string saves the key and lets the judge try it again. `null` or an empty strin
|
|||||||
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
|
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
|
||||||
more.
|
more.
|
||||||
|
|
||||||
|
### `POST /api/signals/icon`
|
||||||
|
|
||||||
|
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
|
||||||
|
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
|
||||||
|
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "AI Development",
|
||||||
|
"keywords": ["ai", "development", "programming"],
|
||||||
|
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||||
|
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "jev-latest",
|
||||||
|
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||||
|
"questions": { "icon": {
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||||
|
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeSafe answered, on 2026-10-16, for six candidates:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
|
||||||
|
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
|
||||||
|
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
|
||||||
|
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
|
||||||
|
|
||||||
### The event
|
### The event
|
||||||
|
|
||||||
Once a pass, for each person it found items for, the judge sends this on the event stream
|
Once a pass, for each person it found items for, the judge sends this on the event stream
|
||||||
|
|||||||
@@ -182,3 +182,13 @@ test('a Signal draws its icon only when it is a style and a plain name', () => {
|
|||||||
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
||||||
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Jev is offered the icons found for each word of a Signal', () => {
|
||||||
|
const all = JSON.stringify([
|
||||||
|
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
|
||||||
|
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
|
||||||
|
]);
|
||||||
|
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
|
||||||
|
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
|
||||||
|
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261).
|
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261), or Jev's pick of one, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
|
||||||
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function signalFolder(list: HTMLElement, q: string){
|
|||||||
if(!open) return;
|
if(!open) return;
|
||||||
for(const sg of mine){
|
for(const sg of mine){
|
||||||
const el=feedRow(':signal:'+sg.id,'child',
|
const el=feedRow(':signal:'+sg.id,'child',
|
||||||
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
|
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
|
||||||
()=>selectFeed(':signal:'+sg.id));
|
()=>selectFeed(':signal:'+sg.id));
|
||||||
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
@@ -97,7 +97,7 @@ function renderSignals(){
|
|||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
||||||
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
||||||
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,7 @@ function signalModal(sg?: Signal){
|
|||||||
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||||
<div id="sgpicker" hidden>
|
<div id="sgpicker" hidden>
|
||||||
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
||||||
|
<button type="button" class="dlink" id="sgjev">Let Jev pick</button>
|
||||||
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||||
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
@@ -179,6 +180,16 @@ function signalModal(sg?: Signal){
|
|||||||
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
||||||
};
|
};
|
||||||
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
||||||
|
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
|
||||||
|
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.textContent='Asking Jev…';
|
||||||
|
try{
|
||||||
|
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
|
||||||
|
$<HTMLInputElement>('#sgdesc').value.trim()||null);
|
||||||
|
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
|
||||||
|
else toast('No icon matches those words');
|
||||||
|
}catch(e){ toast(why(e),true); }
|
||||||
|
finally{ b.disabled=false; b.textContent='Let Jev pick'; }
|
||||||
|
};
|
||||||
$<HTMLInputElement>('#sgname').focus();
|
$<HTMLInputElement>('#sgname').focus();
|
||||||
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
||||||
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
||||||
@@ -191,6 +202,10 @@ function signalModal(sg?: Signal){
|
|||||||
try{
|
try{
|
||||||
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
||||||
: await api('/api/signals',{method:'POST',body});
|
: await api('/api/signals',{method:'POST',body});
|
||||||
|
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
|
||||||
|
if(!sg&&!icon) jevIcon(name,keywords,description)
|
||||||
|
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
|
||||||
|
.catch(()=>{});
|
||||||
closeModal(); toast(sg?'Saved. Jev reads everything again':'Listening. What Jev picks out shows up here');
|
closeModal(); toast(sg?'Saved. Jev reads everything again':'Listening. What Jev picks out shows up here');
|
||||||
// Open, so the new one is there to see.
|
// Open, so the new one is there to see.
|
||||||
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
|
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
|
||||||
@@ -233,3 +248,24 @@ function iconMatches(all: [string,string,string][], q: string, max: number){
|
|||||||
};
|
};
|
||||||
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "12 items · 3 unread", as a feed's row counts its own.
|
||||||
|
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
|
||||||
|
|
||||||
|
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
|
||||||
|
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
|
||||||
|
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
|
||||||
|
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
|
||||||
|
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
|
||||||
|
const out=new Map<string,string>();
|
||||||
|
for(const w of [...new Set(words)])
|
||||||
|
for(const [id,label,terms] of iconMatches(all,w,6)) if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
|
||||||
|
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
|
||||||
|
const candidates=iconCandidates(await iconList(),name,keywords,description);
|
||||||
|
if(!candidates.length) return null;
|
||||||
|
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
|
||||||
|
return r.icon;
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ interface Signal {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
|
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
/// The items it holds, read or not, and of those the unread.
|
||||||
|
items: number;
|
||||||
unread: number;
|
unread: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user