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.
11 kB · 288 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-paperless`, a command-line client for Paperless-ngx, and the worked//! example of the library.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;
const paperless = @import("paperless");
const usage = \\usage: zig-paperless COMMAND [ARGS] \\ \\The server and the credential come from the environment: \\ PAPERLESS_URL https://paperless.example.com \\ PAPERLESS_TOKEN an API token (see `token`) \\ \\commands: \\ status the server's version, health and counts \\ list [QUERY] documents, newest first; QUERY is full-text \\ show ID one document \\ upload [-t TAG]... [--title TITLE] [--no-wait] FILE... \\ upload files, and wait for each to be consumed \\ download [--original] ID [PATH] \\ write a document's file to PATH, or to the \\ filename the server gives it \\ tags every tag \\ token USERNAME print the user's API token; the password is \\ read from PAPERLESS_PASSWORD \\;
pub fn main(init: std.process.Init) !u8 { const io = init.io; const gpa = init.gpa; const args = try init.minimal.args.toSlice(init.arena.allocator());
var stdout_buffer: [16 * 1024]u8 = undefined; var stdout_file: Io.File.Writer = .init(.stdout(), io, &stdout_buffer); const out = &stdout_file.interface; defer out.flush() catch {};
if (args.len < 2 or std.mem.eql(u8, args[1], "help") or std.mem.eql(u8, args[1], "--help")) { try out.writeAll(usage); return if (args.len < 2) 2 else 0; } const command = args[1]; const rest = args[2..];
const url = init.environ_map.get("PAPERLESS_URL") orelse { std.debug.print("zig-paperless: PAPERLESS_URL is not set\n", .{}); return 2; };
if (std.mem.eql(u8, command, "token")) { if (rest.len != 1) return fail(out, "token takes a username"); const password = init.environ_map.get("PAPERLESS_PASSWORD") orelse return fail(out, "PAPERLESS_PASSWORD is not set"); const token = try paperless.obtainToken(io, gpa, url, rest[0], password); defer gpa.free(token); try out.print("{s}\n", .{token}); return 0; }
const token = init.environ_map.get("PAPERLESS_TOKEN") orelse { std.debug.print("zig-paperless: PAPERLESS_TOKEN is not set\n", .{}); return 2; }; var client: paperless.Client = try .init(io, gpa, .{ .base_url = url, .auth = .{ .token = token } }); defer client.deinit();
const result = if (std.mem.eql(u8, command, "status")) status(&client, out) else if (std.mem.eql(u8, command, "list")) list(&client, out, rest) else if (std.mem.eql(u8, command, "show")) show(&client, out, rest) else if (std.mem.eql(u8, command, "upload")) upload(io, &client, out, rest) else if (std.mem.eql(u8, command, "download")) download(io, &client, out, rest) else if (std.mem.eql(u8, command, "tags")) tags(&client, out) else { try out.print("zig-paperless: unknown command \"{s}\"\n\n{s}", .{ command, usage }); return 2; };
result catch |err| { try out.flush(); std.debug.print("zig-paperless: {t}\n", .{err}); if (client.last_error) |e| std.debug.print("{s}\n", .{e.body}); return 1; }; return 0;}
fn fail(out: *Io.Writer, message: []const u8) !u8 { try out.flush(); std.debug.print("zig-paperless: {s}\n", .{message}); return 2;}
fn status(client: *paperless.Client, out: *Io.Writer) !void { const stats = try client.statistics(); defer stats.deinit(); try out.print("documents {d}\n", .{stats.value.documents_total}); if (stats.value.documents_inbox) |n| try out.print("in the inbox {d}\n", .{n}); try out.print("tags {d}\n", .{stats.value.tag_count}); try out.print("correspondents {d}\n", .{stats.value.correspondent_count}); try out.print("document types {d}\n", .{stats.value.document_type_count}); try out.print("highest ASN {d}\n", .{stats.value.current_asn});
// Only an administrator may ask. const health = client.status() catch |err| switch (err) { error.Forbidden => return, else => |e| return e, }; defer health.deinit(); try out.print("version {s}\n", .{health.value.pngx_version}); if (health.value.database) |db| try out.print("database {s} ({s})\n", .{ db.type, db.status }); if (health.value.tasks) |t| { try out.print("redis {s}\n", .{t.redis_status}); try out.print("celery {s}\n", .{t.celery_status}); try out.print("index {s}\n", .{t.index_status}); }}
fn list(client: *paperless.Client, out: *Io.Writer, args: []const [:0]const u8) !void { const page = try client.documents().list(.{ .query = if (args.len > 0) args[0] else null, .ordering = if (args.len > 0) null else "-created", .fields = "id,created,title", .page_size = 50, }); defer page.deinit(); for (page.value.results) |doc| { try out.print("{d:>6} {s:<10} {s}\n", .{ doc.id, doc.created orelse "", doc.title }); } if (page.value.count > page.value.results.len) { try out.print("({d} of {d})\n", .{ page.value.results.len, page.value.count }); }}
fn parseId(arg: []const u8) !paperless.Id { return std.fmt.parseInt(paperless.Id, arg, 10) catch error.InvalidId;}
fn show(client: *paperless.Client, out: *Io.Writer, args: []const [:0]const u8) !void { if (args.len != 1) return error.MissingId; const doc = try client.documents().get(try parseId(args[0]), .{}); defer doc.deinit(); const d = doc.value; try out.print("id {d}\n", .{d.id}); try out.print("title {s}\n", .{d.title}); if (d.created) |c| try out.print("created {s}\n", .{c}); if (d.added) |a| try out.print("added {s}\n", .{a}); if (d.archive_serial_number) |asn| try out.print("ASN {d}\n", .{asn}); if (d.original_file_name) |f| try out.print("original file {s}\n", .{f}); try out.print("type {s}\n", .{d.mime_type}); if (d.page_count) |n| try out.print("pages {d}\n", .{n}); if (d.correspondent) |id| { const c = try client.correspondents().get(id); defer c.deinit(); try out.print("correspondent {s}\n", .{c.value.name}); } if (d.document_type) |id| { const t = try client.documentTypes().get(id); defer t.deinit(); try out.print("document type {s}\n", .{t.value.name}); } for (d.tags) |id| { const t = try client.tags().get(id); defer t.deinit(); try out.print("tag {s}\n", .{t.value.name}); } for (d.notes) |note| try out.print("note {s}\n", .{note.note});}
fn upload(io: Io, client: *paperless.Client, out: *Io.Writer, args: []const [:0]const u8) !void { var tag_ids: [32]paperless.Id = undefined; var tag_count: usize = 0; var title: ?[]const u8 = null; var wait = true; var i: usize = 0; var files_start: ?usize = null; while (i < args.len) : (i += 1) { const arg = args[i]; if (std.mem.eql(u8, arg, "-t") or std.mem.eql(u8, arg, "--tag")) { i += 1; if (i == args.len or tag_count == tag_ids.len) return error.MissingTag; tag_ids[tag_count] = try parseId(args[i]); tag_count += 1; } else if (std.mem.eql(u8, arg, "--title")) { i += 1; if (i == args.len) return error.MissingTitle; title = args[i]; } else if (std.mem.eql(u8, arg, "--no-wait")) { wait = false; } else { files_start = i; break; } } const files = args[files_start orelse return error.MissingFile ..]; // Uploads that made no document, so that a script can tell. var failed: usize = 0;
for (files) |name| { var file = try Io.Dir.cwd().openFile(io, name, .{}); defer file.close(io); var buffer: [64 * 1024]u8 = undefined; var reader: Io.File.Reader = .init(file, io, &buffer); // Read as it is sent: the length goes out first, as Content-Length, // and the file is never held. const length = try file.length(io);
const task_id = try client.documents().upload(.{ .filename = std.fs.path.basename(name), .content = .{ .reader = .{ .reader = &reader.interface, .length = length } }, .title = title, .tags = tag_ids[0..tag_count], }); if (!wait) { try out.print("{s} {s}\n", .{ name, &task_id }); continue; } try out.print("{s} consuming...", .{name}); try out.flush(); const task = try client.tasks().wait(&task_id, .{}); defer task.deinit(); try out.print(" {f}\n", .{task.value.outcome()}); if (task.value.documentId() == null) failed += 1; } if (failed > 0) return error.UploadFailed;}
fn download(io: Io, client: *paperless.Client, out: *Io.Writer, args: []const [:0]const u8) !void { var original = false; var rest = args; if (rest.len > 0 and std.mem.eql(u8, rest[0], "--original")) { original = true; rest = rest[1..]; } if (rest.len < 1 or rest.len > 2) return error.MissingId; const id = try parseId(rest[0]);
// Into a file of its own, renamed into place only once it has all // arrived, so that a connection dropped part way leaves no half a // document under the name asked for. var partial_name: [64]u8 = undefined; const partial = try std.fmt.bufPrint(&partial_name, ".zig-paperless-{d}.partial", .{id}); const cwd = Io.Dir.cwd(); var file = try cwd.createFile(io, partial, .{}); var buffer: [64 * 1024]u8 = undefined; var writer: Io.File.Writer = .init(file, io, &buffer); var info = client.documents().download(id, &writer.interface, .{ .file = if (original) .original else .archive, }) catch |err| { file.close(io); cwd.deleteFile(io, partial) catch {}; return err; }; defer info.deinit(); try writer.interface.flush(); file.close(io);
const destination = if (rest.len == 2) rest[1] else std.fs.path.basename(info.filename orelse partial); try Io.Dir.rename(cwd, partial, cwd, destination, io); try out.print("{s} {d} bytes\n", .{ destination, writer.pos });}
fn tags(client: *paperless.Client, out: *Io.Writer) !void { var pager = client.tags().pages(.{ .page_size = 1000, .ordering = "name" }); while (try pager.next()) |page| { defer page.deinit(); for (page.value.results) |tag| { try out.print("{d:>6} {d:>6} {s}{s}\n", .{ tag.id, tag.document_count, tag.name, if (tag.is_inbox_tag) " (inbox)" else "", }); } }}