From 3c1f31d29bf66083dc7d331e8c61929640266891 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Sat, 24 May 2025 00:05:44 +0200 Subject: [PATCH] Refactor note handling to use epoch time and improve utility functions for settings management --- lib/main.dart | 54 ++++++--------- lib/meilisearch.dart | 162 ++++++++++++++++++++++++++++++------------- lib/notes.dart | 11 +-- lib/utils.dart | 39 +++++++++++ 4 files changed, 182 insertions(+), 84 deletions(-) create mode 100644 lib/utils.dart diff --git a/lib/main.dart b/lib/main.dart index 779d85e..dbd804e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:journaler/db.dart'; import 'package:journaler/notes.dart'; +import 'package:journaler/utils.dart'; import 'package:system_tray/system_tray.dart'; import 'package:window_manager/window_manager.dart'; import 'package:audioplayers/audioplayers.dart'; @@ -483,8 +484,9 @@ class MainPageState extends State with WindowListener { return; } - final prev = await getPreviousTo(_currentlyDisplayedNote!.date); - final bool isLatest = _currentlyDisplayedNote!.date == previousNote?.date; + final prev = await getPreviousTo(_currentlyDisplayedNote!.epochTime); + final bool isLatest = + _currentlyDisplayedNote!.epochTime == previousNote?.epochTime; setState(() { _canGoPrevious = prev != null; @@ -498,10 +500,10 @@ class MainPageState extends State with WindowListener { // Save the current note content before navigating away if (_currentlyDisplayedNote != null) { _currentlyDisplayedNote!.content = _previousEntryController.text; - await updateNote(_currentlyDisplayedNote!.date, _currentlyDisplayedNote!.content); + await updateNote(_currentlyDisplayedNote!); } - final prevNote = await getPreviousTo(_currentlyDisplayedNote!.date); + final prevNote = await getPreviousTo(_currentlyDisplayedNote!.epochTime); if (prevNote != null) { setState(() { _currentlyDisplayedNote = prevNote; @@ -517,10 +519,10 @@ class MainPageState extends State with WindowListener { // Save the current note content before navigating away if (_currentlyDisplayedNote != null) { _currentlyDisplayedNote!.content = _previousEntryController.text; - await updateNote(_currentlyDisplayedNote!.date, _currentlyDisplayedNote!.content); + await updateNote(_currentlyDisplayedNote!); } - final nextNote = await getNextTo(_currentlyDisplayedNote!.date); + final nextNote = await getNextTo(_currentlyDisplayedNote!.epochTime); if (nextNote != null) { setState(() { _currentlyDisplayedNote = nextNote; @@ -531,16 +533,13 @@ class MainPageState extends State with WindowListener { } void _loadData() async { - String? intervalMinutesStr = await DB.getSetting('popupIntervalMinutes'); - String? soundFileStr = await DB.getSetting('notificationSound'); + Duration interval = await getPopupInterval(); + String soundFile = await getNotificationSound(); - int intervalMinutes = - int.tryParse(intervalMinutesStr ?? '') ?? - _defaultPopupInterval.inMinutes; - _currentPopupInterval = Duration(minutes: intervalMinutes); - _currentNotificationSound = soundFileStr ?? _defaultNotificationSound; + _currentPopupInterval = interval; + _currentNotificationSound = soundFile; - _intervalController.text = intervalMinutes.toString(); + _intervalController.text = interval.inMinutes.toString(); _soundController.text = _currentNotificationSound; _startPopupTimer(); @@ -560,20 +559,16 @@ class MainPageState extends State with WindowListener { // Load volume setting from database Future _loadVolume() async { - String? volumeStr = await DB.getSetting('notificationVolume'); - if (volumeStr != null) { - setState(() { - _volume = double.tryParse(volumeStr) ?? 0.7; - _audioPlayer.setVolume(_linearToLogVolume(_volume)); - }); - } else { + double? volume = await getVolume(); + setState(() { + _volume = volume; _audioPlayer.setVolume(_linearToLogVolume(_volume)); - } + }); } // Save volume setting to database Future _saveVolume() async { - await DB.setSetting('notificationVolume', _volume.toString()); + await setVolume(_volume); debugPrint("Volume saved: $_volume"); } @@ -596,7 +591,7 @@ class MainPageState extends State with WindowListener { // Handle previous/currently displayed note if (_currentlyDisplayedNote != null) { _currentlyDisplayedNote!.content = previousEntry; - await updateNote(_currentlyDisplayedNote!.date, _currentlyDisplayedNote!.content); + await updateNote(_currentlyDisplayedNote!); // If the note was deleted (due to being empty), update the UI state if (previousEntry.isEmpty) { @@ -759,7 +754,7 @@ class MainPageState extends State with WindowListener { // Sort by date, newest first filteredResults.sort( - (a, b) => b.date.compareTo(a.date), + (a, b) => b.epochTime.compareTo(a.epochTime), ); // Important: update the dialog state after search completes @@ -840,8 +835,7 @@ class MainPageState extends State with WindowListener { _currentlyDisplayedNote!.content = _previousEntryController.text; await updateNote( - _currentlyDisplayedNote!.date, - _currentlyDisplayedNote!.content, + _currentlyDisplayedNote!, ); } @@ -916,9 +910,7 @@ class MainPageState extends State with WindowListener { // Show cleanup dialog void _showCleanupDialog() async { double sensitivity = 0.7; // Default 70% - final problematicEntries = await getProblematic( - threshold: sensitivity, - ); + final problematicEntries = await getProblematic(threshold: sensitivity); if (!mounted) return; @@ -1080,7 +1072,7 @@ class MainPageState extends State with WindowListener { } if (shouldDelete) { - await deleteNote(note.date); + await deleteNote(note.id); dialogSetState(() { problematicEntries.removeAt( index, diff --git a/lib/meilisearch.dart b/lib/meilisearch.dart index 0f5d761..43550a8 100644 --- a/lib/meilisearch.dart +++ b/lib/meilisearch.dart @@ -17,6 +17,7 @@ const header = { 'Authorization': 'Bearer $apiKey', 'Content-Type': 'application/json', }; +final alphanum = RegExp(r'[a-zA-Z0-9]', caseSensitive: false); class MeilisearchQuery { String q; @@ -63,12 +64,12 @@ class MeilisearchQuery { } class MeilisearchResponse { - final List> hits; + final List hits; final String query; final int processingTimeMs; final int limit; final int offset; - final double estimatedTotalHits; + final int estimatedTotalHits; MeilisearchResponse({ required this.hits, @@ -98,14 +99,17 @@ Future init() async { headers: header, body: jsonEncode({'uid': noteIndex, 'primaryKey': 'id'}), ); - await http.put( - Uri.parse('$endpoint/indexes/$noteIndex/settings/sortable-attributes'), - headers: header, - body: jsonEncode({ - 'attributes': ['date'], - }), - ); } + await http.put( + Uri.parse('$endpoint/indexes/$noteIndex/settings/sortable-attributes'), + headers: header, + body: jsonEncode(['date']), + ); + await http.put( + Uri.parse('$endpoint/indexes/$noteIndex/settings/filterable-attributes'), + headers: header, + body: jsonEncode(['date', 'topLetter', 'topLetterFrequency']), + ); if (!await indexExists(scratchIndex)) { await http.post( @@ -113,14 +117,17 @@ Future init() async { headers: header, body: jsonEncode({'uid': scratchIndex, 'primaryKey': 'id'}), ); - await http.put( - Uri.parse('$endpoint/indexes/$scratchIndex/settings/sortable-attributes'), - headers: header, - body: jsonEncode({ - 'attributes': ['date'], - }), - ); } + await http.put( + Uri.parse('$endpoint/indexes/$scratchIndex/settings/sortable-attributes'), + headers: header, + body: jsonEncode(['date']), + ); + await http.put( + Uri.parse('$endpoint/indexes/$scratchIndex/settings/filterable-attributes'), + headers: header, + body: jsonEncode(['date']), + ); if (!await indexExists(settingsIndex)) { await http.post( @@ -193,18 +200,18 @@ Future> searchNotes(String query) async { .map( (hit) => Note( id: hit['id'] as String, - date: hit['date'] as String, + epochTime: hit['date'] as int, content: hit['content'] as String, ), ) .toList(); } -Future getPreviousTo(String date) async { +Future getPreviousTo(int epochTime) async { final searchCondition = MeilisearchQuery( q: '', - filter: 'date < $date', - sort: ['date DESC'], + filter: 'date < $epochTime', + sort: ['date:desc'], limit: 1, ); final response = await http.post( @@ -213,7 +220,9 @@ Future getPreviousTo(String date) async { body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { - throw Exception('Failed to get previous note'); + throw Exception( + 'Failed to get previous note, backend responded with ${response.statusCode}', + ); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { @@ -221,16 +230,16 @@ Future getPreviousTo(String date) async { } return Note( id: responseJson.hits.first['id'] as String, - date: responseJson.hits.first['date'] as String, + epochTime: responseJson.hits.first['date'] as int, content: responseJson.hits.first['content'] as String, ); } -Future getNextTo(String date) async { +Future getNextTo(int epochTime) async { final searchCondition = MeilisearchQuery( q: '', - filter: 'date > $date', - sort: ['date ASC'], + filter: 'date > $epochTime', + sort: ['date:asc'], limit: 1, ); final response = await http.post( @@ -239,7 +248,9 @@ Future getNextTo(String date) async { body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { - throw Exception('Failed to get next note'); + throw Exception( + 'Failed to get next note, backend responded with ${response.statusCode}', + ); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { @@ -247,7 +258,7 @@ Future getNextTo(String date) async { } return Note( id: responseJson.hits.first['id'] as String, - date: responseJson.hits.first['date'] as String, + epochTime: responseJson.hits.first['date'] as int, content: responseJson.hits.first['content'] as String, ); } @@ -264,7 +275,9 @@ Future getLatest() async { body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { - throw Exception('Failed to get latest note'); + throw Exception( + 'Failed to get latest note, backend responded with ${response.statusCode}', + ); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { @@ -272,7 +285,7 @@ Future getLatest() async { } return Note( id: responseJson.hits.first['id'] as String, - date: responseJson.hits.first['date'] as String, + epochTime: responseJson.hits.first['date'] as int, content: responseJson.hits.first['content'] as String, ); } @@ -302,7 +315,9 @@ Future createNote(String content) async { final letterFrequency = {}; for (final char in trimmedContent.split('')) { - letterFrequency[char] = (letterFrequency[char] ?? 0) + 1; + if (alphanum.hasMatch(char)) { + letterFrequency[char] = (letterFrequency[char] ?? 0) + 1; + } } final mostFrequentLetter = @@ -311,7 +326,7 @@ Future createNote(String content) async { final document = { 'id': generateRandomString(32), - 'date': DateTime.now().toIso8601String(), + 'date': DateTime.now().toUtc().millisecondsSinceEpoch, 'content': content, 'topLetter': mostFrequentLetter, 'topLetterFrequency': mostFrequentLetterCount, @@ -326,7 +341,7 @@ Future createNote(String content) async { } return Note( id: document['id'] as String, - date: document['id'] as String, + epochTime: document['date'] as int, content: document['content'] as String, ); } @@ -342,14 +357,16 @@ Future> getProblematic({double threshold = 0.7}) async { body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { - throw Exception('Failed to get problematic notes'); + 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, - date: hit['date'] as String, + epochTime: hit['date'] as int, content: hit['content'] as String, isProblematic: true, problemReason: @@ -359,16 +376,49 @@ Future> getProblematic({double threshold = 0.7}) async { .toList(); } -Future updateNote(String id, String content) async { - // TODO: Trim and calculate frequency - final document = {'id': id, 'content': content}; +// TODO: only update if changed +// How? idk +Future updateNote(Note note) async { + final lines = note.content.split('\n'); + final trimmedLines = []; + 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 = {}; + 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: header, body: jsonEncode(document), ); - if (response.statusCode != 200) { - throw Exception('Failed to update note'); + if (response.statusCode != 202) { + throw Exception( + 'Failed to update note, backend responded with ${response.statusCode}', + ); } } @@ -377,15 +427,17 @@ Future deleteNote(String id) async { Uri.parse('$endpoint/indexes/$noteIndex/documents/$id'), headers: header, ); - if (response.statusCode != 200) { - throw Exception('Failed to delete note'); + if (response.statusCode != 202) { + throw Exception( + 'Failed to delete note, backend responded with ${response.statusCode}', + ); } } Future getLatestScratch() async { final searchCondition = MeilisearchQuery( q: '', - sort: ['date DESC'], + sort: ['date:desc'], limit: 1, ); final response = await http.post( @@ -394,26 +446,40 @@ Future getLatestScratch() async { body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { - throw Exception('Failed to get latest scratch'); + 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( - date: responseJson.hits.first['date'] as String, + id: responseJson.hits.first['id'] as String, + epochTime: responseJson.hits.first['date'] as int, content: responseJson.hits.first['content'] as String, ); } -Future createScratch(String content) async { - final document = {'id': DateTime.now().toIso8601String(), 'content': content}; +Future createScratch(String content) async { + final document = { + 'id': generateRandomString(32), + 'date': DateTime.now().toUtc().millisecondsSinceEpoch, + 'content': content, + }; final response = await http.post( Uri.parse('$endpoint/indexes/$scratchIndex/documents'), headers: header, body: jsonEncode(document), ); - if (response.statusCode != 200) { - throw Exception('Failed to create scratch'); + 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, + ); } diff --git a/lib/notes.dart b/lib/notes.dart index 07d3063..20103a5 100644 --- a/lib/notes.dart +++ b/lib/notes.dart @@ -2,7 +2,7 @@ import 'package:intl/intl.dart'; class Note { final String id; - final String date; + final int epochTime; late final String displayDate; String content; String? snippet; @@ -11,21 +11,22 @@ class Note { Note({ required this.id, - required this.date, + required this.epochTime, required this.content, this.snippet, this.isProblematic = false, this.problemReason = '', }) { - final dtUtc = DateFormat('yyyy-MM-dd HH:mm:ss').parse(date, true); + final dtUtc = DateTime.fromMillisecondsSinceEpoch(epochTime, isUtc: true); final dtLocal = dtUtc.toLocal(); displayDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(dtLocal); } } class Scratch { - final String date; + final int epochTime; + final String id; String content; - Scratch({required this.date, required this.content}); + Scratch({required this.id, required this.epochTime, required this.content}); } diff --git a/lib/utils.dart b/lib/utils.dart new file mode 100644 index 0000000..d3d57cd --- /dev/null +++ b/lib/utils.dart @@ -0,0 +1,39 @@ +import 'package:journaler/meilisearch.dart'; + +Future getVolume() async { + try { + final volumeStr = await getSetting('notificationVolume'); + return double.tryParse(volumeStr ?? '0.7') ?? 0.7; + } catch (e) { + return 0.7; + } +} + +Future setVolume(double volume) async { + await setSetting('notificationVolume', volume.toString()); +} + +Future getPopupInterval() async { + try { + final intervalStr = await getSetting('popupIntervalMinutes'); + return Duration(minutes: int.tryParse(intervalStr ?? '10') ?? 10); + } catch (e) { + return Duration(minutes: 10); + } +} + +Future setPopupInterval(Duration interval) async { + await setSetting('popupIntervalMinutes', interval.inMinutes.toString()); +} + +Future getNotificationSound() async { + try { + return await getSetting('notificationSound') ?? 'MeetTheSniper.mp3'; + } catch (e) { + return 'MeetTheSniper.mp3'; + } +} + +Future setNotificationSound(String sound) async { + await setSetting('notificationSound', sound); +}