// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The server's background work: consuming an upload, training the //! classifier, emptying the trash. The one a client most often cares about //! is the first, since a document does not exist until its upload's task has //! succeeded. const std = @import("std"); const Io = std.Io; const Client = @import("Client.zig"); const models = @import("models.zig"); const query = @import("query.zig"); const resource = @import("resource.zig"); const Id = models.Id; const Task = models.Task; const Parsed = std.json.Parsed; const Tasks = @This(); client: *Client, pub const Query = struct { page: ?u32 = null, page_size: ?u32 = null, ordering: ?[]const u8 = null, task_id: ?[]const u8 = null, status: ?models.TaskStatus = null, task_type: ?models.TaskType = null, acknowledged: ?bool = null, /// Done one way or the other. is_complete: ?bool = null, extra: []const query.Param = &.{}, }; /// One page of tasks, newest first. pub fn list(self: Tasks, q: Query) Client.Error!Parsed(models.Page(Task)) { return self.client.getJson(models.Page(Task), "tasks/", q); } pub fn pages(self: Tasks, q: Query) resource.Pager(Task, Query) { return .{ .client = self.client, .path = "tasks/", .query = q }; } /// A task by its Celery identifier -- what an upload hands back -- or null /// if the server has no record of it yet. pub fn byTaskId(self: Tasks, task_id: []const u8) Client.Error!?Parsed(Task) { var page = try self.list(.{ .task_id = task_id }); if (page.value.results.len == 0) { page.deinit(); return null; } return .{ .arena = page.arena, .value = page.value.results[0] }; } pub fn get(self: Tasks, id: Id) Client.Error!Parsed(Task) { var buffer: [32]u8 = undefined; return self.client.getJson(Task, std.fmt.bufPrint(&buffer, "tasks/{d}/", .{id}) catch unreachable, .{}); } /// Marks tasks as seen, which takes them out of the web interface's list of /// things to look at. pub fn acknowledge(self: Tasks, ids: []const Id) Client.Error!void { return self.client.sendJsonIgnore(.post, "tasks/acknowledge/", .{}, .{ .tasks = ids }); } pub const WaitOptions = struct { /// How long to sleep between asking. interval: Io.Duration = .fromSeconds(1), /// How long to keep asking before `error.Timeout`. Consuming a large /// scan -- OCR on every page -- can take minutes. timeout: Io.Duration = .fromSeconds(600), }; pub const WaitError = Client.Error || Io.Cancelable || error{Timeout}; /// Asks after a task until it has finished, and hands it back. Finished is /// not succeeded: check `status`, and `errorMessage` when it failed -- an /// upload of a file Paperless cannot read fails, for one. (A file it already /// has does not: Paperless 3 consumes it again and lists the original in /// the new document's `duplicate_documents`.) /// /// A task the server does not know of yet is waited for too, since an /// upload's task is recorded a moment after the upload is answered. pub fn wait(self: Tasks, task_id: []const u8, options: WaitOptions) WaitError!Parsed(Task) { const io = self.client.http.io; const deadline = Io.Clock.awake.now(io).addDuration(options.timeout); while (true) { if (try self.byTaskId(task_id)) |task| { if (task.value.status.isDone()) return task; task.deinit(); } if (Io.Clock.awake.now(io).addDuration(options.interval).nanoseconds > deadline.nanoseconds) return error.Timeout; try io.sleep(options.interval, .awake); } }