fix: reserve Untitled Project name; rename project will trigger save immidiatly.

This commit is contained in:
Xiaohan-Tian
2026-04-16 20:24:43 -07:00
parent 3010cf4e2d
commit 24ffaa76f6
5 changed files with 79 additions and 11 deletions
+12
View File
@@ -18,6 +18,8 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
import { KGCore } from './core/KGCore'; import { KGCore } from './core/KGCore';
import { ConfigManager } from './core/config/ConfigManager'; import { ConfigManager } from './core/config/ConfigManager';
import { validateFunctionalChordsJSON } from './util/scaleUtil'; import { validateFunctionalChordsJSON } from './util/scaleUtil';
import { KGProjectStorage } from './core/io/KGProjectStorage';
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
function App() { function App() {
// Enable global keyboard handler for copy/paste and undo/redo // Enable global keyboard handler for copy/paste and undo/redo
@@ -42,6 +44,16 @@ function App() {
hasInitialized.current = true; hasInitialized.current = true;
const initializeApp = async () => { const initializeApp = async () => {
// Wipe the reserved "Untitled Project" OPFS folder on every startup so it stays ephemeral
try {
const storage = KGProjectStorage.getInstance();
if (await storage.exists(RESERVED_PROJECT_NAME)) {
await storage.delete(RESERVED_PROJECT_NAME);
}
} catch (error) {
console.warn('Could not clear Untitled Project folder on startup:', error);
}
// Load the current project from KGCore // Load the current project from KGCore
loadProject(null); loadProject(null);
+34 -6
View File
@@ -2,7 +2,7 @@ import React from 'react';
import './Toolbar.css'; import './Toolbar.css';
import { saveProject } from '../util/saveUtil'; import { saveProject } from '../util/saveUtil';
import { KGProjectStorage } from '../core/io/KGProjectStorage'; import { KGProjectStorage } from '../core/io/KGProjectStorage';
import { isValidProjectName } from '../util/projectNameUtil'; import { isValidProjectName, isReservedProjectName, RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { DEBUG_MODE } from '../constants/uiConstants'; import { DEBUG_MODE } from '../constants/uiConstants';
@@ -86,15 +86,43 @@ const Toolbar: React.FC = () => {
// Export options // Export options
const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"]; const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"];
const handleProjectNameClick = () => { const handleProjectNameClick = async () => {
const newName = prompt("Enter project name:", projectName); const newName = prompt("Enter project name:", projectName);
if (newName) { if (!newName) return;
if (!isValidProjectName(newName)) { if (!isValidProjectName(newName)) {
window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed."); window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
return;
}
if (isReservedProjectName(newName)) {
window.alert(`"${RESERVED_PROJECT_NAME}" is a reserved project name. Please choose a different name.`);
return;
}
// Conflict check: only relevant when targeting a different OPFS folder
if (newName !== savedProjectName) {
const storage = KGProjectStorage.getInstance();
const exists = await storage.exists(newName);
if (exists) {
const confirmed = window.confirm(
`Project "${newName}" already exists. Do you want to overwrite it?`
);
if (!confirmed) return;
// Confirmed: update in-memory name then save immediately, overwriting the existing project
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
}, true /* forceOverwrite */);
return; return;
} }
setProjectName(newName);
} }
// Name is available — update and save immediately
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
});
}; };
// Common project loading logic extracted for reuse // Common project loading logic extracted for reuse
+2 -2
View File
@@ -351,11 +351,11 @@ export class KGProjectStorage {
* Used when the user renames the project and saves. Handles the case where the * Used when the user renames the project and saves. Handles the case where the
* old folder doesn't exist yet (new project never saved). * old folder doesn't exist yet (new project never saved).
*/ */
public async saveWithRename(oldName: string, newName: string, data: KGProject): Promise<void> { public async saveWithRename(oldName: string, newName: string, data: KGProject, overwrite: boolean = false): Promise<void> {
this.ensureInitialized(); this.ensureInitialized();
// Save project JSON to the new folder // Save project JSON to the new folder
await this.save(newName, data, false); await this.save(newName, data, overwrite);
// Migrate media files only if the old folder exists // Migrate media files only if the old folder exists
if (await this.exists(oldName)) { if (await this.exists(oldName)) {
+14 -1
View File
@@ -9,6 +9,12 @@ const VALID_PROJECT_NAME_REGEX = /^[a-zA-Z0-9 \-_.()\u00C0-\u024F]+$/;
*/ */
const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g; const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g;
/**
* The reserved project name used for unsaved/new projects.
* Users cannot rename a project to this name, and its OPFS folder is wiped on every startup.
*/
export const RESERVED_PROJECT_NAME = "Untitled Project";
/** /**
* Validate whether a project name contains only allowed characters. * Validate whether a project name contains only allowed characters.
* Does NOT check for empty string — caller should check that separately. * Does NOT check for empty string — caller should check that separately.
@@ -19,6 +25,13 @@ export function isValidProjectName(name: string): boolean {
return VALID_PROJECT_NAME_REGEX.test(name); return VALID_PROJECT_NAME_REGEX.test(name);
} }
/**
* Returns true if the given name matches the reserved "Untitled Project" name.
*/
export function isReservedProjectName(name: string): boolean {
return name.trim() === RESERVED_PROJECT_NAME;
}
/** /**
* Sanitize a project name by replacing disallowed characters with underscores, * Sanitize a project name by replacing disallowed characters with underscores,
* collapsing consecutive underscores/spaces, and trimming. * collapsing consecutive underscores/spaces, and trimming.
@@ -37,7 +50,7 @@ export function sanitizeProjectName(name: string): string {
// If everything was stripped, provide a fallback // If everything was stripped, provide a fallback
if (sanitized.length === 0) { if (sanitized.length === 0) {
sanitized = 'Untitled Project'; sanitized = RESERVED_PROJECT_NAME;
} }
return sanitized; return sanitized;
+17 -2
View File
@@ -1,5 +1,6 @@
import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage'; import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
import { RESERVED_PROJECT_NAME } from './projectNameUtil';
/** /**
* Save project utility function. * Save project utility function.
@@ -17,14 +18,27 @@ export const saveProject = async (
savedProjectName: string, savedProjectName: string,
setStatus: (status: string) => void, setStatus: (status: string) => void,
onSaveSuccess: (finalName: string) => void, onSaveSuccess: (finalName: string) => void,
forceOverwrite: boolean = false,
): Promise<boolean> => { ): Promise<boolean> => {
const storage = KGProjectStorage.getInstance(); const storage = KGProjectStorage.getInstance();
// Auto-rename reserved "Untitled Project" to "Untitled Project (1)", "(2)", etc.
if (projectName === RESERVED_PROJECT_NAME) {
let counter = 1;
let autoName = `${RESERVED_PROJECT_NAME} (${counter})`;
while (await storage.exists(autoName)) {
counter++;
autoName = `${RESERVED_PROJECT_NAME} (${counter})`;
}
projectName = autoName;
}
const isRename = savedProjectName !== projectName; const isRename = savedProjectName !== projectName;
if (isRename) { if (isRename) {
// Determine the target name, resolving conflicts automatically // Determine the target name; skip conflict resolution when caller already confirmed overwrite
let targetName = projectName; let targetName = projectName;
if (await storage.exists(projectName)) { if (!forceOverwrite && await storage.exists(projectName)) {
targetName = await storage.resolveUniqueName(projectName); targetName = await storage.resolveUniqueName(projectName);
} }
@@ -33,6 +47,7 @@ export const saveProject = async (
savedProjectName, savedProjectName,
targetName, targetName,
KGCore.instance().getCurrentProject(), KGCore.instance().getCurrentProject(),
forceOverwrite,
); );
const statusMsg = const statusMsg =