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