import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:paperless_mobile/core/model/error_message.dart'; import 'package:paperless_mobile/core/store/local_vault.dart'; import 'package:paperless_mobile/core/type/types.dart'; import 'package:paperless_mobile/core/util.dart'; import 'package:paperless_mobile/di_initializer.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/features/documents/model/bulk_edit.model.dart'; import 'package:paperless_mobile/features/documents/model/document.model.dart'; import 'package:paperless_mobile/features/documents/model/document_filter.dart'; import 'package:paperless_mobile/features/documents/model/document_meta_data.model.dart'; import 'package:paperless_mobile/features/documents/model/paged_search_result.dart'; import 'package:paperless_mobile/features/documents/model/query_parameters/asn_query.dart'; import 'package:paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; import 'package:paperless_mobile/features/documents/model/query_parameters/sort_order.dart'; import 'package:paperless_mobile/features/documents/model/similar_document.model.dart'; import 'package:paperless_mobile/features/documents/repository/document_repository.dart'; import 'package:paperless_mobile/util.dart'; import 'package:http/http.dart'; import 'package:http/src/boundary_characters.dart'; //TODO: remove once there is either a paperless API update or there is a better solution... import 'package:injectable/injectable.dart'; @Injectable(as: DocumentRepository) class DocumentRepositoryImpl implements DocumentRepository { //// //final StatusService statusService; final LocalVault localStorage; final BaseClient httpClient; DocumentRepositoryImpl( //this.statusService, this.localStorage, @Named("timeoutClient") this.httpClient, ); @override Future create( Uint8List documentBytes, String filename, { required String title, int? documentType, int? correspondent, List? tags, DateTime? createdAt, }) async { final auth = await localStorage.loadAuthenticationInformation(); if (auth == null) { throw const ErrorMessage(ErrorCode.notAuthenticated); } // The multipart request has to be generated from scratch as the http library does // not allow the same key (tags) to be added multiple times. However, this is what the // paperless api expects, i.e. one block for each tag. final request = await getIt().postUrl( Uri.parse("${auth.serverUrl}/api/documents/post_document/"), ); final boundary = _boundaryString(); StringBuffer bodyBuffer = StringBuffer(); var fields = {}; fields.tryPutIfAbsent('title', () => title); fields.tryPutIfAbsent('created', () => formatDateNullable(createdAt)); fields.tryPutIfAbsent('correspondent', () => correspondent == null ? null : json.encode(correspondent)); fields.tryPutIfAbsent('document_type', () => documentType == null ? null : json.encode(documentType)); for (final key in fields.keys) { bodyBuffer.write(_buildMultipartField(key, fields[key]!, boundary)); } for (final tag in tags ?? []) { bodyBuffer.write(_buildMultipartField('tags', tag.toString(), boundary)); } bodyBuffer.write("--$boundary" '\r\nContent-Disposition: form-data; name="document"; filename="$filename"' "\r\nContent-type: application/octet-stream" "\r\n\r\n"); final closing = "\r\n--" + boundary + "--\r\n"; // Set headers request.headers.set(HttpHeaders.contentTypeHeader, "multipart/form-data; boundary=" + boundary); request.headers.set(HttpHeaders.contentLengthHeader, "${bodyBuffer.length + closing.length + documentBytes.lengthInBytes}"); request.headers.set(HttpHeaders.authorizationHeader, "Token ${auth.token}"); //Write fields to request request.write(bodyBuffer.toString()); //Stream file await request.addStream(Stream.fromIterable(documentBytes.map((e) => [e]))); // Write closing boundary to request request.write(closing); final response = await request.close(); if (response.statusCode != 200) { throw ErrorMessage(ErrorCode.documentUploadFailed, httpStatusCode: response.statusCode); } } String _buildMultipartField(String fieldName, String value, String boundary) { return '--$boundary' '\r\nContent-Disposition: form-data; name="$fieldName"' '\r\nContent-type: text/plain' '\r\n\r\n' + value + '\r\n'; } String _boundaryString() { Random _random = Random(); var prefix = 'dart-http-boundary-'; var list = List.generate( 70 - prefix.length, (index) => boundaryCharacters[_random.nextInt(boundaryCharacters.length)], growable: false); return '$prefix${String.fromCharCodes(list)}'; } @override Future update(DocumentModel doc) async { final response = await httpClient.put( Uri.parse("/api/documents/${doc.id}/"), body: json.encode(doc.toJson()), headers: {"Content-Type": "application/json"}).timeout(requestTimeout); if (response.statusCode == 200) { return DocumentModel.fromJson( jsonDecode(utf8.decode(response.bodyBytes)) as JSON, ); } else { throw const ErrorMessage(ErrorCode.documentUpdateFailed); } } @override Future> find(DocumentFilter filter) async { final filterParams = filter.toQueryString(); final response = await httpClient.get( Uri.parse("/api/documents/?$filterParams"), ); if (response.statusCode == 200) { return compute( PagedSearchResult.fromJson, PagedSearchResultJsonSerializer( jsonDecode(utf8.decode(response.bodyBytes)), DocumentModel.fromJson, ), ); } else { throw const ErrorMessage(ErrorCode.documentLoadFailed); } } @override Future delete(DocumentModel doc) async { final response = await httpClient.delete(Uri.parse("/api/documents/${doc.id}/")); if (response.statusCode == 204) { return Future.value(doc.id); } throw const ErrorMessage(ErrorCode.documentDeleteFailed); } @override String getThumbnailUrl(int documentId) { return "/api/documents/$documentId/thumb/"; } String getPreviewUrl(int documentId) { return "/api/documents/$documentId/preview/"; } @override Future getPreview(int documentId) async { final response = await httpClient.get(Uri.parse(getPreviewUrl(documentId))); if (response.statusCode == 200) { return response.bodyBytes; } throw const ErrorMessage(ErrorCode.documentPreviewFailed); } @override Future findNextAsn() async { const DocumentFilter asnQueryFilter = DocumentFilter( sortField: SortField.archiveSerialNumber, sortOrder: SortOrder.descending, asn: AsnQuery.anyAssigned(), page: 1, pageSize: 1, ); try { final result = await find(asnQueryFilter); return result.results .map((e) => e.archiveSerialNumber) .firstWhere((asn) => asn != null, orElse: () => 0)! + 1; } on ErrorMessage catch (_) { throw const ErrorMessage(ErrorCode.documentAsnQueryFailed); } } @override Future> bulkDelete(List documentModels) async { final List ids = documentModels.map((e) => e.id).toList(); final action = BulkEditAction.delete(ids); final response = await httpClient.post( Uri.parse("/api/documents/bulk_edit/"), body: json.encode(action.toJson()), headers: {'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { return ids; } else { throw const ErrorMessage(ErrorCode.documentBulkDeleteFailed); } } @override Future waitForConsumptionFinished( String fileName, String title) async { // Always wait 5 seconds, processing usually takes longer... //await Future.delayed(const Duration(seconds: 5)); PagedSearchResult results = await find(DocumentFilter.latestDocument); while ((results.results.isEmpty || (results.results[0].originalFileName != fileName && results.results[0].title != title))) { //TODO: maybe implement more intelligent retry logic or find workaround for websocket authentication... await Future.delayed(const Duration(seconds: 2)); results = await find(DocumentFilter.latestDocument); } try { return results.results.first; } on StateError { throw const ErrorMessage(ErrorCode.documentUploadFailed); } } @override Future download(DocumentModel document) async { //TODO: Check if this works... final response = await httpClient .get(Uri.parse("/api/documents/${document.id}/download/")); return response.bodyBytes; } @override Future getMetaData(DocumentModel document) async { final response = await httpClient .get(Uri.parse("/api/documents/${document.id}/metadata/")); return compute( DocumentMetaData.fromJson, jsonDecode(utf8.decode(response.bodyBytes)) as JSON, ); } @override Future> autocomplete(String query, [int limit = 10]) async { final response = await httpClient .get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}")); if (response.statusCode == 200) { return jsonDecode(utf8.decode(response.bodyBytes)) as List; } throw const ErrorMessage(ErrorCode.autocompleteQueryError); } @override Future> findSimilar(int docId) async { final response = await httpClient .get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10")); if (response.statusCode == 200) { return (await compute( PagedSearchResult.fromJson, PagedSearchResultJsonSerializer( jsonDecode(utf8.decode(response.bodyBytes)), SimilarDocumentModel.fromJson, ), )) .results; } throw const ErrorMessage(ErrorCode.similarQueryError); } }