This commit is contained in:
1 parent
e646598078
commit
ead5d96b78
13 files changed
+1829
-1
No files matched your search
@@ -0,0 +1,650 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Cursor;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
use super::reconciliation::{
|
||||
list_source_screenshots, reconcile_source_screenshots,
|
||||
scan_source_screenshots,
|
||||
};
|
||||
use crate::State;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::{
|
||||
instance_rows::{self, InstanceScreenshotSource},
|
||||
screenshot_rows,
|
||||
};
|
||||
use crate::util::fetch::sha1_file_async;
|
||||
use crate::util::io::{self, IOError};
|
||||
|
||||
const SCREENSHOTS_DIRECTORY: &str = "screenshots";
|
||||
const SCREENSHOT_SCAN_CONCURRENCY: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
pub struct ScreenshotKey {
|
||||
pub instance_id: String,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct InstanceScreenshot {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub instance_name: String,
|
||||
pub file_name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: i64,
|
||||
pub group_id: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScreenshotEditSaveMode {
|
||||
CreateCopy,
|
||||
ReplaceEdit,
|
||||
}
|
||||
|
||||
pub async fn list_screenshots(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
let state = State::get().await?;
|
||||
let source =
|
||||
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
list_source_screenshots(&state, source).await
|
||||
}
|
||||
|
||||
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
|
||||
{
|
||||
list_all_screenshots().await
|
||||
}
|
||||
|
||||
pub async fn list_all_screenshots() -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
let state = State::get().await?;
|
||||
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
|
||||
list_source_screenshot_sets(&state, sources).await
|
||||
}
|
||||
|
||||
async fn list_source_screenshot_sets(
|
||||
state: &State,
|
||||
sources: Vec<InstanceScreenshotSource>,
|
||||
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
let mut screenshots =
|
||||
stream::iter(sources.into_iter().map(|source| async move {
|
||||
list_source_screenshots(state, source).await
|
||||
}))
|
||||
.buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY)
|
||||
.try_collect::<Vec<Vec<InstanceScreenshot>>>()
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sort_screenshots(&mut screenshots);
|
||||
Ok(screenshots)
|
||||
}
|
||||
|
||||
async fn lock_instance_screenshots<'a>(
|
||||
state: &State,
|
||||
instance_ids: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<OwnedMutexGuard<()>> {
|
||||
let mut locks = Vec::new();
|
||||
for instance_id in instance_ids {
|
||||
locks.push(state.lock_instance_screenshots(instance_id).await);
|
||||
}
|
||||
locks
|
||||
}
|
||||
|
||||
pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> {
|
||||
ensure_unique_keys(keys)?;
|
||||
let state = State::get().await?;
|
||||
let mut instance_ids = keys
|
||||
.iter()
|
||||
.map(|key| key.instance_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
instance_ids.sort_unstable();
|
||||
instance_ids.dedup();
|
||||
let _locks = lock_instance_screenshots(&state, instance_ids).await;
|
||||
|
||||
for key in keys {
|
||||
io::remove_file(get_screenshot_path(key).await?).await?;
|
||||
screenshot_rows::delete_screenshot_by_key(
|
||||
&key.instance_id,
|
||||
&key.file_name,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn export_screenshots(
|
||||
keys: &[ScreenshotKey],
|
||||
export_path: PathBuf,
|
||||
) -> crate::Result<()> {
|
||||
ensure_unique_keys(keys)?;
|
||||
if keys.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"At least one screenshot must be selected".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut sources = HashMap::new();
|
||||
let mut archive_folders = HashMap::<String, String>::new();
|
||||
let mut used_archive_folders = HashSet::new();
|
||||
let mut screenshots = Vec::with_capacity(keys.len());
|
||||
|
||||
for key in keys {
|
||||
let source = match sources.get(&key.instance_id) {
|
||||
Some(source) => source,
|
||||
None => {
|
||||
let source = instance_rows::get_instance_screenshot_source(
|
||||
&key.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
sources.insert(key.instance_id.clone(), source);
|
||||
&sources[&key.instance_id]
|
||||
}
|
||||
};
|
||||
let archive_folder = match archive_folders.get(&key.instance_id) {
|
||||
Some(folder) => folder.clone(),
|
||||
None => {
|
||||
let folder = unique_archive_folder(
|
||||
&sanitize_archive_component(&source.name),
|
||||
&mut used_archive_folders,
|
||||
);
|
||||
archive_folders.insert(key.instance_id.clone(), folder.clone());
|
||||
folder
|
||||
}
|
||||
};
|
||||
let archive_name = format!("{archive_folder}/{}", key.file_name);
|
||||
screenshots.push((archive_name, get_screenshot_path(key).await?));
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || -> crate::Result<()> {
|
||||
let file = std::fs::File::create(&export_path)
|
||||
.map_err(|error| IOError::with_path(error, &export_path))?;
|
||||
let mut writer = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(CompressionMethod::Stored);
|
||||
|
||||
for (archive_name, path) in screenshots {
|
||||
writer
|
||||
.start_file(archive_name, options)
|
||||
.map_err(std::io::Error::from)?;
|
||||
let mut source = std::fs::File::open(&path)
|
||||
.map_err(|error| IOError::with_path(error, &path))?;
|
||||
std::io::copy(&mut source, &mut writer).map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
writer.finish().map_err(std::io::Error::from)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub async fn move_screenshots(
|
||||
keys: &[ScreenshotKey],
|
||||
target_instance_id: &str,
|
||||
) -> crate::Result<Vec<ScreenshotKey>> {
|
||||
ensure_unique_keys(keys)?;
|
||||
if keys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut instance_ids = keys
|
||||
.iter()
|
||||
.map(|key| key.instance_id.as_str())
|
||||
.chain(std::iter::once(target_instance_id))
|
||||
.collect::<Vec<_>>();
|
||||
instance_ids.sort_unstable();
|
||||
instance_ids.dedup();
|
||||
let _locks = lock_instance_screenshots(&state, instance_ids).await;
|
||||
let target_source = instance_rows::get_instance_screenshot_source(
|
||||
target_instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown target instance".to_string())
|
||||
})?;
|
||||
let target_dir = source_screenshots_dir(&state, &target_source).await?;
|
||||
io::create_dir_all(&target_dir).await?;
|
||||
ensure_directory_is_not_symlink(&target_dir).await?;
|
||||
|
||||
let mut moved_keys = Vec::with_capacity(keys.len());
|
||||
for key in keys {
|
||||
if key.instance_id == target_instance_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_path = get_screenshot_path(key).await?;
|
||||
let target_path =
|
||||
available_target_path(&target_dir, &key.file_name).await?;
|
||||
io::rename_or_move(&source_path, &target_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not move screenshot: {error}"
|
||||
))
|
||||
})?;
|
||||
let file_name = target_path
|
||||
.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Could not determine moved screenshot file name"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
screenshot_rows::move_screenshot(
|
||||
&key.instance_id,
|
||||
&key.file_name,
|
||||
target_instance_id,
|
||||
&file_name,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
moved_keys.push(ScreenshotKey {
|
||||
instance_id: target_instance_id.to_string(),
|
||||
file_name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(moved_keys)
|
||||
}
|
||||
|
||||
pub async fn get_screenshot_path(
|
||||
key: &ScreenshotKey,
|
||||
) -> crate::Result<PathBuf> {
|
||||
validate_file_name(&key.file_name)?;
|
||||
|
||||
let state = State::get().await?;
|
||||
let source = instance_rows::get_instance_screenshot_source(
|
||||
&key.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
|
||||
let canonical_dir = tokio::fs::canonicalize(&screenshots_dir)
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &screenshots_dir))?;
|
||||
let requested_path = screenshots_dir.join(&key.file_name);
|
||||
let metadata = tokio::fs::symlink_metadata(&requested_path)
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &requested_path))?;
|
||||
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Screenshot must be a regular file".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let canonical_path = tokio::fs::canonicalize(&requested_path)
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &requested_path))?;
|
||||
if !canonical_path.starts_with(&canonical_dir) {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Screenshot path is outside the instance screenshots directory"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(canonical_path)
|
||||
}
|
||||
|
||||
pub async fn save_edited_screenshot(
|
||||
key: ScreenshotKey,
|
||||
png_bytes: Vec<u8>,
|
||||
mode: ScreenshotEditSaveMode,
|
||||
) -> crate::Result<InstanceScreenshot> {
|
||||
validate_file_name(&key.file_name)?;
|
||||
|
||||
let state = State::get().await?;
|
||||
let source = instance_rows::get_instance_screenshot_source(
|
||||
&key.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let _lock = state.lock_instance_screenshots(&source.id).await;
|
||||
|
||||
let scanned = scan_source_screenshots(&state, &source).await?;
|
||||
let current =
|
||||
reconcile_source_screenshots(&state, &source, scanned).await?;
|
||||
let source_screenshot = current
|
||||
.iter()
|
||||
.find(|screenshot| screenshot.file_name == key.file_name)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown screenshot".to_string())
|
||||
})?;
|
||||
let source_row = screenshot_rows::get_screenshot_by_key(
|
||||
&key.instance_id,
|
||||
&key.file_name,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown screenshot".to_string())
|
||||
})?;
|
||||
|
||||
let png_bytes = tokio::task::spawn_blocking(move || {
|
||||
validate_png_dimensions(&png_bytes)?;
|
||||
Ok::<_, crate::Error>(png_bytes)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
|
||||
let (target_path, copy_group) = match mode {
|
||||
ScreenshotEditSaveMode::CreateCopy => (
|
||||
available_target_path(&screenshots_dir, &source_row.file_name)
|
||||
.await?,
|
||||
true,
|
||||
),
|
||||
ScreenshotEditSaveMode::ReplaceEdit => {
|
||||
(source_screenshot.path.clone(), false)
|
||||
}
|
||||
};
|
||||
let target_file_name = target_path
|
||||
.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Could not determine edited screenshot file name".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
io::write(&target_path, png_bytes).await?;
|
||||
if !copy_group {
|
||||
let (file_size, content_hash) = sha1_file_async(&target_path).await?;
|
||||
let file_size = i64::try_from(file_size).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Screenshot is too large to index".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut source_row = source_row.clone();
|
||||
source_row.content_hash = content_hash;
|
||||
source_row.file_size = file_size;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
screenshot_rows::update_screenshot(&source_row, &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
}
|
||||
let scanned = scan_source_screenshots(&state, &source).await?;
|
||||
let reconciled =
|
||||
reconcile_source_screenshots(&state, &source, scanned).await?;
|
||||
let mut saved = reconciled
|
||||
.into_iter()
|
||||
.find(|screenshot| screenshot.file_name == target_file_name)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Could not index edited screenshot".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if copy_group {
|
||||
let result: crate::Result<()> = async {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
screenshot_rows::copy_group_membership(
|
||||
&source_row.id,
|
||||
&saved.id,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
let _ = io::remove_file(&target_path).await;
|
||||
let _ = screenshot_rows::delete_screenshot_by_key(
|
||||
&source.id,
|
||||
&target_file_name,
|
||||
&state.pool,
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
let mut saved_row = screenshot_rows::get_screenshot_by_key(
|
||||
&saved.instance_id,
|
||||
&saved.file_name,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Could not load edited screenshot".to_string(),
|
||||
)
|
||||
})?;
|
||||
if !copy_group {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
if saved_row.created_at != source_row.created_at {
|
||||
saved_row.created_at = source_row.created_at;
|
||||
screenshot_rows::update_screenshot(&saved_row, &mut tx).await?;
|
||||
saved.created_at =
|
||||
DateTime::from_timestamp_millis(source_row.created_at)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Screenshot creation time is out of range"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
}
|
||||
saved.modified_at = saved_row.modified_at;
|
||||
saved.group_id = saved_row.group_id;
|
||||
|
||||
let _ = emit_instance(
|
||||
&saved.instance_id,
|
||||
InstancePayloadType::ScreenshotsUpdated,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
pub(super) async fn source_screenshots_dir(
|
||||
state: &State,
|
||||
source: &InstanceScreenshotSource,
|
||||
) -> crate::Result<PathBuf> {
|
||||
// Directly associated instances keep their files in the external launcher's
|
||||
// resolved game directory (which may be the shared `.minecraft` root or an
|
||||
// isolated version directory). Use the same runtime resolver as launching
|
||||
// and content browsing so screenshots are read from the directory the game
|
||||
// actually uses.
|
||||
let instance_dir =
|
||||
match instance_rows::get_instance_by_id(&source.id, &state.pool).await?
|
||||
{
|
||||
Some(instance) => crate::launcher::linked_game_dir(&instance)
|
||||
.unwrap_or_else(|| {
|
||||
state.directories.instances_dir().join(&source.path)
|
||||
}),
|
||||
None => state.directories.instances_dir().join(&source.path),
|
||||
};
|
||||
let canonical_instance_dir =
|
||||
tokio::fs::canonicalize(&instance_dir)
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &instance_dir))?;
|
||||
let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY);
|
||||
|
||||
ensure_directory_is_not_symlink(&screenshots_dir).await?;
|
||||
Ok(screenshots_dir)
|
||||
}
|
||||
|
||||
async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> {
|
||||
match tokio::fs::symlink_metadata(path).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"Instance screenshots directory cannot be a symbolic link"
|
||||
.to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
Ok(metadata) if !metadata.is_dir() => {
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"Instance screenshots path must be a directory".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(IOError::with_path(error, path).into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn available_target_path(
|
||||
target_dir: &Path,
|
||||
file_name: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
validate_file_name(file_name)?;
|
||||
let original = target_dir.join(file_name);
|
||||
if !tokio::fs::try_exists(&original)
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &original))?
|
||||
{
|
||||
return Ok(original);
|
||||
}
|
||||
|
||||
let path = Path::new(file_name);
|
||||
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap();
|
||||
let extension = path.extension().and_then(|value| value.to_str()).unwrap();
|
||||
|
||||
for suffix in 1_u32.. {
|
||||
let candidate =
|
||||
target_dir.join(format!("{stem} ({suffix}).{extension}"));
|
||||
if !tokio::fs::try_exists(&candidate)
|
||||
.await
|
||||
.map_err(|error| IOError::with_path(error, &candidate))?
|
||||
{
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> {
|
||||
let validate = || -> Result<_, png::DecodingError> {
|
||||
let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?;
|
||||
let dimensions = (reader.info().width, reader.info().height);
|
||||
while reader.next_row()?.is_some() {}
|
||||
reader.finish()?;
|
||||
Ok(dimensions)
|
||||
};
|
||||
validate().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not decode screenshot as PNG: {error}"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> {
|
||||
let mut unique = HashSet::new();
|
||||
if keys.iter().all(|key| unique.insert(key)) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"Screenshot selection contains duplicates".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn validate_file_name(file_name: &str) -> crate::Result<()> {
|
||||
let path = Path::new(file_name);
|
||||
let mut components = path.components();
|
||||
let is_single_file =
|
||||
matches!(components.next(), Some(Component::Normal(_)))
|
||||
&& components.next().is_none();
|
||||
|
||||
if !is_single_file || !has_png_extension(path) {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Invalid screenshot file name".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn has_png_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
|
||||
}
|
||||
|
||||
pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) {
|
||||
screenshots.sort_by(|left, right| {
|
||||
right
|
||||
.created_at
|
||||
.cmp(&left.created_at)
|
||||
.then_with(|| left.instance_name.cmp(&right.instance_name))
|
||||
.then_with(|| left.file_name.cmp(&right.file_name))
|
||||
});
|
||||
}
|
||||
|
||||
fn sanitize_archive_component(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
_ => character,
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
|
||||
"Instance".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_archive_folder(
|
||||
base: &str,
|
||||
used_folders: &mut HashSet<String>,
|
||||
) -> String {
|
||||
if used_folders.insert(base.to_string()) {
|
||||
return base.to_string();
|
||||
}
|
||||
|
||||
for suffix in 2_u32.. {
|
||||
let candidate = format!("{base} ({suffix})");
|
||||
if used_folders.insert(candidate.clone()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
Reference in new issue
Block a user