use crate::model::{cover, project, properties, workspace::Workspace};
use bezel::{
gpui::{App, AppContext as _, Context, Entity, ScrollHandle},
ui::input::{Shape, TextField},
};
use editor::Editor;
use markdown::Typography;
use std::{
cmp::Reverse,
path::{Path, PathBuf},
};
const UNTITLED: &str = "untitled";
pub const UNNAMED: &str = "Untitled";
pub const TITLE_CONTEXT: &str = "CydoniaArticleTitle";
const DIR: &str = "articles";
const CONTENT: &str = "content.md";
pub struct Article {
pub path: PathBuf,
pub cover: Option<PathBuf>,
pub title: String,
pub field: Option<Entity<TextField>>,
pub editor: Option<Entity<Editor>>,
pub scroll: ScrollHandle,
saved: String,
pub touched: u128,
pub archived: bool,
}
impl Article {
fn new(path: PathBuf) -> Self {
Self {
cover: cover::of(&path),
title: properties::title(&path),
touched: project::written(&path),
archived: properties::archived(&path),
path,
field: None,
editor: None,
scroll: ScrollHandle::new(),
saved: String::new(),
}
}
pub fn archive(&mut self, archived: bool) {
self.archived = archived;
properties::set_archived(&self.path, archived);
}
pub fn rename(&mut self, title: &str, cx: &mut App) {
self.title = title.to_owned();
self.touched = project::stamp();
properties::set_title(&self.path, title);
if let Some(field) = &self.field {
field.update(cx, |field, cx| field.set_content(title.to_owned(), cx));
}
}
pub fn label(&self) -> &str {
match self.title.is_empty() {
true => UNNAMED,
false => &self.title,
}
}
pub fn open(&mut self, cx: &mut Context<Workspace>) {
if self.editor.is_some() {
return;
}
let h1 = Typography::of(cx).h1;
let title = self.title.clone();
let field = cx.new(|cx| {
let mut field = TextField::new(cx)
.with_frame(false)
.with_shape(Shape::Line)
.with_key_context(TITLE_CONTEXT)
.with_placeholder(UNNAMED)
.with_metrics(h1);
field.set_content(title, cx);
field
});
cx.observe(&field, |workspace, field, cx| {
workspace.write_article(field.entity_id(), cx);
})
.detach();
self.saved = std::fs::read_to_string(&self.path).unwrap_or_default();
let scroll = self.scroll.clone();
let editor = cx.new(|cx| Editor::new(&self.saved, cx).with_scroll(scroll));
cx.observe(&editor, |workspace, editor, cx| {
workspace.write_article(editor.entity_id(), cx);
})
.detach();
self.field = Some(field);
self.editor = Some(editor);
}
pub fn write(&mut self, cx: &App) -> bool {
let renamed = match &self.field {
Some(field) => {
let title = field.read(cx).content().to_string();
let moved = self.title != title;
if moved {
self.title = title;
self.touched = project::stamp();
properties::set_title(&self.path, &self.title);
}
moved
}
None => false,
};
if let Some(editor) = &self.editor {
let source = editor.read(cx).source();
if self.saved != source && std::fs::write(&self.path, &source).is_ok() {
self.saved = source;
self.touched = project::stamp();
}
}
renamed
}
pub fn remove(&self) {
if let Some(dir) = self.path.parent() {
let _ = std::fs::remove_dir_all(dir);
}
}
pub fn set_cover(&mut self, source: Option<&Path>) {
let Some(source) = source else {
self.replace_cover(None);
return;
};
let seed = cover::seed(&self.path, self.cover.as_deref());
let to = source
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase)
.and_then(|ext| cover::path(&self.path, seed, &ext));
if let Some(to) = to.filter(|to| cover::import(source, to).is_ok()) {
self.replace_cover(Some(to));
}
}
pub fn shuffle_cover(&mut self) {
let seed = cover::seed(&self.path, self.cover.as_deref());
let Some(to) = cover::path(&self.path, seed, "svg") else {
return;
};
if std::fs::write(&to, cover::svg(seed)).is_ok() {
self.replace_cover(Some(to));
}
}
fn replace_cover(&mut self, next: Option<PathBuf>) {
let previous = std::mem::replace(&mut self.cover, next);
if let Some(old) = previous.filter(|old| Some(old) != self.cover.as_ref()) {
let _ = std::fs::remove_file(old);
}
}
}
pub fn list(project: &Path) -> Vec<Article> {
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().join(CONTENT))
.filter(|path| path.is_file())
.collect();
let mut articles: Vec<Article> = paths.into_iter().map(Article::new).collect();
articles.sort_by_key(|article| Reverse(article.touched));
articles
}
pub fn create(project: &Path) -> Option<Article> {
let dir = project::init(project).ok()?.join(DIR);
let article = free(&dir, project::stamp());
std::fs::create_dir_all(&article).ok()?;
let path = article.join(CONTENT);
std::fs::write(&path, "").ok()?;
Some(Article::new(path))
}
fn free(dir: &Path, stamp: u128) -> PathBuf {
(stamp..)
.map(|stamp| dir.join(stamp.to_string()))
.find(|article| !article.exists())
.unwrap_or_else(|| dir.join(stamp.to_string()))
}
fn migrate(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let loose: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
for path in loose
.iter()
.filter(|path| path.extension().is_some_and(|ext| ext == "md"))
{
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let to = free(&dir.join(DIR), project::written(path));
if std::fs::create_dir_all(&to).is_err() {
continue;
}
let worn = format!("{stem}.");
for cover in loose.iter().filter_map(|cover| {
cover
.file_name()
.and_then(|name| name.to_str())?
.strip_prefix(&worn)
.filter(|tail| tail.starts_with("cover-"))
.map(|tail| (cover, tail))
}) {
let _ = std::fs::rename(cover.0, to.join(cover.1));
}
let content = to.join(CONTENT);
if std::fs::rename(path, &content).is_ok() && !stem.starts_with(UNTITLED) {
properties::set_title(&content, stem);
}
}
}