281 lines
8.6 KiB
Rust
281 lines
8.6 KiB
Rust
use std::cmp::Ordering;
|
|
use std::path::Path;
|
|
use std::time::SystemTime;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EntryKind {
|
|
Directory,
|
|
ZipArchive,
|
|
Image,
|
|
Video,
|
|
OtherFile,
|
|
}
|
|
|
|
impl EntryKind {
|
|
pub fn is_viewable(self) -> bool {
|
|
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BrowserEntry {
|
|
pub uri: String,
|
|
pub name: String,
|
|
pub list_name: Option<String>,
|
|
pub kind: EntryKind,
|
|
pub size: Option<u64>,
|
|
pub modified: Option<SystemTime>,
|
|
pub created: Option<SystemTime>,
|
|
pub etag: Option<String>,
|
|
}
|
|
|
|
impl BrowserEntry {
|
|
pub fn display_name(&self) -> &str {
|
|
self.list_name.as_deref().unwrap_or(&self.name)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SortField {
|
|
Name,
|
|
Modified,
|
|
Created,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SortDirection {
|
|
Ascending,
|
|
Descending,
|
|
}
|
|
|
|
pub fn sort_entries(
|
|
entries: &mut [BrowserEntry],
|
|
field: SortField,
|
|
direction: SortDirection,
|
|
case_insensitive: bool,
|
|
) {
|
|
entries.sort_by(|left, right| {
|
|
let kind_order = match (
|
|
left.kind == EntryKind::Directory,
|
|
right.kind == EntryKind::Directory,
|
|
) {
|
|
(true, false) => Ordering::Less,
|
|
(false, true) => Ordering::Greater,
|
|
_ => Ordering::Equal,
|
|
};
|
|
if kind_order != Ordering::Equal {
|
|
return kind_order;
|
|
}
|
|
|
|
match field {
|
|
SortField::Name => apply_direction(
|
|
natural_cmp_with_case(left.display_name(), right.display_name(), case_insensitive),
|
|
direction,
|
|
),
|
|
SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
|
|
.then_with(|| {
|
|
apply_direction(
|
|
natural_cmp_with_case(
|
|
left.display_name(),
|
|
right.display_name(),
|
|
case_insensitive,
|
|
),
|
|
direction,
|
|
)
|
|
}),
|
|
SortField::Created => compare_optional_time(left.created, right.created, direction)
|
|
.then_with(|| {
|
|
apply_direction(
|
|
natural_cmp_with_case(
|
|
left.display_name(),
|
|
right.display_name(),
|
|
case_insensitive,
|
|
),
|
|
direction,
|
|
)
|
|
}),
|
|
}
|
|
});
|
|
}
|
|
|
|
fn compare_optional_time(
|
|
left: Option<SystemTime>,
|
|
right: Option<SystemTime>,
|
|
direction: SortDirection,
|
|
) -> Ordering {
|
|
match (left, right) {
|
|
(Some(left), Some(right)) => apply_direction(left.cmp(&right), direction),
|
|
(Some(_), None) => Ordering::Less,
|
|
(None, Some(_)) => Ordering::Greater,
|
|
(None, None) => Ordering::Equal,
|
|
}
|
|
}
|
|
|
|
fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering {
|
|
match direction {
|
|
SortDirection::Ascending => order,
|
|
SortDirection::Descending => order.reverse(),
|
|
}
|
|
}
|
|
|
|
pub fn classify_file(path: &Path) -> EntryKind {
|
|
let extension = path
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(str::to_ascii_lowercase);
|
|
match extension.as_deref() {
|
|
Some("zip") => EntryKind::ZipArchive,
|
|
Some("jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif") => EntryKind::Image,
|
|
Some("mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "mpeg" | "mpg" | "ogv") => {
|
|
EntryKind::Video
|
|
}
|
|
_ => EntryKind::OtherFile,
|
|
}
|
|
}
|
|
|
|
pub fn natural_cmp_with_case(left: &str, right: &str, case_insensitive: bool) -> Ordering {
|
|
let mut left_chars = left.chars().peekable();
|
|
let mut right_chars = right.chars().peekable();
|
|
|
|
loop {
|
|
match (left_chars.peek(), right_chars.peek()) {
|
|
(None, None) => return left.cmp(right),
|
|
(None, Some(_)) => return Ordering::Less,
|
|
(Some(_), None) => return Ordering::Greater,
|
|
(Some(left_char), Some(right_char))
|
|
if left_char.is_ascii_digit() && right_char.is_ascii_digit() =>
|
|
{
|
|
let left_number = take_digits(&mut left_chars);
|
|
let right_number = take_digits(&mut right_chars);
|
|
let left_trimmed = left_number.trim_start_matches('0');
|
|
let right_trimmed = right_number.trim_start_matches('0');
|
|
let left_significant = if left_trimmed.is_empty() {
|
|
"0"
|
|
} else {
|
|
left_trimmed
|
|
};
|
|
let right_significant = if right_trimmed.is_empty() {
|
|
"0"
|
|
} else {
|
|
right_trimmed
|
|
};
|
|
|
|
let order = left_significant
|
|
.len()
|
|
.cmp(&right_significant.len())
|
|
.then_with(|| left_significant.cmp(right_significant))
|
|
.then_with(|| left_number.len().cmp(&right_number.len()));
|
|
if order != Ordering::Equal {
|
|
return order;
|
|
}
|
|
}
|
|
(Some(_), Some(_)) => {
|
|
let left_char = left_chars.next().unwrap();
|
|
let right_char = right_chars.next().unwrap();
|
|
let order = if case_insensitive {
|
|
left_char.to_lowercase().cmp(right_char.to_lowercase())
|
|
} else {
|
|
left_char.cmp(&right_char)
|
|
};
|
|
if order != Ordering::Equal {
|
|
return order;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn take_digits<I>(characters: &mut std::iter::Peekable<I>) -> String
|
|
where
|
|
I: Iterator<Item = char>,
|
|
{
|
|
let mut digits = String::new();
|
|
while characters.peek().is_some_and(char::is_ascii_digit) {
|
|
digits.push(characters.next().unwrap());
|
|
}
|
|
digits
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn natural_order_places_two_before_ten() {
|
|
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"];
|
|
names.sort_by(|left, right| natural_cmp_with_case(left, right, true));
|
|
assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
|
|
}
|
|
|
|
#[test]
|
|
fn natural_order_handles_numbers_larger_than_integers() {
|
|
assert_eq!(
|
|
natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true),
|
|
Ordering::Less
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn natural_order_can_ignore_or_respect_case() {
|
|
assert_eq!(
|
|
natural_cmp_with_case("a2.jpg", "A10.jpg", true),
|
|
Ordering::Less
|
|
);
|
|
assert_eq!(
|
|
natural_cmp_with_case("a2.jpg", "A10.jpg", false),
|
|
Ordering::Greater
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_entries_sort_by_relative_display_path() {
|
|
let mut entries = ["10권/page.jpg", "2권/page.jpg"].map(|list_name| BrowserEntry {
|
|
uri: format!("file:///{list_name}"),
|
|
name: "page.jpg".into(),
|
|
list_name: Some(list_name.into()),
|
|
kind: EntryKind::Image,
|
|
size: None,
|
|
modified: None,
|
|
created: None,
|
|
etag: None,
|
|
});
|
|
|
|
sort_entries(
|
|
&mut entries,
|
|
SortField::Name,
|
|
SortDirection::Ascending,
|
|
true,
|
|
);
|
|
|
|
assert_eq!(entries[0].display_name(), "2권/page.jpg");
|
|
assert_eq!(entries[1].display_name(), "10권/page.jpg");
|
|
}
|
|
|
|
#[test]
|
|
fn zip_extension_is_case_insensitive() {
|
|
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
|
|
assert_ne!(classify_file(Path::new("comic.cbz")), EntryKind::ZipArchive);
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_common_media_extensions_case_insensitively() {
|
|
assert_eq!(classify_file(Path::new("photo.JPG")), EntryKind::Image);
|
|
assert_eq!(classify_file(Path::new("animation.webp")), EntryKind::Image);
|
|
assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video);
|
|
assert_eq!(classify_file(Path::new("movie.mkv")), EntryKind::Video);
|
|
assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_times_sort_after_known_times() {
|
|
let now = SystemTime::now();
|
|
assert_eq!(
|
|
compare_optional_time(Some(now), None, SortDirection::Ascending),
|
|
Ordering::Less
|
|
);
|
|
assert_eq!(
|
|
compare_optional_time(Some(now), None, SortDirection::Descending),
|
|
Ordering::Less
|
|
);
|
|
}
|
|
}
|