// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The objects that are only ever listed, read, created, changed and //! deleted: tags, correspondents, document types, storage paths, custom //! fields, saved views, share links, users and groups. //! //! Each is the same five calls on a different path with a different model, //! so each is one instance of `Resource`. Documents and tasks do more than //! that and have files of their own. const std = @import("std"); const Allocator = std.mem.Allocator; const Client = @import("Client.zig"); const models = @import("models.zig"); const query = @import("query.zig"); const Id = models.Id; const Parsed = std.json.Parsed; /// The filters every one of these accepts, or nearly: by name, by /// identifier, and the page. pub const NamedQuery = struct { page: ?u32 = null, /// Up to 100000. Every list is paginated, and one very large page is /// the simplest way to have all of something. page_size: ?u32 = null, /// A field name, `-` before it to reverse: `name`, `-document_count`. ordering: ?[]const u8 = null, id__in: ?[]const Id = null, name__icontains: ?[]const u8 = null, name__iexact: ?[]const u8 = null, name__istartswith: ?[]const u8 = null, name__iendswith: ?[]const u8 = null, /// `permissions` in each result instead of `user_can_change`. full_perms: ?bool = null, /// Any other filter, as `name=value`. extra: []const query.Param = &.{}, }; pub const TagQuery = struct { page: ?u32 = null, page_size: ?u32 = null, ordering: ?[]const u8 = null, id__in: ?[]const Id = null, name__icontains: ?[]const u8 = null, name__iexact: ?[]const u8 = null, name__istartswith: ?[]const u8 = null, name__iendswith: ?[]const u8 = null, /// Only the tags with no parent. is_root: ?bool = null, full_perms: ?bool = null, extra: []const query.Param = &.{}, }; pub const PageQuery = struct { page: ?u32 = null, page_size: ?u32 = null, ordering: ?[]const u8 = null, extra: []const query.Param = &.{}, }; pub const Tags = Resource(models.Tag, models.TagFields, TagQuery, "tags/"); pub const Correspondents = Resource(models.Correspondent, models.CorrespondentFields, NamedQuery, "correspondents/"); pub const DocumentTypes = Resource(models.DocumentType, models.DocumentTypeFields, NamedQuery, "document_types/"); pub const StoragePaths = Resource(models.StoragePath, models.StoragePathFields, NamedQuery, "storage_paths/"); pub const CustomFields = Resource(models.CustomField, models.CustomFieldFields, NamedQuery, "custom_fields/"); pub const SavedViews = Resource(models.SavedView, models.SavedViewFields, PageQuery, "saved_views/"); pub const ShareLinks = Resource(models.ShareLink, models.ShareLinkFields, PageQuery, "share_links/"); pub const Users = Resource(models.User, models.UserFields, PageQuery, "users/"); pub const Groups = Resource(models.Group, models.GroupFields, PageQuery, "groups/"); /// List, get, create, update and delete, for one kind of object. pub fn Resource( comptime Model: type, comptime Fields: type, comptime Query: type, comptime path: []const u8, ) type { return struct { client: *Client, const Self = @This(); pub const Object = Model; pub const ObjectFields = Fields; pub const ListQuery = Query; /// One page. pub fn list(self: Self, q: Query) Client.Error!Parsed(models.Page(Model)) { return self.client.getJson(models.Page(Model), path, q); } /// Every page, one at a time. See `Pager`. pub fn pages(self: Self, q: Query) Pager(Model, Query) { return .{ .client = self.client, .path = path, .query = q }; } pub fn get(self: Self, id: Id) Client.Error!Parsed(Model) { var buffer: [path.len + 24]u8 = undefined; return self.client.getJson(Model, objectPath(&buffer, id), .{}); } /// The first object whose name is exactly `name`, ignoring case, or /// null. Names are unique per owner, so with several owners there /// may be more than one; this is the one the server lists first. pub fn findByName(self: Self, name: []const u8) Client.Error!?Parsed(Model) { if (!@hasField(Query, "name__iexact")) @compileError(path ++ " has no name filter"); var page = try self.list(.{ .name__iexact = name, .page_size = 1 }); if (page.value.results.len == 0) { page.deinit(); return null; } return .{ .arena = page.arena, .value = page.value.results[0] }; } pub fn create(self: Self, fields: Fields) Client.Error!Parsed(Model) { return self.client.sendJson(Model, .post, path, .{}, fields); } /// Changes the fields that are set in `fields` and leaves the rest /// alone (a `PATCH`). pub fn update(self: Self, id: Id, fields: Fields) Client.Error!Parsed(Model) { var buffer: [path.len + 24]u8 = undefined; return self.client.sendJson(Model, .patch, objectPath(&buffer, id), .{}, fields); } pub fn delete(self: Self, id: Id) Client.Error!void { var buffer: [path.len + 24]u8 = undefined; return self.client.sendJsonIgnore(.delete, objectPath(&buffer, id), .{}, null); } fn objectPath(buffer: []u8, id: Id) []const u8 { return std.fmt.bufPrint(buffer, path ++ "{d}/", .{id}) catch unreachable; } }; } /// Walks a list a page at a time. Each page is its own `Parsed`, freed by /// the caller, so that walking ten thousand documents holds one page of them /// at once rather than all of them. /// /// ```zig /// var pager = client.tags().pages(.{}); /// while (try pager.next()) |page| { /// defer page.deinit(); /// for (page.value.results) |tag| ... /// } /// ``` /// /// Pages are asked for by number rather than by following the `next` link /// the server sends, because that link is built from the request as the /// server saw it, and behind a proxy that can be a scheme, host or port the /// client cannot reach. A list that changes during the walk can repeat or /// skip an object at a page boundary, as it would in the web interface. pub fn Pager(comptime Model: type, comptime Query: type) type { return struct { client: *Client, path: []const u8, query: Query, page: u32 = 1, done: bool = false, pub fn next(self: *@This()) Client.Error!?Parsed(models.Page(Model)) { if (self.done) return null; var q = self.query; q.page = self.page; const result = try self.client.getJson(models.Page(Model), self.path, q); self.page += 1; if (result.value.next == null) self.done = true; return result; } }; }