// SPDX-FileCopyrightText: © 2025 Jeffrey C. Ollie // 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 = "", \\ } \\ \\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); }