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)
);
"#;
pub const LEFT: [&str; 4] = ["inbox", "desks", "folders", "widgets"];
pub const RIGHT: [&str; 7] = [
"turn", "panels", "rest", "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 fn prefs(conn: &Connection, name: &str) -> Result<Prefs> {
Ok(conn
.query_row(
"SELECT name, hidden, settings, trusted_hash, rerun_edits 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,
})
},
)
.optional()?
.unwrap_or_else(|| Prefs {
name: name.to_string(),
settings: "{}".into(),
..Prefs::default()
}))
}
pub fn set_prefs(
conn: &Connection,
name: &str,
hidden: Option<bool>,
settings: Option<&str>,
trusted_hash: Option<&str>,
rerun_edits: Option<bool>,
now: i64,
) -> Result<Prefs> {
let was = prefs(conn, name)?;
conn.execute(
"INSERT INTO widget_prefs (name, hidden, settings, trusted_hash, rerun_edits, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(name) DO UPDATE SET hidden = excluded.hidden, settings = excluded.settings,
trusted_hash = excluded.trusted_hash, rerun_edits = excluded.rerun_edits, updated_at = excluded.updated_at",
params![
name,
hidden.unwrap_or(was.hidden) as i64,
settings.unwrap_or(&was.settings),
trusted_hash.unwrap_or(&was.trusted_hash),
rerun_edits.unwrap_or(was.rerun_edits) as i64,
now
],
)?;
prefs(conn, name)
}