Something went wrong. Try again.
A Zig client for the Paperless-ngx REST API, with streaming uploads, task waiting and a command-line tool.
Something went wrong. Try again.
30 kB · 846 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! What the server sends back, and what is sent to it.//!//! Each object comes in two shapes. The one named for the object -- `Tag`,//! `Document` -- is what a response holds, and nearly every field in it has a//! default. That is not because the fields are optional in Paperless but//! because what arrives depends on how it was asked for: `?fields=id,title`//! trims a document to two fields, `?full_perms=true` swaps//! `user_can_change` for `permissions`, and an older server leaves out//! whatever a newer one added. Only the identifier is required; anything//! else that is missing reads as its default rather than failing the parse.//!//! The other shape is the request body: `TagFields`, `DocumentFields`. Every//! field in one of those is optional, and a field left `null` is not sent --//! so the same struct is a complete object to `create` and a partial one to//! `update`, which is a `PATCH`. A field the server allows to be null is a//! `?Clearable(T)`, so that "leave it" and "clear it" can be told apart.//!//! Dates and times are kept as the strings Paperless sent: ISO 8601, a//! date-time in UTC with a `Z` or a bare date. Interpreting them is the//! caller's business, and a calendar library is a heavier dependency than a//! client for one HTTP API should impose on everybody who uses it.
const std = @import("std");const Allocator = std.mem.Allocator;
const json = @import("json.zig");const Clearable = json.Clearable;
pub const Id = u64;
/// One page of a list. Paperless paginates every list it returns, with a/// default page size of 25 that `page_size` raises -- to as much as 100000,/// so asking for one very large page is a legitimate way to fetch/// everything.pub fn Page(comptime T: type) type { return struct { /// How many objects match, across every page. count: u64, /// The URL of the next page, built by the server from the request it /// received. Behind a proxy that rewrites the scheme or the host it /// can name somewhere the client cannot reach, which is why /// `Pager` counts pages instead of following it; whether it is /// null is all that is used. next: ?[]const u8 = null, previous: ?[]const u8 = null, results: []T, /// The identifiers of every matching object, on every page. Sent with /// document lists, which is what lets a client select "all" without /// fetching them. all: []const Id = &.{}, /// With a full-text `query` that the index thinks was misspelled, /// what it would have searched for instead. corrected_query: ?[]const u8 = null, };}
/// How an object decides, on its own, which documents it belongs on.pub const MatchingAlgorithm = enum(u8) { none = 0, /// Any of the words in `match`. any = 1, /// All of the words in `match`. all = 2, /// `match` as an exact string. literal = 3, /// `match` as a regular expression. regex = 4, /// `match`, approximately. fuzzy = 5, /// Learned from the documents it has been assigned to by hand. auto = 6, _,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { return json.writeIntEnum(self, jw); }};
/// Who besides the owner may see an object, and who may change it.pub const Permissions = struct { view: Set = .{}, change: Set = .{},
pub const Set = struct { users: []const Id = &.{}, groups: []const Id = &.{}, };};
/// The `set_permissions` a create or an update sends. The same shape as/// `Permissions`; a separate name because it is a request and not a reading/// of one.pub const SetPermissions = Permissions;
pub const Tag = struct { id: Id, slug: []const u8 = "", name: []const u8 = "", /// `#rrggbb`. color: []const u8 = "", /// Chosen by the server to be legible on `color`. text_color: []const u8 = "", match: []const u8 = "", matching_algorithm: MatchingAlgorithm = .none, is_insensitive: bool = false, /// Whether a new document is given this tag on arrival. is_inbox_tag: bool = false, document_count: u64 = 0, owner: ?Id = null, /// With `full_perms`; null without. permissions: ?Permissions = null, /// Without `full_perms`; null with. user_can_change: ?bool = null, /// Tags form a tree. parent: ?Id = null, /// The tags whose parent this is, each whole and with its own children. /// The schema Paperless publishes says these are identifiers; what it /// sends is objects. children: []const Tag = &.{},};
pub const TagFields = struct { name: ?[]const u8 = null, color: ?[]const u8 = null, match: ?[]const u8 = null, matching_algorithm: ?MatchingAlgorithm = null, is_insensitive: ?bool = null, is_inbox_tag: ?bool = null, owner: ?Clearable(Id) = null, parent: ?Clearable(Id) = null, set_permissions: ?SetPermissions = null,};
pub const Correspondent = struct { id: Id, slug: []const u8 = "", name: []const u8 = "", match: []const u8 = "", matching_algorithm: MatchingAlgorithm = .none, is_insensitive: bool = false, document_count: u64 = 0, /// The `created` date of the newest document from this correspondent. last_correspondence: ?[]const u8 = null, owner: ?Id = null, permissions: ?Permissions = null, user_can_change: ?bool = null,};
pub const CorrespondentFields = struct { name: ?[]const u8 = null, match: ?[]const u8 = null, matching_algorithm: ?MatchingAlgorithm = null, is_insensitive: ?bool = null, owner: ?Clearable(Id) = null, set_permissions: ?SetPermissions = null,};
pub const DocumentType = struct { id: Id, slug: []const u8 = "", name: []const u8 = "", match: []const u8 = "", matching_algorithm: MatchingAlgorithm = .none, is_insensitive: bool = false, document_count: u64 = 0, owner: ?Id = null, permissions: ?Permissions = null, user_can_change: ?bool = null,};
pub const DocumentTypeFields = CorrespondentFields;
pub const StoragePath = struct { id: Id, slug: []const u8 = "", name: []const u8 = "", /// A filename template, as in `PAPERLESS_FILENAME_FORMAT`. path: []const u8 = "", match: []const u8 = "", matching_algorithm: MatchingAlgorithm = .none, is_insensitive: bool = false, document_count: u64 = 0, owner: ?Id = null, permissions: ?Permissions = null, user_can_change: ?bool = null,};
pub const StoragePathFields = struct { name: ?[]const u8 = null, path: ?[]const u8 = null, match: ?[]const u8 = null, matching_algorithm: ?MatchingAlgorithm = null, is_insensitive: ?bool = null, owner: ?Clearable(Id) = null, set_permissions: ?SetPermissions = null,};
/// What a custom field holds. Paperless adds a type every few releases, so a/// type this does not name reads as `unknown` rather than failing.pub const CustomFieldType = enum { string, url, date, boolean, integer, float, /// A currency code followed by an amount, `EUR12.50`, or a bare amount. monetary, /// A list of document identifiers. documentlink, /// One of `extra_data.select_options`, by its `id`. select, longtext, unknown,
pub fn jsonParse(allocator: Allocator, source: anytype, options: std.json.ParseOptions) !@This() { return json.parseOpenEnum(@This(), allocator, source, options); }};
pub const CustomField = struct { id: Id, name: []const u8 = "", data_type: CustomFieldType = .unknown, /// Type-specific settings -- `select_options` for a select, /// `default_currency` for a monetary field. Left as JSON because what it /// holds depends on `data_type`. extra_data: std.json.Value = .null, document_count: u64 = 0,};
pub const CustomFieldFields = struct { name: ?[]const u8 = null, data_type: ?CustomFieldType = null, extra_data: ?std.json.Value = null,};
/// A custom field's value on one document.pub const CustomFieldInstance = struct { field: Id, /// Whatever the field's type makes it: a string, a number, a boolean, a /// list of document identifiers, or null. value: std.json.Value = .null,};
pub const BasicUser = struct { id: Id, username: []const u8 = "", first_name: []const u8 = "", last_name: []const u8 = "",};
pub const Note = struct { id: Id, note: []const u8 = "", created: []const u8 = "", /// Null for a note whose author has since been deleted. user: ?BasicUser = null,};
/// One version of a document's file. Paperless 3 keeps the history of a/// document's file as a chain of versions, the first of which is the root.pub const DocumentVersion = struct { id: Id, added: []const u8 = "", version_label: ?[]const u8 = null, checksum: ?[]const u8 = null, is_root: bool = false,};
pub const DuplicateDocument = struct { id: Id, title: []const u8 = "", deleted_at: ?[]const u8 = null,};
pub const Document = struct { id: Id, correspondent: ?Id = null, document_type: ?Id = null, storage_path: ?Id = null, title: []const u8 = "", /// The extracted text, which is what full-text search reads. Cut short /// when the list was asked for with `truncate_content`. content: []const u8 = "", tags: []const Id = &.{}, /// The date the document itself bears, `YYYY-MM-DD`. created: ?[]const u8 = null, created_date: ?[]const u8 = null, modified: ?[]const u8 = null, /// When it arrived in Paperless. added: ?[]const u8 = null, /// Set when the document is in the trash. deleted_at: ?[]const u8 = null, archive_serial_number: ?u64 = null, original_file_name: ?[]const u8 = null, archived_file_name: ?[]const u8 = null, duplicate_documents: []const DuplicateDocument = &.{}, owner: ?Id = null, permissions: ?Permissions = null, user_can_change: ?bool = null, is_shared_by_requester: bool = false, notes: []const Note = &.{}, custom_fields: []const CustomFieldInstance = &.{}, page_count: ?u64 = null, mime_type: []const u8 = "", /// The document this one is a version of, when it is one. root_document: ?Id = null, versions: []const DocumentVersion = &.{}, /// With `?search=` or `?more_like_id=`: how well it matched. __search_hit__: ?SearchHit = null,
pub const SearchHit = struct { score: ?f64 = null, /// The matching passage, with the terms marked in HTML. highlights: ?[]const u8 = null, /// The same for the document's notes, when it was a note that /// matched. note_highlights: ?[]const u8 = null, rank: ?u64 = null, };};
/// The editable fields of a document. The file itself is not one of them:/// a new file is a new version, uploaded with `Documents.updateVersion`.pub const DocumentFields = struct { title: ?[]const u8 = null, content: ?[]const u8 = null, correspondent: ?Clearable(Id) = null, document_type: ?Clearable(Id) = null, storage_path: ?Clearable(Id) = null, /// Replaces the document's tags; it does not add to them. tags: ?[]const Id = null, /// `YYYY-MM-DD`. created: ?[]const u8 = null, archive_serial_number: ?Clearable(u64) = null, owner: ?Clearable(Id) = null, /// Replaces the document's custom fields: one left out of the list is /// taken off the document. custom_fields: ?[]const CustomFieldInstance = null, /// Takes every inbox tag off the document, alongside whatever `tags` /// says -- what the web interface's "remove from inbox" sends. remove_inbox_tags: ?bool = null, set_permissions: ?SetPermissions = null,};
pub const MetadataEntry = struct { namespace: []const u8 = "", prefix: []const u8 = "", key: []const u8 = "", value: []const u8 = "",};
/// What Paperless knows about a document's files, as opposed to about the/// document.pub const Metadata = struct { original_checksum: []const u8 = "", original_size: u64 = 0, original_mime_type: []const u8 = "", media_filename: []const u8 = "", has_archive_version: bool = false, original_metadata: []const MetadataEntry = &.{}, archive_checksum: ?[]const u8 = null, archive_media_filename: ?[]const u8 = null, original_filename: []const u8 = "", archive_size: ?u64 = null, archive_metadata: ?[]const MetadataEntry = null, /// The language the classifier guessed, as a two-letter code. lang: []const u8 = "",};
/// What the classifier would assign to a document.pub const Suggestions = struct { correspondents: []const Id = &.{}, tags: []const Id = &.{}, document_types: []const Id = &.{}, storage_paths: []const Id = &.{}, /// Dates found in the content, `YYYY-MM-DD`. dates: []const []const u8 = &.{},};
pub const TaskStatus = enum { pending, started, success, failure, revoked, unknown,
pub fn jsonParse(allocator: Allocator, source: anytype, options: std.json.ParseOptions) !@This() { return json.parseOpenEnum(@This(), allocator, source, options); }
/// Whether the task will change no further. pub fn isDone(status: TaskStatus) bool { return switch (status) { .success, .failure, .revoked => true, .pending, .started, .unknown => false, }; }};
pub const TaskType = enum { consume_file, train_classifier, sanity_check, index_optimize, mail_fetch, llm_index, empty_trash, check_workflows, bulk_update, reprocess_document, build_share_link, bulk_delete, apply_ai_suggestions, unknown,
pub fn jsonParse(allocator: Allocator, source: anytype, options: std.json.ParseOptions) !@This() { return json.parseOpenEnum(@This(), allocator, source, options); }};
pub const TaskTrigger = enum { scheduled, web_ui, api_upload, folder_consume, email_consume, system, manual, unknown,
pub fn jsonParse(allocator: Allocator, source: anytype, options: std.json.ParseOptions) !@This() { return json.parseOpenEnum(@This(), allocator, source, options); }};
/// Something the server is doing, or did, in the background. An upload/// becomes one of these and the document appears only when it has finished.pub const Task = struct { id: Id, /// The Celery identifier, a UUID. What `post_document` hands back. task_id: []const u8 = "", task_type: TaskType = .unknown, task_type_display: []const u8 = "", trigger_source: TaskTrigger = .unknown, trigger_source_display: []const u8 = "", status: TaskStatus = .unknown, status_display: []const u8 = "", date_created: ?[]const u8 = null, date_started: ?[]const u8 = null, date_done: ?[]const u8 = null, duration_seconds: ?f64 = null, wait_time_seconds: ?f64 = null, /// What the task was started with -- for an upload, the filename and the /// overrides that came with it. input_data: std.json.Value = .null, /// What it produced: `{"document_id": N}` for an upload that succeeded, /// `{"error_type": ..., "error_message": ...}` for one that did not. result_data: std.json.Value = .null, related_document_ids: []const Id = &.{}, acknowledged: bool = false, owner: ?Id = null,
/// How a finished upload -- a `consume_file` task -- came out. /// /// `status` alone does not say: a refused duplicate is a `failure` with /// no error message, and an upload a plugin stopped on purpose (a barcode /// that split the file, the first half of a double-sided scan) is a /// `success` that made no document. Paperless says which in /// `result_data`, and this reads it. pub const Outcome = union(enum) { /// Not finished yet. pending, /// A new document. created: Id, /// Refused because Paperless already has this file -- with /// `PAPERLESS_CONSUMER_DELETE_DUPLICATES` set. (Without it, a /// duplicate is consumed again and is `created`, with the original /// in the new document's `duplicate_documents`.) duplicate: struct { /// The document that already has it. of: Id, /// Whether that document is in the trash. in_trash: bool, }, /// A plugin ended the task on purpose, and no document was made here. stopped: []const u8, /// It went wrong. failed: struct { /// The Python exception's name, `ConsumerError`. error_type: ?[]const u8, message: ?[]const u8, }, /// Finished, with a result this does not recognise -- a newer /// server, or a task that is not an upload. other,
/// One line a person can read: what the command-line tool prints. pub fn format(o: Outcome, w: *std.Io.Writer) std.Io.Writer.Error!void { switch (o) { .pending => try w.writeAll("not finished"), .created => |id| try w.print("document {d}", .{id}), .duplicate => |d| try w.print("already in Paperless as document {d}{s}", .{ d.of, if (d.in_trash) ", which is in the trash" else "", }), .stopped => |reason| try w.print("stopped: {s}", .{reason}), .failed => |f| try w.print("failed: {s}", .{f.message orelse f.error_type orelse "no reason given"}), .other => try w.writeAll("finished"), } } };
pub fn outcome(task: Task) Outcome { if (!task.status.isDone()) return .pending; const data = switch (task.result_data) { .object => |o| o, else => return if (task.status == .success) .other else .{ .failed = .{ .error_type = null, .message = null } }, }; if (integer(data.get("duplicate_of"))) |of| { const in_trash = if (data.get("duplicate_in_trash")) |v| v == .bool and v.bool else false; return .{ .duplicate = .{ .of = of, .in_trash = in_trash } }; } if (integer(data.get("document_id"))) |id| return .{ .created = id }; if (string(data.get("reason"))) |reason| return .{ .stopped = reason }; if (task.status != .success) return .{ .failed = .{ .error_type = string(data.get("error_type")), .message = string(data.get("error_message")), } }; return .other; }
/// The document a finished upload made, or null if it made none -- /// including a refused duplicate, whose `related_document_ids` names the /// document that was already there rather than a new one. pub fn documentId(task: Task) ?Id { return switch (task.outcome()) { .created => |id| id, else => null, }; }
/// Why a failed task failed, as the server put it. Null for a refused /// duplicate, which carries no message; `outcome` says what happened in /// every case. pub fn errorMessage(task: Task) ?[]const u8 { return switch (task.outcome()) { .failed => |f| f.message, else => null, }; }
fn integer(value: ?std.json.Value) ?Id { return switch (value orelse return null) { .integer => |i| if (i >= 0) @intCast(i) else null, else => null, }; }
fn string(value: ?std.json.Value) ?[]const u8 { return switch (value orelse return null) { .string => |s| s, else => null, }; }};
/// One condition in a saved view's filter. `rule_type` is one of the fifty/// or so filters the web interface knows -- 0 is "title contains", 6 is/// "has tag" -- and is left as a number, since naming them all here would/// be a table to keep in step with the web interface's source.pub const FilterRule = struct { rule_type: u32, value: ?[]const u8 = null,};
pub const SavedView = struct { id: Id, name: []const u8 = "", icon: ?[]const u8 = null, sort_field: ?[]const u8 = null, sort_reverse: bool = false, filter_rules: []const FilterRule = &.{}, page_size: ?u64 = null, display_mode: ?[]const u8 = null, display_fields: ?[]const []const u8 = null, owner: ?Id = null, permissions: ?Permissions = null, user_can_change: ?bool = null,};
pub const SavedViewFields = struct { name: ?[]const u8 = null, icon: ?[]const u8 = null, sort_field: ?Clearable([]const u8) = null, sort_reverse: ?bool = null, filter_rules: ?[]const FilterRule = null, page_size: ?Clearable(u64) = null, display_mode: ?Clearable([]const u8) = null, display_fields: ?Clearable([]const []const u8) = null, owner: ?Clearable(Id) = null, set_permissions: ?SetPermissions = null,};
pub const FileVersion = enum { archive, original };
/// A link that hands a document to anybody who has it, with no login.pub const ShareLink = struct { id: Id, created: ?[]const u8 = null, expiration: ?[]const u8 = null, /// The last part of `<server>/share/<slug>`. slug: []const u8 = "", document: ?Id = null, document_title: []const u8 = "", file_version: ?FileVersion = null,};
pub const ShareLinkFields = struct { document: ?Id = null, /// When the link stops working, as an ISO 8601 date-time. Null never. expiration: ?Clearable([]const u8) = null, file_version: ?FileVersion = null,};
pub const User = struct { id: Id, username: []const u8 = "", email: []const u8 = "", first_name: []const u8 = "", last_name: []const u8 = "", date_joined: ?[]const u8 = null, is_staff: bool = false, is_active: bool = false, is_superuser: bool = false, groups: []const Id = &.{}, user_permissions: []const []const u8 = &.{}, inherited_permissions: []const []const u8 = &.{}, is_mfa_enabled: bool = false,};
pub const UserFields = struct { username: ?[]const u8 = null, email: ?[]const u8 = null, /// Write only; the server never sends a password back. password: ?[]const u8 = null, first_name: ?[]const u8 = null, last_name: ?[]const u8 = null, is_staff: ?bool = null, is_active: ?bool = null, is_superuser: ?bool = null, groups: ?[]const Id = null, user_permissions: ?[]const []const u8 = null,};
pub const Group = struct { id: Id, name: []const u8 = "", /// Django permission codenames, `documents.view_document`. permissions: []const []const u8 = &.{},};
pub const GroupFields = struct { name: ?[]const u8 = null, permissions: ?[]const []const u8 = null,};
pub const Statistics = struct { documents_total: u64 = 0, /// Documents carrying an inbox tag; null when there is no inbox tag. documents_inbox: ?u64 = null, inbox_tag: ?Id = null, inbox_tags: ?[]const Id = null, document_file_type_counts: []const FileTypeCount = &.{}, character_count: u64 = 0, tag_count: u64 = 0, correspondent_count: u64 = 0, document_type_count: u64 = 0, storage_path_count: u64 = 0, /// The highest archive serial number in use. current_asn: u64 = 0,
pub const FileTypeCount = struct { mime_type: []const u8 = "", mime_type_count: u64 = 0, };};
pub const RemoteVersion = struct { /// The newest release, `v3.2.1`. version: []const u8 = "", update_available: bool = false,};
/// The server's own account of its health: what the system status dialog/// in the web interface shows.pub const SystemStatus = struct { pngx_version: []const u8 = "", server_os: []const u8 = "", install_type: []const u8 = "", storage: ?Storage = null, database: ?Database = null, tasks: ?Tasks = null,
pub const Storage = struct { total: u64 = 0, available: u64 = 0, };
pub const Database = struct { type: []const u8 = "", url: []const u8 = "", status: []const u8 = "", @"error": ?[]const u8 = null, };
pub const Tasks = struct { redis_status: []const u8 = "", redis_error: ?[]const u8 = null, celery_status: []const u8 = "", celery_error: ?[]const u8 = null, index_status: []const u8 = "", index_last_modified: ?[]const u8 = null, index_error: ?[]const u8 = null, classifier_status: []const u8 = "", classifier_last_trained: ?[]const u8 = null, classifier_error: ?[]const u8 = null, sanity_check_status: []const u8 = "", sanity_check_last_run: ?[]const u8 = null, sanity_check_error: ?[]const u8 = null, };};
/// What the search box in the web interface searches: everything at once,/// a few of each, by name.pub const GlobalSearch = struct { total: u64 = 0, documents: []const Document = &.{}, saved_views: []const SavedView = &.{}, tags: []const Tag = &.{}, correspondents: []const Correspondent = &.{}, document_types: []const DocumentType = &.{}, storage_paths: []const StoragePath = &.{}, users: []const User = &.{}, groups: []const Group = &.{}, custom_fields: []const CustomField = &.{},};
test "a document trimmed by ?fields= still parses" { const parsed = try std.json.parseFromSlice( Document, std.testing.allocator, \\{"id":7,"title":"Hello","permissions":{"view":{"users":[2],"groups":[]},"change":{"users":[],"groups":[]}}} , json.parse_options, ); defer parsed.deinit(); try std.testing.expectEqual(7, parsed.value.id); try std.testing.expectEqualStrings("Hello", parsed.value.title); try std.testing.expectEqualSlices(Id, &.{2}, parsed.value.permissions.?.view.users); try std.testing.expectEqual(null, parsed.value.user_can_change);}
test "a task names the document an upload made" { const parsed = try std.json.parseFromSlice( Task, std.testing.allocator, \\{"id":2,"task_id":"737bdbcd-bd4b-42a2-9951-f0fcef1774c9","task_type":"consume_file", \\ "trigger_source":"api_upload","status":"success","result_data":{"document_id":1}, \\ "related_document_ids":[],"acknowledged":false,"owner":1,"brand_new_field":[1,2,3]} , json.parse_options, ); defer parsed.deinit(); try std.testing.expectEqual(TaskStatus.success, parsed.value.status); try std.testing.expect(parsed.value.status.isDone()); try std.testing.expectEqual(1, parsed.value.documentId().?); try std.testing.expectEqual(null, parsed.value.errorMessage());}
fn expectOutcome(comptime task_json: []const u8, expected: []const u8) !void { const parsed = try std.json.parseFromSlice(Task, std.testing.allocator, task_json, json.parse_options); defer parsed.deinit(); var buffer: [128]u8 = undefined; try std.testing.expectEqualStrings(expected, try std.fmt.bufPrint(&buffer, "{f}", .{parsed.value.outcome()}));}
test "every way an upload can end, as Paperless 3.2 records it" { // A refused duplicate: a failure with no message, whose related // document is the one that was already there. try expectOutcome( \\{"id":13046,"task_type":"consume_file","status":"failure", \\ "result_data":{"duplicate_of":3011,"duplicate_in_trash":false},"related_document_ids":[3011]} , "already in Paperless as document 3011"); try expectOutcome( \\{"id":1,"status":"failure","result_data":{"duplicate_of":5,"duplicate_in_trash":true},"related_document_ids":[5]} , "already in Paperless as document 5, which is in the trash"); // Stopped by a plugin: a success with no document. try expectOutcome( \\{"id":2,"status":"success","result_data":{"reason":"Barcode splitting complete!"}} , "stopped: Barcode splitting complete!"); try expectOutcome( \\{"id":3,"status":"failure","result_data":{"error_type":"ConsumerError","error_message":"x.pdf: cannot open resource"}} , "failed: x.pdf: cannot open resource"); try expectOutcome( \\{"id":4,"status":"success","result_data":{"document_id":9},"related_document_ids":[9]} , "document 9"); try expectOutcome( \\{"id":5,"status":"started","result_data":null} , "not finished");
const duplicate = try std.json.parseFromSlice(Task, std.testing.allocator, \\{"id":13046,"status":"failure","result_data":{"duplicate_of":3011,"duplicate_in_trash":false},"related_document_ids":[3011]} , json.parse_options); defer duplicate.deinit(); try std.testing.expectEqual(null, duplicate.value.documentId()); try std.testing.expectEqual(null, duplicate.value.errorMessage()); try std.testing.expectEqual(3011, duplicate.value.outcome().duplicate.of);}
test "a task type from a newer server is unknown rather than an error" { const parsed = try std.json.parseFromSlice(Task, std.testing.allocator, \\{"id":3,"task_type":"teleport_document","status":"failure","result_data":{"error_message":"no"}} , json.parse_options); defer parsed.deinit(); try std.testing.expectEqual(TaskType.unknown, parsed.value.task_type); try std.testing.expectEqualStrings("no", parsed.value.errorMessage().?);}
test "a matching algorithm goes out as a number" { var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try std.json.Stringify.value(TagFields{ .name = "x", .matching_algorithm = .regex, .parent = .clear, }, json.stringify_options, &out.writer); try std.testing.expectEqualStrings( \\{"name":"x","matching_algorithm":4,"parent":null} , out.written());}