Skip to content

Commit a42da8c

Browse files
committed
Add project filename migration from UUID to project name
1 parent e665245 commit a42da8c

File tree

5 files changed

+240
-40
lines changed

5 files changed

+240
-40
lines changed

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod recording_settings;
2525
mod target_select_overlay;
2626
mod thumbnails;
2727
mod tray;
28+
mod update_project_names;
2829
mod upload;
2930
mod web_api;
3031
mod window_exclusion;
@@ -2050,6 +2051,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
20502051
.invoke_handler(specta_builder.invoke_handler())
20512052
.setup(move |app| {
20522053
let app = app.handle().clone();
2054+
update_project_names::migrate_if_needed(&app)?;
20532055
specta_builder.mount_events(&app);
20542056
hotkeys::init(&app);
20552057
general_settings::init(&app);

apps/desktop/src-tauri/src/recording.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ pub async fn start_recording(
407407
);
408408

409409
let filename = project_name.replace(":", ".");
410-
let filename = sanitize_filename::sanitize(&filename);
410+
let filename = format!("{}.cap", sanitize_filename::sanitize(&filename));
411411

412412
let recordings_base_dir = app.path().app_data_dir().unwrap().join("recordings");
413413

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use std::path::{Path, PathBuf};
2+
3+
use cap_project::RecordingMeta;
4+
use futures::StreamExt;
5+
use tauri::AppHandle;
6+
use tokio::fs;
7+
8+
use crate::recordings_path;
9+
10+
const STORE_KEY: &str = "uuid_projects_migrated";
11+
12+
pub fn migrate_if_needed(app: &AppHandle) -> Result<(), String> {
13+
use tauri_plugin_store::StoreExt;
14+
15+
let store = app
16+
.store("store")
17+
.map_err(|e| format!("Failed to access store: {}", e))?;
18+
19+
if store
20+
.get(STORE_KEY)
21+
.and_then(|v| v.as_bool())
22+
.unwrap_or(false)
23+
{
24+
return Ok(());
25+
}
26+
27+
if let Err(err) = futures::executor::block_on(migrate(app)) {
28+
tracing::error!("Updating project names failed: {err}");
29+
}
30+
31+
store.set(STORE_KEY, true);
32+
store
33+
.save()
34+
.map_err(|e| format!("Failed to save store: {}", e))?;
35+
36+
Ok(())
37+
}
38+
39+
/// Performs a one-time migration of all UUID-named projects to pretty name-based naming.
40+
pub async fn migrate(app: &AppHandle) -> Result<(), String> {
41+
let recordings_dir = recordings_path(app);
42+
if !fs::try_exists(&recordings_dir)
43+
.await
44+
.map_err(|e| format!("Failed to check recordings directory: {}", e))?
45+
{
46+
return Ok(());
47+
}
48+
49+
let uuid_projects = collect_uuid_projects(&recordings_dir).await?;
50+
if uuid_projects.is_empty() {
51+
tracing::debug!("No UUID-named projects found to migrate");
52+
return Ok(());
53+
}
54+
55+
tracing::info!(
56+
"Found {} UUID-named projects to migrate",
57+
uuid_projects.len()
58+
);
59+
60+
let total_found = uuid_projects.len();
61+
let concurrency_limit = std::thread::available_parallelism()
62+
.map(|n| n.get())
63+
.unwrap_or(4)
64+
.max(2)
65+
.min(16)
66+
.min(total_found);
67+
tracing::debug!("Using concurrency limit of {}", concurrency_limit);
68+
69+
let migration_results = futures::stream::iter(uuid_projects)
70+
.map(migrate_single_project)
71+
.buffer_unordered(concurrency_limit)
72+
.collect::<Vec<_>>()
73+
.await;
74+
75+
// Aggregate results
76+
let mut migrated = 0;
77+
let mut skipped = 0;
78+
let mut failed = 0;
79+
80+
for result in migration_results {
81+
match result {
82+
Ok(ProjectMigrationResult::Migrated) => migrated += 1,
83+
Ok(ProjectMigrationResult::Skipped) => skipped += 1,
84+
Err(_) => failed += 1,
85+
}
86+
}
87+
88+
tracing::info!(
89+
total_found = total_found,
90+
migrated = migrated,
91+
skipped = skipped,
92+
failed = failed,
93+
"Migration complete"
94+
);
95+
96+
Ok(())
97+
}
98+
99+
async fn collect_uuid_projects(recordings_dir: &Path) -> Result<Vec<PathBuf>, String> {
100+
let mut uuid_projects = Vec::new();
101+
let mut entries = fs::read_dir(recordings_dir)
102+
.await
103+
.map_err(|e| format!("Failed to read recordings directory: {}", e))?;
104+
105+
while let Some(entry) = entries
106+
.next_entry()
107+
.await
108+
.map_err(|e| format!("Failed to read directory entry: {}", e))?
109+
{
110+
let path = entry.path();
111+
if !path.is_dir() {
112+
continue;
113+
}
114+
115+
let Some(filename) = path.file_name().and_then(|s| s.to_str()) else {
116+
continue;
117+
};
118+
119+
if filename.ends_with(".cap") && fast_is_project_filename_uuid(filename) {
120+
uuid_projects.push(path);
121+
}
122+
}
123+
124+
Ok(uuid_projects)
125+
}
126+
127+
#[derive(Debug)]
128+
enum ProjectMigrationResult {
129+
Migrated,
130+
Skipped,
131+
}
132+
133+
async fn migrate_single_project(path: PathBuf) -> Result<ProjectMigrationResult, String> {
134+
let filename = path
135+
.file_name()
136+
.and_then(|s| s.to_str())
137+
.unwrap_or("unknown");
138+
139+
let meta = match RecordingMeta::load_for_project(&path) {
140+
Ok(meta) => meta,
141+
Err(e) => {
142+
tracing::warn!("Failed to load metadata for {}: {}", filename, e);
143+
return Err(format!("Failed to load metadata: {}", e));
144+
}
145+
};
146+
147+
match migrate_project_filename_async(&path, &meta).await {
148+
Ok(new_path) => {
149+
if new_path != path {
150+
let new_name = new_path.file_name().unwrap().to_string_lossy();
151+
tracing::info!("Updated name: \"{}\" -> \"{}\"", filename, new_name);
152+
Ok(ProjectMigrationResult::Migrated)
153+
} else {
154+
Ok(ProjectMigrationResult::Skipped)
155+
}
156+
}
157+
Err(e) => {
158+
tracing::error!("Failed to migrate {}: {}", filename, e);
159+
Err(e)
160+
}
161+
}
162+
}
163+
164+
/// Migrates a project filename from UUID to sanitized pretty name
165+
async fn migrate_project_filename_async(
166+
project_path: &Path,
167+
meta: &RecordingMeta,
168+
) -> Result<PathBuf, String> {
169+
let sanitized = sanitize_filename::sanitize(&meta.pretty_name.replace(":", "."));
170+
171+
let filename = if sanitized.ends_with(".cap") {
172+
sanitized
173+
} else {
174+
format!("{}.cap", sanitized)
175+
};
176+
177+
let parent_dir = project_path
178+
.parent()
179+
.ok_or("Project path has no parent directory")?;
180+
181+
let unique_filename = cap_utils::ensure_unique_filename(&filename, parent_dir)
182+
.map_err(|e| format!("Failed to ensure unique filename: {}", e))?;
183+
184+
let final_path = parent_dir.join(&unique_filename);
185+
186+
fs::rename(project_path, &final_path)
187+
.await
188+
.map_err(|e| format!("Failed to rename project directory: {}", e))?;
189+
190+
Ok(final_path)
191+
}
192+
193+
pub fn fast_is_project_filename_uuid(filename: &str) -> bool {
194+
if filename.len() != 40 || !filename.ends_with(".cap") {
195+
return false;
196+
}
197+
198+
let uuid_part = &filename[..36];
199+
200+
if uuid_part.as_bytes()[8] != b'-'
201+
|| uuid_part.as_bytes()[13] != b'-'
202+
|| uuid_part.as_bytes()[18] != b'-'
203+
|| uuid_part.as_bytes()[23] != b'-'
204+
{
205+
return false;
206+
}
207+
208+
uuid_part.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
209+
}
210+
211+
#[cfg(test)]
212+
mod tests {
213+
use super::*;
214+
215+
#[test]
216+
fn test_is_project_filename_uuid() {
217+
// Valid UUID
218+
assert!(fast_is_project_filename_uuid(
219+
"a1b2c3d4-e5f6-7890-abcd-ef1234567890.cap"
220+
));
221+
assert!(fast_is_project_filename_uuid(
222+
"00000000-0000-0000-0000-000000000000.cap"
223+
));
224+
225+
// Invalid cases
226+
assert!(!fast_is_project_filename_uuid("my-project-name.cap"));
227+
assert!(!fast_is_project_filename_uuid(
228+
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
229+
));
230+
assert!(!fast_is_project_filename_uuid(
231+
"a1b2c3d4-e5f6-7890-abcd-ef1234567890.txt"
232+
));
233+
assert!(!fast_is_project_filename_uuid(
234+
"g1b2c3d4-e5f6-7890-abcd-ef1234567890.cap"
235+
));
236+
}
237+
}

apps/desktop/src/routes/(window-chrome)/settings/general.tsx

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -793,38 +793,6 @@ function DefaultProjectNameCard(props: {
793793
</div>
794794
</Collapsible.Content>
795795
</Collapsible>
796-
797-
{/*<div class="flex justify-end gap-2 pt-1">
798-
<Button
799-
size="sm"
800-
variant="gray"
801-
disabled={
802-
inputValue() === DEFAULT_PROJECT_NAME_TEMPLATE &&
803-
inputValue() !== props.value
804-
}
805-
onClick={async () => {
806-
await props.onChange(null);
807-
const newTemplate = initialTemplate();
808-
setInputValue(newTemplate);
809-
if (inputRef) inputRef.value = newTemplate;
810-
await updatePreview(newTemplate);
811-
}}
812-
>
813-
Reset
814-
</Button>
815-
816-
<Button
817-
size="sm"
818-
variant="dark"
819-
disabled={isSaveDisabled()}
820-
onClick={() => {
821-
props.onChange(inputValue() ?? null);
822-
updatePreview();
823-
}}
824-
>
825-
Save
826-
</Button>
827-
</div>*/}
828796
</div>
829797
</div>
830798
);

crates/utils/src/lib.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ pub fn ensure_unique_filename(
4949
let initial_path = parent_dir.join(base_filename);
5050

5151
if !initial_path.exists() {
52-
println!("Ensure unique filename: is free!");
5352
return Ok(base_filename.to_string());
5453
}
5554

@@ -76,13 +75,7 @@ pub fn ensure_unique_filename(
7675

7776
let test_path = parent_dir.join(&numbered_filename);
7877

79-
println!("Ensure unique filename: test path count \"{counter}\"");
80-
8178
if !test_path.exists() {
82-
println!(
83-
"Ensure unique filename: Found free! \"{}\"",
84-
&test_path.display()
85-
);
8679
return Ok(numbered_filename);
8780
}
8881

0 commit comments

Comments
 (0)