cache management

This commit is contained in:
randogoth 2025-03-04 20:50:16 +02:00
parent ab4accfc8c
commit fa7e5b1c62
2 changed files with 268 additions and 108 deletions

106
README.md
View file

@ -2,7 +2,7 @@
# Rugreefer # Rugreefer
**Rugreefer** is a Dart library for retrieving metadata about geographic coordinates from a [dopecarpet](http://github.com/TheRandonauts/dopecarpet) server. It converts coordinates into a geohash, fetches a gzipped GeoJSON file from the API, and extracts relevant spatial data such as polygons and lines. The library also supports caching with optional expiration to improve performance. Rugreefer is a Dart library for retrieving metadata about geographic coordinates from a [dopecarpet](http://github.com/TheRandonauts/dopecarpet) server. It converts coordinates into a geohash, fetches a gzipped GeoJSON file from the API, and extracts relevant spatial data such as polygons and lines. The library also supports caching with optional expiration to improve performance.
## Features ## Features
- Converts latitude/longitude to geohash (precision: 4 characters) - Converts latitude/longitude to geohash (precision: 4 characters)
@ -10,10 +10,10 @@
- Extracts relevant metadata from polygons and lines - Extracts relevant metadata from polygons and lines
- Supports caching for performance optimization - Supports caching for performance optimization
- Optional cache expiration (default: 14 days) - Optional cache expiration (default: 14 days)
- Works in **Flutter** (mobile, desktop, web) - Works in Flutter (mobile, desktop, web)
## Installation ## Installation
Add **rugreefer** to your `pubspec.yaml`: Add rugreefer to your `pubspec.yaml`:
```yaml ```yaml
dependencies: dependencies:
@ -29,17 +29,23 @@ flutter pub get
## Usage ## Usage
### **Basic Example** ### Basic Example
To create an instance of `RugReefer`, provide the required parameters:
```dart ```dart
import 'package:rugreefer/rugreefer.dart'; import 'package:rugreefer/rugreefer.dart';
void main() async { void main() async {
final lookup = RugReefer( final rugReefer = RugReefer(
apiUrl: 'http://localhost:8000/get_geodata/?format=geojson&geohash=', apiUrl: 'https://example.com/api', // API endpoint for geospatial data
enableCacheExpiration: true, categoryMap: 'assets/categories.json', // Path to the JSON asset for category mapping
); enableCacheExpiration: true, // Optional: Enable cache expiration (default: false)
cacheExpirationDuration: Duration(days: 14), // Optional: Cache expiration duration (default: 14 days)
cacheSizeThreshold: 20 * 1024 * 1024, // Optional: Cache size threshold in bytes (default: 20MB)
);
final results = await lookup.lookup(30.5735040, 34.453125); final results = await rugReefer.lookup(30.5735040, 34.453125);
print(results); print(results);
} }
``` ```
@ -62,13 +68,83 @@ Example Output:
] ]
``` ```
### **Custom Cache Settings** ## Available Methods
### 1. Lookup Metadata
Perform a geospatial metadata lookup for a given latitude and longitude.
```dart ```dart
final lookup = RugReefer( Future<Map<String, Map<String, List<String>>>> lookup(
apiUrl: 'http://your-api.com/get_geodata/?format=geojson&geohash=', double latitude,
enableCacheExpiration: true, double longitude, {
cacheExpirationDuration: Duration(days: 7), double distanceThreshold = 10.0, // Optional: Distance threshold in meters (default: 10.0)
); });
```
Example:
```dart
final metadata = await rugReefer.lookup(37.7749, -122.4194);
print(metadata);
```
---
### 2. Get Current Cache Size
Get the total size of the cache in bytes.
```dart
Future<int> getCurrentCacheSize();
```
Example:
```dart
final cacheSize = await rugReefer.getCurrentCacheSize();
print("Cache size: ${cacheSize / 1024 / 1024} MB");
```
---
### 3. List Cached Files
List all currently cached files with their sizes and last access times.
```dart
Future<List<Map<String, dynamic>>> listCachedFiles();
```
Example:
```dart
final cachedFiles = await rugReefer.listCachedFiles();
for (var file in cachedFiles) {
print("File: ${file['file']}, Size: ${file['size']} bytes, Last Access: ${file['lastAccess']}");
}
```
---
### 4. Delete a Specific Cached File
Delete a specific cached file by its geohash.
```dart
Future<void> deleteCachedFile(String geohash);
```
Example:
```dart
await rugReefer.deleteCachedFile('u4pru');
```
---
### 5. Clear the Entire Cache
Delete all cached files and remove their last access times.
```dart
Future<void> clearCache();
```
Example:
```dart
await rugReefer.clearCache();
``` ```
## Author ## Author

View file

@ -14,6 +14,7 @@ class RugReefer {
final Duration cacheExpirationDuration; final Duration cacheExpirationDuration;
final http.Client httpClient; final http.Client httpClient;
final String categoryMap; final String categoryMap;
final int cacheSizeThreshold; // Cache size threshold in bytes (default: 20MB)
Directory? cacheDir; Directory? cacheDir;
RugReefer({ RugReefer({
@ -21,6 +22,7 @@ class RugReefer {
required this.categoryMap, required this.categoryMap,
this.enableCacheExpiration = false, this.enableCacheExpiration = false,
this.cacheExpirationDuration = const Duration(days: 14), this.cacheExpirationDuration = const Duration(days: 14),
this.cacheSizeThreshold = 20 * 1024 * 1024, // 20MB in bytes
http.Client? client, http.Client? client,
}) : httpClient = client ?? http.Client() { }) : httpClient = client ?? http.Client() {
_initCacheDir(); _initCacheDir();
@ -29,87 +31,107 @@ class RugReefer {
Future<void> _initCacheDir() async { Future<void> _initCacheDir() async {
if (!kIsWeb) { if (!kIsWeb) {
cacheDir = await getApplicationDocumentsDirectory(); cacheDir = await getApplicationDocumentsDirectory();
if (enableCacheExpiration) { if (enableCacheExpiration) await _cleanExpiredCache();
await _cleanExpiredCache();
}
} }
} }
Future<Map<String, Map<String, List<String>>>> lookup(double latitude, double longitude, {double distanceThreshold = 10.0}) async { Future<Map<String, Map<String, List<String>>>> lookup(double latitude, double longitude, {double distanceThreshold = 10.0}) async {
final String geohash = GeoHasher().encode(longitude, latitude, precision: 4); final geohash = GeoHasher().encode(longitude, latitude, precision: 4);
print("Generated geohash: $geohash");
final jsonMap = await loadJsonFromAssets(categoryMap); final jsonMap = await loadJsonFromAssets(categoryMap);
print("Loaded JSON Map from Assets");
try { try {
List<Map<String, dynamic>> rawData = kIsWeb final rawData = kIsWeb
? await _lookupWeb(geohash, latitude, longitude, distanceThreshold) ? await _lookupWeb(geohash)
: await _lookupFile(geohash, latitude, longitude, distanceThreshold); : await _lookupFile(geohash);
return _processMetadata(rawData, jsonMap, Position(longitude, latitude), distanceThreshold);
print("Raw Data from lookup: ${jsonEncode(rawData)}");
final processedData = _processMetadata(rawData, jsonMap, latitude, longitude, distanceThreshold);
print("Processed Data: ${jsonEncode(processedData)}");
return processedData;
} catch (e, stackTrace) { } catch (e, stackTrace) {
print("Error in lookup: $e"); print("Error in lookup: $e\nStackTrace: $stackTrace");
print("StackTrace: $stackTrace");
return {}; return {};
} }
} }
Future<List<Map<String, dynamic>>> _lookupFile(String geohash) async {
Future<List<Map<String, dynamic>>> _lookupFile(String geohash, double latitude, double longitude, double distanceThreshold) async {
await _initCacheDir(); await _initCacheDir();
final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
print("Checking cache file: ${cacheFile.path}");
if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) { if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) {
print("Cache file missing or expired, downloading...");
await _downloadGeoJson(geohash, cacheFile); await _downloadGeoJson(geohash, cacheFile);
} else { } else {
print("Cache file found, updating last access time");
await _updateLastAccessTime(geohash); await _updateLastAccessTime(geohash);
} }
final String geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync())); final geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync()));
print("Read from cache: ${geoJsonStr.length} characters"); return (jsonDecode(geoJsonStr)['features'] as List).cast<Map<String, dynamic>>();
// Explicitly cast to List<Map<String, dynamic>>
final List<dynamic> decodedJson = jsonDecode(geoJsonStr)['features'];
return decodedJson.cast<Map<String, dynamic>>();
} }
Future<List<Map<String, dynamic>>> _lookupWeb(String geohash, double latitude, double longitude, double distanceThreshold) async { Future<List<Map<String, dynamic>>> _lookupWeb(String geohash) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final String? cachedGeoJson = prefs.getString('geojson_$geohash'); final cachedGeoJson = prefs.getString('geojson_$geohash');
if (cachedGeoJson == null || await _isCacheExpired(geohash)) { if (cachedGeoJson == null || await _isCacheExpired(geohash)) {
final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson')); final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson'));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final geoJsonStr = utf8.decode(response.bodyBytes); final geoJsonStr = utf8.decode(response.bodyBytes);
await prefs.setString('geojson_$geohash', geoJsonStr); await prefs.setString('geojson_$geohash', geoJsonStr);
await _updateLastAccessTime(geohash); await _updateLastAccessTime(geohash);
return jsonDecode(geoJsonStr)['features']; return (jsonDecode(geoJsonStr)['features'] as List).cast<Map<String, dynamic>>();
} }
throw Exception('Failed to download GeoJSON for geohash: $geohash'); throw Exception('Failed to download GeoJSON for geohash: $geohash');
} }
await _updateLastAccessTime(geohash); await _updateLastAccessTime(geohash);
return jsonDecode(cachedGeoJson)['features']; return (jsonDecode(cachedGeoJson)['features'] as List).cast<Map<String, dynamic>>();
} }
Future<void> _downloadGeoJson(String geohash, File cacheFile) async { Future<void> _downloadGeoJson(String geohash, File cacheFile) async {
final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson')); final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson'));
if (response.statusCode == 200) { if (response.statusCode == 200) {
cacheFile.writeAsBytesSync(GZipCodec().encode(response.bodyBytes)); final bytes = response.bodyBytes;
cacheFile.writeAsBytesSync(GZipCodec().encode(bytes));
await _updateLastAccessTime(geohash); await _updateLastAccessTime(geohash);
await _ensureCacheSizeWithinThreshold(bytes.length); // Check and manage cache size
} else { } else {
throw Exception('Failed to download GeoJSON for geohash: $geohash'); throw Exception('Failed to download GeoJSON for geohash: $geohash');
} }
} }
Future<void> _ensureCacheSizeWithinThreshold(int newFileSize) async {
if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
final fileSizes = await Future.wait(files.map((file) => file.stat().then((stat) => stat.size)));
int totalSize = fileSizes.fold(0, (sum, size) => sum + size);
print("Current cache size: ${totalSize / 1024 / 1024} MB");
if (totalSize + newFileSize <= cacheSizeThreshold) return; // Within threshold
// Sort files by last access time (oldest first)
final prefs = await SharedPreferences.getInstance();
final sortedFiles = await Future.wait(files.map((file) async {
final geohash = file.path.split('/').last.replaceAll('.geojson.gz', '');
final lastAccess = prefs.getInt('cache_last_access_$geohash') ?? 0;
return {'file': file, 'lastAccess': lastAccess};
}));
sortedFiles.sort((a, b) => a['lastAccess'].compareTo(b['lastAccess']));
// Delete oldest files until we're within the threshold
for (var entry in sortedFiles) {
final file = entry['file'] as File;
final size = await file.length();
file.deleteSync();
totalSize -= size;
print("Deleted file: ${file.path} (${size / 1024 / 1024} MB)");
if (totalSize + newFileSize <= cacheSizeThreshold) break;
}
print("New cache size: ${totalSize / 1024 / 1024} MB");
}
Future<void> _updateLastAccessTime(String geohash) async { Future<void> _updateLastAccessTime(String geohash) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('cache_last_access_$geohash', DateTime.now().millisecondsSinceEpoch); await prefs.setInt('cache_last_access_$geohash', DateTime.now().millisecondsSinceEpoch);
@ -118,7 +140,7 @@ class RugReefer {
Future<bool> _isCacheExpired(String geohash) async { Future<bool> _isCacheExpired(String geohash) async {
if (!enableCacheExpiration) return false; if (!enableCacheExpiration) return false;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final int? lastAccess = prefs.getInt('cache_last_access_$geohash'); final lastAccess = prefs.getInt('cache_last_access_$geohash');
return lastAccess == null || DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(lastAccess)) > cacheExpirationDuration; return lastAccess == null || DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(lastAccess)) > cacheExpirationDuration;
} }
@ -128,76 +150,110 @@ class RugReefer {
for (var key in keys) { for (var key in keys) {
final geohash = key.replaceFirst('cache_last_access_', ''); final geohash = key.replaceFirst('cache_last_access_', '');
if (await _isCacheExpired(geohash)) { if (await _isCacheExpired(geohash)) {
final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz'); final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
if (cacheFile.existsSync()) cacheFile.deleteSync(); if (cacheFile.existsSync()) cacheFile.deleteSync();
await prefs.remove(key); await prefs.remove(key);
} }
} }
} }
Future<void> clearCache() async {
if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized
final prefs = await SharedPreferences.getInstance();
// Delete all cached files
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
for (var file in files) {
if (file is File) {
file.deleteSync();
print("Deleted file: ${file.path}");
}
}
// Remove all last access times from SharedPreferences
final keys = prefs.getKeys().where((key) => key.startsWith('cache_last_access_')).toList();
for (var key in keys) {
await prefs.remove(key);
print("Removed cache access time for key: $key");
}
print("Cache cleared successfully.");
}
/// Returns the total size of the cache in bytes.
Future<int> getCurrentCacheSize() async {
if (kIsWeb || cacheDir == null) return 0; // Skip for web or if cacheDir is not initialized
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
final fileSizes = await Future.wait(files.map((file) => file.stat().then((stat) => stat.size)));
return fileSizes.fold(0, (sum, size) => sum + size);
}
/// Returns a list of currently cached files with their sizes and last access times.
Future<List<Map<String, dynamic>>> listCachedFiles() async {
if (kIsWeb || cacheDir == null) return []; // Skip for web or if cacheDir is not initialized
final prefs = await SharedPreferences.getInstance();
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
final fileDetails = await Future.wait(files.map((file) async {
final geohash = file.path.split('/').last.replaceAll('.geojson.gz', '');
final lastAccess = prefs.getInt('cache_last_access_$geohash') ?? 0;
final size = await file.stat().then((stat) => stat.size);
return {
'file': file.path,
'size': size,
'lastAccess': DateTime.fromMillisecondsSinceEpoch(lastAccess),
};
}));
return fileDetails;
}
/// Deletes a specific cached file by its geohash.
Future<void> deleteCachedFile(String geohash) async {
if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized
final prefs = await SharedPreferences.getInstance();
final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
if (cacheFile.existsSync()) {
cacheFile.deleteSync();
print("Deleted file: ${cacheFile.path}");
}
// Remove the last access time for this geohash
await prefs.remove('cache_last_access_$geohash');
print("Removed cache access time for geohash: $geohash");
}
Future<Map<String, dynamic>> loadJsonFromAssets(String assetPath) async { Future<Map<String, dynamic>> loadJsonFromAssets(String assetPath) async {
final jsonString = await rootBundle.loadString(assetPath); return jsonDecode(await rootBundle.loadString(assetPath));
return jsonDecode(jsonString);
} }
Map<String, Map<String, List<String>>> _processMetadata( Map<String, Map<String, List<String>>> _processMetadata(
List<Map<String, dynamic>> features, List<Map<String, dynamic>> features,
Map<String, dynamic> jsonMap, Map<String, dynamic> jsonMap,
double latitude, Position point,
double longitude,
double distanceThreshold) { double distanceThreshold) {
final Position point = Position(longitude, latitude); final groupedObjects = {"in": <String, List<String>>{}, "by": <String, List<String>>{}};
Map<String, Map<String, List<String>>> groupedObjects = {"in": {}, "by": {}};
for (var feature in features) { for (var feature in features) {
final geometry = feature['geometry']; final geometry = feature['geometry'];
final metadata = Map<String, dynamic>.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null); final metadata = Map<String, dynamic>.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null);
String? mainKey; final mainKey = _determineSpatialCategory(geometry, point, distanceThreshold);
// Determine spatial category: "in" (Polygon) or "by" (LineString)
switch (geometry['type']) {
case 'Polygon':
final polygon = Polygon(
coordinates: (geometry['coordinates'] as List)
.map((ring) => (ring as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList())
.toList(),
);
if (booleanPointInPolygon(point, polygon)) mainKey = 'in';
break;
case 'LineString':
final line = LineString(
coordinates: (geometry['coordinates'] as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList(),
);
if (_isPointNearLine(Point(coordinates: point), line, distanceThreshold)) mainKey = 'by';
break;
}
if (mainKey == null) continue; if (mainKey == null) continue;
for (var key in metadata.keys) { for (var key in metadata.keys) {
var value = metadata[key]; final value = metadata[key];
for (var category in jsonMap.keys) { for (var category in jsonMap.keys) {
var categoryMap = jsonMap[category]; final rule = jsonMap[category] is Map ? jsonMap[category][key] : null;
if (_matchesRule(rule, value)) {
if (categoryMap is Map && categoryMap.containsKey(key)) { groupedObjects[mainKey]!.putIfAbsent(category, () => []).add(value);
var rule = categoryMap[key];
if (rule is String && rule == value) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []);
groupedObjects[mainKey]![category]!.add(value);
} else if (rule is List && rule.contains(value)) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []);
groupedObjects[mainKey]![category]!.add(value);
} else if (rule == true) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []);
groupedObjects[mainKey]![category]!.add(value);
}
} }
} }
} }
@ -206,15 +262,43 @@ class RugReefer {
return groupedObjects; return groupedObjects;
} }
String? _determineSpatialCategory(Map<String, dynamic> geometry, Position point, double distanceThreshold) {
switch (geometry['type']) {
bool _isPointNearLine(Point point, LineString line, double distanceThreshold) { case 'Polygon':
for (final Position p in line.coordinates) { final polygon = Polygon(
if (distance(point, Point(coordinates: p), Unit.meters) <= distanceThreshold) { coordinates: (geometry['coordinates'] as List)
return true; .map((ring) => (ring as List)
} .map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList())
.toList(),
);
return booleanPointInPolygon(point, polygon) ? 'in' : null;
case 'LineString':
final line = LineString(
coordinates: (geometry['coordinates'] as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList(),
);
return _isPointNearLine(Point(coordinates: point), line, distanceThreshold) ? 'by' : null;
default:
return null;
} }
}
bool _matchesRule(dynamic rule, dynamic value) {
if (rule == true) return true;
if (rule is String && rule == value) return true;
if (rule is List && rule.contains(value)) return true;
return false; return false;
} }
} bool _isPointNearLine(Point point, LineString line, double distanceThreshold) {
for (int i = 0; i < line.coordinates.length - 1; i++) {
final p1 = line.coordinates[i];
final p2 = line.coordinates[i + 1];
final dist = distance(p1, point, Unit.meter);
if (dist <= distanceThreshold) return true;
}
return false;
}
}