fluffychat/lib/pangea/analytics_data/analytics_database.dart
ggurdin e8428783e6
Fluffychat merge 2 (#5590)
* build: Reenable shrink resources and minify in gradle

* build: (deps): bump image from 4.6.0 to 4.7.1

Bumps [image](https://github.com/brendan-duncan/image) from 4.6.0 to 4.7.1.
- [Changelog](https://github.com/brendan-duncan/image/blob/main/CHANGELOG.md)
- [Commits](https://github.com/brendan-duncan/image/commits)

---
updated-dependencies:
- dependency-name: image
  dependency-version: 4.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build: (deps): bump file_picker from 10.3.7 to 10.3.8

Bumps [file_picker](https://github.com/miguelpruivo/flutter_file_picker) from 10.3.7 to 10.3.8.
- [Release notes](https://github.com/miguelpruivo/flutter_file_picker/releases)
- [Changelog](https://github.com/miguelpruivo/flutter_file_picker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/miguelpruivo/flutter_file_picker/compare/v10.3.7...v10.3.8)

---
updated-dependencies:
- dependency-name: file_picker
  dependency-version: 10.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: Improved search

* build: Use matrix sdk vom pub.dev again

* chore: Follow up better search

* build: (deps): bump image from 4.7.1 to 4.7.2

Bumps [image](https://github.com/brendan-duncan/image) from 4.7.1 to 4.7.2.
- [Changelog](https://github.com/brendan-duncan/image/blob/main/CHANGELOG.md)
- [Commits](https://github.com/brendan-duncan/image/commits)

---
updated-dependencies:
- dependency-name: image
  dependency-version: 4.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: Make cross signing self sign mandatory for bootstrap

* chore: Update user device keys before creating bootstrap

* fix: Better wait for secrets after verification bootstrap

* refactor: Remove native imaging and enable web worker

* refactor: Remove unused html onfocus streams

* build: (deps): bump flutter_foreground_task from 9.1.0 to 9.2.0

Bumps [flutter_foreground_task](https://github.com/Dev-hwang/flutter_foreground_task) from 9.1.0 to 9.2.0.
- [Changelog](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Dev-hwang/flutter_foreground_task/commits)

---
updated-dependencies:
- dependency-name: flutter_foreground_task
  dependency-version: 9.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(translations): Translated using Weblate (Uzbek)

Currently translated at 99.7% (823 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/uz/

* chore(translations): Translated using Weblate (Russian)

Currently translated at 99.8% (824 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/ru/

* chore(translations): Translated using Weblate (Norwegian Bokmål)

Currently translated at 90.9% (750 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/nb_NO/

* chore(translations): Translated using Weblate (Galician)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/gl/

* chore(translations): Translated using Weblate (Basque)

Currently translated at 99.7% (823 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/eu/

* chore(translations): Translated using Weblate (Ukrainian)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/uk/

* chore(translations): Translated using Weblate (Estonian)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/et/

* chore(translations): Translated using Weblate (Dutch)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/nl/

* chore(translations): Translated using Weblate (Russian)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/ru/

* chore(translations): Translated using Weblate (Spanish)

Currently translated at 95.2% (788 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/es/

* chore(translations): Translated using Weblate (Spanish)

Currently translated at 96.3% (797 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/es/

* chore(translations): Translated using Weblate (Russian)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/ru/

* chore(translations): Translated using Weblate (Russian)

Currently translated at 100.0% (825 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/ru/

* fix: Broken ruzzian plurals

* chore(translations): Translated using Weblate (Norwegian Bokmål)

Currently translated at 91.2% (753 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/nb_NO/

* chore(translations): Translated using Weblate (Bengali)

Currently translated at 4.5% (38 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/bn/

* chore(translations): Translated using Weblate (French)

Currently translated at 82.3% (679 of 825 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/fr/

* build: (deps): bump translations_cleaner from 0.0.5 to 0.1.0

Bumps [translations_cleaner](https://github.com/Chinmay-KB/translations_cleaner) from 0.0.5 to 0.1.0.
- [Changelog](https://github.com/Chinmay-KB/translations_cleaner/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Chinmay-KB/translations_cleaner/commits)

---
updated-dependencies:
- dependency-name: translations_cleaner
  dependency-version: 0.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(translations): Translated using Weblate (German)

Currently translated at 99.2% (821 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/de/

* chore(translations): Translated using Weblate (Estonian)

Currently translated at 100.0% (827 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/et/

* build: Bump version to 2.4.0

* build: (deps): bump sqflite_common_ffi from 2.3.6 to 2.3.7+1

Bumps [sqflite_common_ffi](https://github.com/tekartik/sqflite) from 2.3.6 to 2.3.7+1.
- [Commits](https://github.com/tekartik/sqflite/compare/sqflite_common_ffi_v2.3.6...sqflite_common_ffi/v2.3.7)

---
updated-dependencies:
- dependency-name: sqflite_common_ffi
  dependency-version: 2.3.7+1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(translations): Translated using Weblate (Czech)

Currently translated at 66.1% (547 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/cs/

* chore(translations): Translated using Weblate (Czech)

Currently translated at 72.7% (602 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/cs/

* chore(translations): Translated using Weblate (German)

Currently translated at 99.8% (826 of 827 strings)

Translation: FluffyChat/Translations
Translate-URL: https://hosted.weblate.org/projects/fluffychat/translations/de/

* chore: Add security.md file

* fix: Locale unlocalized strings

* build: (deps): bump matrix from 4.1.0 to 5.0.0

Bumps [matrix](https://github.com/famedly/matrix-dart-sdk) from 4.1.0 to 5.0.0.
- [Release notes](https://github.com/famedly/matrix-dart-sdk/releases)
- [Changelog](https://github.com/famedly/matrix-dart-sdk/blob/main/CHANGELOG.md)
- [Commits](https://github.com/famedly/matrix-dart-sdk/compare/v4.1.0...v5.0.0)

---
updated-dependencies:
- dependency-name: matrix
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: Notifications on web correctly managed when tab not focused

* chore: Add changelog for android

* chore: Remove duplicated localization

* fix: Sign in label

* chore: Versionize fcm shared isolate

* build: Remove unused packag

* build: (deps): bump package_info_plus from 8.3.1 to 9.0.0

Bumps [package_info_plus](https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus) from 8.3.1 to 9.0.0.
- [Release notes](https://github.com/fluttercommunity/plus_plugins/releases)
- [Commits](https://github.com/fluttercommunity/plus_plugins/commits/package_info_plus-v9.0.0/packages/package_info_plus)

---
updated-dependencies:
- dependency-name: package_info_plus
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: Display particle animation on login page

* chore: Use fixed version of fcm shared isolate

* fix: apk crash on some platforms due new flutter version

* chore: Correct kotlin format

* fix iOS notifications

* fluffychat merge

* fluffychat merge

* fluffychat merge

* fluffychat merge

* fluffychat merge

* fluffychat merge

* add missing type annotations

* update matrix version

* fluffychat merge

* fluffychat merge

* fix notification on click actions

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Christian Kußowski <c.kussowski@famedly.com>
Co-authored-by: Krille-chan <christian-kussowski@posteo.de>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: BeMeritus <bemerituss@gmail.com>
Co-authored-by: Frank Paul Silye <frankps@gmail.com>
Co-authored-by: josé m. <correoxm@disroot.org>
Co-authored-by: xabirequejo <xabi.rn@gmail.com>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Jelv <post@jelv.nl>
Co-authored-by: Дмитрий Михирев <bizdelnick@gmail.com>
Co-authored-by: Kimby <kimbyqs@gmail.com>
Co-authored-by: Christian <christian-pauly@posteo.de>
Co-authored-by: Kom nake <kominak310@svcache.com>
Co-authored-by: hugues de keyzer <komputilisto@hugues.info>
Co-authored-by: nautilusx <translate@disroot.org>
Co-authored-by: Šebestová <ka.sebestova.cz@gmail.com>
2026-02-10 08:01:12 -05:00

616 lines
19 KiB
Dart

// ignore_for_file: implementation_imports, depend_on_referenced_packages
import 'dart:async';
import 'dart:math';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common/sqflite.dart';
import 'package:synchronized/synchronized.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:matrix/src/database/database_file_storage_stub.dart'
if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.dart';
import 'package:matrix/src/database/indexeddb_box.dart'
if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart';
class AnalyticsDatabase with DatabaseFileStorage {
final String name;
late BoxCollection _collection;
late Box<String> _lastEventTimestampBox;
late Box<List> _serverConstructsBox;
late Box<List> _localConstructsBox;
late Box<Map> _aggregatedServerVocabConstructsBox;
late Box<Map> _aggregatedLocalVocabConstructsBox;
late Box<Map> _aggregatedServerMorphConstructsBox;
late Box<Map> _aggregatedLocalMorphConstructsBox;
late Box<Map> _derivedStatsBox;
static const String _serverConstructsBoxName = 'box_server_constructs';
static const String _localConstructsBoxName = 'box_local_constructs';
/// Key is Tuple of construct lemma, type, and category
static const String _aggregatedServerVocabConstructsBoxName =
'box_aggregated_server_vocab_constructs';
static const String _aggregatedLocalVocabConstructsBoxName =
'box_aggregated_local_vocab_constructs';
static const String _aggregatedServerMorphConstructsBoxName =
'box_aggregated_server_morph_constructs';
static const String _aggregatedLocalMorphConstructsBoxName =
'box_aggregated_local_morph_constructs';
static const String _derivedStatsBoxName = 'box_derived_stats';
static const String _lastEventTimestampBoxName = 'box_last_event_timestamp';
Database? database;
/// Custom IdbFactory used to create the indexedDB. On IO platforms it would
/// lead to an error to import "dart:indexed_db" so this is dynamically
/// typed.
final dynamic idbFactory;
/// Custom SQFlite Database Factory used for high level operations on IO
/// like delete. Set it if you want to use sqlite FFI.
final DatabaseFactory? sqfliteFactory;
static Future<AnalyticsDatabase> init(
String name, {
Database? database,
dynamic idbFactory,
DatabaseFactory? sqfliteFactory,
Uri? fileStorageLocation,
Duration? deleteFilesAfterDuration,
}) async {
final analyticsDatabase = AnalyticsDatabase._(
name,
database: database,
idbFactory: idbFactory,
sqfliteFactory: sqfliteFactory,
fileStorageLocation: fileStorageLocation,
deleteFilesAfterDuration: deleteFilesAfterDuration,
);
await analyticsDatabase.open();
return analyticsDatabase;
}
AnalyticsDatabase._(
this.name, {
this.database,
this.idbFactory,
this.sqfliteFactory,
Uri? fileStorageLocation,
Duration? deleteFilesAfterDuration,
}) {
this.fileStorageLocation = fileStorageLocation;
this.deleteFilesAfterDuration = deleteFilesAfterDuration;
}
final _lock = Lock();
Future<void> open() async {
_collection = await BoxCollection.open(
name,
{
_lastEventTimestampBoxName,
_serverConstructsBoxName,
_localConstructsBoxName,
_aggregatedServerVocabConstructsBoxName,
_aggregatedLocalVocabConstructsBoxName,
_aggregatedServerMorphConstructsBoxName,
_aggregatedLocalMorphConstructsBoxName,
_derivedStatsBoxName,
},
sqfliteDatabase: database,
sqfliteFactory: sqfliteFactory,
idbFactory: idbFactory,
version: MatrixSdkDatabase.version,
);
_lastEventTimestampBox = _collection.openBox<String>(
_lastEventTimestampBoxName,
);
_serverConstructsBox = _collection.openBox<List>(_serverConstructsBoxName);
_localConstructsBox = _collection.openBox<List>(_localConstructsBoxName);
_aggregatedServerVocabConstructsBox = _collection.openBox<Map>(
_aggregatedServerVocabConstructsBoxName,
);
_aggregatedLocalVocabConstructsBox = _collection.openBox<Map>(
_aggregatedLocalVocabConstructsBoxName,
);
_aggregatedServerMorphConstructsBox = _collection.openBox<Map>(
_aggregatedServerMorphConstructsBoxName,
);
_aggregatedLocalMorphConstructsBox = _collection.openBox<Map>(
_aggregatedLocalMorphConstructsBoxName,
);
_derivedStatsBox = _collection.openBox<Map>(_derivedStatsBoxName);
}
Future<void> delete() async {
await _collection.deleteDatabase(
database?.path ?? name,
sqfliteFactory ?? idbFactory,
);
}
Future<void> clear() async {
_lastEventTimestampBox.clearQuickAccessCache();
_serverConstructsBox.clearQuickAccessCache();
_localConstructsBox.clearQuickAccessCache();
_aggregatedServerVocabConstructsBox.clearQuickAccessCache();
_aggregatedLocalVocabConstructsBox.clearQuickAccessCache();
_aggregatedServerMorphConstructsBox.clearQuickAccessCache();
_aggregatedLocalMorphConstructsBox.clearQuickAccessCache();
_derivedStatsBox.clearQuickAccessCache();
await _collection.clear();
}
Future<T> _transaction<T>(Future<T> Function() action) {
return _lock.synchronized(action);
}
Box<Map> _aggBox(ConstructTypeEnum type, bool local) =>
switch ((type, local)) {
(ConstructTypeEnum.vocab, true) => _aggregatedLocalVocabConstructsBox,
(ConstructTypeEnum.vocab, false) => _aggregatedServerVocabConstructsBox,
(ConstructTypeEnum.morph, true) => _aggregatedLocalMorphConstructsBox,
(ConstructTypeEnum.morph, false) => _aggregatedServerMorphConstructsBox,
};
Future<String?> getUserID() => _lastEventTimestampBox.get('user_id');
Future<DateTime?> getLastUpdated() async {
final entry = await _lastEventTimestampBox.get('last_updated');
if (entry == null) return null;
return DateTime.tryParse(entry);
}
Future<DateTime?> getLastEventTimestamp() async {
final timestampString = await _lastEventTimestampBox.get(
'last_event_timestamp',
);
if (timestampString == null) return null;
return DateTime.parse(timestampString);
}
Future<DerivedAnalyticsDataModel> getDerivedStats() async {
final raw = await _derivedStatsBox.get('derived_stats');
return raw == null
? DerivedAnalyticsDataModel()
: DerivedAnalyticsDataModel.fromJson(Map<String, dynamic>.from(raw));
}
Future<List<OneConstructUse>> getUses({
int? count,
String? roomId,
DateTime? since,
List<ConstructUseTypeEnum>? types,
}) async {
final stopwatch = Stopwatch()..start();
final results = <OneConstructUse>[];
bool addUseIfValid(OneConstructUse use) {
if (since != null && use.timeStamp.isBefore(since)) {
return false; // stop iteration entirely
}
if (roomId != null && use.metadata.roomId != roomId) {
return true; // skip but continue
}
if (types != null && !types.contains(use.useType)) {
return true; // skip but continue
}
results.add(use);
return count == null || results.length < count;
}
// ---- Local uses ----
final localUses = await getLocalUses()
..sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
for (final use in localUses) {
if (!addUseIfValid(use)) break;
}
if (count != null && results.length >= count) {
stopwatch.stop();
Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms");
return results;
}
// ---- Server uses ----
final serverKeys = await _serverConstructsBox.getAllKeys()
..sort(
(a, b) =>
int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])),
);
for (final key in serverKeys) {
final serverUses = await getServerUses(key)
..sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
for (final use in serverUses) {
if (!addUseIfValid(use)) break;
}
if (count != null && results.length >= count) break;
}
stopwatch.stop();
Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms");
return results;
}
Future<List<OneConstructUse>> getLocalUses() async {
final List<OneConstructUse> uses = [];
final localKeys = await _localConstructsBox.getAllKeys();
final localValues = await _localConstructsBox.getAll(localKeys);
for (final rawList in localValues) {
if (rawList == null) continue;
for (final raw in rawList) {
final use = OneConstructUse.fromJson(Map<String, dynamic>.from(raw));
uses.add(use);
}
}
return uses;
}
Future<List<OneConstructUse>> getServerUses(String key) async {
final List<OneConstructUse> uses = [];
final serverValues = await _serverConstructsBox.get(key);
if (serverValues == null) return [];
for (final entry in serverValues) {
uses.add(OneConstructUse.fromJson(Map<String, dynamic>.from(entry)));
}
return uses;
}
Future<int> getLocalConstructCount() async {
final keys = await _localConstructsBox.getAllKeys();
return keys.length;
}
Future<List<String>> getVocabConstructKeys() async {
final serverKeys = await _aggregatedServerVocabConstructsBox.getAllKeys();
final localKeys = await _aggregatedLocalVocabConstructsBox.getAllKeys();
return [...serverKeys, ...localKeys];
}
Future<List<String>> getMorphConstructKeys() async {
final serverKeys = await _aggregatedServerMorphConstructsBox.getAllKeys();
final localKeys = await _aggregatedLocalMorphConstructsBox.getAllKeys();
return [...serverKeys, ...localKeys];
}
Future<ConstructUses> getConstructUse(List<ConstructIdentifier> ids) async {
assert(ids.isNotEmpty);
final ConstructUses construct = ConstructUses(
uses: [],
constructType: ids.first.type,
lemma: ids.first.lemma,
category: ids.first.category,
);
for (final id in ids) {
final key = id.storageKey;
ConstructUses? server;
ConstructUses? local;
final serverBox = _aggBox(id.type, false);
final localBox = _aggBox(id.type, true);
final serverRaw = await serverBox.get(key);
if (serverRaw != null) {
server = ConstructUses.fromJson(Map<String, dynamic>.from(serverRaw));
}
final localRaw = await localBox.get(key);
if (localRaw != null) {
local = ConstructUses.fromJson(Map<String, dynamic>.from(localRaw));
}
if (server != null) construct.merge(server);
if (local != null) construct.merge(local);
}
return construct;
}
Future<Map<ConstructIdentifier, ConstructUses>> getConstructUses(
Map<ConstructIdentifier, List<ConstructIdentifier>> ids,
) async {
final Map<ConstructIdentifier, ConstructUses> results = {};
for (final entry in ids.entries) {
final construct = await getConstructUse(entry.value);
results[entry.key] = construct;
}
return results;
}
Future<void> clearLocalConstructData() async {
await _transaction(() async {
await _localConstructsBox.clear();
await _aggregatedLocalVocabConstructsBox.clear();
await _aggregatedLocalMorphConstructsBox.clear();
});
}
/// Group uses by aggregate key
Map<String, List<OneConstructUse>> _groupUses(List<OneConstructUse> uses) {
final Map<String, List<OneConstructUse>> grouped = {};
for (final u in uses) {
final key = u.identifier.storageKey;
(grouped[key] ??= []).add(u);
}
return grouped;
}
Map<String, ConstructUses> _aggregateConstructs(
Map<String, List<OneConstructUse>> groups,
Map<String, Map<dynamic, dynamic>?> existingRaw,
) {
final Map<String, ConstructUses> updates = {};
for (final entry in groups.entries) {
final key = entry.key;
final usesForKey = entry.value;
final raw = existingRaw[key];
ConstructUses model;
if (raw != null) {
model = ConstructUses.fromJson(Map<String, dynamic>.from(raw));
} else {
final u = usesForKey.first;
model = ConstructUses(
uses: [],
constructType: u.constructType,
lemma: u.lemma,
category: u.category,
);
}
model.addUses(usesForKey);
updates[key] = model;
}
return updates;
}
Future<Map<String, ConstructUses>> _aggregateFromBox(
Box<Map> box,
Map<String, List<OneConstructUse>> grouped,
) async {
final keys = grouped.keys.toList();
final existing = await box.getAll(keys);
final existingMap = Map.fromIterables(keys, existing);
return _aggregateConstructs(grouped, existingMap);
}
Future<List<ConstructUses>> getAggregatedConstructs(
ConstructTypeEnum type,
) async {
Map<String, ConstructUses> combined = {};
final stopwatch = Stopwatch()..start();
final localKeys = await _aggBox(type, true).getAllKeys();
final serverKeys = await _aggBox(type, false).getAllKeys();
final serverValues = await _aggBox(type, false).getAll(serverKeys);
final serverConstructs = serverValues
.map((e) => ConstructUses.fromJson(Map<String, dynamic>.from(e!)))
.toList();
final serverAgg = Map.fromIterables(serverKeys, serverConstructs);
if (localKeys.isEmpty) {
combined = serverAgg;
} else {
final localValues = await _aggBox(type, true).getAll(localKeys);
final localConstructs = localValues
.map((e) => ConstructUses.fromJson(Map<String, dynamic>.from(e!)))
.toList();
final localAgg = Map.fromIterables(localKeys, localConstructs);
combined = Map<String, ConstructUses>.from(serverAgg);
for (final entry in localAgg.entries) {
final key = entry.key;
final localModel = entry.value;
if (combined.containsKey(key)) {
final serverModel = combined[key]!;
serverModel.merge(localModel);
combined[key] = serverModel;
} else {
combined[key] = localModel;
}
}
}
stopwatch.stop();
Logs().i("Combining aggregates took ${stopwatch.elapsedMilliseconds} ms");
return combined.values.toList();
}
Future<void> updateUserID(String userID) {
return _transaction(() async {
await _lastEventTimestampBox.put('user_id', userID);
});
}
Future<void> updateLastUpdated(DateTime timestamp) {
return _transaction(() async {
await _lastEventTimestampBox.put(
'last_updated',
timestamp.toIso8601String(),
);
});
}
Future<void> updateXPOffset(int offset) {
return _transaction(() async {
final stats = await getDerivedStats();
final updatedStats = stats.copyWith(offset: offset);
await _derivedStatsBox.put('derived_stats', updatedStats.toJson());
});
}
Future<void> updateDerivedStats(DerivedAnalyticsDataModel newStats) =>
_derivedStatsBox.put('derived_stats', newStats.toJson());
Future<void> updateServerAnalytics(
List<ConstructAnalyticsEvent> events,
) async {
if (events.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
final lastUpdated = await getLastEventTimestamp();
DateTime mostRecent = lastUpdated ?? events.first.event.originServerTs;
final existingKeys = (await _serverConstructsBox.getAllKeys()).toSet();
final List<OneConstructUse> aggregatedVocabUses = [];
final List<OneConstructUse> aggregatedMorphUses = [];
final Map<String, List<OneConstructUse>> pendingWrites = {};
for (final event in events) {
final ts = event.event.originServerTs;
final key = TupleKey(
event.event.eventId,
ts.millisecondsSinceEpoch.toString(),
).toString();
if (lastUpdated != null && ts.isBefore(lastUpdated)) continue;
if (existingKeys.contains(key)) continue;
if (ts.isAfter(mostRecent)) mostRecent = ts;
pendingWrites[key] = event.content.uses;
for (final u in event.content.uses) {
u.constructType == ConstructTypeEnum.vocab
? aggregatedVocabUses.add(u)
: aggregatedMorphUses.add(u);
}
}
if (pendingWrites.isEmpty) return;
// Write events sequentially
for (final e in pendingWrites.entries) {
_serverConstructsBox.put(
e.key,
e.value.map((u) => u.toJson()).toList(),
);
}
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedServerVocabConstructsBox,
_groupUses(aggregatedVocabUses),
);
for (final entry in aggVocabUpdates.entries) {
await _aggregatedServerVocabConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedServerMorphConstructsBox,
_groupUses(aggregatedMorphUses),
);
for (final entry in aggMorphUpdates.entries) {
await _aggregatedServerMorphConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
// Update timestamp
await _lastEventTimestampBox.put(
'last_event_timestamp',
mostRecent.toIso8601String(),
);
});
await updateLastUpdated(DateTime.now());
stopwatch.stop();
Logs().i(
"Server analytics update took ${stopwatch.elapsedMilliseconds} ms",
);
}
Future<void> updateLocalAnalytics(List<OneConstructUse> uses) async {
if (uses.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
// Store local constructs
final key = DateTime.now().millisecondsSinceEpoch;
_localConstructsBox.put(
key.toString(),
uses.map((u) => u.toJson()).toList(),
);
final List<OneConstructUse> vocabUses = [];
final List<OneConstructUse> morphUses = [];
for (final u in uses) {
u.constructType == ConstructTypeEnum.vocab
? vocabUses.add(u)
: morphUses.add(u);
}
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedLocalVocabConstructsBox,
_groupUses(vocabUses),
);
for (final entry in aggVocabUpdates.entries) {
await _aggregatedLocalVocabConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedLocalMorphConstructsBox,
_groupUses(morphUses),
);
for (final entry in aggMorphUpdates.entries) {
await _aggregatedLocalMorphConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
});
await updateLastUpdated(DateTime.now());
stopwatch.stop();
Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms");
}
}