//! The prototypes' mailbox: the sample threads, changed in memory by what //! the user does (archive, label, star, read). Nothing is saved. const std = @import("std"); const sample = @import("sample.zig"); const ascii = std.ascii; const sort = std.sort; const testing = std.testing; const Store = @This(); pub const Label = sample.Label; pub const Thread = sample.Thread; /// Thread ids are indices into `threads`. pub const Id = u32; pub const capacity = sample.threads.len; /// Which threads a view shows. // ast-grep-ignore: public-struct-field-default -- an options bag for query and count. pub const Filter = struct { /// Null is All Mail: everything but spam and trash. label: ?Label = .inbox, /// Matches sender, subject, or snippet, ignoring case; empty matches all. text: []const u8 = "", }; threads: [capacity]Thread, pub fn init() Store { return .{ .threads = sample.threads }; } /// The ids of the threads `filter` selects, newest first, written into `out`. pub fn query(self: *const Store, filter: Filter, out: *[capacity]Id) []Id { var n: usize = 0; for (self.threads, 0..) |thread, i| { if (!matches(thread, filter)) continue; out[n] = @intCast(i); n += 1; } const ids = out[0..n]; sort.insertion(Id, ids, self, newerFirst); return ids; } /// How many threads `filter` selects, and how many of those are unread. pub fn count(self: *const Store, filter: Filter) struct { total: u32, unread: u32 } { var total: u32 = 0; var unread: u32 = 0; for (self.threads) |thread| { if (!matches(thread, filter)) continue; total += 1; if (thread.unread) unread += 1; } return .{ .total = total, .unread = unread }; } pub fn get(self: *const Store, id: Id) *const Thread { return &self.threads[id]; } pub fn has(self: *const Store, id: Id, label: Label) bool { return self.threads[id].labels.contains(label); } /// Archiving takes a thread out of the inbox and nothing else. pub fn archive(self: *Store, id: Id) void { self.threads[id].labels.remove(.inbox); } /// Trash replaces every label, as Gmail does. pub fn trash(self: *Store, id: Id) void { self.threads[id].labels = .initOne(.trash); } pub fn toggleLabel(self: *Store, id: Id, label: Label) void { self.threads[id].labels.toggle(label); } pub fn setUnread(self: *Store, id: Id, unread: bool) void { self.threads[id].unread = unread; } fn matches(thread: Thread, filter: Filter) bool { const in_label = if (filter.label) |label| thread.labels.contains(label) else !thread.labels.contains(.spam) and !thread.labels.contains(.trash); if (!in_label) return false; if (filter.text.len == 0) return true; return ascii.findIgnoreCase(thread.from, filter.text) != null or ascii.findIgnoreCase(thread.subject, filter.text) != null or ascii.findIgnoreCase(thread.snippet, filter.text) != null; } fn newerFirst(self: *const Store, a: Id, b: Id) bool { return self.threads[a].minutes_ago < self.threads[b].minutes_ago; } test "query filters by label and text, newest first" { var store: Store = .init(); var buf: [capacity]Id = undefined; const hiring = store.query(.{ .label = .hiring }, &buf); try testing.expect(hiring.len >= 3); for (hiring[1..], hiring[0 .. hiring.len - 1]) |later, earlier| { try testing.expect(store.get(earlier).minutes_ago <= store.get(later).minutes_ago); try testing.expect(store.has(later, .hiring)); } const lisbon = store.query(.{ .label = null, .text = "LISBON" }, &buf); try testing.expect(lisbon.len >= 3); } test "archive leaves other labels; All Mail hides spam and trash" { var store: Store = .init(); var buf: [capacity]Id = undefined; const first = store.query(.{}, &buf)[0]; store.archive(first); try testing.expect(!store.has(first, .inbox)); try testing.expect(store.has(first, .work)); const all_before = store.count(.{ .label = null }).total; store.trash(first); try testing.expectEqual(all_before - 1, store.count(.{ .label = null }).total); }