28 Commits

Author SHA1 Message Date
73e27c5f5b Fix loading notes when approaching newest 2025-06-09 09:39:12 +02:00
559e0fea36 Implement some form of caching entries while scrolling 2025-06-09 08:49:48 +02:00
7ba188eb6c Add persistent focus node for keyboard listener and improve focus handling 2025-05-25 13:30:05 +02:00
188619478e Track original scratch content to only write scratch when modified 2025-05-25 10:11:35 +02:00
27f21a23fe Fix settings by making key filterable in meilisearc 2025-05-25 10:05:48 +02:00
d78db03d5d Add Meilisearch document handling and fix utilities 2025-05-25 02:24:54 +02:00
df93602db4 Update most frequent letter count calculation to normalize by trimmed content length 2025-05-25 02:07:52 +02:00
d6f9fa5032 Optimize note content updates and improve scratch pad handling 2025-05-24 14:56:09 +02:00
64884cbb42 Add ISO dates to search so we can query by them 2025-05-24 14:52:09 +02:00
29f6f28f12 Add meilisearch config settings dialogue 2025-05-24 01:46:12 +02:00
fadd9a7387 Implement loading meilisearch config from disk
Encrypted such as it is
2025-05-24 01:35:09 +02:00
716a02a1dc Fix search highlighting 2025-05-24 01:22:44 +02:00
6eb55c5d50 Fix various bugs and shit 2025-05-24 01:07:08 +02:00
3c1f31d29b Refactor note handling to use epoch time and improve utility functions for settings management 2025-05-24 00:05:44 +02:00
dfe1c2b34c Refactor note handling to include ID generation and improve initialization logic 2025-05-23 22:40:19 +02:00
89f8889f1e Rework everything to use meilisearch 2025-05-23 21:36:36 +02:00
597ce8c9cf Sanitize out "-" and remove the arbitrary 100 query limit 2025-05-23 17:04:20 +02:00
c2202bdfef Remove keyboard smashing detection logic from note saving process which I thought I already did, damn you AI 2025-05-21 11:24:07 +02:00
8daf7ed6bf Improve note creation by trimming whitespace from each line 2025-05-21 11:20:14 +02:00
4339763261 Enhance cleanup dialog with sensitivity slider for problematic entries 2025-05-20 12:44:53 +02:00
621e85c747 Add functionality to identify and clean up problematic entries in notes 2025-05-20 12:39:43 +02:00
d937ae212c Sort search results by date, newest first 2025-05-18 17:00:23 +02:00
b5dad28491 Clear input field after saving note 2025-05-18 13:50:59 +02:00
1e11cd53c9 Do not reset controller when showing 2025-05-18 09:56:04 +02:00
b74dc5b3c6 Remove unused 2025-05-16 15:44:51 +02:00
474cb662e5 Remove fallback to like 2025-05-16 15:41:28 +02:00
a84973def6 Fix cyrillic 2025-05-16 15:32:58 +02:00
fcb8e41cde Add icu support for cyrillic fts 2025-05-16 14:56:22 +02:00
11 changed files with 2701 additions and 361 deletions

View File

@@ -15,6 +15,10 @@ Journaler helps you build a consistent journaling habit by periodically remindin
![Journaler Screenshot](docs/screenshots/journaler_main.png)
## 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

Binary file not shown.

View File

@@ -6,10 +6,23 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
const settingsDir = '.journaler';
const dbFileName = 'journaler.db';
// Add this at the top level
typedef ShowMessageCallback = void Function(String message);
class DB {
static late Database db;
static bool _hasIcuSupport = false;
static ShowMessageCallback? _showMessage;
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 UNIQUE INDEX IF NOT EXISTS idx_notes_date_unique ON notes (date);
-- Todos are "static", we are only interested in the latest entry
-- ie. they're not really a list but instead a string
-- But we will also keep a history of all todos
-- Because we might as well
CREATE TABLE IF NOT EXISTS scratches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -33,15 +43,31 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY 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(
content,
date,
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
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);
@@ -58,6 +84,124 @@ CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
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 {
debugPrint('Attempting to get database path...');
if (Platform.isWindows || Platform.isLinux) {
@@ -88,26 +232,100 @@ END;
static Future<void> init() async {
debugPrint('Starting database initialization...');
// Initialize SQLite FFI
sqfliteFfiInit();
final databaseFactory = databaseFactoryFfi;
// Create a temporary database to check version
try {
debugPrint(
'\n================== SQLITE VERSION CHECK ==================',
);
final tempDb = await databaseFactory.openDatabase(
':memory:',
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
final results = await db.rawQuery('SELECT sqlite_version()');
debugPrint('SQLite version: ${results.first.values.first}');
final compileOpts = await db.rawQuery('PRAGMA compile_options');
debugPrint('SQLite compile options:');
for (var opt in compileOpts) {
debugPrint(' ${opt.values.first}');
}
},
),
);
await tempDb.close();
debugPrint('=====================================================\n');
} catch (e, stackTrace) {
debugPrint('Error checking SQLite version:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
}
await databaseFactory.setDatabasesPath(
await databaseFactory.getDatabasesPath(),
);
final dbPath = await _getDatabasePath();
debugPrint('Database path: $dbPath');
try {
db = await databaseFactoryFfi.openDatabase(
db = await databaseFactory.openDatabase(
dbPath,
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 {
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');
},
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) {
debugPrint('Failed to initialize database: $e');
// Store Unicode support status in settings for future reference
await setSetting('has_icu_support', _hasIcuSupport.toString());
debugPrint(
'Database opened and initialized (Unicode support: $_hasIcuSupport)',
);
} catch (e, stackTrace) {
debugPrint('Failed to initialize database:');
debugPrint('Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
@@ -141,7 +359,6 @@ END;
return [];
}
// Process the query for partial word matching
// Split into individual terms, filter empty ones
List<String> terms =
query
@@ -154,16 +371,16 @@ END;
return [];
}
// Add wildcards to each term for prefix matching (e.g., "fuck*" will match "fucked")
// Join terms with AND for all-term matching (results must contain ALL terms)
// Process terms for FTS5 query using proper tokenization
String ftsQuery = terms
.map((term) {
// Remove any special characters that might break the query
String sanitizedTerm = term.replaceAll(RegExp(r'[^\w]'), '');
// Remove dangerous characters but preserve Unicode
String sanitizedTerm = term.replaceAll(RegExp(r'''['"]'''), '');
if (sanitizedTerm.isEmpty) return '';
// Add wildcard for stemming/prefix matching
return '$sanitizedTerm*';
// Use proper FTS5 syntax: each word becomes a separate token with prefix matching
List<String> words = sanitizedTerm.split(RegExp(r'\s+'));
return words.map((word) => '$word*').join(' OR ');
})
.where((term) => term.isNotEmpty)
.join(' AND ');
@@ -173,26 +390,27 @@ END;
return [];
}
ftsQuery = ftsQuery.replaceAll('-', ' ');
debugPrint('FTS query: "$ftsQuery"');
// Execute the FTS query with AND logic
// Execute the FTS query
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
JOIN notes n ON notes_fts.rowid = n.id
WHERE notes_fts MATCH ?
ORDER BY n.date DESC
LIMIT 100
''',
ORDER BY rank
''',
[ftsQuery],
);
debugPrint('Search query "$ftsQuery" returned ${results.length} results');
debugPrint('Search returned ${results.length} results');
return results;
} catch (e) {
} catch (e, stackTrace) {
debugPrint('Search failed: $e');
// Return empty results rather than crashing on malformed queries
debugPrint('Stack trace: $stackTrace');
return [];
}
}

File diff suppressed because it is too large Load Diff

220
lib/meilifix.dart Normal file
View File

@@ -0,0 +1,220 @@
import 'dart:convert';
import 'dart:core';
import 'package:http/http.dart' as http;
import 'package:journaler/meilisearch_config.dart';
const noteIndex = 'notes';
const scratchIndex = 'scratch';
final alphanum = RegExp(r'[a-zA-Z0-9]', caseSensitive: false);
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<Map<String, String>> _getHeaders() async {
final apiKey = await getMeilisearchApiKey();
return {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
};
}
Future<String> _getEndpoint() async {
return await getMeilisearchEndpoint();
}
Future<List<Map<String, dynamic>>> getAllDocuments(String index) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final allDocuments = <Map<String, dynamic>>[];
var offset = 0;
const limit = 100;
while (true) {
final response = await http.get(
Uri.parse('$endpoint/indexes/$index/documents?limit=$limit&offset=$offset'),
headers: headers,
);
if (response.statusCode != 200) {
throw Exception('Failed to get documents: ${response.statusCode}');
}
final responseJson = jsonDecode(response.body);
final documents = List<Map<String, dynamic>>.from(responseJson['results']);
if (documents.isEmpty) {
break;
}
allDocuments.addAll(documents);
print('Found ${allDocuments.length} documents so far in $index');
if (documents.length < limit) {
break;
}
offset += limit;
}
print('Total documents found in $index: ${allDocuments.length}');
return allDocuments;
}
Map<String, dynamic> fixDocument(Map<String, dynamic> doc) {
final content = doc['content'] as String;
final date = doc['date'] as int;
// Calculate letter frequency
final letterFrequency = <String, int>{};
for (final char in content.split('')) {
if (alphanum.hasMatch(char)) {
letterFrequency[char] = (letterFrequency[char] ?? 0) + 1;
}
}
final mostFrequentLetter =
letterFrequency.entries.isEmpty
? 'a'
: letterFrequency.entries
.reduce((a, b) => a.value > b.value ? a : b)
.key;
final mostFrequentLetterCount =
letterFrequency.isEmpty
? 0.0
: letterFrequency[mostFrequentLetter]! / content.length;
return {
...doc,
'dateISO':
DateTime.fromMillisecondsSinceEpoch(date).toUtc().toIso8601String(),
'topLetter': mostFrequentLetter,
'topLetterFrequency': mostFrequentLetterCount,
};
}
Future<void> updateDocument(String index, Map<String, dynamic> doc) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final response = await http.post(
Uri.parse('$endpoint/indexes/$index/documents'),
headers: headers,
body: jsonEncode(doc),
);
if (response.statusCode != 202) {
throw Exception('Failed to update document: ${response.statusCode}');
}
}
Future<void> fixAllDocuments() async {
print('Fixing notes...');
final notes = await getAllDocuments(noteIndex);
final noteBatches = <List<Map<String, dynamic>>>[];
for (var i = 0; i < notes.length; i += 10) {
noteBatches.add(notes.skip(i).take(10).toList());
}
for (final batch in noteBatches) {
await Future.wait(
batch.map((note) async {
final fixed = fixDocument(note);
await updateDocument(noteIndex, fixed);
print('Fixed note: ${note['id']}');
}),
);
}
print('Fixing scratches...');
final scratches = await getAllDocuments(scratchIndex);
final scratchBatches = <List<Map<String, dynamic>>>[];
for (var i = 0; i < scratches.length; i += 10) {
scratchBatches.add(scratches.skip(i).take(10).toList());
}
for (final batch in scratchBatches) {
await Future.wait(
batch.map((scratch) async {
final fixed = fixDocument(scratch);
await updateDocument(scratchIndex, fixed);
print('Fixed scratch: ${scratch['id']}');
}),
);
}
}
void main() async {
try {
await fixAllDocuments();
print('All documents fixed successfully!');
} catch (e) {
print('Error fixing documents: $e');
}
}

635
lib/meilisearch.dart Normal file
View File

@@ -0,0 +1,635 @@
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'}),
);
}
await http.put(
Uri.parse('$endpoint/indexes/$settingsIndex/settings/filterable-attributes'),
headers: headers,
body: jsonEncode(['key', 'value']),
);
}
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;
}
}
// Handle the case where there are no alphanumeric characters
String mostFrequentLetter = 'a'; // Default value
double mostFrequentLetterCount = 0.0; // Default value
if (letterFrequency.isNotEmpty) {
mostFrequentLetter = letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key;
mostFrequentLetterCount = letterFrequency[mostFrequentLetter]! / (trimmedContent.length > 0 ? trimmedContent.length : 1);
}
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;
}
}
// Handle the case where there are no alphanumeric characters
String mostFrequentLetter = 'a'; // Default value
double mostFrequentLetterRatio = 0.0; // Default value
if (letterFrequency.isNotEmpty) {
mostFrequentLetter = letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key;
mostFrequentLetterRatio = letterFrequency[mostFrequentLetter]! / (trimmedContent.length > 0 ? trimmedContent.length : 1);
}
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,
);
}
Future<List<Note>> getNotesBefore(int epochTime, {int limit = 50}) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(
q: '',
filter: 'date < $epochTime',
sort: ['date:desc'],
limit: limit,
);
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 notes before timestamp, 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,
),
)
.toList();
}
Future<List<Note>> getNotesAfter(int epochTime, {int limit = 50}) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(
q: '',
filter: 'date > $epochTime',
sort: ['date:asc'],
limit: limit,
);
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 notes after timestamp, 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,
),
)
.toList();
}
Future<Duration> getPopupInterval() async {
final value = await getSetting('popupInterval');
if (value == null) {
return const Duration(minutes: 20);
}
return Duration(minutes: int.parse(value));
}
Future<void> setPopupInterval(Duration interval) async {
await setSetting('popupInterval', interval.inMinutes.toString());
}
Future<int> getCacheSizeBefore() async {
final value = await getSetting('cacheSizeBefore');
if (value == null) {
return 50; // Default value
}
return int.parse(value);
}
Future<void> setCacheSizeBefore(int size) async {
await setSetting('cacheSizeBefore', size.toString());
}
Future<int> getCacheSizeAfter() async {
final value = await getSetting('cacheSizeAfter');
if (value == null) {
return 50; // Default value
}
return int.parse(value);
}
Future<void> setCacheSizeAfter(int size) async {
await setSetting('cacheSizeAfter', size.toString());
}

133
lib/meilisearch_config.dart Normal file
View 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;
}

View File

@@ -1,154 +1,32 @@
import 'package:journaler/db.dart';
import 'package:intl/intl.dart';
class Note {
final String date;
final String id;
final int epochTime;
late final String displayDate;
String content;
String? snippet;
bool isProblematic;
String problemReason;
Note({required this.date, required this.content, this.snippet}) {
final dtUtc = DateFormat('yyyy-MM-dd HH:mm:ss').parse(date, true);
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 {
final String date;
final int epochTime;
final String id;
String content;
Scratch({required this.date, 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 {
// Trim the content to avoid saving just whitespace
final trimmedContent = content.trim();
if (trimmedContent.isEmpty) {
return;
}
await DB.db.insert('notes', {'content': trimmedContent});
}
Future<void> updateNote(Note note) async {
// Trim the content to avoid saving just whitespace
final trimmedContent = note.content.trim();
if (trimmedContent.isEmpty) {
// Delete the note if content is empty
await DB.db.delete('notes', where: 'date = ?', whereArgs: [note.date]);
return;
}
await DB.db.update(
'notes',
{'content': trimmedContent},
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 {
// Trim content but allow empty scratch notes (they might be intentionally cleared)
final trimmedContent = content.trim();
await DB.db.insert('scratches', {'content': trimmedContent});
}
// 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();
}
/// Delete a note by its date
Future<bool> deleteNote(String date) async {
final result = await DB.db.delete(
'notes',
where: 'date = ?',
whereArgs: [date],
);
return result > 0; // Return true if a note was deleted
}
// 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();
Scratch({required this.id, required this.epochTime, required this.content});
}

39
lib/utils.dart Normal file
View 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);
}

View File

@@ -130,7 +130,7 @@ packages:
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
@@ -201,13 +201,13 @@ packages:
source: sdk
version: "0.0.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
http_parser:
dependency: transitive
description:

View File

@@ -41,6 +41,8 @@ dependencies:
path: ^1.8.0
ps_list: ^0.0.5
intl: ^0.20.2
http: ^1.4.0
crypto: ^3.0.3
dev_dependencies:
flutter_test:
@@ -69,6 +71,7 @@ flutter:
assets:
- assets/ # Include the main assets directory for the icon
- assets/sounds/
- assets/windows/sqlite3_icu.dll
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg