Compare commits
27 Commits
v2.0.0
...
d6f9fa5032
Author | SHA1 | Date | |
---|---|---|---|
d6f9fa5032 | |||
64884cbb42 | |||
29f6f28f12 | |||
fadd9a7387 | |||
716a02a1dc | |||
6eb55c5d50 | |||
3c1f31d29b | |||
dfe1c2b34c | |||
89f8889f1e | |||
597ce8c9cf | |||
c2202bdfef | |||
8daf7ed6bf | |||
4339763261 | |||
621e85c747 | |||
d937ae212c | |||
b5dad28491 | |||
1e11cd53c9 | |||
b74dc5b3c6 | |||
474cb662e5 | |||
a84973def6 | |||
fcb8e41cde | |||
4f54b89689 | |||
eb2ec79150 | |||
e8b9f0ba49 | |||
6c8340d768 | |||
db565f4603 | |||
b2bff9e5c5 |
97
README.md
97
README.md
@@ -1,16 +1,93 @@
|
|||||||
# journaler
|
# Journaler
|
||||||
|
|
||||||
A new Flutter project.
|

|
||||||
|
|
||||||
## Getting Started
|
Journaler is a "cross-platform" desktop application designed to encourage regular journaling by providing automated reminders and a clean writing environment.
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
## Overview
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
Journaler helps you build a consistent journaling habit by periodically reminding you to record your thoughts throughout the day. It runs silently in the system tray and pops up at customizable intervals, making it perfect for:
|
||||||
|
|
||||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
- **Daily reflection and mindfulness practice**
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
- **Tracking your thoughts and activities**
|
||||||
|
- **Building a journaling habit**
|
||||||
|
- **Capturing ideas before they fade**
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|

|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
## Note
|
||||||
|
|
||||||
|
**Versions up to 3 use a local sqlite database while versions 4 and above use meillisearch!**
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automated Reminders**: Customizable popup intervals to remind you to journal
|
||||||
|
- **System Tray Integration**: Runs silently in the background
|
||||||
|
- **Scratch Pad**: Quick notes area for temporary thoughts
|
||||||
|
- **Journal History**: Browse through past entries
|
||||||
|
- **Full-Text Search**: Find entries containing specific words or phrases
|
||||||
|
- **Dark/Light Theme**: Automatically follows your system theme
|
||||||
|
- **Notification Sounds**: Audio cues when it's time to journal
|
||||||
|
- **Single Instance**: Only one copy of the app runs at a time
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. **First Launch**: When you first launch Journaler, it will start in the system tray
|
||||||
|
2. **Writing Entries**: The app will automatically pop up at set intervals (default: 20 minutes)
|
||||||
|
3. **Manual Access**: Click the system tray icon to show the app manually
|
||||||
|
4. **"Multiple" instances**: Running another instance will make the existing instance show itself
|
||||||
|
5. **Saving entries**: Entries are saved automatically when the popup is closed either manually or by hitting the escape key
|
||||||
|
|
||||||
|
### Main Interface
|
||||||
|
|
||||||
|
1. **Current Entry**: Write your thoughts for the current moment
|
||||||
|
2. **Previous Entry**: View what you wrote last time
|
||||||
|
3. **Scratch Pad**: Space for quick notes that persist between sessions
|
||||||
|
4. **Navigation**: Browse through your past entries
|
||||||
|
5. **Search**: Find specific content in your journal history (May also be invoked by CTRL-F)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
You can adjust:
|
||||||
|
|
||||||
|
- **Popup Interval**: Change how frequently Journaler reminds you
|
||||||
|
- **Notification Sound**: Select your preferred audio reminder
|
||||||
|
- **Volume**: Adjust the sound level of reminders
|
||||||
|
|
||||||
|
## Data privacy
|
||||||
|
|
||||||
|
All your journal entries are stored securely on your local machine:
|
||||||
|
|
||||||
|
- **Windows**: `C:\Users\YourUsername\.journaler\`
|
||||||
|
|
||||||
|
No network requests are made, no data sent or received, your data is yours alone.
|
||||||
|
|
||||||
|
Data is stored in an SQLite database with a very simple schema that can be fucked with easily.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
This application is built with Flutter. If you want to build from source:
|
||||||
|
|
||||||
|
1. Install Flutter (version 3.7.2 or higher)
|
||||||
|
2. Clone the repository
|
||||||
|
3. Run `flutter pub get` to install dependencies
|
||||||
|
4. Use `flutter run` to run the application in debug mode
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
I don't know whether or not the sound customization works.<br>
|
||||||
|
In theory only 2 sound files are bundled with the app so, in theory, you may only choose between those two.<br>
|
||||||
|
But I don't know how flutter handles assets, currently I am not very motivated to figure this out.
|
||||||
|
|
||||||
|
Scrolling through long notes is difficult since we're intercepting the scroll event and using it to scroll through notes themselves.<br>
|
||||||
|
This, again, is not really an issue for me so I'm not very motivated to fix it.
|
||||||
|
|
||||||
|
Showing and focusing the window is really annoying for "gamers" and generally whoever is doing anything at the time.<br>
|
||||||
|
But I have no better ideas.<br>
|
||||||
|
If we show the window without focusing it, it will still be on top and, if not focused, will be even more annoying because to close it you have to click on it (to focus it) and THEN close it.<br>
|
||||||
|
If we only play the sound as a reminder we could miss it or simply get used to the sound and learn to ignore it.<br>
|
||||||
|
I have no ideas for a better solution.
|
||||||
|
BIN
assets/windows/sqlite3_icu.dll
Normal file
BIN
assets/windows/sqlite3_icu.dll
Normal file
Binary file not shown.
BIN
docs/screenshots/journaler_annotated.png
(Stored with Git LFS)
Normal file
BIN
docs/screenshots/journaler_annotated.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
docs/screenshots/journaler_main.png
(Stored with Git LFS)
Normal file
BIN
docs/screenshots/journaler_main.png
(Stored with Git LFS)
Normal file
Binary file not shown.
272
lib/db.dart
272
lib/db.dart
@@ -6,10 +6,23 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|||||||
const settingsDir = '.journaler';
|
const settingsDir = '.journaler';
|
||||||
const dbFileName = 'journaler.db';
|
const dbFileName = 'journaler.db';
|
||||||
|
|
||||||
|
// Add this at the top level
|
||||||
|
typedef ShowMessageCallback = void Function(String message);
|
||||||
|
|
||||||
class DB {
|
class DB {
|
||||||
static late Database db;
|
static late Database db;
|
||||||
|
static bool _hasIcuSupport = false;
|
||||||
|
static ShowMessageCallback? _showMessage;
|
||||||
|
|
||||||
static const String _schema = '''
|
// 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 (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
date TEXT DEFAULT CURRENT_TIMESTAMP,
|
date TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -17,10 +30,7 @@ CREATE TABLE IF NOT EXISTS notes (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_date ON notes (date);
|
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 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 (
|
CREATE TABLE IF NOT EXISTS scratches (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
date TEXT DEFAULT CURRENT_TIMESTAMP,
|
date TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -33,15 +43,31 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
key TEXT PRIMARY KEY NOT NULL,
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
''';
|
||||||
|
|
||||||
-- Create virtual FTS5 table for searching notes content
|
static const String _ftsSchemaWithIcu = '''
|
||||||
|
-- Create virtual FTS5 table with Unicode tokenizer
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
||||||
content,
|
content,
|
||||||
date,
|
date,
|
||||||
content='notes',
|
content='notes',
|
||||||
content_rowid='id'
|
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
|
-- Trigger to keep FTS table in sync with notes table when inserting
|
||||||
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
|
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);
|
INSERT INTO notes_fts(rowid, content, date) VALUES (new.id, new.content, new.date);
|
||||||
@@ -58,6 +84,124 @@ CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
|
|||||||
END;
|
END;
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
// 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 {
|
static Future<String> _getDatabasePath() async {
|
||||||
debugPrint('Attempting to get database path...');
|
debugPrint('Attempting to get database path...');
|
||||||
if (Platform.isWindows || Platform.isLinux) {
|
if (Platform.isWindows || Platform.isLinux) {
|
||||||
@@ -88,26 +232,100 @@ END;
|
|||||||
|
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
debugPrint('Starting database initialization...');
|
debugPrint('Starting database initialization...');
|
||||||
|
|
||||||
|
// Initialize SQLite FFI
|
||||||
sqfliteFfiInit();
|
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();
|
final dbPath = await _getDatabasePath();
|
||||||
debugPrint('Database path: $dbPath');
|
debugPrint('Database path: $dbPath');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db = await databaseFactoryFfi.openDatabase(
|
db = await databaseFactory.openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
options: OpenDatabaseOptions(
|
options: OpenDatabaseOptions(
|
||||||
version: 1,
|
version: 2,
|
||||||
|
onConfigure: (db) async {
|
||||||
|
debugPrint('Configuring database...');
|
||||||
|
await db.execute('PRAGMA foreign_keys = ON');
|
||||||
|
debugPrint('Database configured');
|
||||||
|
},
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
debugPrint('Creating database schema...');
|
debugPrint('Creating database schema...');
|
||||||
await db.execute(_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');
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
debugPrint('Database opened and initialized');
|
|
||||||
} catch (e) {
|
// Store Unicode support status in settings for future reference
|
||||||
debugPrint('Failed to initialize database: $e');
|
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;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +359,6 @@ END;
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the query for partial word matching
|
|
||||||
// Split into individual terms, filter empty ones
|
// Split into individual terms, filter empty ones
|
||||||
List<String> terms =
|
List<String> terms =
|
||||||
query
|
query
|
||||||
@@ -154,16 +371,16 @@ END;
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add wildcards to each term for prefix matching (e.g., "fuck*" will match "fucked")
|
// Process terms for FTS5 query using proper tokenization
|
||||||
// Join terms with AND for all-term matching (results must contain ALL terms)
|
|
||||||
String ftsQuery = terms
|
String ftsQuery = terms
|
||||||
.map((term) {
|
.map((term) {
|
||||||
// Remove any special characters that might break the query
|
// Remove dangerous characters but preserve Unicode
|
||||||
String sanitizedTerm = term.replaceAll(RegExp(r'[^\w]'), '');
|
String sanitizedTerm = term.replaceAll(RegExp(r'''['"]'''), '');
|
||||||
if (sanitizedTerm.isEmpty) return '';
|
if (sanitizedTerm.isEmpty) return '';
|
||||||
|
|
||||||
// Add wildcard for stemming/prefix matching
|
// Use proper FTS5 syntax: each word becomes a separate token with prefix matching
|
||||||
return '$sanitizedTerm*';
|
List<String> words = sanitizedTerm.split(RegExp(r'\s+'));
|
||||||
|
return words.map((word) => '$word*').join(' OR ');
|
||||||
})
|
})
|
||||||
.where((term) => term.isNotEmpty)
|
.where((term) => term.isNotEmpty)
|
||||||
.join(' AND ');
|
.join(' AND ');
|
||||||
@@ -173,26 +390,27 @@ END;
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ftsQuery = ftsQuery.replaceAll('-', ' ');
|
||||||
debugPrint('FTS query: "$ftsQuery"');
|
debugPrint('FTS query: "$ftsQuery"');
|
||||||
|
|
||||||
// Execute the FTS query with AND logic
|
// Execute the FTS query
|
||||||
final List<Map<String, dynamic>> results = await db.rawQuery(
|
final List<Map<String, dynamic>> results = await db.rawQuery(
|
||||||
'''
|
'''
|
||||||
SELECT n.id, n.date, n.content, snippet(notes_fts, 0, '<b>', '</b>', '...', 20) as snippet
|
SELECT n.id, n.date, n.content,
|
||||||
|
snippet(notes_fts, -1, '<b>', '</b>', '...', 64) as snippet
|
||||||
FROM notes_fts
|
FROM notes_fts
|
||||||
JOIN notes n ON notes_fts.rowid = n.id
|
JOIN notes n ON notes_fts.rowid = n.id
|
||||||
WHERE notes_fts MATCH ?
|
WHERE notes_fts MATCH ?
|
||||||
ORDER BY n.date DESC
|
ORDER BY rank
|
||||||
LIMIT 100
|
|
||||||
''',
|
''',
|
||||||
[ftsQuery],
|
[ftsQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('Search query "$ftsQuery" returned ${results.length} results');
|
debugPrint('Search returned ${results.length} results');
|
||||||
return results;
|
return results;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('Search failed: $e');
|
debugPrint('Search failed: $e');
|
||||||
// Return empty results rather than crashing on malformed queries
|
debugPrint('Stack trace: $stackTrace');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
753
lib/main.dart
753
lib/main.dart
@@ -1,13 +1,19 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:journaler/db.dart';
|
import 'package:journaler/db.dart';
|
||||||
import 'package:journaler/notes.dart';
|
import 'package:journaler/notes.dart';
|
||||||
|
import 'package:journaler/utils.dart';
|
||||||
import 'package:system_tray/system_tray.dart';
|
import 'package:system_tray/system_tray.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:ps_list/ps_list.dart';
|
||||||
|
import 'package:journaler/meilisearch.dart';
|
||||||
|
import 'package:journaler/meilisearch_config.dart';
|
||||||
|
|
||||||
// TODO: Sound does not play when ran from a different workdir? Weird
|
// TODO: Sound does not play when ran from a different workdir? Weird
|
||||||
// TODO: Fix saving the same scratch over and over again
|
// TODO: Fix saving the same scratch over and over again
|
||||||
@@ -16,9 +22,61 @@ import 'dart:math';
|
|||||||
const Duration _defaultPopupInterval = Duration(minutes: 20);
|
const Duration _defaultPopupInterval = Duration(minutes: 20);
|
||||||
const String _defaultNotificationSound = 'MeetTheSniper.mp3';
|
const String _defaultNotificationSound = 'MeetTheSniper.mp3';
|
||||||
|
|
||||||
|
// Flag to indicate if there's a pending show request from a secondary instance
|
||||||
|
bool _pendingShowRequest = false;
|
||||||
|
|
||||||
|
// Global navigator key to access the navigator state from anywhere
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
/// Paths for IPC file in system temp directory
|
||||||
|
class AppFiles {
|
||||||
|
static String get ipcFilePath =>
|
||||||
|
path.join(Directory.systemTemp.path, 'journaler_ipc.txt');
|
||||||
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
await DB.init();
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
final ipcFile = File(AppFiles.ipcFilePath);
|
||||||
|
|
||||||
|
if (await alreadyRunning()) {
|
||||||
|
await signalPrimaryInstanceAndExit(ipcFile);
|
||||||
|
} else {
|
||||||
|
await runPrimaryInstance(ipcFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> alreadyRunning() async {
|
||||||
|
final processes = await PSList.getRunningProcesses();
|
||||||
|
final executable = Platform.resolvedExecutable;
|
||||||
|
final executableName = path.basename(executable);
|
||||||
|
final journalers =
|
||||||
|
processes.where((process) => process == executableName).toList();
|
||||||
|
debugPrint("Journalers: $journalers");
|
||||||
|
return journalers.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the primary instance of the application
|
||||||
|
Future<void> runPrimaryInstance(File ipcFile) async {
|
||||||
|
debugPrint("Starting as primary instance");
|
||||||
|
|
||||||
|
// Create or clear the IPC file
|
||||||
|
if (!await ipcFile.exists()) {
|
||||||
|
await ipcFile.create();
|
||||||
|
}
|
||||||
|
// Start a watcher for the IPC file to detect signals from other instances
|
||||||
|
startIpcWatcher(ipcFile);
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
try {
|
||||||
|
// Initialize Meilisearch first
|
||||||
|
await init();
|
||||||
|
debugPrint('Meilisearch initialized successfully');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error initializing Meilisearch: $e');
|
||||||
|
// Continue anyway - the app will work with default values
|
||||||
|
}
|
||||||
|
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
|
|
||||||
WindowOptions windowOptions = const WindowOptions(
|
WindowOptions windowOptions = const WindowOptions(
|
||||||
@@ -34,6 +92,114 @@ void main() async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
runApp(const JournalerApp());
|
runApp(const JournalerApp());
|
||||||
|
|
||||||
|
// Set up termination handlers to clean up resources
|
||||||
|
setupTerminationHandlers(ipcFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle termination signals by cleaning up IPC file
|
||||||
|
void setupTerminationHandlers(File ipcFile) {
|
||||||
|
// Handle normal termination
|
||||||
|
ProcessSignal.sigterm.watch().listen((_) async {
|
||||||
|
await cleanupFiles(ipcFile);
|
||||||
|
exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Windows and Linux CTRL+C termination
|
||||||
|
if (Platform.isWindows || Platform.isLinux) {
|
||||||
|
ProcessSignal.sigint.watch().listen((_) async {
|
||||||
|
await cleanupFiles(ipcFile);
|
||||||
|
exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up IPC file
|
||||||
|
Future<void> cleanupFiles(File ipcFile) async {
|
||||||
|
try {
|
||||||
|
if (await ipcFile.exists()) {
|
||||||
|
await ipcFile.delete();
|
||||||
|
}
|
||||||
|
debugPrint("Removed IPC file on app termination");
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error cleaning up files: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the primary instance to show itself, then exit
|
||||||
|
Future<void> signalPrimaryInstanceAndExit(File ipcFile) async {
|
||||||
|
debugPrint(
|
||||||
|
"Another instance is already running. Signaling it to show window.",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the IPC file if it doesn't exist
|
||||||
|
if (!await ipcFile.exists()) {
|
||||||
|
await ipcFile.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a 'show' command with a timestamp
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
await ipcFile.writeAsString('show:$timestamp');
|
||||||
|
debugPrint("Signal sent to primary instance via IPC file");
|
||||||
|
|
||||||
|
// Give the primary instance a moment to process the signal
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error signaling primary instance: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit this instance
|
||||||
|
debugPrint("Exiting secondary instance");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start watching the IPC file for signals from other instances
|
||||||
|
void startIpcWatcher(File ipcFile) {
|
||||||
|
// Initial check of the file content
|
||||||
|
checkIpcFileContent(ipcFile);
|
||||||
|
|
||||||
|
// Set up a file watcher
|
||||||
|
final watcher = ipcFile.watch();
|
||||||
|
watcher.listen((event) {
|
||||||
|
if (event.type == FileSystemEvent.modify) {
|
||||||
|
checkIpcFileContent(ipcFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also set up a periodic check as a fallback
|
||||||
|
Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||||
|
checkIpcFileContent(ipcFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint("IPC file watcher started");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the IPC file content for commands
|
||||||
|
Future<void> checkIpcFileContent(File ipcFile) async {
|
||||||
|
try {
|
||||||
|
if (await ipcFile.exists()) {
|
||||||
|
final content = await ipcFile.readAsString();
|
||||||
|
|
||||||
|
if (content.isNotEmpty && content.startsWith('show:')) {
|
||||||
|
// Clear the file immediately to avoid processing the same command multiple times
|
||||||
|
await ipcFile.writeAsString('');
|
||||||
|
|
||||||
|
// Process the show command
|
||||||
|
if (MainPageState.instance != null) {
|
||||||
|
debugPrint("Received show command - making window visible");
|
||||||
|
MainPageState.instance!.showWindow();
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
"MainPageState not initialized yet - setting pending flag",
|
||||||
|
);
|
||||||
|
_pendingShowRequest = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error processing IPC file: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class JournalerApp extends StatelessWidget {
|
class JournalerApp extends StatelessWidget {
|
||||||
@@ -114,6 +280,7 @@ class JournalerApp extends StatelessWidget {
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: const MainPage(),
|
home: const MainPage(),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigatorKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +293,9 @@ class MainPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MainPageState extends State<MainPage> with WindowListener {
|
class MainPageState extends State<MainPage> with WindowListener {
|
||||||
|
// Static reference to the current instance
|
||||||
|
static MainPageState? instance;
|
||||||
|
|
||||||
final SystemTray _systemTray = SystemTray();
|
final SystemTray _systemTray = SystemTray();
|
||||||
final Menu _menu = Menu();
|
final Menu _menu = Menu();
|
||||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||||
@@ -157,16 +327,31 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Store reference to this instance
|
||||||
|
instance = this;
|
||||||
windowManager.addListener(this);
|
windowManager.addListener(this);
|
||||||
_initSystemTray();
|
_initSystemTray();
|
||||||
_loadData();
|
_loadData();
|
||||||
_loadVolume();
|
_loadVolume();
|
||||||
windowManager.setPreventClose(true);
|
windowManager.setPreventClose(true);
|
||||||
_setWindowConfig();
|
_setWindowConfig();
|
||||||
|
|
||||||
|
// Check if there's a pending show request from another instance
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_pendingShowRequest) {
|
||||||
|
debugPrint("Processing pending show request during initialization");
|
||||||
|
_pendingShowRequest = false;
|
||||||
|
showWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Clear reference to this instance
|
||||||
|
if (instance == this) {
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
windowManager.removeListener(this);
|
windowManager.removeListener(this);
|
||||||
_popupTimer?.cancel();
|
_popupTimer?.cancel();
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
@@ -183,8 +368,9 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowClose() {
|
void onWindowClose() async {
|
||||||
_saveData();
|
// Save data when window is closed
|
||||||
|
await _saveData();
|
||||||
windowManager.hide();
|
windowManager.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +396,7 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
_systemTray.registerSystemTrayEventHandler((eventName) {
|
_systemTray.registerSystemTrayEventHandler((eventName) {
|
||||||
debugPrint("System Tray Event: $eventName");
|
debugPrint("System Tray Event: $eventName");
|
||||||
if (eventName == kSystemTrayEventClick) {
|
if (eventName == kSystemTrayEventClick) {
|
||||||
_showWindow();
|
showWindow();
|
||||||
} else if (eventName == kSystemTrayEventRightClick) {
|
} else if (eventName == kSystemTrayEventRightClick) {
|
||||||
_systemTray.popUpContextMenu();
|
_systemTray.popUpContextMenu();
|
||||||
}
|
}
|
||||||
@@ -220,26 +406,56 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
void _startPopupTimer() {
|
void _startPopupTimer() {
|
||||||
_popupTimer?.cancel();
|
_popupTimer?.cancel();
|
||||||
_popupTimer = Timer.periodic(_currentPopupInterval, (timer) {
|
_popupTimer = Timer.periodic(_currentPopupInterval, (timer) {
|
||||||
_showWindow();
|
showWindow();
|
||||||
});
|
});
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes",
|
"Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showWindow() async {
|
Future<void> showWindow() async {
|
||||||
_loadData();
|
_loadData();
|
||||||
|
try {
|
||||||
bool wasVisible = await windowManager.isVisible();
|
bool wasVisible = await windowManager.isVisible();
|
||||||
|
debugPrint("Current window visibility: $wasVisible");
|
||||||
|
|
||||||
if (!wasVisible) {
|
if (!wasVisible) {
|
||||||
|
// First make sure the window has the right size and position
|
||||||
await windowManager.setSize(const Size(1600, 900));
|
await windowManager.setSize(const Size(1600, 900));
|
||||||
await windowManager.center();
|
await windowManager.center();
|
||||||
|
|
||||||
|
// Now show and focus
|
||||||
|
await windowManager.show();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100)); // Short delay
|
||||||
|
await windowManager.focus();
|
||||||
|
|
||||||
|
// Set input focus
|
||||||
|
_currentEntryFocusNode.requestFocus();
|
||||||
|
|
||||||
|
// Play notification sound
|
||||||
|
await _playSound();
|
||||||
|
|
||||||
|
debugPrint("Window made visible and focused");
|
||||||
|
} else {
|
||||||
|
// Already visible, just focus
|
||||||
|
await windowManager.focus();
|
||||||
|
_currentEntryFocusNode.requestFocus();
|
||||||
|
debugPrint("Window already visible, just focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the window is now visible
|
||||||
|
bool isNowVisible = await windowManager.isVisible();
|
||||||
|
debugPrint("Window visibility after show attempt: $isNowVisible");
|
||||||
|
|
||||||
|
if (!isNowVisible) {
|
||||||
|
debugPrint(
|
||||||
|
"WARNING: Window still not visible after show attempt, trying again",
|
||||||
|
);
|
||||||
await windowManager.show();
|
await windowManager.show();
|
||||||
await windowManager.focus();
|
await windowManager.focus();
|
||||||
_currentEntryFocusNode.requestFocus();
|
}
|
||||||
await _playSound();
|
} catch (e) {
|
||||||
} else {
|
debugPrint("Error showing window: $e");
|
||||||
await windowManager.focus();
|
|
||||||
_currentEntryFocusNode.requestFocus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +492,9 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final prev = await getPreviousNote(_currentlyDisplayedNote!.date);
|
final prev = await getPreviousTo(_currentlyDisplayedNote!.epochTime);
|
||||||
final bool isLatest = _currentlyDisplayedNote!.date == previousNote?.date;
|
final bool isLatest =
|
||||||
|
_currentlyDisplayedNote!.epochTime == previousNote?.epochTime;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_canGoPrevious = prev != null;
|
_canGoPrevious = prev != null;
|
||||||
@@ -288,7 +505,15 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Future<void> _goToPreviousNote() async {
|
Future<void> _goToPreviousNote() async {
|
||||||
if (!_canGoPrevious || _currentlyDisplayedNote == null) return;
|
if (!_canGoPrevious || _currentlyDisplayedNote == null) return;
|
||||||
|
|
||||||
final prevNote = await getPreviousNote(_currentlyDisplayedNote!.date);
|
// Save the current note content before navigating away
|
||||||
|
if (_currentlyDisplayedNote != null) {
|
||||||
|
if (_currentlyDisplayedNote!.content != _previousEntryController.text) {
|
||||||
|
_currentlyDisplayedNote!.content = _previousEntryController.text;
|
||||||
|
await updateNote(_currentlyDisplayedNote!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final prevNote = await getPreviousTo(_currentlyDisplayedNote!.epochTime);
|
||||||
if (prevNote != null) {
|
if (prevNote != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentlyDisplayedNote = prevNote;
|
_currentlyDisplayedNote = prevNote;
|
||||||
@@ -301,7 +526,15 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Future<void> _goToNextNote() async {
|
Future<void> _goToNextNote() async {
|
||||||
if (!_canGoNext || _currentlyDisplayedNote == null) return;
|
if (!_canGoNext || _currentlyDisplayedNote == null) return;
|
||||||
|
|
||||||
final nextNote = await getNextNote(_currentlyDisplayedNote!.date);
|
// Save the current note content before navigating away
|
||||||
|
if (_currentlyDisplayedNote != null) {
|
||||||
|
if (_currentlyDisplayedNote!.content != _previousEntryController.text) {
|
||||||
|
_currentlyDisplayedNote!.content = _previousEntryController.text;
|
||||||
|
await updateNote(_currentlyDisplayedNote!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextNote = await getNextTo(_currentlyDisplayedNote!.epochTime);
|
||||||
if (nextNote != null) {
|
if (nextNote != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentlyDisplayedNote = nextNote;
|
_currentlyDisplayedNote = nextNote;
|
||||||
@@ -312,65 +545,84 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadData() async {
|
void _loadData() async {
|
||||||
String? intervalMinutesStr = await DB.getSetting('popupIntervalMinutes');
|
Duration interval = await getPopupInterval();
|
||||||
String? soundFileStr = await DB.getSetting('notificationSound');
|
String soundFile = await getNotificationSound();
|
||||||
|
|
||||||
int intervalMinutes =
|
_currentPopupInterval = interval;
|
||||||
int.tryParse(intervalMinutesStr ?? '') ??
|
_currentNotificationSound = soundFile;
|
||||||
_defaultPopupInterval.inMinutes;
|
|
||||||
_currentPopupInterval = Duration(minutes: intervalMinutes);
|
|
||||||
_currentNotificationSound = soundFileStr ?? _defaultNotificationSound;
|
|
||||||
|
|
||||||
_intervalController.text = intervalMinutes.toString();
|
_intervalController.text = interval.inMinutes.toString();
|
||||||
_soundController.text = _currentNotificationSound;
|
_soundController.text = _currentNotificationSound;
|
||||||
|
|
||||||
_startPopupTimer();
|
_startPopupTimer();
|
||||||
|
|
||||||
final note = await getLatestNote();
|
final note = await getLatest();
|
||||||
previousNote = note;
|
previousNote = note;
|
||||||
_currentlyDisplayedNote = note;
|
_currentlyDisplayedNote = note;
|
||||||
_previousEntryController.text = _currentlyDisplayedNote?.content ?? "";
|
_previousEntryController.text = _currentlyDisplayedNote?.content ?? "";
|
||||||
|
|
||||||
final scratch = await getLatestScratch();
|
final scratch = await getLatestScratch();
|
||||||
_scratchController.text = scratch?.content ?? "";
|
_scratchController.text = scratch?.content ?? "";
|
||||||
_currentEntryController.text = "";
|
|
||||||
|
|
||||||
await _checkNavigation();
|
await _checkNavigation();
|
||||||
|
|
||||||
debugPrint("Data loaded.");
|
debugPrint("Data loaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load volume setting from database
|
// Load volume setting from database
|
||||||
Future<void> _loadVolume() async {
|
Future<void> _loadVolume() async {
|
||||||
String? volumeStr = await DB.getSetting('notificationVolume');
|
double? volume = await getVolume();
|
||||||
if (volumeStr != null) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_volume = double.tryParse(volumeStr) ?? 0.7;
|
_volume = volume;
|
||||||
_audioPlayer.setVolume(_linearToLogVolume(_volume));
|
_audioPlayer.setVolume(_linearToLogVolume(_volume));
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
_audioPlayer.setVolume(_linearToLogVolume(_volume));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save volume setting to database
|
// Save volume setting to database
|
||||||
Future<void> _saveVolume() async {
|
Future<void> _saveVolume() async {
|
||||||
await DB.setSetting('notificationVolume', _volume.toString());
|
await setVolume(_volume);
|
||||||
debugPrint("Volume saved: $_volume");
|
debugPrint("Volume saved: $_volume");
|
||||||
}
|
}
|
||||||
|
|
||||||
void _saveData() async {
|
Future<void> _saveData() async {
|
||||||
String previousEntry = _previousEntryController.text;
|
String previousEntry = _previousEntryController.text;
|
||||||
String currentEntry = _currentEntryController.text;
|
String currentEntry = _currentEntryController.text;
|
||||||
String scratchContent = _scratchController.text;
|
String scratchContent = _scratchController.text;
|
||||||
String intervalStr = _intervalController.text;
|
String intervalStr = _intervalController.text;
|
||||||
String soundStr = _soundController.text;
|
String soundStr = _soundController.text;
|
||||||
|
|
||||||
|
// Handle current entry
|
||||||
|
if (currentEntry.isNotEmpty) {
|
||||||
await createNote(currentEntry);
|
await createNote(currentEntry);
|
||||||
|
_currentEntryController.clear(); // Clear the input field after saving
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scratchContent != _scratchController.text) {
|
||||||
|
// Was modified
|
||||||
await createScratch(scratchContent);
|
await createScratch(scratchContent);
|
||||||
if (previousNote != null) {
|
}
|
||||||
previousNote!.content = previousEntry;
|
|
||||||
await updateNote(previousNote!);
|
// Handle previous/currently displayed note
|
||||||
|
if (_currentlyDisplayedNote != null) {
|
||||||
|
if (_currentlyDisplayedNote!.content != previousEntry) {
|
||||||
|
_currentlyDisplayedNote!.content = previousEntry;
|
||||||
|
await updateNote(_currentlyDisplayedNote!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the note was deleted (due to being empty), update the UI state
|
||||||
|
if (previousEntry.isEmpty) {
|
||||||
|
// Check if we need to navigate to another note
|
||||||
|
Note? nextNote = await getLatest();
|
||||||
|
setState(() {
|
||||||
|
_currentlyDisplayedNote = nextNote;
|
||||||
|
if (nextNote != null) {
|
||||||
|
_previousEntryController.text = nextNote.content;
|
||||||
|
} else {
|
||||||
|
_previousEntryController.text = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await _checkNavigation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int newIntervalMinutes =
|
int newIntervalMinutes =
|
||||||
@@ -378,17 +630,13 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Duration newInterval = Duration(minutes: newIntervalMinutes);
|
Duration newInterval = Duration(minutes: newIntervalMinutes);
|
||||||
if (newInterval != _currentPopupInterval) {
|
if (newInterval != _currentPopupInterval) {
|
||||||
_currentPopupInterval = newInterval;
|
_currentPopupInterval = newInterval;
|
||||||
DB.setSetting('popupIntervalMinutes', newIntervalMinutes.toString());
|
await setPopupInterval(newInterval);
|
||||||
_startPopupTimer();
|
_startPopupTimer();
|
||||||
} else {
|
|
||||||
DB.setSetting('popupIntervalMinutes', newIntervalMinutes.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (soundStr != _currentNotificationSound) {
|
if (soundStr != _currentNotificationSound) {
|
||||||
_currentNotificationSound = soundStr;
|
_currentNotificationSound = soundStr;
|
||||||
DB.setSetting('notificationSound', soundStr);
|
await setNotificationSound(soundStr);
|
||||||
} else {
|
|
||||||
DB.setSetting('notificationSound', soundStr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also save volume
|
// Also save volume
|
||||||
@@ -412,8 +660,8 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
// Build rich text with highlights for search results
|
// Build rich text with highlights for search results
|
||||||
List<InlineSpan> _buildHighlightedText(String highlightedText) {
|
List<InlineSpan> _buildHighlightedText(String highlightedText) {
|
||||||
List<InlineSpan> spans = [];
|
List<InlineSpan> spans = [];
|
||||||
// The text comes with <b>highlighted parts</b>
|
// The text comes with <highlight>highlighted parts</highlight>
|
||||||
RegExp exp = RegExp(r'<b>(.*?)</b>');
|
RegExp exp = RegExp(r'<highlight>(.*?)</highlight>');
|
||||||
|
|
||||||
int lastIndex = 0;
|
int lastIndex = 0;
|
||||||
for (final match in exp.allMatches(highlightedText)) {
|
for (final match in exp.allMatches(highlightedText)) {
|
||||||
@@ -422,9 +670,7 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: highlightedText.substring(lastIndex, match.start),
|
text: highlightedText.substring(lastIndex, match.start),
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 13),
|
||||||
fontSize: 13,
|
|
||||||
), // Smaller font for regular text
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -437,7 +683,7 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
backgroundColor: Colors.yellow,
|
backgroundColor: Colors.yellow,
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
fontSize: 13, // Smaller font for highlighted text
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -450,7 +696,7 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: highlightedText.substring(lastIndex),
|
text: highlightedText.substring(lastIndex),
|
||||||
style: const TextStyle(fontSize: 13), // Smaller font for regular text
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,9 +756,20 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
try {
|
try {
|
||||||
final results = await searchNotes(trimmedQuery);
|
final results = await searchNotes(trimmedQuery);
|
||||||
|
|
||||||
|
// Filter out empty notes (which may exist in the search index but were deleted)
|
||||||
|
final filteredResults =
|
||||||
|
results
|
||||||
|
.where((note) => note.content.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Sort by date, newest first
|
||||||
|
filteredResults.sort(
|
||||||
|
(a, b) => b.epochTime.compareTo(a.epochTime),
|
||||||
|
);
|
||||||
|
|
||||||
// Important: update the dialog state after search completes
|
// Important: update the dialog state after search completes
|
||||||
dialogSetState(() {
|
dialogSetState(() {
|
||||||
_searchResults = results;
|
_searchResults = filteredResults;
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -555,35 +812,42 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
vertical: 2,
|
vertical: 2,
|
||||||
), // Tighter padding
|
), // Tighter padding
|
||||||
title: Text(
|
title: Text(
|
||||||
note.date,
|
note.displayDate,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize:
|
fontSize:
|
||||||
12, // Smaller font for date
|
12, // Smaller font for date
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle:
|
subtitle: Text.rich(
|
||||||
note.snippet != null
|
|
||||||
? Text.rich(
|
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children:
|
children: _buildHighlightedText(
|
||||||
_buildHighlightedText(
|
note.snippet ?? note.content,
|
||||||
note.snippet!,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
note.content.length > 200
|
|
||||||
? '${note.content.substring(0, 200)}...'
|
|
||||||
: note.content,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
), // Smaller font for content
|
|
||||||
),
|
),
|
||||||
isThreeLine: true,
|
isThreeLine: true,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
|
// Save current note if needed
|
||||||
|
if (_currentlyDisplayedNote !=
|
||||||
|
null) {
|
||||||
|
if (_currentlyDisplayedNote!
|
||||||
|
.content !=
|
||||||
|
_previousEntryController
|
||||||
|
.text) {
|
||||||
|
_currentlyDisplayedNote!
|
||||||
|
.content =
|
||||||
|
_previousEntryController
|
||||||
|
.text;
|
||||||
|
await updateNote(
|
||||||
|
_currentlyDisplayedNote!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the selected note
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
this.setState(() {
|
setState(() {
|
||||||
_currentlyDisplayedNote = note;
|
_currentlyDisplayedNote = note;
|
||||||
_previousEntryController.text =
|
_previousEntryController.text =
|
||||||
note.content;
|
note.content;
|
||||||
@@ -649,6 +913,316 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show cleanup dialog
|
||||||
|
void _showCleanupDialog() async {
|
||||||
|
double sensitivity = 0.7; // Default 70%
|
||||||
|
final problematicEntries = await getProblematic(threshold: sensitivity);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, dialogSetState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Cleanup Problematic Entries'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width * 0.7,
|
||||||
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('Sensitivity: '),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: sensitivity,
|
||||||
|
min: 0.3,
|
||||||
|
max: 0.9,
|
||||||
|
divisions: 12,
|
||||||
|
label: '${(sensitivity * 100).toInt()}%',
|
||||||
|
onChanged: (value) async {
|
||||||
|
dialogSetState(() {
|
||||||
|
sensitivity =
|
||||||
|
(value * 100).round() /
|
||||||
|
100; // Round to 2 decimal places
|
||||||
|
});
|
||||||
|
// Refresh results with new sensitivity
|
||||||
|
final newResults = await getProblematic(
|
||||||
|
threshold: sensitivity,
|
||||||
|
);
|
||||||
|
dialogSetState(() {
|
||||||
|
problematicEntries.clear();
|
||||||
|
problematicEntries.addAll(newResults);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text('${(sensitivity * 100).toInt()}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Found ${problematicEntries.length} potentially problematic entries',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
problematicEntries.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text('No problematic entries found!'),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: problematicEntries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final note = problematicEntries[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(
|
||||||
|
note.displayDate,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Reason: ${note.problemReason}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red[700],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(note.content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.visibility),
|
||||||
|
tooltip: 'View in main window',
|
||||||
|
onPressed: () {
|
||||||
|
// Close the dialog
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
// Navigate to the note in main window
|
||||||
|
setState(() {
|
||||||
|
_currentlyDisplayedNote = note;
|
||||||
|
_previousEntryController.text =
|
||||||
|
note.content;
|
||||||
|
});
|
||||||
|
_checkNavigation();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
color: Colors.red,
|
||||||
|
tooltip:
|
||||||
|
'Delete (hold Shift to skip confirmation)',
|
||||||
|
onPressed: () async {
|
||||||
|
// Check if shift is pressed
|
||||||
|
final isShiftPressed =
|
||||||
|
HardwareKeyboard
|
||||||
|
.instance
|
||||||
|
.isShiftPressed;
|
||||||
|
|
||||||
|
bool shouldDelete =
|
||||||
|
isShiftPressed;
|
||||||
|
if (!isShiftPressed) {
|
||||||
|
// Show confirmation dialog
|
||||||
|
shouldDelete =
|
||||||
|
await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(
|
||||||
|
context,
|
||||||
|
) => AlertDialog(
|
||||||
|
title: const Text(
|
||||||
|
'Delete Entry?',
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to delete this entry? This action cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
() => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
const Text(
|
||||||
|
'Cancel',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
() => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(true),
|
||||||
|
child:
|
||||||
|
const Text(
|
||||||
|
'Delete',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDelete) {
|
||||||
|
await deleteNote(note.id);
|
||||||
|
dialogSetState(() {
|
||||||
|
problematicEntries.removeAt(
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show Meilisearch settings dialog
|
||||||
|
void _showMeilisearchSettings() {
|
||||||
|
final endpointController = TextEditingController();
|
||||||
|
final apiKeyController = TextEditingController();
|
||||||
|
bool isLoading = true;
|
||||||
|
String? errorMessage;
|
||||||
|
|
||||||
|
// Load current values
|
||||||
|
getMeilisearchEndpoint()
|
||||||
|
.then((value) {
|
||||||
|
endpointController.text = value;
|
||||||
|
isLoading = false;
|
||||||
|
})
|
||||||
|
.catchError((e) {
|
||||||
|
errorMessage = 'Failed to load endpoint: $e';
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
getMeilisearchApiKey()
|
||||||
|
.then((value) {
|
||||||
|
apiKeyController.text = value;
|
||||||
|
})
|
||||||
|
.catchError((e) {
|
||||||
|
errorMessage = 'Failed to load API key: $e';
|
||||||
|
});
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Meilisearch Settings'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else if (errorMessage != null)
|
||||||
|
Text(
|
||||||
|
errorMessage!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
TextField(
|
||||||
|
controller: endpointController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Endpoint URL',
|
||||||
|
hintText: 'http://localhost:7700',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: apiKeyController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'API Key',
|
||||||
|
hintText: 'masterKey',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await setMeilisearchEndpoint(
|
||||||
|
endpointController.text,
|
||||||
|
);
|
||||||
|
await setMeilisearchApiKey(apiKeyController.text);
|
||||||
|
|
||||||
|
// Try to reinitialize Meilisearch with new settings
|
||||||
|
await init();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
errorMessage = 'Failed to save settings: $e';
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Wrap Scaffold with RawKeyboardListener as workaround for Escape key
|
// Wrap Scaffold with RawKeyboardListener as workaround for Escape key
|
||||||
@@ -680,6 +1254,12 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Journaler'),
|
title: const Text('Journaler'),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
|
// Add cleanup button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.cleaning_services),
|
||||||
|
tooltip: 'Cleanup Problematic Entries',
|
||||||
|
onPressed: _showCleanupDialog,
|
||||||
|
),
|
||||||
// Add search button
|
// Add search button
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
@@ -762,6 +1342,15 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
onPressed: _playSound,
|
onPressed: _playSound,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Meilisearch Settings Button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
tooltip: 'Meilisearch Settings',
|
||||||
|
onPressed: _showMeilisearchSettings,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -795,10 +1384,10 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_currentlyDisplayedNote?.date ==
|
_currentlyDisplayedNote?.displayDate ==
|
||||||
previousNote?.date
|
previousNote?.displayDate
|
||||||
? 'Previous Entry (Latest)'
|
? 'Previous Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}'
|
||||||
: 'Entry: ${_currentlyDisplayedNote?.date ?? 'N/A'}',
|
: 'Entry: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
@@ -821,25 +1410,23 @@ class MainPageState extends State<MainPage> with WindowListener {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _previousEntryController,
|
controller: _previousEntryController,
|
||||||
readOnly:
|
readOnly: false, // Always allow editing
|
||||||
_currentlyDisplayedNote?.date !=
|
|
||||||
previousNote?.date,
|
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
expands: true,
|
expands: true,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText:
|
hintText:
|
||||||
_currentlyDisplayedNote?.date !=
|
_currentlyDisplayedNote?.displayDate !=
|
||||||
previousNote?.date
|
previousNote?.displayDate
|
||||||
? 'Viewing note from ${_currentlyDisplayedNote?.date} (Read-Only)'
|
? 'Viewing note from ${_currentlyDisplayedNote?.displayDate} (Editable)'
|
||||||
: 'Latest Note',
|
: 'Latest Note: ${_currentlyDisplayedNote?.displayDate ?? 'N/A'}',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
filled:
|
filled:
|
||||||
_currentlyDisplayedNote?.date !=
|
_currentlyDisplayedNote?.displayDate !=
|
||||||
previousNote?.date,
|
previousNote?.displayDate,
|
||||||
fillColor:
|
fillColor:
|
||||||
_currentlyDisplayedNote?.date !=
|
_currentlyDisplayedNote?.displayDate !=
|
||||||
previousNote?.date
|
previousNote?.displayDate
|
||||||
? Colors.grey.withOpacity(0.1)
|
? Colors.grey.withOpacity(0.1)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
523
lib/meilisearch.dart
Normal file
523
lib/meilisearch.dart
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:core';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:journaler/notes.dart';
|
||||||
|
import 'package:journaler/meilisearch_config.dart';
|
||||||
|
|
||||||
|
const noteIndex = 'notes';
|
||||||
|
const scratchIndex = 'scratch';
|
||||||
|
const settingsIndex = 'settings';
|
||||||
|
final alphanum = RegExp(r'[a-zA-Z0-9]', caseSensitive: false);
|
||||||
|
|
||||||
|
Future<Map<String, String>> _getHeaders() async {
|
||||||
|
final apiKey = await getMeilisearchApiKey();
|
||||||
|
return {
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getEndpoint() async {
|
||||||
|
return await getMeilisearchEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MeilisearchQuery {
|
||||||
|
String q;
|
||||||
|
String? filter;
|
||||||
|
int? limit;
|
||||||
|
int? offset;
|
||||||
|
bool? showRankingScore;
|
||||||
|
double? rankingScoreThreshold;
|
||||||
|
String? highlightPreTag;
|
||||||
|
String? highlightPostTag;
|
||||||
|
List<String>? attributesToHighlight;
|
||||||
|
List<String>? sort;
|
||||||
|
|
||||||
|
MeilisearchQuery({
|
||||||
|
required this.q,
|
||||||
|
this.filter,
|
||||||
|
this.sort,
|
||||||
|
this.limit,
|
||||||
|
this.offset,
|
||||||
|
this.showRankingScore,
|
||||||
|
this.rankingScoreThreshold,
|
||||||
|
this.highlightPreTag,
|
||||||
|
this.highlightPostTag,
|
||||||
|
this.attributesToHighlight,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> json = {'q': q};
|
||||||
|
if (filter != null) json['filter'] = filter;
|
||||||
|
if (sort != null) json['sort'] = sort;
|
||||||
|
if (limit != null) json['limit'] = limit;
|
||||||
|
if (offset != null) json['offset'] = offset;
|
||||||
|
if (showRankingScore != null) json['showRankingScore'] = showRankingScore;
|
||||||
|
if (rankingScoreThreshold != null) {
|
||||||
|
json['rankingScoreThreshold'] = rankingScoreThreshold;
|
||||||
|
}
|
||||||
|
if (highlightPreTag != null) json['highlightPreTag'] = highlightPreTag;
|
||||||
|
if (highlightPostTag != null) json['highlightPostTag'] = highlightPostTag;
|
||||||
|
if (attributesToHighlight != null) {
|
||||||
|
json['attributesToHighlight'] = attributesToHighlight;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MeilisearchResponse {
|
||||||
|
final List<dynamic> hits;
|
||||||
|
final String query;
|
||||||
|
final int processingTimeMs;
|
||||||
|
final int limit;
|
||||||
|
final int offset;
|
||||||
|
final int estimatedTotalHits;
|
||||||
|
|
||||||
|
MeilisearchResponse({
|
||||||
|
required this.hits,
|
||||||
|
required this.query,
|
||||||
|
required this.processingTimeMs,
|
||||||
|
required this.limit,
|
||||||
|
required this.offset,
|
||||||
|
required this.estimatedTotalHits,
|
||||||
|
});
|
||||||
|
|
||||||
|
static MeilisearchResponse fromJson(Map<String, dynamic> json) {
|
||||||
|
return MeilisearchResponse(
|
||||||
|
hits: json['hits'],
|
||||||
|
query: json['query'],
|
||||||
|
processingTimeMs: json['processingTimeMs'],
|
||||||
|
limit: json['limit'],
|
||||||
|
offset: json['offset'],
|
||||||
|
estimatedTotalHits: json['estimatedTotalHits'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
if (!await indexExists(noteIndex)) {
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'uid': noteIndex, 'primaryKey': 'id'}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await http.put(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/settings/sortable-attributes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(['date']),
|
||||||
|
);
|
||||||
|
await http.put(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/settings/filterable-attributes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(['date', 'topLetter', 'topLetterFrequency']),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!await indexExists(scratchIndex)) {
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'uid': scratchIndex, 'primaryKey': 'id'}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await http.put(
|
||||||
|
Uri.parse('$endpoint/indexes/$scratchIndex/settings/sortable-attributes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(['date']),
|
||||||
|
);
|
||||||
|
await http.put(
|
||||||
|
Uri.parse('$endpoint/indexes/$scratchIndex/settings/filterable-attributes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(['date']),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!await indexExists(settingsIndex)) {
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'uid': settingsIndex, 'primaryKey': 'key'}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> indexExists(String index) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$endpoint/indexes/$index'),
|
||||||
|
headers: headers,
|
||||||
|
);
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings Management
|
||||||
|
Future<String?> getSetting(String key) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(q: '', filter: 'key = $key');
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$settingsIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to get settings');
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
if (responseJson.hits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return responseJson.hits.first['value'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSetting(String key, String value) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final document = {'key': key, 'value': value};
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$settingsIndex/documents'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(document),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 202) {
|
||||||
|
throw Exception('Failed to set settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe we could factor a lot of this out into a separate function
|
||||||
|
// But we don't care for now...
|
||||||
|
Future<List<Note>> searchNotes(String query) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(
|
||||||
|
q: query,
|
||||||
|
limit: 10,
|
||||||
|
attributesToHighlight: ['content'],
|
||||||
|
showRankingScore: true,
|
||||||
|
highlightPreTag: '<highlight>',
|
||||||
|
highlightPostTag: '</highlight>',
|
||||||
|
);
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to search notes');
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
return responseJson.hits
|
||||||
|
.map(
|
||||||
|
(hit) => Note(
|
||||||
|
id: hit['id'] as String,
|
||||||
|
epochTime: hit['date'] as int,
|
||||||
|
content: hit['content'] as String,
|
||||||
|
snippet: hit['_formatted']['content'] as String,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note?> getPreviousTo(int epochTime) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(
|
||||||
|
q: '',
|
||||||
|
filter: 'date < $epochTime',
|
||||||
|
sort: ['date:desc'],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to get previous note, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
if (responseJson.hits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Note(
|
||||||
|
id: responseJson.hits.first['id'] as String,
|
||||||
|
epochTime: responseJson.hits.first['date'] as int,
|
||||||
|
content: responseJson.hits.first['content'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note?> getNextTo(int epochTime) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(
|
||||||
|
q: '',
|
||||||
|
filter: 'date > $epochTime',
|
||||||
|
sort: ['date:asc'],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to get next note, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
if (responseJson.hits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Note(
|
||||||
|
id: responseJson.hits.first['id'] as String,
|
||||||
|
epochTime: responseJson.hits.first['date'] as int,
|
||||||
|
content: responseJson.hits.first['content'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note?> getLatest() async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(
|
||||||
|
q: '',
|
||||||
|
sort: ['date:desc'],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to get latest note, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
if (responseJson.hits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Note(
|
||||||
|
id: responseJson.hits.first['id'] as String,
|
||||||
|
epochTime: responseJson.hits.first['date'] as int,
|
||||||
|
content: responseJson.hits.first['content'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String generateRandomString(int length) {
|
||||||
|
const characters =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
var result = '';
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final randomIndex = Random().nextInt(characters.length);
|
||||||
|
result += characters[randomIndex];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> createNote(String content) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final lines = content.split('\n');
|
||||||
|
final trimmedLines = <String>[];
|
||||||
|
for (final line in lines) {
|
||||||
|
final trimmedContent = line.trim().replaceAll(RegExp(r'\s{2,}'), ' ');
|
||||||
|
if (trimmedContent.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
trimmedLines.add(trimmedContent);
|
||||||
|
}
|
||||||
|
final trimmedContent = trimmedLines.join('\n');
|
||||||
|
|
||||||
|
final letterFrequency = <String, int>{};
|
||||||
|
for (final char in trimmedContent.split('')) {
|
||||||
|
if (alphanum.hasMatch(char)) {
|
||||||
|
letterFrequency[char] = (letterFrequency[char] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mostFrequentLetter =
|
||||||
|
letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key;
|
||||||
|
final mostFrequentLetterCount = letterFrequency[mostFrequentLetter];
|
||||||
|
|
||||||
|
final document = {
|
||||||
|
'id': generateRandomString(32),
|
||||||
|
'date': DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||||
|
'dateISO': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'content': content,
|
||||||
|
'topLetter': mostFrequentLetter,
|
||||||
|
'topLetterFrequency': mostFrequentLetterCount,
|
||||||
|
};
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/documents'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(document),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 202) {
|
||||||
|
throw Exception('Failed to create note');
|
||||||
|
}
|
||||||
|
return Note(
|
||||||
|
id: document['id'] as String,
|
||||||
|
epochTime: document['date'] as int,
|
||||||
|
content: document['content'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Note>> getProblematic({double threshold = 0.7}) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(
|
||||||
|
q: '',
|
||||||
|
filter: 'topLetterFrequency > $threshold',
|
||||||
|
);
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to get problematic notes, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
return responseJson.hits
|
||||||
|
.map(
|
||||||
|
(hit) => Note(
|
||||||
|
id: hit['id'] as String,
|
||||||
|
epochTime: hit['date'] as int,
|
||||||
|
content: hit['content'] as String,
|
||||||
|
isProblematic: true,
|
||||||
|
problemReason:
|
||||||
|
'Character "${hit['topLetter']}" makes up ${(hit['topLetterFrequency'] * 100).toStringAsFixed(1)}% of the content',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: only update if changed
|
||||||
|
// How? idk
|
||||||
|
Future<void> updateNote(Note note) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final lines = note.content.split('\n');
|
||||||
|
final trimmedLines = <String>[];
|
||||||
|
for (final line in lines) {
|
||||||
|
final trimmedContent = line.trim().replaceAll(RegExp(r'\s{2,}'), ' ');
|
||||||
|
if (trimmedContent.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
trimmedLines.add(trimmedContent);
|
||||||
|
}
|
||||||
|
final trimmedContent = trimmedLines.join('\n');
|
||||||
|
|
||||||
|
final letterFrequency = <String, int>{};
|
||||||
|
for (final char in trimmedContent.split('')) {
|
||||||
|
if (alphanum.hasMatch(char)) {
|
||||||
|
letterFrequency[char] = (letterFrequency[char] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mostFrequentLetter =
|
||||||
|
letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key;
|
||||||
|
final mostFrequentLetterRatio =
|
||||||
|
letterFrequency[mostFrequentLetter]! / trimmedContent.length;
|
||||||
|
|
||||||
|
final document = {
|
||||||
|
'id': note.id,
|
||||||
|
'content': trimmedContent,
|
||||||
|
'date': note.epochTime,
|
||||||
|
'dateISO':
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
note.epochTime,
|
||||||
|
).toUtc().toIso8601String(),
|
||||||
|
'topLetter': mostFrequentLetter,
|
||||||
|
'topLetterFrequency': mostFrequentLetterRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/documents'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(document),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 202) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to update note, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteNote(String id) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('$endpoint/indexes/$noteIndex/documents/$id'),
|
||||||
|
headers: headers,
|
||||||
|
);
|
||||||
|
if (response.statusCode != 202) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to delete note, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Scratch?> getLatestScratch() async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final searchCondition = MeilisearchQuery(
|
||||||
|
q: '',
|
||||||
|
sort: ['date:desc'],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$scratchIndex/search'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(searchCondition.toJson()),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to get latest scratch, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
|
||||||
|
if (responseJson.hits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Scratch(
|
||||||
|
id: responseJson.hits.first['id'] as String,
|
||||||
|
epochTime: responseJson.hits.first['date'] as int,
|
||||||
|
content: responseJson.hits.first['content'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Scratch> createScratch(String content) async {
|
||||||
|
final endpoint = await _getEndpoint();
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final document = {
|
||||||
|
'id': generateRandomString(32),
|
||||||
|
'date': DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||||
|
'content': content,
|
||||||
|
};
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$endpoint/indexes/$scratchIndex/documents'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(document),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 202) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to create scratch, backend responded with ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Scratch(
|
||||||
|
id: document['id'] as String,
|
||||||
|
epochTime: document['date'] as int,
|
||||||
|
content: document['content'] as String,
|
||||||
|
);
|
||||||
|
}
|
133
lib/meilisearch_config.dart
Normal file
133
lib/meilisearch_config.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
const defaultEndpoint = 'http://localhost:7700';
|
||||||
|
const defaultApiKey = 'masterKey';
|
||||||
|
|
||||||
|
// Cache for configuration
|
||||||
|
String? _cachedEndpoint;
|
||||||
|
String? _cachedApiKey;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Get the config file path in the user's home directory
|
||||||
|
Future<String> _getConfigPath() async {
|
||||||
|
final home =
|
||||||
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||||
|
if (home == null) {
|
||||||
|
throw Exception('Could not find home directory');
|
||||||
|
}
|
||||||
|
final configDir = Directory(path.join(home, '.journaler'));
|
||||||
|
if (!await configDir.exists()) {
|
||||||
|
await configDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return path.join(configDir.path, 'meilisearch_config.enc');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple encryption key derived from machine-specific data
|
||||||
|
String _getEncryptionKey() {
|
||||||
|
final machineId =
|
||||||
|
Platform.operatingSystem +
|
||||||
|
Platform.operatingSystemVersion +
|
||||||
|
Platform.localHostname;
|
||||||
|
return sha256.convert(utf8.encode(machineId)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt data
|
||||||
|
String _encrypt(String data) {
|
||||||
|
final key = _getEncryptionKey();
|
||||||
|
final bytes = utf8.encode(data);
|
||||||
|
final encrypted = <int>[];
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
encrypted.add(bytes[i] ^ key.codeUnitAt(i % key.length));
|
||||||
|
}
|
||||||
|
return base64.encode(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt data
|
||||||
|
String _decrypt(String encrypted) {
|
||||||
|
final key = _getEncryptionKey();
|
||||||
|
final bytes = base64.decode(encrypted);
|
||||||
|
final decrypted = <int>[];
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
decrypted.add(bytes[i] ^ key.codeUnitAt(i % key.length));
|
||||||
|
}
|
||||||
|
return utf8.decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize cache from file
|
||||||
|
Future<void> _initializeCache() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final configPath = await _getConfigPath();
|
||||||
|
final file = File(configPath);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
_cachedEndpoint = defaultEndpoint;
|
||||||
|
_cachedApiKey = defaultApiKey;
|
||||||
|
_isInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final encrypted = await file.readAsString();
|
||||||
|
final decrypted = _decrypt(encrypted);
|
||||||
|
final config = jsonDecode(decrypted);
|
||||||
|
|
||||||
|
_cachedEndpoint = config['endpoint'] ?? defaultEndpoint;
|
||||||
|
_cachedApiKey = config['apiKey'] ?? defaultApiKey;
|
||||||
|
} catch (e) {
|
||||||
|
_cachedEndpoint = defaultEndpoint;
|
||||||
|
_cachedApiKey = defaultApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getMeilisearchEndpoint() async {
|
||||||
|
await _initializeCache();
|
||||||
|
return _cachedEndpoint!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getMeilisearchApiKey() async {
|
||||||
|
await _initializeCache();
|
||||||
|
return _cachedApiKey!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMeilisearchEndpoint(String endpoint) async {
|
||||||
|
final configPath = await _getConfigPath();
|
||||||
|
final file = File(configPath);
|
||||||
|
|
||||||
|
Map<String, dynamic> config = {};
|
||||||
|
if (await file.exists()) {
|
||||||
|
final encrypted = await file.readAsString();
|
||||||
|
final decrypted = _decrypt(encrypted);
|
||||||
|
config = jsonDecode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
config['endpoint'] = endpoint;
|
||||||
|
final encrypted = _encrypt(jsonEncode(config));
|
||||||
|
await file.writeAsString(encrypted);
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
_cachedEndpoint = endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMeilisearchApiKey(String apiKey) async {
|
||||||
|
final configPath = await _getConfigPath();
|
||||||
|
final file = File(configPath);
|
||||||
|
|
||||||
|
Map<String, dynamic> config = {};
|
||||||
|
if (await file.exists()) {
|
||||||
|
final encrypted = await file.readAsString();
|
||||||
|
final decrypted = _decrypt(encrypted);
|
||||||
|
config = jsonDecode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
config['apiKey'] = apiKey;
|
||||||
|
final encrypted = _encrypt(jsonEncode(config));
|
||||||
|
await file.writeAsString(encrypted);
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
_cachedApiKey = apiKey;
|
||||||
|
}
|
138
lib/notes.dart
138
lib/notes.dart
@@ -1,126 +1,32 @@
|
|||||||
import 'package:journaler/db.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class Note {
|
class Note {
|
||||||
final String date;
|
final String id;
|
||||||
|
final int epochTime;
|
||||||
|
late final String displayDate;
|
||||||
String content;
|
String content;
|
||||||
String?
|
String? snippet;
|
||||||
snippet; // Optional field to hold highlighted snippets for search results
|
bool isProblematic;
|
||||||
|
String problemReason;
|
||||||
|
|
||||||
Note({required this.date, required this.content, this.snippet});
|
Note({
|
||||||
|
required this.id,
|
||||||
|
required this.epochTime,
|
||||||
|
required this.content,
|
||||||
|
this.snippet,
|
||||||
|
this.isProblematic = false,
|
||||||
|
this.problemReason = '',
|
||||||
|
}) {
|
||||||
|
final dtUtc = DateTime.fromMillisecondsSinceEpoch(epochTime, isUtc: true);
|
||||||
|
final dtLocal = dtUtc.toLocal();
|
||||||
|
displayDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(dtLocal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Scratch {
|
class Scratch {
|
||||||
final String date;
|
final int epochTime;
|
||||||
|
final String id;
|
||||||
String content;
|
String content;
|
||||||
|
|
||||||
Scratch({required this.date, required this.content});
|
Scratch({required this.id, required this.epochTime, required this.content});
|
||||||
}
|
|
||||||
|
|
||||||
Future<Note?> getLatestNote() async {
|
|
||||||
final note = await DB.db.rawQuery(
|
|
||||||
'SELECT content, date FROM notes ORDER BY date DESC LIMIT 1',
|
|
||||||
);
|
|
||||||
if (note.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Note(
|
|
||||||
date: note[0]['date'] as String,
|
|
||||||
content: note[0]['content'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> createNote(String content) async {
|
|
||||||
if (content.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await DB.db.insert('notes', {'content': content});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateNote(Note note) async {
|
|
||||||
await DB.db.update(
|
|
||||||
'notes',
|
|
||||||
{'content': note.content},
|
|
||||||
where: 'date = ?',
|
|
||||||
whereArgs: [note.date],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Scratch?> getLatestScratch() async {
|
|
||||||
final scratch = await DB.db.rawQuery(
|
|
||||||
'SELECT content, date FROM scratches ORDER BY date DESC LIMIT 1',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scratch.isEmpty) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return Scratch(
|
|
||||||
date: scratch[0]['date'] as String,
|
|
||||||
content: scratch[0]['content'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> createScratch(String content) async {
|
|
||||||
await DB.db.insert('scratches', {'content': content});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the note immediately older than the given date
|
|
||||||
Future<Note?> getPreviousNote(String currentDate) async {
|
|
||||||
final List<Map<String, dynamic>> notes = await DB.db.query(
|
|
||||||
'notes',
|
|
||||||
where: 'date < ?',
|
|
||||||
whereArgs: [currentDate],
|
|
||||||
orderBy: 'date DESC',
|
|
||||||
limit: 1,
|
|
||||||
);
|
|
||||||
if (notes.isNotEmpty) {
|
|
||||||
return Note(
|
|
||||||
date: notes.first['date'] as String,
|
|
||||||
content: notes.first['content'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the note immediately newer than the given date
|
|
||||||
Future<Note?> getNextNote(String currentDate) async {
|
|
||||||
final List<Map<String, dynamic>> notes = await DB.db.query(
|
|
||||||
'notes',
|
|
||||||
where: 'date > ?',
|
|
||||||
whereArgs: [currentDate],
|
|
||||||
orderBy: 'date ASC',
|
|
||||||
limit: 1,
|
|
||||||
);
|
|
||||||
if (notes.isNotEmpty) {
|
|
||||||
return Note(
|
|
||||||
date: notes.first['date'] as String,
|
|
||||||
content: notes.first['content'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If there's no newer note, it means we might be at the latest
|
|
||||||
// but let's double-check by explicitly getting the latest again.
|
|
||||||
// This handles the case where the `currentDate` might not be the absolute latest.
|
|
||||||
return getLatestNote();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search notes using full-text search
|
|
||||||
Future<List<Note>> searchNotes(String query) async {
|
|
||||||
if (query.isEmpty) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call DB search function
|
|
||||||
final List<Map<String, dynamic>> results = await DB.searchNotes(query);
|
|
||||||
|
|
||||||
// Convert results to Note objects
|
|
||||||
return results
|
|
||||||
.map(
|
|
||||||
(result) => Note(
|
|
||||||
date: result['date'] as String,
|
|
||||||
content: result['content'] as String,
|
|
||||||
snippet:
|
|
||||||
result['snippet'] as String?, // Highlighted snippets from FTS
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
39
lib/utils.dart
Normal file
39
lib/utils.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:journaler/meilisearch.dart';
|
||||||
|
|
||||||
|
Future<double> getVolume() async {
|
||||||
|
try {
|
||||||
|
final volumeStr = await getSetting('notificationVolume');
|
||||||
|
return double.tryParse(volumeStr ?? '0.7') ?? 0.7;
|
||||||
|
} catch (e) {
|
||||||
|
return 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
await setSetting('notificationVolume', volume.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Duration> getPopupInterval() async {
|
||||||
|
try {
|
||||||
|
final intervalStr = await getSetting('popupIntervalMinutes');
|
||||||
|
return Duration(minutes: int.tryParse(intervalStr ?? '10') ?? 10);
|
||||||
|
} catch (e) {
|
||||||
|
return Duration(minutes: 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPopupInterval(Duration interval) async {
|
||||||
|
await setSetting('popupIntervalMinutes', interval.inMinutes.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getNotificationSound() async {
|
||||||
|
try {
|
||||||
|
return await getSetting('notificationSound') ?? 'MeetTheSniper.mp3';
|
||||||
|
} catch (e) {
|
||||||
|
return 'MeetTheSniper.mp3';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNotificationSound(String sound) async {
|
||||||
|
await setSetting('notificationSound', sound);
|
||||||
|
}
|
26
pubspec.lock
26
pubspec.lock
@@ -130,7 +130,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||||
@@ -201,13 +201,13 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
|
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.4.0"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -224,6 +224,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
version: "4.5.4"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -289,7 +297,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
@@ -376,6 +384,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "6.0.2"
|
||||||
|
ps_list:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: ps_list
|
||||||
|
sha256: "19d32f6c643313cf4f5101bb144b8978b9ba3dc42c9a01b247e8ed90581bc0ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.5"
|
||||||
screen_retriever:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@@ -38,6 +38,11 @@ dependencies:
|
|||||||
window_manager: ^0.4.3
|
window_manager: ^0.4.3
|
||||||
audioplayers: ^6.4.0
|
audioplayers: ^6.4.0
|
||||||
sqflite_common_ffi: ^2.3.5
|
sqflite_common_ffi: ^2.3.5
|
||||||
|
path: ^1.8.0
|
||||||
|
ps_list: ^0.0.5
|
||||||
|
intl: ^0.20.2
|
||||||
|
http: ^1.4.0
|
||||||
|
crypto: ^3.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -66,6 +71,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/ # Include the main assets directory for the icon
|
- assets/ # Include the main assets directory for the icon
|
||||||
- assets/sounds/
|
- assets/sounds/
|
||||||
|
- assets/windows/sqlite3_icu.dll
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user