import 'dart:io' show Platform, Directory; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const settingsDir = '.journaler'; const dbFileName = 'journaler.db'; class DB { static late Database db; static const String _schema = ''' CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT DEFAULT CURRENT_TIMESTAMP, content TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_notes_date ON notes (date); CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_date_unique ON notes (date); -- Todos are "static", we are only interested in the latest entry -- ie. they're not really a list but instead a string -- But we will also keep a history of all todos -- Because we might as well CREATE TABLE IF NOT EXISTS scratches ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT DEFAULT CURRENT_TIMESTAMP, content TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_scratches_date ON scratches (date); CREATE UNIQUE INDEX IF NOT EXISTS idx_scratches_date_unique ON scratches (date); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ); -- Create virtual FTS5 table for searching notes content CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( content, date, content='notes', content_rowid='id' ); -- Trigger to keep FTS table in sync with notes table when inserting CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN INSERT INTO notes_fts(rowid, content, date) VALUES (new.id, new.content, new.date); END; -- Trigger to keep FTS table in sync when deleting notes CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN DELETE FROM notes_fts WHERE rowid = old.id; END; -- Trigger to keep FTS table in sync when updating notes CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN UPDATE notes_fts SET content = new.content, date = new.date WHERE rowid = old.id; END; '''; static Future _getDatabasePath() async { debugPrint('Attempting to get database path...'); if (Platform.isWindows || Platform.isLinux) { // Get user's home directory final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null) { throw Exception('Could not find home directory'); } debugPrint('Home directory found: $home'); final dbDir = Directory(path.join(home, settingsDir)); if (!await dbDir.exists()) { await dbDir.create(recursive: true); debugPrint('$settingsDir directory created'); } else { debugPrint('$settingsDir directory already exists'); } return path.join(dbDir.path, dbFileName); } else { // Default path for other platforms final databasesPath = await databaseFactoryFfi.getDatabasesPath(); debugPrint('Using default databases path: $databasesPath'); return path.join(databasesPath, dbFileName); } } static Future init() async { debugPrint('Starting database initialization...'); sqfliteFfiInit(); final dbPath = await _getDatabasePath(); debugPrint('Database path: $dbPath'); try { db = await databaseFactoryFfi.openDatabase( dbPath, options: OpenDatabaseOptions( version: 1, onCreate: (db, version) async { debugPrint('Creating database schema...'); await db.execute(_schema); debugPrint('Database schema created successfully'); }, ), ); debugPrint('Database opened and initialized'); } catch (e) { debugPrint('Failed to initialize database: $e'); rethrow; } } // Settings Management static Future getSetting(String key) async { final List> maps = await db.query( 'settings', columns: ['value'], where: 'key = ?', whereArgs: [key], ); if (maps.isNotEmpty) { return maps.first['value'] as String?; } return null; } static Future setSetting(String key, String value) async { await db.insert('settings', { 'key': key, 'value': value, }, conflictAlgorithm: ConflictAlgorithm.replace); debugPrint("Setting updated: $key = $value"); } // Search notes using FTS static Future>> searchNotes(String query) async { try { if (query.trim().isEmpty) { return []; } // Process the query for partial word matching // Split into individual terms, filter empty ones List terms = query .trim() .split(RegExp(r'\s+')) .where((term) => term.isNotEmpty) .toList(); if (terms.isEmpty) { return []; } // Add wildcards to each term for prefix matching (e.g., "fuck*" will match "fucked") // Join terms with AND for all-term matching (results must contain ALL terms) String ftsQuery = terms .map((term) { // Remove any special characters that might break the query String sanitizedTerm = term.replaceAll(RegExp(r'[^\w]'), ''); if (sanitizedTerm.isEmpty) return ''; // Add wildcard for stemming/prefix matching return '$sanitizedTerm*'; }) .where((term) => term.isNotEmpty) .join(' AND '); if (ftsQuery.isEmpty) { debugPrint('Query was sanitized to empty string'); return []; } debugPrint('FTS query: "$ftsQuery"'); // Execute the FTS query with AND logic final List> results = await db.rawQuery( ''' SELECT n.id, n.date, n.content, snippet(notes_fts, 0, '', '', '...', 20) as snippet FROM notes_fts JOIN notes n ON notes_fts.rowid = n.id WHERE notes_fts MATCH ? ORDER BY n.date DESC LIMIT 100 ''', [ftsQuery], ); debugPrint('Search query "$ftsQuery" returned ${results.length} results'); return results; } catch (e) { debugPrint('Search failed: $e'); // Return empty results rather than crashing on malformed queries return []; } } }