fix: missing media files when renaming + saving the project

This commit is contained in:
Xiaohan-Tian
2026-04-10 12:14:20 -07:00
parent 6080accd3a
commit 83f4bfe47f
7 changed files with 167 additions and 52 deletions
+13 -7
View File
@@ -30,6 +30,7 @@ import PianoIcon from './common/icons/PianoIcon';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
const { const {
projectName, setProjectName, projectName, setProjectName,
savedProjectName, setSavedProjectName,
bpm, timeSignature, keySignature, setStatus, bpm, timeSignature, keySignature, setStatus,
isPlaying, startPlaying, stopPlaying, setPlayheadPosition, isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
currentTime, setBpm, setTimeSignature, setKeySignature, currentTime, setBpm, setTimeSignature, setKeySignature,
@@ -73,7 +74,7 @@ const Toolbar: React.FC = () => {
}; };
// Common project loading logic extracted for reuse // Common project loading logic extracted for reuse
const loadProjectFromData = async (project: KGProject, sourceDescription: string) => { const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string) => {
try { try {
// Clean up UI state first // Clean up UI state first
cleanupProjectState(); cleanupProjectState();
@@ -83,7 +84,7 @@ const Toolbar: React.FC = () => {
// Load the project using the store's loadProject method // Load the project using the store's loadProject method
const { loadProject: storeLoadProject } = useProjectStore.getState(); const { loadProject: storeLoadProject } = useProjectStore.getState();
await storeLoadProject(project); await storeLoadProject(project, savedName);
// Update status to indicate project loaded // Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`); setStatus(`${sourceDescription} loaded successfully`);
@@ -159,8 +160,8 @@ const Toolbar: React.FC = () => {
return; return;
} }
// Use common loading logic // Use common loading logic — pass the OPFS folder name so savedProjectName is set correctly
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`); await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad.trim());
} catch (error) { } catch (error) {
console.error("Error loading project:", error); console.error("Error loading project:", error);
@@ -174,7 +175,12 @@ const Toolbar: React.FC = () => {
console.log("user clicked save button"); console.log("user clicked save button");
} }
await saveProject(projectName, setStatus); await saveProject(projectName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== projectName) {
setProjectName(finalName);
}
});
}; };
const handleExportProject = (exportType: string) => { const handleExportProject = (exportType: string) => {
@@ -317,7 +323,7 @@ const Toolbar: React.FC = () => {
throw new Error('Failed to load imported project'); throw new Error('Failed to load imported project');
} }
await loadProjectFromData(loaded, `KGStudio file "${file.name}"`); await loadProjectFromData(loaded, `KGStudio file "${file.name}"`, projectName);
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio file imported successfully:", projectName); console.log("KGStudio file imported successfully:", projectName);
@@ -357,7 +363,7 @@ const Toolbar: React.FC = () => {
throw new Error('Failed to load imported project from storage'); throw new Error('Failed to load imported project from storage');
} }
await loadProjectFromData(loaded, `JSON file "${file.name}"`); await loadProjectFromData(loaded, `JSON file "${file.name}"`, importedName);
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio JSON project imported and saved to OPFS:", importedName); console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
+4
View File
@@ -85,6 +85,10 @@
box-shadow: 0 0 0 1px #6aab7a; box-shadow: 0 0 0 1px #6aab7a;
} }
.track-region.audio-region.selected {
border-color: #ffffff;
}
.track-region.audio-region .region-header { .track-region.audio-region .region-header {
background-color: #4a8b5a; background-color: #4a8b5a;
} }
+15 -2
View File
@@ -7,13 +7,14 @@ import { OPFS_CONSTANTS } from '../../constants/coreConstants';
export class KGAudioFileStorage { export class KGAudioFileStorage {
/** /**
* Store an audio file in the project's media/ directory. * Store an audio file in the project's media/ directory.
* Creates the project and media directories if they don't exist yet.
*/ */
public static async storeAudioFile( public static async storeAudioFile(
projectName: string, projectName: string,
fileId: string, fileId: string,
file: File file: File
): Promise<void> { ): Promise<void> {
const mediaDir = await KGAudioFileStorage.getMediaDir(projectName); const mediaDir = await KGAudioFileStorage.getOrCreateMediaDir(projectName);
const fileHandle = await mediaDir.getFileHandle(fileId, { create: true }); const fileHandle = await mediaDir.getFileHandle(fileId, { create: true });
const writable = await fileHandle.createWritable(); const writable = await fileHandle.createWritable();
await writable.write(await file.arrayBuffer()); await writable.write(await file.arrayBuffer());
@@ -61,7 +62,8 @@ export class KGAudioFileStorage {
} }
/** /**
* Get the media directory handle for a project. * Get the media directory handle for an existing project (read/delete path).
* Does NOT create directories — fails if project folder doesn't exist.
*/ */
private static async getMediaDir(projectName: string): Promise<FileSystemDirectoryHandle> { private static async getMediaDir(projectName: string): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory(); const root = await navigator.storage.getDirectory();
@@ -69,4 +71,15 @@ export class KGAudioFileStorage {
const projectDir = await projectsDir.getDirectoryHandle(projectName); const projectDir = await projectsDir.getDirectoryHandle(projectName);
return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true }); return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
} }
/**
* Get or create the media directory handle for a project (write path).
* Creates project and media directories if they don't exist.
*/
private static async getOrCreateMediaDir(projectName: string): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory();
const projectsDir = await root.getDirectoryHandle(OPFS_CONSTANTS.ROOT_DIR, { create: true });
const projectDir = await projectsDir.getDirectoryHandle(projectName, { create: true });
return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
}
} }
+57
View File
@@ -206,10 +206,31 @@ export class KGProjectStorage {
project.setName(newName); project.setName(newName);
await this.save(newName, project, false); await this.save(newName, project, false);
// Copy media files from old to new location
await this.copyMediaFiles(oldName, newName);
// Delete old location // Delete old location
await this.delete(oldName); await this.delete(oldName);
} }
/**
* Save a project under a new name, migrating media files from the old folder.
* Used when the user renames the project and saves. Handles the case where the
* old folder doesn't exist yet (new project never saved).
*/
public async saveWithRename(oldName: string, newName: string, data: KGProject): Promise<void> {
this.ensureInitialized();
// Save project JSON to the new folder
await this.save(newName, data, false);
// Migrate media files only if the old folder exists
if (await this.exists(oldName)) {
await this.copyMediaFiles(oldName, newName);
await this.delete(oldName);
}
}
/** /**
* Export a project folder as a zip Blob (.kgstudio bundle). * Export a project folder as a zip Blob (.kgstudio bundle).
* Includes project.json, meta.json, and all files in media/. * Includes project.json, meta.json, and all files in media/.
@@ -374,6 +395,42 @@ export class KGProjectStorage {
return candidate; return candidate;
} }
// --- Media migration ---
/**
* Copy all files from projects/<fromName>/media/ to projects/<toName>/media/.
* If the source media directory doesn't exist, returns without error.
*/
private async copyMediaFiles(fromName: string, toName: string): Promise<void> {
try {
const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName);
let fromMedia: FileSystemDirectoryHandle;
try {
fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
} catch {
// No media directory in source — nothing to copy
return;
}
const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName);
const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
for await (const entry of fromMedia.values()) {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
const newHandle = await toMedia.getFileHandle(entry.name, { create: true });
const writable = await newHandle.createWritable();
await writable.write(await file.arrayBuffer());
await writable.close();
}
}
} catch (error) {
console.error(`Error copying media files from "${fromName}" to "${toName}":`, error);
throw error;
}
}
// --- File I/O helpers --- // --- File I/O helpers ---
private async writeFile( private async writeFile(
+9 -3
View File
@@ -11,7 +11,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
* Handles keyboard shortcuts defined in the configuration * Handles keyboard shortcuts defined in the configuration
*/ */
export const useGlobalKeyboardHandler = () => { export const useGlobalKeyboardHandler = () => {
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName } = useProjectStore(); const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore();
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -39,7 +39,10 @@ export const useGlobalKeyboardHandler = () => {
// Early return to handle save immediately // Early return to handle save immediately
try { try {
saveProject(projectName, setStatus); saveProject(projectName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== projectName) setProjectName(finalName);
});
} catch (error) { } catch (error) {
console.error('Save failed:', error); console.error('Save failed:', error);
setStatus('Save failed'); setStatus('Save failed');
@@ -154,7 +157,10 @@ export const useGlobalKeyboardHandler = () => {
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault(); event.preventDefault();
try { try {
saveProject(projectName, setStatus); saveProject(projectName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== projectName) setProjectName(finalName);
});
} catch (error) { } catch (error) {
console.error('Save failed:', error); console.error('Save failed:', error);
setStatus('Save failed'); setStatus('Save failed');
+10 -2
View File
@@ -38,6 +38,7 @@ function updateMaxBarsCSS(maxBars: number): void {
interface ProjectState { interface ProjectState {
// State // State
projectName: string; projectName: string;
savedProjectName: string; // OPFS folder name where the project is currently saved
tracks: KGTrack[]; tracks: KGTrack[];
currentStatus: string; currentStatus: string;
maxBars: number; maxBars: number;
@@ -82,6 +83,7 @@ interface ProjectState {
// Actions // Actions
setProjectName: (name: string) => void; setProjectName: (name: string) => void;
setSavedProjectName: (name: string) => void;
addTrack: () => Promise<void>; addTrack: () => Promise<void>;
addAudioTrack: () => Promise<void>; addAudioTrack: () => Promise<void>;
importAudioToTrack: (trackId: string, file: File) => Promise<void>; importAudioToTrack: (trackId: string, file: File) => Promise<void>;
@@ -95,7 +97,7 @@ interface ProjectState {
setStatus: (status: string) => void; setStatus: (status: string) => void;
removeStatus: () => void; removeStatus: () => void;
refreshStatus: () => void; refreshStatus: () => void;
loadProject: (project: KGProject | null) => Promise<void>; loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
setPlayheadPosition: (position: number) => void; setPlayheadPosition: (position: number) => void;
startPlaying: () => Promise<void>; startPlaying: () => Promise<void>;
stopPlaying: () => Promise<void>; stopPlaying: () => Promise<void>;
@@ -234,6 +236,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
return { return {
// Initial state // Initial state
projectName: currentProject.getName(), projectName: currentProject.getName(),
savedProjectName: currentProject.getName(),
tracks: currentProject.getTracks() as KGTrack[], tracks: currentProject.getTracks() as KGTrack[],
currentStatus: KGCore.instance().getStatus() || 'Unknown', currentStatus: KGCore.instance().getStatus() || 'Unknown',
maxBars: currentProject.getMaxBars(), maxBars: currentProject.getMaxBars(),
@@ -292,6 +295,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
} }
}, },
setSavedProjectName: (name: string) => {
set({ savedProjectName: name });
},
addTrack: async () => { addTrack: async () => {
try { try {
// Create and execute the add track command // Create and execute the add track command
@@ -579,7 +586,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' }); set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' });
}, },
loadProject: async (project: KGProject | null = null) => { loadProject: async (project: KGProject | null = null, savedName?: string) => {
try { try {
const { setPlayheadPosition } = get(); const { setPlayheadPosition } = get();
@@ -675,6 +682,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Force a new array reference for tracks to trigger React/Zustand re-render // Force a new array reference for tracks to trigger React/Zustand re-render
set({ set({
projectName: projectToLoad.getName(), projectName: projectToLoad.getName(),
savedProjectName: savedName ?? projectToLoad.getName(),
tracks: [...tracks], tracks: [...tracks],
maxBars, maxBars,
timeSignature, timeSignature,
+52 -31
View File
@@ -2,58 +2,79 @@ import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStora
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
/** /**
* Save project utility function * Save project utility function.
* Handles saving the current project with proper error handling and user confirmation * Detects renames (savedProjectName !== projectName) and migrates the OPFS folder,
* @param projectName - The name of the project to save * including media files. Duplicate name conflicts during rename auto-resolve with
* @param setStatus - Function to update the status message * the {name} (1), {name} (2), ... pattern.
* @returns Promise<boolean> - Returns true if save was successful, false otherwise *
* @param projectName Current in-memory project name
* @param savedProjectName OPFS folder name the project was last saved under
* @param setStatus Function to update the status message
* @param onSaveSuccess Called with the final saved name on success
*/ */
export const saveProject = async ( export const saveProject = async (
projectName: string, projectName: string,
setStatus: (status: string) => void savedProjectName: string,
setStatus: (status: string) => void,
onSaveSuccess: (finalName: string) => void,
): Promise<boolean> => { ): Promise<boolean> => {
const storage = KGProjectStorage.getInstance(); const storage = KGProjectStorage.getInstance();
const isRename = savedProjectName !== projectName;
if (isRename) {
// Determine the target name, resolving conflicts automatically
let targetName = projectName;
if (await storage.exists(projectName)) {
targetName = await storage.resolveUniqueName(projectName);
}
try {
await storage.saveWithRename(
savedProjectName,
targetName,
KGCore.instance().getCurrentProject(),
);
const statusMsg =
targetName !== projectName
? `Project renamed to "${targetName}" and saved`
: `Project "${targetName}" has been saved`;
setStatus(statusMsg);
onSaveSuccess(targetName);
return true;
} catch (error) {
console.error('Error saving renamed project:', error);
window.alert(`An error occurred while saving: ${error}`);
return false;
}
}
// Same name — existing overwrite logic
try { try {
await storage.save( await storage.save(projectName, KGCore.instance().getCurrentProject(), false);
projectName,
KGCore.instance().getCurrentProject(),
false,
);
setStatus(`Project "${projectName}" has been saved`); setStatus(`Project "${projectName}" has been saved`);
console.log("project saved successfully"); onSaveSuccess(projectName);
return true; return true;
} catch (error) { } catch (error) {
console.error("Error saving project:", error);
if (error instanceof DuplicateEntryError) { if (error instanceof DuplicateEntryError) {
const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`); const confirmed = window.confirm(
`Project "${projectName}" already exists. Do you want to overwrite it?`,
);
if (confirmed) { if (confirmed) {
try { try {
await storage.save( await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
projectName,
KGCore.instance().getCurrentProject(),
true,
);
setStatus(`Project "${projectName}" has been saved`); setStatus(`Project "${projectName}" has been saved`);
console.log("project saved successfully after overwrite"); onSaveSuccess(projectName);
return true; return true;
} catch (overwriteError) { } catch (overwriteError) {
console.error("Error overwriting project:", overwriteError); console.error('Error overwriting project:', overwriteError);
window.alert(`An error occurred while overwriting the project: ${overwriteError}`); window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
return false; return false;
} }
} else {
// User cancelled the overwrite
return false;
} }
return false;
} else { } else {
console.error("Error saving project:", error); console.error('Error saving project:', error);
window.alert(`An unknown error ${error} occurred. Please try again.`); window.alert(`An unknown error ${error} occurred. Please try again.`);
return false; return false;
} }