19 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
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
10 changed files with 1373 additions and 229 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

@@ -360,7 +360,8 @@ END;
} }
// Split into individual terms, filter empty ones // Split into individual terms, filter empty ones
List<String> terms = query List<String> terms =
query
.trim() .trim()
.split(RegExp(r'\s+')) .split(RegExp(r'\s+'))
.where((term) => term.isNotEmpty) .where((term) => term.isNotEmpty)
@@ -389,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
@@ -400,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
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_currentEntryFocusNode.requestFocus(); _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();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_currentEntryFocusNode.requestFocus(); _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) {
if (_currentlyDisplayedNote!.content != _previousEntryController.text) {
_currentlyDisplayedNote!.content = _previousEntryController.text; _currentlyDisplayedNote!.content = _previousEntryController.text;
await updateNote(_currentlyDisplayedNote!); 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) {
if (_currentlyDisplayedNote!.content != _previousEntryController.text) {
_currentlyDisplayedNote!.content = _previousEntryController.text; _currentlyDisplayedNote!.content = _previousEntryController.text;
await updateNote(_currentlyDisplayedNote!); 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,20 +581,16 @@ 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 = double.tryParse(volumeStr) ?? 0.7; _volume = volume;
_audioPlayer.setVolume(_linearToLogVolume(_volume)); _audioPlayer.setVolume(_linearToLogVolume(_volume));
}); });
} else {
_audioPlayer.setVolume(_linearToLogVolume(_volume));
}
} }
// Save volume setting to database // Save volume setting to database
Future<void> _saveVolume() async { Future<void> _saveVolume() async {
await DB.setSetting('notificationVolume', _volume.toString()); await setVolume(_volume);
debugPrint("Volume saved: $_volume"); debugPrint("Volume saved: $_volume");
} }
@@ -588,18 +607,23 @@ class MainPageState extends State<MainPage> with WindowListener {
_currentEntryController.clear(); // Clear the input field after saving _currentEntryController.clear(); // Clear the input field after saving
} }
// Handle scratch pad // Only create new scratch if content has changed
if (scratchContent != _originalScratchContent) {
await createScratch(scratchContent); await createScratch(scratchContent);
_originalScratchContent = scratchContent; // Update original content
}
// Handle previous/currently displayed note // Handle previous/currently displayed note
if (_currentlyDisplayedNote != null) { if (_currentlyDisplayedNote != null) {
if (_currentlyDisplayedNote!.content != previousEntry) {
_currentlyDisplayedNote!.content = previousEntry; _currentlyDisplayedNote!.content = previousEntry;
await updateNote(_currentlyDisplayedNote!); 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) {
@@ -617,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
@@ -651,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)) {
@@ -661,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
), ),
); );
} }
@@ -676,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,
), ),
), ),
); );
@@ -689,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),
), ),
); );
} }
@@ -756,7 +774,9 @@ class MainPageState extends State<MainPage> with WindowListener {
.toList(); .toList();
// Sort by date, newest first // Sort by date, newest first
filteredResults.sort((a, b) => b.date.compareTo(a.date)); filteredResults.sort(
(a, b) => b.epochTime.compareTo(a.epochTime),
);
// Important: update the dialog state after search completes // Important: update the dialog state after search completes
dialogSetState(() { dialogSetState(() {
@@ -810,35 +830,31 @@ class MainPageState extends State<MainPage> with WindowListener {
12, // Smaller font for date 12, // Smaller font for date
), ),
), ),
subtitle: subtitle: Text.rich(
note.snippet != null
? Text.rich(
TextSpan( TextSpan(
children: children: _buildHighlightedText(
_buildHighlightedText( note.snippet ?? note.content,
note.snippet!,
), ),
), ),
)
: Text(
note.content.length > 200
? '${note.content.substring(0, 200)}...'
: note.content,
style: const TextStyle(
fontSize: 13,
), // Smaller font for content
), ),
isThreeLine: true, isThreeLine: true,
onTap: () 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 !=
_previousEntryController
.text) {
_currentlyDisplayedNote!
.content =
_previousEntryController
.text;
await updateNote( await updateNote(
_currentlyDisplayedNote!, _currentlyDisplayedNote!,
); );
} }
}
// Navigate to the selected note // Navigate to the selected note
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -908,13 +924,321 @@ class MainPageState extends State<MainPage> with WindowListener {
); );
} }
// Show cleanup dialog
void _showCleanupDialog() async {
double sensitivity = 0.7; // Default 70%
final problematicEntries = await getProblematic(threshold: sensitivity);
if (!mounted) return;
showDialog(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, dialogSetState) {
return AlertDialog(
title: const Text('Cleanup Problematic Entries'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.7,
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
Row(
children: [
const Text('Sensitivity: '),
Expanded(
child: Slider(
value: sensitivity,
min: 0.3,
max: 0.9,
divisions: 12,
label: '${(sensitivity * 100).toInt()}%',
onChanged: (value) async {
dialogSetState(() {
sensitivity =
(value * 100).round() /
100; // Round to 2 decimal places
});
// Refresh results with new sensitivity
final newResults = await getProblematic(
threshold: sensitivity,
);
dialogSetState(() {
problematicEntries.clear();
problematicEntries.addAll(newResults);
});
},
),
),
Text('${(sensitivity * 100).toInt()}%'),
],
),
Text(
'Found ${problematicEntries.length} potentially problematic entries',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Expanded(
child:
problematicEntries.isEmpty
? const Center(
child: Text('No problematic entries found!'),
)
: ListView.builder(
itemCount: problematicEntries.length,
itemBuilder: (context, index) {
final note = problematicEntries[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
note.displayDate,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Reason: ${note.problemReason}',
style: TextStyle(
color: Colors.red[700],
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(note.content),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: 'View in main window',
onPressed: () {
// Close the dialog
Navigator.of(context).pop();
// Navigate to the note in main window
setState(() {
_currentlyDisplayedNote = note;
_previousEntryController.text =
note.content;
});
_checkNavigation();
},
),
IconButton(
icon: const Icon(Icons.delete),
color: Colors.red,
tooltip:
'Delete (hold Shift to skip confirmation)',
onPressed: () async {
// Check if shift is pressed
final isShiftPressed =
HardwareKeyboard
.instance
.isShiftPressed;
bool shouldDelete =
isShiftPressed;
if (!isShiftPressed) {
// Show confirmation dialog
shouldDelete =
await showDialog<bool>(
context: context,
builder:
(
context,
) => AlertDialog(
title: const Text(
'Delete Entry?',
),
content: const Text(
'Are you sure you want to delete this entry? This action cannot be undone.',
),
actions: [
TextButton(
onPressed:
() => Navigator.of(
context,
).pop(
false,
),
child:
const Text(
'Cancel',
),
),
TextButton(
onPressed:
() => Navigator.of(
context,
).pop(true),
child:
const Text(
'Delete',
),
),
],
),
) ??
false;
}
if (shouldDelete) {
await deleteNote(note.id);
dialogSetState(() {
problematicEntries.removeAt(
index,
);
});
}
},
),
],
),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close'),
),
],
);
},
);
},
);
}
// Show Meilisearch settings dialog
void _showMeilisearchSettings() {
final endpointController = TextEditingController();
final apiKeyController = TextEditingController();
bool isLoading = true;
String? errorMessage;
// Load current values
getMeilisearchEndpoint()
.then((value) {
endpointController.text = value;
isLoading = false;
})
.catchError((e) {
errorMessage = 'Failed to load endpoint: $e';
isLoading = false;
});
getMeilisearchApiKey()
.then((value) {
apiKeyController.text = value;
})
.catchError((e) {
errorMessage = 'Failed to load API key: $e';
});
showDialog(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Meilisearch Settings'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
const Center(child: CircularProgressIndicator())
else if (errorMessage != null)
Text(
errorMessage!,
style: const TextStyle(color: Colors.red),
)
else ...[
TextField(
controller: endpointController,
decoration: const InputDecoration(
labelText: 'Endpoint URL',
hintText: 'http://localhost:7700',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: apiKeyController,
decoration: const InputDecoration(
labelText: 'API Key',
hintText: 'masterKey',
border: OutlineInputBorder(),
),
obscureText: true,
),
],
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed:
isLoading
? null
: () async {
try {
setState(() {
isLoading = true;
errorMessage = null;
});
await setMeilisearchEndpoint(
endpointController.text,
);
await setMeilisearchApiKey(apiKeyController.text);
// Try to reinitialize Meilisearch with new settings
await init();
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
setState(() {
errorMessage = 'Failed to save settings: $e';
isLoading = false;
});
}
},
child: const Text('Save'),
),
],
);
},
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Wrap Scaffold with RawKeyboardListener as workaround for Escape key // Wrap Scaffold with RawKeyboardListener as workaround for Escape key
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
@@ -923,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
@@ -939,6 +1262,12 @@ class MainPageState extends State<MainPage> with WindowListener {
appBar: AppBar( appBar: AppBar(
title: const Text('Journaler'), title: const Text('Journaler'),
actions: <Widget>[ actions: <Widget>[
// Add cleanup button
IconButton(
icon: const Icon(Icons.cleaning_services),
tooltip: 'Cleanup Problematic Entries',
onPressed: _showCleanupDialog,
),
// Add search button // Add search button
IconButton( IconButton(
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
@@ -1021,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,154 +1,32 @@
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;
bool isProblematic;
String problemReason;
Note({required this.date, required this.content, this.snippet}) { Note({
final dtUtc = DateFormat('yyyy-MM-dd HH:mm:ss').parse(date, true); 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(); 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();
} }

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: