// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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()); }