diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart new file mode 100644 index 0000000..f82e145 --- /dev/null +++ b/lib/apis/record_api.dart @@ -0,0 +1,137 @@ + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/message.dart'; +import 'package:http/http.dart' as http; + +import 'package:contacts_plus_plus/clients/api_client.dart'; +import 'package:contacts_plus_plus/models/records/asset_upload_data.dart'; +import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; +import 'package:contacts_plus_plus/models/records/preprocess_status.dart'; +import 'package:contacts_plus_plus/models/records/record.dart'; +import 'package:path/path.dart'; + +class AssetApi { + static Future> getRecordsAt(ApiClient client, {required String path}) async { + final response = await client.get("/users/${client.userId}/records?path=$path"); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body) as List; + return body.map((e) => Record.fromMap(e)).toList(); + } + + static Future preprocessRecord(ApiClient client, {required Record record}) async { + final response = await client.post( + "/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap())); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + return PreprocessStatus.fromMap(body); + } + + static Future getPreprocessStatus(ApiClient client, + {required PreprocessStatus preprocessStatus}) async { + final response = await client.get( + "/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}" + ); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + return PreprocessStatus.fromMap(body); + } + + static Future beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async { + final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks?bytes=${asset.bytes}"); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + final res = AssetUploadData.fromMap(body); + if (res.uploadState == UploadState.failed) throw body; + return res; + } + + static Future upsertRecord(ApiClient client, {required Record record}) async { + final body = jsonEncode(record.toMap()); + final response = await client.put("/users/${client.userId}/records/${record.id}", body: body); + ApiClient.checkResponse(response); + } + + static Future uploadAsset(ApiClient client, {required NeosDBAsset asset, required Uint8List data}) async { + final request = http.MultipartRequest( + "POST", + ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}"), + ) + ..files.add(http.MultipartFile.fromBytes("file", data)); + final response = await request.send(); + final body = jsonDecode(await response.stream.bytesToString()); + return body; + } + + static Future finishUpload(ApiClient client, {required NeosDBAsset asset}) async { + final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks"); + ApiClient.checkResponse(response); + } + + static Future uploadFile(ApiClient client, {required File file, required String machineId}) async { + final data = await file.readAsBytes(); + final asset = NeosDBAsset.fromData(data); + final assetUri = "neosdb://$machineId/${asset.hash}${extension(file.path)}"; + final combinedRecordId = RecordId(id: Record.generateId(), ownerId: client.userId, isValid: true); + final record = Record( + id: 0, + recordId: combinedRecordId.id.toString(), + combinedRecordId: combinedRecordId, + assetUri: assetUri, + name: basenameWithoutExtension(file.path), + tags: [ + "message_item", + "message_id:${Message.generateId()}" + ], + recordType: RecordType.texture, + thumbnailUri: assetUri, + isPublic: false, + isForPatreons: false, + isListed: false, + neosDBManifest: [ + asset, + ], + globalVersion: 0, + localVersion: 1, + lastModifyingUserId: client.userId, + lastModifyingMachineId: machineId, + lastModificationTime: DateTime.now().toUtc(), + creationTime: DateTime.now().toUtc(), + ownerId: client.userId, + isSynced: false, + fetchedOn: DateTimeX.one, + path: '', + description: '', + manifest: [ + assetUri + ], + url: "neosrec://${client.userId}/${combinedRecordId.id}", + isValidOwnerId: true, + isValidRecordId: true, + visits: 0, + rating: 0, + randomOrder: 0, + ); + + var status = await preprocessRecord(client, record: record); + while (status.state == RecordPreprocessState.preprocessing) { + await Future.delayed(const Duration(seconds: 1)); + status = await getPreprocessStatus(client, preprocessStatus: status); + } + + if (status.state != RecordPreprocessState.success) { + throw "Record Preprocessing failed: ${status.failReason}"; + } + + final uploadData = await beginUploadAsset(client, asset: asset); + if (uploadData.uploadState == UploadState.failed) { + throw "Asset upload failed: ${uploadData.uploadState.name}"; + } + + await uploadAsset(client, asset: asset, data: data); + await finishUpload(client, asset: asset); + return record; + } +} \ No newline at end of file diff --git a/lib/auxiliary.dart b/lib/auxiliary.dart index 93d0e50..5a606be 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -85,4 +85,9 @@ extension Format on Duration { return "$hh:$mm:$ss"; } } +} + +extension DateTimeX on DateTime { + static DateTime epoch = DateTime.fromMillisecondsSinceEpoch(0); + static DateTime one = DateTime(1); } \ No newline at end of file diff --git a/lib/models/records/asset_diff.dart b/lib/models/records/asset_diff.dart new file mode 100644 index 0000000..cd97d30 --- /dev/null +++ b/lib/models/records/asset_diff.dart @@ -0,0 +1,34 @@ + +class AssetDiff { + final String hash; + final int bytes; + final Diff state; + final bool isUploaded; + + const AssetDiff({required this.hash, required this.bytes, required this.state, required this.isUploaded}); + + factory AssetDiff.fromMap(Map map) { + return AssetDiff( + hash: map["hash"], + bytes: map["bytes"], + state: Diff.fromInt(map["state"]), + isUploaded: map["isUploaded"], + ); + } +} + +enum Diff { + added, + unchanged, + removed; + + factory Diff.fromInt(int? idx) { + return Diff.values[idx ?? 1]; + } + + factory Diff.fromString(String? text) { + return Diff.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => Diff.unchanged, + ); + } +} \ No newline at end of file diff --git a/lib/models/records/asset_upload_data.dart b/lib/models/records/asset_upload_data.dart new file mode 100644 index 0000000..6df0555 --- /dev/null +++ b/lib/models/records/asset_upload_data.dart @@ -0,0 +1,46 @@ + +enum UploadState { + uploadingChunks, + finalizing, + uploaded, + failed, + unknown; + + factory UploadState.fromString(String? text) { + return UploadState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => UploadState.unknown, + ); + } +} + +class AssetUploadData { + final String signature; + final String variant; + final String ownerId; + final int totalBytes; + final int chunkSize; + final int totalChunks; + final UploadState uploadState; + + const AssetUploadData({ + required this.signature, + required this.variant, + required this.ownerId, + required this.totalBytes, + required this.chunkSize, + required this.totalChunks, + required this.uploadState, + }); + + factory AssetUploadData.fromMap(Map map) { + return AssetUploadData( + signature: map["signature"], + variant: map["variant"] ?? "", + ownerId: map["ownerId"] ?? "", + totalBytes: map["totalBytes"] ?? -1, + chunkSize: map["chunkSize"] ?? -1, + totalChunks: map["totalChunks"] ?? -1, + uploadState: UploadState.fromString(map["uploadStat"]), + ); + } +} \ No newline at end of file diff --git a/lib/models/records/neos_db_asset.dart b/lib/models/records/neos_db_asset.dart new file mode 100644 index 0000000..8b0c64e --- /dev/null +++ b/lib/models/records/neos_db_asset.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +class NeosDBAsset { + final String hash; + final int bytes; + + const NeosDBAsset({required this.hash, required this.bytes}); + + factory NeosDBAsset.fromMap(Map map) { + return NeosDBAsset(hash: map["hash"] ?? "", bytes: map["bytes"] ?? -1); + } + + factory NeosDBAsset.fromData(Uint8List data) { + final digest = sha256.convert(data); + return NeosDBAsset(hash: digest.toString().replaceAll("-", "").toLowerCase(), bytes: data.length); + } + + Map toMap() { + return { + "hash": hash, + "bytes": bytes, + }; + } +} \ No newline at end of file diff --git a/lib/models/records/preprocess_status.dart b/lib/models/records/preprocess_status.dart new file mode 100644 index 0000000..9b25d03 --- /dev/null +++ b/lib/models/records/preprocess_status.dart @@ -0,0 +1,41 @@ +import 'package:contacts_plus_plus/models/records/asset_diff.dart'; + +enum RecordPreprocessState +{ + preprocessing, + success, + failed; + + factory RecordPreprocessState.fromString(String? text) { + return RecordPreprocessState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => RecordPreprocessState.failed, + ); + } +} + + +class PreprocessStatus { + final String id; + final String ownerId; + final String recordId; + final RecordPreprocessState state; + final num progress; + final String failReason; + final List resultDiffs; + + const PreprocessStatus({required this.id, required this.ownerId, required this.recordId, required this.state, + required this.progress, required this.failReason, required this.resultDiffs, + }); + + factory PreprocessStatus.fromMap(Map map) { + return PreprocessStatus( + id: map["id"], + ownerId: map["ownerId"], + recordId: map["recordId"], + state: RecordPreprocessState.fromString(map["state"]), + progress: map["progress"], + failReason: map["failReason"] ?? "", + resultDiffs: (map["resultDiffs"] as List? ?? []).map((e) => AssetDiff.fromMap(e)).toList(), + ); + } +} \ No newline at end of file diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart new file mode 100644 index 0000000..97ef243 --- /dev/null +++ b/lib/models/records/record.dart @@ -0,0 +1,247 @@ +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +enum RecordType { + unknown, + link, + object, + directory, + texture, + audio; + + factory RecordType.fromName(String? name) { + return RecordType.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase().trim(), orElse: () => RecordType.unknown); + } +} + +class RecordId { + final String? id; + final String? ownerId; + final bool isValid; + + const RecordId({required this.id, required this.ownerId, required this.isValid}); + + factory RecordId.fromMap(Map? map) { + return RecordId(id: map?["id"], ownerId: map?["ownerId"], isValid: map?["isValid"] ?? false); + } + + Map toMap() { + return { + "id": id, + "ownerId": ownerId, + "isValid": isValid, + }; + } +} + +class Record { + final int id; + final RecordId combinedRecordId; + final String recordId; + final String ownerId; + final String assetUri; + final int globalVersion; + final int localVersion; + final String lastModifyingUserId; + final String lastModifyingMachineId; + final bool isSynced; + final DateTime fetchedOn; + final String name; + final FormatNode formattedName; + final String description; + final RecordType recordType; + final List tags; + final String path; + final String thumbnailUri; + final bool isPublic; + final bool isForPatreons; + final bool isListed; + final DateTime lastModificationTime; + final DateTime creationTime; + final int visits; + final int rating; + final int randomOrder; + final List manifest; + final List neosDBManifest; + final String url; + final bool isValidOwnerId; + final bool isValidRecordId; + + Record({ + required this.id, + required this.combinedRecordId, + required this.recordId, + required this.isSynced, + required this.fetchedOn, + required this.path, + required this.ownerId, + required this.assetUri, + required this.name, + required this.description, + required this.tags, + required this.recordType, + required this.thumbnailUri, + required this.isPublic, + required this.isListed, + required this.isForPatreons, + required this.lastModificationTime, + required this.neosDBManifest, + required this.lastModifyingUserId, + required this.lastModifyingMachineId, + required this.creationTime, + required this.manifest, + required this.url, + required this.isValidOwnerId, + required this.isValidRecordId, + required this.globalVersion, + required this.localVersion, + required this.visits, + required this.rating, + required this.randomOrder, + }) : formattedName = FormatNode.fromText(name); + + factory Record.fromMap(Map map) { + return Record( + id: map["id"] ?? 0, + combinedRecordId: RecordId.fromMap(map["combinedRecordId"]), + recordId: map["recordId"], + ownerId: map["ownerId"] ?? "", + assetUri: map["assetUri"] ?? "", + globalVersion: map["globalVersion"] ?? 0, + localVersion: map["localVersion"] ?? 0, + name: map["name"] ?? "", + description: map["description"] ?? "", + tags: (map["tags"] as List? ?? []).map((e) => e.toString()).toList(), + recordType: RecordType.fromName(map["recordType"]), + thumbnailUri: map["thumbnailUri"] ?? "", + isPublic: map["isPublic"] ?? false, + isForPatreons: map["isForPatreons"] ?? false, + isListed: map["isListed"] ?? false, + lastModificationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, + neosDBManifest: (map["neosDBManifest"] as List? ?? []).map((e) => NeosDBAsset.fromMap(e)).toList(), + lastModifyingUserId: map["lastModifyingUserId"] ?? "", + lastModifyingMachineId: map["lastModifyingMachineId"] ?? "", + creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, + isSynced: map["isSynced"] ?? false, + fetchedOn: DateTime.tryParse(map["fetchedOn"]) ?? DateTimeX.epoch, + path: map["path"] ?? "", + manifest: (map["neosDBManifest"] as List? ?? []).map((e) => e.toString()).toList(), + url: map["url"] ?? "", + isValidOwnerId: map["isValidOwnerId"] ?? "", + isValidRecordId: map["isValidRecordId"] ?? "", + visits: map["visits"] ?? 0, + rating: map["rating"] ?? 0, + randomOrder: map["randomOrder"] ?? 0 + ); + } + + Record copyWith({ + int? id, + String? ownerId, + String? recordId, + String? assetUri, + int? globalVersion, + int? localVersion, + String? name, + TextSpan? formattedName, + String? description, + List? tags, + RecordType? recordType, + String? thumbnailUri, + bool? isPublic, + bool? isForPatreons, + bool? isListed, + bool? isDeleted, + DateTime? lastModificationTime, + List? neosDBManifest, + String? lastModifyingUserId, + String? lastModifyingMachineId, + DateTime? creationTime, + RecordId? combinedRecordId, + bool? isSynced, + DateTime? fetchedOn, + String? path, + List? manifest, + String? url, + bool? isValidOwnerId, + bool? isValidRecordId, + int? visits, + int? rating, + int? randomOrder, + }) { + return Record( + id: id ?? this.id, + ownerId: ownerId ?? this.ownerId, + recordId: recordId ?? this.recordId, + assetUri: assetUri ?? this.assetUri, + globalVersion: globalVersion ?? this.globalVersion, + localVersion: localVersion ?? this.localVersion, + name: name ?? this.name, + description: description ?? this.description, + tags: tags ?? this.tags, + recordType: recordType ?? this.recordType, + thumbnailUri: thumbnailUri ?? this.thumbnailUri, + isPublic: isPublic ?? this.isPublic, + isForPatreons: isForPatreons ?? this.isForPatreons, + isListed: isListed ?? this.isListed, + lastModificationTime: lastModificationTime ?? this.lastModificationTime, + neosDBManifest: neosDBManifest ?? this.neosDBManifest, + lastModifyingUserId: lastModifyingUserId ?? this.lastModifyingUserId, + lastModifyingMachineId: lastModifyingMachineId ?? this.lastModifyingMachineId, + creationTime: creationTime ?? this.creationTime, + combinedRecordId: combinedRecordId ?? this.combinedRecordId, + isSynced: isSynced ?? this.isSynced, + fetchedOn: fetchedOn ?? this.fetchedOn, + path: path ?? this.path, + manifest: manifest ?? this.manifest, + url: url ?? this.url, + isValidOwnerId: isValidOwnerId ?? this.isValidOwnerId, + isValidRecordId: isValidRecordId ?? this.isValidRecordId, + visits: visits ?? this.visits, + rating: rating ?? this.rating, + randomOrder: randomOrder ?? this.randomOrder, + ); + } + + Map toMap() { + return { + "id": id, + "ownerId": ownerId, + "recordId": recordId, + "assetUri": assetUri, + "globalVersion": globalVersion, + "localVersion": localVersion, + "name": name, + "description": description, + "tags": tags, + "recordType": recordType.name, + "thumbnailUri": thumbnailUri, + "isPublic": isPublic, + "isForPatreons": isForPatreons, + "isListed": isListed, + "lastModificationTime": lastModificationTime.toUtc().toIso8601String(), + "neosDBManifest": neosDBManifest.map((e) => e.toMap()).toList(), + "lastModifyingUserId": lastModifyingUserId, + "lastModifyingMachineId": lastModifyingMachineId, + "creationTime": creationTime.toUtc().toIso8601String(), + "combinedRecordId": combinedRecordId.toMap(), + "isSynced": isSynced, + "fetchedOn": fetchedOn.toUtc().toIso8601String(), + "path": path, + "manifest": manifest, + "url": url, + "isValidOwnerId": isValidOwnerId, + "isValidRecordId": isValidRecordId, + "visits": visits, + "rating": rating, + "randomOrder": randomOrder, + }; + } + + static String generateId() { + return "R-${const Uuid().v4()}"; + } +} \ No newline at end of file