diff --git a/backend/app.py b/backend/app.py index afa9f98..011289e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -30,26 +30,105 @@ def get_db(): conn.row_factory = sqlite3.Row return conn +def check_and_migrate_schema(): + """Check if schema needs migration and perform it if necessary""" + conn = get_db() + cursor = conn.cursor() + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='namespaces'") + namespaces_exists = cursor.fetchone() is not None + + cursor.execute("PRAGMA table_info(snippets)") + columns = [row[1] for row in cursor.fetchall()] + has_namespace_id = 'namespaceId' in columns + + if not namespaces_exists or not has_namespace_id: + print("Schema migration needed - recreating tables with namespace support...") + + cursor.execute("DROP TABLE IF EXISTS snippets") + cursor.execute("DROP TABLE IF EXISTS namespaces") + + cursor.execute(''' + CREATE TABLE namespaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + createdAt INTEGER NOT NULL, + isDefault INTEGER DEFAULT 0 + ) + ''') + + cursor.execute(''' + CREATE TABLE snippets ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + code TEXT NOT NULL, + language TEXT NOT NULL, + category TEXT NOT NULL, + namespaceId TEXT, + hasPreview INTEGER DEFAULT 0, + functionName TEXT, + inputParameters TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + FOREIGN KEY (namespaceId) REFERENCES namespaces(id) + ) + ''') + + cursor.execute(''' + INSERT INTO namespaces (id, name, createdAt, isDefault) + VALUES ('default', 'Default', ?, 1) + ''', (int(datetime.utcnow().timestamp() * 1000),)) + + conn.commit() + print("Schema migration completed") + + conn.close() + def init_db(): conn = get_db() cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS namespaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + createdAt INTEGER NOT NULL, + isDefault INTEGER DEFAULT 0 + ) + ''') + cursor.execute(''' CREATE TABLE IF NOT EXISTS snippets ( id TEXT PRIMARY KEY, title TEXT NOT NULL, + description TEXT, code TEXT NOT NULL, language TEXT NOT NULL, - description TEXT, - tags TEXT, - category TEXT DEFAULT 'general', - componentName TEXT, - previewParams TEXT, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL + category TEXT NOT NULL, + namespaceId TEXT, + hasPreview INTEGER DEFAULT 0, + functionName TEXT, + inputParameters TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + FOREIGN KEY (namespaceId) REFERENCES namespaces(id) ) ''') + + cursor.execute("SELECT COUNT(*) FROM namespaces WHERE isDefault = 1") + default_count = cursor.fetchone()[0] + + if default_count == 0: + cursor.execute(''' + INSERT INTO namespaces (id, name, createdAt, isDefault) + VALUES ('default', 'Default', ?, 1) + ''', (int(datetime.utcnow().timestamp() * 1000),)) + conn.commit() conn.close() + + check_and_migrate_schema() @app.route('/health', methods=['GET']) def health(): @@ -67,10 +146,12 @@ def get_snippets(): snippets = [] for row in rows: snippet = dict(row) - if snippet.get('tags'): - snippet['tags'] = json.loads(snippet['tags']) - if snippet.get('previewParams'): - snippet['previewParams'] = json.loads(snippet['previewParams']) + if snippet.get('inputParameters'): + try: + snippet['inputParameters'] = json.loads(snippet['inputParameters']) + except: + snippet['inputParameters'] = None + snippet['hasPreview'] = bool(snippet.get('hasPreview', 0)) snippets.append(snippet) return jsonify(snippets) @@ -90,10 +171,12 @@ def get_snippet(snippet_id): return jsonify({'error': 'Snippet not found'}), 404 snippet = dict(row) - if snippet.get('tags'): - snippet['tags'] = json.loads(snippet['tags']) - if snippet.get('previewParams'): - snippet['previewParams'] = json.loads(snippet['previewParams']) + if snippet.get('inputParameters'): + try: + snippet['inputParameters'] = json.loads(snippet['inputParameters']) + except: + snippet['inputParameters'] = None + snippet['hasPreview'] = bool(snippet.get('hasPreview', 0)) return jsonify(snippet) except Exception as e: @@ -106,24 +189,32 @@ def create_snippet(): conn = get_db() cursor = conn.cursor() - tags_json = json.dumps(data.get('tags', [])) - preview_params_json = json.dumps(data.get('previewParams', {})) + input_params_json = json.dumps(data.get('inputParameters')) if data.get('inputParameters') else None + + created_at = data.get('createdAt') + if isinstance(created_at, str): + created_at = int(datetime.fromisoformat(created_at.replace('Z', '+00:00')).timestamp() * 1000) + + updated_at = data.get('updatedAt') + if isinstance(updated_at, str): + updated_at = int(datetime.fromisoformat(updated_at.replace('Z', '+00:00')).timestamp() * 1000) cursor.execute(''' - INSERT INTO snippets (id, title, code, language, description, tags, category, componentName, previewParams, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO snippets (id, title, description, code, language, category, namespaceId, hasPreview, functionName, inputParameters, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( data['id'], data['title'], + data.get('description', ''), data['code'], data['language'], - data.get('description', ''), - tags_json, data.get('category', 'general'), - data.get('componentName', ''), - preview_params_json, - data['createdAt'], - data['updatedAt'] + data.get('namespaceId'), + 1 if data.get('hasPreview') else 0, + data.get('functionName'), + input_params_json, + created_at, + updated_at )) conn.commit() @@ -140,23 +231,27 @@ def update_snippet(snippet_id): conn = get_db() cursor = conn.cursor() - tags_json = json.dumps(data.get('tags', [])) - preview_params_json = json.dumps(data.get('previewParams', {})) + input_params_json = json.dumps(data.get('inputParameters')) if data.get('inputParameters') else None + + updated_at = data.get('updatedAt') + if isinstance(updated_at, str): + updated_at = int(datetime.fromisoformat(updated_at.replace('Z', '+00:00')).timestamp() * 1000) cursor.execute(''' UPDATE snippets - SET title = ?, code = ?, language = ?, description = ?, tags = ?, category = ?, componentName = ?, previewParams = ?, updatedAt = ? + SET title = ?, description = ?, code = ?, language = ?, category = ?, namespaceId = ?, hasPreview = ?, functionName = ?, inputParameters = ?, updatedAt = ? WHERE id = ? ''', ( data['title'], + data.get('description', ''), data['code'], data['language'], - data.get('description', ''), - tags_json, data.get('category', 'general'), - data.get('componentName', ''), - preview_params_json, - data['updatedAt'], + data.get('namespaceId'), + 1 if data.get('hasPreview') else 0, + data.get('functionName'), + input_params_json, + updated_at, snippet_id )) @@ -186,6 +281,104 @@ def delete_snippet(snippet_id): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/api/namespaces', methods=['GET']) +def get_namespaces(): + try: + conn = get_db() + cursor = conn.cursor() + cursor.execute('SELECT * FROM namespaces ORDER BY isDefault DESC, name ASC') + rows = cursor.fetchall() + conn.close() + + namespaces = [] + for row in rows: + namespace = dict(row) + namespace['isDefault'] = bool(namespace.get('isDefault', 0)) + namespaces.append(namespace) + + return jsonify(namespaces) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/namespaces', methods=['POST']) +def create_namespace(): + try: + data = request.json + conn = get_db() + cursor = conn.cursor() + + created_at = data.get('createdAt') + if isinstance(created_at, str): + created_at = int(datetime.fromisoformat(created_at.replace('Z', '+00:00')).timestamp() * 1000) + + cursor.execute(''' + INSERT INTO namespaces (id, name, createdAt, isDefault) + VALUES (?, ?, ?, ?) + ''', ( + data['id'], + data['name'], + created_at, + 1 if data.get('isDefault') else 0 + )) + + conn.commit() + conn.close() + + return jsonify(data), 201 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/namespaces/', methods=['DELETE']) +def delete_namespace(namespace_id): + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute('SELECT isDefault FROM namespaces WHERE id = ?', (namespace_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return jsonify({'error': 'Namespace not found'}), 404 + + if row['isDefault']: + conn.close() + return jsonify({'error': 'Cannot delete default namespace'}), 400 + + cursor.execute('SELECT id FROM namespaces WHERE isDefault = 1') + default_row = cursor.fetchone() + default_id = default_row['id'] if default_row else 'default' + + cursor.execute('UPDATE snippets SET namespaceId = ? WHERE namespaceId = ?', (default_id, namespace_id)) + + cursor.execute('DELETE FROM namespaces WHERE id = ?', (namespace_id,)) + + conn.commit() + conn.close() + + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/wipe', methods=['POST']) +def wipe_database(): + """Emergency endpoint to wipe and recreate the database""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute("DROP TABLE IF EXISTS snippets") + cursor.execute("DROP TABLE IF EXISTS namespaces") + + conn.commit() + conn.close() + + init_db() + + return jsonify({'success': True, 'message': 'Database wiped and recreated'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + if __name__ == '__main__': init_db() app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/src/lib/db.ts b/src/lib/db.ts index fe806e4..1679acd 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -112,6 +112,64 @@ function saveToLocalStorage(data: Uint8Array): boolean { } } +async function validateSchema(db: Database): Promise { + try { + const snippetsCheck = db.exec("PRAGMA table_info(snippets)") + if (snippetsCheck.length === 0) return true + + const columns = snippetsCheck[0].values.map(row => row[1] as string) + const requiredColumns = ['id', 'title', 'code', 'language', 'category', 'namespaceId', 'createdAt', 'updatedAt'] + + for (const col of requiredColumns) { + if (!columns.includes(col)) { + console.warn(`Schema validation failed: missing column '${col}'`) + return false + } + } + + const namespacesCheck = db.exec("PRAGMA table_info(namespaces)") + if (namespacesCheck.length === 0) { + console.warn('Schema validation failed: namespaces table missing') + return false + } + + return true + } catch (error) { + console.error('Schema validation error:', error) + return false + } +} + +async function wipeAndRecreateDB(): Promise { + console.warn('Wiping corrupted database and creating fresh schema...') + + await saveToIndexedDB(new Uint8Array()) + saveToLocalStorage(new Uint8Array()) + + const idb = await openIndexedDB() + if (idb) { + try { + const transaction = idb.transaction([IDB_STORE], 'readwrite') + const store = transaction.objectStore(IDB_STORE) + await new Promise((resolve) => { + const request = store.delete(DB_KEY) + request.onsuccess = () => resolve() + request.onerror = () => resolve() + }) + } catch (error) { + console.warn('Error clearing IndexedDB:', error) + } + } + + try { + localStorage.removeItem(DB_KEY) + } catch (error) { + console.warn('Error clearing localStorage:', error) + } + + dbInstance = null +} + export async function initDB(): Promise { if (dbInstance) return dbInstance @@ -122,6 +180,7 @@ export async function initDB(): Promise { } let loadedData: Uint8Array | null = null + let schemaValid = false loadedData = await loadFromIndexedDB() @@ -129,11 +188,22 @@ export async function initDB(): Promise { loadedData = loadFromLocalStorage() } - if (loadedData) { + if (loadedData && loadedData.length > 0) { try { - dbInstance = new sqlInstance.Database(loadedData) + const testDb = new sqlInstance.Database(loadedData) + schemaValid = await validateSchema(testDb) + + if (schemaValid) { + dbInstance = testDb + } else { + console.warn('Schema validation failed, wiping database') + testDb.close() + await wipeAndRecreateDB() + dbInstance = new sqlInstance.Database() + } } catch (error) { console.error('Failed to load saved database, creating new one:', error) + await wipeAndRecreateDB() dbInstance = new sqlInstance.Database() } } else { @@ -836,6 +906,12 @@ export async function getDatabaseStats(): Promise<{ } export async function clearDatabase(): Promise { + const adapter = getFlaskAdapter() + if (adapter) { + await adapter.wipeDatabase() + return + } + const db = await openIndexedDB() if (db) { try { @@ -875,6 +951,11 @@ export async function syncTemplatesFromJSON(templates: SnippetTemplate[]): Promi } export async function getAllNamespaces(): Promise { + const adapter = getFlaskAdapter() + if (adapter) { + return await adapter.getAllNamespaces() + } + const db = await initDB() const results = db.exec('SELECT * FROM namespaces ORDER BY isDefault DESC, name ASC') @@ -898,14 +979,20 @@ export async function getAllNamespaces(): Promise } export async function createNamespace(name: string): Promise { - const db = await initDB() - const namespace: import('./types').Namespace = { id: Date.now().toString(), name, createdAt: Date.now(), isDefault: false } + + const adapter = getFlaskAdapter() + if (adapter) { + await adapter.createNamespace(namespace) + return namespace + } + + const db = await initDB() db.run( `INSERT INTO namespaces (id, name, createdAt, isDefault) @@ -918,6 +1005,11 @@ export async function createNamespace(name: string): Promise { + const adapter = getFlaskAdapter() + if (adapter) { + return await adapter.deleteNamespace(id) + } + const db = await initDB() const defaultNamespace = db.exec('SELECT id FROM namespaces WHERE isDefault = 1') @@ -1009,3 +1101,34 @@ export async function getNamespaceById(id: string): Promise { + try { + const db = await initDB() + const issues: string[] = [] + + const snippetsCheck = db.exec("PRAGMA table_info(snippets)") + if (snippetsCheck.length === 0) { + issues.push('Snippets table missing') + return { valid: false, issues } + } + + const columns = snippetsCheck[0].values.map(row => row[1] as string) + const requiredColumns = ['id', 'title', 'code', 'language', 'category', 'namespaceId', 'createdAt', 'updatedAt'] + + for (const col of requiredColumns) { + if (!columns.includes(col)) { + issues.push(`Missing column '${col}' in snippets table`) + } + } + + const namespacesCheck = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='namespaces'") + if (namespacesCheck.length === 0) { + issues.push('Namespaces table missing') + } + + return { valid: issues.length === 0, issues } + } catch (error) { + return { valid: false, issues: ['Failed to validate schema: ' + (error as Error).message] } + } +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 6a0dc12..754a5f2 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -186,4 +186,53 @@ export class FlaskStorageAdapter { async migrateToIndexedDB(): Promise { return this.getAllSnippets() } + + async getAllNamespaces(): Promise { + if (!this.isValidUrl()) { + throw new Error('Invalid Flask backend URL') + } + const response = await fetch(`${this.baseUrl}/api/namespaces`) + if (!response.ok) { + throw new Error(`Failed to fetch namespaces: ${response.statusText}`) + } + return await response.json() + } + + async createNamespace(namespace: import('./types').Namespace): Promise { + if (!this.isValidUrl()) { + throw new Error('Invalid Flask backend URL') + } + const response = await fetch(`${this.baseUrl}/api/namespaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(namespace) + }) + if (!response.ok) { + throw new Error(`Failed to create namespace: ${response.statusText}`) + } + } + + async deleteNamespace(id: string): Promise { + if (!this.isValidUrl()) { + throw new Error('Invalid Flask backend URL') + } + const response = await fetch(`${this.baseUrl}/api/namespaces/${id}`, { + method: 'DELETE' + }) + if (!response.ok) { + throw new Error(`Failed to delete namespace: ${response.statusText}`) + } + } + + async wipeDatabase(): Promise { + if (!this.isValidUrl()) { + throw new Error('Invalid Flask backend URL') + } + const response = await fetch(`${this.baseUrl}/api/wipe`, { + method: 'POST' + }) + if (!response.ok) { + throw new Error(`Failed to wipe database: ${response.statusText}`) + } + } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 7aa4999..f8e5a0e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -4,8 +4,8 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Database, Download, Upload, Trash, CloudArrowUp, CloudCheck, CloudSlash } from '@phosphor-icons/react' -import { getDatabaseStats, exportDatabase, importDatabase, clearDatabase, seedDatabase, getAllSnippets } from '@/lib/db' +import { Database, Download, Upload, Trash, CloudArrowUp, CloudCheck, CloudSlash, FirstAid, CheckCircle, Warning } from '@phosphor-icons/react' +import { getDatabaseStats, exportDatabase, importDatabase, clearDatabase, seedDatabase, getAllSnippets, validateDatabaseSchema } from '@/lib/db' import { toast } from 'sonner' import { Alert, AlertDescription } from '@/components/ui/alert' import { @@ -30,6 +30,8 @@ export function SettingsPage() { const [flaskConnectionStatus, setFlaskConnectionStatus] = useState<'unknown' | 'connected' | 'failed'>('unknown') const [testingConnection, setTestingConnection] = useState(false) const [envVarSet, setEnvVarSet] = useState(false) + const [schemaHealth, setSchemaHealth] = useState<'unknown' | 'healthy' | 'corrupted'>('unknown') + const [checkingSchema, setCheckingSchema] = useState(false) const loadStats = async () => { setLoading(true) @@ -60,8 +62,26 @@ export function SettingsPage() { } } + const checkSchemaHealth = async () => { + setCheckingSchema(true) + try { + const result = await validateDatabaseSchema() + setSchemaHealth(result.valid ? 'healthy' : 'corrupted') + + if (!result.valid) { + console.warn('Schema validation failed:', result.issues) + } + } catch (error) { + console.error('Schema check failed:', error) + setSchemaHealth('corrupted') + } finally { + setCheckingSchema(false) + } + } + useEffect(() => { loadStats() + checkSchemaHealth() const config = loadStorageConfig() const envFlaskUrl = import.meta.env.VITE_FLASK_BACKEND_URL @@ -116,8 +136,9 @@ export function SettingsPage() { try { await clearDatabase() - toast.success('Database cleared successfully') + toast.success('Database cleared and schema recreated successfully') await loadStats() + await checkSchemaHealth() } catch (error) { console.error('Failed to clear:', error) toast.error('Failed to clear database') @@ -247,6 +268,51 @@ export function SettingsPage() {
+ {schemaHealth === 'corrupted' && ( + + + + + Schema Corruption Detected + + + Your database schema is outdated or corrupted and needs to be repaired + + + + + + The database schema is missing required tables or columns (likely due to namespace feature addition). + This can cause errors when loading or saving snippets. Click the button below to wipe and recreate the database with the correct schema. + + +
+ + +
+
+
+ )} + + {schemaHealth === 'healthy' && ( + + + + + Schema Healthy + + + Your database schema is up to date and functioning correctly + + + + )} + {envVarSet && (