use anyhow::Result;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
pub mod files;
#[cfg(test)]
mod tests;
pub fn title_of(name: &str) -> String {
let t = name.replace('-', " ");
let mut c = t.chars();
c.next()
.map(|f| f.to_uppercase().chain(c).collect())
.unwrap_or_default()
}
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS ui_layout (
id INTEGER PRIMARY KEY CHECK (id = 1),
json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS widget_prefs (
name TEXT PRIMARY KEY,
hidden INTEGER NOT NULL DEFAULT 0,
settings TEXT NOT NULL DEFAULT '{}',
trusted_hash TEXT NOT NULL DEFAULT '',
rerun_edits INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS widget_bodies (
desk_id INTEGER NOT NULL,
name TEXT NOT NULL,
source TEXT NOT NULL,
body_md TEXT NOT NULL,
body_html TEXT NOT NULL,
tone TEXT NOT NULL DEFAULT 'none',
count TEXT NOT NULL DEFAULT '',
lines INTEGER NOT NULL DEFAULT 3,
stale_after INTEGER NOT NULL DEFAULT 1800,
writer TEXT NOT NULL DEFAULT '',
pane TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY (desk_id, name)
);
CREATE TABLE IF NOT EXISTS widget_asks (
desk_id INTEGER NOT NULL,
name TEXT NOT NULL,
answer TEXT NOT NULL DEFAULT '',
pane TEXT NOT NULL DEFAULT '',
writer TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
until INTEGER NOT NULL DEFAULT 0,
until_pane TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY (desk_id, name)
);
"#;
pub const PREFS_COLUMNS_1_30: [&str; 3] = [
"ALTER TABLE widget_prefs ADD COLUMN desks TEXT NOT NULL DEFAULT ''",
"ALTER TABLE widget_prefs ADD COLUMN until INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE widget_prefs ADD COLUMN until_pane TEXT NOT NULL DEFAULT ''",
];
pub const STOPPED: &str = "stopped: ";
pub const FAILS_TO_STOP: u8 = 3;
pub const LEFT: [&str; 4] = ["inbox", "desks", "folders", "widgets"];
pub const RIGHT: [&str; 6] = ["turn", "panels", "points", "docs", "notes", "widgets"];
pub const FIXED: &str = "turn";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Layout {
#[serde(default)]
pub left: Vec<String>,
#[serde(default)]
pub right: Vec<String>,
#[serde(default)]
pub hidden: Vec<String>,
}
impl Default for Layout {
fn default() -> Layout {
Layout {
left: LEFT.iter().map(|s| s.to_string()).collect(),
right: RIGHT.iter().map(|s| s.to_string()).collect(),
hidden: Vec::new(),
}
}
}
fn order(ids: &[String], known: &[&str]) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(known.len());
for id in ids {
if known.contains(&id.as_str()) && !out.contains(id) {
out.push(id.clone());
}
}
for (i, k) in known.iter().enumerate() {
if out.iter().any(|x| x == k) {
continue;
}
let at = known[..i]
.iter()
.rev()
.find_map(|before| out.iter().position(|x| x == before))
.map_or(0, |p| p + 1);
out.insert(at, k.to_string());
}
out
}
impl Layout {
pub fn normalize(&self) -> Layout {
let left = order(&self.left, &LEFT);
let mut right = order(&self.right, &RIGHT);
right.retain(|x| x != FIXED);
right.insert(0, FIXED.to_string());
let can_hide = |h: &str| -> bool {
match h.split_once(':') {
Some(("left", "widgets")) | Some(("right", "widgets")) => true,
Some(_) => false,
None => h != FIXED && h != "widgets" && (LEFT.contains(&h) || RIGHT.contains(&h)),
}
};
let mut hidden: Vec<String> = Vec::new();
for h in &self.hidden {
if can_hide(h) && !hidden.contains(h) {
hidden.push(h.clone());
}
}
Layout {
left,
right,
hidden,
}
}
}
pub fn layout(conn: &Connection) -> Result<Layout> {
let json: Option<String> = conn
.query_row("SELECT json FROM ui_layout WHERE id = 1", [], |r| r.get(0))
.optional()?;
Ok(json
.and_then(|j| serde_json::from_str::<Layout>(&j).ok())
.unwrap_or_default()
.normalize())
}
pub fn set_layout(conn: &Connection, l: &Layout, now: i64) -> Result<Layout> {
let l = l.normalize();
conn.execute(
"INSERT INTO ui_layout (id, json, updated_at) VALUES (1, ?1, ?2)
ON CONFLICT(id) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at",
params![serde_json::to_string(&l)?, now],
)?;
Ok(l)
}
pub const NAME_MAX: usize = 32;
pub const BODY_MAX: usize = 1500;
pub const COUNT_MAX: usize = 8;
pub const LINES_MIN: u8 = 1;
pub const LINES_MAX: u8 = 6;
pub const LINES_DEFAULT: u8 = 3;
pub const STALE_DEFAULT: i64 = 30 * 60;
pub const STALE_MAX: i64 = 7 * 24 * 3600;
pub const DESK_MAX: usize = 6;
pub const GLOBAL_MAX: usize = 8;
pub fn name_ok(name: &str) -> bool {
!name.is_empty()
&& name.len() <= NAME_MAX
&& !name.starts_with('-')
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Tone {
#[default]
None,
Ok,
Warn,
Bad,
}
impl Tone {
pub fn as_str(self) -> &'static str {
match self {
Tone::None => "none",
Tone::Ok => "ok",
Tone::Warn => "warn",
Tone::Bad => "bad",
}
}
pub fn parse(s: &str) -> Option<Tone> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "none" => Some(Tone::None),
"ok" | "good" | "green" => Some(Tone::Ok),
"warn" | "warning" | "yellow" => Some(Tone::Warn),
"bad" | "error" | "fail" | "red" => Some(Tone::Bad),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Body {
pub md: String,
pub tone: Tone,
pub count: String,
pub lines: u8,
pub stale_after: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sent {
Body(Body),
Clear,
}
#[derive(Deserialize)]
struct Wire {
body: Option<String>,
#[serde(default)]
tone: Option<String>,
#[serde(default)]
count: Option<serde_json::Value>,
#[serde(default)]
lines: Option<i64>,
#[serde(default)]
stale_after: Option<i64>,
}
impl Body {
pub fn parse(raw: &str) -> std::result::Result<Sent, String> {
let t = raw.trim();
if t.is_empty() {
return Ok(Sent::Clear);
}
let mut b = Body {
md: t.to_string(),
tone: Tone::None,
count: String::new(),
lines: LINES_DEFAULT,
stale_after: STALE_DEFAULT,
};
if t.starts_with('{') {
if let Ok(w) = serde_json::from_str::<Wire>(t) {
let body = w.body.unwrap_or_default();
if body.trim().is_empty() {
return Ok(Sent::Clear);
}
b.md = body.trim().to_string();
if let Some(s) = w.tone {
b.tone = Tone::parse(&s)
.ok_or_else(|| format!("tone is ok, warn, bad or none, not \"{s}\""))?;
}
b.count = match w.count {
None | Some(serde_json::Value::Null) => String::new(),
Some(serde_json::Value::String(s)) => s.trim().to_string(),
Some(serde_json::Value::Number(n)) => n.to_string(),
Some(_) => return Err("count is a number or a short text".into()),
};
if let Some(l) = w.lines {
b.lines = l.clamp(LINES_MIN as i64, LINES_MAX as i64) as u8;
}
if let Some(s) = w.stale_after {
b.stale_after = s.clamp(0, STALE_MAX);
}
}
}
if b.md.chars().count() > BODY_MAX {
return Err(format!("the body is longer than {BODY_MAX} characters"));
}
if b.count.chars().count() > COUNT_MAX {
return Err(format!("the count is longer than {COUNT_MAX} characters"));
}
Ok(Sent::Body(b))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Push,
File,
}
impl Source {
fn as_str(self) -> &'static str {
match self {
Source::Push => "push",
Source::File => "file",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Seat {
pub desk_id: i64,
pub name: String,
pub source: String,
pub html: String,
pub tone: String,
pub count: String,
pub lines: u8,
pub stale_after: i64,
pub writer: String,
pub pane: String,
pub error: String,
pub updated_at: i64,
pub hidden: bool,
}
pub struct Writer<'a> {
pub source: Source,
pub writer: &'a str,
pub pane: &'a str,
}
const SEAT_COLS: &str =
"b.desk_id, b.name, b.source, b.body_html, b.tone, b.count, b.lines, b.stale_after,
b.writer, b.pane, b.error, b.updated_at, COALESCE(p.hidden, 0)";
fn seat(r: &rusqlite::Row) -> rusqlite::Result<Seat> {
Ok(Seat {
desk_id: r.get(0)?,
name: r.get(1)?,
source: r.get(2)?,
html: r.get(3)?,
tone: r.get(4)?,
count: r.get(5)?,
lines: r
.get::<_, i64>(6)?
.clamp(LINES_MIN as i64, LINES_MAX as i64) as u8,
stale_after: r.get(7)?,
writer: r.get(8)?,
pane: r.get(9)?,
error: r.get(10)?,
updated_at: r.get(11)?,
hidden: r.get::<_, i64>(12)? != 0,
})
}
pub fn seats(conn: &Connection, desk_id: i64) -> Result<Vec<Seat>> {
let mut st = conn.prepare(&format!(
"SELECT {SEAT_COLS} FROM widget_bodies b LEFT JOIN widget_prefs p ON p.name = b.name
WHERE b.desk_id = ?1 ORDER BY b.rowid"
))?;
let rows = st.query_map([desk_id], seat)?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn seat_of(conn: &Connection, desk_id: i64, name: &str) -> Result<Option<Seat>> {
Ok(conn
.query_row(
&format!(
"SELECT {SEAT_COLS} FROM widget_bodies b LEFT JOIN widget_prefs p ON p.name = b.name
WHERE b.desk_id = ?1 AND b.name = ?2"
),
params![desk_id, name],
seat,
)
.optional()?)
}
#[derive(Debug, PartialEq, Eq)]
pub enum Put {
Done,
Full,
Owned,
}
pub fn put(
conn: &Connection,
desk_id: i64,
name: &str,
b: &Body,
html: &str,
w: &Writer,
now: i64,
) -> Result<Put> {
let owner: Option<String> = conn
.query_row(
"SELECT source FROM widget_bodies WHERE desk_id = ?1 AND name = ?2",
params![desk_id, name],
|r| r.get(0),
)
.optional()?;
match owner.as_deref() {
Some("file") if w.source != Source::File => return Ok(Put::Owned),
Some(_) => {}
None => {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM widget_bodies WHERE desk_id = ?1",
[desk_id],
|r| r.get(0),
)?;
let cap = if desk_id == 0 { GLOBAL_MAX } else { DESK_MAX };
if n as usize >= cap {
return Ok(Put::Full);
}
}
}
conn.execute(
"INSERT INTO widget_bodies (desk_id, name, source, body_md, body_html, tone, count, lines, stale_after, writer, pane, error, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, '', ?12)
ON CONFLICT(desk_id, name) DO UPDATE SET source = excluded.source, body_md = excluded.body_md,
body_html = excluded.body_html, tone = excluded.tone, count = excluded.count, lines = excluded.lines,
stale_after = excluded.stale_after, writer = excluded.writer, pane = excluded.pane, error = '',
updated_at = excluded.updated_at",
params![
desk_id,
name,
w.source.as_str(),
b.md,
html,
b.tone.as_str(),
b.count,
b.lines as i64,
b.stale_after,
w.writer,
w.pane,
now
],
)?;
Ok(Put::Done)
}
pub fn fail(
conn: &Connection,
desk_id: i64,
name: &str,
why: &str,
writer: &str,
now: i64,
) -> Result<()> {
let why: String = why.chars().take(200).collect();
conn.execute(
"INSERT INTO widget_bodies (desk_id, name, source, body_md, body_html, writer, error, updated_at)
VALUES (?1, ?2, 'file', '', '', ?3, ?4, ?5)
ON CONFLICT(desk_id, name) DO UPDATE SET error = excluded.error, updated_at = excluded.updated_at
WHERE widget_bodies.source = 'file'",
params![desk_id, name, writer, why, now],
)?;
Ok(())
}
pub fn clear(conn: &Connection, desk_id: i64, name: &str, source: Source) -> Result<bool> {
let n = conn.execute(
"DELETE FROM widget_bodies WHERE desk_id = ?1 AND name = ?2 AND (source = ?3 OR ?3 = 'file')",
params![desk_id, name, source.as_str()],
)?;
Ok(n > 0)
}
pub fn desks_of(conn: &Connection, name: &str) -> Result<Vec<i64>> {
let mut st = conn.prepare("SELECT desk_id FROM widget_bodies WHERE name = ?1")?;
let rows = st.query_map([name], |r| r.get(0))?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn of_pane(conn: &Connection, pane: &str) -> Result<Vec<(i64, String)>> {
let mut st = conn
.prepare("SELECT desk_id, name FROM widget_bodies WHERE pane = ?1 AND source = 'push'")?;
let rows = st.query_map([pane], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn pane_ended(conn: &Connection, pane: &str, said: &str) -> Result<usize> {
Ok(conn.execute(
"UPDATE widget_bodies SET writer = ?2, stale_after = 1, pane = '' WHERE pane = ?1 AND source = 'push'",
params![pane, said],
)?)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
pub struct Prefs {
pub name: String,
pub hidden: bool,
pub settings: String,
pub trusted_hash: String,
pub rerun_edits: bool,
pub desks: Vec<i64>,
pub until: i64,
pub until_pane: String,
}
impl Prefs {
pub fn on_desk(&self, id: i64) -> bool {
self.desks.is_empty() || self.desks.contains(&id)
}
pub fn ended(&self, now: i64, live: impl Fn(&str) -> bool) -> bool {
(self.until > 0 && self.until <= now)
|| (!self.until_pane.is_empty() && !live(&self.until_pane))
}
}
fn desks_text(ids: &[i64]) -> String {
ids.iter().map(i64::to_string).collect::<Vec<_>>().join(",")
}
fn desks_read(s: &str) -> Vec<i64> {
s.split(',').filter_map(|x| x.trim().parse().ok()).collect()
}
pub fn prefs(conn: &Connection, name: &str) -> Result<Prefs> {
Ok(conn
.query_row(
"SELECT name, hidden, settings, trusted_hash, rerun_edits, desks, until, until_pane
FROM widget_prefs WHERE name = ?1",
[name],
|r| {
Ok(Prefs {
name: r.get(0)?,
hidden: r.get::<_, i64>(1)? != 0,
settings: r.get(2)?,
trusted_hash: r.get(3)?,
rerun_edits: r.get::<_, i64>(4)? != 0,
desks: desks_read(&r.get::<_, String>(5)?),
until: r.get(6)?,
until_pane: r.get(7)?,
})
},
)
.optional()?
.unwrap_or_else(|| Prefs {
name: name.to_string(),
settings: "{}".into(),
..Prefs::default()
}))
}
#[derive(Default)]
pub struct Change<'a> {
pub hidden: Option<bool>,
pub settings: Option<&'a str>,
pub trusted_hash: Option<&'a str>,
pub rerun_edits: Option<bool>,
pub desks: Option<&'a [i64]>,
pub until: Option<i64>,
pub until_pane: Option<&'a str>,
}
pub fn set_prefs(conn: &Connection, name: &str, ch: &Change, now: i64) -> Result<Prefs> {
let was = prefs(conn, name)?;
conn.execute(
"INSERT INTO widget_prefs (name, hidden, settings, trusted_hash, rerun_edits, desks, until, until_pane, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(name) DO UPDATE SET hidden = excluded.hidden, settings = excluded.settings,
trusted_hash = excluded.trusted_hash, rerun_edits = excluded.rerun_edits, desks = excluded.desks,
until = excluded.until, until_pane = excluded.until_pane, updated_at = excluded.updated_at",
params![
name,
ch.hidden.unwrap_or(was.hidden) as i64,
ch.settings.unwrap_or(&was.settings),
ch.trusted_hash.unwrap_or(&was.trusted_hash),
ch.rerun_edits.unwrap_or(was.rerun_edits) as i64,
desks_text(ch.desks.unwrap_or(&was.desks)),
ch.until.unwrap_or(was.until),
ch.until_pane.unwrap_or(&was.until_pane),
now
],
)?;
prefs(conn, name)
}
pub fn off_desks(conn: &Connection, name: &str, desks: &[i64]) -> Result<Vec<i64>> {
if desks.is_empty() {
return Ok(Vec::new());
}
let gone: Vec<i64> = desks_of(conn, name)?
.into_iter()
.filter(|d| *d != 0 && !desks.contains(d))
.collect();
for d in &gone {
conn.execute(
"DELETE FROM widget_bodies WHERE desk_id = ?1 AND name = ?2 AND source = 'file'",
params![d, name],
)?;
}
Ok(gone)
}
pub fn unstop(conn: &Connection, name: &str, desk: Option<i64>) -> Result<Vec<i64>> {
let mut st = conn.prepare(
"SELECT desk_id FROM widget_bodies WHERE name = ?1 AND source = 'file'
AND substr(error, 1, length(?2)) = ?2 AND (?3 IS NULL OR desk_id = ?3)",
)?;
let ds: Vec<i64> = st
.query_map(params![name, STOPPED, desk], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
for d in &ds {
conn.execute(
"UPDATE widget_bodies SET error = '' WHERE desk_id = ?1 AND name = ?2",
params![d, name],
)?;
}
Ok(ds)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Ask {
pub desk_id: i64,
pub name: String,
pub answer: String,
pub pane: String,
pub writer: String,
pub body: String,
pub until: i64,
pub until_pane: String,
}
pub fn ask_of(conn: &Connection, desk_id: i64, name: &str) -> Result<Option<Ask>> {
Ok(conn
.query_row(
"SELECT desk_id, name, answer, pane, writer, body, until, until_pane FROM widget_asks
WHERE desk_id = ?1 AND name = ?2",
params![desk_id, name],
|r| {
Ok(Ask {
desk_id: r.get(0)?,
name: r.get(1)?,
answer: r.get(2)?,
pane: r.get(3)?,
writer: r.get(4)?,
body: r.get(5)?,
until: r.get(6)?,
until_pane: r.get(7)?,
})
},
)
.optional()?)
}
pub fn ask_wait(
conn: &Connection,
desk_id: i64,
name: &str,
pane: &str,
writer: &str,
body: &str,
now: i64,
) -> Result<()> {
conn.execute(
"INSERT INTO widget_asks (desk_id, name, answer, pane, writer, body, updated_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, ?6)
ON CONFLICT(desk_id, name) DO UPDATE SET answer = '', pane = excluded.pane,
writer = excluded.writer, body = excluded.body, updated_at = excluded.updated_at",
params![desk_id, name, pane, writer, body, now],
)?;
Ok(())
}
pub fn ask_answer(
conn: &Connection,
desk_id: i64,
name: &str,
answer: &str,
until: i64,
until_pane: &str,
now: i64,
) -> Result<bool> {
Ok(conn.execute(
"UPDATE widget_asks SET answer = ?3, until = ?4, until_pane = ?5, updated_at = ?6
WHERE desk_id = ?1 AND name = ?2",
params![desk_id, name, answer, until, until_pane, now],
)? > 0)
}
pub fn ask_grandfather(conn: &Connection, desk_id: i64, name: &str, now: i64) -> Result<bool> {
Ok(conn.execute(
"INSERT INTO widget_asks (desk_id, name, answer, pane, writer, updated_at)
SELECT desk_id, name, 'yes', pane, writer, ?3 FROM widget_bodies
WHERE desk_id = ?1 AND name = ?2 AND source = 'push'
ON CONFLICT(desk_id, name) DO NOTHING",
params![desk_id, name, now],
)? > 0)
}
pub fn asks_ending(
conn: &Connection,
now: i64,
live: impl Fn(&str) -> bool,
) -> Result<Vec<(i64, String)>> {
let mut st = conn.prepare(
"SELECT desk_id, name, until, until_pane FROM widget_asks
WHERE answer = 'yes' AND (until > 0 OR until_pane != '')",
)?;
let due: Vec<(i64, String)> = st
.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
.into_iter()
.filter(|(_, _, until, pane)| {
(*until > 0 && *until <= now) || (!pane.is_empty() && !live(pane))
})
.map(|(d, n, _, _)| (d, n))
.collect();
for (d, n) in &due {
conn.execute(
"UPDATE widget_asks SET answer = 'ended', updated_at = ?3 WHERE desk_id = ?1 AND name = ?2",
params![d, n, now],
)?;
}
Ok(due)
}