Something went wrong. Try again.
Uploads documents to Paperless-ngx from the command line or from a Send to Paperless item in Nautilus.
Something went wrong. Try again.
11 kB · 286 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Sending one file to Paperless and waiting for it to be consumed: the//! part the command-line tool and the Nautilus extension share. The two//! differ only in how they tell somebody what is happening, which is the//! `Reporter` they pass in.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const paperless = @import("paperless");const supported = @import("supported.zig");
pub const Options = struct { /// Null lets Paperless make the title from the file's name. title: ?[]const u8 = null, tags: []const paperless.Id = &.{}, /// Whether to wait for the document to be consumed, or return as soon /// as Paperless has queued it. wait: bool = true, wait_options: paperless.Tasks.WaitOptions = .{},};
pub const Outcome = union(enum) { /// Consumed, and this is the new document. document: paperless.Id, /// Refused, because Paperless already has this file as that document. duplicate: struct { of: paperless.Id, in_trash: bool }, /// Queued, and not waited for. queued: paperless.Documents.TaskId, /// Why not, fit to show a person. failed: []const u8,
/// Whether the file is in Paperless now, newly or already. pub fn succeeded(o: Outcome) bool { return o != .failed and o != .duplicate; }
/// The document the file is in Paperless as, new or existing. pub fn documentId(o: Outcome) ?paperless.Id { return switch (o) { .document => |id| id, .duplicate => |d| d.of, .queued, .failed => null, }; }
/// One line a person can read. pub fn format(o: Outcome, w: *Io.Writer) Io.Writer.Error!void { switch (o) { .document => |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 "", }), .queued => |task_id| try w.print("task {s}", .{&task_id}), .failed => |reason| try w.print("failed: {s}", .{reason}), } }};
pub const Stage = enum { /// The file is being sent. uploading, /// Paperless has it and is consuming it. consuming,};
/// Told of each stage as a file reaches it, and, while it is being sent, of/// how much of it has gone.pub const Reporter = struct { context: *anyopaque, stage: *const fn (context: *anyopaque, path: []const u8, stage: Stage) void, sent: ?*const fn (context: *anyopaque, path: []const u8, sent: u64, total: u64) void = null,
pub const none: Reporter = .{ .context = undefined, .stage = struct { fn f(_: *anyopaque, _: []const u8, _: Stage) void {} }.f, };};
/// Uploads the file at `path` and, if asked, waits for it to be consumed./// Every way it can go wrong becomes `.failed`, with the reason allocated in/// `arena`, so the caller can report the outcome and go on to the next.pub fn uploadFile( io: Io, client: *paperless.Client, arena: Allocator, path: []const u8, options: Options, reporter: Reporter,) Allocator.Error!Outcome { const name = std.fs.path.basename(path); const media_type = supported.forFilename(name) orelse return .{ .failed = try std.fmt.allocPrint(arena, "Paperless does not consume files of this kind", .{}) };
var file = Io.Dir.cwd().openFile(io, path, .{}) catch |err| return .{ .failed = try std.fmt.allocPrint(arena, "cannot open: {t}", .{err}) }; defer file.close(io); const stat = file.stat(io) catch |err| return .{ .failed = try std.fmt.allocPrint(arena, "cannot stat: {t}", .{err}) }; if (stat.kind != .file) return .{ .failed = try std.fmt.allocPrint(arena, "not a regular file", .{}) };
reporter.stage(reporter.context, path, .uploading);
var buffer: [64 * 1024]u8 = undefined; var reader: Io.File.Reader = .init(file, io, &buffer); var counting: Counting = .init(&reader.interface, stat.size, path, reporter); const task_id = client.documents().upload(.{ .filename = name, // Read as it is sent: the length goes out first, as Content-Length, // and the file is never held. .content = .{ .reader = .{ .reader = &counting.interface, .length = stat.size } }, .content_type = media_type, .title = options.title, .tags = options.tags, }) catch |err| return .{ .failed = try describe(arena, client, err) };
if (!options.wait) return .{ .queued = task_id };
reporter.stage(reporter.context, path, .consuming);
const task = client.tasks().wait(&task_id, options.wait_options) catch |err| switch (err) { error.Timeout => return .{ .failed = try std.fmt.allocPrint( arena, "still being consumed after {d} seconds (task {s})", .{ @divTrunc(options.wait_options.timeout.nanoseconds, std.time.ns_per_s), &task_id }, ) }, else => |e| return .{ .failed = try describe(arena, client, e) }, }; defer task.deinit();
return fromTask(arena, task.value);}
/// Passes a reader through, telling the reporter how much has been read./// It holds no buffer of its own: zig-paperless streams the content/// straight into the request, through `stream`, which is all this needs.const Counting = struct { inner: *Io.Reader, total: u64, path: []const u8, reporter: Reporter, sent: u64 = 0, interface: Io.Reader = .{ .vtable = &.{ .stream = stream }, .buffer = &.{}, .seek = 0, .end = 0, },
fn init(inner: *Io.Reader, total: u64, path: []const u8, reporter: Reporter) Counting { return .{ .inner = inner, .total = total, .path = path, .reporter = reporter }; }
fn stream(r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { const self: *Counting = @alignCast(@fieldParentPtr("interface", r)); const n = try self.inner.stream(w, limit); self.sent += n; if (self.reporter.sent) |sent| sent(self.reporter.context, self.path, self.sent, self.total); return n; }};
test Counting { const Seen = struct { calls: usize = 0, last: u64 = 0, fn stage(_: *anyopaque, _: []const u8, _: Stage) void {} fn sent(context: *anyopaque, _: []const u8, n: u64, total: u64) void { const self: *@This() = @ptrCast(@alignCast(context)); self.calls += 1; self.last = n; std.debug.assert(total == 11); } }; var seen: Seen = .{}; var inner: Io.Reader = .fixed("hello world"); var counting: Counting = .init(&inner, 11, "x", .{ .context = &seen, .stage = Seen.stage, .sent = Seen.sent }); var out: [32]u8 = undefined; var w: Io.Writer = .fixed(&out); try testing.expectEqual(11, try counting.interface.streamRemaining(&w)); try testing.expectEqualStrings("hello world", w.buffered()); try testing.expectEqual(11, seen.last); try testing.expect(seen.calls >= 1);}
/// How a finished upload task ended.fn fromTask(arena: Allocator, task: paperless.Task) Allocator.Error!Outcome { return switch (task.outcome()) { .created => |id| .{ .document = id }, .duplicate => |d| .{ .duplicate = .{ .of = d.of, .in_trash = d.in_trash } }, .stopped => |reason| .{ .failed = try std.fmt.allocPrint(arena, "stopped: {s}", .{reason}) }, .failed => |f| .{ .failed = try arena.dupe(u8, f.message orelse f.error_type orelse @tagName(task.status)) }, .pending => .{ .failed = try std.fmt.allocPrint(arena, "not finished", .{}) }, .other => .{ .failed = try std.fmt.allocPrint(arena, "finished ({t}), but Paperless did not say which document it became", .{task.status}) }, };}
/// A request error, with what the server said about it when it said/// something.fn describe(arena: Allocator, client: *paperless.Client, err: anyerror) Allocator.Error![]const u8 { if (client.last_error) |e| { const body = std.mem.trim(u8, e.body, " \t\r\n"); if (body.len > 0) { const max = 500; return std.fmt.allocPrint(arena, "{t}: {s}{s}", .{ err, body[0..@min(body.len, max)], if (body.len > max) "…" else "", }); } } return std.fmt.allocPrint(arena, "{t}", .{err});}
const testing = std.testing;
fn testTask(arena: Allocator, json: []const u8) !paperless.Task { return std.json.parseFromSliceLeaky(paperless.Task, arena, json, .{ .ignore_unknown_fields = true });}
fn expectOutcome(expected: []const u8, json: []const u8) !void { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const outcome = try fromTask(a, try testTask(a, json)); try testing.expectEqualStrings(expected, try std.fmt.allocPrint(a, "{f}", .{outcome}));}
test "how a task ended" { try expectOutcome("document 9", \\{"id": 1, "status": "success", "result_data": {"document_id": 9}, "related_document_ids": [9]} ); // What Paperless-ngx 3.2.1 sent for a refused duplicate. try expectOutcome("already in Paperless as document 3011", \\{"id": 13046, "status": "failure", \\ "result_data": {"duplicate_of": 3011, "duplicate_in_trash": false}, \\ "related_document_ids": [3011]} ); try expectOutcome("already in Paperless as document 7, which is in the trash", \\{"id": 1, "status": "failure", "result_data": {"duplicate_of": 7, "duplicate_in_trash": true}} ); try expectOutcome("failed: cannot parse", \\{"id": 1, "status": "failure", "result_data": {"error_type": "x", "error_message": "cannot parse"}} ); try expectOutcome("failed: stopped: a workflow said no", \\{"id": 1, "status": "success", "result_data": {"reason": "a workflow said no"}} );}
test "a duplicate names the document it duplicates" { const o: Outcome = .{ .duplicate = .{ .of = 3011, .in_trash = false } }; try testing.expect(!o.succeeded()); try testing.expectEqual(3011, o.documentId().?);}
test "a file of a kind Paperless does not consume is refused before anything is sent" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var client: paperless.Client = try .init(testing.io, testing.allocator, .{ // Nothing listens here, and nothing should be sent. .base_url = "http://127.0.0.1:9", }); defer client.deinit();
const outcome = try uploadFile(testing.io, &client, arena.allocator(), "setup.exe", .{}, .none); try testing.expect(outcome == .failed);}
test "a file that is not there fails without sending" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var client: paperless.Client = try .init(testing.io, testing.allocator, .{ .base_url = "http://127.0.0.1:9", }); defer client.deinit();
const outcome = try uploadFile(testing.io, &client, arena.allocator(), "/nonexistent/scan.pdf", .{}, .none); try testing.expect(outcome == .failed); try testing.expect(std.mem.startsWith(u8, outcome.failed, "cannot open"));}