diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index c1c5649..476c372 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -24,6 +24,7 @@ import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; import KGDropdown from './common/KGDropdown'; import FileImportModal from './common/FileImportModal'; +import OpenProjectModal from './common/OpenProjectModal'; import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; @@ -55,6 +56,9 @@ const Toolbar: React.FC = () => { // State for import modal const [showImportModal, setShowImportModal] = React.useState(false); + + // State for open project modal + const [showOpenProject, setShowOpenProject] = React.useState(false); // Key signature options const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; @@ -86,6 +90,12 @@ const Toolbar: React.FC = () => { const { loadProject: storeLoadProject } = useProjectStore.getState(); await storeLoadProject(project, savedName); + // Clean up orphan media files (only on open, not on save) + if (savedName) { + const storage = KGProjectStorage.getInstance(); + await storage.cleanupOrphanMedia(savedName, project); + } + // Update status to indicate project loaded setStatus(`${sourceDescription} loaded successfully`); @@ -100,73 +110,55 @@ const Toolbar: React.FC = () => { } }; + // Core logic for creating a new project (no confirmation dialog) + const createNewProject = () => { + if (DEBUG_MODE.TOOLBAR) { + console.log("creating new project"); + } + + cleanupProjectState(); + clearChatHistoryAndUI(); + + const newProject = new KGProject(); + const { loadProject: storeLoadProject } = useProjectStore.getState(); + storeLoadProject(newProject); + + setStatus(`New project "${newProject.getName()}" created`); + + if (DEBUG_MODE.TOOLBAR) { + console.log("new project created successfully"); + } + }; + // Handler functions for file operations const handleNewProject = () => { const confirmed = window.confirm("Are you sure you want to create a new project? Any unsaved changes will be lost."); if (confirmed) { - if (DEBUG_MODE.TOOLBAR) { - console.log("user clicked new button"); - } - - // Clean up UI state first - cleanupProjectState(); - - // Automatically clear chat history when creating a new project - clearChatHistoryAndUI(); - - // Create a new project with default parameters - const newProject = new KGProject(); - - // Load the new project using the store's loadProject method - const { loadProject: storeLoadProject } = useProjectStore.getState(); - storeLoadProject(newProject); - - // Default track creation is handled centrally in the store's loadProject - - // Update status to indicate new project created - setStatus(`New project "${newProject.getName()}" created`); - - if (DEBUG_MODE.TOOLBAR) { - console.log("new project created successfully"); - } + createNewProject(); } }; - const handleLoadProject = async () => { - const confirmed = window.confirm("Are you sure you want to load another project? Any unsaved changes will be lost."); - if (confirmed) { - if (DEBUG_MODE.TOOLBAR) { - console.log("user clicked load button"); - } - - // Ask user for project name - const projectNameToLoad = window.prompt("Enter the project name to load:"); - - // Check if user input is empty or null (user cancelled) - if (!projectNameToLoad || projectNameToLoad.trim() === '') { - if (projectNameToLoad !== null) { // Only show error if user didn't cancel - window.alert("Project name cannot be empty. Please enter a valid project name."); - } + const handleLoadProject = () => { + if (DEBUG_MODE.TOOLBAR) { + console.log("user clicked load button"); + } + setShowOpenProject(true); + }; + + const handleOpenProjectSelect = async (projectNameToLoad: string) => { + try { + const storage = KGProjectStorage.getInstance(); + const loadedProject = await storage.load(projectNameToLoad); + + if (!loadedProject) { + window.alert(`Project "${projectNameToLoad}" not found.`); return; } - - try { - // Try to load the project from storage - const storage = KGProjectStorage.getInstance(); - const loadedProject = await storage.load(projectNameToLoad.trim()); - - if (!loadedProject) { - window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`); - return; - } - - // 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); - window.alert(`An error occurred while loading the project: ${error}`); - } + + await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad); + } catch (error) { + console.error("Error loading project:", error); + window.alert(`An error occurred while loading the project: ${error}`); } }; @@ -842,6 +834,15 @@ const Toolbar: React.FC = () => { title="Import Project" description="Drag and drop your project file here" /> + + {showOpenProject && ( + setShowOpenProject(false)} + onOpenProject={handleOpenProjectSelect} + currentProjectName={savedProjectName} + onCreateNewProject={createNewProject} + /> + )} ); }; diff --git a/src/components/common/OpenProjectModal.css b/src/components/common/OpenProjectModal.css new file mode 100644 index 0000000..8a3c6c8 --- /dev/null +++ b/src/components/common/OpenProjectModal.css @@ -0,0 +1,285 @@ +/* Overlay — blocks and blurs content below */ +.open-project-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(3px); + z-index: 2000; +} + +/* Open Project Panel — follows PianoRoll panel pattern */ +.open-project-panel { + position: fixed; + background-color: #2d2d2d; + border: 1px solid #3a3a3a; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Header — draggable */ +.open-project-header { + height: 30px; + background-color: #1e1e1e; + border-bottom: 1px solid #3a3a3a; + display: flex; + align-items: center; + padding: 0 10px; + cursor: move; + user-select: none; + flex-shrink: 0; +} + +.open-project-title { + flex: 1; + text-align: center; + font-size: 14px; + color: #e0e0e0; + text-transform: uppercase; +} + +/* Filter bar — sticky at top */ +.open-project-filter { + padding: 10px 12px; + background-color: #252525; + border-bottom: 1px solid #3a3a3a; + flex-shrink: 0; +} + +.open-project-filter-row { + display: flex; + align-items: center; + gap: 10px; +} + +.open-project-filter input { + flex: 1; + padding: 6px 10px; + background-color: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 4px; + color: #e0e0e0; + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +.open-project-filter input:focus { + border-color: #7b68ee; +} + +.open-project-filter input::placeholder { + color: #777; +} + +/* View toggle (Projects / Trash) */ +.open-project-view-toggle { + display: flex; + border: 1px solid #3a3a3a; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.open-project-toggle-btn { + background-color: #1e1e1e; + color: #999; + border: none; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; + transition: background-color 0.1s, color 0.1s; +} + +.open-project-toggle-btn:not(:last-child) { + border-right: 1px solid #3a3a3a; +} + +.open-project-toggle-btn:hover { + color: #e0e0e0; +} + +.open-project-toggle-btn.active { + background-color: #7b68ee; + color: #fff; +} + +/* Trash hint banner */ +.open-project-trash-hint { + padding: 6px 12px; + background-color: #3a2a1a; + border-bottom: 1px solid #5a3a1a; + color: #d4a054; + font-size: 11px; + flex-shrink: 0; +} + +/* Sort header row */ +.open-project-sort-header { + display: flex; + align-items: center; + padding: 6px 12px; + background-color: #252525; + border-bottom: 1px solid #3a3a3a; + font-size: 11px; + color: #999; + text-transform: uppercase; + user-select: none; + flex-shrink: 0; +} + +.open-project-sort-col { + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: 3px; +} + +.open-project-sort-col:hover { + color: #e0e0e0; + background-color: #3a3a3a; +} + +.open-project-sort-col.active { + color: #7b68ee; +} + +.open-project-sort-col-name { + flex: 1; +} + +.open-project-sort-col-created, +.open-project-sort-col-updated { + width: 160px; + text-align: left; +} + +/* Scrollable project list */ +.open-project-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Individual project row */ +.open-project-item { + display: flex; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid #333; + cursor: pointer; + transition: background-color 0.1s; +} + +.open-project-item:hover { + background-color: #353535; +} + +.open-project-item-info { + flex: 1; + min-width: 0; +} + +.open-project-item-name { + font-size: 14px; + color: #e0e0e0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.open-project-item-meta { + font-size: 11px; + color: #888; + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Action buttons container */ +.open-project-item-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + margin-left: 10px; +} + +/* Open button */ +.open-project-item-open-btn { + background-color: #7b68ee; + color: #fff; + border: none; + border-radius: 4px; + padding: 5px 14px; + font-size: 12px; + cursor: pointer; +} + +.open-project-item-open-btn:hover { + background-color: #6a58d6; +} + +/* Icon action buttons (duplicate, delete, restore, perm-delete) */ +.open-project-item-action-btn { + background: transparent; + border: 1px solid #555; + border-radius: 4px; + color: #bbb; + padding: 5px 8px; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.1s, color 0.1s, border-color 0.1s; +} + +.open-project-item-action-btn:hover { + background-color: #444; + color: #e0e0e0; + border-color: #777; +} + +.open-project-item-permdelete-btn:hover { + background-color: #5a2020; + color: #ff6666; + border-color: #ff4444; +} + +.open-project-item-restore-btn:hover { + background-color: #1a3a2a; + color: #66cc88; + border-color: #44aa66; +} + +/* Loading and empty states */ +.open-project-loading, +.open-project-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: #888; + font-size: 14px; + padding: 40px; +} + +/* Resize handle — same as PianoRoll */ +.open-project-panel .resize-handle { + position: absolute; + right: 5px; + bottom: 5px; + width: 20px; + height: 20px; + cursor: nwse-resize; + color: #999; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} diff --git a/src/components/common/OpenProjectModal.tsx b/src/components/common/OpenProjectModal.tsx new file mode 100644 index 0000000..5f93577 --- /dev/null +++ b/src/components/common/OpenProjectModal.tsx @@ -0,0 +1,442 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { FaTimes, FaGripLines, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-icons/fa'; +import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage'; +import { isValidProjectName } from '../../util/projectNameUtil'; +import './OpenProjectModal.css'; + +interface OpenProjectModalProps { + onClose: () => void; + onOpenProject: (projectName: string) => Promise; + currentProjectName: string | null; + onCreateNewProject: () => void; +} + +type SortField = 'name' | 'createdAt' | 'updatedAt'; +type SortDirection = 'asc' | 'desc'; +type ViewMode = 'projects' | 'trash'; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * Incremental fuzzy filter: characters in the filter must appear in order + * within the project name (case-insensitive, ignoring non-alphanumeric chars). + */ +const matchesFilter = (projectName: string, filter: string): boolean => { + if (!filter) return true; + const normalizedName = projectName.toLowerCase().replace(/[^a-z0-9]/g, ''); + const normalizedFilter = filter.toLowerCase().replace(/[^a-z0-9]/g, ''); + + let nameIndex = 0; + for (let i = 0; i < normalizedFilter.length; i++) { + const charIndex = normalizedName.indexOf(normalizedFilter[i], nameIndex); + if (charIndex === -1) return false; + nameIndex = charIndex + 1; + } + return true; +}; + +const formatDate = (timestamp: number): string => { + if (timestamp === 0) return 'Unknown'; + const date = new Date(timestamp); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const OpenProjectModal: React.FC = ({ onClose, onOpenProject, currentProjectName, onCreateNewProject }) => { + const panelRef = useRef(null); + + // Position & size + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [size, setSize] = useState({ width: 600, height: 400 }); + + // Drag & resize + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + // Data + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [filter, setFilter] = useState(''); + + // Sort + const [sortField, setSortField] = useState('updatedAt'); + const [sortDirection, setSortDirection] = useState('desc'); + + // View mode toggle + const [viewMode, setViewMode] = useState('projects'); + + // Calculate initial position/size to fill main-content area + useEffect(() => { + const toolbarEl = document.querySelector('.toolbar'); + const statusBarEl = document.querySelector('.status-bar'); + + const width = Math.max(400, window.innerWidth - 400); + const height = Math.max(300, window.innerHeight - 200); + const x = (window.innerWidth - width) / 2; + const y = (window.innerHeight - height) / 2; + + setPosition({ x, y }); + setSize({ width, height }); + }, []); + + // Fetch projects when view mode changes + const fetchProjects = useCallback(async (mode: ViewMode) => { + setIsLoading(true); + try { + const storage = KGProjectStorage.getInstance(); + + // Auto-purge old trash when switching to trash view + if (mode === 'trash') { + await storage.purgeDeletedOlderThan(THIRTY_DAYS_MS); + } + + const list = await storage.listWithMeta(mode === 'trash'); + setProjects(list); + } catch (error) { + console.error('Error loading project list:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchProjects(viewMode); + }, [viewMode, fetchProjects]); + + // Escape key to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Drag & resize mouse handling + const handleMouseDown = (e: React.MouseEvent, action: 'drag' | 'resize') => { + if (action === 'drag') { + setIsDragging(true); + if (panelRef.current) { + const rect = panelRef.current.getBoundingClientRect(); + setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + } + } else { + setIsResizing(true); + e.preventDefault(); + } + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + setPosition({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y }); + } else if (isResizing) { + setSize({ + width: Math.max(400, e.clientX - position.x), + height: Math.max(300, e.clientY - position.y), + }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, isResizing, dragOffset, position]); + + // Sort toggle + const handleSortClick = useCallback((field: SortField) => { + if (field === sortField) { + setSortDirection(d => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection(field === 'name' ? 'asc' : 'desc'); + } + }, [sortField]); + + // Filter + sort projects + const filteredProjects = React.useMemo(() => { + const filtered = projects.filter(p => matchesFilter(p.name, filter)); + + filtered.sort((a, b) => { + let cmp: number; + if (sortField === 'name') { + cmp = a.name.localeCompare(b.name); + } else { + cmp = a[sortField] - b[sortField]; + } + return sortDirection === 'asc' ? cmp : -cmp; + }); + + return filtered; + }, [projects, filter, sortField, sortDirection]); + + // --- Action handlers --- + + const handleOpen = async (projectName: string) => { + await onOpenProject(projectName); + onClose(); + }; + + const handleDuplicate = async (e: React.MouseEvent, projectName: string) => { + e.stopPropagation(); + const newName = window.prompt('Enter a name for the duplicated project:', projectName); + if (!newName || newName.trim() === '') return; + + const trimmed = newName.trim(); + if (!isValidProjectName(trimmed)) { + window.alert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.'); + return; + } + + try { + const storage = KGProjectStorage.getInstance(); + const finalName = await storage.resolveUniqueName(trimmed); + await storage.duplicate(projectName, finalName); + // Open the duplicated project + await onOpenProject(finalName); + onClose(); + } catch (error) { + console.error('Error duplicating project:', error); + window.alert(`Failed to duplicate project: ${error}`); + } + }; + + const handleSoftDelete = async (e: React.MouseEvent, projectName: string) => { + e.stopPropagation(); + try { + const storage = KGProjectStorage.getInstance(); + await storage.softDelete(projectName); + // Refresh list + await fetchProjects(viewMode); + } catch (error) { + console.error('Error deleting project:', error); + window.alert(`Failed to delete project: ${error}`); + } + }; + + const handleRestore = async (e: React.MouseEvent, projectName: string) => { + e.stopPropagation(); + try { + const storage = KGProjectStorage.getInstance(); + await storage.restore(projectName); + await fetchProjects(viewMode); + } catch (error) { + console.error('Error restoring project:', error); + window.alert(`Failed to restore project: ${error}`); + } + }; + + const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => { + e.stopPropagation(); + const confirmed = window.confirm( + `Are you sure you want to permanently delete "${projectName}"?\n\nThis operation cannot be undone.` + ); + if (!confirmed) return; + + try { + const storage = KGProjectStorage.getInstance(); + await storage.delete(projectName); + await fetchProjects(viewMode); + + // If the deleted project is the currently open one, create a new project + if (currentProjectName && projectName === currentProjectName) { + onCreateNewProject(); + } + } catch (error) { + console.error('Error permanently deleting project:', error); + window.alert(`Failed to permanently delete project: ${error}`); + } + }; + + const handleViewModeChange = (mode: ViewMode) => { + setViewMode(mode); + setFilter(''); + }; + + const SortIcon: React.FC<{ field: SortField }> = ({ field }) => { + if (sortField !== field) return null; + return sortDirection === 'asc' ? : ; + }; + + const content = ( +
+
e.stopPropagation()} + style={{ + left: `${position.x}px`, + top: `${position.y}px`, + width: `${size.width}px`, + height: `${size.height}px`, + zIndex: 2001, + }} + > + {/* Header */} +
handleMouseDown(e, 'drag')}> + +
Open Project
+
+ + {/* Filter + view toggle */} +
+
+ setFilter(e.target.value)} + autoFocus + /> +
+ + +
+
+
+ + {/* Trash hint */} + {viewMode === 'trash' && ( +
+ Deleted projects are automatically removed after 30 days. +
+ )} + + {/* Sort header */} +
+ handleSortClick('name')} + > + Name + + handleSortClick('createdAt')} + > + Created + + handleSortClick('updatedAt')} + > + Updated + +
+ + {/* Project list */} + {isLoading ? ( +
Loading projects...
+ ) : filteredProjects.length === 0 ? ( +
+ {projects.length === 0 + ? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects') + : 'No projects match the filter'} +
+ ) : ( +
+ {filteredProjects.map((project) => ( +
handleOpen(project.name) : undefined} + > +
+
{project.name}
+
+ Created {formatDate(project.createdAt)} · Updated {formatDate(project.updatedAt)} + {viewMode === 'trash' && project.deletedAt && ( + <> · Deleted {formatDate(project.deletedAt)} + )} +
+
+
+ {viewMode === 'projects' ? ( + <> + + + + + ) : ( + <> + + + + )} +
+
+ ))} +
+ )} + + {/* Resize handle */} +
handleMouseDown(e, 'resize')}> + +
+
+
+ ); + + return createPortal(content, document.body); +}; + +export default OpenProjectModal; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 3d8c0ea..b0dc2f8 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,4 +1,5 @@ export { default as KGDropdown } from './KGDropdown'; export { default as Playhead } from './Playhead'; export { default as FileImportModal } from './FileImportModal'; -export { default as LoadingOverlay } from './LoadingOverlay'; \ No newline at end of file +export { default as LoadingOverlay } from './LoadingOverlay'; +export { default as OpenProjectModal } from './OpenProjectModal'; \ No newline at end of file diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts index 3fc296d..0ef41f1 100644 --- a/src/core/io/KGProjectStorage.ts +++ b/src/core/io/KGProjectStorage.ts @@ -4,6 +4,7 @@ import { KGProject } from '../KGProject'; import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader'; import { isValidProjectName } from '../../util/projectNameUtil'; import { OPFS_CONSTANTS } from '../../constants/coreConstants'; +import { KGAudioRegion } from '../region/KGAudioRegion'; export class DuplicateEntryError extends Error { constructor(name: string) { @@ -12,10 +13,11 @@ export class DuplicateEntryError extends Error { } } -interface ProjectMeta { +export interface ProjectMeta { name: string; createdAt: number; updatedAt: number; + deletedAt?: number | null; } /** @@ -154,6 +156,137 @@ export class KGProjectStorage { return names.sort(); } + /** + * List all projects with metadata (name, createdAt, updatedAt). + * Reads each project's meta.json without loading the full project data. + * @param deleted - if true, return only soft-deleted projects; if false (default), return only non-deleted projects. + */ + public async listWithMeta(deleted: boolean = false): Promise { + this.ensureInitialized(); + + const results: ProjectMeta[] = []; + for await (const entry of this.projectsDirHandle!.values()) { + if (entry.kind === 'directory') { + try { + const dirHandle = await this.projectsDirHandle!.getDirectoryHandle(entry.name); + const metaJson = await this.readFile(dirHandle, OPFS_CONSTANTS.METADATA_FILE); + const meta = JSON.parse(metaJson) as ProjectMeta; + const isDeleted = !!meta.deletedAt; + if (isDeleted === deleted) { + results.push(meta); + } + } catch { + // Skip folders without a valid meta.json (e.g. temporary unsaved projects) + } + } + } + return results.sort((a, b) => b.updatedAt - a.updatedAt); + } + + /** + * Soft-delete a project by setting deletedAt in its meta.json. + */ + public async softDelete(name: string): Promise { + this.ensureInitialized(); + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name); + const meta = await this.readMeta(projectDir, name); + meta.deletedAt = Date.now(); + await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2)); + } + + /** + * Restore a soft-deleted project by removing deletedAt from its meta.json. + */ + public async restore(name: string): Promise { + this.ensureInitialized(); + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name); + const meta = await this.readMeta(projectDir, name); + delete meta.deletedAt; + await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2)); + } + + /** + * Duplicate a project under a new name, copying all files including media. + */ + public async duplicate(sourceName: string, targetName: string): Promise { + this.ensureInitialized(); + + if (!isValidProjectName(targetName)) { + throw new Error(`Invalid project name "${targetName}".`); + } + + // Load the source project + const project = await this.load(sourceName); + if (!project) { + throw new Error(`Project "${sourceName}" not found.`); + } + + // Save to new location with fresh timestamps + project.setName(targetName); + await this.save(targetName, project, false); + + // Copy media files + await this.copyMediaFiles(sourceName, targetName); + } + + /** + * Permanently delete all soft-deleted projects older than the given age in milliseconds. + */ + public async purgeDeletedOlderThan(ageMs: number): Promise { + this.ensureInitialized(); + const cutoff = Date.now() - ageMs; + const deleted = await this.listWithMeta(true); + for (const meta of deleted) { + if (meta.deletedAt && meta.deletedAt < cutoff) { + await this.delete(meta.name); + } + } + } + + /** + * Remove orphan media files that are not referenced by any track in the project. + */ + public async cleanupOrphanMedia(name: string, project: KGProject): Promise { + this.ensureInitialized(); + + try { + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name); + let mediaDir: FileSystemDirectoryHandle; + try { + mediaDir = await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR); + } catch { + return; // No media directory — nothing to clean up + } + + // Collect all referenced audio file IDs from the project + const referencedIds = new Set(); + for (const track of project.getTracks()) { + for (const region of track.getRegions()) { + if (region instanceof KGAudioRegion) { + const fileId = region.getAudioFileId(); + if (fileId) referencedIds.add(fileId); + } + } + } + + // Iterate media files and remove orphans + const orphans: string[] = []; + for await (const entry of mediaDir.values()) { + if (entry.kind === 'file' && !referencedIds.has(entry.name)) { + orphans.push(entry.name); + } + } + + for (const orphan of orphans) { + await mediaDir.removeEntry(orphan); + console.log(`Removed orphan media file "${orphan}" from project "${name}"`); + } + } catch (error) { + // Non-critical — log but don't throw + console.warn(`Error cleaning up orphan media for project "${name}":`, error); + } + } + /** * Delete a project and all its files. */ @@ -452,4 +585,16 @@ export class KGProjectStorage { const file = await fileHandle.getFile(); return file.text(); } + + private async readMeta( + projectDir: FileSystemDirectoryHandle, + name: string, + ): Promise { + try { + const raw = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE); + return JSON.parse(raw) as ProjectMeta; + } catch { + return { name, createdAt: 0, updatedAt: 0 }; + } + } }