1use anyhow::{anyhow, bail, Context, Result};
2
3use std::ffi::OsStr;
4use std::fs::metadata;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, Instant, SystemTime};
8
9use crate::common::{
10 filename_from_path, hash_path, path_to_string, FFMPEG, FONTIMAGE, LIBREOFFICE, PDFINFO,
11 PDFTOPPM, RSVG_CONVERT, THUMBNAIL_PATH_NO_EXT, THUMBNAIL_PATH_PNG, TMP_THUMBNAILS_DIR,
12};
13use crate::io::{execute_and_capture_output, execute_and_output_no_log};
14use crate::log_info;
15use crate::modes::ExtensionKind;
16
17#[derive(Default)]
22pub enum Kind {
23 Font,
24 Image,
25 Office,
26 Pdf,
27 Svg,
28 Video,
29 #[default]
30 Unknown,
31}
32
33impl Kind {
34 fn allow_multiples(&self) -> bool {
35 matches!(self, Self::Pdf)
36 }
37
38 pub fn for_first_line(&self) -> &str {
39 match self {
40 Self::Font => "a font",
41 Self::Image => "an image",
42 Self::Office => "an office document",
43 Self::Pdf => "a pdf",
44 Self::Svg => "an svg image",
45 Self::Video => "a video",
46 Self::Unknown => "Unknown",
47 }
48 }
49}
50
51impl From<ExtensionKind> for Kind {
52 fn from(kind: ExtensionKind) -> Self {
53 match &kind {
54 ExtensionKind::Font => Self::Font,
55 ExtensionKind::Image => Self::Image,
56 ExtensionKind::Office => Self::Office,
57 ExtensionKind::Pdf => Self::Pdf,
58 ExtensionKind::Svg => Self::Svg,
59 ExtensionKind::Video => Self::Video,
60 _ => Self::Unknown,
61 }
62 }
63}
64impl std::fmt::Display for Kind {
65 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
66 match self {
67 Self::Font => write!(f, "font"),
68 Self::Image => write!(f, "image"),
69 Self::Office => write!(f, "office"),
70 Self::Pdf => write!(f, "pdf"),
71 Self::Svg => write!(f, "svg"),
72 Self::Unknown => write!(f, "unknown"),
73 Self::Video => write!(f, "video"),
74 }
75 }
76}
77
78pub fn path_is_video<P: AsRef<Path>>(path: P) -> bool {
80 let Some(ext) = path.as_ref().extension() else {
81 return false;
82 };
83 matches!(
84 ext.to_string_lossy().as_ref(),
85 "mkv" | "webm" | "mpeg" | "mp4" | "avi" | "flv" | "mpg" | "wmv" | "m4v" | "mov"
86 )
87}
88
89pub struct DisplayedImage {
92 since: Instant,
93 pub kind: Kind,
94 pub identifier: String,
95 pub images: Vec<PathBuf>,
96 length: usize,
97 pub index: usize,
98}
99
100impl DisplayedImage {
101 fn new(kind: Kind, identifier: String, images: Vec<PathBuf>) -> Self {
102 let index = 0;
103 let length = images.len();
104 let since = Instant::now();
105 Self {
106 since,
107 kind,
108 identifier,
109 images,
110 length,
111 index,
112 }
113 }
114
115 pub fn filepath(&self) -> Arc<Path> {
116 Arc::from(self.images[self.index].as_path())
117 }
118
119 pub fn up_one_row(&mut self) {
121 if self.kind.allow_multiples() && self.index > 0 {
122 self.index -= 1;
123 }
124 }
125
126 pub fn down_one_row(&mut self) {
128 if self.kind.allow_multiples() && self.index + 1 < self.len() {
129 self.index += 1;
130 }
131 }
132
133 pub fn len(&self) -> usize {
135 self.length
136 }
137
138 pub fn is_empty(&self) -> bool {
139 self.len() == 0
140 }
141
142 fn video_index(&self) -> usize {
143 let elapsed = self.since.elapsed().as_secs() as usize;
144 elapsed % self.images.len()
145 }
146
147 fn image_index(&self) -> usize {
148 if matches!(self.kind, Kind::Video) {
149 self.video_index()
150 } else {
151 self.index
152 }
153 }
154
155 pub fn selected_path(&self) -> std::borrow::Cow<'_, str> {
157 self.images[self.image_index()].to_string_lossy()
158 }
159}
160
161pub struct DisplayedImageBuilder {
164 kind: Kind,
165 source: PathBuf,
166}
167
168impl DisplayedImageBuilder {
169 pub fn video_thumbnails(hashed_path: &str) -> [String; 4] {
170 [
171 format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_1.jpg"),
172 format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_2.jpg"),
173 format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_3.jpg"),
174 format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_4.jpg"),
175 ]
176 }
177
178 pub fn new(source: &Path, kind: Kind) -> Self {
179 let source = source.to_path_buf();
180 Self { source, kind }
181 }
182
183 pub fn build(self) -> Result<DisplayedImage> {
184 match &self.kind {
185 Kind::Font => self.build_font(),
186 Kind::Image => self.build_image(),
187 Kind::Office => self.build_office(),
188 Kind::Pdf => self.build_pdf(),
189 Kind::Svg => self.build_svg(),
190 Kind::Video => self.build_video(),
191 _ => Err(anyhow!("Unknown kind {kind}", kind = self.kind)),
192 }
193 }
194
195 fn build_office(self) -> Result<DisplayedImage> {
196 let calc_str = path_to_string(&self.source);
197 Self::convert_office_to_pdf(&calc_str)?;
198 let pdf = Self::office_to_pdf_filename(
199 self.source
200 .file_name()
201 .context("couldn't extract filename")?,
202 )?;
203 if !pdf.exists() {
204 bail!("couldn't convert {calc_str} to pdf");
205 }
206 let identifier = filename_from_path(&pdf)?.to_owned();
207 Thumbnail::create(&self.kind, pdf.to_string_lossy().as_ref());
208 let images = Self::make_pdf_images_paths(Self::get_pdf_length(&pdf)?)?;
209 std::fs::remove_file(&pdf)?;
210
211 Ok(DisplayedImage::new(Kind::Pdf, identifier, images))
212 }
213
214 fn convert_office_to_pdf(calc_str: &str) -> Result<std::process::Output> {
215 let args = ["--convert-to", "pdf", "--outdir", "/tmp", calc_str];
216 execute_and_output_no_log(LIBREOFFICE, args)
217 }
218
219 fn office_to_pdf_filename(filename: &OsStr) -> Result<PathBuf> {
220 let mut pdf_path = PathBuf::from("/tmp");
221 pdf_path.push(filename);
222 pdf_path.set_extension("pdf");
223 Ok(pdf_path)
224 }
225
226 fn make_pdf_images_paths(length: usize) -> Result<Vec<PathBuf>> {
227 let images = (1..length + 1)
228 .map(|index| PathBuf::from(format!("{THUMBNAIL_PATH_NO_EXT}-{index}.jpg")))
229 .filter(|p| p.exists())
230 .collect();
231 Ok(images)
232 }
233
234 fn get_pdf_length(path: &Path) -> Result<usize> {
235 let output =
236 execute_and_capture_output(PDFINFO, &[path.to_string_lossy().to_string().as_ref()])?;
237 let line = output.lines().find(|line| line.starts_with("Pages: "));
238
239 match line {
240 Some(line) => {
241 let page_count_str = line.split_whitespace().nth(1).unwrap();
242 let page_count = page_count_str.parse::<usize>()?;
243 log_info!(
244 "pdf {path} has {page_count_str} pages",
245 path = path.display()
246 );
247 Ok(page_count)
248 }
249 None => Err(anyhow!("Couldn't find the page number")),
250 }
251 }
252
253 fn build_pdf(self) -> Result<DisplayedImage> {
254 let length = Self::get_pdf_length(&self.source)?;
255 let identifier = filename_from_path(&self.source)?.to_owned();
256 Thumbnail::create(&self.kind, self.source.to_string_lossy().as_ref());
257 let images = Self::make_pdf_images_paths(length)?;
258 log_info!("build_pdf images: {images:?}");
259 Ok(DisplayedImage::new(self.kind, identifier, images))
260 }
261
262 fn build_video(self) -> Result<DisplayedImage> {
263 let path_str = self
264 .source
265 .to_str()
266 .context("make_thumbnail: couldn't parse the path into a string")?;
267 Thumbnail::create(&self.kind, path_str);
268 let hashed_path = hash_path(path_str);
269 let images: Vec<PathBuf> = Self::video_thumbnails(&hashed_path)
270 .map(PathBuf::from)
271 .into_iter()
272 .filter(|p| p.exists())
273 .collect();
274 let identifier = filename_from_path(&self.source)?.to_owned();
275 Ok(DisplayedImage::new(self.kind, identifier, images))
276 }
277
278 fn build_single_image(self, images: Vec<PathBuf>) -> Result<DisplayedImage> {
279 let identifier = filename_from_path(&self.source)?.to_owned();
280 Ok(DisplayedImage::new(self.kind, identifier, images))
281 }
282
283 fn build_font(self) -> Result<DisplayedImage> {
284 let path_str = self
285 .source
286 .to_str()
287 .context("make_thumbnail: couldn't parse the path into a string")?;
288 Thumbnail::create(&self.kind, path_str);
289 let p = PathBuf::from(THUMBNAIL_PATH_PNG);
290 let images = if p.exists() { vec![p] } else { vec![] };
291 self.build_single_image(images)
292 }
293
294 fn build_image(self) -> Result<DisplayedImage> {
295 let images = vec![self.source.clone()];
296 self.build_single_image(images)
297 }
298
299 fn build_svg(self) -> Result<DisplayedImage> {
300 let path_str = self
301 .source
302 .to_str()
303 .context("make_thumbnail: couldn't parse the path into a string")?;
304 Thumbnail::create(&self.kind, path_str);
305 let p = PathBuf::from(THUMBNAIL_PATH_PNG);
306 let images = if p.exists() { vec![p] } else { vec![] };
307 self.build_single_image(images)
308 }
309}
310
311pub struct Thumbnail;
313
314impl Thumbnail {
315 fn create(kind: &Kind, path_str: &str) {
316 let _ = match kind {
317 Kind::Font => Self::create_font(path_str),
318 Kind::Office => Self::create_office(path_str),
319 Kind::Pdf => Self::create_pdf(path_str),
320 Kind::Svg => Self::create_svg(path_str),
321 Kind::Video => Self::create_video(path_str),
322
323 _ => Ok(()),
324 };
325 }
326
327 fn create_font(path_str: &str) -> Result<()> {
328 Self::execute(FONTIMAGE, &["-o", THUMBNAIL_PATH_PNG, path_str])
329 }
330
331 fn create_office(path_str: &str) -> Result<()> {
332 Self::create_pdf(path_str)
333 }
334
335 fn create_svg(path_str: &str) -> Result<()> {
336 Self::execute(
337 RSVG_CONVERT,
338 &["--keep-aspect-ratio", path_str, "-o", THUMBNAIL_PATH_PNG],
339 )
340 }
341
342 pub fn create_video(path_str: &str) -> Result<()> {
343 let rand = hash_path(path_str);
344 let images_paths = DisplayedImageBuilder::video_thumbnails(&rand);
345 if Path::new(&images_paths[0]).exists() && !is_older_than_a_week(&images_paths[0]) {
346 return Ok(());
347 }
348 for image in &images_paths {
349 let _ = std::fs::remove_file(image);
350 }
351 let ffmpeg_filename = format!("{TMP_THUMBNAILS_DIR}/{rand}_%d.jpg",);
352
353 let ffmpeg_args = [
354 "-i",
355 path_str,
356 "-an",
357 "-sn",
358 "-vf",
359 "fps=1/10,scale=320:-1",
360 "-threads",
361 "2",
362 "-frames:v",
363 "4",
364 &ffmpeg_filename,
365 ];
367 Self::execute(FFMPEG, &ffmpeg_args)
368 }
369
370 fn create_pdf(path_str: &str) -> Result<()> {
371 Self::execute(
372 PDFTOPPM,
373 &[
374 "-jpeg",
375 "-jpegopt",
376 "quality=75",
377 path_str,
378 THUMBNAIL_PATH_NO_EXT,
379 ],
380 )
381 }
382
383 fn execute(exe: &str, args: &[&str]) -> Result<()> {
384 let output = execute_and_output_no_log(exe, args.to_owned())?;
385 log_info!(
386 "make thumbnail error: {}",
387 String::from_utf8(output.stderr).unwrap_or_default()
389 );
390 Ok(())
391 }
392}
393
394const ONE_WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
395
396fn is_older_than_a_week(path: &str) -> bool {
397 let Ok(metadata) = metadata(path) else {
398 return true;
399 };
400 let Ok(creation) = metadata.created() else {
401 return true;
402 };
403 let current_time = SystemTime::now();
404 let Ok(elapsed_since_creation) = current_time.duration_since(creation) else {
405 return true;
406 };
407 elapsed_since_creation > ONE_WEEK
408}