use crate::model::project;
use serde::{Deserialize, Serialize};
use std::{
cmp::Reverse,
path::{Path, PathBuf},
};
const DIR: &str = "boards";
const FILE: &str = "board.toml";
const NAMED: &str = "Board";
pub const UNNAMED: &str = "Untitled";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Board {
#[serde(skip)]
pub path: PathBuf,
#[serde(skip)]
pub touched: u128,
#[serde(default)]
pub archived: bool,
#[serde(default)]
pub name: String,
#[serde(default)]
pub columns: Vec<Column>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
pub name: String,
#[serde(default)]
pub cards: Vec<Card>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Card {
pub text: String,
#[serde(skip)]
pub session: Option<u64>,
}
impl Board {
fn new(path: PathBuf, name: &str) -> Self {
Self {
path,
touched: project::stamp(),
archived: false,
name: name.to_owned(),
columns: ["Todo", "Doing", "Done"].map(Column::new).into(),
}
}
pub fn label(&self) -> &str {
match self.name.is_empty() {
true => UNNAMED,
false => &self.name,
}
}
pub fn column(&self, ix: usize) -> Option<&Column> {
self.columns.get(ix)
}
pub fn card(&self, at: Spot) -> Option<&Card> {
self.column(at.column)?.cards.get(at.card)
}
pub fn card_mut(&mut self, at: Spot) -> Option<&mut Card> {
self.columns.get_mut(at.column)?.cards.get_mut(at.card)
}
pub fn take(&mut self, at: Spot) -> Option<Card> {
let column = self.columns.get_mut(at.column)?;
(at.card < column.cards.len()).then(|| column.cards.remove(at.card))
}
pub fn save(&mut self) {
if let Ok(body) = toml::to_string_pretty(self) {
let _ = std::fs::write(&self.path, body);
self.touched = project::stamp();
}
}
pub fn remove(&self) {
let _ = std::fs::remove_file(&self.path);
}
}
impl Column {
fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
cards: Vec::new(),
}
}
}
impl Card {
pub fn new(text: String) -> Self {
Self {
text,
session: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spot {
pub column: usize,
pub card: usize,
}
impl Spot {
pub fn new(column: usize, card: usize) -> Self {
Self { column, card }
}
}
pub fn list(project: &Path) -> Vec<Board> {
let dir = project::dir(project);
migrate(&dir);
let Ok(entries) = std::fs::read_dir(dir.join(DIR)) else {
return Vec::new();
};
let paths: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
.collect();
let mut boards: Vec<Board> = paths.into_iter().filter_map(read).collect();
boards.sort_by_key(|board| Reverse(board.touched));
boards
}
pub fn create(project: &Path) -> Option<Board> {
let dir = project::init(project).ok()?.join(DIR);
std::fs::create_dir_all(&dir).ok()?;
let mut board = Board::new(free(&dir, project::stamp()), NAMED);
board.save();
Some(board)
}
fn read(path: PathBuf) -> Option<Board> {
let body = std::fs::read_to_string(&path).ok()?;
let mut board: Board = toml::from_str(&body).ok()?;
board.touched = project::written(&path);
board.path = path;
Some(board)
}
fn free(dir: &Path, stamp: u128) -> PathBuf {
(stamp..)
.map(|stamp| dir.join(format!("{stamp}.toml")))
.find(|board| !board.exists())
.unwrap_or_else(|| dir.join(format!("{stamp}.toml")))
}
fn migrate(dir: &Path) {
let old = dir.join(FILE);
let Some(mut board) = read(old.clone()) else {
return;
};
let to = dir.join(DIR);
if std::fs::create_dir_all(&to).is_err() {
return;
}
board.path = free(&to, project::stamp());
board.name = NAMED.to_owned();
board.save();
let _ = std::fs::remove_file(old);
}