mirror of
https://github.com/julyx10/lap.git
synced 2026-10-02 01:54:57 +08:00
feat(import): rework import-and-organize with resume, content dedup, and RAW support
This commit is contained in:
@@ -127,11 +127,6 @@ int lap_libraw_get_meta(libraw_data_t *raw, LapLibRawMeta *out) {
|
||||
return LIBRAW_UNSPECIFIED_ERROR;
|
||||
}
|
||||
|
||||
int ret = libraw_unpack(raw);
|
||||
if (ret != LIBRAW_SUCCESS) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
std::memset(out, 0, sizeof(*out));
|
||||
|
||||
// Camera info
|
||||
|
||||
@@ -174,6 +174,37 @@ pub(crate) fn rollback_rename_changes(
|
||||
// AAE sidecar helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Import-local cache; successful copies update an already scanned directory.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ImportAaeCache(HashMap<PathBuf, Vec<PathBuf>>);
|
||||
|
||||
impl ImportAaeCache {
|
||||
pub(crate) fn paths(&mut self, path: &Path) -> Vec<PathBuf> {
|
||||
let Some(parent) = path.parent() else { return Vec::new(); };
|
||||
let entries = self.0.entry(parent.to_path_buf()).or_insert_with(|| {
|
||||
fs::read_dir(parent).into_iter().flatten().filter_map(Result::ok)
|
||||
.map(|entry| entry.path()).filter(|p| p.is_file() && p.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("aae"))).collect()
|
||||
});
|
||||
let stem = format!("{}.aae", path.file_stem().unwrap_or_default().to_string_lossy());
|
||||
let full = format!("{}.aae", path.file_name().unwrap_or_default().to_string_lossy());
|
||||
let mut matches: Vec<_> = entries.iter().filter(|p| p.file_name().is_some_and(|name| {
|
||||
let name = name.to_string_lossy();
|
||||
name.eq_ignore_ascii_case(&stem) || name.eq_ignore_ascii_case(&full)
|
||||
})).cloned().collect();
|
||||
matches.sort();
|
||||
matches
|
||||
}
|
||||
|
||||
pub(crate) fn record(&mut self, paths: &[PathBuf]) {
|
||||
for path in paths {
|
||||
if let Some(entries) = path.parent().and_then(|parent| self.0.get_mut(parent)) {
|
||||
if !entries.contains(path) { entries.push(path.clone()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apple_aae_sidecar_paths(file_path: &str) -> Vec<PathBuf> {
|
||||
let path = Path::new(file_path);
|
||||
let Some(parent) = path.parent() else {
|
||||
@@ -233,15 +264,16 @@ pub(crate) fn delete_apple_aae_sidecars(file_path: &str, permanently: bool) -> R
|
||||
/// Copy Apple edit sidecars alongside an imported file. The destination file
|
||||
/// name may have received a conflict suffix, so derive each sidecar name from
|
||||
/// the final destination rather than copying its source name verbatim.
|
||||
pub(crate) fn copy_apple_aae_sidecars_for_import(
|
||||
pub(crate) fn copy_apple_aae_paths_for_import(
|
||||
source_file_path: &str,
|
||||
destination_file_path: &str,
|
||||
sidecars: Vec<PathBuf>,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let source = Path::new(source_file_path);
|
||||
let destination = Path::new(destination_file_path);
|
||||
let mut copied = Vec::new();
|
||||
|
||||
for sidecar in apple_aae_sidecar_paths(source_file_path) {
|
||||
for sidecar in sidecars {
|
||||
let Some(name) = build_aae_transfer_target_name(
|
||||
source,
|
||||
&sidecar,
|
||||
|
||||
@@ -640,8 +640,10 @@ pub fn fetch_folder(
|
||||
|
||||
/// count all files in a folder (include all sub-folders)
|
||||
#[tauri::command]
|
||||
pub fn count_folder(path: &str) -> (u64, u64, u64, u64, u64, u64, u64) {
|
||||
t_utils::count_folder_files(path)
|
||||
pub async fn count_folder(path: String) -> Result<(u64, u64, u64, u64, u64, u64, u64), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || t_utils::count_folder_files(&path))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to count folder: {}", e))
|
||||
}
|
||||
|
||||
/// create a new folder
|
||||
@@ -1678,6 +1680,7 @@ pub fn import_and_organize(
|
||||
source_path: String,
|
||||
destination_path: String,
|
||||
layout: String,
|
||||
completed_paths: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let mut import = state.0.lock().map_err(|_| "Import cancellation state is unavailable")?;
|
||||
@@ -1694,6 +1697,7 @@ pub fn import_and_organize(
|
||||
&source_path,
|
||||
&destination_path,
|
||||
&layout,
|
||||
completed_paths.into_iter().collect(),
|
||||
|progress| { let _ = app_handle.emit("import-organize-progress", progress); },
|
||||
|| cancellation.lock().map(|import| import.cancelled).unwrap_or(true),
|
||||
);
|
||||
|
||||
@@ -408,6 +408,7 @@ impl RawHandle {
|
||||
}
|
||||
|
||||
/// Metadata extracted from a RAW file via LibRaw.
|
||||
#[derive(Clone)]
|
||||
pub struct RawMeta {
|
||||
pub make: Option<String>,
|
||||
pub model: Option<String>,
|
||||
@@ -466,6 +467,18 @@ pub fn get_raw_dimensions_with_flip(file_path: &str) -> Result<(u32, u32, i32),
|
||||
RawHandle::open(file_path)?.dimensions_with_flip()
|
||||
}
|
||||
|
||||
/// Small owned metadata snapshot; never retains a LibRaw decoder or pixel buffer.
|
||||
#[derive(Clone)]
|
||||
pub struct RawInfo {
|
||||
pub dimensions: Option<(u32, u32)>,
|
||||
pub meta: RawMeta,
|
||||
}
|
||||
|
||||
pub fn get_raw_info(file_path: &str) -> Result<RawInfo, String> {
|
||||
let raw = RawHandle::open(file_path)?;
|
||||
Ok(RawInfo { dimensions: raw.dimensions().ok(), meta: raw.meta()? })
|
||||
}
|
||||
|
||||
pub fn get_raw_meta(file_path: &str) -> Result<RawMeta, String> {
|
||||
RawHandle::open(file_path)?.meta()
|
||||
}
|
||||
|
||||
+80
-27
@@ -2146,6 +2146,13 @@ impl AFile {
|
||||
}
|
||||
|
||||
fn new(folder_id: i64, file_path: &str, file_type: i64) -> Result<Self, String> {
|
||||
Self::new_with_raw_info(folder_id, file_path, file_type, None)
|
||||
}
|
||||
|
||||
fn new_with_raw_info(folder_id: i64, file_path: &str, file_type: i64, raw_info: Option<t_libraw::RawInfo>) -> Result<Self, String> {
|
||||
let raw_info = if file_type == 3 {
|
||||
raw_info.or_else(|| t_libraw::get_raw_info(file_path).ok())
|
||||
} else { None };
|
||||
let file_info = t_utils::FileInfo::new(file_path)?;
|
||||
|
||||
// get dimensions and duration based on file type
|
||||
@@ -2239,7 +2246,11 @@ impl AFile {
|
||||
content_identifier = video_metadata.content_identifier;
|
||||
}
|
||||
3 => {
|
||||
let (w, h) = t_image::get_raw_dimensions(file_path)?;
|
||||
let (w, h) = match raw_info.as_ref().and_then(|info| info.dimensions)
|
||||
.filter(|(w, h)| *w > 0 && *h > 0) {
|
||||
Some(dimensions) => dimensions,
|
||||
None => t_image::get_raw_dimensions(file_path)?,
|
||||
};
|
||||
width = w;
|
||||
height = h;
|
||||
}
|
||||
@@ -2371,7 +2382,7 @@ impl AFile {
|
||||
// that the permissive EXIF reader scans, so it is robust against
|
||||
// RAW files whose EXIF data is stored outside the preview image.
|
||||
if file_type == 3 {
|
||||
if let Ok(meta) = t_libraw::get_raw_meta(file_path) {
|
||||
if let Some(meta) = raw_info.as_ref().map(|info| info.meta.clone()) {
|
||||
if e_make.is_none() {
|
||||
e_make = meta.make;
|
||||
}
|
||||
@@ -2425,24 +2436,25 @@ impl AFile {
|
||||
|| e_lens_model.is_none()
|
||||
{
|
||||
if let Some(data) = file_header_deref {
|
||||
let tiff_base = Self::find_tiff_base(data);
|
||||
if e_make.is_none() {
|
||||
e_make = Self::scrape_ascii_from_tag(data, 0x010f);
|
||||
e_make = Self::scrape_ascii_from_tag(data, tiff_base, 0x010f);
|
||||
}
|
||||
if e_model.is_none() {
|
||||
e_model = Self::scrape_ascii_from_tag(data, 0x0110);
|
||||
e_model = Self::scrape_ascii_from_tag(data, tiff_base, 0x0110);
|
||||
}
|
||||
if e_date_time.is_none() {
|
||||
e_date_time = Self::scrape_ascii_from_tag(data, 0x9003)
|
||||
.or_else(|| Self::scrape_ascii_from_tag(data, 0x0132));
|
||||
e_date_time = Self::scrape_ascii_from_tag(data, tiff_base, 0x9003)
|
||||
.or_else(|| Self::scrape_ascii_from_tag(data, tiff_base, 0x0132));
|
||||
}
|
||||
if e_software.is_none() {
|
||||
e_software = Self::scrape_ascii_from_tag(data, 0x0131);
|
||||
e_software = Self::scrape_ascii_from_tag(data, tiff_base, 0x0131);
|
||||
}
|
||||
if e_lens_model.is_none() {
|
||||
e_lens_model = Self::scrape_ascii_from_tag(data, 0xa434);
|
||||
e_lens_model = Self::scrape_ascii_from_tag(data, tiff_base, 0xa434);
|
||||
}
|
||||
if e_lens_make.is_none() {
|
||||
e_lens_make = Self::scrape_ascii_from_tag(data, 0xa433);
|
||||
e_lens_make = Self::scrape_ascii_from_tag(data, tiff_base, 0xa433);
|
||||
}
|
||||
// Extra Orientation fallback for Sony MakerNotes (Tag 0x2000)
|
||||
if e_orientation.is_none() || e_orientation == Some(1) {
|
||||
@@ -2462,13 +2474,8 @@ impl AFile {
|
||||
}
|
||||
|
||||
// Re-update taken_date if we found e_date_time via binary fallback
|
||||
if taken_date == file_info.modified {
|
||||
if let Some(dt) = e_date_time.as_ref() {
|
||||
if let Some(ts) = t_utils::meta_date_to_timestamp(dt) {
|
||||
taken_date = Some(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
taken_date = Self::capture_date_with_fallback(
|
||||
taken_date, file_info.modified, e_date_time.as_deref());
|
||||
} else if file_type == 2 {
|
||||
taken_date = e_date_time
|
||||
.as_ref()
|
||||
@@ -2584,14 +2591,52 @@ impl AFile {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn capture_date_with_fallback(taken: Option<i64>, modified: Option<i64>, date: Option<&str>) -> Option<i64> {
|
||||
if taken == modified {
|
||||
date.and_then(t_utils::meta_date_to_timestamp).or(taken)
|
||||
} else { taken }
|
||||
}
|
||||
|
||||
/// Read the capture timestamp used by Lap before a file is imported.
|
||||
/// This keeps date-organized imports consistent with the timestamp shown
|
||||
/// after the same file has been indexed.
|
||||
pub fn capture_timestamp_for_path(file_path: &str, file_type: i64) -> Result<i64, String> {
|
||||
let file = Self::new(0, file_path, file_type)?;
|
||||
file.taken_date
|
||||
.or(file.modified_at)
|
||||
.ok_or_else(|| format!("Could not read a date from: {}", file_path))
|
||||
pub fn capture_timestamp_with_raw_info(file_path: &str, file_type: i64, raw_info: Option<&t_libraw::RawInfo>) -> Result<i64, String> {
|
||||
let modified = t_utils::systemtime_to_timestamp(
|
||||
std::fs::metadata(file_path).map_err(|e| e.to_string())?.modified().ok());
|
||||
let taken = if file_type == 2 {
|
||||
t_video::get_video_metadata(file_path)?.e_date_time
|
||||
.as_deref().and_then(t_utils::meta_date_to_timestamp).or(modified)
|
||||
} else if file_type == 1 || file_type == 3 {
|
||||
use std::io::Read;
|
||||
let header = std::fs::File::open(file_path).ok().and_then(|mut file| {
|
||||
let mut bytes = vec![0; 128 * 1024];
|
||||
file.read(&mut bytes).ok().map(|n| { bytes.truncate(n); bytes })
|
||||
});
|
||||
let exif = if let Some(bytes) = header.as_deref() {
|
||||
t_image::read_exif_from_bytes_permissive(bytes).or_else(|| {
|
||||
(file_type == 1 && t_image::is_jpeg_path(file_path))
|
||||
.then(|| t_image::read_exif_permissive(file_path)).flatten()
|
||||
})
|
||||
} else { t_image::read_exif_permissive(file_path) };
|
||||
let mut date = Self::get_exif_field(&exif, Tag::DateTimeOriginal);
|
||||
let mut taken = date.as_deref().and_then(t_utils::meta_date_to_timestamp).or(modified);
|
||||
if file_type == 3 && taken == modified {
|
||||
let timestamp = match raw_info {
|
||||
Some(info) => info.meta.timestamp,
|
||||
None => t_libraw::get_raw_meta(file_path).ok().and_then(|meta| meta.timestamp),
|
||||
};
|
||||
taken = timestamp.or(taken);
|
||||
}
|
||||
if date.is_none() {
|
||||
if let Some(bytes) = header.as_deref() {
|
||||
let tiff_base = Self::find_tiff_base(bytes);
|
||||
date = Self::scrape_ascii_from_tag(bytes, tiff_base, 0x9003)
|
||||
.or_else(|| Self::scrape_ascii_from_tag(bytes, tiff_base, 0x0132));
|
||||
}
|
||||
}
|
||||
Self::capture_date_with_fallback(taken, modified, date.as_deref())
|
||||
} else { modified };
|
||||
taken.ok_or_else(|| format!("Could not read a date from: {}", file_path))
|
||||
}
|
||||
|
||||
fn extract_gps_data(exif: &Option<exif::Exif>) -> (Option<f64>, Option<f64>, Option<f64>) {
|
||||
@@ -2716,11 +2761,12 @@ impl AFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn scrape_ascii_from_tag(data: &[u8], tag_id: u16) -> Option<String> {
|
||||
// Find the TIFF base (where the EXIF/TIFF header starts)
|
||||
let tiff_base = data
|
||||
.windows(4)
|
||||
.position(|w| w == b"II\x2a\x00" || w == b"MM\x00\x2a")?;
|
||||
fn find_tiff_base(data: &[u8]) -> Option<usize> {
|
||||
data.windows(4).position(|w| w == b"II\x2a\x00" || w == b"MM\x00\x2a")
|
||||
}
|
||||
|
||||
fn scrape_ascii_from_tag(data: &[u8], tiff_base: Option<usize>, tag_id: u16) -> Option<String> {
|
||||
let tiff_base = tiff_base?;
|
||||
|
||||
let target_le = [(tag_id & 0xFF) as u8, (tag_id >> 8) as u8, 0x02, 0x00];
|
||||
let target_be = [(tag_id >> 8) as u8, (tag_id & 0xFF) as u8, 0x00, 0x02];
|
||||
@@ -3517,6 +3563,13 @@ impl AFile {
|
||||
file_path: &str,
|
||||
file_type: i64,
|
||||
last_scan_time: i64,
|
||||
) -> Result<(Self, i32), String> {
|
||||
Self::add_to_db_with_raw_info(folder_id, file_path, file_type, last_scan_time, None)
|
||||
}
|
||||
|
||||
pub fn add_to_db_with_raw_info(
|
||||
folder_id: i64, file_path: &str, file_type: i64, last_scan_time: i64,
|
||||
raw_info: Option<t_libraw::RawInfo>,
|
||||
) -> Result<(Self, i32), String> {
|
||||
// Check if the file exists
|
||||
let existing_file = Self::fetch(folder_id, file_path)?;
|
||||
@@ -3592,7 +3645,7 @@ impl AFile {
|
||||
}
|
||||
|
||||
// insert the new file into the database
|
||||
let mut new_file_struct = Self::new(folder_id, file_path, file_type)?;
|
||||
let mut new_file_struct = Self::new_with_raw_info(folder_id, file_path, file_type, raw_info)?;
|
||||
new_file_struct.last_scan_time = Some(last_scan_time);
|
||||
let inserted = new_file_struct.insert()?;
|
||||
|
||||
|
||||
+176
-47
@@ -1274,14 +1274,35 @@ pub fn import_file(source_path: &str, dest_folder: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Compare in fixed-size chunks; unreadable files must never be treated as duplicates.
|
||||
fn import_files_equal(left: &Path, right: &Path, cancelled: &impl Fn() -> bool) -> std::io::Result<bool> {
|
||||
let mut left = fs::File::open(left)?;
|
||||
let mut right = fs::File::open(right)?;
|
||||
let size = left.metadata()?.len();
|
||||
if size != right.metadata()?.len() { return Ok(false); }
|
||||
let mut remaining = size;
|
||||
let mut a = [0u8; 65536];
|
||||
let mut b = [0u8; 65536];
|
||||
while remaining > 0 {
|
||||
if cancelled() { return Ok(false); }
|
||||
let len = remaining.min(a.len() as u64) as usize;
|
||||
left.read_exact(&mut a[..len])?;
|
||||
right.read_exact(&mut b[..len])?;
|
||||
if a[..len] != b[..len] { return Ok(false); }
|
||||
remaining -= len as u64;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportOrganizeProgress {
|
||||
pub phase: String,
|
||||
pub current_path: Option<String>,
|
||||
pub processed: usize,
|
||||
pub total: usize,
|
||||
pub total_size: u64,
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub failed: usize,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
@@ -1289,8 +1310,10 @@ pub struct ImportOrganizeProgress {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportOrganizeResult {
|
||||
pub completed_paths: Vec<String>,
|
||||
pub total: usize,
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub failed: usize,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
@@ -1303,6 +1326,7 @@ pub fn import_and_organize<F, C>(
|
||||
source_path: &str,
|
||||
destination_path: &str,
|
||||
layout: &str,
|
||||
completed_paths: HashSet<String>,
|
||||
mut report_progress: F,
|
||||
is_cancelled: C,
|
||||
) -> Result<ImportOrganizeResult, String>
|
||||
@@ -1310,6 +1334,8 @@ where
|
||||
F: FnMut(ImportOrganizeProgress),
|
||||
C: Fn() -> bool,
|
||||
{
|
||||
let started = Instant::now();
|
||||
let mut aae_cache = t_apple_sidecar::ImportAaeCache::default();
|
||||
let album = Album::get_album_by_id(album_id)?;
|
||||
let source = fs::canonicalize(source_path)
|
||||
.map_err(|e| format!("Cannot access source folder: {}", e))?;
|
||||
@@ -1339,66 +1365,155 @@ where
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "preparing".to_string(), current_path: None, processed: 0, total: 0,
|
||||
imported: 0, failed: 0, cancelled: false,
|
||||
phase: "preparing".to_string(), processed: 0, total: 0, total_size: 0,
|
||||
imported: 0, skipped: 0, failed: 0, cancelled: false,
|
||||
});
|
||||
let mut files = Vec::new();
|
||||
// Resume only files successfully imported by this dialog, including renamed collisions.
|
||||
let mut completed_paths = completed_paths;
|
||||
let mut last_progress = Instant::now();
|
||||
let mut files: Vec<(PathBuf, i64, String, Option<crate::t_libraw::RawInfo>)> = Vec::new();
|
||||
let mut failed = 0;
|
||||
let mut total_size: u64 = 0;
|
||||
for entry in WalkDir::new(&source)
|
||||
.into_iter()
|
||||
.filter_entry(is_visible_or_root)
|
||||
{
|
||||
if last_progress.elapsed() >= Duration::from_millis(100) {
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "preparing".to_string(), processed: files.len() + failed,
|
||||
total: files.len() + failed, total_size, imported: 0, skipped: 0, failed, cancelled: false,
|
||||
});
|
||||
last_progress = Instant::now();
|
||||
}
|
||||
if is_cancelled() {
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "preparing".to_string(), current_path: None, processed: 0, total: files.len(),
|
||||
imported: 0, failed: 0, cancelled: true,
|
||||
phase: "preparing".to_string(), processed: files.len() + failed, total: files.len() + failed, total_size,
|
||||
imported: 0, skipped: 0, failed, cancelled: true,
|
||||
});
|
||||
return Ok(ImportOrganizeResult { total: files.len(), imported: 0, failed: 0, cancelled: true });
|
||||
return Ok(ImportOrganizeResult { completed_paths: completed_paths.into_iter().collect(), total: files.len() + failed, imported: 0, skipped: 0, failed, cancelled: true });
|
||||
}
|
||||
let Ok(entry) = entry else { continue; };
|
||||
if !entry.file_type().is_file() { continue; }
|
||||
let path = entry.into_path();
|
||||
if let Some(file_type) = get_file_type(&path.to_string_lossy()) {
|
||||
files.push((path, file_type));
|
||||
}
|
||||
}
|
||||
let total = files.len();
|
||||
let mut imported = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for (processed, (path, file_type)) in files.into_iter().enumerate() {
|
||||
if is_cancelled() {
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "importing".to_string(), current_path: None, processed, total, imported, failed, cancelled: true,
|
||||
});
|
||||
return Ok(ImportOrganizeResult { total, imported, failed, cancelled: true });
|
||||
}
|
||||
|
||||
let Some(file_type) = get_file_type(&path.to_string_lossy()) else { continue; };
|
||||
let file_size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
let result = (|| -> Result<String, String> {
|
||||
let timestamp = AFile::capture_timestamp_for_path(&path_string, file_type)?;
|
||||
if completed_paths.contains(&path_string) { continue; }
|
||||
total_size += file_size;
|
||||
let raw_info = if file_type == 3 && layout != "none" {
|
||||
crate::t_libraw::get_raw_info(&path_string).ok()
|
||||
} else { None };
|
||||
let relative_folder = match (|| -> Result<String, String> {
|
||||
if layout == "none" { return Ok(String::new()); }
|
||||
let timestamp = AFile::capture_timestamp_with_raw_info(&path_string, file_type, raw_info.as_ref())?;
|
||||
let date = Local.timestamp_opt(timestamp, 0).single()
|
||||
.ok_or_else(|| format!("Invalid capture date: {}", path_string))?;
|
||||
let relative_folder = match layout {
|
||||
Ok(match layout {
|
||||
"day" => date.format("%Y/%Y-%m-%d").to_string(),
|
||||
"month" => date.format("%Y/%Y-%m").to_string(),
|
||||
"year" => date.format("%Y").to_string(),
|
||||
"none" => String::new(),
|
||||
_ => return Err("Invalid import folder layout".to_string()),
|
||||
};
|
||||
let target_folder = destination_folder.join(&relative_folder);
|
||||
fs::create_dir_all(&target_folder).map_err(|e| format!("Cannot create destination folder: {}", e))?;
|
||||
let mut folder = AFolder::add_to_db(album_id, &album_root.to_string_lossy())?;
|
||||
for component in destination_components.iter().map(String::as_str)
|
||||
.chain(relative_folder.split('/').filter(|component| !component.is_empty()))
|
||||
{
|
||||
let parent = Path::new(&folder.path).join(component);
|
||||
folder = AFolder::add_to_db(album_id, &parent.to_string_lossy())?;
|
||||
})
|
||||
})() {
|
||||
Ok(relative_folder) => relative_folder,
|
||||
Err(_) => {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
files.push((path, file_type, relative_folder, raw_info));
|
||||
}
|
||||
eprintln!("Import preparation: {:?}, files: {}", started.elapsed(), files.len() + failed);
|
||||
let import_started = Instant::now();
|
||||
let mut compare_time = Duration::ZERO;
|
||||
let mut copy_time = Duration::ZERO;
|
||||
let mut database_time = Duration::ZERO;
|
||||
let total = files.len() + failed;
|
||||
let mut processed = failed;
|
||||
let mut imported = 0;
|
||||
let mut skipped = 0;
|
||||
let mut folder_ids: HashMap<PathBuf, i64> = HashMap::new();
|
||||
let mut candidates: HashMap<PathBuf, HashMap<u64, Vec<PathBuf>>> = HashMap::new();
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "importing".to_string(), processed,
|
||||
total, total_size, imported, skipped, failed, cancelled: false,
|
||||
});
|
||||
|
||||
for (path, file_type, relative_folder, raw_info) in files {
|
||||
if is_cancelled() {
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "importing".to_string(), processed, total, total_size, imported, skipped, failed, cancelled: true,
|
||||
});
|
||||
return Ok(ImportOrganizeResult { completed_paths: completed_paths.into_iter().collect(), total, imported, skipped, failed, cancelled: true });
|
||||
}
|
||||
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
let target_folder = destination_folder.join(&relative_folder);
|
||||
let result = (|| -> Result<bool, String> {
|
||||
// Cache file sizes once per target directory. Include successful copies below.
|
||||
if !candidates.contains_key(&target_folder) {
|
||||
let mut by_size: HashMap<u64, Vec<PathBuf>> = HashMap::new();
|
||||
if target_folder.exists() {
|
||||
for entry in fs::read_dir(&target_folder).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let metadata = entry.metadata().map_err(|e| e.to_string())?;
|
||||
if metadata.is_file() && get_file_type(&entry.path().to_string_lossy()).is_some() {
|
||||
by_size.entry(metadata.len()).or_default().push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.insert(target_folder.clone(), by_size);
|
||||
}
|
||||
let size = path.metadata().map_err(|e| e.to_string())?.len();
|
||||
let folder_id = if let Some(&id) = folder_ids.get(&target_folder) {
|
||||
id
|
||||
} else {
|
||||
fs::create_dir_all(&target_folder).map_err(|e| format!("Cannot create destination folder: {}", e))?;
|
||||
|
||||
let mut folder = AFolder::add_to_db(album_id, &album_root.to_string_lossy())?;
|
||||
for component in destination_components.iter().map(String::as_str)
|
||||
.chain(relative_folder.split('/').filter(|component| !component.is_empty()))
|
||||
{
|
||||
let parent = Path::new(&folder.path).join(component);
|
||||
folder = AFolder::add_to_db(album_id, &parent.to_string_lossy())?;
|
||||
}
|
||||
let id = folder.id.ok_or_else(|| "Imported folder is missing its database id".to_string())?;
|
||||
folder_ids.insert(target_folder.clone(), id);
|
||||
id
|
||||
};
|
||||
if let Some(matches) = candidates[&target_folder].get(&size) {
|
||||
for candidate in matches {
|
||||
if is_cancelled() { return Err("Import cancelled".to_string()); }
|
||||
let compare_started = Instant::now();
|
||||
let duplicate = import_files_equal(&path, candidate, &is_cancelled).unwrap_or(false)
|
||||
&& {
|
||||
let left = aae_cache.paths(&path);
|
||||
let right = aae_cache.paths(candidate);
|
||||
left.len() == right.len() && left.iter().zip(&right)
|
||||
.all(|(a, b)| import_files_equal(a, b, &is_cancelled).unwrap_or(false))
|
||||
};
|
||||
compare_time += compare_started.elapsed();
|
||||
if duplicate {
|
||||
let database_started = Instant::now();
|
||||
let destination = candidate.to_string_lossy().to_string();
|
||||
if AFile::fetch(folder_id, &destination)?.is_none() {
|
||||
let candidate_type = get_file_type(&destination)
|
||||
.ok_or_else(|| format!("Unsupported import candidate: {}", destination))?;
|
||||
AFile::add_to_db(folder_id, &destination, candidate_type, chrono::Utc::now().timestamp_millis())?;
|
||||
}
|
||||
database_time += database_started.elapsed();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_cancelled() { return Err("Import cancelled".to_string()); }
|
||||
let copy_started = Instant::now();
|
||||
let destination = import_file(&path_string, &target_folder.to_string_lossy())
|
||||
.ok_or_else(|| format!("Failed to copy file: {}", path_string))?;
|
||||
let copied_sidecars = match t_apple_sidecar::copy_apple_aae_sidecars_for_import(
|
||||
let copied_sidecars = match t_apple_sidecar::copy_apple_aae_paths_for_import(
|
||||
&path_string,
|
||||
&destination,
|
||||
aae_cache.paths(&path),
|
||||
) {
|
||||
Ok(sidecars) => sidecars,
|
||||
Err(error) => {
|
||||
@@ -1406,32 +1521,46 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let folder_id = folder.id.ok_or_else(|| "Imported folder is missing its database id".to_string())?;
|
||||
if let Err(error) = AFile::add_to_db(folder_id, &destination, file_type, chrono::Utc::now().timestamp_millis()) {
|
||||
copy_time += copy_started.elapsed();
|
||||
let database_started = Instant::now();
|
||||
if let Err(error) = AFile::add_to_db_with_raw_info(folder_id, &destination, file_type, chrono::Utc::now().timestamp_millis(), raw_info) {
|
||||
let _ = fs::remove_file(&destination);
|
||||
for sidecar in copied_sidecars {
|
||||
let _ = fs::remove_file(sidecar);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(destination)
|
||||
database_time += database_started.elapsed();
|
||||
aae_cache.record(&copied_sidecars);
|
||||
candidates.get_mut(&target_folder).unwrap().entry(size).or_default()
|
||||
.push(PathBuf::from(&destination));
|
||||
Ok(false)
|
||||
})();
|
||||
let current_path = match result {
|
||||
Ok(destination) => {
|
||||
imported += 1;
|
||||
destination
|
||||
if result.is_err() && is_cancelled() {
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "importing".to_string(), processed,
|
||||
total, total_size, imported, skipped, failed, cancelled: true,
|
||||
});
|
||||
return Ok(ImportOrganizeResult { completed_paths: completed_paths.into_iter().collect(), total, imported, skipped, failed, cancelled: true });
|
||||
}
|
||||
match result {
|
||||
Ok(duplicate) => {
|
||||
if duplicate { skipped += 1; } else { imported += 1; }
|
||||
completed_paths.insert(path_string);
|
||||
}
|
||||
Err(_) => {
|
||||
failed += 1;
|
||||
path_string.clone()
|
||||
}
|
||||
};
|
||||
processed += 1;
|
||||
report_progress(ImportOrganizeProgress {
|
||||
phase: "importing".to_string(), current_path: Some(current_path), processed: processed + 1, total, imported, failed, cancelled: false,
|
||||
phase: "importing".to_string(), processed, total, total_size, imported, skipped, failed, cancelled: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ImportOrganizeResult { total, imported, failed, cancelled: false })
|
||||
eprintln!("Import successful steps: comparison {:?}, copy/sidecars {:?}, file database {:?}", compare_time, copy_time, database_time);
|
||||
eprintln!("Import processing: {:?}, imported: {}, skipped: {}, failed: {}", import_started.elapsed(), imported, skipped, failed);
|
||||
Ok(ImportOrganizeResult { completed_paths: completed_paths.into_iter().collect(), total, imported, skipped, failed, cancelled: false })
|
||||
}
|
||||
|
||||
/// Map an image MIME type to the canonical file extension.
|
||||
|
||||
@@ -1288,8 +1288,8 @@ export async function importFile(filePath, folderId, folderPath) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function importAndOrganize(albumId, sourcePath, destinationPath, layout) {
|
||||
return await invoke('import_and_organize', { albumId, sourcePath, destinationPath, layout });
|
||||
export async function importAndOrganize(albumId, sourcePath, destinationPath, layout, completedPaths = []) {
|
||||
return await invoke('import_and_organize', { albumId, sourcePath, destinationPath, layout, completedPaths });
|
||||
}
|
||||
|
||||
export async function cancelImportAndOrganize() {
|
||||
|
||||
@@ -7157,6 +7157,7 @@ let contentUpdateSeq = 0;
|
||||
async function updateContent(force = false, preserveMultiSelection = selectMode.value) {
|
||||
const updateSeq = ++contentUpdateSeq;
|
||||
const newIndex = config.main.sidebarIndex;
|
||||
const nextAlbumId = newIndex === SIDEBAR.ALBUM ? Number(libConfig.album.id || 0) : 0;
|
||||
const isCurrentAlbumIndexing =
|
||||
newIndex === SIDEBAR.ALBUM &&
|
||||
libConfig.activePane !== 'collection' &&
|
||||
|
||||
@@ -1,55 +1,68 @@
|
||||
<template>
|
||||
<ModalDialog :title="$t('import_organize.import')" :width="520" @cancel="close">
|
||||
<div class="space-y-4 text-sm">
|
||||
<section v-if="!running && !completed" class="space-y-2">
|
||||
<div class="font-medium">{{ $t('import_organize.source') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="min-w-0 flex-1 h-9 px-3 flex items-center rounded-box bg-base-100/60 border border-base-content/10 truncate text-base-content/60">
|
||||
{{ sourcePath || $t('import_organize.source_placeholder') }}
|
||||
<div class="space-y-2 select-none">
|
||||
<section class="rounded-box p-2 space-y-2 bg-base-300/30 border border-base-content/5 shadow-sm">
|
||||
<div class="font-bold uppercase text-[10px] tracking-widest text-base-content/30">{{ $t('import_organize.section_folders') }}</div>
|
||||
<div class="grid grid-cols-[84px_1fr] gap-x-4 gap-y-2 items-start px-1 text-xs">
|
||||
<div class="min-h-6 flex items-center text-[11px] text-base-content/45">{{ $t('import_organize.source') }}</div>
|
||||
<div class="min-w-0 min-h-6 flex items-center justify-between gap-2">
|
||||
<input v-if="sourcePath" readonly :value="sourcePath" :title="sourcePath" class="w-full bg-transparent border-none p-0 text-[12px] text-base-content/75 focus:outline-none focus:ring-0" />
|
||||
<button v-else class="btn btn-primary btn-sm rounded-box" :disabled="locked" @click="chooseSource">
|
||||
<IconDownload class="w-4 h-4" />{{ $t('album.edit.select_folder') }}
|
||||
</button>
|
||||
<TButton v-if="sourcePath" :icon="IconDownload" :selected="true" :disabled="locked" :tooltip="$t('album.edit.select_folder')" @click="chooseSource" />
|
||||
</div>
|
||||
<button class="t-button-default shrink-0" @click="chooseSource">{{ $t('import_organize.choose_folder') }}</button>
|
||||
|
||||
<div class="min-h-6 flex items-center text-[11px] text-base-content/45">{{ $t('import_organize.destination') }}</div>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="min-w-0 min-h-6 flex items-center">
|
||||
<input readonly :value="destinationPath" :title="destinationPath" class="w-full bg-transparent border-none p-0 text-[12px] text-base-content/75 focus:outline-none focus:ring-0" />
|
||||
</div>
|
||||
<div :inert="locked" class="max-h-44 overflow-y-auto rounded-box bg-base-100/40 border border-base-content/10 p-1" :class="{ 'opacity-60': locked }">
|
||||
<AlbumFolder :children="destinationTree ? [destinationTree] : []" :albumId="Number(album.id)" :rootPath="album.path" :allowContextMenu="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="min-h-6 flex items-center text-[11px] text-base-content/45" for="import-layout">{{ $t('import_organize.folder_layout') }}</label>
|
||||
<select id="import-layout" v-model="layout" :disabled="locked" class="select select-bordered select-xs w-full text-[12px]">
|
||||
<option value="day">{{ $t('import_organize.layout_day') }}</option>
|
||||
<option value="month">{{ $t('import_organize.layout_month') }}</option>
|
||||
<option value="year">{{ $t('import_organize.layout_year') }}</option>
|
||||
<option value="none">{{ $t('import_organize.layout_none') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!running && !completed" class="space-y-2">
|
||||
<div class="font-medium">{{ $t('import_organize.destination') }}</div>
|
||||
<div class="max-h-44 overflow-y-auto rounded-box bg-base-100/40 border border-base-content/10 p-1">
|
||||
<AlbumFolder
|
||||
:children="destinationTree ? [destinationTree] : []"
|
||||
:albumId="Number(album.id)"
|
||||
:rootPath="album.path"
|
||||
:allowContextMenu="false"
|
||||
/>
|
||||
<section class="rounded-box p-2 space-y-2 bg-base-300/30 border border-base-content/5 shadow-sm">
|
||||
<div class="font-bold uppercase text-[10px] tracking-widest text-base-content/30">{{ $t('import_organize.section_status') }}</div>
|
||||
<div v-if="!running && !completed" class="grid grid-cols-[84px_1fr] gap-x-4 gap-y-1.5 items-center px-1 text-xs">
|
||||
<div class="min-h-6 flex items-center text-[11px] text-base-content/45">{{ $t('import_organize.to_import') }}</div>
|
||||
<div class="min-h-6 flex items-center text-[12px] text-base-content/75" :class="{ 'animate-pulse': counting }">
|
||||
<span v-if="!sourcePath">{{ $t('import_organize.source_placeholder') }}</span>
|
||||
<span v-else-if="counting">{{ $t('album.edit.files_counting') }}</span>
|
||||
<span v-else-if="sourceSummary">{{ $t('album.edit.files_count', { count: sourceSummary.count.toLocaleString(), size: formatFileSize(sourceSummary.size) }) }}</span>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!running && !completed" class="space-y-2">
|
||||
<label class="font-medium" for="import-layout">{{ $t('import_organize.folder_layout') }}</label>
|
||||
<select id="import-layout" v-model="layout" class="select select-bordered w-full h-9 min-h-0">
|
||||
<option value="day">{{ $t('import_organize.layout_day') }}</option>
|
||||
<option value="month">{{ $t('import_organize.layout_month') }}</option>
|
||||
<option value="year">{{ $t('import_organize.layout_year') }}</option>
|
||||
<option value="none">{{ $t('import_organize.layout_none') }}</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section v-if="running || completed" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">{{ completed ? $t(cancelled ? 'import_organize.cancelled' : 'import_organize.complete') : progress.phase === 'preparing' ? $t('import_organize.preparing') : $t('import_organize.importing') }}</span>
|
||||
<span>{{ progress.processed.toLocaleString() }} / {{ progress.total.toLocaleString() }}</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" :value="progress.processed" :max="Math.max(progress.total, 1)"></progress>
|
||||
<div v-if="progress.currentPath" class="rounded-box bg-base-100/40 border border-base-content/10 px-3 py-2 text-xs truncate" :title="progress.currentPath">
|
||||
{{ progress.currentPath }}
|
||||
</div>
|
||||
<div v-if="progress.failed" class="text-base-content/60">
|
||||
{{ $t('import_organize.failed', { count: progress.failed.toLocaleString() }) }}
|
||||
<div v-if="running || completed" class="space-y-2 px-1 text-xs">
|
||||
<div class="flex items-center justify-between gap-2 text-base-content/75">
|
||||
<span>{{ completed ? $t(cancelled ? 'import_organize.cancelled' : 'import_organize.complete') : progress.phase === 'preparing' ? $t('import_organize.preparing') : $t('import_organize.importing') }}</span>
|
||||
<span>{{ $t('import_organize.files_progress', { processed: progress.processed.toLocaleString(), total: progress.total.toLocaleString(), size: formatFileSize(progress.totalSize) }) }}</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" :value="running && progress.phase === 'preparing' ? undefined : progress.processed" :max="Math.max(progress.total, 1)"></progress>
|
||||
<div class="flex gap-3 text-base-content/60">
|
||||
<span>{{ $t('import_organize.imported', { count: progress.imported.toLocaleString() }) }}</span>
|
||||
<span>{{ $t('import_organize.skipped', { count: progress.skipped.toLocaleString() }) }}</span>
|
||||
</div>
|
||||
<div v-if="progress.failed" class="text-error/70">{{ $t('import_organize.failed', { count: progress.failed.toLocaleString() }) }}</div>
|
||||
</div>
|
||||
<div v-if="importError" role="alert" class="px-1 text-xs text-error/70 break-words">{{ importError }}</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<div class="mt-4 flex justify-end gap-4">
|
||||
<button v-if="!running" class="t-button-default" @click="close">{{ completed ? $t('msgbox.close') : $t('msgbox.cancel') }}</button>
|
||||
<button v-if="!running && !completed" class="t-button-primary" :disabled="!sourcePath" @click="startImport">{{ $t('import_organize.import') }}</button>
|
||||
<button v-if="completed && cancelled" class="t-button-primary" @click="continueImport">{{ $t('import_organize.continue') }}</button>
|
||||
<button v-if="!running && !completed" class="t-button-primary" :disabled="!sourcePath || counting || !destinationTree" @click="startImport">{{ $t('import_organize.import') }}</button>
|
||||
<button v-if="running" class="t-button-default" :disabled="cancelling" @click="cancel">{{ $t('msgbox.cancel') }}</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
@@ -57,28 +70,37 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useToast } from '@/common/toast';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { openFolderDialog } from '@/common/utils';
|
||||
import { cancelImportAndOrganize, fetchFolder, importAndOrganize, listenImportOrganizeFinished, listenImportOrganizeProgress } from '@/common/api';
|
||||
import { openFolderDialog, formatFileSize } from '@/common/utils';
|
||||
import { cancelImportAndOrganize, countFolder, fetchFolder, importAndOrganize, listenImportOrganizeFinished, listenImportOrganizeProgress } from '@/common/api';
|
||||
import { config, libConfig } from '@/common/config';
|
||||
import { useAlbumSelectionProvider } from '@/composables/useAlbumSelection';
|
||||
import { listen, type Event } from '@tauri-apps/api/event';
|
||||
import AlbumFolder from '@/components/AlbumFolder.vue';
|
||||
import ModalDialog from '@/components/ModalDialog.vue';
|
||||
import TButton from '@/components/TButton.vue';
|
||||
import { IconDownload } from '@/common/icons';
|
||||
|
||||
const props = defineProps<{ album: any }>();
|
||||
const emit = defineEmits(['complete', 'cancel']);
|
||||
const toast = useToast();
|
||||
const uiStore = useUIStore();
|
||||
const { t } = useI18n();
|
||||
const sourcePath = ref('');
|
||||
const sourceSummary = ref<{ count: number; size: number } | null>(null);
|
||||
const counting = ref(false);
|
||||
const importError = ref('');
|
||||
let sourceRequest = 0;
|
||||
let completedPaths: string[] = [];
|
||||
let disposed = false;
|
||||
const destinationTree = ref<any>(null);
|
||||
const layout = ref('day');
|
||||
const running = ref(false);
|
||||
const completed = ref(false);
|
||||
const cancelled = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const progress = ref({ phase: 'preparing', currentPath: '', processed: 0, total: 0, imported: 0, failed: 0 });
|
||||
const locked = computed(() => running.value || completed.value);
|
||||
const progress = ref({ phase: 'preparing', processed: 0, total: 0, totalSize: 0, imported: 0, skipped: 0, failed: 0 });
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
let unlistenFinished: (() => void) | undefined;
|
||||
let unlistenKeydown: (() => void) | undefined;
|
||||
@@ -91,17 +113,36 @@ libConfig.destFolder.selected = false;
|
||||
const destinationPath = computed(() => String(libConfig.destFolder.folderPath || props.album.path));
|
||||
|
||||
async function chooseSource() {
|
||||
if (locked.value) return;
|
||||
const path = await openFolderDialog();
|
||||
if (path) sourcePath.value = path;
|
||||
if (!path || disposed || locked.value) return;
|
||||
const request = ++sourceRequest;
|
||||
sourcePath.value = path;
|
||||
completedPaths = [];
|
||||
sourceSummary.value = null;
|
||||
importError.value = '';
|
||||
counting.value = true;
|
||||
try {
|
||||
const summary = await countFolder(path);
|
||||
if (disposed || request !== sourceRequest) return;
|
||||
if (summary) sourceSummary.value = {
|
||||
count: Number(summary[1]) + Number(summary[3]),
|
||||
size: Number(summary[2]) + Number(summary[4]),
|
||||
};
|
||||
} finally {
|
||||
if (!disposed && request === sourceRequest) counting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!sourcePath.value || running.value) return;
|
||||
if (!sourcePath.value || locked.value || counting.value || !destinationTree.value) return;
|
||||
running.value = true;
|
||||
unlistenProgress = await listenImportOrganizeProgress((event: any) => {
|
||||
progress.value = event.payload || progress.value;
|
||||
});
|
||||
importError.value = '';
|
||||
progress.value = { phase: 'preparing', processed: 0, total: 0, totalSize: 0, imported: 0, skipped: 0, failed: 0 };
|
||||
try {
|
||||
unlistenProgress = await listenImportOrganizeProgress((event: any) => {
|
||||
progress.value = event.payload || progress.value;
|
||||
});
|
||||
let resolveFinished: (result: any) => void;
|
||||
let rejectFinished: (error: Error) => void;
|
||||
const finished = new Promise<any>((resolve, reject) => {
|
||||
@@ -112,14 +153,15 @@ async function startImport() {
|
||||
const payload = event.payload || {};
|
||||
payload.error ? rejectFinished(new Error(payload.error)) : resolveFinished(payload.result);
|
||||
});
|
||||
await importAndOrganize(props.album.id, sourcePath.value, destinationPath.value, layout.value);
|
||||
await importAndOrganize(props.album.id, sourcePath.value, destinationPath.value, layout.value, completedPaths);
|
||||
const result = await finished;
|
||||
completedPaths = result.completedPaths;
|
||||
progress.value = { ...progress.value, ...result };
|
||||
cancelled.value = Boolean(result.cancelled);
|
||||
completed.value = true;
|
||||
emit('complete', result);
|
||||
} catch (error) {
|
||||
toast.error(String(error));
|
||||
importError.value = localizeImportError(error);
|
||||
} finally {
|
||||
running.value = false;
|
||||
unlistenProgress?.();
|
||||
@@ -132,7 +174,22 @@ async function startImport() {
|
||||
async function cancel() {
|
||||
if (cancelling.value) return;
|
||||
cancelling.value = true;
|
||||
try { await cancelImportAndOrganize(); } finally { cancelling.value = false; }
|
||||
try { await cancelImportAndOrganize(); } catch (error) { importError.value = localizeImportError(error); } finally { cancelling.value = false; }
|
||||
}
|
||||
|
||||
async function continueImport() {
|
||||
if (running.value) return;
|
||||
completed.value = false;
|
||||
cancelled.value = false;
|
||||
await startImport();
|
||||
}
|
||||
|
||||
function localizeImportError(error: unknown): string {
|
||||
const raw = String(error);
|
||||
if (raw.includes('cannot contain one another')) return t('import_organize.error_nested');
|
||||
if (raw.includes('must be inside the album')) return t('import_organize.error_destination_outside');
|
||||
if (raw.includes('source')) return t('import_organize.error_source');
|
||||
return t('import_organize.error_failed', { message: raw });
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -141,6 +198,8 @@ function close() {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
sourceRequest++;
|
||||
unlistenProgress?.();
|
||||
unlistenFinished?.();
|
||||
unlistenKeydown?.();
|
||||
@@ -161,6 +220,11 @@ onMounted(async () => {
|
||||
close();
|
||||
}
|
||||
});
|
||||
destinationTree.value = await fetchFolder(props.album.path, false, config.settings.folderSort);
|
||||
if (disposed) {
|
||||
unlistenKeydown?.();
|
||||
return;
|
||||
}
|
||||
const tree = await fetchFolder(props.album.path, false, config.settings.folderSort);
|
||||
if (!disposed) destinationTree.value = tree;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "Quellordner",
|
||||
"source_placeholder": "Quellordner auswählen",
|
||||
"choose_folder": "Ordner auswählen…",
|
||||
"destination": "Zielordner",
|
||||
"section_folders": "Ordner auswählen",
|
||||
"section_status": "Importstatus",
|
||||
"to_import": "Zu importieren",
|
||||
"files_progress": "{processed} / {total} Dateien ({size})",
|
||||
"source": "Quelle",
|
||||
"source_placeholder": "Quellordner wählen",
|
||||
"destination": "Ziel",
|
||||
"folder_layout": "Ordnerstruktur",
|
||||
"layout_day": "Nach Tag — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "Nach Monat — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "Dateien werden importiert",
|
||||
"complete": "Import abgeschlossen",
|
||||
"cancelled": "Import abgebrochen",
|
||||
"failed": "Fehlgeschlagen: {count}"
|
||||
"continue": "Fortsetzen",
|
||||
"imported": "Importiert: {count}",
|
||||
"skipped": "Übersprungen: {count}",
|
||||
"failed": "Fehlgeschlagen: {count}",
|
||||
"error_nested": "Quell- und Zielordner dürfen sich nicht gegenseitig enthalten",
|
||||
"error_destination_outside": "Der Zielordner muss sich im Album befinden",
|
||||
"error_source": "Auf den Quellordner kann nicht zugegriffen werden",
|
||||
"error_failed": "Import fehlgeschlagen: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Willkommen bei Lap",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "Source folder",
|
||||
"source_placeholder": "Choose a source folder",
|
||||
"choose_folder": "Choose folder…",
|
||||
"destination": "Destination folder",
|
||||
"section_folders": "Select folder",
|
||||
"section_status": "Import status",
|
||||
"to_import": "To import",
|
||||
"files_progress": "{processed} / {total} files ({size})",
|
||||
"source": "Source",
|
||||
"source_placeholder": "Select a source folder",
|
||||
"destination": "Destination",
|
||||
"folder_layout": "Folder layout",
|
||||
"layout_day": "By day — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "By month — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "Importing files",
|
||||
"complete": "Import complete",
|
||||
"cancelled": "Import cancelled",
|
||||
"failed": "Failed: {count}"
|
||||
"continue": "Continue",
|
||||
"imported": "Imported: {count}",
|
||||
"skipped": "Skipped: {count}",
|
||||
"failed": "Failed: {count}",
|
||||
"error_nested": "The source and destination folders cannot contain one another",
|
||||
"error_destination_outside": "The destination folder must be inside the album",
|
||||
"error_source": "Cannot access the source folder",
|
||||
"error_failed": "Import failed: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to Lap",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "Carpeta de origen",
|
||||
"source_placeholder": "Elige una carpeta de origen",
|
||||
"choose_folder": "Elegir carpeta…",
|
||||
"destination": "Carpeta de destino",
|
||||
"section_folders": "Seleccionar carpeta",
|
||||
"section_status": "Estado de importación",
|
||||
"to_import": "Por importar",
|
||||
"files_progress": "{processed} / {total} archivos ({size})",
|
||||
"source": "Origen",
|
||||
"source_placeholder": "Seleccionar carpeta de origen",
|
||||
"destination": "Destino",
|
||||
"folder_layout": "Estructura de carpetas",
|
||||
"layout_day": "Por día — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "Por mes — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "Importando archivos",
|
||||
"complete": "Importación completada",
|
||||
"cancelled": "Importación cancelada",
|
||||
"failed": "Fallidos: {count}"
|
||||
"continue": "Continuar",
|
||||
"imported": "Importados: {count}",
|
||||
"skipped": "Omitidos: {count}",
|
||||
"failed": "Fallidos: {count}",
|
||||
"error_nested": "Las carpetas de origen y destino no pueden contenerse entre sí",
|
||||
"error_destination_outside": "La carpeta de destino debe estar dentro del álbum",
|
||||
"error_source": "No se puede acceder a la carpeta de origen",
|
||||
"error_failed": "Error al importar: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenido a Lap",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "Dossier source",
|
||||
"source_placeholder": "Choisir un dossier source",
|
||||
"choose_folder": "Choisir un dossier…",
|
||||
"destination": "Dossier de destination",
|
||||
"section_folders": "Sélectionner un dossier",
|
||||
"section_status": "État de l’importation",
|
||||
"to_import": "À importer",
|
||||
"files_progress": "{processed} / {total} fichiers ({size})",
|
||||
"source": "Source",
|
||||
"source_placeholder": "Choisir dossier source",
|
||||
"destination": "Destination",
|
||||
"folder_layout": "Organisation des dossiers",
|
||||
"layout_day": "Par jour — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "Par mois — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "Importation des fichiers",
|
||||
"complete": "Importation terminée",
|
||||
"cancelled": "Importation annulée",
|
||||
"failed": "Échecs : {count}"
|
||||
"continue": "Continuer",
|
||||
"imported": "Importés : {count}",
|
||||
"skipped": "Ignorés : {count}",
|
||||
"failed": "Échecs : {count}",
|
||||
"error_nested": "Les dossiers source et destination ne peuvent pas se contenir l'un l'autre",
|
||||
"error_destination_outside": "Le dossier de destination doit se trouver dans l'album",
|
||||
"error_source": "Impossible d'accéder au dossier source",
|
||||
"error_failed": "Échec de l'importation : {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenue sur Lap",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "読み込み元フォルダー",
|
||||
"section_folders": "フォルダーを選択",
|
||||
"section_status": "インポート状況",
|
||||
"to_import": "インポート予定",
|
||||
"files_progress": "{processed} / {total} ファイル({size})",
|
||||
"source": "読み込み元",
|
||||
"source_placeholder": "読み込み元フォルダーを選択",
|
||||
"choose_folder": "フォルダーを選択…",
|
||||
"destination": "保存先フォルダー",
|
||||
"destination": "保存先",
|
||||
"folder_layout": "フォルダー構成",
|
||||
"layout_day": "日別 — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "月別 — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "ファイルを読み込み中",
|
||||
"complete": "読み込み完了",
|
||||
"cancelled": "読み込みをキャンセルしました",
|
||||
"failed": "失敗: {count}"
|
||||
"continue": "続行",
|
||||
"imported": "読み込み済み: {count}",
|
||||
"skipped": "スキップ: {count}",
|
||||
"failed": "失敗: {count}",
|
||||
"error_nested": "ソースフォルダーと宛先フォルダーは互いに含めることはできません",
|
||||
"error_destination_outside": "宛先フォルダーはアルバム内にある必要があります",
|
||||
"error_source": "ソースフォルダーにアクセスできません",
|
||||
"error_failed": "インポートに失敗しました: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lap へようこそ",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "원본 폴더",
|
||||
"section_folders": "폴더 선택",
|
||||
"section_status": "가져오기 상태",
|
||||
"to_import": "가져올 파일",
|
||||
"files_progress": "{processed} / {total}개 파일 ({size})",
|
||||
"source": "원본",
|
||||
"source_placeholder": "원본 폴더 선택",
|
||||
"choose_folder": "폴더 선택…",
|
||||
"destination": "대상 폴더",
|
||||
"destination": "대상",
|
||||
"folder_layout": "폴더 구조",
|
||||
"layout_day": "일별 — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "월별 — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "파일 가져오는 중",
|
||||
"complete": "가져오기 완료",
|
||||
"cancelled": "가져오기가 취소됨",
|
||||
"failed": "실패: {count}"
|
||||
"continue": "계속",
|
||||
"imported": "가져옴: {count}",
|
||||
"skipped": "건너뜀: {count}",
|
||||
"failed": "실패: {count}",
|
||||
"error_nested": "소스 및 대상 폴더는 서로를 포함할 수 없습니다",
|
||||
"error_destination_outside": "대상 폴더는 앨범 안에 있어야 합니다",
|
||||
"error_source": "소스 폴더에 액세스할 수 없습니다",
|
||||
"error_failed": "가져오기 실패: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Lap에 오신 것을 환영합니다",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "Pasta de origem",
|
||||
"source_placeholder": "Escolha uma pasta de origem",
|
||||
"choose_folder": "Escolher pasta…",
|
||||
"destination": "Pasta de destino",
|
||||
"section_folders": "Selecionar pasta",
|
||||
"section_status": "Status da importação",
|
||||
"to_import": "Para importar",
|
||||
"files_progress": "{processed} / {total} arquivos ({size})",
|
||||
"source": "Origem",
|
||||
"source_placeholder": "Selecionar pasta de origem",
|
||||
"destination": "Destino",
|
||||
"folder_layout": "Estrutura de pastas",
|
||||
"layout_day": "Por dia — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "Por mês — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "Importando arquivos",
|
||||
"complete": "Importação concluída",
|
||||
"cancelled": "Importação cancelada",
|
||||
"failed": "Falhos: {count}"
|
||||
"continue": "Continuar",
|
||||
"imported": "Importados: {count}",
|
||||
"skipped": "Ignorados: {count}",
|
||||
"failed": "Falhos: {count}",
|
||||
"error_nested": "As pastas de origem e destino não podem se conter",
|
||||
"error_destination_outside": "A pasta de destino deve estar dentro do álbum",
|
||||
"error_source": "Não foi possível acessar a pasta de origem",
|
||||
"error_failed": "Falha na importação: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bem-vindo ao Lap",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "Исходная папка",
|
||||
"section_folders": "Выбор папки",
|
||||
"section_status": "Состояние импорта",
|
||||
"to_import": "К импорту",
|
||||
"files_progress": "{processed} / {total} файлов ({size})",
|
||||
"source": "Источник",
|
||||
"source_placeholder": "Выберите исходную папку",
|
||||
"choose_folder": "Выбрать папку…",
|
||||
"destination": "Целевая папка",
|
||||
"destination": "Назначение",
|
||||
"folder_layout": "Структура папок",
|
||||
"layout_day": "По дням — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "По месяцам — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "Импорт файлов",
|
||||
"complete": "Импорт завершён",
|
||||
"cancelled": "Импорт отменён",
|
||||
"failed": "Ошибок: {count}"
|
||||
"continue": "Продолжить",
|
||||
"imported": "Импортировано: {count}",
|
||||
"skipped": "Пропущено: {count}",
|
||||
"failed": "Ошибок: {count}",
|
||||
"error_nested": "Исходная и целевая папки не могут содержать друг друга",
|
||||
"error_destination_outside": "Целевая папка должна находиться внутри альбома",
|
||||
"error_source": "Не удается получить доступ к исходной папке",
|
||||
"error_failed": "Не удалось импортировать: {message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Добро пожаловать в Lap",
|
||||
|
||||
@@ -200,10 +200,13 @@
|
||||
}
|
||||
},
|
||||
"import_organize": {
|
||||
"source": "来源文件夹",
|
||||
"section_folders": "选择文件夹",
|
||||
"section_status": "导入状态",
|
||||
"to_import": "待导入",
|
||||
"files_progress": "{processed} / {total} 个文件({size})",
|
||||
"source": "来源",
|
||||
"source_placeholder": "选择来源文件夹",
|
||||
"choose_folder": "选择文件夹…",
|
||||
"destination": "目标文件夹",
|
||||
"destination": "目标",
|
||||
"folder_layout": "文件夹结构",
|
||||
"layout_day": "按天 — YYYY/YYYY-MM-DD",
|
||||
"layout_month": "按月 — YYYY/YYYY-MM",
|
||||
@@ -214,7 +217,14 @@
|
||||
"importing": "正在导入文件",
|
||||
"complete": "导入完成",
|
||||
"cancelled": "已取消导入",
|
||||
"failed": "失败:{count}"
|
||||
"continue": "继续导入",
|
||||
"imported": "已导入:{count}",
|
||||
"skipped": "已跳过:{count}",
|
||||
"failed": "失败:{count}",
|
||||
"error_nested": "源文件夹和目标文件夹不能互相包含",
|
||||
"error_destination_outside": "目标文件夹必须在相册内",
|
||||
"error_source": "无法访问源文件夹",
|
||||
"error_failed": "导入失败:{message}"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "欢迎使用 Lap",
|
||||
|
||||
Reference in New Issue
Block a user