mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 13:23:28 +00:00
[ENG-1181] File System actions for Ephemeral Files (#1677)
* Backend side * Rust fmt * Removing uneeded duplicate files rspc route * Create folder for ephemeral files * Ephemeral delete files * First draft on copy, cut and delete, still buggy * Fixing copy function and updating async-channel dep * Rename and some fixes
This commit is contained in:
parent
b7354a4580
commit
f23e0b13c6
43
Cargo.lock
generated
43
Cargo.lock
generated
|
@ -270,13 +270,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||
checksum = "336d835910fab747186c56586562cb46f42809c2843ef3a84f47509009522838"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener",
|
||||
"event-listener 3.0.1",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -305,7 +307,18 @@ version = "2.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 2.5.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1942,6 +1955,27 @@ version = "2.5.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01cec0252c2afff729ee6f00e903d479fba81784c8e2bd77447673471fdfaea1"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96b852f1345da36d551b9473fa1e2b1eb5c5195585c6c018118bc92a8d91160"
|
||||
dependencies = [
|
||||
"event-listener 3.0.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.7.0"
|
||||
|
@ -6677,6 +6711,7 @@ version = "0.1.2"
|
|||
dependencies = [
|
||||
"aovec",
|
||||
"async-channel",
|
||||
"async-recursion",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
|
|
@ -77,17 +77,6 @@ pub fn sd_server_plugin<R: Runtime>(node: Arc<Node>) -> io::Result<TauriPlugin<R
|
|||
))
|
||||
.on_event(move |app, e| {
|
||||
if let RunEvent::Exit { .. } = e {
|
||||
debug!("Closing all open windows...");
|
||||
app
|
||||
.windows()
|
||||
.iter()
|
||||
.for_each(|(window_name, window)| {
|
||||
debug!("closing window: {window_name}");
|
||||
if let Err(e) = window.close() {
|
||||
error!("failed to close window '{}': {:#?}", window_name, e);
|
||||
}
|
||||
});
|
||||
|
||||
block_in_place(|| {
|
||||
block_on(node.shutdown());
|
||||
block_on(tx.send(())).ok();
|
||||
|
|
|
@ -97,9 +97,9 @@ strum_macros = "0.25"
|
|||
regex = "1.9.5"
|
||||
hex = "0.4.3"
|
||||
int-enum = "0.5.0"
|
||||
tokio-stream = "0.1.14"
|
||||
tokio-stream = { version = "0.1.14", features = ["fs"] }
|
||||
futures-concurrency = "7.4.3"
|
||||
async-channel = "1.9"
|
||||
async-channel = "2.0.0"
|
||||
tokio-util = { version = "0.7.8", features = ["io"] }
|
||||
slotmap = "1.0.6"
|
||||
flate2 = "1.0.27"
|
||||
|
@ -112,6 +112,7 @@ bytes = "1.5.0"
|
|||
reqwest = { version = "0.11.20", features = ["json", "native-tls-vendored"] }
|
||||
directories = "5.0.1"
|
||||
streamunordered = "0.5.3"
|
||||
async-recursion = "1.0.5"
|
||||
|
||||
# Override features of transitive dependencies
|
||||
[dependencies.openssl]
|
||||
|
|
542
core/src/api/ephemeral_files.rs
Normal file
542
core/src/api/ephemeral_files.rs
Normal file
|
@ -0,0 +1,542 @@
|
|||
use crate::{
|
||||
api::utils::library,
|
||||
invalidate_query,
|
||||
library::Library,
|
||||
location::file_path_helper::IsolatedFilePathData,
|
||||
object::{
|
||||
fs::{error::FileSystemJobsError, find_available_filename_for_duplicate},
|
||||
media::media_data_extractor::{
|
||||
can_extract_media_data_for_image, extract_media_data, MediaDataError,
|
||||
},
|
||||
},
|
||||
util::error::FileIOError,
|
||||
};
|
||||
|
||||
use sd_file_ext::extensions::ImageExtension;
|
||||
use sd_media_metadata::MediaMetadata;
|
||||
|
||||
use std::{ffi::OsStr, path::PathBuf, str::FromStr};
|
||||
|
||||
use async_recursion::async_recursion;
|
||||
use futures_concurrency::future::TryJoin;
|
||||
use regex::Regex;
|
||||
use rspc::{alpha::AlphaRouter, ErrorCode};
|
||||
use serde::Deserialize;
|
||||
use specta::Type;
|
||||
use tokio::{fs, io};
|
||||
use tokio_stream::{wrappers::ReadDirStream, StreamExt};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use super::{
|
||||
files::{create_directory, FromPattern},
|
||||
Ctx, R,
|
||||
};
|
||||
|
||||
const UNTITLED_FOLDER_STR: &str = "Untitled Folder";
|
||||
|
||||
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
.procedure("getMediaData", {
|
||||
R.query(|_, full_path: PathBuf| async move {
|
||||
let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// TODO(fogodev): change this when we have media data for audio and videos
|
||||
let image_extension = ImageExtension::from_str(extension).map_err(|e| {
|
||||
error!("Failed to parse image extension: {e:#?}");
|
||||
rspc::Error::new(ErrorCode::BadRequest, "Invalid image extension".to_string())
|
||||
})?;
|
||||
|
||||
if !can_extract_media_data_for_image(&image_extension) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match extract_media_data(full_path).await {
|
||||
Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))),
|
||||
Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath(
|
||||
_,
|
||||
))) => Ok(None),
|
||||
Err(e) => Err(rspc::Error::with_cause(
|
||||
ErrorCode::InternalServerError,
|
||||
"Failed to extract media data".to_string(),
|
||||
e,
|
||||
)),
|
||||
}
|
||||
})
|
||||
})
|
||||
.procedure("createFolder", {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct CreateEphemeralFolderArgs {
|
||||
pub path: PathBuf,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
R.with2(library()).mutation(
|
||||
|(_, library),
|
||||
CreateEphemeralFolderArgs { mut path, name }: CreateEphemeralFolderArgs| async move {
|
||||
path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR));
|
||||
|
||||
create_directory(path, &library).await
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("deleteFiles", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), paths: Vec<PathBuf>| async move {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| async move {
|
||||
match fs::metadata(&path).await {
|
||||
Ok(metadata) => if metadata.is_dir() {
|
||||
fs::remove_dir_all(&path).await
|
||||
} else {
|
||||
fs::remove_file(&path).await
|
||||
}
|
||||
.map_err(|e| FileIOError::from((path, e, "Failed to delete file"))),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(FileIOError::from((
|
||||
path,
|
||||
e,
|
||||
"Failed to get file metadata for deletion",
|
||||
))),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_join()
|
||||
.await?;
|
||||
|
||||
invalidate_query!(library, "search.ephemeralPaths");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.procedure("copyFiles", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), args: EphemeralFileSystemOps| async move {
|
||||
args.copy(&library).await
|
||||
})
|
||||
})
|
||||
.procedure("cutFiles", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), args: EphemeralFileSystemOps| async move {
|
||||
args.cut(&library).await
|
||||
})
|
||||
})
|
||||
.procedure("renameFile", {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct EphemeralRenameOne {
|
||||
pub from_path: PathBuf,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct EphemeralRenameMany {
|
||||
pub from_pattern: FromPattern,
|
||||
pub to_pattern: String,
|
||||
pub from_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
pub enum EphemeralRenameKind {
|
||||
One(EphemeralRenameOne),
|
||||
Many(EphemeralRenameMany),
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct EphemeralRenameFileArgs {
|
||||
pub kind: EphemeralRenameKind,
|
||||
}
|
||||
|
||||
impl EphemeralRenameFileArgs {
|
||||
pub async fn rename_one(
|
||||
EphemeralRenameOne { from_path, to }: EphemeralRenameOne,
|
||||
) -> Result<(), rspc::Error> {
|
||||
let Some(old_name) = from_path.file_name() else {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Missing file name on file to be renamed".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if old_name == OsStr::new(&to) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (new_file_name, new_extension) =
|
||||
IsolatedFilePathData::separate_name_and_extension_from_str(&to).map_err(
|
||||
|e| rspc::Error::with_cause(ErrorCode::BadRequest, e.to_string(), e),
|
||||
)?;
|
||||
|
||||
if !IsolatedFilePathData::accept_file_name(new_file_name) {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Invalid file name".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(parent) = from_path.parent() else {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Missing parent path on file to be renamed".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let new_file_full_path = parent.join(if !new_extension.is_empty() {
|
||||
&to
|
||||
} else {
|
||||
new_file_name
|
||||
});
|
||||
|
||||
match fs::metadata(&new_file_full_path).await {
|
||||
Ok(_) => Err(rspc::Error::new(
|
||||
ErrorCode::Conflict,
|
||||
"Renaming would overwrite a file".to_string(),
|
||||
)),
|
||||
|
||||
Err(e) => {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(rspc::Error::with_cause(
|
||||
ErrorCode::InternalServerError,
|
||||
"Failed to check if file exists".to_string(),
|
||||
e,
|
||||
));
|
||||
}
|
||||
|
||||
fs::rename(&from_path, new_file_full_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
FileIOError::from((from_path, e, "Failed to rename file"))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename_many(
|
||||
EphemeralRenameMany {
|
||||
ref from_pattern,
|
||||
ref to_pattern,
|
||||
from_paths,
|
||||
}: EphemeralRenameMany,
|
||||
) -> Result<(), rspc::Error> {
|
||||
let from_regex = &Regex::new(&from_pattern.pattern).map_err(|e| {
|
||||
rspc::Error::with_cause(
|
||||
rspc::ErrorCode::BadRequest,
|
||||
"Invalid `from` regex pattern".to_string(),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
from_paths
|
||||
.into_iter()
|
||||
.map(|old_path| async move {
|
||||
let Some(old_name) = old_path.file_name() else {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Missing file name on file to be renamed".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let Some(old_name_str) = old_name.to_str() else {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"File with non UTF-8 name".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let replaced_full_name = if from_pattern.replace_all {
|
||||
from_regex.replace_all(old_name_str, to_pattern)
|
||||
} else {
|
||||
from_regex.replace(old_name_str, to_pattern)
|
||||
};
|
||||
|
||||
if !IsolatedFilePathData::accept_file_name(replaced_full_name.as_ref())
|
||||
{
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Invalid file name".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(parent) = old_path.parent() else {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Missing parent path on file to be renamed".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let new_path = parent.join(replaced_full_name.as_ref());
|
||||
|
||||
fs::rename(&old_path, &new_path).await.map_err(|e| {
|
||||
error!(
|
||||
"Failed to rename file from: '{}' to: '{}'; Error: {e:#?}",
|
||||
old_path.display(),
|
||||
new_path.display()
|
||||
);
|
||||
let e = FileIOError::from((old_path, e, "Failed to rename file"));
|
||||
rspc::Error::with_cause(ErrorCode::Conflict, e.to_string(), e)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_join()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
R.with2(library()).mutation(
|
||||
|(_, library), EphemeralRenameFileArgs { kind }: EphemeralRenameFileArgs| async move {
|
||||
let res = match kind {
|
||||
EphemeralRenameKind::One(one) => {
|
||||
EphemeralRenameFileArgs::rename_one(one).await
|
||||
}
|
||||
EphemeralRenameKind::Many(many) => {
|
||||
EphemeralRenameFileArgs::rename_many(many).await
|
||||
}
|
||||
};
|
||||
|
||||
if res.is_ok() {
|
||||
invalidate_query!(library, "search.ephemeralPaths");
|
||||
}
|
||||
|
||||
res
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
struct EphemeralFileSystemOps {
|
||||
sources: Vec<PathBuf>,
|
||||
target_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl EphemeralFileSystemOps {
|
||||
async fn check_target_directory(&self) -> Result<(), rspc::Error> {
|
||||
match fs::metadata(&self.target_dir).await {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_dir() {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Target is not a directory".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
let e = FileIOError::from((&self.target_dir, e, "Target directory not found"));
|
||||
return Err(rspc::Error::with_cause(
|
||||
ErrorCode::BadRequest,
|
||||
e.to_string(),
|
||||
e,
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(FileIOError::from((
|
||||
&self.target_dir,
|
||||
e,
|
||||
"Failed to get target metadata",
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_sources(&self) -> Result<(), rspc::Error> {
|
||||
if self.sources.is_empty() {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::BadRequest,
|
||||
"Sources cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check(&self) -> Result<(), rspc::Error> {
|
||||
self.check_sources()?;
|
||||
self.check_target_directory().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn copy(self, library: &Library) -> Result<(), rspc::Error> {
|
||||
self.check().await?;
|
||||
|
||||
let EphemeralFileSystemOps {
|
||||
sources,
|
||||
target_dir,
|
||||
} = self;
|
||||
|
||||
let (directories_to_create, files_to_copy) = sources
|
||||
.into_iter()
|
||||
.filter_map(|source| {
|
||||
if let Some(name) = source.file_name() {
|
||||
let target = target_dir.join(name);
|
||||
Some((source, target))
|
||||
} else {
|
||||
warn!("Skipping file with no name: '{}'", source.display());
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|(source, target)| async move {
|
||||
match fs::metadata(&source).await {
|
||||
Ok(metadata) => Ok((source, target, metadata.is_dir())),
|
||||
Err(e) => Err(FileIOError::from((
|
||||
source,
|
||||
e,
|
||||
"Failed to get source file metadata",
|
||||
))),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_join()
|
||||
.await?
|
||||
.into_iter()
|
||||
.partition::<Vec<_>, _>(|(_, _, is_dir)| *is_dir);
|
||||
|
||||
files_to_copy
|
||||
.into_iter()
|
||||
.map(|(source, mut target, _)| async move {
|
||||
match fs::metadata(&target).await {
|
||||
Ok(_) => target = find_available_filename_for_duplicate(&target).await?,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
// Everything is awesome!
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(FileSystemJobsError::FileIO(FileIOError::from((
|
||||
target,
|
||||
e,
|
||||
"Failed to get target file metadata",
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
fs::copy(&source, target).await.map_err(|e| {
|
||||
FileSystemJobsError::FileIO(FileIOError::from((
|
||||
source,
|
||||
e,
|
||||
"Failed to copy file",
|
||||
)))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_join()
|
||||
.await?;
|
||||
|
||||
if !directories_to_create.is_empty() {
|
||||
directories_to_create
|
||||
.into_iter()
|
||||
.map(|(source, mut target, _)| async move {
|
||||
match fs::metadata(&target).await {
|
||||
Ok(_) => target = find_available_filename_for_duplicate(&target).await?,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
// Everything is awesome!
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(rspc::Error::from(FileIOError::from((
|
||||
target,
|
||||
e,
|
||||
"Failed to get target file metadata",
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
fs::create_dir_all(&target).await.map_err(|e| {
|
||||
FileIOError::from((&target, e, "Failed to create directory"))
|
||||
})?;
|
||||
|
||||
let more_files =
|
||||
ReadDirStream::new(fs::read_dir(&source).await.map_err(|e| {
|
||||
FileIOError::from((&source, e, "Failed to read directory to be copied"))
|
||||
})?)
|
||||
.map(|read_dir| match read_dir {
|
||||
Ok(dir_entry) => Ok(dir_entry.path()),
|
||||
Err(e) => Err(FileIOError::from((
|
||||
&source,
|
||||
e,
|
||||
"Failed to read directory to be copied",
|
||||
))),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.await?;
|
||||
|
||||
if !more_files.is_empty() {
|
||||
Self {
|
||||
sources: more_files,
|
||||
target_dir: target,
|
||||
}
|
||||
.copy(library)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_join()
|
||||
.await?;
|
||||
}
|
||||
|
||||
invalidate_query!(library, "search.ephemeralPaths");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cut(self, library: &Library) -> Result<(), rspc::Error> {
|
||||
self.check().await?;
|
||||
|
||||
let EphemeralFileSystemOps {
|
||||
sources,
|
||||
target_dir,
|
||||
} = self;
|
||||
|
||||
sources
|
||||
.into_iter()
|
||||
.filter_map(|source| {
|
||||
if let Some(name) = source.file_name() {
|
||||
let target = target_dir.join(name);
|
||||
Some((source, target))
|
||||
} else {
|
||||
warn!("Skipping file with no name: '{}'", source.display());
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|(source, target)| async move {
|
||||
match fs::metadata(&target).await {
|
||||
Ok(_) => {
|
||||
return Err(FileSystemJobsError::WouldOverwrite(
|
||||
target.into_boxed_path(),
|
||||
));
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
// Everything is awesome!
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(FileSystemJobsError::FileIO(FileIOError::from((
|
||||
source,
|
||||
e,
|
||||
"Failed to get target file metadata",
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&source, target).await.map_err(|e| {
|
||||
FileSystemJobsError::FileIO(FileIOError::from((
|
||||
source,
|
||||
e,
|
||||
"Failed to move file",
|
||||
)))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_join()
|
||||
.await?;
|
||||
|
||||
invalidate_query!(library, "search.ephemeralPaths");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -15,25 +15,19 @@ use crate::{
|
|||
erase::FileEraserJobInit, error::FileSystemJobsError,
|
||||
find_available_filename_for_duplicate,
|
||||
},
|
||||
media::{
|
||||
media_data_extractor::{
|
||||
can_extract_media_data_for_image, extract_media_data, MediaDataError,
|
||||
},
|
||||
media_data_image_from_prisma_data,
|
||||
},
|
||||
media::media_data_image_from_prisma_data,
|
||||
},
|
||||
prisma::{file_path, location, object},
|
||||
util::{db::maybe_missing, error::FileIOError},
|
||||
};
|
||||
|
||||
use sd_file_ext::{extensions::ImageExtension, kind::ObjectKind};
|
||||
use sd_file_ext::kind::ObjectKind;
|
||||
use sd_images::ConvertableExtension;
|
||||
use sd_media_metadata::MediaMetadata;
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
|
@ -93,35 +87,6 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
})
|
||||
})
|
||||
})
|
||||
.procedure("getEphemeralMediaData", {
|
||||
R.query(|_, full_path: PathBuf| async move {
|
||||
let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// TODO(fogodev): change this when we have media data for audio and videos
|
||||
let image_extension = ImageExtension::from_str(extension).map_err(|e| {
|
||||
error!("Failed to parse image extension: {e:#?}");
|
||||
rspc::Error::new(ErrorCode::BadRequest, "Invalid image extension".to_string())
|
||||
})?;
|
||||
|
||||
if !can_extract_media_data_for_image(&image_extension) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match extract_media_data(full_path).await {
|
||||
Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))),
|
||||
Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath(
|
||||
_,
|
||||
))) => Ok(None),
|
||||
Err(e) => Err(rspc::Error::with_cause(
|
||||
ErrorCode::InternalServerError,
|
||||
"Failed to extract media data".to_string(),
|
||||
e,
|
||||
)),
|
||||
}
|
||||
})
|
||||
})
|
||||
.procedure("getPath", {
|
||||
R.with2(library())
|
||||
.query(|(_, library), id: i32| async move {
|
||||
|
@ -223,23 +188,6 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
|
||||
path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR));
|
||||
|
||||
dbg!(&path);
|
||||
|
||||
create_directory(path, &library).await
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("createEphemeralFolder", {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct CreateEphemeralFolderArgs {
|
||||
pub path: PathBuf,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
R.with2(library()).mutation(
|
||||
|(_, library),
|
||||
CreateEphemeralFolderArgs { mut path, name }: CreateEphemeralFolderArgs| async move {
|
||||
path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR));
|
||||
|
||||
create_directory(path, &library).await
|
||||
},
|
||||
)
|
||||
|
@ -509,15 +457,6 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
.map_err(Into::into)
|
||||
})
|
||||
})
|
||||
.procedure("duplicateFiles", {
|
||||
R.with2(library())
|
||||
.mutation(|(node, library), args: FileCopierJobInit| async move {
|
||||
Job::new(args)
|
||||
.spawn(&node, &library)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
})
|
||||
})
|
||||
.procedure("copyFiles", {
|
||||
R.with2(library())
|
||||
.mutation(|(node, library), args: FileCopierJobInit| async move {
|
||||
|
@ -537,12 +476,6 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
})
|
||||
})
|
||||
.procedure("renameFile", {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct FromPattern {
|
||||
pub pattern: String,
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct RenameOne {
|
||||
pub from_file_path_id: file_path::id::Type,
|
||||
|
@ -747,7 +680,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
})
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
pub(super) async fn create_directory(
|
||||
mut target_path: PathBuf,
|
||||
library: &Library,
|
||||
) -> Result<String, rspc::Error> {
|
||||
|
@ -775,10 +708,9 @@ async fn create_directory(
|
|||
.await
|
||||
.map_err(|e| FileIOError::from((&target_path, e, "Failed to create directory")))?;
|
||||
|
||||
println!("Created directory: {}", target_path.display());
|
||||
|
||||
invalidate_query!(library, "search.objects");
|
||||
invalidate_query!(library, "search.paths");
|
||||
invalidate_query!(library, "search.ephemeralPaths");
|
||||
|
||||
Ok(target_path
|
||||
.file_name()
|
||||
|
@ -786,3 +718,9 @@ async fn create_directory(
|
|||
.to_string_lossy()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct FromPattern {
|
||||
pub pattern: String,
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ impl BackendFeature {
|
|||
mod auth;
|
||||
mod backups;
|
||||
mod categories;
|
||||
mod ephemeral_files;
|
||||
mod files;
|
||||
mod jobs;
|
||||
mod keys;
|
||||
|
@ -172,6 +173,7 @@ pub(crate) fn mount() -> Arc<Router> {
|
|||
.merge("categories.", categories::mount())
|
||||
// .merge("keys.", keys::mount())
|
||||
.merge("locations.", locations::mount())
|
||||
.merge("ephemeralFiles.", ephemeral_files::mount())
|
||||
.merge("files.", files::mount())
|
||||
.merge("jobs.", jobs::mount())
|
||||
.merge("p2p.", p2p::mount())
|
||||
|
|
|
@ -169,19 +169,20 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
}
|
||||
|
||||
if args.unassign {
|
||||
let query =
|
||||
db.tag_on_object().delete_many(vec![
|
||||
tag_on_object::tag_id::equals(args.tag_id),
|
||||
tag_on_object::object_id::in_vec(
|
||||
objects
|
||||
.iter()
|
||||
.map(|o| o.id)
|
||||
.chain(file_paths.iter().filter_map(|fp| {
|
||||
fp.object.as_ref().map(|o| o.id.clone())
|
||||
}))
|
||||
.collect(),
|
||||
),
|
||||
]);
|
||||
let query = db.tag_on_object().delete_many(vec![
|
||||
tag_on_object::tag_id::equals(args.tag_id),
|
||||
tag_on_object::object_id::in_vec(
|
||||
objects
|
||||
.iter()
|
||||
.map(|o| o.id)
|
||||
.chain(
|
||||
file_paths
|
||||
.iter()
|
||||
.filter_map(|fp| fp.object.as_ref().map(|o| o.id)),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
]);
|
||||
|
||||
sync.write_ops(
|
||||
db,
|
||||
|
|
|
@ -5,6 +5,7 @@ use std::{
|
|||
fmt,
|
||||
hash::{Hash, Hasher},
|
||||
mem,
|
||||
pin::pin,
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
|
@ -810,11 +811,13 @@ async fn handle_init_phase<SJob: StatefulJob>(
|
|||
|
||||
let init_abort_handle = init_task.abort_handle();
|
||||
|
||||
let mut msg_stream = (
|
||||
let mut msg_stream = pin!((
|
||||
stream::once(init_task).map(StreamMessage::<SJob>::InitResult),
|
||||
commands_rx.clone().map(StreamMessage::<SJob>::NewCommand),
|
||||
)
|
||||
.merge();
|
||||
.merge());
|
||||
|
||||
let mut commands_rx = pin!(commands_rx);
|
||||
|
||||
'messages: while let Some(msg) = msg_stream.next().await {
|
||||
match msg {
|
||||
|
@ -1036,11 +1039,13 @@ async fn handle_single_step<SJob: StatefulJob>(
|
|||
|
||||
let mut status = JobStatus::Running;
|
||||
|
||||
let mut msg_stream = (
|
||||
let mut msg_stream = pin!((
|
||||
stream::once(&mut step_task).map(StreamMessage::<SJob>::StepResult),
|
||||
commands_rx.clone().map(StreamMessage::<SJob>::NewCommand),
|
||||
)
|
||||
.merge();
|
||||
.merge());
|
||||
|
||||
let mut commands_rx = pin!(commands_rx);
|
||||
|
||||
'messages: while let Some(msg) = msg_stream.next().await {
|
||||
match msg {
|
||||
|
|
|
@ -2,6 +2,7 @@ use crate::{api::CoreEvent, invalidate_query, library::Library, Node};
|
|||
|
||||
use std::{
|
||||
fmt,
|
||||
pin::pin,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
|
@ -361,7 +362,7 @@ impl Worker {
|
|||
let mut last_reporter_watch_update = Instant::now();
|
||||
invalidate_query!(library, "jobs.reports");
|
||||
|
||||
let mut finalized_events_rx = events_rx.clone();
|
||||
let mut finalized_events_rx = pin!(events_rx.clone());
|
||||
|
||||
let mut is_paused = false;
|
||||
|
||||
|
@ -391,12 +392,12 @@ impl Worker {
|
|||
Tick,
|
||||
}
|
||||
|
||||
let mut msg_stream = (
|
||||
let mut msg_stream = pin!((
|
||||
stream::once(&mut run_task).map(StreamMessage::JobResult),
|
||||
events_rx.map(StreamMessage::NewEvent),
|
||||
IntervalStream::new(timeout_checker).map(|_| StreamMessage::Tick),
|
||||
)
|
||||
.merge();
|
||||
.merge());
|
||||
|
||||
let mut events_ended = false;
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ use crate::{
|
|||
use std::{
|
||||
hash::Hash,
|
||||
path::{Path, PathBuf},
|
||||
pin::pin,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
|
@ -225,19 +226,20 @@ impl StatefulJob for MediaProcessorJobInit {
|
|||
)),
|
||||
]);
|
||||
|
||||
let mut progress_rx =
|
||||
if let Some(progress_rx) = data.maybe_thumbnailer_progress_rx.clone() {
|
||||
progress_rx
|
||||
} else {
|
||||
let (progress_tx, progress_rx) = chan::unbounded();
|
||||
let mut progress_rx = pin!(if let Some(progress_rx) =
|
||||
data.maybe_thumbnailer_progress_rx.clone()
|
||||
{
|
||||
progress_rx
|
||||
} else {
|
||||
let (progress_tx, progress_rx) = chan::unbounded();
|
||||
|
||||
ctx.node
|
||||
.thumbnailer
|
||||
.register_reporter(self.location.id, progress_tx)
|
||||
.await;
|
||||
ctx.node
|
||||
.thumbnailer
|
||||
.register_reporter(self.location.id, progress_tx)
|
||||
.await;
|
||||
|
||||
progress_rx
|
||||
};
|
||||
progress_rx
|
||||
});
|
||||
|
||||
let mut total_completed = 0;
|
||||
|
||||
|
|
|
@ -272,7 +272,6 @@ impl Thumbnailer {
|
|||
|
||||
#[inline]
|
||||
pub async fn shutdown(&self) {
|
||||
let start = Instant::now();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.cancel_tx
|
||||
.send(tx)
|
||||
|
@ -281,8 +280,6 @@ impl Thumbnailer {
|
|||
|
||||
rx.await
|
||||
.expect("critical thumbnailer error: failed to receive shutdown signal response");
|
||||
|
||||
debug!("Thumbnailer has been shutdown in {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// WARNING!!!! DON'T USE THIS METHOD IN A LOOP!!!!!!!!!!!!! It will be pretty slow on purpose!
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::api::CoreEvent;
|
||||
|
||||
use std::{collections::HashMap, ffi::OsString, path::PathBuf, sync::Arc};
|
||||
use std::{collections::HashMap, ffi::OsString, path::PathBuf, pin::pin, sync::Arc};
|
||||
|
||||
use sd_prisma::prisma::location;
|
||||
|
||||
|
@ -78,12 +78,12 @@ pub(super) async fn worker(
|
|||
let (batch_report_progress_tx, batch_report_progress_rx) = chan::bounded(8);
|
||||
let (stop_older_processing_tx, stop_older_processing_rx) = chan::bounded(1);
|
||||
|
||||
let mut shutdown_leftovers_rx = leftovers_rx.clone();
|
||||
let mut shutdowm_batch_report_progress_rx = batch_report_progress_rx.clone();
|
||||
let mut shutdown_leftovers_rx = pin!(leftovers_rx.clone());
|
||||
let mut shutdowm_batch_report_progress_rx = pin!(batch_report_progress_rx.clone());
|
||||
|
||||
let mut current_batch_processing_rx: Option<oneshot::Receiver<()>> = None;
|
||||
|
||||
let mut msg_stream = (
|
||||
let mut msg_stream = pin!((
|
||||
IntervalStream::new(to_remove_interval).map(|_| StreamMessage::RemovalTick),
|
||||
cas_ids_to_delete_rx.map(StreamMessage::ToDelete),
|
||||
databases_rx.map(StreamMessage::Database),
|
||||
|
@ -95,7 +95,7 @@ pub(super) async fn worker(
|
|||
cancel_rx.map(StreamMessage::Shutdown),
|
||||
IntervalStream::new(idle_interval).map(|_| StreamMessage::IdleTick),
|
||||
)
|
||||
.merge();
|
||||
.merge());
|
||||
|
||||
while let Some(msg) = msg_stream.next().await {
|
||||
match msg {
|
||||
|
@ -258,24 +258,27 @@ pub(super) async fn worker(
|
|||
|
||||
StreamMessage::Shutdown(cancel_tx) => {
|
||||
debug!("Thumbnail actor is shutting down...");
|
||||
let start = Instant::now();
|
||||
|
||||
// First stopping the current batch processing
|
||||
let (tx, rx) = oneshot::channel();
|
||||
match stop_older_processing_tx.try_send(tx) {
|
||||
Ok(()) => {
|
||||
// We put a timeout here to avoid a deadlock in case the older processing already
|
||||
// finished its batch
|
||||
if timeout(ONE_SEC, rx).await.is_err() {
|
||||
if current_batch_processing_rx.is_some() {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
match stop_older_processing_tx.try_send(tx) {
|
||||
Ok(()) => {
|
||||
// We put a timeout here to avoid a deadlock in case the older processing already
|
||||
// finished its batch
|
||||
if timeout(ONE_SEC, rx).await.is_err() {
|
||||
stop_older_processing_rx.recv().await.ok();
|
||||
}
|
||||
}
|
||||
Err(e) if e.is_full() => {
|
||||
// The last signal we sent happened after a batch was already processed
|
||||
// So we clean the channel and we're good to go.
|
||||
stop_older_processing_rx.recv().await.ok();
|
||||
}
|
||||
}
|
||||
Err(e) if e.is_full() => {
|
||||
// The last signal we sent happened after a batch was already processed
|
||||
// So we clean the channel and we're good to go.
|
||||
stop_older_processing_rx.recv().await.ok();
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Thumbnail actor died when trying to stop older processing");
|
||||
Err(_) => {
|
||||
error!("Thumbnail actor died when trying to stop older processing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -312,6 +315,8 @@ pub(super) async fn worker(
|
|||
|
||||
// Signaling that we're done shutting down
|
||||
cancel_tx.send(()).ok();
|
||||
|
||||
debug!("Thumbnailer has been shutdown in {:?}", start.elapsed());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -13,17 +13,35 @@ import { useContextMenuContext } from '../context';
|
|||
export const CutCopyItems = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { parent } = useExplorerContext();
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
const { selectedFilePaths, selectedEphemeralPaths } = useContextMenuContext();
|
||||
|
||||
if (parent?.type !== 'Location' || !isNonEmpty(selectedFilePaths)) return null;
|
||||
if (
|
||||
(parent?.type !== 'Location' && parent?.type !== 'Ephemeral') ||
|
||||
(!isNonEmpty(selectedFilePaths) && !isNonEmpty(selectedEphemeralPaths))
|
||||
)
|
||||
return null;
|
||||
|
||||
return { locationId: parent.location.id, selectedFilePaths };
|
||||
return { parent, selectedFilePaths, selectedEphemeralPaths };
|
||||
},
|
||||
Component: ({ locationId, selectedFilePaths }) => {
|
||||
Component: ({ parent, selectedFilePaths, selectedEphemeralPaths }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles');
|
||||
|
||||
const indexedArgs =
|
||||
parent.type === 'Location' && isNonEmpty(selectedFilePaths)
|
||||
? {
|
||||
sourceLocationId: parent.location.id,
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id)
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const ephemeralArgs =
|
||||
parent.type === 'Ephemeral' && isNonEmpty(selectedEphemeralPaths)
|
||||
? { sourcePaths: selectedEphemeralPaths.map((p) => p.path) }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -33,8 +51,8 @@ export const CutCopyItems = new ConditionalItem({
|
|||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: path ?? '/',
|
||||
sourceLocationId: locationId,
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id),
|
||||
indexedArgs,
|
||||
ephemeralArgs,
|
||||
type: 'Cut'
|
||||
};
|
||||
}}
|
||||
|
@ -47,8 +65,8 @@ export const CutCopyItems = new ConditionalItem({
|
|||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: path ?? '/',
|
||||
sourceLocationId: locationId,
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id),
|
||||
indexedArgs,
|
||||
ephemeralArgs,
|
||||
type: 'Copy'
|
||||
};
|
||||
}}
|
||||
|
@ -60,12 +78,24 @@ export const CutCopyItems = new ConditionalItem({
|
|||
keybind={keybind([ModifierKeys.Control], ['D'])}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: locationId,
|
||||
sources_file_path_ids: selectedFilePaths.map((p) => p.id),
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: path ?? '/'
|
||||
});
|
||||
if (parent.type === 'Location' && isNonEmpty(selectedFilePaths)) {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: parent.location.id,
|
||||
sources_file_path_ids: selectedFilePaths.map((p) => p.id),
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path ?? '/'
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'Ephemeral' &&
|
||||
isNonEmpty(selectedEphemeralPaths)
|
||||
) {
|
||||
await copyEphemeralFiles.mutateAsync({
|
||||
sources: selectedEphemeralPaths.map((p) => p.path),
|
||||
target_dir: path ?? '/'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
title: 'Failed to duplicate file',
|
||||
|
|
|
@ -17,21 +17,38 @@ export * from './CutCopyItems';
|
|||
|
||||
export const Delete = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths)) return null;
|
||||
const { selectedFilePaths, selectedEphemeralPaths } = useContextMenuContext();
|
||||
|
||||
const locationId = selectedFilePaths[0].location_id;
|
||||
if (locationId === null) return null;
|
||||
if (!isNonEmpty(selectedFilePaths) && !isNonEmpty(selectedEphemeralPaths)) return null;
|
||||
|
||||
return { selectedFilePaths, locationId };
|
||||
return { selectedFilePaths, selectedEphemeralPaths };
|
||||
},
|
||||
Component: ({ selectedFilePaths, locationId }) => {
|
||||
Component: ({ selectedFilePaths, selectedEphemeralPaths }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
const rescan = useQuickRescan();
|
||||
|
||||
const dirCount = selectedFilePaths.filter((p) => p.is_dir).length;
|
||||
const fileCount = selectedFilePaths.filter((p) => !p.is_dir).length;
|
||||
const dirCount =
|
||||
selectedFilePaths.filter((p) => p.is_dir).length +
|
||||
selectedEphemeralPaths.filter((p) => p.is_dir).length;
|
||||
const fileCount =
|
||||
selectedFilePaths.filter((p) => !p.is_dir).length +
|
||||
selectedEphemeralPaths.filter((p) => !p.is_dir).length;
|
||||
|
||||
const indexedArgs =
|
||||
isNonEmpty(selectedFilePaths) && selectedFilePaths[0].location_id
|
||||
? {
|
||||
locationId: selectedFilePaths[0].location_id,
|
||||
rescan,
|
||||
pathIds: selectedFilePaths.map((p) => p.id)
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const ephemeralArgs = isNonEmpty(selectedEphemeralPaths)
|
||||
? {
|
||||
paths: selectedEphemeralPaths.map((p) => p.path)
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
|
@ -43,9 +60,8 @@ export const Delete = new ConditionalItem({
|
|||
dialogManager.create((dp) => (
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
rescan={rescan}
|
||||
locationId={locationId}
|
||||
pathIds={selectedFilePaths.map((p) => p.id)}
|
||||
indexedArgs={indexedArgs}
|
||||
ephemeralArgs={ephemeralArgs}
|
||||
dirCount={dirCount}
|
||||
fileCount={fileCount}
|
||||
/>
|
||||
|
@ -58,16 +74,29 @@ export const Delete = new ConditionalItem({
|
|||
|
||||
export const CopyAsPath = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths) || selectedFilePaths.length > 1) return null;
|
||||
const { selectedFilePaths, selectedEphemeralPaths } = useContextMenuContext();
|
||||
if (
|
||||
!isNonEmpty(selectedFilePaths) ||
|
||||
selectedFilePaths.length > 1 ||
|
||||
!isNonEmpty(selectedEphemeralPaths) ||
|
||||
selectedEphemeralPaths.length > 1 ||
|
||||
(selectedFilePaths.length === 1 && selectedEphemeralPaths.length === 1) // should never happen
|
||||
)
|
||||
return null;
|
||||
|
||||
return { selectedFilePaths };
|
||||
return { selectedFilePaths, selectedEphemeralPaths };
|
||||
},
|
||||
Component: ({ selectedFilePaths }) => (
|
||||
<CopyAsPathBase
|
||||
getPath={() => libraryClient.query(['files.getPath', selectedFilePaths[0].id])}
|
||||
/>
|
||||
)
|
||||
Component: ({ selectedFilePaths, selectedEphemeralPaths }) => {
|
||||
if (selectedFilePaths.length === 1) {
|
||||
return (
|
||||
<CopyAsPathBase
|
||||
getPath={() => libraryClient.query(['files.getPath', selectedFilePaths[0].id])}
|
||||
/>
|
||||
);
|
||||
} else if (selectedEphemeralPaths.length === 1) {
|
||||
return <CopyAsPathBase getPath={async () => selectedEphemeralPaths[0].path} />;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const Compress = new ConditionalItem({
|
||||
|
|
|
@ -96,12 +96,7 @@ export const Rename = new ConditionalItem({
|
|||
|
||||
const settings = useExplorerContext().useSettingsSnapshot();
|
||||
|
||||
if (
|
||||
settings.layoutMode === 'media' ||
|
||||
selectedItems.length > 1 ||
|
||||
selectedItems.some((item) => item.type === 'NonIndexedPath')
|
||||
)
|
||||
return null;
|
||||
if (settings.layoutMode === 'media' || selectedItems.length > 1) return null;
|
||||
|
||||
return {};
|
||||
},
|
||||
|
|
|
@ -3,9 +3,14 @@ import { CheckBox, Dialog, Tooltip, useDialog, UseDialogProps } from '@sd/ui';
|
|||
import { Icon } from '~/components';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
locationId: number;
|
||||
rescan?: () => void;
|
||||
pathIds: number[];
|
||||
indexedArgs?: {
|
||||
locationId: number;
|
||||
rescan?: () => void;
|
||||
pathIds: number[];
|
||||
};
|
||||
ephemeralArgs?: {
|
||||
paths: string[];
|
||||
};
|
||||
dirCount?: number;
|
||||
fileCount?: number;
|
||||
}
|
||||
|
@ -39,9 +44,10 @@ function getWording(dirCount: number, fileCount: number) {
|
|||
|
||||
export default (props: Props) => {
|
||||
const deleteFile = useLibraryMutation('files.deleteFiles');
|
||||
const deleteEphemeralFile = useLibraryMutation('ephemeralFiles.deleteFiles');
|
||||
|
||||
const form = useZodForm();
|
||||
const { dirCount = 0, fileCount = 0 } = props;
|
||||
const { dirCount = 0, fileCount = 0, indexedArgs, ephemeralArgs } = props;
|
||||
|
||||
const { type, prefix } = getWording(dirCount, fileCount);
|
||||
|
||||
|
@ -52,12 +58,20 @@ export default (props: Props) => {
|
|||
<Dialog
|
||||
form={form}
|
||||
onSubmit={form.handleSubmit(async () => {
|
||||
await deleteFile.mutateAsync({
|
||||
location_id: props.locationId,
|
||||
file_path_ids: props.pathIds
|
||||
});
|
||||
if (indexedArgs != undefined) {
|
||||
const { locationId, rescan, pathIds } = indexedArgs;
|
||||
await deleteFile.mutateAsync({
|
||||
location_id: locationId,
|
||||
file_path_ids: pathIds
|
||||
});
|
||||
|
||||
props.rescan?.();
|
||||
rescan?.();
|
||||
}
|
||||
|
||||
if (ephemeralArgs != undefined) {
|
||||
const { paths } = ephemeralArgs;
|
||||
await deleteEphemeralFile.mutateAsync(paths);
|
||||
}
|
||||
})}
|
||||
icon={<Icon theme="light" name={icon} size={28} />}
|
||||
dialog={useDialog(props)}
|
||||
|
|
|
@ -226,7 +226,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
|||
});
|
||||
|
||||
const ephemeralLocationMediaData = useBridgeQuery(
|
||||
['files.getEphemeralMediaData', ephemeralPathData != null ? ephemeralPathData.path : ''],
|
||||
['ephemeralFiles.getMediaData', ephemeralPathData != null ? ephemeralPathData.path : ''],
|
||||
{
|
||||
enabled: ephemeralPathData?.kind === ObjectKindEnum.Image && readyToFetch
|
||||
}
|
||||
|
|
|
@ -23,65 +23,105 @@ export default (props: PropsWithChildren) => {
|
|||
const objectValidator = useLibraryMutation('jobs.objectValidator');
|
||||
const rescanLocation = useLibraryMutation('locations.subPathRescan');
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles');
|
||||
const cutFiles = useLibraryMutation('files.cutFiles');
|
||||
const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles');
|
||||
|
||||
return (
|
||||
<CM.Root trigger={props.children}>
|
||||
{parent?.type === 'Location' && cutCopyState.type !== 'Idle' && (
|
||||
<>
|
||||
<CM.Item
|
||||
label="Paste"
|
||||
keybind={keybind([ModifierKeys.Control], ['V'])}
|
||||
onClick={async () => {
|
||||
const path = currentPath ?? '/';
|
||||
const { type, sourcePathIds, sourceParentPath, sourceLocationId } =
|
||||
cutCopyState;
|
||||
{(parent?.type === 'Location' || parent?.type === 'Ephemeral') &&
|
||||
cutCopyState.type !== 'Idle' && (
|
||||
<>
|
||||
<CM.Item
|
||||
label="Paste"
|
||||
keybind={keybind([ModifierKeys.Control], ['V'])}
|
||||
onClick={async () => {
|
||||
const path = currentPath ?? '/';
|
||||
const { type, sourceParentPath, indexedArgs, ephemeralArgs } =
|
||||
cutCopyState;
|
||||
|
||||
const sameLocation =
|
||||
sourceLocationId === parent.location.id &&
|
||||
sourceParentPath === path;
|
||||
try {
|
||||
if (type == 'Copy') {
|
||||
if (
|
||||
parent?.type === 'Location' &&
|
||||
indexedArgs != undefined
|
||||
) {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: indexedArgs.sourceLocationId,
|
||||
sources_file_path_ids: [
|
||||
...indexedArgs.sourcePathIds
|
||||
],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (type == 'Copy') {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [...sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
} else if (sameLocation) {
|
||||
toast.error('File already exists in this location');
|
||||
} else {
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [...sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
if (
|
||||
parent?.type === 'Ephemeral' &&
|
||||
ephemeralArgs != undefined
|
||||
) {
|
||||
await copyEphemeralFiles.mutateAsync({
|
||||
sources: [...ephemeralArgs.sourcePaths],
|
||||
target_dir: path
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
parent?.type === 'Location' &&
|
||||
indexedArgs != undefined
|
||||
) {
|
||||
if (
|
||||
indexedArgs.sourceLocationId ===
|
||||
parent.location.id &&
|
||||
sourceParentPath === path
|
||||
) {
|
||||
toast.error('File already exists in this location');
|
||||
}
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: indexedArgs.sourceLocationId,
|
||||
sources_file_path_ids: [
|
||||
...indexedArgs.sourcePathIds
|
||||
],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
parent?.type === 'Ephemeral' &&
|
||||
ephemeralArgs != undefined
|
||||
) {
|
||||
if (sourceParentPath !== path) {
|
||||
await cutEphemeralFiles.mutateAsync({
|
||||
sources: [...ephemeralArgs.sourcePaths],
|
||||
target_dir: path
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
title: `Failed to ${type.toLowerCase()} file`,
|
||||
body: `Error: ${error}.`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
title: `Failed to ${type.toLowerCase()} file`,
|
||||
body: `Error: ${error}.`
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={Clipboard}
|
||||
/>
|
||||
}}
|
||||
icon={Clipboard}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Deselect"
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
type: 'Idle'
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
<CM.Item
|
||||
label="Deselect"
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
type: 'Idle'
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
|
||||
<CM.Separator />
|
||||
</>
|
||||
)}
|
||||
<CM.Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CM.Item
|
||||
label="Share"
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
useState
|
||||
} from 'react';
|
||||
import {
|
||||
getEphemeralPath,
|
||||
getExplorerItemData,
|
||||
getIndexedItemFilePath,
|
||||
ObjectKindKey,
|
||||
|
@ -90,6 +91,11 @@ export const QuickPreview = () => {
|
|||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
|
||||
});
|
||||
|
||||
const renameEphemeralFile = useLibraryMutation(['ephemeralFiles.renameFile'], {
|
||||
onError: () => setNewName(null),
|
||||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
|
||||
});
|
||||
|
||||
const changeCurrentItem = (index: number) => {
|
||||
if (items[index]) getQuickPreviewStore().itemIndex = index;
|
||||
};
|
||||
|
@ -204,17 +210,33 @@ export const QuickPreview = () => {
|
|||
|
||||
const path = getIndexedItemFilePath(item);
|
||||
|
||||
if (!path || path.location_id === null) return;
|
||||
if (path != null && path.location_id !== null) {
|
||||
return dialogManager.create((dp) => (
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
indexedArgs={{
|
||||
locationId: path.location_id!,
|
||||
pathIds: [path.id]
|
||||
}}
|
||||
dirCount={path.is_dir ? 1 : 0}
|
||||
fileCount={path.is_dir ? 0 : 1}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
dialogManager.create((dp) => (
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
locationId={path.location_id!}
|
||||
pathIds={[path.id]}
|
||||
dirCount={path.is_dir ? 1 : 0}
|
||||
fileCount={path.is_dir ? 0 : 1}
|
||||
/>
|
||||
));
|
||||
const ephemeralFile = getEphemeralPath(item);
|
||||
if (ephemeralFile != null) {
|
||||
return dialogManager.create((dp) => (
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
ephemeralArgs={{
|
||||
paths: [ephemeralFile.path]
|
||||
}}
|
||||
dirCount={ephemeralFile.is_dir ? 1 : 0}
|
||||
fileCount={ephemeralFile.is_dir ? 0 : 1}
|
||||
/>
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
if (!item) return null;
|
||||
|
@ -321,48 +343,82 @@ export const QuickPreview = () => {
|
|||
onRename={(newName) => {
|
||||
setIsRenaming(false);
|
||||
|
||||
if (
|
||||
!('id' in item.item) ||
|
||||
!newName ||
|
||||
newName === name
|
||||
)
|
||||
return;
|
||||
if (!newName || newName === name) return;
|
||||
|
||||
const filePathData =
|
||||
getIndexedItemFilePath(item);
|
||||
try {
|
||||
switch (item.type) {
|
||||
case 'Path':
|
||||
case 'Object': {
|
||||
const filePathData =
|
||||
getIndexedItemFilePath(item);
|
||||
|
||||
if (!filePathData) return;
|
||||
if (!filePathData)
|
||||
throw new Error(
|
||||
'Failed to get file path object'
|
||||
);
|
||||
|
||||
const locationId = filePathData.location_id;
|
||||
const { id, location_id } =
|
||||
filePathData;
|
||||
|
||||
if (locationId === null) return;
|
||||
if (!location_id)
|
||||
throw new Error(
|
||||
'Missing location id'
|
||||
);
|
||||
|
||||
renameFile.mutate({
|
||||
location_id: locationId,
|
||||
kind: {
|
||||
One: {
|
||||
from_file_path_id: item.item.id,
|
||||
to: newName
|
||||
renameFile.mutate({
|
||||
location_id,
|
||||
kind: {
|
||||
One: {
|
||||
from_file_path_id: id,
|
||||
to: newName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
case 'NonIndexedPath': {
|
||||
const ephemeralFile =
|
||||
getEphemeralPath(item);
|
||||
|
||||
setNewName(newName);
|
||||
if (!ephemeralFile)
|
||||
throw new Error(
|
||||
'Failed to get ephemeral file object'
|
||||
);
|
||||
|
||||
renameEphemeralFile.mutate({
|
||||
kind: {
|
||||
One: {
|
||||
from_path:
|
||||
ephemeralFile.path,
|
||||
to: newName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
'Invalid explorer item type'
|
||||
);
|
||||
}
|
||||
|
||||
setNewName(newName);
|
||||
} catch (e) {
|
||||
toast.error({
|
||||
title: `Could not rename ${itemData.fullName} to ${newName}`,
|
||||
body: `Error: ${e}.`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip label={name} className="truncate">
|
||||
<span
|
||||
onClick={() =>
|
||||
name &&
|
||||
item.type !== 'NonIndexedPath' &&
|
||||
setIsRenaming(true)
|
||||
}
|
||||
className={clsx(
|
||||
item.type === 'NonIndexedPath'
|
||||
? 'cursor-default'
|
||||
: 'cursor-text'
|
||||
)}
|
||||
onClick={() => name && setIsRenaming(true)}
|
||||
className={clsx('cursor-text')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
@ -393,12 +449,10 @@ export const QuickPreview = () => {
|
|||
]}
|
||||
/>
|
||||
|
||||
{item.type !== 'NonIndexedPath' && (
|
||||
<DropdownMenu.Item
|
||||
label="Rename"
|
||||
onClick={() => name && setIsRenaming(true)}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu.Item
|
||||
label="Rename"
|
||||
onClick={() => name && setIsRenaming(true)}
|
||||
/>
|
||||
|
||||
<SeparatedConditional
|
||||
items={[ObjectItems.AssignTag]}
|
||||
|
|
|
@ -49,6 +49,16 @@ export const useExplorerTopBarOptions = () => {
|
|||
rescan();
|
||||
}
|
||||
});
|
||||
const createEphemeralFolder = useLibraryMutation(['ephemeralFiles.createFolder'], {
|
||||
onError: (e) => {
|
||||
toast.error({ title: 'Error creating folder', body: `Error: ${e}.` });
|
||||
console.error(e);
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.success({ title: `Created new folder "${folder}"` });
|
||||
rescan();
|
||||
}
|
||||
});
|
||||
|
||||
const viewOptions = useMemo(
|
||||
() =>
|
||||
|
@ -123,15 +133,22 @@ export const useExplorerTopBarOptions = () => {
|
|||
});
|
||||
|
||||
const toolOptions = [
|
||||
parent?.type === 'Location' && {
|
||||
(parent?.type === 'Location' || parent?.type === 'Ephemeral') && {
|
||||
toolTipLabel: 'New Folder',
|
||||
icon: <FolderPlus className={TOP_BAR_ICON_STYLE} />,
|
||||
onClick: () => {
|
||||
createFolder.mutate({
|
||||
location_id: parent.location.id,
|
||||
sub_path: path || null,
|
||||
name: null
|
||||
});
|
||||
if (parent?.type === 'Location') {
|
||||
createFolder.mutate({
|
||||
location_id: parent.location.id,
|
||||
sub_path: path || null,
|
||||
name: null
|
||||
});
|
||||
} else {
|
||||
createEphemeralFolder.mutate({
|
||||
path: parent?.path,
|
||||
name: null
|
||||
});
|
||||
}
|
||||
},
|
||||
individual: true,
|
||||
showAtResolution: 'xs:flex'
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import clsx from 'clsx';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import {
|
||||
getEphemeralPath,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
getIndexedItemFilePath,
|
||||
useLibraryMutation,
|
||||
useRspcLibraryContext,
|
||||
type ExplorerItem
|
||||
|
@ -41,6 +42,11 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
|
||||
});
|
||||
|
||||
const renameEphemeralFile = useLibraryMutation(['ephemeralFiles.renameFile'], {
|
||||
onError: () => reset(),
|
||||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
|
||||
});
|
||||
|
||||
const renameLocation = useLibraryMutation(['locations.update'], {
|
||||
onError: () => reset(),
|
||||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
|
||||
|
@ -71,11 +77,11 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const filePathData = getItemFilePath(item);
|
||||
case 'Path':
|
||||
case 'Object': {
|
||||
const filePathData = getIndexedItemFilePath(item);
|
||||
|
||||
if (!filePathData || !('id' in filePathData))
|
||||
throw new Error('Unable to rename file');
|
||||
if (!filePathData) throw new Error('Failed to get file path object');
|
||||
|
||||
const { id, location_id } = filePathData;
|
||||
|
||||
|
@ -90,7 +96,29 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'NonIndexedPath': {
|
||||
const ephemeralFile = getEphemeralPath(item);
|
||||
|
||||
if (!ephemeralFile) throw new Error('Failed to get ephemeral file object');
|
||||
|
||||
renameEphemeralFile.mutate({
|
||||
kind: {
|
||||
One: {
|
||||
from_path: ephemeralFile.path,
|
||||
to: newName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Invalid explorer item type');
|
||||
}
|
||||
} catch (e) {
|
||||
reset();
|
||||
|
@ -105,7 +133,6 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
!selected ||
|
||||
explorer.selectedItems.size > 1 ||
|
||||
quickPreviewStore.open ||
|
||||
item.type === 'NonIndexedPath' ||
|
||||
item.type === 'SpacedropPeer';
|
||||
|
||||
return (
|
||||
|
|
|
@ -114,8 +114,13 @@ type CutCopyState =
|
|||
| {
|
||||
type: 'Cut' | 'Copy';
|
||||
sourceParentPath: string; // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
|
||||
sourceLocationId: number;
|
||||
sourcePathIds: number[];
|
||||
indexedArgs?: {
|
||||
sourceLocationId: number;
|
||||
sourcePathIds: number[];
|
||||
};
|
||||
ephemeralArgs?: {
|
||||
sourcePaths: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const state = {
|
||||
|
@ -156,9 +161,24 @@ export function getExplorerStore() {
|
|||
}
|
||||
|
||||
export function isCut(item: ExplorerItem, cutCopyState: ReadonlyDeep<CutCopyState>) {
|
||||
return item.type === 'NonIndexedPath' || item.type === 'SpacedropPeer'
|
||||
? false
|
||||
: cutCopyState.type === 'Cut' && cutCopyState.sourcePathIds.includes(item.item.id);
|
||||
switch (item.type) {
|
||||
case 'NonIndexedPath':
|
||||
return (
|
||||
cutCopyState.type === 'Cut' &&
|
||||
cutCopyState.ephemeralArgs != undefined &&
|
||||
cutCopyState.ephemeralArgs.sourcePaths.includes(item.item.path)
|
||||
);
|
||||
|
||||
case 'Path':
|
||||
return (
|
||||
cutCopyState.type === 'Cut' &&
|
||||
cutCopyState.indexedArgs != undefined &&
|
||||
cutCopyState.indexedArgs.sourcePathIds.includes(item.item.id)
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const filePathOrderingKeysSchema = z.union([
|
||||
|
|
|
@ -20,6 +20,10 @@ export type ExplorerParent =
|
|||
location: Location;
|
||||
subPath?: FilePath;
|
||||
}
|
||||
| {
|
||||
type: 'Ephemeral';
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: 'Tag';
|
||||
tag: Tag;
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
getDismissibleNoticeStore,
|
||||
useDismissibleNoticeStore,
|
||||
useIsDark,
|
||||
useKeyDeleteFile,
|
||||
useOperatingSystem,
|
||||
useZodSearchParams
|
||||
} from '~/hooks';
|
||||
|
@ -203,10 +204,13 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
|
|||
|
||||
const explorer = useExplorer({
|
||||
items,
|
||||
parent: path != null ? { type: 'Ephemeral', path } : undefined,
|
||||
settings: explorerSettings,
|
||||
layouts: { media: false }
|
||||
});
|
||||
|
||||
useKeyDeleteFile(explorer.selectedItems, null);
|
||||
|
||||
return (
|
||||
<ExplorerContextProvider explorer={explorer}>
|
||||
<TopBarPortal
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { useKeys } from 'rooks';
|
||||
import { useItemsAsFilePaths, useLibraryMutation } from '@sd/client';
|
||||
import { useItemsAsEphemeralPaths, useItemsAsFilePaths, useLibraryMutation } from '@sd/client';
|
||||
import { toast } from '@sd/ui';
|
||||
import { useExplorerContext } from '~/app/$libraryId/Explorer/Context';
|
||||
import { getExplorerStore, useExplorerStore } from '~/app/$libraryId/Explorer/store';
|
||||
import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import { useKeyMatcher } from './useKeyMatcher';
|
||||
|
||||
|
@ -13,17 +14,36 @@ export const useKeyCopyCutPaste = () => {
|
|||
|
||||
const metaCtrlKey = useKeyMatcher('Meta').key;
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles');
|
||||
const cutFiles = useLibraryMutation('files.cutFiles');
|
||||
const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles');
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const { parent } = explorer;
|
||||
|
||||
const selectedFilePaths = useItemsAsFilePaths(Array.from(explorer.selectedItems));
|
||||
const selectedEphemeralPaths = useItemsAsEphemeralPaths(Array.from(explorer.selectedItems));
|
||||
|
||||
const indexedArgs =
|
||||
parent?.type === 'Location' && !isNonEmpty(selectedFilePaths)
|
||||
? {
|
||||
sourceLocationId: parent.location.id,
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id)
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const ephemeralArgs =
|
||||
parent?.type === 'Ephemeral' && !isNonEmpty(selectedEphemeralPaths)
|
||||
? { sourcePaths: selectedEphemeralPaths.map((p) => p.path) }
|
||||
: undefined;
|
||||
|
||||
useKeys([metaCtrlKey, 'KeyC'], (e) => {
|
||||
e.stopPropagation();
|
||||
if (explorer.parent?.type === 'Location') {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: path ?? '/',
|
||||
sourceLocationId: explorer.parent.location.id,
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id),
|
||||
indexedArgs,
|
||||
ephemeralArgs,
|
||||
type: 'Copy'
|
||||
};
|
||||
}
|
||||
|
@ -34,8 +54,8 @@ export const useKeyCopyCutPaste = () => {
|
|||
if (explorer.parent?.type === 'Location') {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: path ?? '/',
|
||||
sourceLocationId: explorer.parent.location.id,
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id),
|
||||
indexedArgs,
|
||||
ephemeralArgs,
|
||||
type: 'Cut'
|
||||
};
|
||||
}
|
||||
|
@ -44,29 +64,54 @@ export const useKeyCopyCutPaste = () => {
|
|||
useKeys([metaCtrlKey, 'KeyV'], async (e) => {
|
||||
e.stopPropagation();
|
||||
const parent = explorer.parent;
|
||||
if (parent?.type === 'Location' && cutCopyState.type !== 'Idle' && path) {
|
||||
const { type, sourcePathIds, sourceParentPath, sourceLocationId } = cutCopyState;
|
||||
|
||||
const sameLocation =
|
||||
sourceLocationId === parent.location.id && sourceParentPath === path;
|
||||
if (
|
||||
(parent?.type === 'Location' || parent?.type === 'Ephemeral') &&
|
||||
cutCopyState.type !== 'Idle' &&
|
||||
path
|
||||
) {
|
||||
const { type, sourceParentPath, indexedArgs, ephemeralArgs } = cutCopyState;
|
||||
|
||||
try {
|
||||
if (type == 'Copy') {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [...sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
} else if (sameLocation) {
|
||||
toast.error('File already exists in this location');
|
||||
if (parent?.type === 'Location' && indexedArgs != undefined) {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: indexedArgs.sourceLocationId,
|
||||
sources_file_path_ids: [...indexedArgs.sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
}
|
||||
|
||||
if (parent?.type === 'Ephemeral' && ephemeralArgs != undefined) {
|
||||
await copyEphemeralFiles.mutateAsync({
|
||||
sources: [...ephemeralArgs.sourcePaths],
|
||||
target_dir: path
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [...sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
if (parent?.type === 'Location' && indexedArgs != undefined) {
|
||||
if (
|
||||
indexedArgs.sourceLocationId === parent.location.id &&
|
||||
sourceParentPath === path
|
||||
) {
|
||||
toast.error('File already exists in this location');
|
||||
}
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: indexedArgs.sourceLocationId,
|
||||
sources_file_path_ids: [...indexedArgs.sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
}
|
||||
|
||||
if (parent?.type === 'Ephemeral' && ephemeralArgs != undefined) {
|
||||
if (sourceParentPath !== path) {
|
||||
await cutEphemeralFiles.mutateAsync({
|
||||
sources: [...ephemeralArgs.sourcePaths],
|
||||
target_dir: path
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
|
|
|
@ -1,35 +1,43 @@
|
|||
import { useKey, useKeys } from 'rooks';
|
||||
import type { ExplorerItem } from '@sd/client';
|
||||
import { useItemsAsEphemeralPaths, useItemsAsFilePaths, type ExplorerItem } from '@sd/client';
|
||||
import { dialogManager } from '@sd/ui';
|
||||
import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import { useOperatingSystem } from './useOperatingSystem';
|
||||
|
||||
export const useKeyDeleteFile = (selectedItems: Set<ExplorerItem>, locationId?: number | null) => {
|
||||
const os = useOperatingSystem();
|
||||
|
||||
const filePaths = useItemsAsFilePaths([...selectedItems]);
|
||||
const ephemeralPaths = useItemsAsEphemeralPaths([...selectedItems]);
|
||||
|
||||
const deleteHandler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (!locationId || selectedItems.size === 0) return;
|
||||
|
||||
const pathIds: number[] = [];
|
||||
if ((locationId == null || !isNonEmpty(filePaths)) && !isNonEmpty(ephemeralPaths)) return;
|
||||
|
||||
const indexedArgs =
|
||||
locationId != null && isNonEmpty(filePaths)
|
||||
? { locationId, pathIds: filePaths.map((p) => p.id) }
|
||||
: undefined;
|
||||
const ephemeralArgs = isNonEmpty(ephemeralPaths)
|
||||
? { paths: ephemeralPaths.map((p) => p.path) }
|
||||
: undefined;
|
||||
|
||||
let dirCount = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
for (const item of selectedItems) {
|
||||
if (item.type === 'Path') {
|
||||
pathIds.push(item.item.id);
|
||||
|
||||
dirCount += item.item.is_dir ? 1 : 0;
|
||||
fileCount += item.item.is_dir ? 0 : 1;
|
||||
}
|
||||
for (const entry of [...filePaths, ...ephemeralPaths]) {
|
||||
dirCount += entry.is_dir ? 1 : 0;
|
||||
fileCount += entry.is_dir ? 0 : 1;
|
||||
}
|
||||
|
||||
dialogManager.create((dp) => (
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
locationId={locationId}
|
||||
pathIds={pathIds}
|
||||
indexedArgs={indexedArgs}
|
||||
ephemeralArgs={ephemeralArgs}
|
||||
dirCount={dirCount}
|
||||
fileCount={fileCount}
|
||||
/>
|
||||
|
|
|
@ -7,9 +7,9 @@ export type Procedures = {
|
|||
{ key: "backups.getAll", input: never, result: GetAll } |
|
||||
{ key: "buildInfo", input: never, result: BuildInfo } |
|
||||
{ key: "categories.list", input: LibraryArgs<null>, result: { [key in Category]: number } } |
|
||||
{ key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } |
|
||||
{ key: "files.get", input: LibraryArgs<GetArgs>, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] } | null } |
|
||||
{ key: "files.getConvertableImageExtensions", input: never, result: string[] } |
|
||||
{ key: "files.getEphemeralMediaData", input: string, result: MediaMetadata | null } |
|
||||
{ key: "files.getMediaData", input: LibraryArgs<number>, result: MediaMetadata } |
|
||||
{ key: "files.getPath", input: LibraryArgs<number>, result: string | null } |
|
||||
{ key: "invalidation.test-invalidate", input: never, result: number } |
|
||||
|
@ -48,13 +48,16 @@ export type Procedures = {
|
|||
{ key: "backups.backup", input: LibraryArgs<null>, result: string } |
|
||||
{ key: "backups.delete", input: string, result: null } |
|
||||
{ key: "backups.restore", input: string, result: null } |
|
||||
{ key: "ephemeralFiles.copyFiles", input: LibraryArgs<EphemeralFileSystemOps>, result: null } |
|
||||
{ key: "ephemeralFiles.createFolder", input: LibraryArgs<CreateEphemeralFolderArgs>, result: string } |
|
||||
{ key: "ephemeralFiles.cutFiles", input: LibraryArgs<EphemeralFileSystemOps>, result: null } |
|
||||
{ key: "ephemeralFiles.deleteFiles", input: LibraryArgs<string[]>, result: null } |
|
||||
{ key: "ephemeralFiles.renameFile", input: LibraryArgs<EphemeralRenameFileArgs>, result: null } |
|
||||
{ key: "files.convertImage", input: LibraryArgs<ConvertImageArgs>, result: null } |
|
||||
{ key: "files.copyFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
|
||||
{ key: "files.createEphemeralFolder", input: LibraryArgs<CreateEphemeralFolderArgs>, result: string } |
|
||||
{ key: "files.createFolder", input: LibraryArgs<CreateFolderArgs>, result: string } |
|
||||
{ key: "files.cutFiles", input: LibraryArgs<FileCutterJobInit>, result: null } |
|
||||
{ key: "files.deleteFiles", input: LibraryArgs<FileDeleterJobInit>, result: null } |
|
||||
{ key: "files.duplicateFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
|
||||
{ key: "files.eraseFiles", input: LibraryArgs<FileEraserJobInit>, result: null } |
|
||||
{ key: "files.removeAccessTime", input: LibraryArgs<number[]>, result: null } |
|
||||
{ key: "files.renameFile", input: LibraryArgs<RenameFileArgs>, result: null } |
|
||||
|
@ -158,10 +161,20 @@ export type DoubleClickAction = "openFile" | "quickPreview"
|
|||
|
||||
export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined<string> }
|
||||
|
||||
export type EphemeralFileSystemOps = { sources: string[]; target_dir: string }
|
||||
|
||||
export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder }
|
||||
|
||||
export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null }
|
||||
|
||||
export type EphemeralRenameFileArgs = { kind: EphemeralRenameKind }
|
||||
|
||||
export type EphemeralRenameKind = { One: EphemeralRenameOne } | { Many: EphemeralRenameMany }
|
||||
|
||||
export type EphemeralRenameMany = { from_pattern: FromPattern; to_pattern: string; from_paths: string[] }
|
||||
|
||||
export type EphemeralRenameOne = { from_path: string; to: string }
|
||||
|
||||
export type Error = { code: ErrorCode; message: string }
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,6 +13,10 @@ export function getItemFilePath(data: ExplorerItem) {
|
|||
return (data.type === 'Object' && data.item.file_paths[0]) || null;
|
||||
}
|
||||
|
||||
export function getEphemeralPath(data: ExplorerItem) {
|
||||
return data.type === 'NonIndexedPath' ? data.item : null;
|
||||
}
|
||||
|
||||
export function getIndexedItemFilePath(data: ExplorerItem) {
|
||||
return data.type === 'Path'
|
||||
? data.item
|
||||
|
|
Loading…
Reference in a new issue