Externalized API and models as own package

This commit is contained in:
Anton Stubenbord
2022-12-02 01:48:13 +01:00
parent 60d1a2e62a
commit ec7707e4a4
143 changed files with 1496 additions and 1339 deletions

30
packages/paperless_api/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/

View File

@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849
channel: stable
project_type: package

View File

@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

View File

@@ -0,0 +1 @@
TODO: Add your license here.

View File

@@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/developing-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.

View File

@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,4 @@
library paperless_api;
export 'src/models/models.dart';
export 'src/modules/modules.dart';

View File

@@ -0,0 +1,3 @@
import 'package:intl/intl.dart';
final DateFormat apiDateFormat = DateFormat('yyyy-MM-dd');

View File

@@ -0,0 +1,50 @@
abstract class BulkAction {
final Iterable<int> documentIds;
BulkAction(this.documentIds);
Map<String, dynamic> toJson();
}
class BulkDeleteAction extends BulkAction {
BulkDeleteAction(super.documents);
@override
Map<String, dynamic> toJson() {
return {
'documents': documentIds.toList(),
'method': 'delete',
'parameters': {},
};
}
}
class BulkModifyTagsAction extends BulkAction {
final Iterable<int> removeTags;
final Iterable<int> addTags;
BulkModifyTagsAction(
super.documents, {
this.removeTags = const [],
this.addTags = const [],
});
BulkModifyTagsAction.addTags(super.documents, this.addTags)
: removeTags = const [];
BulkModifyTagsAction.removeTags(super.documents, Iterable<int> tags)
: addTags = const [],
removeTags = tags;
@override
Map<String, dynamic> toJson() {
return {
'documents': documentIds.toList(),
'method': 'modify_tags',
'parameters': {
'add_tags': addTags.toList(),
'remove_tags': removeTags.toList(),
}
};
}
}

View File

@@ -0,0 +1,174 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_api/src/constants.dart';
import 'package:paperless_api/src/models/query_parameters/asn_query.dart';
import 'package:paperless_api/src/models/query_parameters/correspondent_query.dart';
import 'package:paperless_api/src/models/query_parameters/document_type_query.dart';
import 'package:paperless_api/src/models/query_parameters/query_type.dart';
import 'package:paperless_api/src/models/query_parameters/sort_field.dart';
import 'package:paperless_api/src/models/query_parameters/sort_order.dart';
import 'package:paperless_api/src/models/query_parameters/storage_path_query.dart';
import 'package:paperless_api/src/models/query_parameters/tags_query.dart';
class DocumentFilter extends Equatable {
static const _oneDay = Duration(days: 1);
static const DocumentFilter initial = DocumentFilter();
static const DocumentFilter latestDocument = DocumentFilter(
sortField: SortField.added,
sortOrder: SortOrder.descending,
pageSize: 1,
page: 1,
);
final int pageSize;
final int page;
final DocumentTypeQuery documentType;
final CorrespondentQuery correspondent;
final StoragePathQuery storagePath;
final AsnQuery asn;
final TagsQuery tags;
final SortField sortField;
final SortOrder sortOrder;
final DateTime? addedDateAfter;
final DateTime? addedDateBefore;
final DateTime? createdDateAfter;
final DateTime? createdDateBefore;
final QueryType queryType;
final String? queryText;
const DocumentFilter({
this.createdDateAfter,
this.createdDateBefore,
this.documentType = const DocumentTypeQuery.unset(),
this.correspondent = const CorrespondentQuery.unset(),
this.storagePath = const StoragePathQuery.unset(),
this.asn = const AsnQuery.unset(),
this.tags = const IdsTagsQuery(),
this.sortField = SortField.created,
this.sortOrder = SortOrder.descending,
this.page = 1,
this.pageSize = 25,
this.addedDateAfter,
this.addedDateBefore,
this.queryType = QueryType.titleAndContent,
this.queryText,
});
String toQueryString() {
final StringBuffer sb = StringBuffer("page=$page&page_size=$pageSize");
sb.write(documentType.toQueryParameter());
sb.write(correspondent.toQueryParameter());
sb.write(tags.toQueryParameter());
sb.write(storagePath.toQueryParameter());
sb.write(asn.toQueryParameter());
if (queryText?.isNotEmpty ?? false) {
sb.write("&${queryType.queryParam}=$queryText");
}
sb.write("&ordering=${sortOrder.queryString}${sortField.queryString}");
// Add/subtract one day in the following because paperless uses gt/lt not gte/lte
if (addedDateAfter != null) {
sb.write(
"&added__date__gt=${apiDateFormat.format(addedDateAfter!.subtract(_oneDay))}");
}
if (addedDateBefore != null) {
sb.write(
"&added__date__lt=${apiDateFormat.format(addedDateBefore!.add(_oneDay))}");
}
if (createdDateAfter != null) {
sb.write(
"&created__date__gt=${apiDateFormat.format(createdDateAfter!.subtract(_oneDay))}");
}
if (createdDateBefore != null) {
sb.write(
"&created__date__lt=${apiDateFormat.format(createdDateBefore!.add(_oneDay))}");
}
return sb.toString();
}
@override
String toString() {
return toQueryString();
}
DocumentFilter copyWith({
int? pageSize,
int? page,
bool? onlyNoDocumentType,
DocumentTypeQuery? documentType,
CorrespondentQuery? correspondent,
StoragePathQuery? storagePath,
TagsQuery? tags,
SortField? sortField,
SortOrder? sortOrder,
DateTime? addedDateAfter,
DateTime? addedDateBefore,
DateTime? createdDateBefore,
DateTime? createdDateAfter,
QueryType? queryType,
String? queryText,
}) {
return DocumentFilter(
pageSize: pageSize ?? this.pageSize,
page: page ?? this.page,
documentType: documentType ?? this.documentType,
correspondent: correspondent ?? this.correspondent,
storagePath: storagePath ?? this.storagePath,
tags: tags ?? this.tags,
sortField: sortField ?? this.sortField,
sortOrder: sortOrder ?? this.sortOrder,
addedDateAfter: addedDateAfter ?? this.addedDateAfter,
addedDateBefore: addedDateBefore ?? this.addedDateBefore,
queryType: queryType ?? this.queryType,
queryText: queryText ?? this.queryText,
createdDateBefore: createdDateBefore ?? this.createdDateBefore,
createdDateAfter: createdDateAfter ?? this.createdDateAfter,
);
}
String? get titleOnlyMatchString {
if (queryType == QueryType.title) {
return queryText?.isEmpty ?? true ? null : queryText;
}
return null;
}
String? get titleAndContentMatchString {
if (queryType == QueryType.titleAndContent) {
return queryText?.isEmpty ?? true ? null : queryText;
}
return null;
}
String? get extendedMatchString {
if (queryType == QueryType.extended) {
return queryText?.isEmpty ?? true ? null : queryText;
}
return null;
}
@override
List<Object?> get props => [
pageSize,
page,
documentType,
correspondent,
storagePath,
asn,
tags,
sortField,
sortOrder,
addedDateAfter,
addedDateBefore,
createdDateAfter,
createdDateBefore,
queryType,
queryText,
];
}

View File

@@ -0,0 +1,40 @@
class DocumentMetaData {
String originalChecksum;
int originalSize;
String originalMimeType;
String mediaFilename;
bool hasArchiveVersion;
String? archiveChecksum;
int? archiveSize;
DocumentMetaData({
required this.originalChecksum,
required this.originalSize,
required this.originalMimeType,
required this.mediaFilename,
required this.hasArchiveVersion,
this.archiveChecksum,
this.archiveSize,
});
DocumentMetaData.fromJson(Map<String, dynamic> json)
: originalChecksum = json['original_checksum'],
originalSize = json['original_size'],
originalMimeType = json['original_mime_type'],
mediaFilename = json['media_filename'],
hasArchiveVersion = json['has_archive_version'],
archiveChecksum = json['archive_checksum'],
archiveSize = json['archive_size'];
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['original_checksum'] = originalChecksum;
data['original_size'] = originalSize;
data['original_mime_type'] = originalMimeType;
data['media_filename'] = mediaFilename;
data['has_archive_version'] = hasArchiveVersion;
data['archive_checksum'] = archiveChecksum;
data['archive_size'] = archiveSize;
return data;
}
}

View File

@@ -0,0 +1,136 @@
// ignore_for_file: non_constant_identifier_names
import 'package:equatable/equatable.dart';
class DocumentModel extends Equatable {
static const idKey = 'id';
static const titleKey = 'title';
static const contentKey = 'content';
static const archivedFileNameKey = 'archived_file_name';
static const asnKey = 'archive_serial_number';
static const createdKey = 'created';
static const modifiedKey = 'modified';
static const addedKey = 'added';
static const correspondentKey = 'correspondent';
static const originalFileNameKey = 'original_file_name';
static const documentTypeKey = 'document_type';
static const tagsKey = 'tags';
static const storagePathKey = 'storage_path';
final int id;
final String title;
final String? content;
final Iterable<int> tags;
final int? documentType;
final int? correspondent;
final int? storagePath;
final DateTime created;
final DateTime modified;
final DateTime added;
final int? archiveSerialNumber;
final String originalFileName;
final String? archivedFileName;
const DocumentModel({
required this.id,
required this.title,
this.content,
this.tags = const <int>[],
required this.documentType,
required this.correspondent,
required this.created,
required this.modified,
required this.added,
this.archiveSerialNumber,
required this.originalFileName,
this.archivedFileName,
this.storagePath,
});
DocumentModel.fromJson(Map<String, dynamic> json)
: id = json[idKey],
title = json[titleKey],
content = json[contentKey],
created = DateTime.parse(json[createdKey]),
modified = DateTime.parse(json[modifiedKey]),
added = DateTime.parse(json[addedKey]),
archiveSerialNumber = json[asnKey],
originalFileName = json[originalFileNameKey],
archivedFileName = json[archivedFileNameKey],
tags = (json[tagsKey] as List<dynamic>).cast<int>(),
correspondent = json[correspondentKey],
documentType = json[documentTypeKey],
storagePath = json[storagePathKey];
Map<String, dynamic> toJson() {
return {
idKey: id,
titleKey: title,
asnKey: archiveSerialNumber,
archivedFileNameKey: archivedFileName,
contentKey: content,
correspondentKey: correspondent,
documentTypeKey: documentType,
createdKey: created.toUtc().toIso8601String(),
modifiedKey: modified.toUtc().toIso8601String(),
addedKey: added.toUtc().toIso8601String(),
originalFileNameKey: originalFileName,
tagsKey: tags.toList(),
storagePathKey: storagePath,
};
}
DocumentModel copyWith({
String? title,
String? content,
bool overwriteTags = false,
Iterable<int>? tags,
bool overwriteDocumentType = false,
int? documentType,
bool overwriteCorrespondent = false,
int? correspondent,
bool overwriteStoragePath = false,
int? storagePath,
DateTime? created,
DateTime? modified,
DateTime? added,
int? archiveSerialNumber,
String? originalFileName,
String? archivedFileName,
}) {
return DocumentModel(
id: id,
title: title ?? this.title,
content: content ?? this.content,
documentType: overwriteDocumentType ? documentType : this.documentType,
correspondent:
overwriteCorrespondent ? correspondent : this.correspondent,
storagePath: overwriteDocumentType ? storagePath : this.storagePath,
tags: overwriteTags ? tags ?? [] : this.tags,
created: created ?? this.created,
modified: modified ?? this.modified,
added: added ?? this.added,
originalFileName: originalFileName ?? this.originalFileName,
archiveSerialNumber: archiveSerialNumber ?? this.archiveSerialNumber,
archivedFileName: archivedFileName ?? this.archivedFileName,
);
}
@override
List<Object?> get props => [
id,
title,
content.hashCode,
tags,
documentType,
storagePath,
correspondent,
created,
modified,
added,
archiveSerialNumber,
originalFileName,
archivedFileName,
storagePath
];
}

View File

@@ -0,0 +1,203 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_api/src/constants.dart';
import 'package:paperless_api/src/models/document_filter.dart';
import 'package:paperless_api/src/models/query_parameters/correspondent_query.dart';
import 'package:paperless_api/src/models/query_parameters/document_type_query.dart';
import 'package:paperless_api/src/models/query_parameters/query_type.dart';
import 'package:paperless_api/src/models/query_parameters/storage_path_query.dart';
import 'package:paperless_api/src/models/query_parameters/tags_query.dart';
class FilterRule with EquatableMixin {
static const int titleRule = 0;
static const int asnRule = 2;
static const int correspondentRule = 3;
static const int documentTypeRule = 4;
static const int includeTagsRule = 6;
static const int hasAnyTag = 7; // true = any tag, false = not assigned
static const int createdBeforeRule = 8;
static const int createdAfterRule = 9;
static const int addedBeforeRule = 13;
static const int addedAfterRule = 14;
static const int excludeTagsRule = 17;
static const int titleAndContentRule = 19;
static const int extendedRule = 20;
static const int storagePathRule = 25;
// Currently unsupported view options:
static const int _content = 1;
static const int _isInInbox = 5;
static const int _createdYearIs = 10;
static const int _createdMonthIs = 11;
static const int _createdDayIs = 12;
static const int _modifiedBefore = 15;
static const int _modifiedAfter = 16;
static const int _doesNotHaveAsn = 18;
static const int _moreLikeThis = 21;
static const int _hasTagsIn = 22;
static const int _asnGreaterThan = 23;
static const int _asnLessThan = 24;
final int ruleType;
final String? value;
FilterRule(this.ruleType, this.value);
FilterRule.fromJson(Map<String, dynamic> json)
: ruleType = json['rule_type'],
value = json['value'];
Map<String, dynamic> toJson() {
return {
'rule_type': ruleType,
'value': value,
};
}
DocumentFilter applyToFilter(final DocumentFilter filter) {
//TODO: Check in profiling mode if this is inefficient enough to cause stutters...
switch (ruleType) {
case titleRule:
return filter.copyWith(queryText: value, queryType: QueryType.title);
case documentTypeRule:
return filter.copyWith(
documentType: value == null
? const DocumentTypeQuery.notAssigned()
: DocumentTypeQuery.fromId(int.parse(value!)),
);
case correspondentRule:
return filter.copyWith(
correspondent: value == null
? const CorrespondentQuery.notAssigned()
: CorrespondentQuery.fromId(int.parse(value!)),
);
case storagePathRule:
return filter.copyWith(
storagePath: value == null
? const StoragePathQuery.notAssigned()
: StoragePathQuery.fromId(int.parse(value!)),
);
case hasAnyTag:
return filter.copyWith(
tags: value == "true"
? const AnyAssignedTagsQuery()
: const OnlyNotAssignedTagsQuery(),
);
case includeTagsRule:
assert(filter.tags is IdsTagsQuery);
return filter.copyWith(
tags: (filter.tags as IdsTagsQuery)
.withIdQueriesAdded([IncludeTagIdQuery(int.parse(value!))]),
);
case excludeTagsRule:
assert(filter.tags is IdsTagsQuery);
return filter.copyWith(
tags: (filter.tags as IdsTagsQuery)
.withIdQueriesAdded([ExcludeTagIdQuery(int.parse(value!))]),
);
case createdBeforeRule:
return filter.copyWith(
createdDateBefore: value == null ? null : DateTime.parse(value!),
);
case createdAfterRule:
return filter.copyWith(
createdDateAfter: value == null ? null : DateTime.parse(value!),
);
case addedBeforeRule:
return filter.copyWith(
addedDateBefore: value == null ? null : DateTime.parse(value!),
);
case addedAfterRule:
return filter.copyWith(
addedDateAfter: value == null ? null : DateTime.parse(value!),
);
case titleAndContentRule:
return filter.copyWith(
queryText: value,
queryType: QueryType.titleAndContent,
);
case extendedRule:
return filter.copyWith(queryText: value, queryType: QueryType.extended);
//TODO: Add currently unused rules
default:
return filter;
}
}
///
/// Converts a [DocumentFilter] to a list of [FilterRule]s.
///
static List<FilterRule> fromFilter(final DocumentFilter filter) {
List<FilterRule> filterRules = [];
if (filter.correspondent.onlyNotAssigned) {
filterRules.add(FilterRule(correspondentRule, null));
}
if (filter.correspondent.isSet) {
filterRules.add(
FilterRule(correspondentRule, filter.correspondent.id!.toString()));
}
if (filter.documentType.onlyNotAssigned) {
filterRules.add(FilterRule(documentTypeRule, null));
}
if (filter.documentType.isSet) {
filterRules.add(
FilterRule(documentTypeRule, filter.documentType.id!.toString()));
}
if (filter.storagePath.onlyNotAssigned) {
filterRules.add(FilterRule(storagePathRule, null));
}
if (filter.storagePath.isSet) {
filterRules
.add(FilterRule(storagePathRule, filter.storagePath.id!.toString()));
}
if (filter.tags is OnlyNotAssignedTagsQuery) {
filterRules.add(FilterRule(hasAnyTag, false.toString()));
}
if (filter.tags is AnyAssignedTagsQuery) {
filterRules.add(FilterRule(hasAnyTag, true.toString()));
}
if (filter.tags is IdsTagsQuery) {
filterRules.addAll((filter.tags as IdsTagsQuery)
.includedIds
.map((id) => FilterRule(includeTagsRule, id.toString())));
filterRules.addAll((filter.tags as IdsTagsQuery)
.excludedIds
.map((id) => FilterRule(excludeTagsRule, id.toString())));
}
if (filter.queryText != null) {
switch (filter.queryType) {
case QueryType.title:
filterRules.add(FilterRule(titleRule, filter.queryText!));
break;
case QueryType.titleAndContent:
filterRules.add(FilterRule(titleAndContentRule, filter.queryText!));
break;
case QueryType.extended:
filterRules.add(FilterRule(extendedRule, filter.queryText!));
break;
case QueryType.asn:
filterRules.add(FilterRule(asnRule, filter.queryText!));
break;
}
}
if (filter.createdDateAfter != null) {
filterRules.add(FilterRule(
createdAfterRule, apiDateFormat.format(filter.createdDateAfter!)));
}
if (filter.createdDateBefore != null) {
filterRules.add(FilterRule(
createdBeforeRule, apiDateFormat.format(filter.createdDateBefore!)));
}
if (filter.addedDateAfter != null) {
filterRules.add(FilterRule(
addedAfterRule, apiDateFormat.format(filter.addedDateAfter!)));
}
if (filter.addedDateBefore != null) {
filterRules.add(FilterRule(
addedBeforeRule, apiDateFormat.format(filter.addedDateBefore!)));
}
return filterRules;
}
@override
List<Object?> get props => [ruleType, value];
}

View File

@@ -0,0 +1,63 @@
import 'package:paperless_api/src/models/labels/label_model.dart';
import 'package:paperless_api/src/models/labels/matching_algorithm.dart';
class Correspondent extends Label {
static const lastCorrespondenceKey = 'last_correspondence';
late DateTime? lastCorrespondence;
Correspondent({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
this.lastCorrespondence,
});
Correspondent.fromJson(Map<String, dynamic> json)
: lastCorrespondence =
DateTime.tryParse(json[lastCorrespondenceKey] ?? ''),
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(Map<String, dynamic> json) {
if (lastCorrespondence != null) {
json.putIfAbsent(
lastCorrespondenceKey, () => lastCorrespondence!.toIso8601String());
}
}
@override
Correspondent copyWith({
int? id,
String? name,
String? slug,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
DateTime? lastCorrespondence,
}) {
return Correspondent(
id: id ?? this.id,
name: name ?? this.name,
documentCount: documentCount ?? documentCount,
isInsensitive: isInsensitive ?? isInsensitive,
lastCorrespondence: lastCorrespondence ?? this.lastCorrespondence,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
slug: slug ?? this.slug,
);
}
@override
String get queryEndpoint => 'correspondents';
}

View File

@@ -0,0 +1,43 @@
import 'package:paperless_api/src/models/labels/label_model.dart';
import 'package:paperless_api/src/models/labels/matching_algorithm.dart';
class DocumentType extends Label {
DocumentType({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
});
DocumentType.fromJson(Map<String, dynamic> json) : super.fromJson(json);
@override
void addSpecificFieldsToJson(Map<String, dynamic> json) {}
@override
String get queryEndpoint => 'document_types';
@override
DocumentType copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
}) {
return DocumentType(
id: id ?? this.id,
name: name ?? this.name,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
isInsensitive: isInsensitive ?? this.isInsensitive,
documentCount: documentCount ?? this.documentCount,
slug: slug ?? this.slug,
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_api/src/models/labels/matching_algorithm.dart';
abstract class Label with EquatableMixin implements Comparable {
static const idKey = "id";
static const nameKey = "name";
static const slugKey = "slug";
static const matchKey = "match";
static const matchingAlgorithmKey = "matching_algorithm";
static const isInsensitiveKey = "is_insensitive";
static const documentCountKey = "document_count";
String get queryEndpoint;
final int? id;
final String name;
final String? slug;
final String? match;
final MatchingAlgorithm? matchingAlgorithm;
final bool? isInsensitive;
final int? documentCount;
const Label({
required this.id,
required this.name,
this.match,
this.matchingAlgorithm,
this.isInsensitive,
this.documentCount,
this.slug,
});
Label.fromJson(Map<String, dynamic> json)
: id = json[idKey],
name = json[nameKey],
slug = json[slugKey],
match = json[matchKey],
matchingAlgorithm =
MatchingAlgorithm.fromInt(json[matchingAlgorithmKey]),
isInsensitive = json[isInsensitiveKey],
documentCount = json[documentCountKey];
Map<String, dynamic> toJson() {
Map<String, dynamic> json = {};
json.putIfAbsent(idKey, () => id);
json.putIfAbsent(nameKey, () => name);
json.putIfAbsent(slugKey, () => slug);
json.putIfAbsent(matchKey, () => match);
json.putIfAbsent(matchingAlgorithmKey, () => matchingAlgorithm?.value);
json.putIfAbsent(isInsensitiveKey, () => isInsensitive);
json.putIfAbsent(documentCountKey, () => documentCount);
addSpecificFieldsToJson(json);
return json;
}
void addSpecificFieldsToJson(Map<String, dynamic> json);
Label copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
});
@override
String toString() {
return name;
}
@override
int compareTo(dynamic other) {
return toString().toLowerCase().compareTo(other.toString().toLowerCase());
}
@override
List<Object?> get props => [id];
}

View File

@@ -0,0 +1,22 @@
enum MatchingAlgorithm {
anyWord(1, "Any: Match one of the following words"),
allWords(2, "All: Match all of the following words"),
exactMatch(3, "Exact: Match the following string"),
regex(4, "Regex: Match the regular expression"),
similarWord(5, "Similar: Match a similar word"),
auto(6, "Auto: Learn automatic assignment");
final int value;
final String name;
const MatchingAlgorithm(this.value, this.name);
static MatchingAlgorithm fromInt(int? value) {
return MatchingAlgorithm.values
.where((element) => element.value == value)
.firstWhere(
(element) => true,
orElse: () => MatchingAlgorithm.anyWord,
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:paperless_api/src/models/labels/label_model.dart';
import 'package:paperless_api/src/models/labels/matching_algorithm.dart';
class StoragePath extends Label {
static const pathKey = 'path';
late String? path;
StoragePath({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
required this.path,
});
StoragePath.fromJson(Map<String, dynamic> json)
: path = json[pathKey],
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(Map<String, dynamic> json) {
json.putIfAbsent(
pathKey,
() => path,
);
}
@override
StoragePath copyWith({
int? id,
String? name,
String? slug,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? path,
}) {
return StoragePath(
id: id ?? this.id,
name: name ?? this.name,
documentCount: documentCount ?? documentCount,
isInsensitive: isInsensitive ?? isInsensitive,
path: path ?? this.path,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
slug: slug ?? this.slug,
);
}
@override
String get queryEndpoint => 'storage_paths';
}

View File

@@ -0,0 +1,108 @@
import 'dart:developer';
import 'dart:ui';
import 'package:paperless_api/src/models/labels/label_model.dart';
import 'package:paperless_api/src/models/labels/matching_algorithm.dart';
class Tag extends Label {
static const colorKey = 'color';
static const isInboxTagKey = 'is_inbox_tag';
static const textColorKey = 'text_color';
static const legacyColourKey = 'colour';
final Color? color;
final Color? textColor;
final bool? isInboxTag;
Tag({
required super.id,
required super.name,
super.documentCount,
super.isInsensitive,
super.match,
super.matchingAlgorithm,
super.slug,
this.color,
this.textColor,
this.isInboxTag,
});
Tag.fromJson(Map<String, dynamic> json)
: isInboxTag = json[isInboxTagKey],
textColor = Color(_colorStringToInt(json[textColorKey]) ?? 0),
color = _parseColorFromJson(json),
super.fromJson(json);
///
/// The `color` field of the json object can either be of type [Color] or a hex [String].
/// Since API version 2, the old attribute `colour` has been replaced with `color`.
///
static Color _parseColorFromJson(Map<String, dynamic> json) {
if (json.containsKey(legacyColourKey)) {
return Color(_colorStringToInt(json[legacyColourKey]) ?? 0);
}
if (json[colorKey] is Color) {
return json[colorKey];
}
return Color(_colorStringToInt(json[colorKey]) ?? 0);
}
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(Map<String, dynamic> json) {
json.putIfAbsent(colorKey, () => _toHex(color));
json.putIfAbsent(isInboxTagKey, () => isInboxTag);
}
@override
Tag copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
Color? color,
Color? textColor,
bool? isInboxTag,
}) {
return Tag(
id: id ?? this.id,
name: name ?? this.name,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
isInsensitive: isInsensitive ?? this.isInsensitive,
documentCount: documentCount ?? this.documentCount,
slug: slug ?? this.slug,
color: color ?? this.color,
textColor: textColor ?? this.textColor,
isInboxTag: isInboxTag ?? this.isInboxTag,
);
}
@override
String get queryEndpoint => 'tags';
}
///
/// Taken from [FormBuilderColorPicker].
///
String? _toHex(Color? color) {
if (color == null) {
return null;
}
String val =
'#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}';
log("Color in Tag#_toHex is $val");
return val;
}
int? _colorStringToInt(String? color) {
if (color == null) return null;
return int.tryParse(color.replaceAll("#", "ff"), radix: 16);
}

View File

@@ -0,0 +1,26 @@
export 'labels/correspondent_model.dart';
export 'labels/document_type_model.dart';
export 'labels/label_model.dart';
export 'labels/matching_algorithm.dart';
export 'labels/storage_path_model.dart';
export 'labels/tag_model.dart';
export 'query_parameters/asn_query.dart';
export 'query_parameters/correspondent_query.dart';
export 'query_parameters/document_type_query.dart';
export 'query_parameters/id_query_parameter.dart';
export 'query_parameters/query_type.dart';
export 'query_parameters/sort_field.dart';
export 'query_parameters/sort_order.dart';
export 'query_parameters/storage_path_query.dart';
export 'query_parameters/tags_query.dart';
export 'bulk_edit_model.dart';
export 'document_filter.dart';
export 'document_meta_data_model.dart';
export 'document_model.dart';
export 'filter_rule_model.dart';
export 'paged_search_result.dart';
export 'paperless_server_exception.dart';
export 'paperless_server_information_model.dart';
export 'paperless_server_statistics_model.dart';
export 'saved_view_model.dart';
export 'similar_document_model.dart';

View File

@@ -0,0 +1,93 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_api/src/models/document_model.dart';
const pageRegex = r".*page=(\d+).*";
class PagedSearchResultJsonSerializer<T> {
final Map<String, dynamic> json;
final T Function(Map<String, dynamic>) fromJson;
PagedSearchResultJsonSerializer(this.json, this.fromJson);
}
class PagedSearchResult<T> extends Equatable {
/// Total number of available items
final int count;
/// Link to next page
final String? next;
/// Link to previous page
final String? previous;
/// Actual items
final List<T> results;
int get pageKey {
if (next != null) {
final matches = RegExp(pageRegex).allMatches(next!);
final group = matches.first.group(1)!;
final nextPageKey = int.parse(group);
return nextPageKey - 1;
}
if (previous != null) {
// This is only executed if it's the last page or there is no data.
final matches = RegExp(pageRegex).allMatches(previous!);
if (matches.isEmpty) {
//In case there is a match but a page is not explicitly set, the page is 1 per default. Therefore, if the previous page is 1, this page is 1+1=2
return 2;
}
final group = matches.first.group(1)!;
final previousPageKey = int.parse(group);
return previousPageKey + 1;
}
return 1;
}
const PagedSearchResult({
required this.count,
required this.next,
required this.previous,
required this.results,
});
factory PagedSearchResult.fromJson(
PagedSearchResultJsonSerializer<T> serializer) {
return PagedSearchResult(
count: serializer.json['count'],
next: serializer.json['next'],
previous: serializer.json['previous'],
results: List<Map<String, dynamic>>.from(serializer.json['results'])
.map<T>(serializer.fromJson)
.toList(),
);
}
PagedSearchResult copyWith({
int? count,
String? next,
String? previous,
List<DocumentModel>? results,
}) {
return PagedSearchResult(
count: count ?? this.count,
next: next ?? this.next,
previous: previous ?? this.previous,
results: results ?? this.results,
);
}
///
/// Returns the number of pages based on the given [pageSize]. The last page
/// might not exhaust its capacity.
///
int inferPageCount({required int pageSize}) {
if (pageSize == 0) {
return 0;
}
return (count / pageSize).round() + 1;
}
@override
List<Object?> get props => [count, next, previous, results];
}

View File

@@ -0,0 +1,55 @@
class PaperlessServerException implements Exception {
final ErrorCode code;
final String? details;
final StackTrace? stackTrace;
final int? httpStatusCode;
const PaperlessServerException(
this.code, {
this.details,
this.stackTrace,
this.httpStatusCode,
});
const PaperlessServerException.unknown() : this(ErrorCode.unknown);
@override
String toString() {
return "ErrorMessage(code: $code${stackTrace != null ? ', stackTrace: ${stackTrace.toString()}' : ''}${httpStatusCode != null ? ', httpStatusCode: $httpStatusCode' : ''})";
}
}
enum ErrorCode {
unknown,
authenticationFailed,
notAuthenticated,
documentUploadFailed,
documentUpdateFailed,
documentLoadFailed,
documentDeleteFailed,
documentBulkActionFailed,
documentPreviewFailed,
documentAsnQueryFailed,
tagCreateFailed,
tagLoadFailed,
documentTypeCreateFailed,
documentTypeLoadFailed,
correspondentCreateFailed,
correspondentLoadFailed,
scanRemoveFailed,
invalidClientCertificateConfiguration,
biometricsNotSupported,
biometricAuthenticationFailed,
deviceOffline,
serverUnreachable,
similarQueryError,
autocompleteQueryError,
storagePathLoadFailed,
storagePathCreateFailed,
loadSavedViewsError,
createSavedViewError,
deleteSavedViewError,
requestTimedOut,
unsupportedFileFormat,
missingClientCertificate;
}

View File

@@ -0,0 +1,16 @@
class PaperlessServerInformationModel {
static const String versionHeader = 'x-version';
static const String apiVersionHeader = 'x-api-version';
static const String hostHeader = 'x-served-by';
final String? version;
final int? apiVersion;
final String? username;
final String? host;
PaperlessServerInformationModel({
this.host,
this.username,
this.version = 'unknown',
this.apiVersion = 1,
});
}

View File

@@ -0,0 +1,13 @@
class PaperlessServerStatisticsModel {
final int documentsTotal;
final int documentsInInbox;
PaperlessServerStatisticsModel({
required this.documentsTotal,
required this.documentsInInbox,
});
PaperlessServerStatisticsModel.fromJson(Map<String, dynamic> json)
: documentsTotal = json['documents_total'],
documentsInInbox = json['documents_inbox'];
}

View File

@@ -0,0 +1,11 @@
import 'package:paperless_api/src/models/query_parameters/id_query_parameter.dart';
class AsnQuery extends IdQueryParameter {
const AsnQuery.fromId(super.id) : super.fromId();
const AsnQuery.unset() : super.unset();
const AsnQuery.notAssigned() : super.notAssigned();
const AsnQuery.anyAssigned() : super.anyAssigned();
@override
String get queryParameterKey => 'archive_serial_number';
}

View File

@@ -0,0 +1,11 @@
import 'package:paperless_api/src/models/query_parameters/id_query_parameter.dart';
class CorrespondentQuery extends IdQueryParameter {
const CorrespondentQuery.fromId(super.id) : super.fromId();
const CorrespondentQuery.unset() : super.unset();
const CorrespondentQuery.notAssigned() : super.notAssigned();
const CorrespondentQuery.anyAssigned() : super.anyAssigned();
@override
String get queryParameterKey => 'correspondent';
}

View File

@@ -0,0 +1,11 @@
import 'package:paperless_api/src/models/query_parameters/id_query_parameter.dart';
class DocumentTypeQuery extends IdQueryParameter {
const DocumentTypeQuery.fromId(super.id) : super.fromId();
const DocumentTypeQuery.unset() : super.unset();
const DocumentTypeQuery.notAssigned() : super.notAssigned();
const DocumentTypeQuery.anyAssigned() : super.anyAssigned();
@override
String get queryParameterKey => 'document_type';
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
abstract class IdQueryParameter extends Equatable {
final int? _assignmentStatus;
final int? _id;
const IdQueryParameter.notAssigned()
: _assignmentStatus = 1,
_id = null;
const IdQueryParameter.anyAssigned()
: _assignmentStatus = 0,
_id = null;
const IdQueryParameter.fromId(int? id)
: _assignmentStatus = null,
_id = id;
const IdQueryParameter.unset() : this.fromId(null);
bool get isUnset => _id == null && _assignmentStatus == null;
bool get isSet => _id != null && _assignmentStatus == null;
bool get onlyNotAssigned => _assignmentStatus == 1;
bool get onlyAssigned => _assignmentStatus == 0;
int? get id => _id;
String get queryParameterKey;
String toQueryParameter() {
if (onlyNotAssigned || onlyAssigned) {
return "&${queryParameterKey}__isnull=$_assignmentStatus";
}
if (isSet) {
return "&${queryParameterKey}__id=$id";
}
return "";
}
@override
List<Object?> get props => [_assignmentStatus, _id];
}

View File

@@ -0,0 +1,9 @@
enum QueryType {
title('title__icontains'),
titleAndContent('title_content'),
extended('query'),
asn('asn');
final String queryParam;
const QueryType(this.queryParam);
}

View File

@@ -0,0 +1,18 @@
enum SortField {
archiveSerialNumber("archive_serial_number"),
correspondentName("correspondent__name"),
title("title"),
documentType("document_type__name"),
created("created"),
added("added"),
modified("modified");
final String queryString;
const SortField(this.queryString);
@override
String toString() {
return name.toLowerCase();
}
}

View File

@@ -0,0 +1,11 @@
enum SortOrder {
ascending(""),
descending("-");
final String queryString;
const SortOrder(this.queryString);
SortOrder toggle() {
return this == ascending ? descending : ascending;
}
}

View File

@@ -0,0 +1,11 @@
import 'package:paperless_api/src/models/query_parameters/id_query_parameter.dart';
class StoragePathQuery extends IdQueryParameter {
const StoragePathQuery.fromId(super.id) : super.fromId();
const StoragePathQuery.unset() : super.unset();
const StoragePathQuery.notAssigned() : super.notAssigned();
const StoragePathQuery.anyAssigned() : super.anyAssigned();
@override
String get queryParameterKey => 'storage_path';
}

View File

@@ -0,0 +1,133 @@
import 'package:equatable/equatable.dart';
abstract class TagsQuery {
const TagsQuery();
String toQueryParameter();
}
class OnlyNotAssignedTagsQuery extends TagsQuery {
const OnlyNotAssignedTagsQuery();
@override
String toQueryParameter() {
return '&is_tagged=0';
}
}
class AnyAssignedTagsQuery extends TagsQuery {
final Iterable<int> tagIds;
const AnyAssignedTagsQuery({
this.tagIds = const [],
});
@override
String toQueryParameter() {
if (tagIds.isEmpty) {
return '&is_tagged=1';
}
return '&tags__id__in=${tagIds.join(',')}';
}
}
class IdsTagsQuery extends TagsQuery {
final Iterable<TagIdQuery> _idQueries;
const IdsTagsQuery([this._idQueries = const []]);
IdsTagsQuery.included(Iterable<int> ids)
: _idQueries = ids.map((id) => IncludeTagIdQuery(id));
IdsTagsQuery.fromIds(Iterable<int> ids) : this.included(ids);
IdsTagsQuery.excluded(Iterable<int> ids)
: _idQueries = ids.map((id) => ExcludeTagIdQuery(id));
IdsTagsQuery withIdQueriesAdded(Iterable<TagIdQuery> idQueries) {
final intersection = idQueries
.map((idQ) => idQ.id)
.toSet()
.intersection(_idQueries.map((idQ) => idQ.id).toSet());
final res = IdsTagsQuery(
[...withIdsRemoved(intersection).queries, ...idQueries],
);
return res;
}
IdsTagsQuery withIdsRemoved(Iterable<int> ids) {
return IdsTagsQuery(
_idQueries.where((idQuery) => !ids.contains(idQuery.id)),
);
}
Iterable<TagIdQuery> get queries => _idQueries;
Iterable<int> get includedIds {
return _idQueries.whereType<IncludeTagIdQuery>().map((e) => e.id);
}
Iterable<int> get excludedIds {
return _idQueries.whereType<ExcludeTagIdQuery>().map((e) => e.id);
}
///
/// Returns a new instance with the type of the given [id] toggled.
/// E.g. if the provided [id] is currently registered as a [IncludeTagIdQuery],
/// then the new isntance will contain a [ExcludeTagIdQuery] with given id.
///
IdsTagsQuery withIdQueryToggled(int id) {
return IdsTagsQuery(
_idQueries.map((idQ) => idQ.id == id ? idQ.toggle() : idQ),
);
}
Iterable<int> get ids => [...includedIds, ...excludedIds];
@override
String toQueryParameter() {
final StringBuffer sb = StringBuffer("");
if (includedIds.isNotEmpty) {
sb.write('&tags__id__all=${includedIds.join(',')}');
}
if (excludedIds.isNotEmpty) {
sb.write('&tags__id__none=${excludedIds.join(',')}');
}
return sb.toString();
}
}
abstract class TagIdQuery with EquatableMixin {
final int id;
TagIdQuery(this.id);
String get methodName;
@override
List<Object?> get props => [id, methodName];
TagIdQuery toggle();
}
class IncludeTagIdQuery extends TagIdQuery {
IncludeTagIdQuery(super.id);
@override
String get methodName => 'include';
@override
TagIdQuery toggle() {
return ExcludeTagIdQuery(id);
}
}
class ExcludeTagIdQuery extends TagIdQuery {
ExcludeTagIdQuery(super.id);
@override
String get methodName => 'exclude';
@override
TagIdQuery toggle() {
return IncludeTagIdQuery(id);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_api/src/models/document_filter.dart';
import 'package:paperless_api/src/models/filter_rule_model.dart';
import 'package:paperless_api/src/models/query_parameters/sort_field.dart';
import 'package:paperless_api/src/models/query_parameters/sort_order.dart';
class SavedView with EquatableMixin {
final int? id;
final String name;
final bool showOnDashboard;
final bool showInSidebar;
final SortField sortField;
final bool sortReverse;
final List<FilterRule> filterRules;
SavedView({
this.id,
required this.name,
required this.showOnDashboard,
required this.showInSidebar,
required this.sortField,
required this.sortReverse,
required this.filterRules,
}) {
filterRules.sort(
(a, b) => (a.ruleType.compareTo(b.ruleType) != 0
? a.ruleType.compareTo(b.ruleType)
: a.value?.compareTo(b.value ?? "") ?? -1),
);
}
@override
List<Object?> get props => [
name,
showOnDashboard,
showInSidebar,
sortField,
sortReverse,
filterRules
];
SavedView.fromJson(Map<String, dynamic> json)
: this(
id: json['id'],
name: json['name'],
showOnDashboard: json['show_on_dashboard'],
showInSidebar: json['show_in_sidebar'],
sortField: SortField.values
.where((order) => order.queryString == json['sort_field'])
.first,
sortReverse: json['sort_reverse'],
filterRules: (json['filter_rules'] as List)
.cast<Map<String, dynamic>>()
.map(FilterRule.fromJson)
.toList(),
);
DocumentFilter toDocumentFilter() {
return filterRules.fold(
DocumentFilter(
sortOrder: sortReverse ? SortOrder.descending : SortOrder.ascending,
sortField: sortField,
),
(filter, filterRule) => filterRule.applyToFilter(filter),
);
}
SavedView.fromDocumentFilter(
DocumentFilter filter, {
required String name,
required bool showInSidebar,
required bool showOnDashboard,
}) : this(
id: null,
name: name,
filterRules: FilterRule.fromFilter(filter),
sortField: filter.sortField,
showInSidebar: showInSidebar,
showOnDashboard: showOnDashboard,
sortReverse: filter.sortOrder == SortOrder.descending,
);
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'show_on_dashboard': showOnDashboard,
'show_in_sidebar': showInSidebar,
'sort_reverse': sortReverse,
'sort_field': sortField.queryString,
'filter_rules': filterRules.map((rule) => rule.toJson()).toList(),
};
}
}

View File

@@ -0,0 +1,58 @@
import 'package:paperless_api/src/models/document_model.dart';
class SimilarDocumentModel extends DocumentModel {
final SearchHit searchHit;
const SimilarDocumentModel({
required super.id,
required super.title,
required super.documentType,
required super.correspondent,
required super.created,
required super.modified,
required super.added,
required super.originalFileName,
required this.searchHit,
super.archiveSerialNumber,
super.archivedFileName,
super.content,
super.storagePath,
super.tags,
});
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
json['__search_hit__'] = searchHit.toJson();
return json;
}
SimilarDocumentModel.fromJson(Map<String, dynamic> json)
: searchHit = SearchHit.fromJson(json),
super.fromJson(json);
}
class SearchHit {
final double? score;
final String? highlights;
final int? rank;
SearchHit({
this.score,
required this.highlights,
required this.rank,
});
Map<String, dynamic> toJson() {
return {
'score': score,
'highlights': highlights,
'rank': rank,
};
}
SearchHit.fromJson(Map<String, dynamic> json)
: score = json['score'],
highlights = json['highlights'],
rank = json['rank'];
}

View File

@@ -0,0 +1,7 @@
abstract class PaperlessAuthenticationApi {
Future<String> login({
required String username,
required String password,
required String serverUrl,
});
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:paperless_api/src/models/paperless_server_exception.dart';
import 'package:paperless_api/src/modules/authentication_api/authentication_api.dart';
class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi {
final BaseClient client;
PaperlessAuthenticationApiImpl(this.client);
@override
Future<String> login({
required String username,
required String password,
required String serverUrl,
}) async {
late Response response;
try {
response = await client.post(
Uri.parse("/api/token/"),
body: {"username": username, "password": password},
);
} on FormatException catch (e) {
final source = e.source;
if (source is String &&
source.contains("400 No required SSL certificate was sent")) {
throw PaperlessServerException(
ErrorCode.missingClientCertificate,
httpStatusCode: response.statusCode,
);
}
}
if (response.statusCode == HttpStatus.ok) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['token'];
} else if (response.statusCode == HttpStatus.badRequest &&
response.body
.toLowerCase()
.contains("no required certificate was sent")) {
throw PaperlessServerException(
ErrorCode.invalidClientCertificateConfiguration,
httpStatusCode: response.statusCode,
);
} else {
throw PaperlessServerException(
ErrorCode.authenticationFailed,
httpStatusCode: response.statusCode,
);
}
}
}

View File

@@ -0,0 +1,36 @@
import 'dart:typed_data';
import 'package:paperless_api/src/models/bulk_edit_model.dart';
import 'package:paperless_api/src/models/document_filter.dart';
import 'package:paperless_api/src/models/document_meta_data_model.dart';
import 'package:paperless_api/src/models/document_model.dart';
import 'package:paperless_api/src/models/paged_search_result.dart';
import 'package:paperless_api/src/models/similar_document_model.dart';
abstract class PaperlessDocumentsApi {
Future<void> create(
Uint8List documentBytes, {
required String filename,
required String title,
required String authToken,
required String serverUrl,
int? documentType,
int? correspondent,
Iterable<int> tags = const [],
DateTime? createdAt,
});
Future<DocumentModel> update(DocumentModel doc);
Future<int> findNextAsn();
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter);
Future<List<SimilarDocumentModel>> findSimilar(int docId);
Future<int> delete(DocumentModel doc);
Future<DocumentMetaData> getMetaData(DocumentModel document);
Future<Iterable<int>> bulkAction(BulkAction action);
Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId);
Future<DocumentModel> waitForConsumptionFinished(
String filename, String title);
Future<Uint8List> download(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]);
}

View File

@@ -0,0 +1,282 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:http/src/boundary_characters.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:paperless_api/src/constants.dart';
import 'package:paperless_api/src/models/bulk_edit_model.dart';
import 'package:paperless_api/src/models/document_filter.dart';
import 'package:paperless_api/src/models/document_meta_data_model.dart';
import 'package:paperless_api/src/models/document_model.dart';
import 'package:paperless_api/src/models/paged_search_result.dart';
import 'package:paperless_api/src/models/paperless_server_exception.dart';
import 'package:paperless_api/src/models/query_parameters/asn_query.dart';
import 'package:paperless_api/src/models/query_parameters/sort_field.dart';
import 'package:paperless_api/src/models/query_parameters/sort_order.dart';
import 'package:paperless_api/src/models/similar_document_model.dart';
import 'paperless_documents_api.dart';
class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
final BaseClient baseClient;
final HttpClient httpClient;
PaperlessDocumentsApiImpl(this.baseClient, this.httpClient);
@override
Future<void> create(
Uint8List documentBytes, {
required String filename,
required String title,
required String authToken,
required String serverUrl,
int? documentType,
int? correspondent,
Iterable<int> tags = const [],
DateTime? createdAt,
}) async {
// 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 httpClient.postUrl(
Uri.parse("$serverUrl/api/documents/post_document/"),
);
final boundary = _boundaryString();
StringBuffer bodyBuffer = StringBuffer();
var fields = <String, String>{};
fields.putIfAbsent('title', () => title);
if (createdAt != null) {
fields.putIfAbsent('created', () => apiDateFormat.format(createdAt));
}
if (correspondent != null) {
fields.putIfAbsent('correspondent', () => jsonEncode(correspondent));
}
if (documentType != null) {
fields.putIfAbsent('document_type', () => jsonEncode(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 $authToken");
//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 PaperlessServerException(
ErrorCode.documentUploadFailed,
httpStatusCode: response.statusCode,
);
}
}
String _buildMultipartField(String fieldName, String value, String boundary) {
// ignore: prefer_interpolation_to_compose_strings
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<int>.generate(
70 - prefix.length,
(index) => boundaryCharacters[_random.nextInt(boundaryCharacters.length)],
growable: false,
);
return '$prefix${String.fromCharCodes(list)}';
}
@override
Future<DocumentModel> update(DocumentModel doc) async {
final response = await baseClient.put(
Uri.parse("/api/documents/${doc.id}/"),
body: json.encode(doc.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 200) {
return DocumentModel.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
} else {
throw const PaperlessServerException(ErrorCode.documentUpdateFailed);
}
}
@override
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter) async {
final filterParams = filter.toQueryString();
final response = await baseClient.get(
Uri.parse("/api/documents/?$filterParams"),
);
if (response.statusCode == 200) {
return compute(
PagedSearchResult.fromJson,
PagedSearchResultJsonSerializer<DocumentModel>(
jsonDecode(utf8.decode(response.bodyBytes)),
DocumentModel.fromJson,
),
);
} else {
throw const PaperlessServerException(ErrorCode.documentLoadFailed);
}
}
@override
Future<int> delete(DocumentModel doc) async {
final response =
await baseClient.delete(Uri.parse("/api/documents/${doc.id}/"));
if (response.statusCode == 204) {
return Future.value(doc.id);
}
throw const PaperlessServerException(ErrorCode.documentDeleteFailed);
}
@override
String getThumbnailUrl(int documentId) {
return "/api/documents/$documentId/thumb/";
}
String getPreviewUrl(int documentId) {
return "/api/documents/$documentId/preview/";
}
@override
Future<Uint8List> getPreview(int documentId) async {
final response = await baseClient.get(Uri.parse(getPreviewUrl(documentId)));
if (response.statusCode == 200) {
return response.bodyBytes;
}
throw const PaperlessServerException(ErrorCode.documentPreviewFailed);
}
@override
Future<int> 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 PaperlessServerException catch (_) {
throw const PaperlessServerException(ErrorCode.documentAsnQueryFailed);
}
}
@override
Future<Iterable<int>> bulkAction(BulkAction action) async {
final response = await baseClient.post(
Uri.parse("/api/documents/bulk_edit/"),
body: json.encode(action.toJson()),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return action.documentIds;
} else {
throw const PaperlessServerException(ErrorCode.documentBulkActionFailed);
}
}
@override
Future<DocumentModel> waitForConsumptionFinished(
String fileName, String title) async {
PagedSearchResult<DocumentModel> 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 PaperlessServerException(ErrorCode.documentUploadFailed);
}
}
@override
Future<Uint8List> download(DocumentModel document) async {
final response = await baseClient
.get(Uri.parse("/api/documents/${document.id}/download/"));
return response.bodyBytes;
}
@override
Future<DocumentMetaData> getMetaData(DocumentModel document) async {
final response = await baseClient
.get(Uri.parse("/api/documents/${document.id}/metadata/"));
return compute(
DocumentMetaData.fromJson,
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
}
@override
Future<List<String>> autocomplete(String query, [int limit = 10]) async {
final response = await baseClient
.get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}"));
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes)) as List<String>;
}
throw const PaperlessServerException(ErrorCode.autocompleteQueryError);
}
@override
Future<List<SimilarDocumentModel>> findSimilar(int docId) async {
final response = await baseClient
.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10"));
if (response.statusCode == 200) {
return (await compute(
PagedSearchResult<SimilarDocumentModel>.fromJson,
PagedSearchResultJsonSerializer(
jsonDecode(utf8.decode(response.bodyBytes)),
SimilarDocumentModel.fromJson,
),
))
.results;
}
throw const PaperlessServerException(ErrorCode.similarQueryError);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:paperless_api/src/models/labels/correspondent_model.dart';
import 'package:paperless_api/src/models/labels/document_type_model.dart';
import 'package:paperless_api/src/models/labels/storage_path_model.dart';
import 'package:paperless_api/src/models/labels/tag_model.dart';
///
/// Provides basic CRUD operations for labels, including:
/// <ul>
/// <li>Correspondents</li>
/// <li>Document Types</li>
/// <li>Tags</li>
/// <li>Storage Paths</li>
/// </ul>
///
abstract class PaperlessLabelsApi {
Future<Correspondent?> getCorrespondent(int id);
Future<List<Correspondent>> getCorrespondents();
Future<Correspondent> saveCorrespondent(Correspondent correspondent);
Future<Correspondent> updateCorrespondent(Correspondent correspondent);
Future<int> deleteCorrespondent(Correspondent correspondent);
Future<Tag?> getTag(int id);
Future<List<Tag>> getTags({List<int>? ids});
Future<Tag> saveTag(Tag tag);
Future<Tag> updateTag(Tag tag);
Future<int> deleteTag(Tag tag);
Future<DocumentType?> getDocumentType(int id);
Future<List<DocumentType>> getDocumentTypes();
Future<DocumentType> saveDocumentType(DocumentType type);
Future<DocumentType> updateDocumentType(DocumentType documentType);
Future<int> deleteDocumentType(DocumentType documentType);
Future<StoragePath?> getStoragePath(int id);
Future<List<StoragePath>> getStoragePaths();
Future<StoragePath> saveStoragePath(StoragePath path);
Future<StoragePath> updateStoragePath(StoragePath path);
Future<int> deleteStoragePath(StoragePath path);
}

View File

@@ -0,0 +1,300 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:paperless_api/src/models/labels/correspondent_model.dart';
import 'package:paperless_api/src/models/labels/document_type_model.dart';
import 'package:paperless_api/src/models/labels/storage_path_model.dart';
import 'package:paperless_api/src/models/labels/tag_model.dart';
import 'package:paperless_api/src/models/paperless_server_exception.dart';
import 'package:paperless_api/src/modules/labels_api/paperless_labels_api.dart';
import 'package:paperless_api/src/utils.dart';
class PaperlessLabelApiImpl implements PaperlessLabelsApi {
final BaseClient client;
PaperlessLabelApiImpl(this.client);
@override
Future<Correspondent?> getCorrespondent(int id) async {
return getSingleResult(
"/api/correspondents/$id/",
Correspondent.fromJson,
ErrorCode.correspondentLoadFailed,
client: client,
);
}
@override
Future<Tag?> getTag(int id) async {
return getSingleResult(
"/api/tags/$id/",
Tag.fromJson,
ErrorCode.tagLoadFailed,
client: client,
);
}
@override
Future<List<Tag>> getTags({List<int>? ids}) async {
final results = await getCollection(
"/api/tags/?page=1&page_size=100000",
Tag.fromJson,
ErrorCode.tagLoadFailed,
client: client,
minRequiredApiVersion: 2,
);
return results
.where((element) => ids?.contains(element.id) ?? true)
.toList();
}
@override
Future<DocumentType?> getDocumentType(int id) async {
return getSingleResult(
"/api/document_types/$id/",
DocumentType.fromJson,
ErrorCode.documentTypeLoadFailed,
client: client,
);
}
@override
Future<List<Correspondent>> getCorrespondents() {
return getCollection(
"/api/correspondents/?page=1&page_size=100000",
Correspondent.fromJson,
ErrorCode.correspondentLoadFailed,
client: client,
);
}
@override
Future<List<DocumentType>> getDocumentTypes() {
return getCollection(
"/api/document_types/?page=1&page_size=100000",
DocumentType.fromJson,
ErrorCode.documentTypeLoadFailed,
client: client,
);
}
@override
Future<Correspondent> saveCorrespondent(Correspondent correspondent) async {
final response = await client.post(
Uri.parse('/api/correspondents/'),
body: jsonEncode(correspondent.toJson()),
headers: {"Content-Type": "application/json"},
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == HttpStatus.created) {
return Correspondent.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)),
);
}
throw PaperlessServerException(
ErrorCode.correspondentCreateFailed,
httpStatusCode: response.statusCode,
);
}
@override
Future<DocumentType> saveDocumentType(DocumentType type) async {
final response = await client.post(
Uri.parse('/api/document_types/'),
body: json.encode(type.toJson()),
headers: {"Content-Type": "application/json"},
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == HttpStatus.created) {
return DocumentType.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)),
);
}
throw PaperlessServerException(
ErrorCode.documentTypeCreateFailed,
httpStatusCode: response.statusCode,
);
}
@override
Future<Tag> saveTag(Tag tag) async {
final body = json.encode(tag.toJson());
final response = await client.post(
Uri.parse('/api/tags/'),
body: body,
headers: {
"Content-Type": "application/json",
"Accept": "application/json; version=2",
},
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == HttpStatus.created) {
return Tag.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw PaperlessServerException(
ErrorCode.tagCreateFailed,
httpStatusCode: response.statusCode,
);
}
@override
Future<int> deleteCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await client
.delete(Uri.parse('/api/correspondents/${correspondent.id}/'));
if (response.statusCode == HttpStatus.noContent) {
return correspondent.id!;
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<int> deleteDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await client
.delete(Uri.parse('/api/document_types/${documentType.id}/'));
if (response.statusCode == HttpStatus.noContent) {
return documentType.id!;
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<int> deleteTag(Tag tag) async {
assert(tag.id != null);
final response = await client.delete(Uri.parse('/api/tags/${tag.id}/'));
if (response.statusCode == HttpStatus.noContent) {
return tag.id!;
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<Correspondent> updateCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await client.put(
Uri.parse('/api/correspondents/${correspondent.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(correspondent.toJson()),
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == HttpStatus.ok) {
return Correspondent.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<DocumentType> updateDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await client.put(
Uri.parse('/api/document_types/${documentType.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(documentType.toJson()),
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == HttpStatus.ok) {
return DocumentType.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<Tag> updateTag(Tag tag) async {
assert(tag.id != null);
final response = await client.put(
Uri.parse('/api/tags/${tag.id}/'),
headers: {
"Accept": "application/json; version=2",
"Content-Type": "application/json",
},
body: json.encode(tag.toJson()),
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == HttpStatus.ok) {
return Tag.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<int> deleteStoragePath(StoragePath path) async {
assert(path.id != null);
final response =
await client.delete(Uri.parse('/api/storage_paths/${path.id}/'));
if (response.statusCode == HttpStatus.noContent) {
return path.id!;
}
throw PaperlessServerException(
ErrorCode.unknown,
httpStatusCode: response.statusCode,
);
}
@override
Future<StoragePath?> getStoragePath(int id) {
return getSingleResult(
"/api/storage_paths/?page=1&page_size=100000",
StoragePath.fromJson,
ErrorCode.storagePathLoadFailed,
client: client,
);
}
@override
Future<List<StoragePath>> getStoragePaths() {
return getCollection(
"/api/storage_paths/?page=1&page_size=100000",
StoragePath.fromJson,
ErrorCode.storagePathLoadFailed,
client: client,
);
}
@override
Future<StoragePath> saveStoragePath(StoragePath path) async {
final response = await client.post(
Uri.parse('/api/storage_paths/'),
body: json.encode(path.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == HttpStatus.created) {
return StoragePath.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw PaperlessServerException(ErrorCode.storagePathCreateFailed,
httpStatusCode: response.statusCode);
}
@override
Future<StoragePath> updateStoragePath(StoragePath path) async {
assert(path.id != null);
final response = await client.put(
Uri.parse('/api/storage_paths/${path.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(path.toJson()),
);
if (response.statusCode == HttpStatus.ok) {
return StoragePath.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw const PaperlessServerException(ErrorCode.unknown);
}
}

View File

@@ -0,0 +1,11 @@
export 'authentication_api/authentication_api.dart';
export 'authentication_api/authentication_api_impl.dart';
export 'labels_api/paperless_labels_api.dart';
export 'labels_api/paperless_labels_api_impl.dart';
export 'documents_api/paperless_documents_api.dart';
export 'documents_api/paperless_documents_api_impl.dart';
export 'saved_views_api/paperless_saved_views_api.dart';
export 'saved_views_api/paperless_saved_views_api_impl.dart';
export 'server_stats_api/paperless_server_stats_api.dart';
export 'server_stats_api/paperless_server_stats_api_impl.dart';

View File

@@ -0,0 +1,8 @@
import 'package:paperless_api/src/models/saved_view_model.dart';
abstract class PaperlessSavedViewsApi {
Future<List<SavedView>> getAll();
Future<SavedView> save(SavedView view);
Future<int> delete(SavedView view);
}

View File

@@ -0,0 +1,54 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:paperless_api/src/models/paperless_server_exception.dart';
import 'package:paperless_api/src/models/saved_view_model.dart';
import 'package:paperless_api/src/utils.dart';
import 'paperless_saved_views_api.dart';
class PaperlessSavedViewsApiImpl implements PaperlessSavedViewsApi {
final BaseClient client;
PaperlessSavedViewsApiImpl(this.client);
@override
Future<List<SavedView>> getAll() {
return getCollection(
"/api/saved_views/",
SavedView.fromJson,
ErrorCode.loadSavedViewsError,
client: client,
);
}
@override
Future<SavedView> save(SavedView view) async {
final response = await client.post(
Uri.parse("/api/saved_views/"),
body: jsonEncode(view.toJson()),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == HttpStatus.created) {
return SavedView.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw PaperlessServerException(
ErrorCode.createSavedViewError,
httpStatusCode: response.statusCode,
);
}
@override
Future<int> delete(SavedView view) async {
final response =
await client.delete(Uri.parse("/api/saved_views/${view.id}/"));
if (response.statusCode == HttpStatus.noContent) {
return view.id!;
}
throw PaperlessServerException(
ErrorCode.deleteSavedViewError,
httpStatusCode: response.statusCode,
);
}
}

View File

@@ -0,0 +1,7 @@
import 'package:paperless_api/src/models/paperless_server_information_model.dart';
import 'package:paperless_api/src/models/paperless_server_statistics_model.dart';
abstract class PaperlessServerStatsApi {
Future<PaperlessServerInformationModel> getServerInformation();
Future<PaperlessServerStatisticsModel> getServerStatistics();
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:paperless_api/src/models/paperless_server_exception.dart';
import 'package:paperless_api/src/models/paperless_server_information_model.dart';
import 'package:paperless_api/src/models/paperless_server_statistics_model.dart';
import 'paperless_server_stats_api.dart';
///
/// API for retrieving information about paperless server state,
/// such as version number, and statistics including documents in
/// inbox and total number of documents.
///
class PaperlessServerStatsApiImpl implements PaperlessServerStatsApi {
final BaseClient client;
PaperlessServerStatsApiImpl(this.client);
@override
Future<PaperlessServerInformationModel> getServerInformation() async {
final response = await client.get(Uri.parse("/api/ui_settings/"));
final version =
response.headers[PaperlessServerInformationModel.versionHeader] ??
'unknown';
final apiVersion = int.tryParse(
response.headers[PaperlessServerInformationModel.apiVersionHeader] ??
'1');
final String username =
jsonDecode(utf8.decode(response.bodyBytes))['username'];
final String host = response
.headers[PaperlessServerInformationModel.hostHeader] ??
response.request?.headers[PaperlessServerInformationModel.hostHeader] ??
('${response.request?.url.host}:${response.request?.url.port}');
return PaperlessServerInformationModel(
username: username,
version: version,
apiVersion: apiVersion,
host: host,
);
}
@override
Future<PaperlessServerStatisticsModel> getServerStatistics() async {
final response = await client.get(Uri.parse('/api/statistics/'));
if (response.statusCode == 200) {
return PaperlessServerStatisticsModel.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
}
throw const PaperlessServerException.unknown();
}
}

View File

@@ -0,0 +1,73 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:paperless_api/src/models/paperless_server_exception.dart';
Future<T> getSingleResult<T>(
String url,
T Function(Map<String, dynamic>) fromJson,
ErrorCode errorCode, {
required BaseClient client,
int minRequiredApiVersion = 1,
}) async {
final response = await client.get(
Uri.parse(url),
headers: {'accept': 'application/json; version=$minRequiredApiVersion'},
);
if (response.statusCode == HttpStatus.ok) {
return compute(
fromJson,
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
}
throw PaperlessServerException(
errorCode,
httpStatusCode: response.statusCode,
);
}
Future<List<T>> getCollection<T>(
String url,
T Function(Map<String, dynamic>) fromJson,
ErrorCode errorCode, {
required BaseClient client,
int minRequiredApiVersion = 1,
}) async {
final response = await client.get(
Uri.parse(url),
headers: {'accept': 'application/json; version=$minRequiredApiVersion'},
);
if (response.statusCode == HttpStatus.ok) {
final Map<String, dynamic> body =
jsonDecode(utf8.decode(response.bodyBytes));
if (body.containsKey('count')) {
if (body['count'] == 0) {
return <T>[];
} else {
return compute(
_collectionFromJson,
_CollectionFromJsonSerializationParams(
fromJson, (body['results'] as List).cast<Map<String, dynamic>>()),
);
}
}
}
throw PaperlessServerException(
errorCode,
httpStatusCode: response.statusCode,
);
}
List<T> _collectionFromJson<T>(
_CollectionFromJsonSerializationParams<T> params) {
return params.list.map<T>((result) => params.fromJson(result)).toList();
}
class _CollectionFromJsonSerializationParams<T> {
final T Function(Map<String, dynamic>) fromJson;
final List<Map<String, dynamic>> list;
_CollectionFromJsonSerializationParams(this.fromJson, this.list);
}

View File

@@ -0,0 +1,63 @@
name: paperless_api
description: An SDK for paperless(-ng*)
version: 1.0.0+1
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
environment:
sdk: ">=2.18.5 <3.0.0"
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
equatable: ^2.0.5
http: ^0.13.5
json_annotation: ^4.7.0
intl: ^0.17.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
json_serializable: ^6.5.4
build_runner: ^2.3.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# To add assets to your package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/assets-and-images/#from-packages
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# To add custom fonts to your package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/custom-fonts/#from-packages

View File

@@ -0,0 +1,275 @@
import 'package:paperless_api/src/models/document_filter.dart';
import 'package:paperless_api/src/models/filter_rule_model.dart';
import 'package:paperless_api/src/models/query_parameters/correspondent_query.dart';
import 'package:paperless_api/src/models/query_parameters/document_type_query.dart';
import 'package:paperless_api/src/models/query_parameters/query_type.dart';
import 'package:paperless_api/src/models/query_parameters/sort_field.dart';
import 'package:paperless_api/src/models/query_parameters/sort_order.dart';
import 'package:paperless_api/src/models/query_parameters/storage_path_query.dart';
import 'package:paperless_api/src/models/query_parameters/tags_query.dart';
import 'package:paperless_api/src/models/saved_view_model.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Validate parsing logic from [SavedView] to [DocumentFilter]:', () {
test('Values are correctly parsed if set.', () {
expect(
SavedView.fromJson({
"id": 1,
"name": "test_name",
"show_on_dashboard": false,
"show_in_sidebar": false,
"sort_field": SortField.created.name,
"sort_reverse": true,
"filter_rules": [
{
'rule_type': FilterRule.correspondentRule,
'value': "42",
},
{
'rule_type': FilterRule.documentTypeRule,
'value': "69",
},
{
'rule_type': FilterRule.includeTagsRule,
'value': "1",
},
{
'rule_type': FilterRule.includeTagsRule,
'value': "2",
},
{
'rule_type': FilterRule.excludeTagsRule,
'value': "3",
},
{
'rule_type': FilterRule.excludeTagsRule,
'value': "4",
},
{
'rule_type': FilterRule.extendedRule,
'value': "Never gonna give you up",
},
{
'rule_type': FilterRule.storagePathRule,
'value': "14",
},
{
'rule_type': FilterRule.createdBeforeRule,
'value': "2022-10-27",
},
{
'rule_type': FilterRule.createdAfterRule,
'value': "2022-09-27",
},
{
'rule_type': FilterRule.addedBeforeRule,
'value': "2022-09-26",
},
{
'rule_type': FilterRule.addedAfterRule,
'value': "2000-01-01",
}
]
}).toDocumentFilter(),
equals(
DocumentFilter.initial.copyWith(
correspondent: const CorrespondentQuery.fromId(42),
documentType: const DocumentTypeQuery.fromId(69),
storagePath: const StoragePathQuery.fromId(14),
tags: IdsTagsQuery(
[
IncludeTagIdQuery(1),
IncludeTagIdQuery(2),
ExcludeTagIdQuery(3),
ExcludeTagIdQuery(4),
],
),
createdDateBefore: DateTime.parse("2022-10-27"),
createdDateAfter: DateTime.parse("2022-09-27"),
addedDateBefore: DateTime.parse("2022-09-26"),
addedDateAfter: DateTime.parse("2000-01-01"),
sortField: SortField.created,
sortOrder: SortOrder.descending,
queryText: "Never gonna give you up",
queryType: QueryType.extended,
),
),
);
});
test('Values are correctly parsed if unset.', () {
expect(
SavedView.fromJson({
"id": 1,
"name": "test_name",
"show_on_dashboard": false,
"show_in_sidebar": false,
"sort_field": SortField.created.name,
"sort_reverse": true,
"filter_rules": [],
}).toDocumentFilter(),
equals(DocumentFilter.initial),
);
});
test('Values are correctly parsed if not assigned.', () {
expect(
SavedView.fromJson({
"id": 1,
"name": "test_name",
"show_on_dashboard": false,
"show_in_sidebar": false,
"sort_field": SortField.created.name,
"sort_reverse": true,
"filter_rules": [
{
'rule_type': FilterRule.correspondentRule,
'value': null,
},
{
'rule_type': FilterRule.documentTypeRule,
'value': null,
},
{
'rule_type': FilterRule.hasAnyTag,
'value': false.toString(),
},
{
'rule_type': FilterRule.storagePathRule,
'value': null,
},
],
}).toDocumentFilter(),
equals(DocumentFilter.initial.copyWith(
correspondent: const CorrespondentQuery.notAssigned(),
documentType: const DocumentTypeQuery.notAssigned(),
storagePath: const StoragePathQuery.notAssigned(),
tags: const OnlyNotAssignedTagsQuery(),
)),
);
});
});
group('Validate parsing logic from [DocumentFilter] to [SavedView]:', () {
test('Values are correctly parsed if set.', () {
expect(
SavedView.fromDocumentFilter(
DocumentFilter(
correspondent: const CorrespondentQuery.fromId(1),
documentType: const DocumentTypeQuery.fromId(2),
storagePath: const StoragePathQuery.fromId(3),
tags: IdsTagsQuery([
IncludeTagIdQuery(4),
IncludeTagIdQuery(5),
ExcludeTagIdQuery(6),
ExcludeTagIdQuery(7),
ExcludeTagIdQuery(8),
]),
sortField: SortField.added,
sortOrder: SortOrder.ascending,
addedDateAfter: DateTime.parse("2020-01-01"),
addedDateBefore: DateTime.parse("2020-03-01"),
createdDateAfter: DateTime.parse("2020-02-01"),
createdDateBefore: DateTime.parse("2020-04-01"),
queryText: "Never gonna let you down",
queryType: QueryType.title,
),
name: "test_name",
showInSidebar: false,
showOnDashboard: false,
),
equals(
SavedView(
name: "test_name",
showOnDashboard: false,
showInSidebar: false,
sortField: SortField.added,
sortReverse: false,
filterRules: [
FilterRule(FilterRule.correspondentRule, "1"),
FilterRule(FilterRule.documentTypeRule, "2"),
FilterRule(FilterRule.storagePathRule, "3"),
FilterRule(FilterRule.includeTagsRule, "4"),
FilterRule(FilterRule.includeTagsRule, "5"),
FilterRule(FilterRule.excludeTagsRule, "6"),
FilterRule(FilterRule.excludeTagsRule, "7"),
FilterRule(FilterRule.excludeTagsRule, "8"),
FilterRule(FilterRule.addedAfterRule, "2020-01-01"),
FilterRule(FilterRule.addedBeforeRule, "2020-03-01"),
FilterRule(FilterRule.createdAfterRule, "2020-02-01"),
FilterRule(FilterRule.createdBeforeRule, "2020-04-01"),
FilterRule(FilterRule.titleRule, "Never gonna let you down"),
],
),
),
);
});
test('Values are correctly parsed if unset.', () {
expect(
SavedView.fromDocumentFilter(
const DocumentFilter(
correspondent: CorrespondentQuery.unset(),
documentType: DocumentTypeQuery.unset(),
storagePath: StoragePathQuery.unset(),
tags: IdsTagsQuery(),
sortField: SortField.created,
sortOrder: SortOrder.descending,
addedDateAfter: null,
addedDateBefore: null,
createdDateAfter: null,
createdDateBefore: null,
queryText: null,
),
name: "test_name",
showInSidebar: false,
showOnDashboard: false,
),
equals(
SavedView(
name: "test_name",
showOnDashboard: false,
showInSidebar: false,
sortField: SortField.created,
sortReverse: true,
filterRules: [],
),
),
);
});
test('Values are correctly parsed if not assigned.', () {
expect(
SavedView.fromDocumentFilter(
const DocumentFilter(
correspondent: CorrespondentQuery.notAssigned(),
documentType: DocumentTypeQuery.notAssigned(),
storagePath: StoragePathQuery.notAssigned(),
tags: OnlyNotAssignedTagsQuery(),
sortField: SortField.created,
sortOrder: SortOrder.ascending,
),
name: "test_name",
showInSidebar: false,
showOnDashboard: false,
),
equals(
SavedView(
name: "test_name",
showOnDashboard: false,
showInSidebar: false,
sortField: SortField.created,
sortReverse: false,
filterRules: [
FilterRule(FilterRule.correspondentRule, null),
FilterRule(FilterRule.documentTypeRule, null),
FilterRule(FilterRule.storagePathRule, null),
FilterRule(FilterRule.hasAnyTag, false.toString()),
],
),
),
);
});
});
}