feat(import): rework import-and-organize with resume, content dedup, and RAW support

This commit is contained in:
julyx10
2026-09-11 20:12:13 +08:00
parent 0dddb47440
commit 242a69fcc8
18 changed files with 562 additions and 181 deletions
-5
View File
@@ -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
+34 -2
View File
@@ -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,
+6 -2
View File
@@ -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),
);
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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() {
+1
View File
@@ -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' &&
+119 -55
View File
@@ -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>
+15 -5
View File
@@ -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",
+15 -5
View File
@@ -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",
+15 -5
View File
@@ -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",
+15 -5
View File
@@ -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",
+14 -4
View File
@@ -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 へようこそ",
+14 -4
View File
@@ -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에 오신 것을 환영합니다",
+15 -5
View File
@@ -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",
+14 -4
View File
@@ -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",
+14 -4
View File
@@ -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",