1use std::fs;
17use std::io::{Read, Write};
18use std::path::Path;
19
20use crate::error::{Error, Result};
21use crate::library::Library;
22
23pub const TEXT_KEY: &str = "text";
25
26pub const PREVIEW_UNITS: usize = 32 * 1024;
29
30pub const RECORD_KEYS: [&str; 14] =
34 ["id", "name", "size", "btime", "mtime", "ext", "tags", "folders", "isDeleted", "url", "annotation", "modificationTime", "text", "lastModified"];
35
36pub 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
49pub fn preview(path: &Path) -> std::io::Result<String> {
52 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 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 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 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 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 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}