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> _getHeaders() async { final apiKey = await getMeilisearchApiKey(); return { 'Authorization': 'Bearer $apiKey', 'Content-Type': 'application/json', }; } Future _getEndpoint() async { return await getMeilisearchEndpoint(); } class MeilisearchQuery { String q; String? filter; int? limit; int? offset; bool? showRankingScore; double? rankingScoreThreshold; String? highlightPreTag; String? highlightPostTag; List? attributesToHighlight; List? sort; MeilisearchQuery({ required this.q, this.filter, this.sort, this.limit, this.offset, this.showRankingScore, this.rankingScoreThreshold, this.highlightPreTag, this.highlightPostTag, this.attributesToHighlight, }); Map toJson() { final Map 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 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 json) { return MeilisearchResponse( hits: json['hits'], query: json['query'], processingTimeMs: json['processingTimeMs'], limit: json['limit'], offset: json['offset'], estimatedTotalHits: json['estimatedTotalHits'], ); } } Future 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 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 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 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> searchNotes(String query) async { final endpoint = await _getEndpoint(); final headers = await _getHeaders(); final searchCondition = MeilisearchQuery( q: query, limit: 10, attributesToHighlight: ['content'], showRankingScore: true, highlightPreTag: '', highlightPostTag: '', ); 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 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 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 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 createNote(String content) async { final endpoint = await _getEndpoint(); final headers = await _getHeaders(); final lines = 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; } } // Handle the case where there are no alphanumeric characters String mostFrequentLetter = 'a'; // Default value double mostFrequentLetterCount = 0.0; // Default value if (letterFrequency.isNotEmpty) { mostFrequentLetter = letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key; mostFrequentLetterCount = letterFrequency[mostFrequentLetter]! / (trimmedContent.length > 0 ? trimmedContent.length : 1); } final document = { 'id': generateRandomString(32), 'date': DateTime.now().toUtc().millisecondsSinceEpoch, 'dateISO': DateTime.now().toUtc().toIso8601String(), 'content': content, 'topLetter': mostFrequentLetter, 'topLetterFrequency': mostFrequentLetterCount, }; final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/documents'), headers: headers, body: jsonEncode(document), ); if (response.statusCode != 202) { throw Exception('Failed to create note'); } return Note( id: document['id'] as String, epochTime: document['date'] as int, content: document['content'] as String, ); } Future> 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 updateNote(Note note) async { final endpoint = await _getEndpoint(); final headers = await _getHeaders(); 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; } } // Handle the case where there are no alphanumeric characters String mostFrequentLetter = 'a'; // Default value double mostFrequentLetterRatio = 0.0; // Default value if (letterFrequency.isNotEmpty) { mostFrequentLetter = letterFrequency.entries.reduce((a, b) => a.value > b.value ? a : b).key; mostFrequentLetterRatio = letterFrequency[mostFrequentLetter]! / (trimmedContent.length > 0 ? trimmedContent.length : 1); } final document = { 'id': note.id, 'content': trimmedContent, 'date': note.epochTime, 'dateISO': DateTime.fromMillisecondsSinceEpoch( note.epochTime, ).toUtc().toIso8601String(), 'topLetter': mostFrequentLetter, 'topLetterFrequency': mostFrequentLetterRatio, }; final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/documents'), headers: headers, body: jsonEncode(document), ); if (response.statusCode != 202) { throw Exception( 'Failed to update note, backend responded with ${response.statusCode}', ); } } Future 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 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 createScratch(String content) async { final endpoint = await _getEndpoint(); final headers = await _getHeaders(); final document = { 'id': generateRandomString(32), 'date': DateTime.now().toUtc().millisecondsSinceEpoch, 'content': content, }; final response = await http.post( Uri.parse('$endpoint/indexes/$scratchIndex/documents'), headers: headers, body: jsonEncode(document), ); if (response.statusCode != 202) { throw Exception( 'Failed to create scratch, backend responded with ${response.statusCode}', ); } return Scratch( id: document['id'] as String, epochTime: document['date'] as int, content: document['content'] as String, ); } Future> getNotesBefore(int epochTime, {int limit = 50}) async { final endpoint = await _getEndpoint(); final headers = await _getHeaders(); final searchCondition = MeilisearchQuery( q: '', filter: 'date < $epochTime', sort: ['date:desc'], limit: limit, ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: headers, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception( 'Failed to get notes before timestamp, backend responded with ${response.statusCode}', ); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); return responseJson.hits .map( (hit) => Note( id: hit['id'] as String, epochTime: hit['date'] as int, content: hit['content'] as String, ), ) .toList(); } Future> getNotesAfter(int epochTime, {int limit = 50}) async { final endpoint = await _getEndpoint(); final headers = await _getHeaders(); final searchCondition = MeilisearchQuery( q: '', filter: 'date > $epochTime', sort: ['date:asc'], limit: limit, ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: headers, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception( 'Failed to get notes after timestamp, backend responded with ${response.statusCode}', ); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); return responseJson.hits .map( (hit) => Note( id: hit['id'] as String, epochTime: hit['date'] as int, content: hit['content'] as String, ), ) .toList(); } Future getPopupInterval() async { final value = await getSetting('popupInterval'); if (value == null) { return const Duration(minutes: 20); } return Duration(minutes: int.parse(value)); } Future setPopupInterval(Duration interval) async { await setSetting('popupInterval', interval.inMinutes.toString()); } Future getCacheSizeBefore() async { final value = await getSetting('cacheSizeBefore'); if (value == null) { return 50; // Default value } return int.parse(value); } Future setCacheSizeBefore(int size) async { await setSetting('cacheSizeBefore', size.toString()); } Future getCacheSizeAfter() async { final value = await getSetting('cacheSizeAfter'); if (value == null) { return 50; // Default value } return int.parse(value); } Future setCacheSizeAfter(int size) async { await setSetting('cacheSizeAfter', size.toString()); }