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.
9.7 kB · 250 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251// SPDX-FileCopyrightText: © 2025 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `paperless-upload`: sends documents to Paperless-ngx and waits for each//! to be consumed. The server and the API token come from the configuration//! file described in `Config.zig`.
const std = @import("std");const Io = std.Io;const paperless = @import("paperless");const core = @import("core");const Config = core.Config;const upload = core.upload;
const usage = \\usage: paperless-upload [OPTION]... FILE... \\ \\Uploads each FILE to Paperless-ngx and waits for it to be consumed. \\ \\ -c, --config PATH the configuration file (default: \\ paperless-upload/config.zon in $XDG_CONFIG_HOME, \\ else in ~/.config) \\ -t, --tag ID add the tag with this ID; may be repeated \\ --title TITLE the document's title (only with a single FILE) \\ --no-wait print each task ID instead of waiting \\ -h, --help show this and exit \\ \\The configuration file is ZON: \\ \\ .{ \\ .url = "https://paperless.example.com", \\ .token = "<API token from your Paperless profile>", \\ } \\ \\or, to read the token from a file of its own (relative to the \\configuration file's directory unless absolute), .token_file = "PATH" \\in place of .token. \\ \\Exit status is 0 if every file was consumed (or queued, with --no-wait), \\1 if any was not (a duplicate Paperless refused included), and 2 for a \\usage or configuration error. \\;
const Arguments = struct { config: ?[]const u8 = null, tags: std.ArrayList(paperless.Id) = .empty, title: ?[]const u8 = null, wait: bool = true, help: bool = false, files: std.ArrayList([]const u8) = .empty,};
const ParseError = error{ Usage, OutOfMemory };
/// Parses everything after the program name. On `error.Usage`, `message`/// says why.fn parseArguments(arena: std.mem.Allocator, args: []const []const u8, message: *[]const u8) ParseError!Arguments { var result: Arguments = .{}; var i: usize = 0; var options_done = false; while (i < args.len) : (i += 1) { const arg = args[i]; if (options_done or arg.len < 2 or arg[0] != '-') { try result.files.append(arena, arg); } else if (std.mem.eql(u8, arg, "--")) { options_done = true; } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { result.help = true; } else if (std.mem.eql(u8, arg, "--no-wait")) { result.wait = false; } else if (std.mem.eql(u8, arg, "-c") or std.mem.eql(u8, arg, "--config")) { i += 1; if (i == args.len) return usageError(message, "--config needs a path"); result.config = args[i]; } else if (std.mem.eql(u8, arg, "--title")) { i += 1; if (i == args.len) return usageError(message, "--title needs a title"); result.title = args[i]; } else if (std.mem.eql(u8, arg, "-t") or std.mem.eql(u8, arg, "--tag")) { i += 1; if (i == args.len) return usageError(message, "--tag needs a tag ID"); const id = std.fmt.parseInt(paperless.Id, args[i], 10) catch return usageError(message, "a tag ID is a number"); try result.tags.append(arena, id); } else { message.* = try std.fmt.allocPrint(arena, "unknown option {s}", .{arg}); return error.Usage; } } if (result.help) return result; if (result.files.items.len == 0) return usageError(message, "no files named"); if (result.title != null and result.files.items.len > 1) return usageError(message, "--title can only be given with a single file"); return result;}
fn usageError(message: *[]const u8, text: []const u8) error{Usage} { message.* = text; return error.Usage;}
pub fn main(init: std.process.Init) !u8 { const io = init.io; const gpa = init.gpa; const arena = init.arena.allocator();
var stdout_buffer: [4096]u8 = undefined; var stdout_file: Io.File.Writer = .init(.stdout(), io, &stdout_buffer); const out = &stdout_file.interface; defer out.flush() catch {};
var stderr_buffer: [4096]u8 = undefined; var stderr_file: Io.File.Writer = .init(.stderr(), io, &stderr_buffer); const err_out = &stderr_file.interface; defer err_out.flush() catch {};
const argv = try init.minimal.args.toSlice(arena); var message: []const u8 = ""; const args = parseArguments(arena, argv[1..], &message) catch |err| switch (err) { error.Usage => { try err_out.print("paperless-upload: {s}\n\n{s}", .{ message, usage }); return 2; }, error.OutOfMemory => |e| return e, }; if (args.help) { try out.writeAll(usage); return 0; }
const config = Config.load(io, gpa, arena, .{ .explicit = args.config, .environ = init.environ_map, }, err_out) catch |err| switch (err) { error.OutOfMemory, error.Canceled => |e| return e, else => return 2, }; try err_out.flush();
var client: paperless.Client = try .init(io, gpa, .{ .base_url = config.url, .auth = .{ .token = config.token }, }); defer client.deinit();
// Drawn on stderr, and only when it is a terminal: one line for the // files, one for the file being sent, and one for what is happening to // it -- with how much of it has gone, while it is being sent. const root = std.Progress.start(io, .{ .root_name = "Sending to Paperless", .estimated_total_items = args.files.items.len, }); defer root.end();
var failed = false; for (args.files.items) |path| { var file_arena: std.heap.ArenaAllocator = .init(gpa); defer file_arena.deinit();
var progress: Progress = .{ .file = root.start(std.fs.path.basename(path), if (args.wait) 2 else 1) }; const outcome = try upload.uploadFile(io, &client, file_arena.allocator(), path, .{ .title = args.title, .tags = args.tags.items, .wait = args.wait, }, progress.reporter()); progress.end(); if (!outcome.succeeded()) failed = true;
// Written with the terminal held, which takes the progress display // off it first, so that the two do not draw over each other. var clear_buffer: [64]u8 = undefined; _ = try io.lockStderr(&clear_buffer, null); defer io.unlockStderr(); try out.print("{s} {f}\n", .{ path, outcome }); try out.flush(); } return if (failed) 1 else 0;}
/// Feeds a file's progress to `std.Progress`: a node for the file, and/// under it one for the stage it is at.const Progress = struct { file: std.Progress.Node, stage_node: std.Progress.Node = .none,
fn reporter(self: *Progress) upload.Reporter { return .{ .context = self, .stage = stage, .sent = sent }; }
fn stage(context: *anyopaque, _: []const u8, s: upload.Stage) void { const self: *Progress = @ptrCast(@alignCast(context)); self.stage_node.end(); self.stage_node = self.file.start(switch (s) { .uploading => "uploading (KiB)", .consuming => "waiting for Paperless to consume it", }, 0); }
fn sent(context: *anyopaque, _: []const u8, bytes: u64, total: u64) void { const self: *Progress = @ptrCast(@alignCast(context)); self.stage_node.setEstimatedTotalItems(kib(total)); self.stage_node.setCompletedItems(kib(bytes)); }
fn end(self: *Progress) void { self.stage_node.end(); self.file.end(); }
fn kib(bytes: u64) usize { return std.math.cast(usize, std.math.divCeil(u64, bytes, 1024) catch 0) orelse std.math.maxInt(usize); }};
const testing = std.testing;
test "parse files and options" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var message: []const u8 = ""; const args = try parseArguments(arena.allocator(), &.{ "-t", "4", "--tag", "7", "--no-wait", "-c", "/x.zon", "a.pdf", "--", "-b.pdf" }, &message); try testing.expectEqualSlices(paperless.Id, &.{ 4, 7 }, args.tags.items); try testing.expect(!args.wait); try testing.expectEqualStrings("/x.zon", args.config.?); try testing.expectEqual(2, args.files.items.len); try testing.expectEqualStrings("a.pdf", args.files.items[0]); try testing.expectEqualStrings("-b.pdf", args.files.items[1]);}
test "parse refuses what it should" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var message: []const u8 = ""; const a = arena.allocator(); try testing.expectError(error.Usage, parseArguments(a, &.{}, &message)); try testing.expectError(error.Usage, parseArguments(a, &.{ "--title", "x", "a.pdf", "b.pdf" }, &message)); try testing.expectError(error.Usage, parseArguments(a, &.{ "-t", "four", "a.pdf" }, &message)); try testing.expectError(error.Usage, parseArguments(a, &.{ "a.pdf", "-t" }, &message)); try testing.expectError(error.Usage, parseArguments(a, &.{ "--bogus", "a.pdf" }, &message)); try testing.expectEqualStrings("unknown option --bogus", message);}
test "help needs no files" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var message: []const u8 = ""; const args = try parseArguments(arena.allocator(), &.{"--help"}, &message); try testing.expect(args.help);}