A Signal's own icon, from all of Font Awesome Free, searchable (#261)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 18:36:08 +00:00
parent 9e5dcbb8c4
commit b18c92d1f5
15 changed files with 269 additions and 31 deletions

View File

@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 (`/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 (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
### Changed

View File

@@ -113,6 +113,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
if orm.execute_unprepared("SELECT icon FROM signals LIMIT 0").await.is_err() {
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
@@ -2547,9 +2551,19 @@ pub struct SignalRow {
pub name: String,
pub keywords: Vec<String>,
pub description: Option<String>,
pub icon: Option<String>,
pub unread: i64,
}
/// What a Signal is changed to: each field None to leave it as it is.
#[derive(Default)]
pub struct SignalChange<'a> {
pub name: Option<&'a str>,
pub words: Option<&'a [String]>,
pub description: Option<Option<&'a str>>,
pub icon: Option<Option<&'a str>>,
}
/// One Signal's question about an item: what it is called, its words, and its owner's sentence.
#[derive(Debug, Clone)]
pub struct Ask {
@@ -2591,7 +2605,7 @@ impl Db {
let user = a.p(user_id);
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
let sql = format!(
"SELECT g.id, g.name, g.keywords, g.description,
"SELECT g.id, g.name, g.keywords, g.description, g.icon,
(SELECT count(*) FROM signal_verdicts v
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
@@ -2609,6 +2623,7 @@ impl Db {
name: r.try_get("", "name")?,
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
description: r.try_get("", "description")?,
icon: r.try_get("", "icon")?,
unread: r.try_get("", "unread")?,
})
})
@@ -2629,17 +2644,11 @@ impl Db {
Ok(m.id)
}
/// Renames a Signal, or gives it new words or a new sentence. Either of those puts every item
/// to Jev again, since what it said was about the old ones; a new name does too, since the
/// question is about the name (#259). False for a Signal that is not this person's.
pub async fn set_signal(
&self,
user_id: i64,
id: i64,
name: Option<&str>,
words: Option<&[String]>,
description: Option<Option<&str>>,
) -> Result<bool> {
/// Changes a Signal. A new name, words or sentence puts every item to Jev again, since what it
/// said was about the old question (#259); a new icon does not. False for a Signal that is not
/// this person's.
pub async fn set_signal(&self, user_id: i64, id: i64, change: SignalChange<'_>) -> Result<bool> {
let SignalChange { name, words, description, icon } = change;
if self.signal(user_id, id).await?.is_none() {
return Ok(false);
}
@@ -2653,8 +2662,13 @@ impl Db {
if let Some(d) = description {
m.description = Set(d.map(str::to_owned));
}
if let Some(i) = icon {
m.icon = Set(i.map(str::to_owned));
}
if name.is_some() || words.is_some() || description.is_some() {
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
}
if name.is_some() || words.is_some() || description.is_some() || icon.is_some() {
m.update(&self.orm).await?;
}
Ok(true)
@@ -3396,15 +3410,19 @@ mod tests {
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal");
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
assert!(!db.set_signal(2, f1, Some("mine"), None, None).await.unwrap());
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
assert!(!db.remove_signal(2, f1).await.unwrap());
db.set_signal(1, f1, None, Some(&words(&["f1"])), None).await.unwrap();
db.set_signal(1, f1, SignalChange { words: Some(&words(&["f1"])), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh");
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, None, None, Some(Some("Grand prix racing"))).await.unwrap();
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence");
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1, "a new icon asks nothing again");
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
assert!(db.remove_signal(1, food).await.unwrap());
db.record_verdicts("f", "x", &[(food, true)]).await.unwrap();
assert_eq!(db.signals(1).await.unwrap().len(), 1, "a verdict for a removed Signal is not kept");

View File

@@ -308,6 +308,10 @@ pub mod signals {
/// are not enough (#259).
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
/// initials (#261).
#[sea_orm(column_type = "Text", nullable)]
pub icon: Option<String>,
pub created: i64,
}

View File

@@ -429,6 +429,24 @@ struct SignalBody {
/// What its owner means by it, in their words: a string sets it, null or empty clears it.
#[serde(default, deserialize_with = "double_option")]
description: Option<Option<String>>,
/// A Font Awesome Free icon, `solid/robot`: a string sets it, null or empty clears it.
#[serde(default, deserialize_with = "double_option")]
icon: Option<Option<String>>,
}
/// The page puts the name into an address (`/icons/<style>/<name>.svg`), so nothing but a style
/// and a plain name gets through.
fn signal_icon(i: Option<String>) -> Result<Option<String>, ApiError> {
let Some(i) = i.filter(|i| !i.is_empty()) else { return Ok(None) };
let ok = i.split_once('/').is_some_and(|(style, name)| {
["solid", "regular", "brands"].contains(&style)
&& (1..=64).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
});
if !ok {
return Err(ApiError::bad_request("an icon is a Font Awesome Free one, such as solid/robot"));
}
Ok(Some(i))
}
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
@@ -471,7 +489,12 @@ async fn add_signal(
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
let words = signal_words(body.keywords.unwrap_or_default())?;
let description = signal_description(body.description.flatten())?;
let icon = signal_icon(body.icon.flatten())?;
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
if icon.is_some() {
let change = crate::db::SignalChange { icon: Some(icon.as_deref()), ..Default::default() };
state.ctx.db.set_signal(user.id, id, change).await?;
}
state.ctx.signals.notify_one();
Ok(Json(serde_json::json!({ "id": id })))
}
@@ -485,11 +508,18 @@ async fn patch_signal(
let name = body.name.as_deref().map(signal_name).transpose()?;
let words = body.keywords.map(signal_words).transpose()?;
let description = body.description.map(signal_description).transpose()?;
let changed = name.is_some() || words.is_some() || description.is_some();
if !state.ctx.db.set_signal(user.id, id, name.as_deref(), words.as_deref(), description.as_ref().map(|d| d.as_deref())).await? {
let icon = body.icon.map(signal_icon).transpose()?;
let asks_again = name.is_some() || words.is_some() || description.is_some();
let change = crate::db::SignalChange {
name: name.as_deref(),
words: words.as_deref(),
description: description.as_ref().map(|d| d.as_deref()),
icon: icon.as_ref().map(|i| i.as_deref()),
};
if !state.ctx.db.set_signal(user.id, id, change).await? {
return Err(ApiError::not_found("no such Signal"));
}
if changed {
if asks_again {
state.ctx.signals.notify_one();
}
Ok(StatusCode::NO_CONTENT)
@@ -1211,6 +1241,17 @@ mod tests {
}
}
#[test]
fn a_signal_icon_is_a_style_and_a_plain_name() {
assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot"));
assert_eq!(signal_icon(Some("brands/apple".into())).ok().flatten().as_deref(), Some("brands/apple"));
assert!(matches!(signal_icon(Some(String::new())), Ok(None)), "empty clears it");
// It becomes part of an address and a style attribute on the page.
for bad in ["solid/../../api/me", "light/robot", "solid/Robot", "solid/x');background:url(x", "robot", "solid/"] {
assert!(signal_icon(Some(bad.into())).is_err(), "{bad}");
}
}
#[test]
fn only_a_plain_theme_name_is_handed_on() {
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));