12 Commits

9 changed files with 1086 additions and 203 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

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:journaler/db.dart'; import 'package:journaler/db.dart';
import 'package:journaler/notes.dart'; import 'package:journaler/notes.dart';
import 'package:journaler/utils.dart';
import 'package:system_tray/system_tray.dart'; import 'package:system_tray/system_tray.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
@@ -11,6 +12,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 +68,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(
@@ -481,8 +492,9 @@ class MainPageState extends State<MainPage> with WindowListener {
return; return;
} }
final prev = await getPreviousNote(_currentlyDisplayedNote!.date); final prev = await getPreviousTo(_currentlyDisplayedNote!.epochTime);
final bool isLatest = _currentlyDisplayedNote!.date == previousNote?.date; final bool isLatest =
_currentlyDisplayedNote!.epochTime == previousNote?.epochTime;
setState(() { setState(() {
_canGoPrevious = prev != null; _canGoPrevious = prev != null;
@@ -499,7 +511,7 @@ class MainPageState extends State<MainPage> with WindowListener {
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;
@@ -518,7 +530,7 @@ class MainPageState extends State<MainPage> with WindowListener {
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,21 +541,18 @@ 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 ?? "";
@@ -558,20 +567,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");
} }
@@ -599,7 +604,7 @@ class MainPageState extends State<MainPage> with WindowListener {
// 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 +622,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 +652,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)) {
@@ -663,7 +664,7 @@ class MainPageState extends State<MainPage> with WindowListener {
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 +677,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 +690,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 +757,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,23 +813,12 @@ 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 {
@@ -908,6 +900,307 @@ 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
@@ -939,6 +1232,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 +1320,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),
], ],
), ),

518
lib/meilisearch.dart Normal file
View File

@@ -0,0 +1,518 @@
import 'dart:convert';
import 'dart:core';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:journaler/notes.dart';
import 'package:journaler/meilisearch_config.dart';
const noteIndex = 'notes';
const scratchIndex = 'scratch';
const settingsIndex = 'settings';
final alphanum = RegExp(r'[a-zA-Z0-9]', caseSensitive: false);
Future<Map<String, String>> _getHeaders() async {
final apiKey = await getMeilisearchApiKey();
return {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
};
}
Future<String> _getEndpoint() async {
return await getMeilisearchEndpoint();
}
class MeilisearchQuery {
String q;
String? filter;
int? limit;
int? offset;
bool? showRankingScore;
double? rankingScoreThreshold;
String? highlightPreTag;
String? highlightPostTag;
List<String>? attributesToHighlight;
List<String>? sort;
MeilisearchQuery({
required this.q,
this.filter,
this.sort,
this.limit,
this.offset,
this.showRankingScore,
this.rankingScoreThreshold,
this.highlightPreTag,
this.highlightPostTag,
this.attributesToHighlight,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> json = {'q': q};
if (filter != null) json['filter'] = filter;
if (sort != null) json['sort'] = sort;
if (limit != null) json['limit'] = limit;
if (offset != null) json['offset'] = offset;
if (showRankingScore != null) json['showRankingScore'] = showRankingScore;
if (rankingScoreThreshold != null) {
json['rankingScoreThreshold'] = rankingScoreThreshold;
}
if (highlightPreTag != null) json['highlightPreTag'] = highlightPreTag;
if (highlightPostTag != null) json['highlightPostTag'] = highlightPostTag;
if (attributesToHighlight != null) {
json['attributesToHighlight'] = attributesToHighlight;
}
return json;
}
}
class MeilisearchResponse {
final List<dynamic> hits;
final String query;
final int processingTimeMs;
final int limit;
final int offset;
final int estimatedTotalHits;
MeilisearchResponse({
required this.hits,
required this.query,
required this.processingTimeMs,
required this.limit,
required this.offset,
required this.estimatedTotalHits,
});
static MeilisearchResponse fromJson(Map<String, dynamic> json) {
return MeilisearchResponse(
hits: json['hits'],
query: json['query'],
processingTimeMs: json['processingTimeMs'],
limit: json['limit'],
offset: json['offset'],
estimatedTotalHits: json['estimatedTotalHits'],
);
}
}
Future<void> init() async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
if (!await indexExists(noteIndex)) {
await http.post(
Uri.parse('$endpoint/indexes'),
headers: headers,
body: jsonEncode({'uid': noteIndex, 'primaryKey': 'id'}),
);
}
await http.put(
Uri.parse('$endpoint/indexes/$noteIndex/settings/sortable-attributes'),
headers: headers,
body: jsonEncode(['date']),
);
await http.put(
Uri.parse('$endpoint/indexes/$noteIndex/settings/filterable-attributes'),
headers: headers,
body: jsonEncode(['date', 'topLetter', 'topLetterFrequency']),
);
if (!await indexExists(scratchIndex)) {
await http.post(
Uri.parse('$endpoint/indexes'),
headers: headers,
body: jsonEncode({'uid': scratchIndex, 'primaryKey': 'id'}),
);
}
await http.put(
Uri.parse('$endpoint/indexes/$scratchIndex/settings/sortable-attributes'),
headers: headers,
body: jsonEncode(['date']),
);
await http.put(
Uri.parse('$endpoint/indexes/$scratchIndex/settings/filterable-attributes'),
headers: headers,
body: jsonEncode(['date']),
);
if (!await indexExists(settingsIndex)) {
await http.post(
Uri.parse('$endpoint/indexes'),
headers: headers,
body: jsonEncode({'uid': settingsIndex, 'primaryKey': 'key'}),
);
}
}
Future<bool> indexExists(String index) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final response = await http.get(
Uri.parse('$endpoint/indexes/$index'),
headers: headers,
);
return response.statusCode == 200;
}
// Settings Management
Future<String?> getSetting(String key) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(q: '', filter: 'key = $key');
final response = await http.post(
Uri.parse('$endpoint/indexes/$settingsIndex/search'),
headers: headers,
body: jsonEncode(searchCondition.toJson()),
);
if (response.statusCode != 200) {
throw Exception('Failed to get settings');
}
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
if (responseJson.hits.isEmpty) {
return null;
}
return responseJson.hits.first['value'] as String?;
}
Future<void> setSetting(String key, String value) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final document = {'key': key, 'value': value};
final response = await http.post(
Uri.parse('$endpoint/indexes/$settingsIndex/documents'),
headers: headers,
body: jsonEncode(document),
);
if (response.statusCode != 202) {
throw Exception('Failed to set settings');
}
}
// Maybe we could factor a lot of this out into a separate function
// But we don't care for now...
Future<List<Note>> searchNotes(String query) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(
q: query,
limit: 10,
attributesToHighlight: ['content'],
showRankingScore: true,
highlightPreTag: '<highlight>',
highlightPostTag: '</highlight>',
);
final response = await http.post(
Uri.parse('$endpoint/indexes/$noteIndex/search'),
headers: headers,
body: jsonEncode(searchCondition.toJson()),
);
if (response.statusCode != 200) {
throw Exception('Failed to search notes');
}
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
return responseJson.hits
.map(
(hit) => Note(
id: hit['id'] as String,
epochTime: hit['date'] as int,
content: hit['content'] as String,
snippet: hit['_formatted']['content'] as String,
),
)
.toList();
}
Future<Note?> getPreviousTo(int epochTime) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(
q: '',
filter: 'date < $epochTime',
sort: ['date:desc'],
limit: 1,
);
final response = await http.post(
Uri.parse('$endpoint/indexes/$noteIndex/search'),
headers: headers,
body: jsonEncode(searchCondition.toJson()),
);
if (response.statusCode != 200) {
throw Exception(
'Failed to get previous note, backend responded with ${response.statusCode}',
);
}
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
if (responseJson.hits.isEmpty) {
return null;
}
return Note(
id: responseJson.hits.first['id'] as String,
epochTime: responseJson.hits.first['date'] as int,
content: responseJson.hits.first['content'] as String,
);
}
Future<Note?> getNextTo(int epochTime) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(
q: '',
filter: 'date > $epochTime',
sort: ['date:asc'],
limit: 1,
);
final response = await http.post(
Uri.parse('$endpoint/indexes/$noteIndex/search'),
headers: headers,
body: jsonEncode(searchCondition.toJson()),
);
if (response.statusCode != 200) {
throw Exception(
'Failed to get next note, backend responded with ${response.statusCode}',
);
}
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
if (responseJson.hits.isEmpty) {
return null;
}
return Note(
id: responseJson.hits.first['id'] as String,
epochTime: responseJson.hits.first['date'] as int,
content: responseJson.hits.first['content'] as String,
);
}
Future<Note?> getLatest() async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final searchCondition = MeilisearchQuery(
q: '',
sort: ['date:desc'],
limit: 1,
);
final response = await http.post(
Uri.parse('$endpoint/indexes/$noteIndex/search'),
headers: headers,
body: jsonEncode(searchCondition.toJson()),
);
if (response.statusCode != 200) {
throw Exception(
'Failed to get latest note, backend responded with ${response.statusCode}',
);
}
final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body));
if (responseJson.hits.isEmpty) {
return null;
}
return Note(
id: responseJson.hits.first['id'] as String,
epochTime: responseJson.hits.first['date'] as int,
content: responseJson.hits.first['content'] as String,
);
}
String generateRandomString(int length) {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var result = '';
for (var i = 0; i < length; i++) {
final randomIndex = Random().nextInt(characters.length);
result += characters[randomIndex];
}
return result;
}
Future<Note> createNote(String content) async {
final endpoint = await _getEndpoint();
final headers = await _getHeaders();
final lines = content.split('\n');
final trimmedLines = <String>[];
for (final line in lines) {
final trimmedContent = line.trim().replaceAll(RegExp(r'\s{2,}'), ' ');
if (trimmedContent.isEmpty) {
continue;
}
trimmedLines.add(trimmedContent);
}
final trimmedContent = trimmedLines.join('\n');
final letterFrequency = <String, int>{};
for (final char in trimmedContent.split('')) {
if (alphanum.hasMatch(char)) {
letterFrequency[char] = (letterFrequency[char] ?? 0) + 1;
}
}
final mostFrequentLetter =
letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key;
final mostFrequentLetterCount = letterFrequency[mostFrequentLetter];
final document = {
'id': generateRandomString(32),
'date': DateTime.now().toUtc().millisecondsSinceEpoch,
'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,
'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: