Skip to main content

falcon_library/
text.rs

1//! Saving a text (`.txt`) item edited in the app's text viewer.
2//!
3//! Eagle's text editor writes the new text to a temporary file beside the
4//! original (`<name>.txt.<ms>`) and moves it over the original, so a
5//! half-written file is never left in place. It refuses to save empty
6//! text. It leaves the item's `metadata.json` alone, so `size` and
7//! `mtime` go stale; falcon refreshes them (and `lastModified`, as every
8//! item edit does) so the inspector and size filters stay right. `btime`
9//! and `modificationTime` keep their values.
10//!
11//! A text item also carries the start of its text in the item record, under
12//! the `text` key, as Eagle's text importer writes it: the grid draws the
13//! card from it instead of a thumbnail (there is no `_thumbnail.png`), and
14//! search reads it. [`preview`] makes that value.
15
16use std::fs;
17use std::io::{Read, Write};
18use std::path::Path;
19
20use crate::error::{Error, Result};
21use crate::library::Library;
22
23/// The item key holding the start of a text item's text.
24pub const TEXT_KEY: &str = "text";
25
26/// How much text the record keeps: 32 Ki UTF-16 code units, as Eagle's
27/// importer cuts it.
28pub const PREVIEW_UNITS: usize = 32 * 1024;
29
30/// The keys of a text item's `metadata.json` in the order Eagle writes
31/// them: no `width`, `height` or `palettes`, and `text` just before
32/// `lastModified`.
33pub const RECORD_KEYS: [&str; 14] =
34    ["id", "name", "size", "btime", "mtime", "ext", "tags", "folders", "isDeleted", "url", "annotation", "modificationTime", "text", "lastModified"];
35
36/// The start of `text` that fits in [`PREVIEW_UNITS`] UTF-16 code units,
37/// never splitting a character.
38pub fn cut(text: &str) -> &str {
39    let mut units = 0;
40    for (at, c) in text.char_indices() {
41        units += c.len_utf16();
42        if units > PREVIEW_UNITS {
43            return &text[..at];
44        }
45    }
46    text
47}
48
49/// The value of [`TEXT_KEY`] for the text file at `path`: its start, read
50/// as UTF-8 with invalid bytes replaced, as Eagle reads it.
51pub fn preview(path: &Path) -> std::io::Result<String> {
52    // Every character takes at most 4 bytes, so this is always enough.
53    let mut bytes = Vec::new();
54    fs::File::open(path)?.take(PREVIEW_UNITS as u64 * 4).read_to_end(&mut bytes)?;
55    Ok(cut(&String::from_utf8_lossy(&bytes)).to_string())
56}
57
58impl Library {
59    /// Replaces the text of the `.txt` item `id` with `text` and refreshes
60    /// its `size` and `mtime`. Empty text is refused, as in Eagle. The
61    /// save is not recorded in an open undo transaction: the text has its
62    /// own undo in the editor, and reverting only the metadata would leave
63    /// it describing a file it no longer matches.
64    pub fn save_text(&mut self, id: &str, text: &str) -> Result<()> {
65        let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
66        if !item.ext.eq_ignore_ascii_case("txt") {
67            return Err(self.invalid(format!("{}.{} is not a text file", item.name, item.ext)));
68        }
69        if text.is_empty() {
70            return Err(self.invalid("empty text is not saved"));
71        }
72        let raw = item.original_path(self.root());
73        let stamp = crate::edit::now_ms();
74        let tmp = raw.with_file_name(crate::disk_name::on_disk(&format!("{}.{}.{stamp}", item.name, item.ext)).as_ref());
75        let written = (|| -> std::io::Result<()> {
76            let mut f = fs::File::create(&tmp)?;
77            f.write_all(text.as_bytes())?;
78            f.sync_all()?;
79            fs::rename(&tmp, &raw)
80        })();
81        if let Err(e) = written {
82            let _ = fs::remove_file(&tmp);
83            return Err(Error::io(&raw, e));
84        }
85        let meta = fs::metadata(&raw).map_err(|e| Error::io(&raw, e))?;
86        item.size = meta.len();
87        item.mtime = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(item.mtime, |d| d.as_millis() as u64);
88        // The card shows the new text.
89        item.extra.insert(TEXT_KEY.into(), cut(text).into());
90        let journal = self.journal.take();
91        let r = self.update_item(item);
92        self.journal = journal;
93        r
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::import::ImportOptions;
101
102    fn library_with_text(body: &str) -> (tempfile::TempDir, Library, String) {
103        let tmp = tempfile::tempdir().unwrap();
104        let src = tmp.path().join("Notes.txt");
105        fs::write(&src, body).unwrap();
106        let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
107        let id = lib.import_file(&src, &ImportOptions::default()).unwrap();
108        (tmp, lib, id)
109    }
110
111    #[test]
112    fn saving_replaces_the_file_and_refreshes_size_and_mtime() {
113        let (_tmp, mut lib, id) = library_with_text("hello");
114        let before = lib.item(&id).unwrap().clone();
115        lib.save_text(&id, "hello, longer world").unwrap();
116        let after = lib.item(&id).unwrap().clone();
117        let raw = after.original_path(lib.root());
118        assert_eq!(fs::read_to_string(&raw).unwrap(), "hello, longer world");
119        assert_eq!(after.size, 19);
120        assert!(after.mtime >= before.mtime);
121        assert_eq!(after.btime, before.btime, "the birth time is kept");
122        assert_eq!(after.modification_time, before.modification_time, "the import time is kept");
123        assert!(after.last_modified > before.last_modified);
124        // Nothing is left beside the original.
125        let names: Vec<String> = fs::read_dir(after.info_dir(lib.root())).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
126        assert_eq!(names.iter().filter(|n| n.starts_with("Notes.txt")).count(), 1, "{names:?}");
127        // And it reads back from disk the same way.
128        let reopened = Library::open(lib.root()).unwrap();
129        assert_eq!(reopened.item(&id).unwrap().size, 19);
130    }
131
132    #[test]
133    fn empty_text_and_other_formats_are_refused() {
134        let (_tmp, mut lib, id) = library_with_text("keep me");
135        assert!(lib.save_text(&id, "").is_err());
136        let raw = lib.item(&id).unwrap().original_path(lib.root());
137        assert_eq!(fs::read_to_string(raw).unwrap(), "keep me");
138        assert!(lib.save_text("nope", "x").is_err());
139    }
140
141    #[test]
142    fn saving_puts_the_new_text_on_the_card() {
143        let (_tmp, mut lib, id) = library_with_text("first");
144        assert_eq!(lib.item(&id).unwrap().extra[TEXT_KEY], "first");
145        lib.save_text(&id, "second draft").unwrap();
146        let reopened = Library::open(lib.root()).unwrap();
147        assert_eq!(reopened.item(&id).unwrap().extra[TEXT_KEY], "second draft");
148    }
149
150    #[test]
151    fn the_preview_stops_at_32_ki_utf16_units_without_splitting_a_character() {
152        let ascii = "a".repeat(PREVIEW_UNITS + 10);
153        assert_eq!(cut(&ascii).len(), PREVIEW_UNITS);
154        // An emoji takes two units: the one that would straddle the limit goes.
155        let wide = format!("{}{}", "a".repeat(PREVIEW_UNITS - 1), "😀");
156        assert_eq!(cut(&wide), "a".repeat(PREVIEW_UNITS - 1));
157        assert_eq!(cut("short"), "short");
158    }
159
160    #[test]
161    fn the_preview_reads_invalid_bytes_as_replacement_characters() {
162        let tmp = tempfile::tempdir().unwrap();
163        let p = tmp.path().join("x.txt");
164        fs::write(&p, b"ok \xff end").unwrap();
165        assert_eq!(preview(&p).unwrap(), "ok \u{fffd} end");
166    }
167
168    #[test]
169    fn saves_stay_out_of_the_undo_history() {
170        let (_tmp, mut lib, id) = library_with_text("one");
171        lib.begin_transaction("typing");
172        lib.save_text(&id, "two").unwrap();
173        assert!(lib.end_transaction().is_none());
174    }
175}