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
+18 -12
View File
@@ -30,6 +30,7 @@ import PianoIcon from './common/icons/PianoIcon';
const Toolbar: React.FC = () => {
const {
projectName, setProjectName,
savedProjectName, setSavedProjectName,
bpm, timeSignature, keySignature, setStatus,
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
currentTime, setBpm, setTimeSignature, setKeySignature,
@@ -73,25 +74,25 @@ const Toolbar: React.FC = () => {
};
// Common project loading logic extracted for reuse
const loadProjectFromData = async (project: KGProject, sourceDescription: string) => {
const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string) => {
try {
// Clean up UI state first
cleanupProjectState();
// Automatically clear chat history when loading a project
clearChatHistoryAndUI();
// Load the project using the store's loadProject method
const { loadProject: storeLoadProject } = useProjectStore.getState();
await storeLoadProject(project);
await storeLoadProject(project, savedName);
// Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`);
if (DEBUG_MODE.TOOLBAR) {
console.log(`project loaded successfully from ${sourceDescription}`);
}
} catch (error) {
console.error(`Error loading project from ${sourceDescription}:`, error);
setStatus(`Failed to load project: ${error}`);
@@ -159,8 +160,8 @@ const Toolbar: React.FC = () => {
return;
}
// Use common loading logic
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`);
// Use common loading logic — pass the OPFS folder name so savedProjectName is set correctly
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad.trim());
} catch (error) {
console.error("Error loading project:", error);
@@ -174,7 +175,12 @@ const Toolbar: React.FC = () => {
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) => {
@@ -317,7 +323,7 @@ const Toolbar: React.FC = () => {
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) {
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');
}
await loadProjectFromData(loaded, `JSON file "${file.name}"`);
await loadProjectFromData(loaded, `JSON file "${file.name}"`, importedName);
if (DEBUG_MODE.TOOLBAR) {
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;
}
.track-region.audio-region.selected {
border-color: #ffffff;
}
.track-region.audio-region .region-header {
background-color: #4a8b5a;
}
+15 -2
View File
@@ -7,13 +7,14 @@ import { OPFS_CONSTANTS } from '../../constants/coreConstants';
export class KGAudioFileStorage {
/**
* 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(
projectName: string,
fileId: string,
file: File
): Promise<void> {
const mediaDir = await KGAudioFileStorage.getMediaDir(projectName);
const mediaDir = await KGAudioFileStorage.getOrCreateMediaDir(projectName);
const fileHandle = await mediaDir.getFileHandle(fileId, { create: true });
const writable = await fileHandle.createWritable();
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> {
const root = await navigator.storage.getDirectory();
@@ -69,4 +71,15 @@ export class KGAudioFileStorage {
const projectDir = await projectsDir.getDirectoryHandle(projectName);
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);
await this.save(newName, project, false);
// Copy media files from old to new location
await this.copyMediaFiles(oldName, newName);
// Delete old location
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).
* Includes project.json, meta.json, and all files in media/.
@@ -374,6 +395,42 @@ export class KGProjectStorage {
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 ---
private async writeFile(
+9 -3
View File
@@ -11,7 +11,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
* Handles keyboard shortcuts defined in the configuration
*/
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(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -39,7 +39,10 @@ export const useGlobalKeyboardHandler = () => {
// Early return to handle save immediately
try {
saveProject(projectName, setStatus);
saveProject(projectName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== projectName) setProjectName(finalName);
});
} catch (error) {
console.error('Save failed:', error);
setStatus('Save failed');
@@ -154,7 +157,10 @@ export const useGlobalKeyboardHandler = () => {
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault();
try {
saveProject(projectName, setStatus);
saveProject(projectName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== projectName) setProjectName(finalName);
});
} catch (error) {
console.error('Save failed:', error);
setStatus('Save failed');
+12 -4
View File
@@ -38,6 +38,7 @@ function updateMaxBarsCSS(maxBars: number): void {
interface ProjectState {
// State
projectName: string;
savedProjectName: string; // OPFS folder name where the project is currently saved
tracks: KGTrack[];
currentStatus: string;
maxBars: number;
@@ -82,6 +83,7 @@ interface ProjectState {
// Actions
setProjectName: (name: string) => void;
setSavedProjectName: (name: string) => void;
addTrack: () => Promise<void>;
addAudioTrack: () => Promise<void>;
importAudioToTrack: (trackId: string, file: File) => Promise<void>;
@@ -95,7 +97,7 @@ interface ProjectState {
setStatus: (status: string) => void;
removeStatus: () => void;
refreshStatus: () => void;
loadProject: (project: KGProject | null) => Promise<void>;
loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
setPlayheadPosition: (position: number) => void;
startPlaying: () => Promise<void>;
stopPlaying: () => Promise<void>;
@@ -234,6 +236,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
return {
// Initial state
projectName: currentProject.getName(),
savedProjectName: currentProject.getName(),
tracks: currentProject.getTracks() as KGTrack[],
currentStatus: KGCore.instance().getStatus() || 'Unknown',
maxBars: currentProject.getMaxBars(),
@@ -281,10 +284,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ name });
KGCore.instance().executeCommand(command);
// Update the store state
set({ projectName: name });
console.log(`Set project name to "${name}"`);
} catch (error) {
console.error('Error setting project name:', error);
@@ -292,6 +295,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}
},
setSavedProjectName: (name: string) => {
set({ savedProjectName: name });
},
addTrack: async () => {
try {
// Create and execute the add track command
@@ -579,7 +586,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' });
},
loadProject: async (project: KGProject | null = null) => {
loadProject: async (project: KGProject | null = null, savedName?: string) => {
try {
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
set({
projectName: projectToLoad.getName(),
savedProjectName: savedName ?? projectToLoad.getName(),
tracks: [...tracks],
maxBars,
timeSignature,
+52 -31
View File
@@ -2,58 +2,79 @@ import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStora
import { KGCore } from '../core/KGCore';
/**
* Save project utility function
* Handles saving the current project with proper error handling and user confirmation
* @param projectName - The name of the project to save
* @param setStatus - Function to update the status message
* @returns Promise<boolean> - Returns true if save was successful, false otherwise
* Save project utility function.
* Detects renames (savedProjectName !== projectName) and migrates the OPFS folder,
* including media files. Duplicate name conflicts during rename auto-resolve with
* the {name} (1), {name} (2), ... pattern.
*
* @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 (
projectName: string,
setStatus: (status: string) => void
savedProjectName: string,
setStatus: (status: string) => void,
onSaveSuccess: (finalName: string) => void,
): Promise<boolean> => {
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 {
await storage.save(
projectName,
KGCore.instance().getCurrentProject(),
false,
);
await storage.save(projectName, KGCore.instance().getCurrentProject(), false);
setStatus(`Project "${projectName}" has been saved`);
console.log("project saved successfully");
onSaveSuccess(projectName);
return true;
} catch (error) {
console.error("Error saving project:", error);
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) {
try {
await storage.save(
projectName,
KGCore.instance().getCurrentProject(),
true,
);
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
setStatus(`Project "${projectName}" has been saved`);
console.log("project saved successfully after overwrite");
onSaveSuccess(projectName);
return true;
} catch (overwriteError) {
console.error("Error overwriting project:", overwriteError);
console.error('Error overwriting project:', overwriteError);
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
return false;
}
} else {
// User cancelled the overwrite
return false;
}
return false;
} else {
console.error("Error saving project:", error);
console.error('Error saving project:', error);
window.alert(`An unknown error ${error} occurred. Please try again.`);
return false;
}