Skip to content

Commit eadd5b4

Browse files
committed
feat: add rename action
1 parent 3c3f5a1 commit eadd5b4

8 files changed

Lines changed: 1202 additions & 73 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lowcat"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2024"
55
description = "A lightweight local sound library app."
66

src/backend.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ use crate::model::{
1616
canonical_tag_key, supported_audio_extension,
1717
};
1818

19+
#[derive(Clone, Debug)]
20+
pub struct RenameRecord {
21+
pub stem: String,
22+
pub paths: Vec<PathBuf>,
23+
}
24+
1925
pub struct Backend {
2026
db: Database,
2127
folders: BTreeMap<Category, PathBuf>,
@@ -151,6 +157,48 @@ impl Backend {
151157
self.db.remove_tag(category, &stem, key, value)
152158
}
153159

160+
pub fn rename_tag(
161+
&mut self,
162+
category: Category,
163+
path: &Path,
164+
key: &str,
165+
old_value: &str,
166+
new_value: &str,
167+
) -> io::Result<()> {
168+
let Some(key) = canonical_category_key(category, key) else {
169+
return Ok(());
170+
};
171+
let stem = file_stem(path);
172+
self.db
173+
.rename_stem_tag_value(category, &stem, key, old_value, new_value)
174+
}
175+
176+
pub fn rename_records(
177+
&mut self,
178+
category: Category,
179+
records: &[RenameRecord],
180+
new_stem: &str,
181+
) -> io::Result<usize> {
182+
rename_record_files(records, new_stem)?;
183+
for record in records {
184+
self.db.rename_stem_tags(category, &record.stem, new_stem)?;
185+
}
186+
self.refresh_category(category)?;
187+
Ok(records.iter().map(|record| record.paths.len()).sum())
188+
}
189+
190+
pub fn rename_tag_value(
191+
&mut self,
192+
category: Category,
193+
key: &str,
194+
old_value: &str,
195+
new_value: &str,
196+
) -> io::Result<()> {
197+
self.db
198+
.rename_tag_value(category, key, old_value, new_value)?;
199+
self.refresh_category(category)
200+
}
201+
154202
pub fn folder_tag_values(&self, category: Category) -> io::Result<Vec<String>> {
155203
let Some(folder) = self.folders.get(&category) else {
156204
return Ok(Vec::new());
@@ -295,6 +343,77 @@ fn trash_files(paths: Vec<PathBuf>) -> io::Result<usize> {
295343
Ok(path_count)
296344
}
297345

346+
fn rename_record_files(records: &[RenameRecord], new_stem: &str) -> io::Result<usize> {
347+
let new_stem = valid_file_stem(new_stem)?;
348+
let mut source_paths = BTreeSet::new();
349+
let mut planned = Vec::new();
350+
351+
for record in records {
352+
for source in &record.paths {
353+
source_paths.insert(source.clone());
354+
let extension = source
355+
.extension()
356+
.and_then(|extension| extension.to_str())
357+
.ok_or_else(|| {
358+
io::Error::new(io::ErrorKind::InvalidInput, "file has no extension")
359+
})?;
360+
let parent = source.parent().ok_or_else(|| {
361+
io::Error::new(io::ErrorKind::InvalidInput, "file has no parent folder")
362+
})?;
363+
planned.push((
364+
source.clone(),
365+
parent.join(format!("{new_stem}.{extension}")),
366+
));
367+
}
368+
}
369+
370+
let mut destination_paths = BTreeSet::new();
371+
for (source, destination) in &planned {
372+
if source == destination {
373+
continue;
374+
}
375+
if !destination_paths.insert(destination.clone()) {
376+
return Err(io::Error::new(
377+
io::ErrorKind::AlreadyExists,
378+
format!("rename would create duplicate {}", destination.display()),
379+
));
380+
}
381+
if destination.exists() && !source_paths.contains(destination) {
382+
return Err(io::Error::new(
383+
io::ErrorKind::AlreadyExists,
384+
format!("{} already exists", destination.display()),
385+
));
386+
}
387+
}
388+
389+
let mut renamed = 0;
390+
for (source, destination) in planned {
391+
if source == destination {
392+
continue;
393+
}
394+
fs::rename(&source, &destination)?;
395+
renamed += 1;
396+
}
397+
Ok(renamed)
398+
}
399+
400+
fn valid_file_stem(stem: &str) -> io::Result<&str> {
401+
let stem = stem.trim();
402+
if stem.is_empty() {
403+
return Err(io::Error::new(
404+
io::ErrorKind::InvalidInput,
405+
"new name cannot be empty",
406+
));
407+
}
408+
if stem.contains('/') || stem.contains('\\') {
409+
return Err(io::Error::new(
410+
io::ErrorKind::InvalidInput,
411+
"new name cannot contain path separators",
412+
));
413+
}
414+
Ok(stem)
415+
}
416+
298417
pub fn import_to_folder(
299418
folder: &Path,
300419
source: &Path,
@@ -833,6 +952,67 @@ mod tests {
833952
assert_eq!(records[0].tags["TYPE"], vec!["Foley"]);
834953
}
835954

955+
#[test]
956+
fn rename_records_moves_variants_and_preserves_tags() {
957+
let dir = unique_dir("rename-records");
958+
fs::write(dir.join("song.wav"), b"not actually audio").unwrap();
959+
fs::write(dir.join("song.mp3"), b"not actually audio").unwrap();
960+
let paths = vec![dir.join("song.wav"), dir.join("song.mp3")];
961+
962+
let mut backend = backend("rename-records");
963+
backend
964+
.set_category_folder(Category::Music, dir.clone())
965+
.unwrap();
966+
backend
967+
.add_tag(Category::Music, &paths[0], "Genre", "Ambient")
968+
.unwrap();
969+
970+
backend
971+
.rename_records(
972+
Category::Music,
973+
&[RenameRecord {
974+
stem: "song".to_string(),
975+
paths,
976+
}],
977+
"renamed",
978+
)
979+
.unwrap();
980+
981+
assert!(!dir.join("song.wav").exists());
982+
assert!(!dir.join("song.mp3").exists());
983+
assert!(dir.join("renamed.wav").exists());
984+
assert!(dir.join("renamed.mp3").exists());
985+
let records = backend.filter(Category::Music, "", &BTreeMap::new());
986+
assert_eq!(names(records.clone()), vec!["renamed"]);
987+
assert_eq!(records[0].tags["GENRE"], vec!["Ambient"]);
988+
}
989+
990+
#[test]
991+
fn rename_tag_value_updates_all_matching_stems() {
992+
let dir = unique_dir("rename-tag-value");
993+
let first = dir.join("first.wav");
994+
let second = dir.join("second.wav");
995+
fs::write(&first, b"not actually audio").unwrap();
996+
fs::write(&second, b"not actually audio").unwrap();
997+
998+
let mut backend = backend("rename-tag-value");
999+
backend.set_category_folder(Category::Music, dir).unwrap();
1000+
backend
1001+
.add_tag(Category::Music, &first, "Genre", "Ambient")
1002+
.unwrap();
1003+
backend
1004+
.add_tag(Category::Music, &second, "Genre", "Ambient")
1005+
.unwrap();
1006+
1007+
backend
1008+
.rename_tag_value(Category::Music, "Genre", "Ambient", "Drone")
1009+
.unwrap();
1010+
1011+
let records = backend.filter(Category::Music, "", &BTreeMap::new());
1012+
assert_eq!(records[0].tags["GENRE"], vec!["Drone"]);
1013+
assert_eq!(records[1].tags["GENRE"], vec!["Drone"]);
1014+
}
1015+
8361016
#[test]
8371017
fn refresh_indexes_matching_extensions_without_probe() {
8381018
let dir = unique_dir("extension-only");

src/db.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,129 @@ impl Database {
449449
.map_err(io::Error::other)
450450
}
451451

452+
pub fn rename_stem_tag_value(
453+
&self,
454+
category: Category,
455+
stem: &str,
456+
key: &str,
457+
old_value: &str,
458+
new_value: &str,
459+
) -> io::Result<()> {
460+
let Some(key) = canonical_tag_key(key) else {
461+
return Ok(());
462+
};
463+
if !category.tag_keys().contains(&key) || old_value == new_value {
464+
return Ok(());
465+
}
466+
block_on(async {
467+
let category = category_key(category);
468+
let mut tx = self.pool.begin().await?;
469+
sqlx::query(
470+
"INSERT OR IGNORE INTO tag_values(category, stem, key, value)
471+
SELECT category, stem, key, ?
472+
FROM tag_values
473+
WHERE category = ? AND stem = ? AND key = ? AND value = ?",
474+
)
475+
.bind(new_value)
476+
.bind(category)
477+
.bind(stem)
478+
.bind(key)
479+
.bind(old_value)
480+
.execute(&mut *tx)
481+
.await?;
482+
sqlx::query(
483+
"DELETE FROM tag_values
484+
WHERE category = ? AND stem = ? AND key = ? AND value = ?",
485+
)
486+
.bind(category)
487+
.bind(stem)
488+
.bind(key)
489+
.bind(old_value)
490+
.execute(&mut *tx)
491+
.await?;
492+
tx.commit().await?;
493+
Ok::<_, sqlx::Error>(())
494+
})
495+
.map_err(io::Error::other)
496+
}
497+
498+
pub fn rename_stem_tags(
499+
&self,
500+
category: Category,
501+
old_stem: &str,
502+
new_stem: &str,
503+
) -> io::Result<()> {
504+
if old_stem == new_stem {
505+
return Ok(());
506+
}
507+
block_on(async {
508+
let category = category_key(category);
509+
let mut tx = self.pool.begin().await?;
510+
sqlx::query(
511+
"INSERT OR IGNORE INTO tag_values(category, stem, key, value)
512+
SELECT category, ?, key, value
513+
FROM tag_values
514+
WHERE category = ? AND stem = ?",
515+
)
516+
.bind(new_stem)
517+
.bind(category)
518+
.bind(old_stem)
519+
.execute(&mut *tx)
520+
.await?;
521+
sqlx::query("DELETE FROM tag_values WHERE category = ? AND stem = ?")
522+
.bind(category)
523+
.bind(old_stem)
524+
.execute(&mut *tx)
525+
.await?;
526+
tx.commit().await?;
527+
Ok::<_, sqlx::Error>(())
528+
})
529+
.map_err(io::Error::other)
530+
}
531+
532+
pub fn rename_tag_value(
533+
&self,
534+
category: Category,
535+
key: &str,
536+
old_value: &str,
537+
new_value: &str,
538+
) -> io::Result<()> {
539+
let Some(key) = canonical_tag_key(key) else {
540+
return Ok(());
541+
};
542+
if !category.tag_keys().contains(&key) || old_value == new_value {
543+
return Ok(());
544+
}
545+
block_on(async {
546+
let category = category_key(category);
547+
let mut tx = self.pool.begin().await?;
548+
sqlx::query(
549+
"INSERT OR IGNORE INTO tag_values(category, stem, key, value)
550+
SELECT category, stem, key, ?
551+
FROM tag_values
552+
WHERE category = ? AND key = ? AND value = ?",
553+
)
554+
.bind(new_value)
555+
.bind(category)
556+
.bind(key)
557+
.bind(old_value)
558+
.execute(&mut *tx)
559+
.await?;
560+
sqlx::query(
561+
"DELETE FROM tag_values
562+
WHERE category = ? AND key = ? AND value = ?",
563+
)
564+
.bind(category)
565+
.bind(key)
566+
.bind(old_value)
567+
.execute(&mut *tx)
568+
.await?;
569+
tx.commit().await?;
570+
Ok::<_, sqlx::Error>(())
571+
})
572+
.map_err(io::Error::other)
573+
}
574+
452575
pub fn folder_tag_values(
453576
&self,
454577
category: Category,

0 commit comments

Comments
 (0)