import 'dart:convert'; import 'dart:core'; import 'package:http/http.dart' as http; import 'package:journaler/notes.dart'; const endpoint = 'https://meili.site.quack-lab.dev/'; const noteIndex = 'notes'; const scratchIndex = 'scratch'; const settingsIndex = 'settings'; const apiKey = String.fromEnvironment('MEILISEARCH_API_KEY', defaultValue: ''); 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 double 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'], ); } } // Settings Management Future getSetting(String key) async { final searchCondition = MeilisearchQuery(q: '', filter: 'key = $key'); final response = await http.post( Uri.parse('$endpoint/indexes/$settingsIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, 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 document = {'key': key, 'value': value}; final response = await http.post( Uri.parse('$endpoint/indexes/$settingsIndex/documents'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(document), ); if (response.statusCode != 200) { 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 searchCondition = MeilisearchQuery( q: query, limit: 1000, attributesToHighlight: ['content'], showRankingScore: true, highlightPreTag: '', highlightPostTag: '', ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, 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( date: hit['date'] as String, content: hit['content'] as String, ), ) .toList(); } Future getPreviousTo(String date) async { final searchCondition = MeilisearchQuery( q: '', filter: 'date < $date', sort: ['date DESC'], limit: 1, ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception('Failed to get previous note'); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { return null; } return Note( date: responseJson.hits.first['date'] as String, content: responseJson.hits.first['content'] as String, ); } Future getNextTo(String date) async { final searchCondition = MeilisearchQuery( q: '', filter: 'date > $date', sort: ['date ASC'], limit: 1, ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception('Failed to get next note'); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { return null; } return Note( date: responseJson.hits.first['date'] as String, content: responseJson.hits.first['content'] as String, ); } Future getLatest() async { final searchCondition = MeilisearchQuery( q: '', sort: ['date DESC'], limit: 1, ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception('Failed to get latest note'); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { return null; } return Note( date: responseJson.hits.first['date'] as String, content: responseJson.hits.first['content'] as String, ); } Future createNote(String content) async { 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('')) { 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': DateTime.now().toIso8601String(), 'content': content, 'topLetter': mostFrequentLetter, 'topLetterFrequency': mostFrequentLetterCount, }; final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/documents'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(document), ); if (response.statusCode != 200) { throw Exception('Failed to create note'); } return Note( date: document['id'] as String, content: document['content'] as String, ); } Future> getProblematic({double threshold = 0.7}) async { final searchCondition = MeilisearchQuery( q: '', filter: 'topLetterFrequency > $threshold', ); final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception('Failed to get problematic notes'); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); return responseJson.hits .map( (hit) => Note( date: hit['date'] as String, content: hit['content'] as String, isProblematic: true, problemReason: 'Character "${hit['topLetter']}" makes up ${(hit['topLetterFrequency'] * 100).toStringAsFixed(1)}% of the content', ), ) .toList(); } Future updateNote(String id, String content) async { final document = {'id': id, 'content': content}; final response = await http.post( Uri.parse('$endpoint/indexes/$noteIndex/documents'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(document), ); if (response.statusCode != 200) { throw Exception('Failed to update note'); } } Future deleteNote(String id) async { final response = await http.delete( Uri.parse('$endpoint/indexes/$noteIndex/documents/$id'), headers: {'Authorization': 'Bearer $apiKey'}, ); if (response.statusCode != 200) { throw Exception('Failed to delete note'); } } Future getLatestScratch() async { final searchCondition = MeilisearchQuery( q: '', sort: ['date DESC'], limit: 1, ); final response = await http.post( Uri.parse('$endpoint/indexes/$scratchIndex/search'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(searchCondition.toJson()), ); if (response.statusCode != 200) { throw Exception('Failed to get latest scratch'); } final responseJson = MeilisearchResponse.fromJson(jsonDecode(response.body)); if (responseJson.hits.isEmpty) { return null; } return Scratch( date: responseJson.hits.first['date'] as String, content: responseJson.hits.first['content'] as String, ); } Future createScratch(String content) async { final document = {'id': DateTime.now().toIso8601String(), 'content': content}; final response = await http.post( Uri.parse('$endpoint/indexes/$scratchIndex/documents'), headers: {'Authorization': 'Bearer $apiKey'}, body: jsonEncode(document), ); if (response.statusCode != 200) { throw Exception('Failed to create scratch'); } }