1use std::path::{Path, PathBuf};
29
30use fig::Value;
31use flower_core::{Model, Schema, Seg, ViewMode};
32use leaf_core::{Doc, Format as BodyFormat};
33use prov::{Document, MetaCarrier};
34
35use crate::ProvBackend;
36
37static EMPTY_META: Value = Value::Null;
41
42#[derive(Debug)]
45pub struct SessionError(pub String);
46
47impl std::fmt::Display for SessionError {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.write_str(&self.0)
50 }
51}
52
53impl std::error::Error for SessionError {}
54
55fn se(e: impl std::fmt::Display) -> SessionError {
56 SessionError(e.to_string())
57}
58
59pub struct DocumentSession {
62 path: PathBuf,
63 metadata: Model<ProvBackend>,
64 body: Doc,
65 has_body: bool,
68 saved_body: String,
70}
71
72fn body_format_of(path: &Path) -> BodyFormat {
79 match prov::ContentFormat::from_extension(path) {
80 Some(prov::ContentFormat::Djot) => BodyFormat::Djot,
81 Some(prov::ContentFormat::Html) => BodyFormat::Html,
82 Some(prov::ContentFormat::Markdown) | None => BodyFormat::Markdown,
83 }
84}
85
86impl DocumentSession {
87 pub fn open(path: impl Into<PathBuf>) -> Result<Self, SessionError> {
90 let path = path.into();
91 let format = body_format_of(&path);
92 Self::open_with(path, format, None)
93 }
94
95 pub fn open_with_schema(
97 path: impl Into<PathBuf>,
98 schema: Schema,
99 ) -> Result<Self, SessionError> {
100 let path = path.into();
101 let format = body_format_of(&path);
102 Self::open_with(path, format, Some(schema))
103 }
104
105 pub fn open_managed(
122 path: impl Into<PathBuf>,
123 schema: Option<Schema>,
124 derived: Vec<String>,
125 ) -> Result<Self, SessionError> {
126 let path = path.into();
127 let format = body_format_of(&path);
128 let text = std::fs::read_to_string(&path)
129 .map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
130 Self::build(path, &text, format, schema, derived)
131 }
132
133 pub fn open_with(
136 path: impl Into<PathBuf>,
137 body_format: BodyFormat,
138 schema: Option<Schema>,
139 ) -> Result<Self, SessionError> {
140 let path = path.into();
141 let text = std::fs::read_to_string(&path)
142 .map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
143 Self::from_text(path, &text, body_format, schema)
144 }
145
146 pub fn from_text(
150 path: impl Into<PathBuf>,
151 text: &str,
152 body_format: BodyFormat,
153 schema: Option<Schema>,
154 ) -> Result<Self, SessionError> {
155 Self::build(path, text, body_format, schema, Vec::new())
156 }
157
158 fn build(
160 path: impl Into<PathBuf>,
161 text: &str,
162 body_format: BodyFormat,
163 schema: Option<Schema>,
164 derived: Vec<String>,
165 ) -> Result<Self, SessionError> {
166 let path = path.into();
167 let parsed = Document::parse(&path, text).map_err(se)?;
168 let has_body = matches!(parsed.carrier, Some(MetaCarrier::Fenced(_)));
169
170 let backend = match schema {
171 Some(schema) => ProvBackend::open_with_schema(&path, text, schema),
172 None => ProvBackend::open(&path, text),
173 }
174 .map_err(se)?;
175 let metadata = Model::with_managed(backend, Vec::new(), derived).map_err(se)?;
180 let body = Doc::from_source(parsed.body, body_format).map_err(se)?;
181 let saved_body = body.source.clone();
182
183 Ok(Self {
184 path,
185 metadata,
186 body,
187 has_body,
188 saved_body,
189 })
190 }
191
192 pub fn path(&self) -> &Path {
193 &self.path
194 }
195
196 pub fn has_body(&self) -> bool {
198 self.has_body
199 }
200
201 pub fn metadata(&self) -> &Model<ProvBackend> {
203 &self.metadata
204 }
205
206 pub fn metadata_mut(&mut self) -> &mut Model<ProvBackend> {
207 &mut self.metadata
208 }
209
210 pub fn meta(&self) -> &Value {
216 self.metadata.value_at(&[]).unwrap_or(&EMPTY_META)
217 }
218
219 pub fn cursor_path(&self) -> Option<Vec<Seg>> {
228 match self.metadata.view() {
229 ViewMode::Pages => self.metadata.page_item().map(|item| item.path.clone()),
230 _ => self.metadata.selected_path(),
231 }
232 }
233
234 pub fn body(&self) -> &Doc {
236 &self.body
237 }
238
239 pub fn body_mut(&mut self) -> &mut Doc {
240 &mut self.body
241 }
242
243 pub fn set_metadata(&mut self, path: &[Seg], value: Value) {
246 self.metadata.set_value_at(path, value);
247 }
248
249 pub fn dirty(&self) -> bool {
251 self.metadata.dirty || (self.has_body && self.body.source != self.saved_body)
252 }
253
254 pub fn reassemble(&mut self) -> Result<String, SessionError> {
257 if self.has_body {
258 let body = self.body.source.clone();
259 self.metadata.backend_mut().set_body(&body).map_err(se)?;
260 }
261 Ok(self.metadata.source_snapshot())
262 }
263
264 pub fn save(&mut self) -> Result<(), SessionError> {
266 let full = self.reassemble()?;
267 std::fs::write(&self.path, full.as_bytes())
268 .map_err(|e| SessionError(format!("writing {}: {e}", self.path.display())))?;
269 self.saved_body = self.body.source.clone();
270 self.metadata.mark_saved();
271 Ok(())
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 const DOC: &str = "\
280---
281# the title
282title: Old Title
283draft: true
284---
285# Heading
286
287Original body.
288";
289
290 fn preview_of(session: &DocumentSession, key: &str) -> Option<String> {
291 session
292 .metadata()
293 .rows
294 .iter()
295 .find(|r| r.path == [Seg::Key(key.into())])
296 .map(|r| r.preview.clone())
297 }
298
299 #[test]
300 fn reassembles_both_edits_without_disk() {
301 let mut session =
302 DocumentSession::from_text("note.md", DOC, BodyFormat::Markdown, None).unwrap();
303 assert!(session.has_body());
304 assert!(!session.dirty());
305
306 session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
308 session.body_mut().insert("Edited: ");
309 assert!(session.dirty());
310
311 let out = session.reassemble().unwrap();
312 assert!(out.contains("title: New Title"), "metadata edit:\n{out}");
313 assert!(out.contains("# the title"), "frontmatter comment:\n{out}");
314 assert!(out.contains("draft: true"), "sibling key:\n{out}");
315 assert!(out.contains("Edited: "), "body edit:\n{out}");
316 assert!(out.contains("Original body."), "rest of body:\n{out}");
317 assert!(out.starts_with("---\n"), "fences:\n{out}");
318 }
319
320 #[test]
323 fn a_managed_key_is_drawn_and_declines_every_edit() {
324 const WITH_ID: &str = "---\ntitle: A Note\nid: ajp7eq\nmood: rainy\n---\n# Note\n";
325 let path = std::env::temp_dir().join("provui_core_session_managed.md");
326 let _ = std::fs::remove_file(&path);
327 std::fs::write(&path, WITH_ID).unwrap();
328
329 let facets = crate::Facets::default();
330 let mut session =
331 DocumentSession::open_managed(&path, None, facets.managed_key_names()).unwrap();
332
333 assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
335 assert!(session.metadata().is_derived(&[Seg::Key("id".into())]));
336
337 session.set_metadata(&[Seg::Key("id".into())], Value::Str("typed".into()));
339 assert!(!session.dirty(), "a derived key takes no edit");
340 assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
341
342 session.set_metadata(&[Seg::Key("mood".into())], Value::Str("clear".into()));
344 assert!(session.dirty());
345
346 let _ = std::fs::remove_file(&path);
347 }
348
349 #[test]
355 fn open_edit_save_reopen_round_trip_on_disk() {
356 let path = std::env::temp_dir().join("provui_core_document_session_round_trip.md");
357 let _ = std::fs::remove_file(&path);
358 std::fs::write(&path, DOC).unwrap();
359
360 let mut session = DocumentSession::open(&path).unwrap();
362 assert_eq!(preview_of(&session, "title").as_deref(), Some("Old Title"));
363 session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
364 session.body_mut().insert("Edited: ");
365 session.save().unwrap();
366 assert!(!session.dirty(), "clean after save");
367
368 let saved = std::fs::read_to_string(&path).unwrap();
370 assert!(
371 saved.contains("title: New Title"),
372 "saved metadata:\n{saved}"
373 );
374 assert!(saved.contains("# the title"), "saved comment:\n{saved}");
375 assert!(saved.contains("Edited: "), "saved body:\n{saved}");
376 assert!(
377 saved.contains("Original body."),
378 "saved body rest:\n{saved}"
379 );
380
381 let reopened = DocumentSession::open(&path).unwrap();
383 assert_eq!(preview_of(&reopened, "title").as_deref(), Some("New Title"));
384 assert!(reopened.body().source.contains("Edited: "));
385
386 let _ = std::fs::remove_file(&path);
387 }
388}