Files
journaler/lib/db.dart
2025-05-16 15:32:58 +02:00

574 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io' show Platform, Directory, File;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
const settingsDir = '.journaler';
const dbFileName = 'journaler.db';
// Add this at the top level
typedef ShowMessageCallback = void Function(String message);
class DB {
static late Database db;
static bool _hasIcuSupport = false;
static ShowMessageCallback? _showMessage;
// Add this to register the callback
static void registerMessageCallback(ShowMessageCallback callback) {
_showMessage = callback;
}
// Add this method to check ICU status
static bool get hasIcuSupport => _hasIcuSupport;
static const String _baseSchema = '''
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);
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
);
''';
static const String _ftsSchemaWithIcu = '''
-- Create virtual FTS5 table with Unicode tokenizer
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
content,
date,
content='notes',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2 tokenchars "\u0401\u0451\u0410-\u044f"'
);
''';
static const String _ftsSchemaBasic = '''
-- Create virtual FTS5 table with basic tokenizer
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
content,
date,
content='notes',
content_rowid='id',
tokenize='unicode61'
);
''';
static const String _ftsTriggers = '''
-- 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<String?> _getIcuExtensionPath() async {
if (!Platform.isWindows) {
debugPrint('Not on Windows, skipping ICU');
return null;
}
try {
// Get the executable's directory
final exePath = Platform.resolvedExecutable;
final exeDir = path.dirname(exePath);
final currentDir = Directory.current.path;
debugPrint('\n================== ICU EXTENSION SETUP ==================');
debugPrint('Current working directory: $currentDir');
debugPrint('Executable path: $exePath');
debugPrint('Executable directory: $exeDir');
// In release mode, we want the DLL next to the exe
final targetPath = path.join(exeDir, 'sqlite3_icu.dll');
final targetFile = File(targetPath);
// If the DLL doesn't exist in the target location, copy it from assets
if (!await targetFile.exists()) {
debugPrint(
'DLL not found at target location, attempting to copy from assets...',
);
try {
// Load from assets and write to target location
final assetPath = 'assets/windows/sqlite3_icu.dll';
debugPrint('Trying to load from asset path: $assetPath');
final data = await rootBundle.load(assetPath);
debugPrint(
'Asset loaded successfully, size: ${data.lengthInBytes} bytes',
);
debugPrint('Writing to target path: $targetPath');
await targetFile.writeAsBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
debugPrint('Successfully copied DLL to: $targetPath');
} catch (e, stackTrace) {
debugPrint('Failed to copy DLL from assets:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
// Try to list available assets for debugging
try {
final manifestContent = await rootBundle.loadString(
'AssetManifest.json',
);
debugPrint('Available assets in manifest:');
debugPrint(manifestContent);
} catch (e) {
debugPrint('Could not load asset manifest: $e');
}
return null;
}
} else {
debugPrint('Found existing DLL at: $targetPath');
}
debugPrint('Verifying DLL exists at: $targetPath');
if (await targetFile.exists()) {
final fileSize = await targetFile.length();
debugPrint('DLL exists, size: $fileSize bytes');
} else {
debugPrint('DLL does not exist at target path!');
}
debugPrint('=====================================================\n');
return targetPath;
} catch (e, stackTrace) {
debugPrint('Error setting up ICU extension:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
debugPrint('=====================================================\n');
return null;
}
}
// Add this method to check ICU status with visible feedback
static Future<bool> checkAndShowIcuStatus() async {
final status = _hasIcuSupport;
final message =
status
? 'ICU support is enabled - Full Unicode search available'
: 'ICU support is not available - Unicode search will be limited\n'
'Looking for sqlite3_icu.dll in the application directory';
debugPrint(message);
_showMessage?.call(message);
return status;
}
static Future<bool> _checkIcuSupport(Database db) async {
try {
debugPrint(
'\n================== CHECKING UNICODE SUPPORT ==================',
);
// Test Unicode support with a simple query
try {
debugPrint('Testing Unicode support...');
// Test with some Cyrillic characters
await db.rawQuery("SELECT 'тест' LIKE '%ест%'");
await db.rawQuery("SELECT 'ТЕСТ' LIKE '%ЕСТ%'");
final message = 'Unicode support is available';
debugPrint(message);
_showMessage?.call(message);
return true;
} catch (e, stackTrace) {
final message = 'Unicode support test failed';
debugPrint('$message:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
// Try to get SQLite version and compile options for debugging
try {
final version = await db.rawQuery('SELECT sqlite_version()');
final compileOpts = await db.rawQuery('PRAGMA compile_options');
debugPrint('SQLite version: $version');
debugPrint('SQLite compile options: $compileOpts');
} catch (e) {
debugPrint('Could not get SQLite info: $e');
}
_showMessage?.call(message);
return false;
}
} catch (e, stackTrace) {
final message = 'Failed to test Unicode support';
debugPrint('$message:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
_showMessage?.call(message);
return false;
} finally {
debugPrint('=====================================================\n');
}
}
static Future<void> _recreateFtsTable(Database db, bool useIcu) async {
debugPrint('Updating FTS table configuration...');
try {
// Start a transaction to ensure data safety
await db.transaction((txn) async {
// First, create a temporary table with the new configuration
final tempTableName = 'notes_fts_temp';
final schema = useIcu ? _ftsSchemaWithIcu : _ftsSchemaBasic;
// Create temp table with new configuration
await txn.execute(schema.replaceAll('notes_fts', tempTableName));
// Copy data from old FTS table if it exists
try {
debugPrint('Copying existing FTS data to temporary table...');
await txn.execute('''
INSERT INTO $tempTableName(rowid, content, date)
SELECT rowid, content, date FROM notes_fts
''');
debugPrint('Data copied successfully');
} catch (e) {
debugPrint('No existing FTS data to copy: $e');
}
// Drop old triggers
debugPrint('Updating triggers...');
await txn.execute('DROP TRIGGER IF EXISTS notes_ai');
await txn.execute('DROP TRIGGER IF EXISTS notes_ad');
await txn.execute('DROP TRIGGER IF EXISTS notes_au');
// Drop old FTS table
await txn.execute('DROP TABLE IF EXISTS notes_fts');
// Rename temp table to final name
await txn.execute('ALTER TABLE $tempTableName RENAME TO notes_fts');
// Create new triggers
await txn.execute(_ftsTriggers);
// Rebuild FTS index from notes table to ensure consistency
debugPrint('Rebuilding FTS index from notes table...');
await txn.execute('''
INSERT OR REPLACE INTO notes_fts(rowid, content, date)
SELECT id, content, date FROM notes
''');
debugPrint('FTS table update completed successfully');
});
} catch (e, stackTrace) {
debugPrint('Error updating FTS table:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
static Future<String> _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<void> init() async {
debugPrint('Starting database initialization...');
// Initialize SQLite FFI
sqfliteFfiInit();
final databaseFactory = databaseFactoryFfi;
// Create a temporary database to check version
try {
debugPrint(
'\n================== SQLITE VERSION CHECK ==================',
);
final tempDb = await databaseFactory.openDatabase(
':memory:',
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
final results = await db.rawQuery('SELECT sqlite_version()');
debugPrint('SQLite version: ${results.first.values.first}');
final compileOpts = await db.rawQuery('PRAGMA compile_options');
debugPrint('SQLite compile options:');
for (var opt in compileOpts) {
debugPrint(' ${opt.values.first}');
}
},
),
);
await tempDb.close();
debugPrint('=====================================================\n');
} catch (e, stackTrace) {
debugPrint('Error checking SQLite version:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
}
await databaseFactory.setDatabasesPath(
await databaseFactory.getDatabasesPath(),
);
final dbPath = await _getDatabasePath();
debugPrint('Database path: $dbPath');
try {
db = await databaseFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: 2,
onConfigure: (db) async {
debugPrint('Configuring database...');
await db.execute('PRAGMA foreign_keys = ON');
debugPrint('Database configured');
},
onCreate: (db, version) async {
debugPrint('Creating database schema...');
await db.execute(_baseSchema);
// Check for Unicode support on first creation
_hasIcuSupport = await _checkIcuSupport(db);
await _recreateFtsTable(db, _hasIcuSupport);
debugPrint('Database schema created successfully');
},
onOpen: (db) async {
debugPrint('Database opened, checking Unicode support...');
try {
_hasIcuSupport = await _checkIcuSupport(db);
debugPrint('Unicode support check completed: $_hasIcuSupport');
} catch (e, stackTrace) {
debugPrint('Error during Unicode support check:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
},
onUpgrade: (db, oldVersion, newVersion) async {
debugPrint('Upgrading database from $oldVersion to $newVersion');
if (oldVersion < 2) {
// Check for Unicode support during upgrade
_hasIcuSupport = await _checkIcuSupport(db);
await _recreateFtsTable(db, _hasIcuSupport);
}
},
),
);
// Store Unicode support status in settings for future reference
await setSetting('has_icu_support', _hasIcuSupport.toString());
debugPrint(
'Database opened and initialized (Unicode support: $_hasIcuSupport)',
);
} catch (e, stackTrace) {
debugPrint('Failed to initialize database:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
// Settings Management
static Future<String?> getSetting(String key) async {
final List<Map<String, dynamic>> maps = await db.query(
'settings',
columns: ['value'],
where: 'key = ?',
whereArgs: [key],
);
if (maps.isNotEmpty) {
return maps.first['value'] as String?;
}
return null;
}
static Future<void> 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<List<Map<String, dynamic>>> searchNotes(String query) async {
try {
if (query.trim().isEmpty) {
return [];
}
// Split into individual terms, filter empty ones
List<String> terms = query
.trim()
.split(RegExp(r'\s+'))
.where((term) => term.isNotEmpty)
.toList();
if (terms.isEmpty) {
return [];
}
// Create the FTS match expression for each term
List<String> matchExpressions = terms.map((term) {
// Remove dangerous characters but preserve Unicode
String sanitizedTerm = term.replaceAll(RegExp(r'''['\\]'''), '');
if (sanitizedTerm.isEmpty) return '';
// Escape special characters in the term for the LIKE pattern
String escapedTerm = sanitizedTerm
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
// Use LIKE for prefix/suffix matching
return '''(
content LIKE '%' || ? || '%' COLLATE NOCASE OR
content LIKE ? || '%' COLLATE NOCASE OR
content LIKE '%' || ? COLLATE NOCASE
)''';
}).where((expr) => expr.isNotEmpty).toList();
if (matchExpressions.isEmpty) {
debugPrint('Query was sanitized to empty string');
return [];
}
// Combine all terms with AND logic
String whereClause = matchExpressions.join(' AND ');
// Create the parameter list (each term needs to be repeated 3 times for the different LIKE patterns)
List<String> parameters = [];
for (String term in terms) {
String sanitizedTerm = term.replaceAll(RegExp(r'''['\\]'''), '');
if (sanitizedTerm.isNotEmpty) {
parameters.addAll([sanitizedTerm, sanitizedTerm, sanitizedTerm]);
}
}
debugPrint('Search query: "$query" with ${terms.length} terms');
debugPrint('Where clause: $whereClause');
debugPrint('Parameters: $parameters');
// Execute the search query
final List<Map<String, dynamic>> results = await db.rawQuery(
'''
SELECT n.id, n.date, n.content
FROM notes n
WHERE $whereClause
ORDER BY n.date DESC
LIMIT 100
''',
parameters,
);
// Add snippets with highlighting in Dart code
final processedResults = results.map((row) {
final content = row['content'] as String;
final snippet = _createSnippet(content, terms);
return {
...row,
'snippet': snippet,
};
}).toList();
debugPrint('Search returned ${results.length} results');
return processedResults;
} catch (e, stackTrace) {
debugPrint('Search failed: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
// Helper function to create a snippet with highlighted terms
static String _createSnippet(String content, List<String> terms) {
const int snippetLength = 150; // Maximum length of the snippet
const String ellipsis = '...';
// Find the first match position
int firstMatchPos = content.length;
String? firstMatchTerm;
for (final term in terms) {
final pos = content.toLowerCase().indexOf(term.toLowerCase());
if (pos != -1 && pos < firstMatchPos) {
firstMatchPos = pos;
firstMatchTerm = term;
}
}
if (firstMatchTerm == null) {
// No matches found, return start of content
return content.length <= snippetLength
? content
: content.substring(0, snippetLength) + ellipsis;
}
// Calculate snippet range around the first match
int start = (firstMatchPos - snippetLength ~/ 3).clamp(0, content.length);
int end = (start + snippetLength).clamp(0, content.length);
// Adjust start to not cut words
if (start > 0) {
start = content.lastIndexOf(RegExp(r'\s'), start) + 1;
}
// Create the snippet
String snippet = content.substring(start, end);
if (start > 0) snippet = ellipsis + snippet;
if (end < content.length) snippet = snippet + ellipsis;
// Highlight all term matches in the snippet
for (final term in terms) {
final pattern = RegExp(RegExp.escape(term), caseSensitive: false);
snippet = snippet.replaceAllMapped(pattern,
(match) => '<b>${match.group(0)}</b>');
}
return snippet;
}
}