Add asset upload models and apis

This commit is contained in:
Nutcake 2023-05-16 15:59:31 +02:00
parent 69d69d0aa4
commit b6ade63caf
7 changed files with 536 additions and 0 deletions

137
lib/apis/record_api.dart Normal file
View file

@ -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<List<Record>> 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<PreprocessStatus> 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<PreprocessStatus> 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<AssetUploadData> 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<void> 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<String> 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<void> finishUpload(ApiClient client, {required NeosDBAsset asset}) async {
final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks");
ApiClient.checkResponse(response);
}
static Future<Record> 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;
}
}

View file

@ -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);
}

View file

@ -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,
);
}
}

View file

@ -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"]),
);
}
}

View file

@ -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,
};
}
}

View file

@ -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<AssetDiff> 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(),
);
}
}

View file

@ -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<String> 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<String> manifest;
final List<NeosDBAsset> 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<String>? tags,
RecordType? recordType,
String? thumbnailUri,
bool? isPublic,
bool? isForPatreons,
bool? isListed,
bool? isDeleted,
DateTime? lastModificationTime,
List<NeosDBAsset>? neosDBManifest,
String? lastModifyingUserId,
String? lastModifyingMachineId,
DateTime? creationTime,
RecordId? combinedRecordId,
bool? isSynced,
DateTime? fetchedOn,
String? path,
List<String>? 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()}";
}
}