use anyhow::Result;
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY,
desk_id INTEGER NOT NULL REFERENCES desks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
stage TEXT NOT NULL DEFAULT 'planned',
folder TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
branch_seen INTEGER NOT NULL DEFAULT 0,
commits INTEGER NOT NULL DEFAULT 0,
pr TEXT NOT NULL DEFAULT '',
ci TEXT NOT NULL DEFAULT '',
merged TEXT NOT NULL DEFAULT '',
merged_at INTEGER NOT NULL DEFAULT 0,
next TEXT NOT NULL DEFAULT '',
by TEXT NOT NULL DEFAULT '',
pane TEXT NOT NULL DEFAULT '',
moved_by TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
moved_at INTEGER NOT NULL,
shipped_at INTEGER NOT NULL DEFAULT 0,
removed_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS threads_desk ON threads(desk_id, removed_at, moved_at);
CREATE TABLE IF NOT EXISTS turns (
id INTEGER PRIMARY KEY,
desk_id INTEGER NOT NULL REFERENCES desks(id) ON DELETE CASCADE,
thread_id INTEGER NOT NULL DEFAULT 0,
pane TEXT NOT NULL DEFAULT '',
by TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
via TEXT NOT NULL DEFAULT 'ask',
text TEXT NOT NULL,
options TEXT NOT NULL DEFAULT '',
recommended INTEGER NOT NULL DEFAULT -1,
link TEXT NOT NULL DEFAULT '',
answer TEXT NOT NULL DEFAULT '',
answered_in TEXT NOT NULL DEFAULT '',
answered_at INTEGER NOT NULL DEFAULT 0,
told_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
removed_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS turns_desk ON turns(desk_id, removed_at, answered_at);
CREATE TABLE IF NOT EXISTS desk_suggestions (
id INTEGER PRIMARY KEY,
desk_id INTEGER NOT NULL REFERENCES desks(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
cmd TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL DEFAULT '',
why TEXT NOT NULL DEFAULT '',
by TEXT NOT NULL DEFAULT '',
pane TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
settled_at INTEGER NOT NULL DEFAULT 0,
outcome TEXT NOT NULL DEFAULT '',
told_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS desk_suggestions_desk ON desk_suggestions(desk_id, settled_at);
"#;
pub const THREAD_COLUMN: &str =
"ALTER TABLE desk_notes ADD COLUMN thread_id INTEGER NOT NULL DEFAULT 0";
pub const CMD_COLUMN: &str = "ALTER TABLE turns ADD COLUMN cmd TEXT NOT NULL DEFAULT ''";
pub const TAKEN_COLUMN: &str = "ALTER TABLE threads ADD COLUMN taken_at INTEGER NOT NULL DEFAULT 0";
pub const TAKEN_FILL: &str = "UPDATE threads SET taken_at = moved_at WHERE taken_at = 0";
pub const RESTING_SHOWN: i64 = 86400;
pub const PARKED_SHOWN: i64 = 7 * 86400;
pub const STAGES: [&str; 7] = [
"idea", "planned", "building", "review", "waiting", "shipped", "parked",
];
pub const KINDS: [&str; 5] = ["decide", "try", "merge", "key", "run"];
pub const CMD_BYTES: usize = 2048;
pub const THREADS_PER_DESK: i64 = 12;
pub const TURNS_PER_DESK: i64 = 6;
pub const SUGGESTIONS_PER_DESK: i64 = 3;
const NAME_CHARS: usize = 60;
const TEXT_CHARS: usize = 300;
const OPTION_CHARS: usize = 80;
const PATH_CHARS: usize = 400;
const CMD_CHARS: usize = 400;
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Thread {
pub id: i64,
pub desk_id: i64,
pub name: String,
pub stage: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub folder: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub branch: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub seen: bool,
#[serde(skip_serializing_if = "is_zero")]
pub commits: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub pr: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub ci: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub merged: String,
#[serde(skip_serializing_if = "is_zero")]
pub merged_at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub next: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub by: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub pane: String,
#[serde(skip)]
pub moved_by: String,
pub created_at: i64,
pub moved_at: i64,
#[serde(skip_serializing_if = "is_zero")]
pub shipped_at: i64,
#[serde(skip_serializing_if = "is_zero")]
pub removed_at: i64,
#[serde(skip)]
pub taken_at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub rest: String,
pub notes: Vec<i64>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Turn {
pub id: i64,
pub desk_id: i64,
#[serde(skip_serializing_if = "is_zero")]
pub thread_id: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub pane: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub by: String,
pub kind: String,
pub via: String,
pub text: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>,
pub recommended: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub link: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub cmd: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub answer: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub answered_in: String,
#[serde(skip_serializing_if = "is_zero")]
pub answered_at: i64,
#[serde(skip)]
pub told_at: i64,
pub created_at: i64,
#[serde(skip_serializing_if = "is_zero")]
pub removed_at: i64,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Suggestion {
pub id: i64,
pub desk_id: i64,
pub kind: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub cmd: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub folder: String,
pub why: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub by: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub pane: String,
pub created_at: i64,
#[serde(skip_serializing_if = "is_zero")]
pub settled_at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub outcome: String,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
pub fn line(text: &str, chars: usize) -> String {
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
match text.char_indices().nth(chars) {
Some((at, _)) => text[..at].trim_end().to_string(),
None => text,
}
}
pub fn branch_ok(b: &str) -> bool {
!b.is_empty()
&& b.len() <= 120
&& !b.contains("..")
&& !b.starts_with('-')
&& b.bytes()
.all(|c| c.is_ascii_graphic() && !b"~^:?*[\\".contains(&c))
}
pub fn pr_number(pr: &str) -> Option<String> {
let pr = pr.trim();
let tail = pr
.rsplit_once("/pull/")
.map(|(_, n)| n.trim_end_matches('/'))
.unwrap_or_else(|| pr.trim_start_matches('#'));
(!tail.is_empty() && tail.len() <= 9 && tail.bytes().all(|b| b.is_ascii_digit()))
.then(|| tail.to_string())
}
fn desk_open(conn: &Connection, desk_id: i64) -> Result<bool> {
Ok(conn
.query_row(
"SELECT 1 FROM desks WHERE id = ?1 AND closed_at = 0",
params![desk_id],
|_| Ok(()),
)
.optional()?
.is_some())
}
const THREAD_COLS: &str =
"id, desk_id, name, stage, folder, branch, branch_seen, commits, pr, ci, merged, merged_at,
next, by, pane, created_at, moved_at, shipped_at, removed_at, moved_by, taken_at";
fn row_to_thread(r: &rusqlite::Row) -> rusqlite::Result<Thread> {
Ok(Thread {
id: r.get(0)?,
desk_id: r.get(1)?,
name: r.get(2)?,
stage: r.get(3)?,
folder: r.get(4)?,
branch: r.get(5)?,
seen: r.get::<_, i64>(6)? != 0,
commits: r.get(7)?,
pr: r.get(8)?,
ci: r.get(9)?,
merged: r.get(10)?,
merged_at: r.get(11)?,
next: r.get(12)?,
by: r.get(13)?,
pane: r.get(14)?,
created_at: r.get(15)?,
moved_at: r.get(16)?,
shipped_at: r.get(17)?,
removed_at: r.get(18)?,
moved_by: r.get(19)?,
taken_at: r.get(20)?,
rest: String::new(),
notes: Vec::new(),
})
}
fn fill_notes(conn: &Connection, threads: &mut [Thread]) -> Result<()> {
let mut st = conn.prepare_cached(
"SELECT id FROM desk_notes WHERE thread_id = ?1 AND removed_at = 0 ORDER BY id",
)?;
for t in threads.iter_mut() {
t.notes = st
.query_map(params![t.id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
}
Ok(())
}
pub fn get(conn: &Connection, desk_id: i64, id: i64) -> Result<Option<Thread>> {
let t = conn
.query_row(
&format!("SELECT {THREAD_COLS} FROM threads WHERE desk_id = ?1 AND id = ?2"),
params![desk_id, id],
row_to_thread,
)
.optional()?;
let Some(t) = t else { return Ok(None) };
let mut v = [t];
fill_notes(conn, &mut v)?;
let [t] = v;
Ok(Some(t))
}
fn mark_rest(conn: &Connection, threads: &mut [Thread]) -> Result<()> {
let mut open = conn.prepare_cached("SELECT 1 FROM panes WHERE id = ?1 AND desk_id = ?2")?;
let mut later = conn.prepare_cached(
"SELECT 1 FROM threads WHERE desk_id = ?1 AND pane = ?2 AND removed_at = 0
AND (taken_at > ?3 OR (taken_at = ?3 AND id > ?4)) LIMIT 1",
)?;
for t in threads.iter_mut() {
t.rest = if t.stage == "parked" {
"parked"
} else if t.pane.is_empty() {
"no panel"
} else if !open.exists(params![t.pane, t.desk_id])? {
"panel closed"
} else if later.exists(params![t.desk_id, t.pane, t.taken_at, t.id])? {
"moved on"
} else {
""
}
.to_string();
}
Ok(())
}
fn listed(t: &Thread, now: i64) -> bool {
match t.rest.as_str() {
"" => true,
_ if t.stage == "shipped" => true,
"parked" => now - t.moved_at < PARKED_SHOWN,
_ => now - t.moved_at < RESTING_SHOWN,
}
}
pub fn for_desk(conn: &Connection, desk_id: i64, now: i64) -> Result<Vec<Thread>> {
let mut st = conn.prepare(&format!(
"SELECT {THREAD_COLS} FROM threads WHERE desk_id = ?1 AND removed_at = 0
ORDER BY moved_at DESC, id DESC LIMIT 40"
))?;
let mut v: Vec<Thread> = st
.query_map(params![desk_id], row_to_thread)?
.collect::<rusqlite::Result<_>>()?;
mark_rest(conn, &mut v)?;
v.retain(|t| listed(t, now));
fill_notes(conn, &mut v)?;
Ok(v)
}
pub fn across_desks(conn: &Connection, shipped_since: i64, now: i64) -> Result<Vec<Thread>> {
let mut st = conn.prepare(&format!(
"SELECT {} FROM threads t JOIN desks d ON d.id = t.desk_id
WHERE t.removed_at = 0 AND d.closed_at = 0 AND (t.stage != 'shipped' OR t.shipped_at >= ?1)
ORDER BY t.moved_at DESC, t.id DESC LIMIT 60",
THREAD_COLS
.split(',')
.map(|c| format!("t.{}", c.trim()))
.collect::<Vec<_>>()
.join(", ")
))?;
let mut v: Vec<Thread> = st
.query_map(params![shipped_since], row_to_thread)?
.collect::<rusqlite::Result<_>>()?;
mark_rest(conn, &mut v)?;
v.retain(|t| listed(t, now));
fill_notes(conn, &mut v)?;
Ok(v)
}
pub fn of_pane(conn: &Connection, desk_id: i64, pane: &str) -> Result<Option<Thread>> {
if pane.is_empty() {
return Ok(None);
}
let id: Option<i64> = conn
.query_row(
"SELECT id FROM threads WHERE desk_id = ?1 AND pane = ?2 AND removed_at = 0
ORDER BY taken_at DESC, id DESC LIMIT 1",
params![desk_id, pane],
|r| r.get(0),
)
.optional()?;
match id {
Some(id) => get(conn, desk_id, id),
None => Ok(None),
}
}
#[derive(Clone, Debug, Default)]
pub struct Start {
pub name: String,
pub notes: Vec<i64>,
pub folder: String,
pub stage: String,
pub by: String,
pub pane: String,
}
#[derive(Debug, PartialEq)]
pub enum Started {
New(Thread),
Again(Thread),
Full,
Empty,
BadStage,
NoSuchDesk,
}
fn link_notes(conn: &Connection, desk_id: i64, thread: i64, notes: &[i64]) -> Result<()> {
let mut st = conn.prepare_cached(
"UPDATE desk_notes SET thread_id = ?3 WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0
AND suggested_by = ''",
)?;
for n in notes.iter().take(40) {
st.execute(params![desk_id, n, thread])?;
}
Ok(())
}
pub fn start(conn: &mut Connection, desk_id: i64, s: &Start, now: i64) -> Result<Started> {
let name = line(&s.name, NAME_CHARS);
if name.is_empty() {
return Ok(Started::Empty);
}
let stage = if s.stage.is_empty() {
"planned"
} else {
s.stage.as_str()
};
if !STAGES.contains(&stage) {
return Ok(Started::BadStage);
}
let folder = line(&s.folder, PATH_CHARS);
let by = line(&s.by, 60);
let tx = conn.transaction()?;
if !desk_open(&tx, desk_id)? {
return Ok(Started::NoSuchDesk);
}
let same: Option<i64> = tx
.query_row(
"SELECT id FROM threads WHERE desk_id = ?1 AND removed_at = 0 AND stage != 'shipped'
AND lower(name) = lower(?2) ORDER BY id DESC LIMIT 1",
params![desk_id, name],
|r| r.get(0),
)
.optional()?;
let (id, again) = match same {
Some(id) => {
tx.execute(
"UPDATE threads SET pane = ?3, moved_by = ?3, moved_at = ?4, taken_at = ?4,
folder = CASE WHEN ?5 = '' THEN folder ELSE ?5 END,
stage = CASE WHEN ?6 = '' THEN stage ELSE ?6 END,
shipped_at = CASE WHEN ?6 = 'shipped' THEN ?4 ELSE shipped_at END
WHERE desk_id = ?1 AND id = ?2",
params![desk_id, id, s.pane, now, folder, s.stage],
)?;
(id, true)
}
None => {
let open: i64 = tx.query_row(
"SELECT COUNT(*) FROM threads t WHERE desk_id = ?1 AND removed_at = 0
AND stage NOT IN ('shipped', 'parked')
AND (pane = '' OR (pane IN (SELECT id FROM panes WHERE desk_id = ?1)
AND NOT EXISTS (SELECT 1 FROM threads l WHERE l.desk_id = ?1 AND l.pane = t.pane
AND l.removed_at = 0 AND (l.taken_at > t.taken_at OR (l.taken_at = t.taken_at AND l.id > t.id)))))",
params![desk_id],
|r| r.get(0),
)?;
if open >= THREADS_PER_DESK {
return Ok(Started::Full);
}
tx.execute(
"INSERT INTO threads(desk_id, name, stage, folder, by, pane, moved_by, created_at, moved_at, taken_at, shipped_at)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?7, ?7, CASE WHEN ?3 = 'shipped' THEN ?7 ELSE 0 END)",
params![desk_id, name, stage, folder, by, s.pane, now],
)?;
(tx.last_insert_rowid(), false)
}
};
link_notes(&tx, desk_id, id, &s.notes)?;
let t = get(&tx, desk_id, id)?.expect("the row just written");
tx.commit()?;
Ok(if again {
Started::Again(t)
} else {
Started::New(t)
})
}
#[derive(Clone, Debug, Default)]
pub struct Move {
pub stage: String,
pub next: String,
pub pr: String,
pub name: String,
pub notes: Vec<i64>,
pub pane: String,
pub reader: bool,
}
#[derive(Debug, PartialEq)]
pub enum Moved {
Thread(Box<Thread>),
BadStage,
BadPr,
NoThread,
}
pub fn move_thread(
conn: &mut Connection,
desk_id: i64,
id: Option<i64>,
m: &Move,
now: i64,
) -> Result<Moved> {
if !m.stage.is_empty() && !STAGES.contains(&m.stage.as_str()) {
return Ok(Moved::BadStage);
}
let pr = if m.pr.trim().is_empty() {
String::new()
} else {
match pr_number(&m.pr) {
Some(n) => n,
None => return Ok(Moved::BadPr),
}
};
let tx = conn.transaction()?;
let t = match id {
Some(id) => get(&tx, desk_id, id)?.filter(|t| t.removed_at == 0),
None => of_pane(&tx, desk_id, &m.pane)?,
};
let Some(t) = t else {
return Ok(Moved::NoThread);
};
let next = line(&m.next, 200);
let name = line(&m.name, NAME_CHARS);
let next = match m.stage.as_str() {
"parked" if next.is_empty() => t.next.clone(),
"parked" => next,
"" if t.stage == "parked" && !next.is_empty() => next,
"" => t.next.clone(),
_ => String::new(),
};
tx.execute(
"UPDATE threads SET
stage = CASE WHEN ?3 = '' THEN stage ELSE ?3 END,
next = ?4,
pr = CASE WHEN ?5 = '' THEN pr ELSE ?5 END,
name = CASE WHEN ?6 = '' THEN name ELSE ?6 END,
pane = CASE WHEN ?7 = '' THEN pane ELSE ?7 END,
taken_at = CASE WHEN ?7 = '' THEN taken_at ELSE ?8 END,
moved_by = ?9,
moved_at = ?8,
shipped_at = CASE WHEN ?3 = 'shipped' AND stage != 'shipped' THEN ?8
WHEN ?3 != '' AND ?3 != 'shipped' THEN 0 ELSE shipped_at END
WHERE desk_id = ?1 AND id = ?2",
params![
desk_id,
t.id,
m.stage,
next,
pr,
name,
m.pane,
now,
if m.reader { "" } else { m.pane.as_str() }
],
)?;
link_notes(&tx, desk_id, t.id, &m.notes)?;
let t = get(&tx, desk_id, t.id)?.expect("the row just written");
tx.commit()?;
Ok(Moved::Thread(Box::new(t)))
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct Seen {
pub branch: String,
pub commits: i64,
pub pr: String,
pub ci: String,
pub merged: String,
}
pub fn seen(
conn: &mut Connection,
desk_id: i64,
pane: &str,
s: &Seen,
now: i64,
) -> Result<Option<(Thread, bool)>> {
let tx = conn.transaction()?;
let Some(was) = of_pane(&tx, desk_id, pane)? else {
return Ok(None);
};
let t = &was;
let branch = if branch_ok(s.branch.trim()) {
s.branch.trim().to_string()
} else {
String::new()
};
let pr = pr_number(&s.pr).unwrap_or_default();
let ci = line(&s.ci, 30);
let merged = s.merged.trim().to_ascii_lowercase();
let merged = if crate::desk::commit_ok(&merged) {
merged
} else {
String::new()
};
tx.execute(
"UPDATE threads SET
commits = CASE WHEN ?3 != '' AND ?3 != branch THEN 0 ELSE commits END + ?4,
branch_seen = CASE WHEN ?3 != '' OR ?4 > 0 OR ?5 != '' THEN 1 ELSE branch_seen END,
branch = CASE WHEN ?3 = '' THEN branch ELSE ?3 END,
pr = CASE WHEN ?5 = '' THEN pr ELSE ?5 END,
ci = CASE WHEN ?6 = '' THEN ci ELSE ?6 END,
merged = CASE WHEN ?7 = '' THEN merged ELSE ?7 END,
merged_at = CASE WHEN ?7 != '' AND merged = '' THEN ?8 ELSE merged_at END
WHERE desk_id = ?1 AND id = ?2",
params![
desk_id,
t.id,
branch,
s.commits.clamp(0, 1000),
pr,
ci,
merged,
now
],
)?;
if was.merged.is_empty() && !merged.is_empty() {
let evidence = if crate::desk::evidence_ok(s.pr.trim()) {
s.pr.trim()
} else {
""
};
tx.execute(
"UPDATE desk_notes SET done_at = ?3, done_by = 'merged', done_commit = ?4, done_doc = '', done_evidence = ?5, done_pane = ?6
WHERE desk_id = ?1 AND thread_id = ?2 AND removed_at = 0 AND done_at = 0 AND suggested_by = ''",
params![desk_id, t.id, now, merged, evidence, pane],
)?;
}
let t = get(&tx, desk_id, t.id)?.expect("the row just written");
tx.commit()?;
let changed = (&t.branch, t.commits, &t.pr, &t.ci, &t.merged, &t.stage)
!= (
&was.branch,
was.commits,
&was.pr,
&was.ci,
&was.merged,
&was.stage,
);
Ok(Some((t, changed)))
}
pub fn remove(conn: &Connection, desk_id: i64, id: i64, now: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE threads SET removed_at = ?3 WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id, now],
)? > 0)
}
pub fn restore(conn: &Connection, desk_id: i64, id: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE threads SET removed_at = 0 WHERE desk_id = ?1 AND id = ?2",
params![desk_id, id],
)? > 0)
}
pub fn moved_since(conn: &Connection, desk_id: i64, pane: &str, since: i64) -> Result<Vec<Thread>> {
let mut st = conn.prepare(&format!(
"SELECT {THREAD_COLS} FROM threads WHERE desk_id = ?1 AND removed_at = 0
AND ((moved_at > ?2 AND moved_by != ?3) OR merged_at > ?2) ORDER BY moved_at, id LIMIT 8"
))?;
let v = st
.query_map(params![desk_id, since, pane], row_to_thread)?
.collect::<rusqlite::Result<_>>()?;
Ok(v)
}
const TURN_COLS: &str =
"id, desk_id, thread_id, pane, by, kind, via, text, options, recommended, link,
answer, answered_in, answered_at, told_at, created_at, removed_at, cmd";
fn row_to_turn(r: &rusqlite::Row) -> rusqlite::Result<Turn> {
let options: String = r.get(8)?;
Ok(Turn {
id: r.get(0)?,
desk_id: r.get(1)?,
thread_id: r.get(2)?,
pane: r.get(3)?,
by: r.get(4)?,
kind: r.get(5)?,
via: r.get(6)?,
text: r.get(7)?,
options: if options.is_empty() {
Vec::new()
} else {
options.split('\n').map(String::from).collect()
},
recommended: r.get(9)?,
link: r.get(10)?,
answer: r.get(11)?,
answered_in: r.get(12)?,
answered_at: r.get(13)?,
told_at: r.get(14)?,
created_at: r.get(15)?,
removed_at: r.get(16)?,
cmd: r.get(17)?,
})
}
pub fn turn(conn: &Connection, desk_id: i64, id: i64) -> Result<Option<Turn>> {
Ok(conn
.query_row(
&format!("SELECT {TURN_COLS} FROM turns WHERE desk_id = ?1 AND id = ?2"),
params![desk_id, id],
row_to_turn,
)
.optional()?)
}
pub fn turns(conn: &Connection, desk_id: i64, answered_since: i64) -> Result<Vec<Turn>> {
let mut st = conn.prepare(&format!(
"SELECT {TURN_COLS} FROM turns WHERE desk_id = ?1 AND removed_at = 0
AND (answered_at = 0 OR answered_at >= ?2 OR (kind = 'decide' AND thread_id != 0))
ORDER BY answered_at != 0, id LIMIT 60"
))?;
let v = st
.query_map(params![desk_id, answered_since], row_to_turn)?
.collect::<rusqlite::Result<_>>()?;
Ok(v)
}
pub fn waiting_on(conn: &Connection, desk_id: i64, dialog: bool) -> Result<Vec<Turn>> {
let via = if dialog { "" } else { " AND via != 'dialog'" };
let mut st = conn.prepare(&format!(
"SELECT {TURN_COLS} FROM turns WHERE desk_id = ?1 AND answered_at = 0 AND removed_at = 0{via}
ORDER BY id LIMIT 60"
))?;
let v = st
.query_map(params![desk_id], row_to_turn)?
.collect::<rusqlite::Result<_>>()?;
Ok(v)
}
pub fn waiting(conn: &Connection) -> Result<Vec<Turn>> {
let cols = TURN_COLS
.split(',')
.map(|c| format!("t.{}", c.trim()))
.collect::<Vec<_>>()
.join(", ");
let mut st = conn.prepare(&format!(
"SELECT {cols} FROM turns t JOIN desks d ON d.id = t.desk_id
WHERE t.removed_at = 0 AND t.answered_at = 0 AND d.closed_at = 0 ORDER BY t.id LIMIT 30"
))?;
let v = st
.query_map([], row_to_turn)?
.collect::<rusqlite::Result<_>>()?;
Ok(v)
}
#[derive(Clone, Debug, Default)]
pub struct Ask {
pub kind: String,
pub text: String,
pub options: Vec<String>,
pub recommended: i64,
pub link: String,
pub via: String,
pub by: String,
pub pane: String,
pub cmd: String,
}
#[derive(Debug, PartialEq)]
pub enum Asked {
Turn(Box<Turn>),
Full,
Empty,
BadOptions,
BadKind,
BadCmd,
NoSuchDesk,
}
fn command(cmd: &str) -> Option<&str> {
let c = cmd.trim();
(!c.is_empty() && c.len() <= CMD_BYTES && !c.chars().any(char::is_control)).then_some(c)
}
pub fn ask(conn: &mut Connection, desk_id: i64, a: &Ask, now: i64) -> Result<Asked> {
if !KINDS.contains(&a.kind.as_str()) {
return Ok(Asked::BadKind);
}
let text = line(&a.text, TEXT_CHARS);
if text.is_empty() {
return Ok(Asked::Empty);
}
let options: Vec<String> = a
.options
.iter()
.map(|o| line(o, OPTION_CHARS).replace('\n', " "))
.filter(|o| !o.is_empty())
.collect();
let ok = match a.kind.as_str() {
"decide" => (2..=4).contains(&options.len()),
_ => options.is_empty(),
};
if !ok {
return Ok(Asked::BadOptions);
}
let recommended = if (0..options.len() as i64).contains(&a.recommended) {
a.recommended
} else {
-1
};
let cmd = if a.kind == "run" {
match command(&a.cmd) {
Some(c) => c,
None => return Ok(Asked::BadCmd),
}
} else {
""
};
let link = line(&a.link, 400);
let via = if a.via == "dialog" { "dialog" } else { "ask" };
let by = line(&a.by, 60);
let tx = conn.transaction()?;
if !desk_open(&tx, desk_id)? {
return Ok(Asked::NoSuchDesk);
}
let waiting: i64 = tx.query_row(
"SELECT COUNT(*) FROM turns WHERE desk_id = ?1 AND removed_at = 0 AND answered_at = 0",
params![desk_id],
|r| r.get(0),
)?;
if waiting >= TURNS_PER_DESK {
return Ok(Asked::Full);
}
let thread = of_pane(&tx, desk_id, &a.pane)?
.filter(|t| t.stage != "shipped")
.map(|t| t.id)
.unwrap_or(0);
tx.execute(
"INSERT INTO turns(desk_id, thread_id, pane, by, kind, via, text, options, recommended, link, created_at, cmd)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
desk_id,
thread,
a.pane,
by,
a.kind,
via,
text,
options.join("\n"),
recommended,
link,
now,
cmd
],
)?;
let id = tx.last_insert_rowid();
let t = turn(&tx, desk_id, id)?.expect("the row just written");
tx.commit()?;
Ok(Asked::Turn(Box::new(t)))
}
pub fn answer(
conn: &Connection,
desk_id: i64,
id: i64,
answer: &str,
in_: &str,
now: i64,
) -> Result<Option<Turn>> {
let answer = line(answer, TEXT_CHARS);
if answer.is_empty() {
return Ok(None);
}
let in_ = if in_ == "panel" { "panel" } else { "snyvi" };
let Some(t) = turn(conn, desk_id, id)? else {
return Ok(None);
};
if t.answered_at != 0 || t.removed_at != 0 {
return Ok(None);
}
let told = if in_ == "panel" || t.via == "dialog" {
now
} else {
0
};
let n = conn.execute(
"UPDATE turns SET answer = ?3, answered_in = ?4, answered_at = ?5, told_at = ?6
WHERE desk_id = ?1 AND id = ?2 AND answered_at = 0",
params![desk_id, id, answer, in_, now, told],
)?;
if n == 0 {
return Ok(None);
}
turn(conn, desk_id, id)
}
pub fn drop_dialog(conn: &Connection, desk_id: i64, id: i64, now: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE turns SET removed_at = ?3 WHERE desk_id = ?1 AND id = ?2 AND via = 'dialog'
AND answered_at = 0 AND removed_at = 0",
params![desk_id, id, now],
)? > 0)
}
pub fn remove_turn(conn: &Connection, desk_id: i64, id: i64, now: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE turns SET removed_at = ?3 WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id, now],
)? > 0)
}
pub fn restore_turn(conn: &Connection, desk_id: i64, id: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE turns SET removed_at = 0 WHERE desk_id = ?1 AND id = ?2",
params![desk_id, id],
)? > 0)
}
pub fn take_untold(
conn: &Connection,
desk_id: i64,
pane: &str,
live: &[String],
now: i64,
) -> Result<Vec<Turn>> {
let mut st = conn.prepare(&format!(
"SELECT {TURN_COLS} FROM turns WHERE desk_id = ?1 AND removed_at = 0
AND answered_at != 0 AND told_at = 0 ORDER BY answered_at, id LIMIT 8"
))?;
let all: Vec<Turn> = st
.query_map(params![desk_id], row_to_turn)?
.collect::<rusqlite::Result<_>>()?;
let mine: Vec<Turn> = all
.into_iter()
.filter(|t| t.pane == pane || !live.contains(&t.pane))
.collect();
let mut up = conn.prepare_cached("UPDATE turns SET told_at = ?2 WHERE id = ?1")?;
for t in &mine {
up.execute(params![t.id, now])?;
}
Ok(mine)
}
const SUG_COLS: &str =
"id, desk_id, kind, name, cmd, folder, why, by, pane, created_at, settled_at, outcome";
fn row_to_suggestion(r: &rusqlite::Row) -> rusqlite::Result<Suggestion> {
Ok(Suggestion {
id: r.get(0)?,
desk_id: r.get(1)?,
kind: r.get(2)?,
name: r.get(3)?,
cmd: r.get(4)?,
folder: r.get(5)?,
why: r.get(6)?,
by: r.get(7)?,
pane: r.get(8)?,
created_at: r.get(9)?,
settled_at: r.get(10)?,
outcome: r.get(11)?,
})
}
pub fn suggestion(conn: &Connection, desk_id: i64, id: i64) -> Result<Option<Suggestion>> {
Ok(conn
.query_row(
&format!("SELECT {SUG_COLS} FROM desk_suggestions WHERE desk_id = ?1 AND id = ?2"),
params![desk_id, id],
row_to_suggestion,
)
.optional()?)
}
pub fn suggestions(conn: &Connection, desk_id: i64) -> Result<Vec<Suggestion>> {
let mut st = conn.prepare(&format!(
"SELECT {SUG_COLS} FROM desk_suggestions WHERE desk_id = ?1 AND settled_at = 0 ORDER BY id"
))?;
let v = st
.query_map(params![desk_id], row_to_suggestion)?
.collect::<rusqlite::Result<_>>()?;
Ok(v)
}
#[derive(Clone, Debug, Default)]
pub struct Suggest {
pub kind: String,
pub name: String,
pub cmd: String,
pub folder: String,
pub why: String,
pub by: String,
pub pane: String,
}
#[derive(Debug, PartialEq)]
pub enum Suggested {
Card(Box<Suggestion>),
Full,
Empty,
HasDesk(i64),
NoSuchDesk,
}
pub fn suggest(conn: &mut Connection, desk_id: i64, s: &Suggest, now: i64) -> Result<Suggested> {
let kind = match s.kind.as_str() {
"desk" => "desk",
"widget" => "widget",
_ => "panel",
};
let name = line(&s.name, NAME_CHARS);
let cmd = line(&s.cmd, CMD_CHARS);
let folder = line(&s.folder, PATH_CHARS);
let why = line(&s.why, 200);
let by = line(&s.by, 60);
if why.is_empty()
|| (kind == "panel" && cmd.is_empty())
|| (kind == "desk" && folder.is_empty())
|| (kind == "widget" && (name.is_empty() || folder.is_empty()))
{
return Ok(Suggested::Empty);
}
let tx = conn.transaction()?;
if !desk_open(&tx, desk_id)? {
return Ok(Suggested::NoSuchDesk);
}
if kind == "desk" {
let trimmed = folder.trim_end_matches('/');
let has: Option<i64> = tx
.query_row(
"SELECT id FROM desks WHERE closed_at = 0 AND rtrim(root, '/') = ?1 LIMIT 1",
params![trimmed],
|r| r.get(0),
)
.optional()?;
if let Some(d) = has {
return Ok(Suggested::HasDesk(d));
}
}
let waiting: i64 = tx.query_row(
"SELECT COUNT(*) FROM desk_suggestions WHERE desk_id = ?1 AND settled_at = 0",
params![desk_id],
|r| r.get(0),
)?;
if waiting >= SUGGESTIONS_PER_DESK {
return Ok(Suggested::Full);
}
tx.execute(
"INSERT INTO desk_suggestions(desk_id, kind, name, cmd, folder, why, by, pane, created_at)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![desk_id, kind, name, cmd, folder, why, by, s.pane, now],
)?;
let id = tx.last_insert_rowid();
let card = suggestion(&tx, desk_id, id)?.expect("the row just written");
tx.commit()?;
Ok(Suggested::Card(Box::new(card)))
}
pub fn settle(
conn: &Connection,
desk_id: i64,
id: i64,
outcome: &str,
now: i64,
) -> Result<Option<Suggestion>> {
let outcome = if outcome == "opened" {
"opened"
} else {
"dismissed"
};
let n = conn.execute(
"UPDATE desk_suggestions SET settled_at = ?3, outcome = ?4 WHERE desk_id = ?1 AND id = ?2
AND settled_at = 0",
params![desk_id, id, now, outcome],
)?;
if n == 0 {
return Ok(None);
}
suggestion(conn, desk_id, id)
}
pub fn unsettle(conn: &Connection, desk_id: i64, id: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE desk_suggestions SET settled_at = 0, outcome = '' WHERE desk_id = ?1 AND id = ?2
AND outcome = 'dismissed'",
params![desk_id, id],
)? > 0)
}
pub fn take_opened(
conn: &Connection,
desk_id: i64,
pane: &str,
now: i64,
) -> Result<Vec<Suggestion>> {
let mut st = conn.prepare(&format!(
"SELECT {SUG_COLS} FROM desk_suggestions WHERE desk_id = ?1 AND pane = ?2
AND outcome = 'opened' AND told_at = 0 ORDER BY settled_at, id LIMIT 4"
))?;
let v: Vec<Suggestion> = st
.query_map(params![desk_id, pane], row_to_suggestion)?
.collect::<rusqlite::Result<_>>()?;
let mut up = conn.prepare_cached("UPDATE desk_suggestions SET told_at = ?2 WHERE id = ?1")?;
for s in &v {
up.execute(params![s.id, now])?;
}
Ok(v)
}
#[cfg(test)]
mod tests;