use crate::model::{cover, media, workspace::Workspace};
use artifact::{article as layout, article::properties};
use bezel::{
gpui::{App, AppContext as _, Context, Entity, ScrollHandle},
ui::input::{Shape, TextField},
};
use editor::{Editor, Mode};
use markdown::Typography;
use std::{
cmp::Reverse,
path::{Path, PathBuf},
};
use url::Url;
const UNTITLED: &str = "untitled";
pub const UNNAMED: &str = "Untitled";
pub const TITLE_CONTEXT: &str = "CydoniaArticleTitle";
pub struct Article {
pub number: Option<u64>,
pub path: PathBuf,
pub cover: Option<PathBuf>,
pub title: String,
pub field: Option<Entity<TextField>>,
pub editor: Option<Entity<Editor>>,
pub scroll: ScrollHandle,
mode: Mode,
saved: String,
pub touched: u128,
pub archived: bool,
pub full_width: Option<bool>,
pub stale: bool,
}
impl Article {
fn new(path: PathBuf) -> Self {
let held = properties::all(&path);
Self {
number: path.ancestors().nth(4).and_then(|project| {
artifact::entry::number(project, "article", &layout::id_of(&path)).ok()
}),
cover: cover::of(&path),
title: held.title,
touched: layout::touched(&path),
archived: held.archived,
full_width: held.full_width,
path,
field: None,
editor: None,
scroll: ScrollHandle::new(),
mode: Mode::default(),
saved: String::new(),
stale: false,
}
}
pub fn archive(&mut self, archived: bool) {
self.archived = archived;
properties::set_archived(&self.path, archived);
}
pub fn set_full_width(&mut self, wide: Option<bool>) {
self.full_width = wide;
properties::set_full_width(&self.path, wide);
}
pub fn wide(&self, default: bool) -> bool {
self.full_width.unwrap_or(default)
}
pub fn set_mode(&mut self, mode: Mode, cx: &mut Context<Workspace>) {
self.mode = mode;
if let Some(editor) = &self.editor {
editor.update(cx, |editor, cx| editor.set_mode(mode, cx));
}
}
pub fn mode(&self, cx: &App) -> Mode {
match &self.editor {
Some(editor) => editor.read(cx).mode(),
None => self.mode,
}
}
pub fn label(&self) -> &str {
match self.title.is_empty() {
true => UNNAMED,
false => &self.title,
}
}
pub fn open(&mut self, text_size: f32, cx: &mut Context<Workspace>) {
media::aim(Some(&self.path));
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_text_size(text_size)
.with_scroll(scroll)
.with_mode(self.mode)
});
let mut source_digits = self.saved.split('\n').count().to_string().len();
cx.observe(&editor, move |workspace, editor, cx| {
workspace.write_article(editor.entity_id(), cx);
if editor.read(cx).mode() == Mode::Source {
let digits = editor
.read(cx)
.source()
.split('\n')
.count()
.to_string()
.len();
if digits != source_digits {
source_digits = digits;
cx.notify();
}
}
})
.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 = artifact::stamp::now();
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 = artifact::stamp::now();
self.stale = false;
}
}
renamed
}
pub fn keep(&mut self, cx: &App) {
self.write(cx);
self.stale = false;
}
pub fn adopt(&mut self, fresh: &Self, cx: &mut Context<Workspace>) -> bool {
self.number = fresh.number;
self.cover = fresh.cover.clone();
self.archived = fresh.archived;
self.full_width = fresh.full_width;
self.touched = fresh.touched;
if self.editor.is_none() {
self.title = fresh.title.clone();
return false;
}
let disk = std::fs::read_to_string(&self.path).unwrap_or_default();
if disk == self.saved && fresh.title == self.title {
return false;
}
if self.edited(cx) {
self.stale = true;
return false;
}
self.revert(cx);
true
}
pub fn revert(&mut self, cx: &mut Context<Workspace>) {
self.mode = self.mode(cx);
let held = properties::all(&self.path);
self.title = held.title;
self.touched = layout::touched(&self.path);
self.cover = cover::of(&self.path);
self.archived = held.archived;
self.full_width = held.full_width;
let text_size = self
.editor
.as_ref()
.and_then(|editor| editor.read(cx).text_size())
.unwrap_or_else(bezel::theme::base_text_size);
self.field = None;
self.editor = None;
self.open(text_size, cx);
self.stale = false;
}
fn edited(&self, cx: &App) -> bool {
self.editor
.as_ref()
.is_some_and(|editor| editor.read(cx).source() != self.saved)
|| self
.field
.as_ref()
.is_some_and(|field| *field.read(cx).content() != self.title)
}
pub fn remove(&self) {
if let Some(dir) = self.path.parent() {
if std::fs::remove_dir_all(dir).is_ok()
&& let Some(project) = self.path.ancestors().nth(4)
{
let _ = artifact::entry::Registry::open(project)
.and_then(|registry| registry.remove("article", &layout::id_of(&self.path)));
}
}
}
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);
}
}
}
impl From<&Article> for layout::Article {
fn from(article: &Article) -> Self {
Self {
id: layout::id_of(&article.path),
title: article.title.clone(),
archived: article.archived,
touched: article.touched,
cover: article
.cover
.as_deref()
.and_then(|file| Url::from_file_path(file).ok()),
}
}
}
pub fn list(project: &Path) -> Vec<Article> {
migrate(project);
let Ok(entries) = std::fs::read_dir(layout::dir(project)) else {
return Vec::new();
};
let paths: Vec<PathBuf> = entries
.flatten()
.map(|entry| layout::content(&entry.path()))
.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 = layout::init(project).ok()?;
let article = layout::free(&dir, artifact::stamp::now());
std::fs::create_dir_all(&article).ok()?;
let path = layout::content(&article);
std::fs::write(&path, "").ok()?;
Some(Article::new(path))
}
fn migrate(project: &Path) {
let Ok(entries) = std::fs::read_dir(layout::dir(project)) 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 = layout::free(&layout::dir(project), artifact::stamp::of(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 = layout::content(&to);
if std::fs::rename(path, &content).is_ok() && !stem.starts_with(UNTITLED) {
properties::set_title(&content, stem);
}
}
}