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.
23 kB · 582 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Documents: finding them, reading and changing what is known about them,//! getting their files, and putting new ones in.//!//! An upload is not a document yet. `upload` hands the file to the server,//! which answers at once with the identifier of a background task; the//! document exists only when that task has succeeded, which//! `Tasks.wait` waits for://!//! ```zig//! const task_id = try client.documents().upload(.{//! .filename = "invoice.pdf",//! .content = .{ .bytes = pdf },//! .tags = &.{inbox_tag},//! });//! var task = try client.tasks().wait(&task_id, .{});//! defer task.deinit();//! const document_id = task.value.documentId() orelse return error.UploadFailed;//! ```
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;
const Client = @import("Client.zig");const models = @import("models.zig");const multipart = @import("multipart.zig");const query = @import("query.zig");const resource = @import("resource.zig");const Tasks = @import("Tasks.zig");
const Id = models.Id;const Document = models.Document;const Parsed = std.json.Parsed;
const Documents = @This();
client: *Client,
/// The filters the document list takes most often. Paperless has well over/// a hundred; anything not here goes in `extra` under the name the/// Paperless documentation gives it.pub const Query = struct { page: ?u32 = null, /// Up to 100000. page_size: ?u32 = null, /// A field name, `-` before it to reverse: `-created`, `title`, /// `archive_serial_number`. With `query`, `-score` is relevance. ordering: ?[]const u8 = null,
/// Full-text search, in Whoosh's query language: words, `"phrases"`, /// `tag:invoice`, `created:[2024 to now]`. Results carry a /// `__search_hit__`. query: ?[]const u8 = null, /// Documents like this one, by the full-text index. more_like_id: ?Id = null, /// Title or content containing this, without the index. title_content: ?[]const u8 = null,
id__in: ?[]const Id = null, title__icontains: ?[]const u8 = null, content__icontains: ?[]const u8 = null, original_filename__icontains: ?[]const u8 = null, archive_serial_number: ?u64 = null, archive_serial_number__isnull: ?bool = null, mime_type: ?[]const u8 = null,
correspondent__id: ?Id = null, correspondent__id__in: ?[]const Id = null, correspondent__id__none: ?[]const Id = null, correspondent__isnull: ?bool = null, document_type__id: ?Id = null, document_type__id__in: ?[]const Id = null, document_type__id__none: ?[]const Id = null, document_type__isnull: ?bool = null, storage_path__id: ?Id = null, storage_path__id__in: ?[]const Id = null, storage_path__id__none: ?[]const Id = null, storage_path__isnull: ?bool = null, owner__id: ?Id = null, owner__isnull: ?bool = null,
/// Every one of these tags. tags__id__all: ?[]const Id = null, /// Any of these tags. tags__id__in: ?[]const Id = null, /// None of these tags. tags__id__none: ?[]const Id = null, is_tagged: ?bool = null, /// Carrying an inbox tag. is_in_inbox: ?bool = null,
custom_fields__id__all: ?[]const Id = null, custom_fields__id__in: ?[]const Id = null, has_custom_fields: ?bool = null, /// The structured custom field query the web interface builds, as JSON: /// `["Amount", "gt", 100]`, `["AND", [[...], [...]]]`. custom_field_query: ?[]const u8 = null,
/// `YYYY-MM-DD` bounds on the document's own date. created__date__gte: ?[]const u8 = null, created__date__lte: ?[]const u8 = null, /// ISO 8601 bounds on when it arrived. added__gte: ?[]const u8 = null, added__lte: ?[]const u8 = null, modified__gte: ?[]const u8 = null, modified__lte: ?[]const u8 = null,
/// Only these fields in each result, comma-separated: `id,title`. /// Include `id`, which the model requires. fields: ?[]const u8 = null, /// Cut `content` short, as the web interface's list does. truncate_content: ?bool = null, /// `permissions` in each result instead of `user_can_change`. full_perms: ?bool = null,
extra: []const query.Param = &.{},};
/// One page of documents.pub fn list(self: Documents, q: Query) Client.Error!Parsed(models.Page(Document)) { return self.client.getJson(models.Page(Document), "documents/", q);}
/// Every page, one at a time.pub fn pages(self: Documents, q: Query) resource.Pager(Document, Query) { return .{ .client = self.client, .path = "documents/", .query = q };}
pub const GetOptions = struct { full_perms: ?bool = null, fields: ?[]const u8 = null,};
pub fn get(self: Documents, id: Id, options: GetOptions) Client.Error!Parsed(Document) { var buffer: [48]u8 = undefined; return self.client.getJson(Document, path(&buffer, id, ""), options);}
/// Changes the fields that are set in `fields` and leaves the rest alone.pub fn update(self: Documents, id: Id, fields: models.DocumentFields) Client.Error!Parsed(Document) { var buffer: [48]u8 = undefined; return self.client.sendJson(Document, .patch, path(&buffer, id, ""), .{}, fields);}
/// Moves a document to the trash, from which `Client.restoreFromTrash` can/// bring it back until the trash is emptied.pub fn delete(self: Documents, id: Id) Client.Error!void { var buffer: [48]u8 = undefined; return self.client.sendJsonIgnore(.delete, path(&buffer, id, ""), .{}, null);}
pub fn metadata(self: Documents, id: Id) Client.Error!Parsed(models.Metadata) { var buffer: [48]u8 = undefined; return self.client.getJson(models.Metadata, path(&buffer, id, "metadata/"), .{});}
/// What the classifier would assign.pub fn suggestions(self: Documents, id: Id) Client.Error!Parsed(models.Suggestions) { var buffer: [48]u8 = undefined; return self.client.getJson(models.Suggestions, path(&buffer, id, "suggestions/"), .{});}
pub fn notes(self: Documents, id: Id) Client.Error!Parsed([]models.Note) { var buffer: [48]u8 = undefined; return self.client.getJson([]models.Note, path(&buffer, id, "notes/"), .{});}
/// Adds a note, and hands back every note the document now has.pub fn addNote(self: Documents, id: Id, note: []const u8) Client.Error!Parsed([]models.Note) { var buffer: [48]u8 = undefined; return self.client.sendJson([]models.Note, .post, path(&buffer, id, "notes/"), .{}, .{ .note = note });}
/// Deletes a note, and hands back the notes that are left.pub fn deleteNote(self: Documents, id: Id, note_id: Id) Client.Error!Parsed([]models.Note) { var buffer: [48]u8 = undefined; return self.client.sendJson([]models.Note, .delete, path(&buffer, id, "notes/"), .{ .id = note_id }, null);}
/// The share links that hand this document out.pub fn shareLinks(self: Documents, id: Id) Client.Error!Parsed([]models.ShareLink) { var buffer: [48]u8 = undefined; return self.client.getJson([]models.ShareLink, path(&buffer, id, "share_links/"), .{});}
/// The archive serial number the next document should be given.pub fn nextAsn(self: Documents) Client.Error!u64 { const parsed = try self.client.getJson(u64, "documents/next_asn/", .{}); defer parsed.deinit(); return parsed.value;}
/// Words from the full-text index that begin with `term`, for completing a/// search as it is typed.pub fn autocomplete(self: Documents, term: []const u8, limit: ?u32) Client.Error!Parsed([][]const u8) { return self.client.getJson([][]const u8, "search/autocomplete/", .{ .term = term, .limit = limit });}
// ---------------------------------------------------------------------------// Files// ---------------------------------------------------------------------------
pub const File = enum { /// The archived version when there is one -- the PDF/A Paperless made /// with a text layer -- and the original when there is not. What the web /// interface's download button gives. archive, /// The file exactly as it was uploaded. original, /// What the web interface shows inline: the archived version as /// `inline` rather than as an attachment. preview, /// A small WebP image of the first page. thumbnail,};
pub const DownloadOptions = struct { file: File = .archive,};
/// What a download said about the file. Owns its strings.pub const Download = struct { gpa: Allocator, /// `Content-Type`, as the server sent it. content_type: ?[]const u8, /// The filename from `Content-Disposition`, decoded to UTF-8. Paperless /// builds it from the document's title or from `PAPERLESS_FILENAME_FORMAT`. filename: ?[]const u8,
pub fn deinit(d: *Download) void { if (d.content_type) |s| d.gpa.free(s); if (d.filename) |s| d.gpa.free(s); d.* = undefined; }};
/// Writes one of a document's files to `writer`, as it arrives. Nothing is/// written unless the server answers 2xx, so a failure leaves the writer as/// it was -- though a connection that drops part way through leaves what/// had arrived, which is why the command-line tool writes to a file of its/// own and renames it into place only at the end.pub fn download(self: Documents, id: Id, writer: *Io.Writer, options: DownloadOptions) Client.Error!Download { const c = self.client; var buffer: [48]u8 = undefined; const suffix: []const u8, const original: ?bool = switch (options.file) { .archive => .{ "download/", null }, .original => .{ "download/", true }, .preview => .{ "preview/", null }, .thumbnail => .{ "thumb/", null }, }; const url = try c.formatUrl(path(&buffer, id, suffix), .{ .original = original }); defer c.gpa.free(url);
var result = try c.send(.get, url, .none, writer, "*/*"); defer result.deinit();
var info: Download = .{ .gpa = c.gpa, .content_type = null, .filename = null }; errdefer info.deinit(); if (result.headers.get("content-type")) |value| info.content_type = try c.gpa.dupe(u8, value); if (result.headers.get("content-disposition")) |value| info.filename = try Client.dispositionFilename(c.gpa, value); return info;}
// ---------------------------------------------------------------------------// Uploads// ---------------------------------------------------------------------------
pub const Content = union(enum) { bytes: []const u8, /// Read as it is sent, so that a large file is never held. `length` is /// sent as part of `Content-Length` before anything is read, so it has to /// be exact -- a file's size from `stat`, say. reader: struct { reader: *Io.Reader, length: u64, },};
pub const Upload = struct { /// The name the file had. Paperless keeps it as `original_file_name` /// and, without `title`, makes the title from it. filename: []const u8, content: Content, /// Null lets the file's name, or a workflow, decide. content_type: ?[]const u8 = null, title: ?[]const u8 = null, /// The document's own date: `YYYY-MM-DD`, or an ISO 8601 date-time. Null /// lets Paperless find one in the content. created: ?[]const u8 = null, correspondent: ?Id = null, document_type: ?Id = null, storage_path: ?Id = null, tags: []const Id = &.{}, archive_serial_number: ?u64 = null, /// Custom fields to set, with their values. A value of `.null` adds the /// field without one. custom_fields: []const models.CustomFieldInstance = &.{},};
/// A Celery task identifier: a UUID in its 36-character text form.pub const TaskId = [36]u8;
/// Sends a file to be consumed, and hands back the identifier of the task/// that will consume it. The document does not exist until that task/// succeeds; see `Tasks.wait`.pub fn upload(self: Documents, u: Upload) Client.Error!TaskId { const c = self.client;
var random: [16]u8 = undefined; c.http.io.random(&random); var form: multipart.Form = .init(c.gpa, random); defer form.deinit();
if (u.title) |title| try form.field("title", title); if (u.created) |created| try form.field("created", created); if (u.correspondent) |id| try form.fieldInt("correspondent", id); if (u.document_type) |id| try form.fieldInt("document_type", id); if (u.storage_path) |id| try form.fieldInt("storage_path", id); for (u.tags) |id| try form.fieldInt("tags", id); if (u.archive_serial_number) |asn| try form.fieldInt("archive_serial_number", asn); if (u.custom_fields.len > 0) { // An object from field identifier to value, sent as a JSON string: // the one form that carries values, where a list carries only ids. var object: Io.Writer.Allocating = .init(c.gpa); defer object.deinit(); writeCustomFieldObject(&object.writer, u.custom_fields) catch return error.OutOfMemory; try form.field("custom_fields", object.written()); } try form.file("document", u.filename, u.content_type);
var bytes_reader: Io.Reader = undefined; const content: *Io.Reader, const length: u64 = switch (u.content) { .bytes => |bytes| .{ blk: { bytes_reader = .fixed(bytes); break :blk &bytes_reader; }, bytes.len }, .reader => |r| .{ r.reader, r.length }, }; var body = form.finish(content, length); var content_type_buffer: [96]u8 = undefined;
const url = try c.formatUrl("documents/post_document/", .{}); defer c.gpa.free(url);
var response: Io.Writer.Allocating = .init(c.gpa); defer response.deinit(); var result = try c.send(.post, url, .{ .form = .{ .content_type = form.contentType(&content_type_buffer), .body = &body, } }, &response.writer, null); result.deinit();
// A JSON string holding the UUID. const parsed = try std.json.parseFromSlice([]const u8, c.gpa, response.written(), .{}); defer parsed.deinit(); if (parsed.value.len != @sizeOf(TaskId)) return error.UnexpectedToken; return parsed.value[0..@sizeOf(TaskId)].*;}
fn writeCustomFieldObject(w: *Io.Writer, fields: []const models.CustomFieldInstance) !void { var s: std.json.Stringify = .{ .writer = w }; try s.beginObject(); for (fields) |f| { var key: [24]u8 = undefined; try s.objectField(std.fmt.bufPrint(&key, "{d}", .{f.field}) catch unreachable); try s.write(f.value); } try s.endObject();}
/// Uploads a new file for an existing document. Paperless 3 keeps the old/// one as an earlier version; the new file is consumed in the background/// like any upload, so this too hands back a task.pub fn updateVersion(self: Documents, id: Id, filename: []const u8, content: Content, version_label: ?[]const u8) Client.Error!TaskId { const c = self.client;
var random: [16]u8 = undefined; c.http.io.random(&random); var form: multipart.Form = .init(c.gpa, random); defer form.deinit(); if (version_label) |label| try form.field("version_label", label); try form.file("document", filename, null);
var bytes_reader: Io.Reader = undefined; const reader: *Io.Reader, const length: u64 = switch (content) { .bytes => |bytes| .{ blk: { bytes_reader = .fixed(bytes); break :blk &bytes_reader; }, bytes.len }, .reader => |r| .{ r.reader, r.length }, }; var body = form.finish(reader, length); var content_type_buffer: [96]u8 = undefined;
var buffer: [48]u8 = undefined; const url = try c.formatUrl(path(&buffer, id, "update_version/"), .{}); defer c.gpa.free(url);
var response: Io.Writer.Allocating = .init(c.gpa); defer response.deinit(); var result = try c.send(.post, url, .{ .form = .{ .content_type = form.contentType(&content_type_buffer), .body = &body, } }, &response.writer, null); result.deinit();
const parsed = try std.json.parseFromSlice([]const u8, c.gpa, response.written(), .{}); defer parsed.deinit(); if (parsed.value.len != @sizeOf(TaskId)) return error.UnexpectedToken; return parsed.value[0..@sizeOf(TaskId)].*;}
// ---------------------------------------------------------------------------// Many documents at once// ---------------------------------------------------------------------------
/// A change to apply to many documents at once, through `bulk_edit`.pub const Edit = union(enum) { /// Null clears it. set_correspondent: ?Id, set_document_type: ?Id, set_storage_path: ?Id, add_tag: Id, remove_tag: Id, modify_tags: struct { add: []const Id = &.{}, remove: []const Id = &.{}, }, modify_custom_fields: struct { /// Fields to add, or to set the value of where a document already /// has them. add: []const models.CustomFieldInstance = &.{}, remove: []const Id = &.{}, }, set_permissions: struct { set_permissions: ?models.SetPermissions = null, /// Null leaves the owner alone. owner: ?Id = null, /// Add to the permissions each document has rather than replacing /// them. merge: bool = false, },};
/// Applies one change to every document in `ids`. Done in the background:/// the call returns when the change is queued, and a document read straight/// afterwards may not show it yet.pub fn bulkEdit(self: Documents, ids: []const Id, edit: Edit) Client.Error!void { const c = self.client; var body: Io.Writer.Allocating = .init(c.gpa); defer body.deinit(); writeBulkEdit(&body.writer, ids, edit) catch return error.OutOfMemory;
const url = try c.formatUrl("documents/bulk_edit/", .{}); defer c.gpa.free(url); var result = try c.send(.post, url, .{ .json = body.written() }, null, null); result.deinit();}
fn writeBulkEdit(w: *Io.Writer, ids: []const Id, edit: Edit) !void { var s: std.json.Stringify = .{ .writer = w }; try s.beginObject(); try s.objectField("documents"); try s.write(ids); try s.objectField("method"); try s.write(@tagName(edit)); try s.objectField("parameters"); try s.beginObject(); switch (edit) { .set_correspondent => |id| { try s.objectField("correspondent"); try s.write(id); }, .set_document_type => |id| { try s.objectField("document_type"); try s.write(id); }, .set_storage_path => |id| { try s.objectField("storage_path"); try s.write(id); }, .add_tag, .remove_tag => |id| { try s.objectField("tag"); try s.write(id); }, .modify_tags => |m| { try s.objectField("add_tags"); try s.write(m.add); try s.objectField("remove_tags"); try s.write(m.remove); }, .modify_custom_fields => |m| { try s.objectField("add_custom_fields"); try s.beginObject(); for (m.add) |f| { var key: [24]u8 = undefined; try s.objectField(std.fmt.bufPrint(&key, "{d}", .{f.field}) catch unreachable); try s.write(f.value); } try s.endObject(); try s.objectField("remove_custom_fields"); try s.write(m.remove); }, .set_permissions => |p| { try s.objectField("set_permissions"); try s.write(p.set_permissions); if (p.owner) |owner| { try s.objectField("owner"); try s.write(owner); } try s.objectField("merge"); try s.write(p.merge); }, } try s.endObject(); try s.endObject();}
/// Moves documents to the trash.pub fn deleteMany(self: Documents, ids: []const Id) Client.Error!void { return self.client.sendJsonIgnore(.post, "documents/delete/", .{}, .{ .documents = ids });}
/// Consumes documents again from their original files: OCR, the/// classifier, the archived version, all redone.pub fn reprocess(self: Documents, ids: []const Id) Client.Error!void { return self.client.sendJsonIgnore(.post, "documents/reprocess/", .{}, .{ .documents = ids });}
/// Rotates every page, by 90, 180 or 270 degrees clockwise.pub fn rotate(self: Documents, ids: []const Id, degrees: u16) Client.Error!void { return self.client.sendJsonIgnore(.post, "documents/rotate/", .{}, .{ .documents = ids, .degrees = degrees });}
pub const MergeOptions = struct { /// Which document's title, tags and the rest the merged one takes. /// Null gives it none. metadata_document_id: ?Id = null, /// Delete the documents that went into it. delete_originals: ?bool = null,};
/// Merges PDFs into a new document, in the order given. The new document is/// consumed like an upload.pub fn merge(self: Documents, ids: []const Id, options: MergeOptions) Client.Error!void { return self.client.sendJsonIgnore(.post, "documents/merge/", .{}, .{ .documents = ids, .metadata_document_id = options.metadata_document_id, .delete_originals = options.delete_originals, });}
fn path(buffer: []u8, id: Id, suffix: []const u8) []const u8 { return std.fmt.bufPrint(buffer, "documents/{d}/{s}", .{ id, suffix }) catch unreachable;}
test writeBulkEdit { var out: Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try writeBulkEdit(&out.writer, &.{ 1, 2 }, .{ .modify_tags = .{ .add = &.{3}, .remove = &.{} } }); try std.testing.expectEqualStrings( \\{"documents":[1,2],"method":"modify_tags","parameters":{"add_tags":[3],"remove_tags":[]}} , out.written());
out.clearRetainingCapacity(); try writeBulkEdit(&out.writer, &.{4}, .{ .set_correspondent = null }); try std.testing.expectEqualStrings( \\{"documents":[4],"method":"set_correspondent","parameters":{"correspondent":null}} , out.written());
out.clearRetainingCapacity(); try writeBulkEdit(&out.writer, &.{5}, .{ .modify_custom_fields = .{ .add = &.{.{ .field = 2, .value = .{ .string = "EUR1.00" } }}, .remove = &.{7}, } }); try std.testing.expectEqualStrings( \\{"documents":[5],"method":"modify_custom_fields","parameters":{"add_custom_fields":{"2":"EUR1.00"},"remove_custom_fields":[7]}} , out.written());}