Skip to main content

cadmus_core/document/
mod.rs

1pub mod djvu;
2pub mod epub;
3pub mod html;
4pub mod pdf;
5
6mod djvulibre_sys;
7mod mupdf_sys;
8
9use self::djvu::DjvuOpener;
10use self::epub::EpubDocument;
11use self::html::HtmlDocument;
12use self::pdf::PdfOpener;
13use crate::device::CURRENT_DEVICE;
14use crate::framebuffer::Pixmap;
15use crate::geom::{Boundary, CycleDir};
16use crate::metadata::{Annotation, TextAlign};
17use crate::settings::{FileExtension, INTERNAL_CARD_ROOT};
18use anyhow::{format_err, Error};
19use fxhash::FxHashMap;
20use nix::sys::statvfs;
21#[cfg(target_os = "linux")]
22use nix::sys::sysinfo;
23use regex::Regex;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeSet;
26use std::env;
27use std::ffi::OsStr;
28use std::fs::{self, File};
29use std::os::unix::fs::FileExt;
30use std::path::Path;
31use std::process::Command;
32use tracing::{error, warn};
33use unicode_normalization::char::is_combining_mark;
34use unicode_normalization::UnicodeNormalization;
35
36pub const BYTES_PER_PAGE: f64 = 2048.0;
37
38#[derive(Debug, Clone)]
39pub enum Location {
40    Exact(usize),
41    Previous(usize),
42    Next(usize),
43    LocalUri(usize, String),
44    Uri(String),
45}
46
47#[derive(Debug, Clone)]
48pub struct BoundedText {
49    pub text: String,
50    pub rect: Boundary,
51    pub location: TextLocation,
52}
53
54#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum TextLocation {
57    Static(usize, usize),
58    Dynamic(usize),
59}
60
61impl TextLocation {
62    pub fn location(self) -> usize {
63        match self {
64            TextLocation::Static(page, _) => page,
65            TextLocation::Dynamic(offset) => offset,
66        }
67    }
68
69    #[inline]
70    pub fn min_max(self, other: Self) -> (Self, Self) {
71        if self < other {
72            (self, other)
73        } else {
74            (other, self)
75        }
76    }
77}
78
79#[derive(Debug, Clone)]
80pub struct TocEntry {
81    pub title: String,
82    pub location: Location,
83    pub index: usize,
84    pub children: Vec<TocEntry>,
85}
86
87#[derive(Debug, Clone)]
88pub struct Neighbors {
89    pub previous_page: Option<usize>,
90    pub next_page: Option<usize>,
91}
92
93pub trait Document: Send + Sync {
94    fn dims(&self, index: usize) -> Option<(f32, f32)>;
95    fn pages_count(&self) -> usize;
96
97    fn toc(&mut self) -> Option<Vec<TocEntry>>;
98    fn chapter<'a>(&mut self, offset: usize, toc: &'a [TocEntry]) -> Option<(&'a TocEntry, f32)>;
99    fn chapter_relative<'a>(
100        &mut self,
101        offset: usize,
102        dir: CycleDir,
103        toc: &'a [TocEntry],
104    ) -> Option<&'a TocEntry>;
105    fn words(&mut self, loc: Location) -> Option<(Vec<BoundedText>, usize)>;
106    fn lines(&mut self, loc: Location) -> Option<(Vec<BoundedText>, usize)>;
107    fn links(&mut self, loc: Location) -> Option<(Vec<BoundedText>, usize)>;
108    fn images(&mut self, loc: Location) -> Option<(Vec<Boundary>, usize)>;
109
110    fn pixmap(&mut self, loc: Location, scale: f32, samples: usize) -> Option<(Pixmap, usize)>;
111    fn layout(&mut self, width: u32, height: u32, font_size: f32, dpi: u16);
112    fn set_font_family(&mut self, family_name: &str, search_path: &str);
113    fn set_margin_width(&mut self, width: i32);
114    fn set_text_align(&mut self, text_align: TextAlign);
115    fn set_line_height(&mut self, line_height: f32);
116    fn set_hyphen_penalty(&mut self, hyphen_penalty: i32);
117    fn set_stretch_tolerance(&mut self, stretch_tolerance: f32);
118    fn set_ignore_document_css(&mut self, ignore: bool);
119
120    fn title(&self) -> Option<String>;
121    fn author(&self) -> Option<String>;
122    fn metadata(&self, key: &str) -> Option<String>;
123
124    fn is_reflowable(&self) -> bool;
125
126    fn has_synthetic_page_numbers(&self) -> bool {
127        false
128    }
129
130    fn save(&self, _path: &str) -> Result<(), Error> {
131        Err(format_err!("this document can't be saved"))
132    }
133
134    fn preview_pixmap(&mut self, width: f32, height: f32, samples: usize) -> Option<Pixmap> {
135        self.dims(0)
136            .and_then(|dims| {
137                let scale = (width / dims.0).min(height / dims.1);
138                self.pixmap(Location::Exact(0), scale, samples)
139            })
140            .map(|(pixmap, _)| pixmap)
141    }
142
143    fn resolve_location(&mut self, loc: Location) -> Option<usize> {
144        if self.pages_count() == 0 {
145            return None;
146        }
147
148        match loc {
149            Location::Exact(index) => {
150                if index >= self.pages_count() {
151                    None
152                } else {
153                    Some(index)
154                }
155            }
156            Location::Previous(index) => {
157                if index > 0 {
158                    Some(index - 1)
159                } else {
160                    None
161                }
162            }
163            Location::Next(index) => {
164                if index < self.pages_count() - 1 {
165                    Some(index + 1)
166                } else {
167                    None
168                }
169            }
170            _ => None,
171        }
172    }
173}
174
175pub fn file_kind<P: AsRef<Path>>(path: P) -> Option<FileExtension> {
176    path.as_ref()
177        .extension()
178        .and_then(OsStr::to_str)
179        .map(str::to_lowercase)
180        .or_else(|| guess_kind(path.as_ref()).ok().map(String::from))
181        .and_then(|s| s.parse().ok())
182}
183
184pub fn guess_kind<P: AsRef<Path>>(path: P) -> Result<&'static str, Error> {
185    let file = File::open(path.as_ref())?;
186    let mut magic = [0; 4];
187    file.read_exact_at(&mut magic, 0)?;
188
189    if &magic == b"PK\x03\x04" {
190        let mut mime_type = [0; 28];
191        file.read_exact_at(&mut mime_type, 30)?;
192        if &mime_type == b"mimetypeapplication/epub+zip" {
193            return Ok("epub");
194        }
195    } else if &magic == b"%PDF" {
196        return Ok("pdf");
197    } else if &magic == b"AT&T" {
198        return Ok("djvu");
199    }
200
201    Err(format_err!("Unknown file type"))
202}
203
204pub trait HumanSize {
205    fn human_size(&self) -> String;
206}
207
208const SIZE_BASE: f32 = 1024.0;
209
210impl HumanSize for u64 {
211    fn human_size(&self) -> String {
212        let value = *self as f32;
213        let level = (value.max(1.0).log(SIZE_BASE).floor() as usize).min(3);
214        let factor = value / (SIZE_BASE).powi(level as i32);
215        let precision = level.saturating_sub(1 + factor.log(10.0).floor() as usize);
216        format!(
217            "{0:.1$} {2}",
218            factor,
219            precision,
220            ['B', 'K', 'M', 'G'][level]
221        )
222    }
223}
224
225pub fn asciify(name: &str) -> String {
226    name.nfkd()
227        .filter(|&c| !is_combining_mark(c))
228        .collect::<String>()
229        .replace('œ', "oe")
230        .replace('Œ', "Oe")
231        .replace('æ', "ae")
232        .replace('Æ', "Ae")
233        .replace('—', "-")
234        .replace('–', "-")
235        .replace('’', "'")
236}
237
238#[cfg_attr(feature = "tracing", tracing::instrument(skip(path), fields(path = %path.as_ref().display())))]
239pub fn open<P: AsRef<Path>>(path: P) -> Option<Box<dyn Document>> {
240    let kind = file_kind(path.as_ref());
241    if kind.is_none() {
242        warn!(path = %path.as_ref().display(), "Failed to determine file kind");
243    }
244    kind.and_then(|k| match k {
245        FileExtension::Epub => EpubDocument::new(&path)
246            .map_err(|e| error!(path = %path.as_ref().display(), error = %e, "Failed to open epub document"))
247            .map(|d| Box::new(d) as Box<dyn Document>)
248            .ok(),
249        FileExtension::Html => HtmlDocument::new(&path)
250            .map_err(|e| error!(path = %path.as_ref().display(), error = %e, "Failed to open html document"))
251            .map(|d| Box::new(d) as Box<dyn Document>)
252            .ok(),
253        FileExtension::Djvu => {
254            let opener = DjvuOpener::new();
255            if opener.is_none() {
256                warn!(path = %path.as_ref().display(), "Failed to create DjvuOpener");
257            }
258            opener.and_then(|o| {
259                let doc = o.open(&path).map(|d| Box::new(d) as Box<dyn Document>);
260                if doc.is_none() {
261                    warn!(path = %path.as_ref().display(), "DjvuOpener failed to open document");
262                }
263                doc
264            })
265        }
266        FileExtension::Pdf
267        | FileExtension::Cbz
268        | FileExtension::Cbr
269        | FileExtension::Fb2
270        | FileExtension::Mobi
271        | FileExtension::Txt
272        | FileExtension::Xps
273        | FileExtension::Oxps => {
274            let opener = PdfOpener::new();
275            if opener.is_none() {
276                warn!(path = %path.as_ref().display(), "Failed to create PdfOpener");
277            }
278            opener.and_then(|mut o| {
279                if matches!(k, FileExtension::Mobi | FileExtension::Fb2 | FileExtension::Xps | FileExtension::Txt) {
280                    o.load_user_stylesheet();
281                }
282                o.open(&path)
283                    .map_err(|e| warn!(
284                        path = %path.as_ref().display(),
285                        kind = %k,
286                        error = %e,
287                        "PdfOpener failed to open document"
288                    ))
289                    .ok()
290                    .map(|d| Box::new(d) as Box<dyn Document>)
291            })
292        }
293    })
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(untagged)]
298pub enum SimpleTocEntry {
299    Leaf(String, TocLocation),
300    Container(String, TocLocation, Vec<SimpleTocEntry>),
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(untagged)]
305pub enum TocLocation {
306    Exact(usize),
307    Uri(String),
308}
309
310impl From<TocLocation> for Location {
311    fn from(loc: TocLocation) -> Location {
312        match loc {
313            TocLocation::Exact(n) => Location::Exact(n),
314            TocLocation::Uri(uri) => Location::Uri(uri),
315        }
316    }
317}
318
319impl From<&TocEntry> for SimpleTocEntry {
320    /// `LocalUri` and positional variants have no direct [`TocLocation`] equivalent;
321    /// they fall back to page 0 so the entry is still stored and navigable.
322    fn from(entry: &TocEntry) -> SimpleTocEntry {
323        let location = match &entry.location {
324            Location::Exact(n) => TocLocation::Exact(*n),
325            Location::Uri(uri) => TocLocation::Uri(uri.clone()),
326            Location::LocalUri(n, _) => TocLocation::Exact(*n),
327            _ => TocLocation::Exact(0),
328        };
329
330        if entry.children.is_empty() {
331            SimpleTocEntry::Leaf(entry.title.clone(), location)
332        } else {
333            let children = entry.children.iter().map(SimpleTocEntry::from).collect();
334            SimpleTocEntry::Container(entry.title.clone(), location, children)
335        }
336    }
337}
338
339pub fn toc_as_html(toc: &[TocEntry], chap_index: usize) -> String {
340    let mut buf = "<html>\n\t<head>\n\t\t<title>Table of Contents</title>\n\t\t\
341                   <link rel=\"stylesheet\" type=\"text/css\" href=\"css/toc.css\"/>\n\t\
342                   </head>\n\t<body>\n"
343        .to_string();
344    toc_as_html_aux(toc, chap_index, 0, &mut buf);
345    buf.push_str("\t</body>\n</html>");
346    buf
347}
348
349pub fn toc_as_html_aux(toc: &[TocEntry], chap_index: usize, depth: usize, buf: &mut String) {
350    buf.push_str(&"\t".repeat(depth + 2));
351    buf.push_str("<ul>\n");
352    for entry in toc {
353        buf.push_str(&"\t".repeat(depth + 3));
354        match entry.location {
355            Location::Exact(n) => buf.push_str(&format!("<li><a href=\"@{}\">", n)),
356            Location::Uri(ref uri) => buf.push_str(&format!("<li><a href=\"@{}\">", uri)),
357            _ => buf.push_str("<li><a href=\"#\">"),
358        }
359        let title = entry.title.replace('<', "&lt;").replace('>', "&gt;");
360        if entry.index == chap_index {
361            buf.push_str(&format!("<strong>{}</strong>", title));
362        } else {
363            buf.push_str(&title);
364        }
365        buf.push_str("</a></li>\n");
366        if !entry.children.is_empty() {
367            toc_as_html_aux(&entry.children, chap_index, depth + 1, buf);
368        }
369    }
370    buf.push_str(&"\t".repeat(depth + 2));
371    buf.push_str("</ul>\n");
372}
373
374pub fn annotations_as_html(
375    annotations: &[Annotation],
376    active_range: Option<(TextLocation, TextLocation)>,
377) -> String {
378    let mut buf = "<html>\n\t<head>\n\t\t<title>Annotations</title>\n\t\t\
379                   <link rel=\"stylesheet\" type=\"text/css\" href=\"css/annotations.css\"/>\n\t\
380                   </head>\n\t<body>\n"
381        .to_string();
382    buf.push_str("\t\t<ul>\n");
383    for annot in annotations {
384        let mut note = annot.note.replace('<', "&lt;").replace('>', "&gt;");
385        let mut text = annot.text.replace('<', "&lt;").replace('>', "&gt;");
386        let start = annot.selection[0];
387        if active_range.map_or(false, |(first, last)| start >= first && start <= last) {
388            if !note.is_empty() {
389                note = format!("<b>{}</b>", note);
390            }
391            text = format!("<b>{}</b>", text);
392        }
393        if note.is_empty() {
394            buf.push_str(&format!(
395                "\t\t<li><a href=\"@{}\">{}</a></li>\n",
396                start.location(),
397                text
398            ));
399        } else {
400            buf.push_str(&format!(
401                "\t\t<li><a href=\"@{}\"><i>{}</i> — {}</a></li>\n",
402                start.location(),
403                note,
404                text
405            ));
406        }
407    }
408    buf.push_str("\t\t</ul>\n");
409    buf.push_str("\t</body>\n</html>");
410    buf
411}
412
413pub fn bookmarks_as_html(bookmarks: &BTreeSet<usize>, index: usize, synthetic: bool) -> String {
414    let mut buf = "<html>\n\t<head>\n\t\t<title>Bookmarks</title>\n\t\t\
415                   <link rel=\"stylesheet\" type=\"text/css\" href=\"css/bookmarks.css\"/>\n\t\
416                   </head>\n\t<body>\n"
417        .to_string();
418    buf.push_str("\t\t<ul>\n");
419    for bkm in bookmarks {
420        let mut text = if synthetic {
421            format!("{:.1}", *bkm as f64 / BYTES_PER_PAGE)
422        } else {
423            format!("{}", bkm + 1)
424        };
425        if *bkm == index {
426            text = format!("<b>{}</b>", text);
427        }
428        buf.push_str(&format!("\t\t<li><a href=\"@{}\">{}</a></li>\n", bkm, text));
429    }
430    buf.push_str("\t\t</ul>\n");
431    buf.push_str("\t</body>\n</html>");
432    buf
433}
434
435#[inline]
436fn chapter(index: usize, pages_count: usize, toc: &[TocEntry]) -> Option<(&TocEntry, f32)> {
437    let mut chap = None;
438    let mut chap_index = 0;
439    let mut end_index = pages_count;
440    chapter_aux(toc, index, &mut chap, &mut chap_index, &mut end_index);
441    chap.zip(Some(
442        (index - chap_index) as f32 / (end_index - chap_index) as f32,
443    ))
444}
445
446fn chapter_aux<'a>(
447    toc: &'a [TocEntry],
448    index: usize,
449    chap: &mut Option<&'a TocEntry>,
450    chap_index: &mut usize,
451    end_index: &mut usize,
452) {
453    for entry in toc {
454        if let Location::Exact(entry_index) = entry.location {
455            if entry_index <= index && (chap.is_none() || entry_index > *chap_index) {
456                *chap = Some(entry);
457                *chap_index = entry_index;
458            }
459            if entry_index > index && entry_index < *end_index {
460                *end_index = entry_index;
461            }
462        }
463        chapter_aux(&entry.children, index, chap, chap_index, end_index);
464    }
465}
466
467#[inline]
468fn chapter_relative(index: usize, dir: CycleDir, toc: &[TocEntry]) -> Option<&TocEntry> {
469    let chap = chapter(index, usize::MAX, toc).map(|(c, _)| c);
470
471    match dir {
472        CycleDir::Previous => previous_chapter(chap, index, toc),
473        CycleDir::Next => next_chapter(chap, index, toc),
474    }
475}
476
477fn previous_chapter<'a>(
478    chap: Option<&TocEntry>,
479    index: usize,
480    toc: &'a [TocEntry],
481) -> Option<&'a TocEntry> {
482    for entry in toc.iter().rev() {
483        let result = previous_chapter(chap, index, &entry.children);
484        if result.is_some() {
485            return result;
486        }
487
488        if let Some(chap) = chap {
489            if entry.index < chap.index {
490                if let Location::Exact(entry_index) = entry.location {
491                    if entry_index != index {
492                        return Some(entry);
493                    }
494                }
495            }
496        } else {
497            if let Location::Exact(entry_index) = entry.location {
498                if entry_index < index {
499                    return Some(entry);
500                }
501            }
502        }
503    }
504    None
505}
506
507fn next_chapter<'a>(
508    chap: Option<&TocEntry>,
509    index: usize,
510    toc: &'a [TocEntry],
511) -> Option<&'a TocEntry> {
512    for entry in toc {
513        if let Some(chap) = chap {
514            if entry.index > chap.index {
515                if let Location::Exact(entry_index) = entry.location {
516                    if entry_index != index {
517                        return Some(entry);
518                    }
519                }
520            }
521        } else {
522            if let Location::Exact(entry_index) = entry.location {
523                if entry_index > index {
524                    return Some(entry);
525                }
526            }
527        }
528
529        let result = next_chapter(chap, index, &entry.children);
530        if result.is_some() {
531            return result;
532        }
533    }
534    None
535}
536
537pub fn chapter_from_uri<'a>(target_uri: &str, toc: &'a [TocEntry]) -> Option<&'a TocEntry> {
538    for entry in toc {
539        if let Location::Uri(ref uri) = entry.location {
540            if uri.starts_with(target_uri) {
541                return Some(entry);
542            }
543        }
544        let result = chapter_from_uri(target_uri, &entry.children);
545        if result.is_some() {
546            return result;
547        }
548    }
549    None
550}
551
552const CPUINFO_KEYS: [&str; 3] = ["Processor", "Features", "Hardware"];
553const HWINFO_KEYS: [&str; 19] = [
554    "CPU",
555    "PCB",
556    "DisplayPanel",
557    "DisplayCtrl",
558    "DisplayBusWidth",
559    "DisplayResolution",
560    "FrontLight",
561    "FrontLight_LEDrv",
562    "FL_PWM",
563    "TouchCtrl",
564    "TouchType",
565    "Battery",
566    "IFlash",
567    "RamSize",
568    "RamType",
569    "LightSensor",
570    "HallSensor",
571    "RSensor",
572    "Wifi",
573];
574
575pub fn sys_info_as_html() -> String {
576    let mut buf = "<html>\n\t<head>\n\t\t<title>System Info</title>\n\t\t\
577                   <link rel=\"stylesheet\" type=\"text/css\" \
578                   href=\"css/sysinfo.css\"/>\n\t</head>\n\t<body>\n"
579        .to_string();
580
581    buf.push_str("\t\t<table>\n");
582
583    buf.push_str("\t\t\t<tr>\n");
584    buf.push_str("\t\t\t\t<td class=\"key\">Model name</td>\n");
585    buf.push_str(&format!(
586        "\t\t\t\t<td class=\"value\">{}</td>\n",
587        CURRENT_DEVICE.model
588    ));
589    buf.push_str("\t\t\t</tr>\n");
590
591    buf.push_str("\t\t\t<tr>\n");
592    buf.push_str("\t\t\t\t<td class=\"key\">Hardware</td>\n");
593    buf.push_str(&format!(
594        "\t\t\t\t<td class=\"value\">Mark {}</td>\n",
595        CURRENT_DEVICE.mark()
596    ));
597    buf.push_str("\t\t\t</tr>\n");
598    buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
599
600    for (name, var) in [
601        ("Code name", "PRODUCT"),
602        ("Model number", "MODEL_NUMBER"),
603        ("Firmware version", "FIRMWARE_VERSION"),
604    ]
605    .iter()
606    {
607        if let Ok(value) = env::var(var) {
608            buf.push_str("\t\t\t<tr>\n");
609            buf.push_str(&format!("\t\t\t\t<td class=\"key\">{}</td>\n", name));
610            buf.push_str(&format!("\t\t\t\t<td class=\"value\">{}</td>\n", value));
611            buf.push_str("\t\t\t</tr>\n");
612        }
613    }
614
615    buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
616
617    let output = Command::new("scripts/ip.sh")
618        .output()
619        .map_err(|e| error!("Can't execute command: {:#}.", e))
620        .ok();
621
622    if let Some(stdout) = output
623        .filter(|output| output.status.success())
624        .and_then(|output| String::from_utf8(output.stdout).ok())
625        .filter(|stdout| !stdout.is_empty())
626    {
627        buf.push_str("\t\t\t<tr>\n");
628        buf.push_str("\t\t\t\t<td>IP Address</td>\n");
629        buf.push_str(&format!("\t\t\t\t<td>{}</td>\n", stdout));
630        buf.push_str("\t\t\t</tr>\n");
631    }
632
633    if let Ok(info) = statvfs::statvfs(INTERNAL_CARD_ROOT) {
634        let fbs = info.fragment_size() as u64;
635        let free = info.blocks_free() as u64 * fbs;
636        let total = info.blocks() as u64 * fbs;
637        buf.push_str("\t\t\t<tr>\n");
638        buf.push_str("\t\t\t\t<td>Storage (Free / Total)</td>\n");
639        buf.push_str(&format!(
640            "\t\t\t\t<td>{} / {}</td>\n",
641            free.human_size(),
642            total.human_size()
643        ));
644        buf.push_str("\t\t\t</tr>\n");
645    }
646
647    #[cfg(target_os = "linux")]
648    if let Ok(info) = sysinfo::sysinfo() {
649        buf.push_str("\t\t\t<tr>\n");
650        buf.push_str("\t\t\t\t<td>Memory (Free / Total)</td>\n");
651        buf.push_str(&format!(
652            "\t\t\t\t<td>{} / {}</td>\n",
653            info.ram_unused().human_size(),
654            info.ram_total().human_size()
655        ));
656        buf.push_str("\t\t\t</tr>\n");
657        let load = info.load_average();
658        buf.push_str("\t\t\t<tr>\n");
659        buf.push_str("\t\t\t\t<td>Load Average</td>\n");
660        buf.push_str(&format!(
661            "\t\t\t\t<td>{:.1}% {:.1}% {:.1}%</td>\n",
662            load.0 * 100.0,
663            load.1 * 100.0,
664            load.2 * 100.0
665        ));
666        buf.push_str("\t\t\t</tr>\n");
667    }
668
669    buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
670
671    if let Ok(info) = fs::read_to_string("/proc/cpuinfo") {
672        for line in info.lines() {
673            if let Some(index) = line.find(':') {
674                let key = line[0..index].trim();
675                let value = line[index + 1..].trim();
676                if CPUINFO_KEYS.contains(&key) {
677                    buf.push_str("\t\t\t<tr>\n");
678                    buf.push_str(&format!("\t\t\t\t<td class=\"key\">{}</td>\n", key));
679                    buf.push_str(&format!("\t\t\t\t<td class=\"value\">{}</td>\n", value));
680                    buf.push_str("\t\t\t</tr>\n");
681                }
682            }
683        }
684    }
685
686    buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
687
688    let output = Command::new("/bin/ntx_hwconfig")
689        .args(&["-s", "/dev/mmcblk0"])
690        .output()
691        .map_err(|e| error!("Can't execute command: {:#}.", e))
692        .ok();
693
694    let mut map = FxHashMap::default();
695
696    if let Some(stdout) = output.and_then(|output| String::from_utf8(output.stdout).ok()) {
697        let re = Regex::new(r#"\[\d+\]\s+(?P<key>[^=]+)='(?P<value>[^']+)'"#).unwrap();
698        for caps in re.captures_iter(&stdout) {
699            map.insert(caps["key"].to_string(), caps["value"].to_string());
700        }
701    }
702
703    if !map.is_empty() {
704        for key in HWINFO_KEYS.iter() {
705            if let Some(value) = map.get(*key) {
706                buf.push_str("\t\t\t<tr>\n");
707                buf.push_str(&format!("\t\t\t\t<td>{}</td>\n", key));
708                buf.push_str(&format!("\t\t\t\t<td>{}</td>\n", value));
709                buf.push_str("\t\t\t</tr>\n");
710            }
711        }
712    }
713
714    buf.push_str("\t\t</table>\n\t</body>\n</html>");
715    buf
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use std::path::PathBuf;
722
723    #[test]
724    fn test_file_kind_recognizes_htm_extension() {
725        let path = PathBuf::from("test_file.htm");
726        let kind = file_kind(&path);
727        assert_eq!(kind, Some(FileExtension::Html));
728    }
729
730    #[test]
731    fn test_file_kind_recognizes_html_extension() {
732        let path = PathBuf::from("test_file.html");
733        let kind = file_kind(&path);
734        assert_eq!(kind, Some(FileExtension::Html));
735    }
736
737    #[test]
738    fn test_file_kind_case_insensitive() {
739        let path_upper = PathBuf::from("test_file.HTM");
740        let path_mixed = PathBuf::from("test_file.HtM");
741        assert_eq!(file_kind(&path_upper), Some(FileExtension::Html));
742        assert_eq!(file_kind(&path_mixed), Some(FileExtension::Html));
743    }
744}