Merge pull request #29 from KGAudioLab/feat/2026-04-08-refactor-data-storage

Feat/2026 04 08 refactor data storage
This commit is contained in:
Xiaohan-Tian
2026-04-09 19:25:35 -07:00
committed by GitHub
49 changed files with 3903 additions and 2366 deletions
+1 -1
View File
@@ -242,7 +242,7 @@ Feature priorities might change.
- [ ] Support WAV audio tracks - [ ] Support WAV audio tracks
- [ ] Filters and effects - [ ] Filters and effects
- [ ] MCP Support - [ ] MCP Support
- [ ] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`) - [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`)
- [ ] Automatically compact conversations when the context window runs low on space - [ ] Automatically compact conversations when the context window runs low on space
## Help Needed ## Help Needed
+2 -1
View File
@@ -21,7 +21,8 @@ export default tseslint.config([
}, },
rules: { rules: {
'@typescript-eslint/no-unused-vars': 'warn', // Downgrade from error to warning '@typescript-eslint/no-unused-vars': 'warn', // Downgrade from error to warning
'no-unused-vars': 'warn' // Also set the base rule to warn 'no-unused-vars': 'warn', // Also set the base rule to warn
'semi': ['error', 'always'],
} }
}, },
]) ])
+42 -10
View File
@@ -1,15 +1,16 @@
{ {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.8.0-build.20260123", "version": "0.9.0-build.20260406",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.8.0-build.20260123", "version": "0.9.0-build.20260406",
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"jszip": "^3.10.1",
"openai": "^6.33.0", "openai": "^6.33.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -3805,7 +3806,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
@@ -5387,6 +5387,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5441,7 +5447,6 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": { "node_modules/ini": {
@@ -5656,7 +5661,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/isexe": { "node_modules/isexe": {
@@ -5931,6 +5935,18 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5965,6 +5981,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -11030,6 +11055,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11377,7 +11408,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/property-information": { "node_modules/property-information": {
@@ -11792,7 +11822,6 @@
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
@@ -12156,7 +12185,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
@@ -12529,6 +12557,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -12836,7 +12870,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
@@ -13674,7 +13707,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/validate-npm-package-license": { "node_modules/validate-npm-package-license": {
+1
View File
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"jszip": "^3.10.1",
"openai": "^6.33.0", "openai": "^6.33.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
-2026
View File
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -1,10 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import './App.css'; import './App.css';
import './styles/shared.css';
import { useProjectStore } from './stores/projectStore'; import { useProjectStore } from './stores/projectStore';
import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler'; import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler';
import Toolbar from './components/Toolbar'; import Toolbar from './components/Toolbar';
import StatusBar from './components/StatusBar'; import StatusBar from './components/StatusBar';
import TrackControl from './components/TrackControl';
import MainContent from './components/MainContent'; import MainContent from './components/MainContent';
import InstrumentSelection from './components/InstrumentSelection'; import InstrumentSelection from './components/InstrumentSelection';
import ChatBox from './components/ChatBox'; import ChatBox from './components/ChatBox';
@@ -135,14 +135,15 @@ function App() {
<ChatBox isVisible={showChatBox && !showSettings} /> <ChatBox isVisible={showChatBox && !showSettings} />
</div> </div>
{/* Track Control */}
<TrackControl />
{/* Status Bar */} {/* Status Bar */}
<StatusBar /> <StatusBar />
{/* Global Loading Overlay for instrument buffer loading */} {/* Global Loading Overlay for instrument buffer loading */}
<GlobalLoadingOverlayContainer /> <GlobalLoadingOverlayContainer />
{/* Migration Loading Overlay */}
<MigrationOverlayContainer />
</div> </div>
); );
} }
@@ -212,3 +213,22 @@ const GlobalLoadingOverlayContainer: React.FC = () => {
/> />
); );
}; };
// Migration overlay — shown during one-time IndexedDB -> OPFS migration
const MigrationOverlayContainer: React.FC = () => {
const [isMigrating, setIsMigrating] = useState<boolean>(() => KGCore.instance().getIsMigrating());
useEffectReact(() => {
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
return () => {
KGCore.instance().setMigrationStateChangeCallback(() => {});
};
}, []);
return (
<LoadingOverlay
visible={isMigrating}
message="Migrating projects to new storage..."
/>
);
};
+313
View File
@@ -0,0 +1,313 @@
/* ChatBox */
.chatbox {
display: flex;
flex-direction: column;
width: var(--chat-box-width);
background-color: #2d2d2d;
border-left: 1px solid #3a3a3a;
flex-shrink: 0;
}
.chatbox.is-hidden {
display: none;
}
.chatbox-header {
display: flex;
align-items: center;
justify-content: space-between; /* left title, right actions */
background-color: #3a3a3a;
height: 40px;
border-bottom: 1px solid #4a4a4a;
padding: 0 15px;
}
.chatbox-actions {
display: flex;
gap: 8px;
align-items: center;
}
.chatbox-action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #e0e0e0;
font-size: 14px;
}
.chatbox-action-btn:hover {
background-color: #4a4a4a;
border-radius: 3px;
}
/* ChatBox export button wrapper and dropdown positioning */
.chatbox-export-wrapper {
position: relative;
display: inline-block;
}
.chatbox-export-btn {
display: flex;
align-items: center;
gap: 4px;
}
.chatbox-export-dropdown-anchor {
position: absolute;
top: 100%;
left: 0;
z-index: 10000;
}
.chatbox-export-dropdown .quant-dropdown {
width: 250px;
left: -200px;
}
.chatbox-header h3 {
color: #e0e0e0;
font-size: 12px;
font-weight: bold;
margin: 0;
}
.chatbox-content {
flex: 1;
padding: 15px;
color: #e0e0e0;
font-size: 12px;
overflow-y: auto;
}
/* Chat Messages */
.chatbox-messages {
flex: 1;
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.message-container {
width: 100%;
word-wrap: break-word;
}
.message-user {
width: 100%;
}
.message-assistant {
width: 100%;
}
.message-content {
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.4;
}
/* User message styling - lighter background like input */
.message-user .message-content {
background-color: #3a3a3a;
color: #e0e0e0;
border: 1px solid #4a4a4a;
}
/* Assistant message styling - transparent */
.message-assistant .message-content {
background-color: transparent;
color: #e0e0e0;
border: 1px solid #2a2a2a;
}
/* Input Area */
.chatbox-input-area {
padding: 10px;
}
.chatbox-input {
width: 100%;
padding: 8px 12px;
background-color: #4a4a4a;
border: 1px solid #5a5a5a;
border-radius: 6px;
color: #e0e0e0;
font-size: 12px;
outline: none;
resize: none;
min-height: 36px;
max-height: 120px;
}
.chatbox-input:focus {
border-color: #6a6a6a;
background-color: #4a4a4a;
}
.chatbox-input::placeholder {
color: #999;
}
/* Markdown styling in messages */
.message-content h1,
.message-content h2,
.message-content h3 {
margin: 8px 0 4px 0;
color: #e0e0e0;
}
.message-content h3 {
font-size: 14px;
}
.message-content p {
margin: 4px 0;
}
.message-content ul,
.message-content ol {
margin: 4px 0;
padding-left: 16px;
}
.message-content li {
margin: 2px 0;
}
.message-content code {
background-color: #2a2a2a;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
}
.message-content pre {
background-color: #1a1a1a;
padding: 8px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
}
.message-content blockquote {
border-left: 3px solid #5a5a5a;
padding-left: 12px;
margin: 8px 0;
color: #ccc;
}
.message-content table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 11px;
}
.message-content th,
.message-content td {
border: 1px solid #4a4a4a;
padding: 4px 8px;
text-align: left;
}
.message-content th {
background-color: #3a3a3a;
font-weight: bold;
}
/* Abort link styling */
.abort-link {
background: none !important;
border: none !important;
color: #7b68ee !important;
text-decoration: underline !important;
cursor: pointer !important;
padding: 0 !important;
font: inherit !important;
font-size: 12px !important;
}
.abort-link:hover {
color: #9b88ff !important;
text-decoration: none !important;
}
/* Tool XML Expander */
.tool-xml-expander {
margin-bottom: 8px;
}
.tool-xml-expander-header {
color: #e0e0e0;
cursor: pointer;
user-select: none;
padding: 4px 8px;
/* background-color: #f5f5f5; */
border: 1px solid #2a2a2a;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.tool-xml-expander-arrow {
font-size: 12px;
color: #666;
}
.tool-xml-expander-title {
/* font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace; */
font-size: 12px;
font-weight: bold;
color: #e0e0e0;
}
.tool-xml-expander-content {
margin-top: 4px;
padding: 8px;
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
white-space: pre-wrap;
overflow: auto;
max-height: 200px;
color: #333;
}
/* Processing wave animation */
.processing-wave {
background: linear-gradient(
90deg,
rgba(135, 206, 250, 0.5) 0%,
rgba(135, 206, 250, 0.8) 25%,
rgb(171, 218, 250) 50%,
rgba(135, 206, 250, 0.8) 75%,
rgba(135, 206, 250, 0.5) 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: wave 2s linear infinite;
}
@keyframes wave {
0% {
background-position: 200% 0%;
}
100% {
background-position: -200% 0%;
}
}
+1
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
import './ChatBox.css';
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa'; import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
import { UserMessage, AssistantMessage } from './chat'; import { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore'; import { AgentCore } from '../agent/core/AgentCore';
+123
View File
@@ -0,0 +1,123 @@
/* Instrument Selection Panel */
.instrument-selection {
display: flex;
flex-direction: column;
width: var(--instrument-selection-width);
background-color: #2d2d2d;
border-right: 1px solid #3a3a3a;
flex-shrink: 0;
position: relative;
}
.instrument-selection-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 12px;
background-color: transparent;
border: none;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
}
.instrument-selection-header h3 {
color: #e0e0e0;
font-size: 12px;
font-weight: bold;
}
.instrument-selection-close-btn {
background: transparent;
border: none;
color: #e0e0e0;
cursor: pointer;
padding: 4px;
border-radius: 3px;
}
.instrument-selection-close-btn:hover {
background-color: #4a4a4a;
}
.instrument-selection-top {
height: 300px;
position: relative;
border-bottom: 1px solid #3a3a3a;
overflow: hidden;
background-image:
linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)),
url('/resources/instrument_bg.png');
background-size: cover; /* fill while keeping aspect ratio */
background-position: center;
}
.instrument-preview {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.instrument-preview img {
width: 256px;
height: 256px;
object-fit: contain;
border-radius: 6px;
opacity: 1;
}
.instrument-name-overlay {
position: absolute;
bottom: 16px;
left: 0;
right: 0;
text-align: center;
font-size: 16px;
color: #e0e0e0;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
z-index: 1;
font-weight: bold;
}
.instrument-selection-bottom {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 0; /* allow children to scroll */
}
.instrument-groups, .instrument-list {
overflow: hidden;
}
/* vertical divider between groups and instruments */
.instrument-groups {
border-right: 1px solid #3a3a3a;
}
.instrument-groups-list, .instrument-instruments-list {
height: 100%;
overflow-y: auto;
}
.instrument-group-item, .instrument-instrument-item {
padding: 8px 12px;
/* remove per-item separators */
border-bottom: none;
cursor: pointer;
font-size: 12px;
color: #e0e0e0;
}
.instrument-group-item:hover, .instrument-instrument-item:hover {
background-color: #3a3a3a;
}
.instrument-group-item.active, .instrument-instrument-item.active {
background-color: #4a4a4a;
}
+1
View File
@@ -1,4 +1,5 @@
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import './InstrumentSelection.css';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants'; import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack'; import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
+105
View File
@@ -0,0 +1,105 @@
/* Main content */
.main-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto; /* Main scrollable container */
position: relative;
}
.main-content-wrapper {
display: flex;
flex-direction: column;
min-width: calc(200px + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */
min-height: fit-content;
position: relative;
}
.top-left-spacer {
position: fixed;
top: 50px;
left: 0;
width: 200px;
height: 20px;
background-color: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
border-right: 1px solid #3a3a3a;
z-index: 1002; /* Higher than other elements to ensure it's always visible */
}
/* Offset spacer when instrument selection panel is visible on the left */
.main-content.has-left-instrument .top-left-spacer {
left: 300px;
}
.bar-numbers {
position: sticky;
top: 0;
height: 20px;
display: flex;
border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d;
z-index: 20;
margin-left: 200px; /* Offset for info-container */
width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
}
.bar-numbers:hover {
cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer;
}
.bar-number-cell {
min-width: var(--track-grid-bar-width);
width: var(--track-grid-bar-width);
flex-shrink: 0;
flex-grow: 0;
height: 20px;
text-align: center;
font-size: 12px;
border-right: 1px solid #3a3a3a;
color: #999;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.bar-number-cell.looped {
background-color: #e1ae01;
color: #1e1e1e;
}
.main-content-body {
display: flex;
min-height: fit-content;
}
.info-container {
position: sticky;
left: 0;
width: 200px;
z-index: 1002;
background-color: #2d2d2d;
align-self: flex-start;
}
/* Shift the bar numbers and info panel when instrument selection is present */
/* No need to shift bar numbers or info container; they live inside main content.
Only shift the fixed spacer when the left panel is present. */
.track-top-spacer {
height: 20px;
border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d;
position: sticky;
top: 0;
z-index: 15;
}
.grid-container {
margin-left: 0; /* No need for margin since we're using flex */
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Ensure minimum width */
min-height: fit-content;
position: relative; /* Required for absolute positioned playhead */
}
+11 -7
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import './MainContent.css';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
@@ -18,7 +19,7 @@ interface MainContentProps {
} }
const MainContent: React.FC<MainContentProps> = ({ const MainContent: React.FC<MainContentProps> = ({
onTrackClick = () => {} // Default to empty function if not provided onTrackClick = () => { } // Default to empty function if not provided
}) => { }) => {
const { const {
tracks, tracks,
@@ -33,7 +34,8 @@ const MainContent: React.FC<MainContentProps> = ({
showPianoRoll, showPianoRoll,
activeRegionId, activeRegionId,
setShowPianoRoll, setShowPianoRoll,
setActiveRegionId setActiveRegionId,
addTrack
} = useProjectStore(); } = useProjectStore();
// State to store regions // State to store regions
@@ -172,16 +174,16 @@ const MainContent: React.FC<MainContentProps> = ({
// If the region belongs to a track that was shifted due to the drag operation // If the region belongs to a track that was shifted due to the drag operation
else if ( else if (
(fromIndex < toIndex && (fromIndex < toIndex &&
region.trackIndex > fromIndex && region.trackIndex > fromIndex &&
region.trackIndex <= toIndex) region.trackIndex <= toIndex)
) { ) {
// Shift up by 1 // Shift up by 1
return { ...region, trackIndex: region.trackIndex - 1 }; return { ...region, trackIndex: region.trackIndex - 1 };
} }
else if ( else if (
(fromIndex > toIndex && (fromIndex > toIndex &&
region.trackIndex < fromIndex && region.trackIndex < fromIndex &&
region.trackIndex >= toIndex) region.trackIndex >= toIndex)
) { ) {
// Shift down by 1 // Shift down by 1
return { ...region, trackIndex: region.trackIndex + 1 }; return { ...region, trackIndex: region.trackIndex + 1 };
@@ -688,7 +690,9 @@ const MainContent: React.FC<MainContentProps> = ({
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}> <div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
<div className="main-content-wrapper"> <div className="main-content-wrapper">
{/* Top-left spacer */} {/* Top-left spacer */}
<div className="top-left-spacer"></div> <div className="top-left-spacer">
<button className="add-track-btn" onClick={() => addTrack()}>+ Add track</button>
</div>
{/* Bar numbers at the top */} {/* Bar numbers at the top */}
<div <div
+16
View File
@@ -0,0 +1,16 @@
/* Status bar */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #2d2d2d;
padding: 5px 10px;
font-size: 12px;
color: #999;
border-top: 1px solid #3a3a3a;
height: 30px;
}
.status-right span {
margin-left: 15px;
}
+1
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import './StatusBar.css';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
const StatusBar: React.FC = () => { const StatusBar: React.FC = () => {
+155
View File
@@ -0,0 +1,155 @@
/* Toolbar */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #2d2d2d;
height: 50px;
padding: 0 10px;
border-bottom: 1px solid #3a3a3a;
}
.toolbar-left, .toolbar-center, .toolbar-right {
display: flex;
align-items: center;
}
.toolbar-left {
display: flex;
align-items: center;
}
.logo-container {
margin-right: 10px;
display: flex;
align-items: center;
}
.logo {
height: 30px;
width: auto;
}
.project-name {
font-size: 14px;
color: #e0e0e0;
margin-right: 20px;
display: flex;
align-items: center;
pointer-events: auto;
}
.toolbar button {
background: transparent;
border: none;
color: #e0e0e0;
margin: 0 5px;
padding: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
font-size: 14px;
pointer-events: auto;
}
.toolbar button:hover {
background-color: #3a3a3a;
}
.toolbar button:disabled {
color: #666;
cursor: not-allowed;
}
.record-btn {
color: #ff4444;
}
.toolbar-separator {
width: 1px;
height: 20px;
background-color: #555;
margin: 0 10px;
}
.transport-control {
display: flex;
align-items: center;
margin: 0 10px;
pointer-events: auto;
}
.transport-item {
margin: 0 5px;
padding: 2px 2px;
background-color: #3a3a3a;
border-radius: 3px;
font-size: 12px;
}
/* Fixed-width font for time display to prevent layout shifts */
.current-time {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: normal;
letter-spacing: 0.5px;
padding: 2px 4px;
}
/* Clickable BPM styling */
.current-bpm {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: normal;
letter-spacing: 0.5px;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 2px 4px;
border-radius: 2px;
}
.current-bpm:hover {
background-color: #4a4a4a;
color: #fff;
}
/* Clickable time signature styling */
.current-time-signature {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: normal;
letter-spacing: 0.5px;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 2px 4px;
border-radius: 2px;
}
.current-time-signature:hover {
background-color: #4a4a4a;
color: #fff;
}
.current-key-signature {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: normal;
letter-spacing: 0.5px;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 2px 4px;
border-radius: 2px;
}
.current-key-signature:hover {
background-color: #4a4a4a;
color: #fff;
}
.export-dropdown .quant-dropdown {
width: 250px;
left: 0;
}
.key-signature-dropdown .quant-dropdown {
width: 100px;
left: 0;
}
+77 -48
View File
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import './Toolbar.css';
import { saveProject } from '../util/saveUtil'; import { saveProject } from '../util/saveUtil';
import { KGStorage } from '../core/io/KGStorage'; import { KGProjectStorage } from '../core/io/KGProjectStorage';
import { DB_CONSTANTS } from '../constants/coreConstants'; import { isValidProjectName } 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';
@@ -14,7 +15,7 @@ import {
FaCog FaCog
} from 'react-icons/fa'; } from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject'; import { KGProject, type KeySignature } from '../core/KGProject';
import { plainToInstance, instanceToPlain } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6'; import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState'; import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
@@ -58,11 +59,17 @@ const Toolbar: React.FC = () => {
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
// Export options // Export options
const exportOptions = ["Export to KGStudio JSON file", "Export to MIDI file"]; const exportOptions = ["Export to KGStudio file", "Export to MIDI file"];
const handleProjectNameClick = () => { const handleProjectNameClick = () => {
const newName = prompt("Enter project name:", projectName); const newName = prompt("Enter project name:", projectName);
if (newName) setProjectName(newName); if (newName) {
if (!isValidProjectName(newName)) {
window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
return;
}
setProjectName(newName);
}
}; };
// Common project loading logic extracted for reuse // Common project loading logic extracted for reuse
@@ -144,14 +151,8 @@ const Toolbar: React.FC = () => {
try { try {
// Try to load the project from storage // Try to load the project from storage
const storage = KGStorage.getInstance(); const storage = KGProjectStorage.getInstance();
const loadedProject = await storage.load( const loadedProject = await storage.load(projectNameToLoad.trim());
DB_CONSTANTS.DB_NAME,
DB_CONSTANTS.PROJECTS_STORE_NAME,
projectNameToLoad.trim(),
KGProject,
DB_CONSTANTS.DB_VERSION
);
if (!loadedProject) { if (!loadedProject) {
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`); window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
@@ -181,8 +182,8 @@ const Toolbar: React.FC = () => {
console.log("user selected export option:", exportType); console.log("user selected export option:", exportType);
} }
if (exportType === "Export to KGStudio JSON file") { if (exportType === "Export to KGStudio file") {
handleExportKGStudioJSON(); handleExportKGStudio();
} else if (exportType === "Export to MIDI file") { } else if (exportType === "Export to MIDI file") {
handleExportMIDI(); handleExportMIDI();
} }
@@ -190,46 +191,39 @@ const Toolbar: React.FC = () => {
setShowExportDropdown(false); setShowExportDropdown(false);
}; };
const handleExportKGStudioJSON = () => { const handleExportKGStudio = async () => {
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("exporting to KGStudio JSON file"); console.log("exporting to KGStudio file");
} }
try { try {
// Get the current project from KGCore // First save the current project to OPFS so the export reflects the latest state
const currentProject = KGCore.instance().getCurrentProject(); const storage = KGProjectStorage.getInstance();
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
// Serialize the project to JSON (same format as saved to IndexedDB) // Bundle the project folder into a zip
// Use instanceToPlain to include type information for class-transformer const blob = await storage.exportAsZip(projectName);
const projectData = JSON.stringify(instanceToPlain(currentProject), null, 2);
// Create a downloadable blob // Trigger download
const blob = new Blob([projectData], { type: 'application/json' });
// Create a temporary download link
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `${projectName}.json`; link.download = `${projectName}.kgstudio`;
// Trigger download
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
// Cleanup
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as JSON file`); setStatus(`Project "${projectName}" exported as KGStudio file`);
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio JSON export completed successfully"); console.log("KGStudio export completed successfully");
} }
} catch (error) { } catch (error) {
console.error("Error exporting KGStudio JSON:", error); console.error("Error exporting KGStudio file:", error);
setStatus(`Error exporting project: ${error}`); setStatus(`Error exporting project: ${error}`);
window.alert(`Failed to export project as JSON: ${error}`); window.alert(`Failed to export project: ${error}`);
} }
}; };
@@ -291,8 +285,11 @@ const Toolbar: React.FC = () => {
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
try { try {
if (fileExtension === '.json') { if (fileExtension === '.kgstudio') {
// Handle KGStudio JSON import // Handle KGStudio bundle import
await handleKGStudioFileImport(file);
} else if (fileExtension === '.json') {
// Handle legacy KGStudio JSON import
await handleKGStudioJSONImport(file); await handleKGStudioJSONImport(file);
} else if (fileExtension === '.mid' || fileExtension === '.midi') { } else if (fileExtension === '.mid' || fileExtension === '.midi') {
// Handle MIDI import // Handle MIDI import
@@ -308,30 +305,62 @@ const Toolbar: React.FC = () => {
} }
}; };
const handleKGStudioFileImport = async (file: File) => {
const storage = KGProjectStorage.getInstance();
try {
const projectName = await storage.importFromZip(file);
// Load the project from OPFS (this runs the upgrader)
const loaded = await storage.load(projectName);
if (!loaded) {
throw new Error('Failed to load imported project');
}
await loadProjectFromData(loaded, `KGStudio file "${file.name}"`);
if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio file imported successfully:", projectName);
}
} catch (error) {
window.alert(`The .kgstudio file is corrupted or invalid: ${error}`);
throw error;
}
};
const handleKGStudioJSONImport = async (file: File) => { const handleKGStudioJSONImport = async (file: File) => {
try { try {
// Read the file content
const fileContent = await file.text(); const fileContent = await file.text();
const projectData = JSON.parse(fileContent); const projectData = JSON.parse(fileContent);
// Deserialize the project data using class-transformer (same as KGStorage) // Deserialize and handle potential array return
const deserializedResult = plainToInstance(KGProject, projectData); const deserializedResult = plainToInstance(KGProject, projectData);
const project = Array.isArray(deserializedResult)
// Handle case where plainToInstance might return an array
const deserializedProject = Array.isArray(deserializedResult)
? deserializedResult[0] || null ? deserializedResult[0] || null
: deserializedResult; : deserializedResult;
if (!deserializedProject) { if (!project) {
throw new Error("Failed to deserialize project data"); throw new Error("Failed to deserialize project data");
} }
// Load the project using common loading logic // Use the project's name for the OPFS folder, auto-rename if it already exists
await loadProjectFromData(deserializedProject, `File "${file.name}"`); const storage = KGProjectStorage.getInstance();
const importedName = await storage.resolveUniqueName(project.getName() || 'Imported Project');
// Save to OPFS as a proper folder-based project
project.setName(importedName);
await storage.save(importedName, project, false);
// Load back from OPFS so the upgrader runs
const loaded = await storage.load(importedName);
if (!loaded) {
throw new Error('Failed to load imported project from storage');
}
await loadProjectFromData(loaded, `JSON file "${file.name}"`);
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio JSON project imported successfully:", deserializedProject); console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
} }
} catch (error) { } catch (error) {
@@ -803,7 +832,7 @@ const Toolbar: React.FC = () => {
isVisible={showImportModal} isVisible={showImportModal}
onClose={() => setShowImportModal(false)} onClose={() => setShowImportModal(false)}
onFileImport={handleFileImport} onFileImport={handleFileImport}
acceptedTypes={['.json', '.mid', '.midi']} acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
title="Import Project" title="Import Project"
description="Drag and drop your project file here" description="Drag and drop your project file here"
/> />
-18
View File
@@ -1,18 +0,0 @@
import React from 'react';
import { useProjectStore } from '../stores/projectStore';
const TrackControl: React.FC = () => {
const { addTrack } = useProjectStore();
const handleAddTrack = () => {
addTrack();
};
return (
<div className="track-control">
<button onClick={handleAddTrack}>+ Add track</button>
</div>
);
};
export default TrackControl;
+161
View File
@@ -0,0 +1,161 @@
/* File Import Modal */
.file-import-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
backdrop-filter: blur(2px);
}
.file-import-modal {
background-color: #2d2d2d;
border: 1px solid #3a3a3a;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
animation: fileImportFadeIn 0.2s ease-out;
}
@keyframes fileImportFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.file-import-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #3a3a3a;
background-color: #252525;
}
.file-import-title {
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.file-import-close-btn {
background: transparent;
border: none;
color: #b0b0b0;
cursor: pointer;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s ease;
}
.file-import-close-btn:hover {
background-color: #3a3a3a;
color: #e0e0e0;
}
.file-import-drop-zone {
padding: 40px 20px;
margin: 20px;
border: 2px dashed #555;
border-radius: 8px;
background-color: #1e1e1e;
transition: all 0.3s ease;
cursor: pointer;
}
.file-import-drop-zone:hover {
border-color: #7b68ee;
background-color: rgba(123, 104, 238, 0.05);
}
.file-import-drop-zone.drag-over {
border-color: #7b68ee;
background-color: rgba(123, 104, 238, 0.1);
transform: scale(1.02);
}
.file-import-drop-content {
text-align: center;
color: #e0e0e0;
}
.file-import-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.7;
}
.file-import-description {
font-size: 18px;
font-weight: 500;
margin: 0 0 8px 0;
color: #e0e0e0;
}
.file-import-formats {
font-size: 14px;
color: #b0b0b0;
margin: 0 0 24px 0;
}
.file-import-divider {
position: relative;
margin: 24px 0;
text-align: center;
}
.file-import-divider::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: #3a3a3a;
}
.file-import-divider span {
background-color: #1e1e1e;
padding: 0 12px;
color: #888;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.file-import-browse-btn {
display: inline-block;
background-color: #5a9fd4;
color: #fff;
padding: 12px 24px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.file-import-browse-btn:hover {
background-color: #4a8fc4;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3);
}
@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import './FileImportModal.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
interface FileImportModalProps { interface FileImportModalProps {
+36
View File
@@ -0,0 +1,36 @@
/* Global Loading Overlay */
.global-loading-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 20000;
display: flex;
align-items: center;
justify-content: center;
pointer-events: all; /* block interactions */
}
.global-loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.global-loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #ffffff;
border-radius: 50%;
animation: globalSpinner 1s linear infinite;
}
.global-loading-text {
color: #e0e0e0;
font-size: 14px;
}
@keyframes globalSpinner {
to { transform: rotate(360deg); }
}
+1
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import './LoadingOverlay.css';
interface LoadingOverlayProps { interface LoadingOverlayProps {
visible: boolean; visible: boolean;
+265
View File
@@ -0,0 +1,265 @@
/* For the piano roll (to be implemented later) */
.piano-roll {
background-color: #7b68ee;
height: 100%;
}
/* Piano Roll Panel */
.piano-roll-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;
}
/* Piano notes */
.piano-note {
position: absolute;
background-color: #ff5555; /* Red color */
border: 1px solid #999;
border-radius: 2px;
z-index: 10;
cursor: pointer;
user-select: none;
box-sizing: border-box;
}
.piano-note:hover {
filter: brightness(1.1);
box-shadow: 0 0 5px rgba(255, 85, 85, 0.5);
}
.piano-note.dragging {
opacity: 0.8;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
z-index: 100;
cursor: grabbing;
}
.piano-note.resizing {
opacity: 0.8;
box-shadow: 0 0 10px rgba(255, 85, 85, 0.7);
z-index: 100;
}
.piano-note.selected {
border: 2px solid white;
z-index: 50;
}
.piano-roll-header {
height: 30px;
background-color: #1e1e1e;
border-bottom: 1px solid #3a3a3a;
display: flex;
align-items: center;
padding: 0 10px;
cursor: move;
user-select: none;
}
/* Piano roll toolbar */
.piano-roll-toolbar {
height: 30px;
background-color: #252525;
border-bottom: 1px solid #3a3a3a;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
user-select: none;
position: relative;
z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */
}
/* Override pointer-events for piano roll toolbar sections */
.piano-roll-toolbar .toolbar-left,
.piano-roll-toolbar .toolbar-right {
pointer-events: auto;
}
.piano-roll-toolbar .quant-button {
font-size: 10px;
}
.piano-roll-toolbar .toolbar-left .quant-button {
margin-left: 0px;
margin-right: 5px;
}
.piano-roll-toolbar .toolbar-left .quant-dropdown {
left: 0;
}
.piano-roll-title {
flex: 1;
text-align: center;
font-size: 14px;
color: #e0e0e0;
text-transform: uppercase;
cursor: pointer;
padding: 5px;
border-radius: 3px;
}
.close-button {
background: transparent;
border: none;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
padding: 5px;
}
.close-button:hover {
color: #ff4444;
}
.piano-roll-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
}
.piano-grid-header {
display: grid;
grid-template-columns: repeat(var(--max-number-of-bars), var(--region-grid-bar-width));
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width) - 1px);
min-width: 100%;
height: 20px;
border-bottom: 1px solid #3a3a3a;
background-color: #1e1e1e;
padding-left: calc(var(--region-piano-key-width) - 1px);
box-sizing: border-box;
position: sticky;
top: 0;
z-index: 20;
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
}
.piano-grid-header:hover {
cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer;
}
.piano-bar-number {
border-left: 1px solid #3a3a3a;
padding-left: 10px;
font-size: 10px;
}
.piano-roll-body {
display: flex;
min-height: calc(8 * 12 * var(--region-piano-key-height)); /* 8 octaves * 12 notes * piano key height */
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width)); /* 32 bars * 160px width + piano keys width */
}
.piano-keys-container {
width: var(--region-piano-key-width);
flex-shrink: 0;
overflow: hidden;
border-right: 1px solid #3a3a3a;
background-color: #252525;
position: sticky;
left: 0;
z-index: 10;
}
.piano-grid-container {
flex: 1;
min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width));
min-height: calc(8 * 12 * var(--region-piano-key-height));
z-index: 5;
}
.piano-octave {
display: flex;
flex-direction: column;
}
.piano-key {
width: var(--region-piano-key-width);
height: var(--region-piano-key-height);
box-sizing: border-box;
display: flex;
align-items: center;
border-bottom: 1px solid #3a3a3a;
}
.piano-key.natural {
background-color: #e0e0e0;
color: #222;
}
.piano-key.sharp {
background-color: #222;
color: #e0e0e0;
}
.key-label {
font-size: 10px;
padding-left: 5px;
}
.piano-grid {
width: 100%;
height: 100%;
position: relative;
background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%;
/* background-image is now set dynamically via React inline styles in PianoGrid component */
}
.piano-grid.pencil-cursor {
cursor: crosshair;
}
/* Piano Grid Cursor Highlights */
.piano-grid-pitch-highlight {
position: absolute;
left: 0;
right: 0;
background-color: rgba(123, 104, 238, 0.15);
border-top: 1px solid rgba(123, 104, 238, 0.3);
border-bottom: 1px solid rgba(123, 104, 238, 0.3);
pointer-events: none;
z-index: 1;
transition: opacity 0.1s ease;
}
.piano-grid-beat-highlight {
position: absolute;
top: 0;
bottom: 0;
background-color: rgba(123, 104, 238, 0.1);
border-left: 1px solid rgba(123, 104, 238, 0.25);
border-right: 1px solid rgba(123, 104, 238, 0.25);
pointer-events: none;
z-index: 1;
transition: opacity 0.1s ease;
}
.piano-grid-chord-highlight {
position: absolute;
background-color: rgba(255, 77, 77, 0.25);
border: 1px solid rgba(255, 77, 77, 0.8);
pointer-events: none;
z-index: 2;
transition: opacity 0.1s ease;
}
.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;
}
+1
View File
@@ -1,4 +1,5 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import './PianoRoll.css';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { FaGripLines } from 'react-icons/fa'; import { FaGripLines } from 'react-icons/fa';
+389
View File
@@ -0,0 +1,389 @@
/* Settings Panel Styles */
.settings-panel {
width: 100%;
height: 100%;
background-color: #1e1e1e;
display: flex;
flex-direction: column;
}
.settings-container {
display: flex;
height: 100%;
overflow: hidden;
}
/* Settings Sidebar */
.settings-sidebar {
width: 250px;
background-color: #2d2d2d;
border-right: 1px solid #3a3a3a;
display: flex;
flex-direction: column;
}
.settings-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #3a3a3a;
}
.settings-sidebar-header h2 {
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.settings-close-btn {
background: transparent;
border: none;
color: #e0e0e0;
cursor: pointer;
padding: 5px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.settings-close-btn:hover {
background-color: #3a3a3a;
color: #fff;
}
.settings-nav {
display: flex;
flex-direction: column;
padding: 10px;
}
.settings-nav-item {
background: transparent;
border: none;
color: #b0b0b0;
padding: 12px 16px;
text-align: left;
cursor: pointer;
border-radius: 4px;
margin-bottom: 2px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.settings-nav-item:hover {
background-color: #3a3a3a;
color: #e0e0e0;
}
.settings-nav-item.active {
background-color: #4a4a4a;
color: #fff;
}
/* Settings Content */
.settings-content {
flex: 1;
overflow-y: auto;
background-color: #1e1e1e;
display: flex;
justify-content: center;
}
.settings-section {
padding: 30px;
width: 800px;
}
.settings-section-header {
margin-bottom: 30px;
border-bottom: 1px solid #3a3a3a;
padding-bottom: 15px;
}
.settings-section-header h3 {
color: #fff;
font-size: 24px;
font-weight: 600;
margin: 0;
}
.settings-section-content {
display: flex;
flex-direction: column;
gap: 30px;
padding-bottom: 30px;
}
.settings-group {
background-color: #2d2d2d;
border-radius: 8px;
padding: 20px;
border: 1px solid #3a3a3a;
}
.settings-group h4 {
color: #fff;
font-size: 16px;
font-weight: 600;
margin: 0 0 15px 0;
}
.settings-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.settings-group-header h4 {
margin: 0;
}
.settings-description {
color: #b0b0b0;
font-size: 14px;
line-height: 1.5;
margin-bottom: 15px;
}
.settings-item {
margin-bottom: 15px;
}
.settings-item:last-child {
margin-bottom: 0;
}
.settings-label {
display: block;
color: #e0e0e0;
font-size: 14px;
font-weight: 500;
margin-bottom: 5px;
}
.settings-input, .settings-select, .settings-textarea {
width: 100%;
/* max-width: 300px; */
padding: 8px 12px;
background-color: #3a3a3a;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
font-size: 14px;
transition: border-color 0.2s ease;
font-family: inherit;
}
.settings-input:focus, .settings-select:focus, .settings-textarea:focus {
outline: none;
border-color: #5a9fd4;
box-shadow: 0 0 0 2px rgba(90, 159, 212, 0.2);
}
.settings-textarea {
max-width: 600px;
resize: vertical;
min-height: 120px;
}
.settings-input[type="number"] {
max-width: 100px;
}
/* Custom Checkbox */
.settings-checkbox-container {
display: flex;
align-items: center;
cursor: pointer;
color: #e0e0e0;
font-size: 14px;
user-select: none;
}
.settings-checkbox-container input[type="checkbox"] {
display: none;
}
.settings-checkmark {
width: 18px;
height: 18px;
background-color: #3a3a3a;
border: 1px solid #555;
border-radius: 3px;
margin-right: 10px;
position: relative;
transition: all 0.2s ease;
}
.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark {
background-color: #5a9fd4;
border-color: #5a9fd4;
}
.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* Settings Buttons */
.settings-btn {
background-color: #3a3a3a;
border: 1px solid #555;
color: #e0e0e0;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.settings-btn:hover {
background-color: #4a4a4a;
border-color: #666;
}
.settings-btn-primary {
background-color: #5a9fd4;
border-color: #5a9fd4;
color: #fff;
}
.settings-btn-primary:hover {
background-color: #4a8fc4;
border-color: #4a8fc4;
}
.settings-btn-danger {
background-color: #d45a5a;
border-color: #d45a5a;
color: #fff;
}
.settings-btn-danger:hover {
background-color: #c44a4a;
border-color: #c44a4a;
}
.settings-btn-danger:disabled {
background-color: #555;
border-color: #555;
color: #888;
cursor: not-allowed;
}
.settings-btn-small {
padding: 6px 10px;
font-size: 12px;
}
/* Settings Help Links */
.settings-help-links {
display: flex;
gap: 16px;
margin-bottom: 8px;
}
button.settings-help {
color: #5a9fd4;
text-decoration: underline;
cursor: pointer;
font-size: 14px;
background: none;
border: none;
padding: 0;
font-family: inherit;
}
button.settings-help:hover {
color: #7bbfef;
}
/* Settings Validation Errors */
.settings-validation-errors {
margin-top: 8px;
padding: 8px;
background-color: rgba(211, 90, 90, 0.1);
border: 1px solid #d35a5a;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
.settings-validation-error {
color: #ff6b6b;
font-size: 12px;
line-height: 1.4;
margin-bottom: 4px;
}
.settings-validation-error:last-child {
margin-bottom: 0;
}
/* Templates List */
.templates-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.template-item {
background-color: #3a3a3a;
border: 1px solid #555;
border-radius: 6px;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s ease;
}
.template-item:hover {
border-color: #666;
}
.template-info {
flex: 1;
}
.template-name {
color: #fff;
font-size: 16px;
font-weight: 600;
margin: 0 0 5px 0;
}
.template-description {
color: #b0b0b0;
font-size: 14px;
margin: 0 0 5px 0;
}
.template-tracks {
color: #888;
font-size: 12px;
font-weight: 500;
}
.template-actions {
display: flex;
gap: 8px;
}
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import './Settings.css';
import SettingsSidebar from './SettingsSidebar'; import SettingsSidebar from './SettingsSidebar';
import GeneralSettings from './sections/GeneralSettings'; import GeneralSettings from './sections/GeneralSettings';
import BehaviorSettings from './sections/BehaviorSettings'; import BehaviorSettings from './sections/BehaviorSettings';
+110
View File
@@ -0,0 +1,110 @@
/* Track Region Styles */
.track-region {
position: absolute;
background-color: #4a6b8a;
border: 2px solid #5a7b9a;
border-radius: 3px;
height: calc(100%);
margin: 0px;
overflow: hidden;
display: flex;
flex-direction: column;
cursor: pointer;
user-select: none;
transition: box-shadow 0.1s ease;
will-change: transform;
}
.track-region:hover {
box-shadow: 0 0 0 1px #7a9bba;
}
.track-region.selected {
/* box-shadow: 0 0 0 2px #ffffff, 0 0 8px rgba(255, 255, 255, 0.3); */ /* let's only apply a border */
border-color: #ffffff;
}
.track-region.dragging {
opacity: 0.8;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
z-index: 100;
pointer-events: none;
transform-origin: center center;
animation: pulse 1.5s infinite;
cursor: grabbing;
transition: none; /* Remove transition during drag for immediate response */
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(122, 155, 186, 0.7);
}
70% {
box-shadow: 0 0 0 5px rgba(122, 155, 186, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(122, 155, 186, 0);
}
}
.region-header {
background-color: #5a7b9a;
color: #fff;
padding: 2px 6px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: move;
height: 18px;
user-select: none;
}
.track-region:hover .region-header {
background-color: #6a8baa;
}
.region-content {
height: calc(100% - 18px);
background-color: #87CEFA; /* Light blue */
width: 100%;
position: relative; /* Allow overlayed controls */
}
/* Region pencil trigger inside content */
.region-pencil-btn {
position: absolute;
top: 4px;
left: 4px;
background: rgba(0, 0, 0, 0.25);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
padding: 2px;
margin: 0;
cursor: pointer;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
}
.region-pencil-btn:hover {
background: rgba(0, 0, 0, 0.35);
}
/* Instrument dropdown specific styles */
.instrument-dropdown .quant-dropdown {
min-width: 80px;
width: auto;
right: auto;
left: 0;
}
/* Settings dropdown specific styles */
.settings-dropdown .quant-dropdown {
min-width: 100px;
width: auto;
right: auto;
left: 0;
}
+1
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import './Region.css';
import { FaPencilAlt } from 'react-icons/fa'; import { FaPencilAlt } from 'react-icons/fa';
import type { ResizeAction } from '../interfaces'; import type { ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
+207
View File
@@ -0,0 +1,207 @@
/* Track grid */
.track-grid {
display: block;
height: 120px;
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* 32 bars * --track-grid-bar-width */
background-size: var(--track-grid-bar-width) 120px;
background-image:
/* Vertical lines for bars */
linear-gradient(to right,
transparent calc(var(--track-grid-bar-width) - 1px), #3a3a3a calc(var(--track-grid-bar-width) - 1px), #3a3a3a var(--track-grid-bar-width)
);
position: relative;
border-bottom: 1px solid #3a3a3a;
}
.track-grid.pencil-cursor {
cursor: crosshair;
}
.add-track-btn {
background: transparent;
border: none;
outline: none;
color: #999;
cursor: pointer;
font-size: 12px;
padding: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.add-track-btn:focus,
.add-track-btn:focus-visible {
outline: none;
}
.add-track-btn:hover {
color: #e0e0e0;
}
.track {
display: flex;
height: 120px;
position: relative;
border-bottom: none;
}
.track-info {
width: 200px;
height: 120px;
padding: 15px;
background-color: #2d2d2d;
display: flex;
flex-direction: column;
border-right: 1px solid #3a3a3a;
border-bottom: 1px solid #3a3a3a;
flex-shrink: 0;
box-sizing: border-box;
cursor: grab;
position: relative;
}
.track-info:active {
cursor: grabbing;
}
.track-info.selected {
background-color: #363636;
}
/* Drag and drop styles */
.track-info.drag-over {
border-top: 2px solid #7b68ee;
}
.track-grid.drag-over {
border-top: 2px solid #7b68ee;
}
/* Dragging state */
.track-info.dragging {
opacity: 0.5;
background-color: #444;
}
.track-grid.dragging {
opacity: 0.5;
background-color: #333;
}
/* Remove drag indicator since hover cursor is sufficient */
.track-name {
font-size: 12px;
cursor: pointer;
position: relative;
}
.track-name:hover {
color: #7b68ee;
}
.track-name:hover::before {
content: "\270E";
position: absolute;
right: 0px;
font-size: 16px;
opacity: 1;
}
.track-controls {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.track-name-and-volume {
display: flex;
flex-direction: row;
flex: 1;
align-items: flex-start;
gap: 8px;
}
.instrument-image {
flex-shrink: 0;
margin-top: -10px;
margin-left: -10px;
}
.instrument-image img {
display: block;
border-radius: 4px;
}
.track-name-and-controls {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.volume-slider {
margin-bottom: 15px;
width: 100%;
display: flex;
align-items: center;
gap: 6px;
}
.volume-slider input[type="range"] {
flex: 1;
min-width: 0;
height: 6px;
}
.volume-slider .reset-volume {
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: 8px;
background: #3a3a3a;
color: #e0e0e0;
/* border: 1px solid #444; */
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 10px;
}
.volume-slider .reset-volume:hover {
background: #4a4a4a;
}
.pan-controls {
display: flex;
}
.pan-controls button {
background: #3a3a3a;
border: none;
color: #e0e0e0;
margin-right: 5px;
width: 24px;
height: 24px;
font-size: 12px;
border-radius: 2px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pan-controls button.solo.active {
background: #c8a400; /* yellow */
color: #101010;
}
.pan-controls button.mute.active {
background: #d32f2f; /* red */
color: #ffffff;
}
+1
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import './Track.css';
import { KGTrack } from '../../core/track/KGTrack'; import { KGTrack } from '../../core/track/KGTrack';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import TrackInfoItem from './TrackInfoItem'; import TrackInfoItem from './TrackInfoItem';
+12
View File
@@ -93,6 +93,18 @@ export const SAMPLER_CONSTANTS = {
}, },
}; };
export const OPFS_CONSTANTS = {
ROOT_DIR: 'projects',
PROJECT_FILE: 'project.json',
METADATA_FILE: 'meta.json',
MEDIA_DIR: 'media',
};
export const CONFIG_UPGRADER_CONSTANTS = {
VERSION_KEY: '__config_version',
CURRENT_VERSION: 1,
};
export const URL_CONSTANTS = { export const URL_CONSTANTS = {
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1', DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
}; };
+32
View File
@@ -2,6 +2,8 @@ import type { Selectable } from '../components/interfaces';
import { KGProject } from './KGProject'; import { KGProject } from './KGProject';
import { KGAudioInterface } from './audio-interface/KGAudioInterface'; import { KGAudioInterface } from './audio-interface/KGAudioInterface';
import { ConfigManager } from './config/ConfigManager'; import { ConfigManager } from './config/ConfigManager';
import { KGProjectStorage } from './io/KGProjectStorage';
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
import { KGMidiRegion } from './region/KGMidiRegion'; import { KGMidiRegion } from './region/KGMidiRegion';
import { KGMidiNote } from './midi/KGMidiNote'; import { KGMidiNote } from './midi/KGMidiNote';
import { KGRegion } from './region/KGRegion'; import { KGRegion } from './region/KGRegion';
@@ -30,6 +32,8 @@ export class KGCore {
private copiedItems: Selectable[] = []; private copiedItems: Selectable[] = [];
private isPlaying: boolean = false; private isPlaying: boolean = false;
private isMigrating: boolean = false;
private migrationStateChangeCallback: ((isMigrating: boolean) => void) | null = null;
// Timer management for playback // Timer management for playback
private playbackIntervalId: number | null = null; private playbackIntervalId: number | null = null;
@@ -72,6 +76,18 @@ export class KGCore {
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
await configManager.initialize(); await configManager.initialize();
// Initialize OPFS project storage
const projectStorage = KGProjectStorage.getInstance();
await projectStorage.initialize();
// Run app-level migrations (e.g., IndexedDB -> OPFS project migration)
this.setMigrating(true);
try {
await KGConfigUpgrader.upgradeToLatest();
} finally {
this.setMigrating(false);
}
// Initialize audio interface // Initialize audio interface
const audioInterface = KGAudioInterface.instance(); const audioInterface = KGAudioInterface.instance();
await audioInterface.initialize(); await audioInterface.initialize();
@@ -194,6 +210,22 @@ export class KGCore {
this.selectionChangeCallbacks.forEach(callback => callback()); this.selectionChangeCallbacks.forEach(callback => callback());
} }
// Migration state
public getIsMigrating(): boolean {
return this.isMigrating;
}
private setMigrating(value: boolean): void {
this.isMigrating = value;
if (this.migrationStateChangeCallback) {
this.migrationStateChangeCallback(value);
}
}
public setMigrationStateChangeCallback(callback: (isMigrating: boolean) => void): void {
this.migrationStateChangeCallback = callback;
}
// play // play
public getIsPlaying(): boolean { public getIsPlaying(): boolean {
return this.isPlaying; return this.isPlaying;
+215 -1
View File
@@ -28,7 +28,8 @@ export class KGDebugger {
'createTestRegion()', 'createTestRegion()',
'testExtractXMLFromString(input)', 'testExtractXMLFromString(input)',
'testToolCall(jsonInput)', 'testToolCall(jsonInput)',
'inputChatBox(content, interval?)' 'inputChatBox(content, interval?)',
'opfs(command)',
]); ]);
} }
@@ -344,6 +345,7 @@ export class KGDebugger {
console.log(" testExtractXMLFromString(input) - Test XML extraction from string"); console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results"); console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter"); console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl)");
console.log(" help() - Show this help"); console.log(" help() - Show this help");
console.log(""); console.log("");
console.log("💡 Usage tips:"); console.log("💡 Usage tips:");
@@ -446,4 +448,216 @@ export class KGDebugger {
console.error('❌ Error in inputChatBox:', error); console.error('❌ Error in inputChatBox:', error);
} }
} }
// --- OPFS Shell ---
/** Current working directory path segments (relative to OPFS root) */
private opfsCwd: string[] = [];
/**
* Simplified bash-like shell for browsing the OPFS filesystem.
*
* Supported commands:
* pwd print current directory
* ls list files/folders (like ls -lla)
* cd <path> change directory (supports .., /, relative, and quoted paths)
* cat <file> print file contents
* dl <file> download a file to your local machine
*
* Usage in console:
* await KGDebugger.opfs('pwd')
* await KGDebugger.opfs('ls')
* await KGDebugger.opfs('cd projects')
* await KGDebugger.opfs('cat project.json')
*/
public async opfs(command: string): Promise<void> {
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
const cmd = parts[0]
// Strip surrounding quotes from arguments
const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ')
try {
switch (cmd) {
case 'pwd':
console.log('/' + this.opfsCwd.join('/'))
break
case 'ls':
await this.opfsLs()
break
case 'cd':
await this.opfsCd(arg)
break
case 'cat':
await this.opfsCat(arg)
break
case 'dl':
await this.opfsDl(arg)
break
default:
console.log(`opfs: command not found: ${cmd}`)
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>')
}
} catch (error) {
console.error(`opfs: ${error}`)
}
}
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
let dir = await navigator.storage.getDirectory()
for (const segment of this.opfsCwd) {
dir = await dir.getDirectoryHandle(segment)
}
return dir
}
private async opfsLs(): Promise<void> {
const dir = await this.opfsResolveCwd()
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []
for await (const entry of dir.values()) {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle
const file = await fileHandle.getFile()
entries.push({
kind: 'file',
name: entry.name,
size: file.size,
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
})
} else {
entries.push({
kind: 'dir',
name: entry.name + '/',
size: 0,
modified: '-',
})
}
}
// Sort: directories first, then files, alphabetically within each group
entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
if (entries.length === 0) {
console.log('(empty directory)')
return
}
// Print header
const cwdPath = '/' + this.opfsCwd.join('/')
console.log(`total ${entries.length} (${cwdPath})`)
// Format like ls -lla
const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4)
for (const e of entries) {
const typeChar = e.kind === 'dir' ? 'd' : '-'
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen)
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`)
}
}
private async opfsCd(path: string): Promise<void> {
if (!path || path === '') {
// cd with no args goes to root
this.opfsCwd = []
return
}
let segments: string[]
if (path === '/') {
this.opfsCwd = []
return
} else if (path.startsWith('/')) {
// Absolute path
segments = path.split('/').filter(Boolean)
} else {
// Relative path
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]
}
// Resolve . and ..
const resolved: string[] = []
for (const seg of segments) {
if (seg === '.') continue
if (seg === '..') {
resolved.pop()
} else {
resolved.push(seg)
}
}
// Verify the path exists
let dir = await navigator.storage.getDirectory()
for (const seg of resolved) {
try {
dir = await dir.getDirectoryHandle(seg)
} catch {
console.error(`opfs: cd: no such directory: ${path}`)
return
}
}
this.opfsCwd = resolved
}
private async opfsCat(fileName: string): Promise<void> {
if (!fileName) {
console.error('opfs: cat: missing file name')
return
}
const dir = await this.opfsResolveCwd()
try {
const fileHandle = await dir.getFileHandle(fileName)
const file = await fileHandle.getFile()
const text = await file.text()
// Pretty-print JSON files
if (fileName.endsWith('.json')) {
try {
const parsed = JSON.parse(text)
console.log(JSON.stringify(parsed, null, 2))
} catch {
console.log(text)
}
} else {
console.log(text)
}
} catch {
console.error(`opfs: cat: ${fileName}: No such file`)
}
}
private async opfsDl(fileName: string): Promise<void> {
if (!fileName) {
console.error('opfs: dl: missing file name')
return
}
const dir = await this.opfsResolveCwd()
try {
const fileHandle = await dir.getFileHandle(fileName)
const file = await fileHandle.getFile()
const url = URL.createObjectURL(file)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
console.log(`downloaded: ${fileName} (${file.size} bytes)`)
} catch {
console.error(`opfs: dl: ${fileName}: No such file`)
}
}
} }
@@ -0,0 +1,62 @@
import { KGConfigStorage } from '../io/KGConfigStorage';
import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants';
import { upgradeConfigToV1 } from './upgradeConfigToV1';
/**
* KGConfigUpgrader Orchestrates app-level migrations (e.g., storage backend changes).
* Mirrors the KGProjectUpgrader pattern but operates on global app state, not individual projects.
*
* Version is tracked via a `__config_version` key in the IndexedDB config store.
*/
export class KGConfigUpgrader {
/**
* Run all pending config upgrades sequentially.
* Returns the number of upgrade steps that were executed.
*/
public static async upgradeToLatest(): Promise<number> {
const storage = KGConfigStorage.getInstance();
const currentVersion = await KGConfigUpgrader.getConfigVersion(storage);
const targetVersion = CONFIG_UPGRADER_CONSTANTS.CURRENT_VERSION;
if (currentVersion >= targetVersion) {
console.log(`Config is up to date (version ${currentVersion})`);
return 0;
}
console.log(`Config upgrade needed: v${currentVersion} -> v${targetVersion}`);
let stepsExecuted = 0;
for (let nextVersion = currentVersion + 1; nextVersion <= targetVersion; nextVersion++) {
switch (nextVersion) {
case 1: {
await upgradeConfigToV1();
break;
}
default: {
throw new Error(`No config upgrader found for version ${nextVersion}`);
}
}
// Persist the version after each successful step
await KGConfigUpgrader.setConfigVersion(storage, nextVersion);
stepsExecuted++;
console.log(`Config upgraded to version ${nextVersion}`);
}
return stepsExecuted;
}
private static async getConfigVersion(storage: KGConfigStorage): Promise<number> {
const raw = await storage.getRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY);
if (!raw || typeof raw.version !== 'number') return 0;
return raw.version;
}
private static async setConfigVersion(storage: KGConfigStorage, version: number): Promise<void> {
await storage.saveRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY, {
version,
upgradedAt: Date.now(),
});
}
}
@@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { instanceToPlain } from 'class-transformer';
import { KGProject } from '../KGProject';
// --- Mock idb for reading old IndexedDB projects ---
const mockProjects: Array<{ name: string; data: Record<string, unknown>; lastModified: number }> = [];
vi.mock('idb', () => {
const stores: Record<string, Map<string, unknown>> = {};
const getStore = (name: string) => {
if (!stores[name]) stores[name] = new Map();
return stores[name];
};
return {
openDB: vi.fn(() => {
const db = {
getAll: vi.fn((storeName: string) => {
if (storeName === 'projects') return Promise.resolve([...mockProjects]);
return Promise.resolve([...getStore(storeName).values()]);
}),
get: vi.fn((storeName: string, key: string) => getStore(storeName).get(key)),
put: vi.fn((storeName: string, value: { name: string }) => {
getStore(storeName).set(value.name, value);
}),
close: vi.fn(),
objectStoreNames: { contains: () => false },
};
return Promise.resolve(db);
}),
__stores: stores,
__reset: () => Object.keys(stores).forEach((k) => delete stores[k]),
};
});
// --- Mock OPFS for KGProjectStorage ---
class MockWritable {
data = '';
async write(content: string) { this.data = content; }
async close() {}
}
class MockFileHandle {
kind = 'file' as const;
private _content = '';
constructor(public name: string) {}
async getFile() { return { text: () => Promise.resolve(this._content) }; }
async createWritable() {
const w = new MockWritable();
const self = this;
const origClose = w.close.bind(w);
w.close = async () => { self._content = w.data; await origClose(); };
return w;
}
}
class MockDirHandle {
kind = 'directory' as const;
entries = new Map<string, MockDirHandle | MockFileHandle>();
constructor(public name: string) {}
async getDirectoryHandle(name: string, opts?: { create?: boolean }) {
let e = this.entries.get(name);
if (!e || e.kind !== 'directory') {
if (opts?.create) {
e = new MockDirHandle(name);
this.entries.set(name, e);
} else {
throw new DOMException('Not found', 'NotFoundError');
}
}
return e as MockDirHandle;
}
async getFileHandle(name: string, opts?: { create?: boolean }) {
let e = this.entries.get(name);
if (!e || e.kind !== 'file') {
if (opts?.create) {
e = new MockFileHandle(name);
this.entries.set(name, e);
} else {
throw new DOMException('Not found', 'NotFoundError');
}
}
return e as MockFileHandle;
}
async removeEntry(name: string) { this.entries.delete(name); }
async *values() {
for (const e of this.entries.values()) yield e;
}
}
const mockRoot = new MockDirHandle('root');
vi.stubGlobal('navigator', {
...navigator,
storage: {
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
persist: vi.fn(() => Promise.resolve(true)),
},
});
// Now import the module under test
import { upgradeConfigToV1 } from './upgradeConfigToV1';
import { KGProjectStorage } from '../io/KGProjectStorage';
describe('upgradeConfigToV1', () => {
beforeEach(async () => {
mockProjects.length = 0;
mockRoot.entries.clear();
// Reset KGProjectStorage singleton
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
const storage = KGProjectStorage.getInstance();
await storage.initialize();
});
it('migrates projects from IndexedDB to OPFS', async () => {
// Set up a mock IndexedDB project
const project = new KGProject('My Old Song', 32, 0, 130);
mockProjects.push({
name: 'My Old Song',
data: instanceToPlain(project) as Record<string, unknown>,
lastModified: 1700000000000,
});
await upgradeConfigToV1();
const storage = KGProjectStorage.getInstance();
const names = await storage.list();
expect(names).toContain('My Old Song');
const loaded = await storage.load('My Old Song');
expect(loaded).not.toBeNull();
expect(loaded!.getBpm()).toBe(130);
});
it('sanitizes project names with invalid characters', async () => {
const project = new KGProject('My:Song/Here', 32, 0, 120);
mockProjects.push({
name: 'My:Song/Here',
data: instanceToPlain(project) as Record<string, unknown>,
lastModified: Date.now(),
});
await upgradeConfigToV1();
const storage = KGProjectStorage.getInstance();
const names = await storage.list();
// Should be sanitized — no colons or slashes
expect(names).toContain('My_Song_Here');
});
it('skips projects that already exist in OPFS', async () => {
// Pre-create a project in OPFS
const storage = KGProjectStorage.getInstance();
const existing = new KGProject('Existing', 32, 0, 100);
await storage.save('Existing', existing);
// Add same project to IndexedDB mock
mockProjects.push({
name: 'Existing',
data: instanceToPlain(existing) as Record<string, unknown>,
lastModified: Date.now(),
});
// Should not throw or overwrite
await upgradeConfigToV1();
const loaded = await storage.load('Existing');
expect(loaded!.getBpm()).toBe(100); // Original BPM preserved
});
it('handles empty IndexedDB gracefully', async () => {
// No projects in IndexedDB
await expect(upgradeConfigToV1()).resolves.not.toThrow();
});
});
@@ -0,0 +1,144 @@
import { openDB } from 'idb';
import { plainToInstance } from 'class-transformer';
import { KGProject } from '../KGProject';
import { KGProjectStorage } from '../io/KGProjectStorage';
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
import { sanitizeProjectName } from '../../util/projectNameUtil';
import { DB_CONSTANTS } from '../../constants/coreConstants';
/**
* Config upgrade V1: Migrate all projects from IndexedDB to OPFS.
*
* This reads directly from the old IndexedDB `projects` store (no dependency on KGStorage),
* sanitizes project names, and writes each project to the OPFS-backed KGProjectStorage.
*
* Idempotent: projects already present in OPFS are skipped.
* Non-destructive: old IndexedDB data is preserved as a backup.
*/
export async function upgradeConfigToV1(): Promise<void> {
console.log('Config V1 upgrade: migrating projects from IndexedDB to OPFS...');
// Read all projects from the old IndexedDB store
const oldProjects = await readAllProjectsFromIndexedDB();
if (oldProjects.length === 0) {
console.log('Config V1 upgrade: no projects found in IndexedDB, nothing to migrate');
return;
}
const projectStorage = KGProjectStorage.getInstance();
let migrated = 0;
let skipped = 0;
const errors: string[] = [];
for (const { name, data, lastModified } of oldProjects) {
try {
// Deserialize the project
const instance = plainToInstance(KGProject, data);
const project = Array.isArray(instance) ? instance[0] : instance;
if (!project) {
errors.push(`${name}: deserialization returned null`);
continue;
}
// Sanitize the project name for filesystem use
const sanitizedName = sanitizeProjectName(name);
project.setName(sanitizedName);
// Run the project upgrader (handles schema changes like instrument mapping, etc.)
const upgradedProject = upgradeProjectToLatest(project);
// Skip if already exists in OPFS
if (await projectStorage.exists(sanitizedName)) {
skipped++;
continue;
}
// Save to OPFS — use overwrite=false since we checked exists() above
// We call save directly which writes meta.json with createdAt = now.
// Override createdAt with the original lastModified from IndexedDB afterwards.
await projectStorage.save(sanitizedName, upgradedProject, false);
// Patch meta.json to use the original lastModified as createdAt
if (lastModified) {
await patchMetaCreatedAt(projectStorage, sanitizedName, lastModified);
}
migrated++;
} catch (error) {
errors.push(`${name}: ${error}`);
console.error(`Config V1 upgrade: failed to migrate project "${name}":`, error);
}
}
console.log(
`Config V1 upgrade complete: ${migrated} migrated, ${skipped} skipped, ${errors.length} errors`,
);
if (errors.length > 0) {
console.warn('Config V1 upgrade errors:', errors);
}
}
// --- Internal helpers ---
interface OldProjectEntry {
name: string;
data: Record<string, unknown>;
lastModified: number;
}
/**
* Read all projects from the old IndexedDB store directly (no KGStorage dependency).
*/
async function readAllProjectsFromIndexedDB(): Promise<OldProjectEntry[]> {
try {
const db = await openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
upgrade(db) {
// Ensure stores exist (same logic as old KGStorage)
const requiredStores = [DB_CONSTANTS.PROJECTS_STORE_NAME, DB_CONSTANTS.CONFIG_STORE_NAME];
for (const store of requiredStores) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'name' });
}
}
},
});
const allEntries = await db.getAll(DB_CONSTANTS.PROJECTS_STORE_NAME);
db.close();
return allEntries.map((entry) => ({
name: entry.name as string,
data: entry.data as Record<string, unknown>,
lastModified: (entry.lastModified as number) ?? Date.now(),
}));
} catch (error) {
console.error('Config V1 upgrade: failed to read from IndexedDB:', error);
return [];
}
}
/**
* Patch a project's meta.json to set createdAt to the original IndexedDB lastModified.
* This is a best-effort operation we access OPFS directly for this one-time patch.
*/
async function patchMetaCreatedAt(
projectStorage: KGProjectStorage,
projectName: string,
createdAt: number,
): Promise<void> {
try {
const root = await navigator.storage.getDirectory();
const projectsDir = await root.getDirectoryHandle('projects');
const projectDir = await projectsDir.getDirectoryHandle(projectName);
const metaHandle = await projectDir.getFileHandle('meta.json');
const file = await metaHandle.getFile();
const meta = JSON.parse(await file.text());
meta.createdAt = createdAt;
const writable = await metaHandle.createWritable();
await writable.write(JSON.stringify(meta, null, 2));
await writable.close();
} catch {
// Non-critical — createdAt will just be the migration time
}
}
+3 -10
View File
@@ -1,5 +1,4 @@
import { KGStorage } from '../io/KGStorage'; import { KGConfigStorage } from '../io/KGConfigStorage';
import { DB_CONSTANTS } from '../../constants/coreConstants';
/** /**
* Application configuration interface * Application configuration interface
@@ -99,7 +98,7 @@ export class ConfigManager {
// Configuration state // Configuration state
private config: AppConfig; private config: AppConfig;
private storage: KGStorage; private storage: KGConfigStorage;
private isInitialized: boolean = false; private isInitialized: boolean = false;
private defaultConfig: AppConfig | null = null; private defaultConfig: AppConfig | null = null;
private changeListeners: Set<(changedKeys: string[]) => void> = new Set(); private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
@@ -108,7 +107,7 @@ export class ConfigManager {
private constructor() { private constructor() {
// Initialize with empty config, will be loaded during initialize() // Initialize with empty config, will be loaded during initialize()
this.config = {} as AppConfig; this.config = {} as AppConfig;
this.storage = KGStorage.getInstance(); this.storage = KGConfigStorage.getInstance();
console.log('ConfigManager initialized'); console.log('ConfigManager initialized');
} }
@@ -262,11 +261,8 @@ export class ConfigManager {
// For config, we don't use class-transformer since it's plain objects // For config, we don't use class-transformer since it's plain objects
// So we'll use a simple object approach and handle it directly with KGStorage // So we'll use a simple object approach and handle it directly with KGStorage
const savedConfigData = await this.storage.load( const savedConfigData = await this.storage.load(
DB_CONSTANTS.DB_NAME,
DB_CONSTANTS.CONFIG_STORE_NAME,
ConfigManager.CONFIG_KEY, ConfigManager.CONFIG_KEY,
Object, // Simple object class Object, // Simple object class
DB_CONSTANTS.DB_VERSION
); );
if (savedConfigData) { if (savedConfigData) {
@@ -294,12 +290,9 @@ export class ConfigManager {
: this.config; : this.config;
await this.storage.save( await this.storage.save(
DB_CONSTANTS.DB_NAME,
DB_CONSTANTS.CONFIG_STORE_NAME,
ConfigManager.CONFIG_KEY, ConfigManager.CONFIG_KEY,
configToPersist, configToPersist,
true, // Always overwrite config true, // Always overwrite config
DB_CONSTANTS.DB_VERSION
); );
console.log('Saved config to storage:', configToPersist); console.log('Saved config to storage:', configToPersist);
} catch (error) { } catch (error) {
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock idb before importing KGConfigStorage
vi.mock('idb', () => {
const stores: Record<string, Map<string, unknown>> = {};
const getStore = (name: string): Map<string, unknown> => {
if (!stores[name]) stores[name] = new Map();
return stores[name];
};
const mockDB = {
get: vi.fn((storeName: string, key: string) => {
return getStore(storeName).get(key) ?? undefined;
}),
put: vi.fn((storeName: string, value: { name: string }) => {
getStore(storeName).set(value.name, value);
}),
delete: vi.fn((storeName: string, key: string) => {
getStore(storeName).delete(key);
}),
objectStoreNames: { contains: () => false },
};
return {
openDB: vi.fn(() => Promise.resolve(mockDB)),
__stores: stores,
__reset: () => {
Object.keys(stores).forEach((k) => delete stores[k]);
},
};
});
import { KGConfigStorage } from './KGConfigStorage';
// Access mock internals
const idbMock = await import('idb') as unknown as {
__stores: Record<string, Map<string, unknown>>;
__reset: () => void;
};
describe('KGConfigStorage', () => {
let storage: KGConfigStorage;
beforeEach(() => {
idbMock.__reset();
// Reset singleton for test isolation
;(KGConfigStorage as unknown as { _instance: undefined })._instance = undefined;
storage = KGConfigStorage.getInstance();
});
it('saves and loads a config entry', async () => {
await storage.save('testKey', { foo: 'bar' }, true);
const result = await storage.load('testKey', Object);
expect(result).toBeDefined();
expect((result as Record<string, unknown>).foo).toBe('bar');
});
it('deletes a config entry', async () => {
await storage.save('toDelete', { x: 1 }, true);
await storage.delete('toDelete');
const result = await storage.load('toDelete', Object);
expect(result).toBeNull();
});
it('saveRaw and getRaw work for version markers', async () => {
await storage.saveRaw('__config_version', { version: 1, upgradedAt: 123 });
const raw = await storage.getRaw('__config_version');
expect(raw).toBeDefined();
expect(raw!.version).toBe(1);
});
it('getRaw returns null for non-existent key', async () => {
const result = await storage.getRaw('nonexistent');
expect(result).toBeNull();
});
it('returns singleton instance', () => {
const a = KGConfigStorage.getInstance();
const b = KGConfigStorage.getInstance();
expect(a).toBe(b);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { openDB } from 'idb';
import type { IDBPDatabase } from 'idb';
import { instanceToPlain, plainToInstance } from 'class-transformer';
import { DB_CONSTANTS } from '../../constants/coreConstants';
interface ConfigStorageEntry {
name: string;
data: Record<string, unknown>;
lastModified: number;
}
/**
* KGConfigStorage IndexedDB-backed storage for application configuration.
* Extracted from the former KGStorage class; only manages the config object store.
*/
export class KGConfigStorage {
private static _instance: KGConfigStorage;
private dbPromise: Promise<IDBPDatabase> | null = null;
private constructor() {}
public static getInstance(): KGConfigStorage {
if (!KGConfigStorage._instance) {
KGConfigStorage._instance = new KGConfigStorage();
}
return KGConfigStorage._instance;
}
private getDB(): Promise<IDBPDatabase> {
if (!this.dbPromise) {
this.dbPromise = openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
upgrade(db) {
// Create required object stores if they don't exist
const requiredStores = [
DB_CONSTANTS.PROJECTS_STORE_NAME,
DB_CONSTANTS.CONFIG_STORE_NAME,
];
for (const store of requiredStores) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'name' });
console.log(`Created object store: ${store}`);
}
}
},
});
}
return this.dbPromise;
}
public async save(name: string, data: unknown, overwrite: boolean = true): Promise<void> {
const db = await this.getDB();
const storeName = DB_CONSTANTS.CONFIG_STORE_NAME;
if (!overwrite) {
const existing = await db.get(storeName, name);
if (existing) {
throw new Error(`Config entry "${name}" already exists`);
}
}
const entry: ConfigStorageEntry = {
name,
data: instanceToPlain(data) as Record<string, unknown>,
lastModified: Date.now(),
};
await db.put(storeName, entry);
}
public async load<T>(name: string, classType: new () => T): Promise<T | null> {
try {
const db = await this.getDB();
const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
if (!entry?.data) {
return null;
}
const instance = plainToInstance(classType, entry.data);
return Array.isArray(instance) ? instance[0] || null : instance;
} catch (error) {
console.error(`Error loading config entry "${name}":`, error);
return null;
}
}
public async delete(name: string): Promise<void> {
const db = await this.getDB();
await db.delete(DB_CONSTANTS.CONFIG_STORE_NAME, name);
}
/**
* Get a raw value from the config store (no class-transformer deserialization).
* Used by KGConfigUpgrader to read the config version marker.
*/
public async getRaw(name: string): Promise<Record<string, unknown> | null> {
try {
const db = await this.getDB();
const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
return entry?.data ?? null;
} catch (error) {
console.error(`Error loading raw config entry "${name}":`, error);
return null;
}
}
/**
* Save a raw value to the config store (no class-transformer serialization).
* Used by KGConfigUpgrader to write the config version marker.
*/
public async saveRaw(name: string, data: Record<string, unknown>): Promise<void> {
const db = await this.getDB();
const entry: ConfigStorageEntry = {
name,
data,
lastModified: Date.now(),
};
await db.put(DB_CONSTANTS.CONFIG_STORE_NAME, entry);
}
}
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGProject } from '../KGProject';
// --- OPFS mock infrastructure ---
class MockFileSystemWritableFileStream {
public data = '';
async write(content: string) { this.data = content; }
async close() {}
}
class MockFileSystemFileHandle {
kind = 'file' as const;
constructor(public name: string, private _content: string = '') {}
async getFile() {
return { text: () => Promise.resolve(this._content) };
}
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
// When stream closes, update our content
const self = this;
const origClose = stream.close.bind(stream);
stream.close = async () => {
self._content = stream.data;
await origClose();
};
return stream;
}
}
class MockFileSystemDirectoryHandle {
kind = 'directory' as const;
private entries = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
constructor(public name: string) {}
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'directory') {
if (options?.create) {
entry = new MockFileSystemDirectoryHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'file') {
if (options?.create) {
entry = new MockFileSystemFileHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemFileHandle;
}
async removeEntry(name: string, _options?: { recursive?: boolean }): Promise<void> {
if (!this.entries.has(name)) {
throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
}
this.entries.delete(name);
}
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
for (const entry of this.entries.values()) {
yield entry;
}
}
}
// Install the mock
const mockRoot = new MockFileSystemDirectoryHandle('root');
vi.stubGlobal('navigator', {
...navigator,
storage: {
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
persist: vi.fn(() => Promise.resolve(true)),
estimate: vi.fn(() => Promise.resolve({ usage: 0, quota: 1e9 })),
},
});
describe('KGProjectStorage', () => {
let storage: KGProjectStorage;
beforeEach(async () => {
// Reset singleton and mock filesystem
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
// Clear the mock root directory entries
const entries = (mockRoot as unknown as { entries: Map<string, unknown> }).entries;
entries.clear();
storage = KGProjectStorage.getInstance();
await storage.initialize();
});
function createTestProject(name = 'Test Project'): KGProject {
return new KGProject(name, 16, 0, 120);
}
it('initializes and creates the projects directory', async () => {
// The projects directory should exist after init
const projects = await mockRoot.getDirectoryHandle('projects');
expect(projects).toBeDefined();
expect(projects.kind).toBe('directory');
});
it('saves and loads a project', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
const loaded = await storage.load('My Song');
expect(loaded).not.toBeNull();
expect(loaded!.getName()).toBe('My Song');
expect(loaded!.getBpm()).toBe(120);
});
it('creates meta.json and media/ directory on save', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
const projectsDir = await mockRoot.getDirectoryHandle('projects');
const projectDir = await projectsDir.getDirectoryHandle('My Song');
// meta.json should exist
const metaHandle = await projectDir.getFileHandle('meta.json');
const metaFile = await metaHandle.getFile();
const meta = JSON.parse(await metaFile.text());
expect(meta.name).toBe('My Song');
expect(meta.createdAt).toBeGreaterThan(0);
expect(meta.updatedAt).toBeGreaterThan(0);
// media/ directory should exist
const mediaDir = await projectDir.getDirectoryHandle('media');
expect(mediaDir.kind).toBe('directory');
});
it('throws DuplicateEntryError when overwrite is false', async () => {
const project = createTestProject('Duplicate');
await storage.save('Duplicate', project);
await expect(storage.save('Duplicate', project, false)).rejects.toThrow(DuplicateEntryError);
});
it('allows overwrite when overwrite is true', async () => {
const project = createTestProject('Overwrite Test');
await storage.save('Overwrite Test', project);
project.setBpm(140);
await storage.save('Overwrite Test', project, true);
const loaded = await storage.load('Overwrite Test');
expect(loaded!.getBpm()).toBe(140);
});
it('preserves createdAt on overwrite', async () => {
const project = createTestProject('Preserve');
await storage.save('Preserve', project);
// Read the original createdAt
const projectsDir = await mockRoot.getDirectoryHandle('projects');
const projectDir = await projectsDir.getDirectoryHandle('Preserve');
const metaHandle1 = await projectDir.getFileHandle('meta.json');
const meta1 = JSON.parse(await (await metaHandle1.getFile()).text());
// Save again (overwrite)
await storage.save('Preserve', project, true);
const metaHandle2 = await projectDir.getFileHandle('meta.json');
const meta2 = JSON.parse(await (await metaHandle2.getFile()).text());
expect(meta2.createdAt).toBe(meta1.createdAt);
expect(meta2.updatedAt).toBeGreaterThanOrEqual(meta1.updatedAt);
});
it('lists project names', async () => {
await storage.save('Alpha', createTestProject('Alpha'));
await storage.save('Beta', createTestProject('Beta'));
await storage.save('Charlie', createTestProject('Charlie'));
const names = await storage.list();
expect(names).toEqual(['Alpha', 'Beta', 'Charlie']);
});
it('checks if project exists', async () => {
expect(await storage.exists('Nonexistent')).toBe(false);
await storage.save('Exists', createTestProject('Exists'));
expect(await storage.exists('Exists')).toBe(true);
});
it('deletes a project', async () => {
await storage.save('ToDelete', createTestProject('ToDelete'));
expect(await storage.exists('ToDelete')).toBe(true);
await storage.delete('ToDelete');
expect(await storage.exists('ToDelete')).toBe(false);
});
it('returns null for non-existent project on load', async () => {
const result = await storage.load('Ghost');
expect(result).toBeNull();
});
it('rejects invalid project names on save', async () => {
const project = createTestProject();
await expect(storage.save('my/song', project)).rejects.toThrow('Invalid project name');
await expect(storage.save('my:song', project)).rejects.toThrow('Invalid project name');
await expect(storage.save('', project)).rejects.toThrow('Invalid project name');
});
it('renames a project', async () => {
await storage.save('Old Name', createTestProject('Old Name'));
await storage.rename('Old Name', 'New Name');
expect(await storage.exists('Old Name')).toBe(false);
expect(await storage.exists('New Name')).toBe(true);
const loaded = await storage.load('New Name');
expect(loaded!.getName()).toBe('New Name');
});
});
+398
View File
@@ -0,0 +1,398 @@
import { instanceToPlain, plainToInstance } from 'class-transformer';
import JSZip from 'jszip';
import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
import { isValidProjectName } from '../../util/projectNameUtil';
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
export class DuplicateEntryError extends Error {
constructor(name: string) {
super(`Entry "${name}" already exists`);
this.name = 'DuplicateEntryError';
}
}
interface ProjectMeta {
name: string;
createdAt: number;
updatedAt: number;
}
/**
* KGProjectStorage OPFS-backed storage for project files.
* Each project lives in its own directory under the OPFS `projects/` root.
*
* Folder structure:
* projects/<ProjectName>/meta.json
* projects/<ProjectName>/project.json
* projects/<ProjectName>/media/
*/
export class KGProjectStorage {
private static _instance: KGProjectStorage;
private rootDirHandle: FileSystemDirectoryHandle | null = null;
private projectsDirHandle: FileSystemDirectoryHandle | null = null;
private _initialized = false;
private constructor() {}
public static getInstance(): KGProjectStorage {
if (!KGProjectStorage._instance) {
KGProjectStorage._instance = new KGProjectStorage();
}
return KGProjectStorage._instance;
}
/**
* Initialize OPFS root and request persistent storage.
* Must be called before any other method.
*/
public async initialize(): Promise<void> {
if (this._initialized) return;
this.rootDirHandle = await navigator.storage.getDirectory();
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
OPFS_CONSTANTS.ROOT_DIR,
{ create: true },
);
// Request persistent storage so the browser won't evict our data
try {
const persisted = await navigator.storage.persist();
console.log(`Persistent storage ${persisted ? 'granted' : 'denied'}`);
} catch (error) {
console.warn('navigator.storage.persist() not available:', error);
}
this._initialized = true;
console.log('KGProjectStorage initialized (OPFS)');
}
private ensureInitialized(): void {
if (!this._initialized || !this.projectsDirHandle) {
throw new Error('KGProjectStorage not initialized. Call initialize() first.');
}
}
/**
* Save a project. Creates the folder structure and writes meta.json + project.json.
*/
public async save(name: string, data: KGProject, overwrite: boolean = false): Promise<void> {
this.ensureInitialized();
if (!isValidProjectName(name)) {
throw new Error(
`Invalid project name "${name}". Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.`,
);
}
const exists = await this.exists(name);
if (exists && !overwrite) {
throw new DuplicateEntryError(name);
}
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name, { create: true });
// Ensure media/ directory exists
await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
// Write project.json
const projectData = instanceToPlain(data) as Record<string, unknown>;
const projectJson = JSON.stringify(projectData, null, 2);
await this.writeFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE, projectJson);
// Write/update meta.json
const now = Date.now();
let meta: ProjectMeta;
try {
const existingMeta = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
const parsed = JSON.parse(existingMeta) as ProjectMeta;
meta = { name, createdAt: parsed.createdAt, updatedAt: now };
} catch {
meta = { name, createdAt: now, updatedAt: now };
}
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
}
/**
* Load a project by name. Runs the project upgrader on the loaded data.
*/
public async load(name: string): Promise<KGProject | null> {
this.ensureInitialized();
try {
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const projectJson = await this.readFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE);
const plainData = JSON.parse(projectJson);
const instance = plainToInstance(KGProject, plainData);
const project = Array.isArray(instance) ? instance[0] || null : instance;
if (!project) return null;
project.setName(name);
return upgradeProjectToLatest(project);
} catch (error) {
console.error(`Error loading project "${name}":`, error);
return null;
}
}
/**
* List all project names (folder names under projects/).
*/
public async list(): Promise<string[]> {
this.ensureInitialized();
const names: string[] = [];
// FileSystemDirectoryHandle.entries() returns AsyncIterableIterator
// TypeScript's lib.dom.d.ts may lack full typing for this, so we iterate via values()
for await (const entry of this.projectsDirHandle!.values()) {
if (entry.kind === 'directory') {
names.push(entry.name);
}
}
return names.sort();
}
/**
* Delete a project and all its files.
*/
public async delete(name: string): Promise<void> {
this.ensureInitialized();
try {
await this.projectsDirHandle!.removeEntry(name, { recursive: true });
} catch (error) {
console.error(`Error deleting project "${name}":`, error);
throw error;
}
}
/**
* Check if a project exists.
*/
public async exists(name: string): Promise<boolean> {
this.ensureInitialized();
try {
await this.projectsDirHandle!.getDirectoryHandle(name);
return true;
} catch {
return false;
}
}
/**
* Rename a project by copying its directory contents to a new name and deleting the old one.
*/
public async rename(oldName: string, newName: string): Promise<void> {
this.ensureInitialized();
if (!isValidProjectName(newName)) {
throw new Error(`Invalid project name "${newName}".`);
}
if (await this.exists(newName)) {
throw new DuplicateEntryError(newName);
}
// Load the project from the old location
const project = await this.load(oldName);
if (!project) {
throw new Error(`Project "${oldName}" not found.`);
}
// Save to new location
project.setName(newName);
await this.save(newName, project, false);
// Delete old location
await this.delete(oldName);
}
/**
* Export a project folder as a zip Blob (.kgstudio bundle).
* Includes project.json, meta.json, and all files in media/.
*/
public async exportAsZip(name: string): Promise<Blob> {
this.ensureInitialized();
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const zip = new JSZip();
await this.addDirectoryToZip(zip, projectDir);
return zip.generateAsync({ type: 'blob' });
}
/**
* Recursively add all files and subdirectories from an OPFS directory to a JSZip instance.
*/
private async addDirectoryToZip(
zip: JSZip,
dirHandle: FileSystemDirectoryHandle,
path: string = '',
): Promise<void> {
for await (const entry of dirHandle.values()) {
const entryPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
zip.file(entryPath, file.arrayBuffer());
} else {
const subDir = entry as FileSystemDirectoryHandle;
await this.addDirectoryToZip(zip, subDir, entryPath);
}
}
}
/**
* Import a .kgstudio zip bundle into OPFS.
* Validates that meta.json exists and is valid.
* Returns the project name on success.
* On failure, cleans up any partially written folder and throws.
*/
public async importFromZip(blob: Blob): Promise<string> {
this.ensureInitialized();
const zip = await JSZip.loadAsync(blob);
// Validate meta.json
const metaFile = zip.file(OPFS_CONSTANTS.METADATA_FILE);
if (!metaFile) {
throw new Error('Invalid .kgstudio file: missing meta.json');
}
let meta: { name?: string };
try {
const metaText = await metaFile.async('text');
meta = JSON.parse(metaText);
} catch {
throw new Error('Invalid .kgstudio file: meta.json is corrupted');
}
if (!meta.name || typeof meta.name !== 'string') {
throw new Error('Invalid .kgstudio file: meta.json missing project name');
}
const projectName = await this.resolveUniqueName(meta.name);
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create: true });
try {
// Write all files from the zip into the OPFS project directory
for (const [relativePath, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.dir) {
// Create subdirectory
await this.getOrCreateSubDir(projectDir, relativePath);
} else {
// Write file
const data = await zipEntry.async('arraybuffer');
const parts = relativePath.split('/');
const fileName = parts.pop()!;
let targetDir = projectDir;
if (parts.length > 0) {
targetDir = await this.getOrCreateSubDir(projectDir, parts.join('/'));
}
const fileHandle = await targetDir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
}
// If the name was deduplicated, update meta.json and project.json to reflect it
if (projectName !== meta.name) {
// Patch meta.json
try {
const metaHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.METADATA_FILE);
const metaFileObj = await metaHandle.getFile();
const metaData = JSON.parse(await metaFileObj.text());
metaData.name = projectName;
const w1 = await metaHandle.createWritable();
await w1.write(JSON.stringify(metaData, null, 2));
await w1.close();
} catch { /* best effort */ }
// Patch project.json name field
try {
const projHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.PROJECT_FILE);
const projFileObj = await projHandle.getFile();
const projData = JSON.parse(await projFileObj.text());
projData.name = projectName;
const w2 = await projHandle.createWritable();
await w2.write(JSON.stringify(projData, null, 2));
await w2.close();
} catch { /* best effort */ }
}
return projectName;
} catch (error) {
// Clean up the partially written folder
try {
await this.projectsDirHandle!.removeEntry(projectName, { recursive: true });
} catch {
// Best effort cleanup
}
throw error;
}
}
/**
* Get or create a nested subdirectory from a path like "media/subfolder".
*/
private async getOrCreateSubDir(
root: FileSystemDirectoryHandle,
path: string,
): Promise<FileSystemDirectoryHandle> {
const segments = path.replace(/\/$/, '').split('/').filter(Boolean);
let current = root;
for (const seg of segments) {
current = await current.getDirectoryHandle(seg, { create: true });
}
return current;
}
/**
* Return a unique project name by appending (1), (2), etc. if the name already exists.
*/
public async resolveUniqueName(name: string): Promise<string> {
this.ensureInitialized();
if (!(await this.exists(name))) return name;
let counter = 1;
let candidate: string;
do {
candidate = `${name} (${counter})`;
counter++;
} while (await this.exists(candidate));
return candidate;
}
// --- File I/O helpers ---
private async writeFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
content: string,
): Promise<void> {
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}
private async readFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
): Promise<string> {
const fileHandle = await dirHandle.getFileHandle(fileName);
const file = await fileHandle.getFile();
return file.text();
}
}
-132
View File
@@ -1,132 +0,0 @@
// src/core/io/KGStorage.ts
import { openDB } from 'idb'
import type { IDBPDatabase } from 'idb'
import { plainToInstance, instanceToPlain } from 'class-transformer'
import { DB_CONSTANTS } from '../../constants/coreConstants'
export interface StorageEntry {
name: string
data: Record<string, unknown>
lastModified: number
}
export class DuplicateEntryError extends Error {
constructor(name: string) {
super(`Entry "${name}" already exists`)
this.name = 'DuplicateEntryError'
}
}
export class KGStorage {
private static instance: KGStorage
private dbPromises: Map<string, Promise<IDBPDatabase>>
private constructor() {
this.dbPromises = new Map()
}
public static getInstance(): KGStorage {
if (!KGStorage.instance) {
KGStorage.instance = new KGStorage()
}
return KGStorage.instance
}
private getDB(dbName: string, _storeName: string, version: number = 1): Promise<IDBPDatabase> {
const key = `${dbName}_${version}`
if (!this.dbPromises.has(key)) {
const dbPromise = openDB(dbName, version, {
upgrade(db) {
// Create all required object stores for this database
const requiredStores = [
DB_CONSTANTS.PROJECTS_STORE_NAME,
DB_CONSTANTS.CONFIG_STORE_NAME
];
for (const store of requiredStores) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'name' })
console.log(`Created object store: ${store}`)
}
}
},
})
this.dbPromises.set(key, dbPromise)
}
return this.dbPromises.get(key)!
}
public async save<T>(
dbName: string,
storeName: string,
name: string,
data: T,
overwrite: boolean = false,
version: number = 1
): Promise<void> {
const db = await this.getDB(dbName, storeName, version)
const existing = await db.get(storeName, name)
if (existing && !overwrite) {
throw new DuplicateEntryError(name)
}
const entry: StorageEntry = {
name: name,
data: instanceToPlain(data) as Record<string, unknown>,
lastModified: Date.now(),
}
await db.put(storeName, entry)
}
public async load<T>(
dbName: string,
storeName: string,
name: string,
classType: new() => T,
version: number = 1
): Promise<T | null> {
try {
const db = await this.getDB(dbName, storeName, version)
const entry = await db.get(storeName, name)
if (!entry?.data) {
console.log(`No data found for entry "${name}" in store "${storeName}" of database "${dbName}"`)
return null
}
const instance = plainToInstance(classType, entry.data)
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
(loadedInstance as { setName: (projectName: string) => void }).setName(name)
}
return loadedInstance
} catch (error) {
console.log(`Error loading entry "${name}" from store "${storeName}" of database "${dbName}":`, error)
return null
}
}
public async list(
dbName: string,
storeName: string,
version: number = 1
): Promise<string[]> {
const db = await this.getDB(dbName, storeName, version)
const all = await db.getAllKeys(storeName)
return all as string[]
}
public async delete(
dbName: string,
storeName: string,
name: string,
version: number = 1
): Promise<void> {
const db = await this.getDB(dbName, storeName, version)
await db.delete(storeName, name)
}
}
+6
View File
@@ -58,6 +58,12 @@ button:focus-visible {
outline: 2px auto rgba(100, 108, 255, 0.5); outline: 2px auto rgba(100, 108, 255, 0.5);
} }
textarea {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 12px;
line-height: 1.4;
}
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
+1
View File
@@ -1,6 +1,7 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './styles/variables.css';
import './index.css'; import './index.css';
import App from './App.tsx'; import App from './App.tsx';
import { KGCore } from './core/KGCore'; import { KGCore } from './core/KGCore';
+125
View File
@@ -0,0 +1,125 @@
/* Shared toolbar layout classes (used by Toolbar and PianoRollToolbar) */
.toolbar-left, .toolbar-right {
display: flex;
align-items: center;
z-index: 1;
pointer-events: none;
}
.toolbar-left {
width: 20%;
justify-content: flex-start;
}
.toolbar-right {
width: 33%;
justify-content: flex-end;
z-index: 1500;
}
.toolbar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
z-index: 1005;
margin-left: -50px;
}
/* Tool buttons (used by Toolbar and PianoRollToolbar) */
.tool-button {
width: 30px;
height: 30px;
background-color: transparent;
border: none;
border-radius: 3px;
color: #999;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin: 0 2px;
}
.tool-button:hover {
background-color: #3a3a3a;
color: #e0e0e0;
}
.tool-button.active {
background-color: #4a4a4a;
color: #e0e0e0;
}
/* Quantization dropdown system (used by KGDropdown) */
.quant-button {
background-color: #333;
border: 1px solid #444;
border-radius: 3px;
color: #e0e0e0;
font-size: 12px;
padding: 3px 8px;
margin-left: 5px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.quant-button:hover {
background-color: #444;
}
.quant-dropdown-container {
position: relative;
display: inline-block;
z-index: 1500;
}
.quant-dropdown {
position: absolute;
top: 100%;
right: 0;
background-color: #2d2d2d;
border: 1px solid #444;
border-radius: 3px;
width: 100px;
z-index: 1500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
margin-top: 2px;
max-height: 200px;
overflow-y: auto;
}
.quant-option {
padding: 6px 10px;
font-size: 12px;
color: #e0e0e0;
cursor: pointer;
transition: background-color 0.2s;
}
.quant-option:hover {
background-color: #444;
}
.quant-option.active {
background-color: #4a6b8a;
}
/* Generic button blink effect */
.quant-button.button-blink {
animation: buttonBlink 0.2s ease-in-out;
}
@keyframes buttonBlink {
0% { background-color: #333; }
50% { background-color: #555; }
100% { background-color: #333; }
}
+11
View File
@@ -0,0 +1,11 @@
:root {
--time-signature-numerator: 4;
--max-number-of-bars: 32;
--track-grid-bar-width: 40px;
--region-piano-key-width: 60px;
--region-piano-key-height: 20px;
--region-grid-beat-width: 40px;
--region-grid-bar-width: calc(var(--region-grid-beat-width) * var(--time-signature-numerator));
--chat-box-width: 350px;
--instrument-selection-width: 300px;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Type augmentations for the Origin Private File System (OPFS) async iterable APIs.
* These are part of the File System Access API but not yet fully typed in TypeScript's lib.dom.d.ts.
*/
interface FileSystemDirectoryHandle {
values(): AsyncIterableIterator<FileSystemDirectoryHandle | FileSystemFileHandle>;
keys(): AsyncIterableIterator<string>;
entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { isValidProjectName, sanitizeProjectName } from './projectNameUtil';
describe('isValidProjectName', () => {
it('accepts simple alphanumeric names', () => {
expect(isValidProjectName('MyProject')).toBe(true);
expect(isValidProjectName('project123')).toBe(true);
});
it('accepts names with allowed special characters', () => {
expect(isValidProjectName('My Song')).toBe(true);
expect(isValidProjectName('song-v2')).toBe(true);
expect(isValidProjectName('song_final')).toBe(true);
expect(isValidProjectName('song.backup')).toBe(true);
expect(isValidProjectName('Song (v2)')).toBe(true);
});
it('rejects empty or whitespace-only names', () => {
expect(isValidProjectName('')).toBe(false);
expect(isValidProjectName(' ')).toBe(false);
});
it('rejects names starting with a dot', () => {
expect(isValidProjectName('.hidden')).toBe(false);
});
it('rejects names with disallowed characters', () => {
expect(isValidProjectName('my/song')).toBe(false);
expect(isValidProjectName('my\\song')).toBe(false);
expect(isValidProjectName('my:song')).toBe(false);
expect(isValidProjectName('my*song')).toBe(false);
expect(isValidProjectName('my?song')).toBe(false);
expect(isValidProjectName('my"song')).toBe(false);
expect(isValidProjectName('my<song')).toBe(false);
expect(isValidProjectName('my>song')).toBe(false);
expect(isValidProjectName('my|song')).toBe(false);
});
it('accepts accented characters', () => {
expect(isValidProjectName('Café Waltz')).toBe(true);
expect(isValidProjectName('Ñoño')).toBe(true);
});
});
describe('sanitizeProjectName', () => {
it('returns valid names unchanged', () => {
expect(sanitizeProjectName('My Song')).toBe('My Song');
expect(sanitizeProjectName('project-123')).toBe('project-123');
});
it('replaces disallowed characters with underscores', () => {
expect(sanitizeProjectName('my/song')).toBe('my_song');
expect(sanitizeProjectName('my:song')).toBe('my_song');
expect(sanitizeProjectName('a*b?c')).toBe('a_b_c');
});
it('collapses consecutive underscores', () => {
expect(sanitizeProjectName('a///b')).toBe('a_b');
expect(sanitizeProjectName('a__b')).toBe('a_b');
});
it('collapses consecutive spaces', () => {
expect(sanitizeProjectName('a b')).toBe('a b');
});
it('trims leading/trailing whitespace, underscores, and dots', () => {
expect(sanitizeProjectName(' My Song ')).toBe('My Song');
expect(sanitizeProjectName('__song__')).toBe('song');
expect(sanitizeProjectName('.hidden')).toBe('hidden');
expect(sanitizeProjectName('...dots...')).toBe('dots');
});
it('returns fallback for names that become empty after sanitization', () => {
expect(sanitizeProjectName('///')).toBe('Untitled Project');
expect(sanitizeProjectName('...')).toBe('Untitled Project');
expect(sanitizeProjectName('___')).toBe('Untitled Project');
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Allowed characters for project names: letters, numbers, space, hyphen, underscore, period, parentheses.
* These are safe across Windows, macOS, and Linux as directory names.
*/
const VALID_PROJECT_NAME_REGEX = /^[a-zA-Z0-9 \-_.()\u00C0-\u024F]+$/;
/**
* Characters that are NOT allowed in project names replaced during sanitization.
*/
const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g;
/**
* Validate whether a project name contains only allowed characters.
* Does NOT check for empty string caller should check that separately.
*/
export function isValidProjectName(name: string): boolean {
if (!name || name.trim().length === 0) return false;
if (name.startsWith('.')) return false; // hidden files on Unix
return VALID_PROJECT_NAME_REGEX.test(name);
}
/**
* Sanitize a project name by replacing disallowed characters with underscores,
* collapsing consecutive underscores/spaces, and trimming.
*/
export function sanitizeProjectName(name: string): string {
let sanitized = name.replace(DISALLOWED_CHARS_REGEX, '_');
// Collapse consecutive underscores
sanitized = sanitized.replace(/_{2,}/g, '_');
// Collapse consecutive spaces
sanitized = sanitized.replace(/ {2,}/g, ' ');
// Trim leading/trailing whitespace, underscores, and dots
sanitized = sanitized.replace(/^[.\s_]+|[.\s_]+$/g, '').trim();
// If everything was stripped, provide a fallback
if (sanitized.length === 0) {
sanitized = 'Untitled Project';
}
return sanitized;
}
+2 -9
View File
@@ -1,5 +1,4 @@
import { KGStorage, DuplicateEntryError } from '../core/io/KGStorage'; import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage';
import { DB_CONSTANTS } from '../constants/coreConstants';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
/** /**
@@ -13,16 +12,13 @@ export const saveProject = async (
projectName: string, projectName: string,
setStatus: (status: string) => void setStatus: (status: string) => void
): Promise<boolean> => { ): Promise<boolean> => {
const storage = KGStorage.getInstance(); const storage = KGProjectStorage.getInstance();
try { try {
await storage.save( await storage.save(
DB_CONSTANTS.DB_NAME,
DB_CONSTANTS.PROJECTS_STORE_NAME,
projectName, projectName,
KGCore.instance().getCurrentProject(), KGCore.instance().getCurrentProject(),
false, false,
DB_CONSTANTS.DB_VERSION
); );
setStatus(`Project "${projectName}" has been saved`); setStatus(`Project "${projectName}" has been saved`);
@@ -38,12 +34,9 @@ export const saveProject = async (
if (confirmed) { if (confirmed) {
try { try {
await storage.save( await storage.save(
DB_CONSTANTS.DB_NAME,
DB_CONSTANTS.PROJECTS_STORE_NAME,
projectName, projectName,
KGCore.instance().getCurrentProject(), KGCore.instance().getCurrentProject(),
true, true,
DB_CONSTANTS.DB_VERSION
); );
setStatus(`Project "${projectName}" has been saved`); setStatus(`Project "${projectName}" has been saved`);