use std::borrow::Cow; use camino::{Utf8Path, Utf8PathBuf}; use crate::{ JournalName, Settings, WikError, error::FileOp, fs::{self, EitherPath, Open, WikFile, WikPath}, git::GitContext, journal::{Journal, JournalLine}, table::{SectionData, TableEntry, Tables, Tags}, }; pub enum CommitMessage<'a> { None, Borrow(&'a str), Owned(String), } impl<'a> Into>> for CommitMessage<'a> { fn into(self) -> Option> { match self { Self::None => None, Self::Borrow(s) => Some(Cow::Borrowed(s)), Self::Owned(own) => Some(Cow::Owned(own)), } } } impl<'a, T: Into>> From> for CommitMessage<'a> { fn from(opt: Option) -> Self { match opt { None => Self::None, Some(msg) => msg.into(), } } } impl From for CommitMessage<'static> { fn from(owned: String) -> Self { Self::Owned(owned) } } impl<'a> From<&'a str> for CommitMessage<'a> { fn from(s: &'a str) -> Self { Self::Borrow(s) } } pub struct Source { pub path: WikPath, pub data: String, } pub enum PathType { Page, Section, } pub struct Wikarden { root: Utf8PathBuf, journal: Journal, tables: Tables, settings: Settings, } impl Wikarden { const DRAFT_DIR: &'static str = ".wikarden/drafts"; pub async fn new>( root: Root, settings: Settings, ) -> Result { let root = root.into(); Self::init(&root)?; Ok(Self { root: root.clone(), journal: Journal::init(&root).await?, tables: Tables::new(&root).await?, settings, }) } fn init(root: &Utf8Path) -> Result<(), WikError> { let draft_path = root.join(Self::DRAFT_DIR); if let Err(_e) = std::fs::create_dir_all(&draft_path) { Err(WikError::DirectoryCreation { path: draft_path }) } else { Ok(()) } } pub async fn refresh_sections(&self) -> Result<(), WikError> { let res = read_sections_from_fs(&self.root).await; match res { Err(e) => WikError::file_operation(FileOp::Read, self.root.clone(), e), Ok(sections) => { let entries = sections .into_iter() .map(|n| TableEntry::new(n, SectionData::default())) .collect(); self.tables.replace_sections(entries).await } } } pub async fn sections(&self) -> Vec { self.tables .get_sections() .await .into_iter() .map(|te| te.into_name()) .collect() } pub async fn journal_tail(&self, kind: JournalName, count: usize) -> Vec { self.journal.journal_tail(kind, count).await } pub async fn path_type>(&self, path: P) -> Option { let path = path.into(); match self.page_exists(path.clone()).await { Ok(true) => return Some(PathType::Page), Ok(false) => match self.section_exists(path.clone()).await { false => None, true => Some(PathType::Section), }, Err(WikError::SectionNoexist { .. }) => None, Err(_) => unreachable!(), } } // assumes the post stable stays up to date pub async fn page_exists>(&self, path: P) -> Result { let path = path.into(); self.check_parent_section(&path).await?; Ok(self.tables.has_page(path).await) } // assumes the section table stays up to date pub async fn section_exists>(&self, path: P) -> bool { self.tables.has_section(path.into()).await } //MARK: Page Handling pub async fn source>(&self, path: P) -> Result { let path = path.into(); let file = self.file_from_path(path.clone(), Open::Read).await?; let content = file.read_to_string().await?; Ok(Source { path, data: content, }) } pub async fn create( &self, path: Path, content: Content, commit: Message, ) -> Result<(), WikError> where Path: Into, Content: AsRef, Message: Into>, { let path = path.into(); if self.page_exists(path.clone()).await? { return Err(WikError::page_exists(path)); } let mut file = self.file_from_path(path.clone(), Open::Create).await?; file.write_str(content.as_ref()).await?; file.done().await?; self.tables.add_page(path.as_ref(), Tags::default()).await?; self.journal .push(JournalName::Created, path.clone()) .await?; self.git_commit_file( path.clone(), commit.into(), format!("created page '{path}'"), ) } pub async fn move_page, ToPath: Into>( &self, from: FromPath, to: ToPath, ) -> Result<(), WikError> { let from = from.into(); let to = to.into(); if !self.page_exists(from.clone()).await? { return Err(WikError::page_noexists(from)); } if self.page_exists(to.clone()).await? { return Err(WikError::page_exists(to)); } let safe_from = EitherPath::from(from.clone()).safe(&self.root)?; let safe_to = EitherPath::safe_nonexist(&self.root, &to)?; if let Err(e) = tokio::fs::rename(safe_from.0, &safe_to.0).await { return WikError::file_operation(FileOp::Write, safe_to.0, e); } self.journal .rename(JournalName::Created, from.clone(), to.clone()) .await?; self.journal .rename(JournalName::Modified, from.clone(), to.clone()) .await?; self.tables .posts() .modify_entry(from.as_ref(), |te| te.set_name(to.to_string())) .await?; Ok(()) } pub async fn modify( &self, path: Path, new_content: Content, commit: Message, ) -> Result<(), WikError> where Path: Into, Content: AsRef, Message: Into>, { let path = path.into(); if !self.page_exists(path.clone()).await? { return Err(WikError::page_noexists(path)); } let mut file = self.file_from_path(path.clone(), Open::Write).await?; file.write_str(new_content.as_ref()).await?; file.done().await?; self.journal .push(JournalName::Modified, path.clone()) .await?; self.git_commit_file(path.clone(), commit.into(), format!("edited page '{path}'")) } pub async fn delete>(&self, path: P) -> Result<(), WikError> { let path = path.into(); if !self.page_exists(path.clone()).await? { return Err(WikError::page_noexists(path)); } let safe = EitherPath::Wik(path.clone()).safe(&self.root)?; WikFile::remove(safe).await?; self.tables.del_page(&path).await?; self.journal.del(JournalName::Created, path.clone()).await?; self.journal .del(JournalName::Modified, path.clone()) .await?; self.git_commit_file( path.clone(), CommitMessage::None, format!("deleted page '{path}'"), ) } pub async fn create_section>( &self, path: P, commit: Option>, ) -> Result<(), WikError> { let path = path.into(); self.check_parent_section(&path).await?; if self.tables.has_section(&path).await { return Err(WikError::section_exists(path)); } let system_path = EitherPath::safe_nonexist(&self.root, &path)?; fs::create_dir(system_path).await?; self.tables .add_section(path.as_ref(), SectionData::default()) .await?; let wikarden_message = format!("created section '{path}'"); let message = Self::make_commit_message(commit, wikarden_message); let git = self.get_git(); self.finish_git(git, message) } pub async fn get_section_data>( &self, path: P, ) -> Result { let path = path.into(); match self.tables.sections().entry_data(path.as_ref()).await { None => Err(WikError::section_noexist(path)), Some(data) => Ok(data), } } pub async fn modify_section_data, F>( &self, path: P, commit: Option>, f: F, ) -> Result<(), WikError> where F: FnOnce(&mut SectionData), { let path = path.into(); if !self.section_exists(path.clone()).await { return Err(WikError::section_noexist(path)); } let sections = self.tables.sections(); sections.modify_entry_data(path.as_ref(), f).await?; let wikarden_message = format!("modified section '{path}'"); let message = Self::make_commit_message(commit, wikarden_message); let git = self.get_git(); self.finish_git(git, message) } //MARK: Utilities async fn file_from_path>( &self, path: P, flags: Open, ) -> Result { let safe = EitherPath::safe_nonexist(&self.root, &path.into())?; WikFile::open(safe.0, flags).await } async fn check_parent_section(&self, path: &WikPath) -> Result<(), WikError> { if let Some(parent) = path.parent() { if !self.section_exists(parent.clone()).await { return Err(WikError::section_noexist(parent)); } } Ok(()) } /// Creates a new git commit, including the provided path. It also commits /// the tables and journals fn git_commit_file>( &self, path: P, user_message: CommitMessage<'static>, our_message: String, ) -> Result<(), WikError> { let safe = path.into().safe(&self.root)?; let mut git = self.get_git(); git.add(&safe)?; let message = Self::make_commit_message(user_message.into(), our_message.into()); self.finish_git(git, message) } fn make_commit_message( user_message: Option>, our_message: String, ) -> Cow<'static, str> { match user_message { None => Cow::Owned(our_message), Some(user) => Cow::Owned(format!("{user}\n\n{our_message}")), } } /// Creates a new [GitContext] with the Tables and Journal already added /// to the commit fn get_git(&self) -> GitContext { let mut git = GitContext::open(&self.root); Tables::git_add(&mut git); self.journal.git_add(&mut git); git } /// Writes the index and then makes a commit with the provided string fn finish_git(&self, mut git: GitContext, commit: Cow<'static, str>) -> Result<(), WikError> { git.write_index()?; git.commit(self.settings.signature(), commit)?; Ok(()) } } /// Reads the filesystem looking for sections. async fn read_sections_from_fs(root: &Utf8Path) -> Result, std::io::Error> { let mut sections = vec![]; let mut dirs = tokio::fs::read_dir(root).await?; while let Some(entry) = dirs.next_entry().await? { let name = entry.file_name(); let path = entry.path(); if !name.to_string_lossy().starts_with(".") { let meta = entry.metadata().await?; if meta.is_dir() { match path.to_str() { None => (), Some(s) => { let stripped = s.strip_prefix(&format!("{root}/")).unwrap(); sections.push(stripped.to_owned()); let subsec = Box::pin(read_sections_from_fs(s.into())).await?; sections.extend(subsec.iter().map(|s| format!("{stripped}/{s}"))); } } } } } return Ok(sections); }