17 Commits

Author SHA1 Message Date
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
10 changed files with 1157 additions and 311 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) ![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 ## Features
- **Automated Reminders**: Customizable popup intervals to remind you to journal - **Automated Reminders**: Customizable popup intervals to remind you to journal

View File

@@ -390,6 +390,7 @@ END;
return []; return [];
} }
ftsQuery = ftsQuery.replaceAll('-', ' ');
debugPrint('FTS query: "$ftsQuery"'); debugPrint('FTS query: "$ftsQuery"');
// Execute the FTS query // Execute the FTS query
@@ -401,7 +402,6 @@ END;
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 rank ORDER BY rank
LIMIT 100
''', ''',
[ftsQuery], [ftsQuery],
); );

View File

@@ -2,8 +2,8 @@ import 'dart:async';
import 'dart:io'; 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/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';
@@ -11,6 +11,8 @@ import 'package:flutter/gestures.dart';
import 'dart:math'; import 'dart:math';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:ps_list/ps_list.dart'; 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
@@ -65,7 +67,15 @@ Future<void> runPrimaryInstance(File ipcFile) async {
startIpcWatcher(ipcFile); startIpcWatcher(ipcFile);
// Initialize the app // Initialize the app
await DB.init(); 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(
@@ -298,11 +308,13 @@ class MainPageState extends State<MainPage> with WindowListener {
final TextEditingController _intervalController = TextEditingController(); final TextEditingController _intervalController = TextEditingController();
final TextEditingController _soundController = TextEditingController(); final TextEditingController _soundController = TextEditingController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final FocusNode _keyboardListenerFocusNode = FocusNode(); // Add persistent focus node
Note? previousNote; Note? previousNote;
Note? _currentlyDisplayedNote; Note? _currentlyDisplayedNote;
Duration _currentPopupInterval = _defaultPopupInterval; Duration _currentPopupInterval = _defaultPopupInterval;
String _currentNotificationSound = _defaultNotificationSound; String _currentNotificationSound = _defaultNotificationSound;
String? _originalScratchContent; // Track original scratch content
bool _canGoPrevious = false; bool _canGoPrevious = false;
bool _canGoNext = false; bool _canGoNext = false;
@@ -348,6 +360,7 @@ class MainPageState extends State<MainPage> with WindowListener {
_previousEntryController.dispose(); _previousEntryController.dispose();
_currentEntryController.dispose(); _currentEntryController.dispose();
_currentEntryFocusNode.dispose(); _currentEntryFocusNode.dispose();
_keyboardListenerFocusNode.dispose(); // Dispose the keyboard listener focus node
_scratchController.dispose(); _scratchController.dispose();
_intervalController.dispose(); _intervalController.dispose();
_soundController.dispose(); _soundController.dispose();
@@ -413,13 +426,16 @@ class MainPageState extends State<MainPage> with WindowListener {
await windowManager.setSize(const Size(1600, 900)); await windowManager.setSize(const Size(1600, 900));
await windowManager.center(); await windowManager.center();
// Now show and focus // Show and focus immediately
await windowManager.show(); await windowManager.show();
await Future.delayed(const Duration(milliseconds: 100)); // Short delay
await windowManager.focus(); await windowManager.focus();
// Set input focus // Set input focus with a post-frame callback to ensure it sticks
_currentEntryFocusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_currentEntryFocusNode.requestFocus();
}
});
// Play notification sound // Play notification sound
await _playSound(); await _playSound();
@@ -428,7 +444,11 @@ class MainPageState extends State<MainPage> with WindowListener {
} else { } else {
// Already visible, just focus // Already visible, just focus
await windowManager.focus(); await windowManager.focus();
_currentEntryFocusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_currentEntryFocusNode.requestFocus();
}
});
debugPrint("Window already visible, just focused"); debugPrint("Window already visible, just focused");
} }
@@ -481,8 +501,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;
@@ -495,11 +516,13 @@ class MainPageState extends State<MainPage> with WindowListener {
// Save the current note content before navigating away // Save the current note content before navigating away
if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote != null) {
_currentlyDisplayedNote!.content = _previousEntryController.text; if (_currentlyDisplayedNote!.content != _previousEntryController.text) {
await updateNote(_currentlyDisplayedNote!); _currentlyDisplayedNote!.content = _previousEntryController.text;
await updateNote(_currentlyDisplayedNote!);
}
} }
final prevNote = await getPreviousNote(_currentlyDisplayedNote!.date); final prevNote = await getPreviousTo(_currentlyDisplayedNote!.epochTime);
if (prevNote != null) { if (prevNote != null) {
setState(() { setState(() {
_currentlyDisplayedNote = prevNote; _currentlyDisplayedNote = prevNote;
@@ -514,11 +537,13 @@ class MainPageState extends State<MainPage> with WindowListener {
// Save the current note content before navigating away // Save the current note content before navigating away
if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote != null) {
_currentlyDisplayedNote!.content = _previousEntryController.text; if (_currentlyDisplayedNote!.content != _previousEntryController.text) {
await updateNote(_currentlyDisplayedNote!); _currentlyDisplayedNote!.content = _previousEntryController.text;
await updateNote(_currentlyDisplayedNote!);
}
} }
final nextNote = await getNextNote(_currentlyDisplayedNote!.date); final nextNote = await getNextTo(_currentlyDisplayedNote!.epochTime);
if (nextNote != null) { if (nextNote != null) {
setState(() { setState(() {
_currentlyDisplayedNote = nextNote; _currentlyDisplayedNote = nextNote;
@@ -529,27 +554,25 @@ 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 ?? "";
_originalScratchContent = scratch?.content; // Store original content
await _checkNavigation(); await _checkNavigation();
@@ -558,58 +581,19 @@ class MainPageState extends State<MainPage> with WindowListener {
// 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 = volume;
_volume = double.tryParse(volumeStr) ?? 0.7;
_audioPlayer.setVolume(_linearToLogVolume(_volume));
});
} else {
_audioPlayer.setVolume(_linearToLogVolume(_volume)); _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");
} }
// Check if content appears to be keyboard smashing or repeated characters
bool _isLikelyKeyboardSmashing(String content) {
// Skip empty content
if (content.trim().isEmpty) return false;
// Check for repeated characters (like "wwwwwwww")
final repeatedCharPattern = RegExp(
r'(.)\1{4,}',
); // 5 or more repeated chars
if (repeatedCharPattern.hasMatch(content)) {
// But allow if it's a legitimate pattern like base64
if (RegExp(r'^[A-Za-z0-9+/=]+$').hasMatch(content)) return false;
return true;
}
// Check for alternating characters (like "asdfasdf")
final alternatingPattern = RegExp(r'(.)(.)\1\2{2,}');
if (alternatingPattern.hasMatch(content)) return true;
// Check for common keyboard smashing patterns
final commonPatterns = [
r'[qwerty]{5,}', // Common keyboard rows
r'[asdf]{5,}',
r'[zxcv]{5,}',
r'[1234]{5,}',
r'[wasd]{5,}', // Common gaming keys
];
for (final pattern in commonPatterns) {
if (RegExp(pattern, caseSensitive: false).hasMatch(content)) return true;
}
return false;
}
Future<void> _saveData() async { Future<void> _saveData() async {
String previousEntry = _previousEntryController.text; String previousEntry = _previousEntryController.text;
String currentEntry = _currentEntryController.text; String currentEntry = _currentEntryController.text;
@@ -619,28 +603,27 @@ class MainPageState extends State<MainPage> with WindowListener {
// Handle current entry // Handle current entry
if (currentEntry.isNotEmpty) { if (currentEntry.isNotEmpty) {
// Skip saving if it looks like keyboard smashing await createNote(currentEntry);
if (!_isLikelyKeyboardSmashing(currentEntry)) { _currentEntryController.clear(); // Clear the input field after saving
await createNote(currentEntry);
_currentEntryController.clear(); // Clear the input field after saving
} else {
debugPrint("Skipping save of likely keyboard smashing: $currentEntry");
_currentEntryController.clear(); // Still clear the input
}
} }
// Handle scratch pad // Only create new scratch if content has changed
await createScratch(scratchContent); if (scratchContent != _originalScratchContent) {
await createScratch(scratchContent);
_originalScratchContent = scratchContent; // Update original content
}
// Handle previous/currently displayed note // Handle previous/currently displayed note
if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote != null) {
_currentlyDisplayedNote!.content = previousEntry; if (_currentlyDisplayedNote!.content != previousEntry) {
await updateNote(_currentlyDisplayedNote!); _currentlyDisplayedNote!.content = previousEntry;
await updateNote(_currentlyDisplayedNote!);
}
// If the note was deleted (due to being empty), update the UI state // If the note was deleted (due to being empty), update the UI state
if (previousEntry.isEmpty) { if (previousEntry.isEmpty) {
// Check if we need to navigate to another note // Check if we need to navigate to another note
Note? nextNote = await getLatestNote(); Note? nextNote = await getLatest();
setState(() { setState(() {
_currentlyDisplayedNote = nextNote; _currentlyDisplayedNote = nextNote;
if (nextNote != null) { if (nextNote != null) {
@@ -658,17 +641,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
@@ -692,8 +671,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)) {
@@ -702,9 +681,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
), ),
); );
} }
@@ -717,7 +694,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,
), ),
), ),
); );
@@ -730,7 +707,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),
), ),
); );
} }
@@ -798,7 +775,7 @@ class MainPageState extends State<MainPage> with WindowListener {
// Sort by date, newest first // Sort by date, newest first
filteredResults.sort( filteredResults.sort(
(a, b) => b.date.compareTo(a.date), (a, b) => b.epochTime.compareTo(a.epochTime),
); );
// Important: update the dialog state after search completes // Important: update the dialog state after search completes
@@ -853,34 +830,30 @@ class MainPageState extends State<MainPage> with WindowListener {
12, // Smaller font for date 12, // Smaller font for date
), ),
), ),
subtitle: subtitle: Text.rich(
note.snippet != null TextSpan(
? Text.rich( children: _buildHighlightedText(
TextSpan( note.snippet ?? note.content,
children: ),
_buildHighlightedText( ),
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: () async { onTap: () async {
// Save current note if needed // Save current note if needed
if (_currentlyDisplayedNote != if (_currentlyDisplayedNote !=
null) { null) {
_currentlyDisplayedNote!.content = if (_currentlyDisplayedNote!
_previousEntryController.text; .content !=
await updateNote( _previousEntryController
_currentlyDisplayedNote!, .text) {
); _currentlyDisplayedNote!
.content =
_previousEntryController
.text;
await updateNote(
_currentlyDisplayedNote!,
);
}
} }
// Navigate to the selected note // Navigate to the selected note
@@ -954,9 +927,7 @@ class MainPageState extends State<MainPage> with WindowListener {
// Show cleanup dialog // Show cleanup dialog
void _showCleanupDialog() async { void _showCleanupDialog() async {
double sensitivity = 0.7; // Default 70% double sensitivity = 0.7; // Default 70%
final problematicEntries = await findProblematicEntries( final problematicEntries = await getProblematic(threshold: sensitivity);
maxCharPercentage: sensitivity,
);
if (!mounted) return; if (!mounted) return;
@@ -989,8 +960,8 @@ class MainPageState extends State<MainPage> with WindowListener {
100; // Round to 2 decimal places 100; // Round to 2 decimal places
}); });
// Refresh results with new sensitivity // Refresh results with new sensitivity
final newResults = await findProblematicEntries( final newResults = await getProblematic(
maxCharPercentage: sensitivity, threshold: sensitivity,
); );
dialogSetState(() { dialogSetState(() {
problematicEntries.clear(); problematicEntries.clear();
@@ -1118,7 +1089,7 @@ class MainPageState extends State<MainPage> with WindowListener {
} }
if (shouldDelete) { if (shouldDelete) {
await deleteNote(note.date); await deleteNote(note.id);
dialogSetState(() { dialogSetState(() {
problematicEntries.removeAt( problematicEntries.removeAt(
index, index,
@@ -1152,13 +1123,122 @@ class MainPageState extends State<MainPage> with WindowListener {
); );
} }
// 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
return RawKeyboardListener( return RawKeyboardListener(
focusNode: focusNode: _keyboardListenerFocusNode, // Use persistent focus node
FocusNode()
..requestFocus(), // Request focus to ensure keyboard events are captured
onKey: (RawKeyEvent event) { onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent) { if (event is RawKeyDownEvent) {
// Handle Escape to close window // Handle Escape to close window
@@ -1167,8 +1247,7 @@ class MainPageState extends State<MainPage> with WindowListener {
"Escape pressed inside MainPage (RawKeyboardListener - Workaround)", "Escape pressed inside MainPage (RawKeyboardListener - Workaround)",
); );
// Call method directly since we are in the state // Call method directly since we are in the state
FocusManager.instance.primaryFocus FocusManager.instance.primaryFocus?.unfocus(); // Keep unfocus attempt
?.unfocus(); // Keep unfocus attempt
onWindowClose(); onWindowClose();
} }
// Handle Ctrl+F to open search // Handle Ctrl+F to open search
@@ -1271,6 +1350,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),
], ],
), ),

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');
}
}

529
lib/meilisearch.dart Normal file
View File

@@ -0,0 +1,529 @@
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;
}
}
final mostFrequentLetter =
letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key;
final mostFrequentLetterCount =
letterFrequency[mostFrequentLetter]! / trimmedContent.length;
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
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,8 +1,8 @@
import 'package:journaler/db.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class Note { class Note {
final String date; final String id;
final int epochTime;
late final String displayDate; late final String displayDate;
String content; String content;
String? snippet; String? snippet;
@@ -10,192 +10,23 @@ class Note {
String problemReason; String problemReason;
Note({ Note({
required this.date, required this.id,
required this.epochTime,
required this.content, required this.content,
this.snippet, this.snippet,
this.isProblematic = false, this.isProblematic = false,
this.problemReason = '', this.problemReason = '',
}) { }) {
final dtUtc = DateFormat('yyyy-MM-dd HH:mm:ss').parse(date, true); final dtUtc = DateTime.fromMillisecondsSinceEpoch(epochTime, isUtc: true);
final dtLocal = dtUtc.toLocal(); final dtLocal = dtUtc.toLocal();
displayDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(dtLocal); 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 {
// 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();
}
// Find potentially problematic entries based on character distribution
Future<List<Note>> findProblematicEntries({
double maxCharPercentage = 0.7,
}) async {
// Simple SQLite query that counts character occurrences using replace
final List<Map<String, dynamic>> results = await DB.db.rawQuery(
'''
WITH char_counts AS (
SELECT
id,
date,
content,
substr(content, 1, 1) as char,
(length(content) - length(replace(content, substr(content, 1, 1), ''))) as char_count,
length(content) as total_length,
cast(length(content) - length(replace(content, substr(content, 1, 1), '')) as float) / length(content) as percentage
FROM notes
)
SELECT *
FROM char_counts
WHERE percentage > ?
ORDER BY date DESC
''',
[maxCharPercentage],
);
return results
.map(
(row) => Note(
date: row['date'] as String,
content: row['content'] as String,
isProblematic: true,
problemReason:
'Character "${row['char']}" makes up ${(row['percentage'] * 100).toStringAsFixed(1)}% of the content',
),
)
.toList();
} }

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 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:

View File

@@ -41,6 +41,8 @@ dependencies:
path: ^1.8.0 path: ^1.8.0
ps_list: ^0.0.5 ps_list: ^0.0.5
intl: ^0.20.2 intl: ^0.20.2
http: ^1.4.0
crypto: ^3.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: