fix: missing media files when renaming + saving the project
This commit is contained in:
@@ -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,7 +74,7 @@ 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();
|
||||
@@ -83,7 +84,7 @@ const Toolbar: React.FC = () => {
|
||||
|
||||
// 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`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
@@ -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,
|
||||
|
||||
+49
-28
@@ -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.save(
|
||||
projectName,
|
||||
await storage.saveWithRename(
|
||||
savedProjectName,
|
||||
targetName,
|
||||
KGCore.instance().getCurrentProject(),
|
||||
false,
|
||||
);
|
||||
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
console.log("project saved successfully");
|
||||
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 project:", 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);
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
onSaveSuccess(projectName);
|
||||
return true;
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user