diff --git a/src/core/hashtag.zig b/src/core/hashtag.zig new file mode 100644 index 0000000..5a50a3e --- /dev/null +++ b/src/core/hashtag.zig @@ -0,0 +1,61 @@ +//! Inline hashtag extraction (read-side only). +//! +//! Tags live in message text ("我现在在用 #BlueSky"), never in record +//! fields. This module is the extraction half of the AppView contract: +//! matching/case-handling is a rendering concern and lives with the +//! frontend; here we only pull candidate tag strings out of raw text. +//! +//! Syntax: '#' followed by one or more tag chars. A tag char is any byte +//! >= 0x80 (unicode continues) or ASCII alphanumeric / '_' / '-'. +//! Everything else (whitespace, ASCII punctuation, another '#') ends the +//! tag. + +const std = @import("std"); + +fn isTagChar(c: u8) bool { + return c >= 0x80 or std.ascii.isAlphanumeric(c) or c == '_' or c == '-'; +} + +/// Extract all hashtags from text, in order of appearance, duped into +/// `allocator`. Empty when none. +pub fn extract(allocator: std.mem.Allocator, text: []const u8) ![]const []const u8 { + var out: std.ArrayList([]const u8) = .empty; + var i: usize = 0; + while (i < text.len) : (i += 1) { + if (text[i] != '#') continue; + const start = i + 1; + var end = start; + while (end < text.len and isTagChar(text[end])) : (end += 1) {} + if (end == start) continue; // bare '#' + try out.append(allocator, try allocator.dupe(u8, text[start..end])); + i = end - 1; + } + return out.items; +} + +/// Does text carry hashtag `tag`? Exact match - normalization is the +/// frontend's job. +pub fn contains(allocator: std.mem.Allocator, text: []const u8, tag: []const u8) !bool { + const tags = try extract(allocator, text); + for (tags) |t| { + if (std.mem.eql(u8, t, tag)) return true; + } + return false; +} + +test "extract pulls unicode and ascii tags" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const tags = try extract(allocator, "我现在在用 #BlueSky 这个客户端。#定课 第3周,聊聊#week-3。# 不是标签 #x,y"); + try std.testing.expectEqual(@as(usize, 4), tags.len); + try std.testing.expectEqualStrings("BlueSky", tags[0]); + try std.testing.expectEqualStrings("定课", tags[1]); + try std.testing.expectEqualStrings("week-3", tags[2]); + try std.testing.expectEqualStrings("x", tags[3]); + + try std.testing.expect(try contains(allocator, "见 #BlueSky 上", "BlueSky")); + try std.testing.expect(!try contains(allocator, "见 #BlueSky 上", "bluesky")); // exact match + try std.testing.expect(!try contains(allocator, "no tags here", "x")); +} diff --git a/src/core/root.zig b/src/core/root.zig index 0b09e4f..44bb0c1 100644 --- a/src/core/root.zig +++ b/src/core/root.zig @@ -4,6 +4,7 @@ //! thin shells over these modules. pub const accounts = @import("accounts.zig"); +pub const hashtag = @import("hashtag.zig"); pub const loopback = @import("loopback.zig"); pub const oauth = @import("oauth.zig"); pub const pds = @import("pds.zig"); diff --git a/src/core/spaces.zig b/src/core/spaces.zig index 20f5f50..2ce79be 100644 --- a/src/core/spaces.zig +++ b/src/core/spaces.zig @@ -109,11 +109,10 @@ pub fn createThread( account: *accounts.Account, space: []const u8, title: []const u8, - tags: []const []const u8, ) ![]const u8 { var time_buf: [32]u8 = undefined; const created = post.rfc3339Now(&time_buf, io); - const record = try thread.recordJson(allocator, title, tags, created); + const record = try thread.recordJson(allocator, title, created); const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f},\"repo\":{f},\"collection\":{f},\"record\":{s}}}", .{ std.json.fmt(space, .{}), std.json.fmt(account.did, .{}), @@ -157,10 +156,10 @@ pub fn createPost( return allocator.dupe(u8, uri); } -/// Move one of the caller's own posts to a different thread (space.putRecord -/// with an updated `thread` field; text/createdAt preserved). post_uri may be -/// the canonical repo AT-URI or the space-qualified form - the rkey is what -/// matters. Owner/moderator moves are S008 (needs a zds endpoint). +/// Move a post to a different thread (space.putRecord with an updated +/// `thread` field; text/createdAt preserved) and leave a trace message in +/// the old thread so it isn't silently recontextualized. Own posts always; +/// any post when the caller is the space authority (zds override). pub fn movePost( allocator: std.mem.Allocator, io: std.Io, @@ -170,27 +169,54 @@ pub fn movePost( post_uri: []const u8, new_thread: []const u8, ) !void { + const repo = thread.repoFromUri(space, post_uri) orelse return error.BadPostUri; const rkey = thread.rkeyFromUri(post_uri) orelse return error.BadPostUri; - // Fetch the current record (space credential read) so text/createdAt - // survive the rewrite. - const delegation = try delegationToken(allocator, io, store, account, space); - const credential = try spaceCredential(allocator, io, account, space, delegation); - const query = try std.fmt.allocPrint(allocator, "space={s}&repo={s}&collection={s}&rkey={s}", .{ - try pds.percentEncode(allocator, space), - try pds.percentEncode(allocator, account.did), - try pds.percentEncode(allocator, post.collection), - try pds.percentEncode(allocator, rkey), - }); - const get_body = try bearerRequest(allocator, io, account.pds, .GET, "com.atproto.space.getRecord", query, null, credential); - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, get_body, .{}); - defer parsed.deinit(); - const value = zat.json.getPath(parsed.value, "value") orelse return error.MissingRecord; + const current = try spaceGetRecord(allocator, io, store, account, space, repo, post.collection, rkey); + defer current.deinit(); + const value = zat.json.getPath(current.value, "value") orelse return error.MissingRecord; + const old_thread = zat.json.getString(value, "thread") orelse return error.MissingRecord; const text = zat.json.getString(value, "text") orelse return error.MissingRecord; const created_at = zat.json.getString(value, "createdAt") orelse return error.MissingRecord; + if (std.mem.eql(u8, old_thread, new_thread)) return; + const record = try post.recordJson(allocator, new_thread, text, created_at); const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f},\"repo\":{f},\"collection\":{f},\"rkey\":{f},\"record\":{s}}}", .{ std.json.fmt(space, .{}), - std.json.fmt(account.did, .{}), + std.json.fmt(repo, .{}), + std.json.fmt(post.collection, .{}), + std.json.fmt(rkey, .{}), + record, + }); + _ = try pds.authedProcedure(allocator, io, store, account, "com.atproto.space.putRecord", body); + + // trace in the old thread, naming the target when we can resolve it + const title = threadTitle(allocator, io, store, account, space, new_thread) orelse new_thread; + const trace_text = try std.fmt.allocPrint(allocator, "↦ 一条消息被移动到「{s}」", .{title}); + _ = try createPost(allocator, io, store, account, space, old_thread, trace_text); +} + +/// Edit a post's text in place (thread/createdAt preserved). Own posts +/// always; any post when the caller is the space authority. +pub fn editPost( + allocator: std.mem.Allocator, + io: std.Io, + store: *accounts.Store, + account: *accounts.Account, + space: []const u8, + post_uri: []const u8, + new_text: []const u8, +) !void { + const repo = thread.repoFromUri(space, post_uri) orelse return error.BadPostUri; + const rkey = thread.rkeyFromUri(post_uri) orelse return error.BadPostUri; + const current = try spaceGetRecord(allocator, io, store, account, space, repo, post.collection, rkey); + defer current.deinit(); + const value = zat.json.getPath(current.value, "value") orelse return error.MissingRecord; + const thread_uri = zat.json.getString(value, "thread") orelse return error.MissingRecord; + const created_at = zat.json.getString(value, "createdAt") orelse return error.MissingRecord; + const record = try post.recordJson(allocator, thread_uri, new_text, created_at); + const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f},\"repo\":{f},\"collection\":{f},\"rkey\":{f},\"record\":{s}}}", .{ + std.json.fmt(space, .{}), + std.json.fmt(repo, .{}), std.json.fmt(post.collection, .{}), std.json.fmt(rkey, .{}), record, @@ -198,6 +224,69 @@ pub fn movePost( _ = try pds.authedProcedure(allocator, io, store, account, "com.atproto.space.putRecord", body); } +/// Delete a post. Own posts always; any post when the caller is the space +/// authority. +pub fn deletePost( + allocator: std.mem.Allocator, + io: std.Io, + store: *accounts.Store, + account: *accounts.Account, + space: []const u8, + post_uri: []const u8, +) !void { + const repo = thread.repoFromUri(space, post_uri) orelse return error.BadPostUri; + const rkey = thread.rkeyFromUri(post_uri) orelse return error.BadPostUri; + const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f},\"repo\":{f},\"collection\":{f},\"rkey\":{f}}}", .{ + std.json.fmt(space, .{}), + std.json.fmt(repo, .{}), + std.json.fmt(post.collection, .{}), + std.json.fmt(rkey, .{}), + }); + _ = try pds.authedProcedure(allocator, io, store, account, "com.atproto.space.deleteRecord", body); +} + +/// Resolve a thread record's title via a fresh space credential, or null. +fn threadTitle( + allocator: std.mem.Allocator, + io: std.Io, + store: *accounts.Store, + account: *accounts.Account, + space: []const u8, + thread_uri: []const u8, +) ?[]const u8 { + const repo = thread.repoFromUri(space, thread_uri) orelse return null; + const rkey = thread.rkeyFromUri(thread_uri) orelse return null; + const rec = spaceGetRecord(allocator, io, store, account, space, repo, thread.collection, rkey) catch return null; + defer rec.deinit(); + const value = zat.json.getPath(rec.value, "value") orelse return null; + const title = zat.json.getString(value, "title") orelse return null; + return allocator.dupe(u8, title) catch null; +} + +/// space.getRecord via a fresh delegation->credential exchange. +/// Caller owns the parsed result. +fn spaceGetRecord( + allocator: std.mem.Allocator, + io: std.Io, + store: *accounts.Store, + account: *accounts.Account, + space: []const u8, + repo: []const u8, + collection: []const u8, + rkey: []const u8, +) !std.json.Parsed(std.json.Value) { + const delegation = try delegationToken(allocator, io, store, account, space); + const credential = try spaceCredential(allocator, io, account, space, delegation); + const query = try std.fmt.allocPrint(allocator, "space={s}&repo={s}&collection={s}&rkey={s}", .{ + try pds.percentEncode(allocator, space), + try pds.percentEncode(allocator, repo), + try pds.percentEncode(allocator, collection), + try pds.percentEncode(allocator, rkey), + }); + const body = try bearerRequest(allocator, io, account.pds, .GET, "com.atproto.space.getRecord", query, null, credential); + return std.json.parseFromSlice(std.json.Value, allocator, body, .{}); +} + /// Everything in a space, resolved: thread records plus the posts that /// reference them, time-ordered. Fails when the account is not a member /// (the server refuses the delegation exchange). @@ -284,15 +373,10 @@ fn listCollection( for (records) |entry| { var uri_buf: [1024]u8 = undefined; if (std.mem.eql(u8, coll, thread.collection)) { - if (thread.fromSpaceListEntry(allocator, did, entry, &uri_buf)) |t| { - // tag strings point into the parsed response, which is - // deinit'd - dupe each one - const tags = try allocator.alloc([]const u8, t.tags.len); - for (t.tags, 0..) |tag, i| tags[i] = try allocator.dupe(u8, tag); + if (thread.fromSpaceListEntry(did, entry, &uri_buf)) |t| { try threads_out.append(allocator, .{ .author_did = try allocator.dupe(u8, t.author_did), .title = try allocator.dupe(u8, t.title), - .tags = tags, .created_at = try allocator.dupe(u8, t.created_at), .uri = try allocator.dupe(u8, t.uri), }); diff --git a/src/core/thread.zig b/src/core/thread.zig index c95f254..617fa3c 100644 --- a/src/core/thread.zig +++ b/src/core/thread.zig @@ -2,8 +2,8 @@ //! //! Threads are first-class records: they live in the creator's repo inside //! the space (collection at.linji.thread), and posts reference them by -//! canonical repo AT-URI (at://did/at.linji.thread/rkey). Tags live on the -//! thread record so re-tagging never touches posts. +//! canonical repo AT-URI (at://did/at.linji.thread/rkey). Tags are NOT a +//! thread field - they live inline in message text (see hashtag.zig). const std = @import("std"); const zat = @import("zat"); @@ -13,73 +13,38 @@ pub const collection = "at.linji.thread"; pub const Thread = struct { author_did: []const u8, title: []const u8, - tags: []const []const u8, created_at: []const u8, /// Canonical repo AT-URI: at://{author_did}/at.linji.thread/{rkey}. /// This is the value posts store in their `thread` field. uri: []const u8, }; -/// Split a comma-separated --tags value into tags. Tags are stored raw: -/// any unicode string goes (hashtag semantics); trimming surrounding -/// whitespace is the only hygiene here. Matching/normalization for -/// filtering is the AppView's job, not the write path's. -pub fn parseTags(allocator: std.mem.Allocator, raw: []const u8) ![]const []const u8 { - var out: std.ArrayList([]const u8) = .empty; - var it = std.mem.splitScalar(u8, raw, ','); - while (it.next()) |piece| { - const tag = std.mem.trim(u8, piece, " \t\r\n"); - if (tag.len == 0) continue; - try out.append(allocator, tag); - } - return out.items; -} - /// Build the record value for com.atproto.space.createRecord. -pub fn recordJson(allocator: std.mem.Allocator, title: []const u8, tags: []const []const u8, created_at: []const u8) ![]u8 { - var tags_json: std.ArrayList(u8) = .empty; - try tags_json.append(allocator, '['); - for (tags, 0..) |tag, i| { - if (i != 0) try tags_json.append(allocator, ','); - const enc = try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(tag, .{})}); - try tags_json.appendSlice(allocator, enc); - } - try tags_json.append(allocator, ']'); +pub fn recordJson(allocator: std.mem.Allocator, title: []const u8, created_at: []const u8) ![]u8 { return std.fmt.allocPrint(allocator, - \\{{"$type":"at.linji.thread","title":{f},"tags":{s},"createdAt":{f}}} + \\{{"$type":"at.linji.thread","title":{f},"createdAt":{f}}} , .{ std.json.fmt(title, .{}), - tags_json.items, std.json.fmt(created_at, .{}), }); } /// Extract a Thread from a com.atproto.space.listRecords entry /// ({collection, rkey, cid, value}); the canonical repo AT-URI is derived. -/// Tag strings are collected into `allocator`. -pub fn fromSpaceListEntry(allocator: std.mem.Allocator, author_did: []const u8, entry: std.json.Value, uri_buf: []u8) ?Thread { +pub fn fromSpaceListEntry(author_did: []const u8, entry: std.json.Value, uri_buf: []u8) ?Thread { const coll = zat.json.getString(entry, "collection") orelse return null; const rkey = zat.json.getString(entry, "rkey") orelse return null; const uri = std.fmt.bufPrint(uri_buf, "at://{s}/{s}/{s}", .{ author_did, coll, rkey }) catch return null; const value = zat.json.getPath(entry, "value") orelse return null; - return fromValue(allocator, author_did, uri, value); + return fromValue(author_did, uri, value); } -fn fromValue(allocator: std.mem.Allocator, author_did: []const u8, uri: []const u8, value: std.json.Value) ?Thread { +fn fromValue(author_did: []const u8, uri: []const u8, value: std.json.Value) ?Thread { const title = zat.json.getString(value, "title") orelse return null; const created_at = zat.json.getString(value, "createdAt") orelse return null; - var tags: []const []const u8 = &.{}; - if (zat.json.getArray(value, "tags")) |arr| { - var list: std.ArrayList([]const u8) = .empty; - for (arr) |item| { - if (item == .string) list.append(allocator, item.string) catch return null; - } - tags = list.items; - } return .{ .author_did = author_did, .title = title, - .tags = tags, .created_at = created_at, .uri = uri, }; @@ -93,34 +58,46 @@ pub fn rkeyFromUri(uri: []const u8) ?[]const u8 { return rkey; } -test "parseTags keeps tags raw" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const tags = try parseTags(arena.allocator(), "定课, Week 3,语录#3,,"); - try std.testing.expectEqual(@as(usize, 3), tags.len); - try std.testing.expectEqualStrings("定课", tags[0]); - try std.testing.expectEqualStrings("Week 3", tags[1]); - try std.testing.expectEqualStrings("语录#3", tags[2]); +/// The repo DID a post/thread URI belongs to. Handles both the canonical +/// form (at://did/collection/rkey) and the space-qualified form +/// ({space}/{did}/{collection}/{rkey}) that listRecords derives. +pub fn repoFromUri(space: []const u8, uri: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, uri, space)) { + const rest = uri[space.len..]; + if (rest.len == 0 or rest[0] != '/') return null; + const did = rest[1..]; + const idx = std.mem.indexOfScalar(u8, did, '/') orelse return null; + return did[0..idx]; + } + // at://did/... + if (!std.mem.startsWith(u8, uri, "at://")) return null; + const rest = uri["at://".len..]; + const idx = std.mem.indexOfScalar(u8, rest, '/') orelse return null; + return rest[0..idx]; } test "recordJson round-trips through fromSpaceListEntry" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); - const tags = [_][]const u8{ "定课", "week-3" }; - const json = try recordJson(allocator, "课文\"讨论", &tags, "2026-07-30T00:00:00Z"); + const json = try recordJson(allocator, "课文\"讨论", "2026-07-30T00:00:00Z"); const entry_json = try std.fmt.allocPrint(allocator, "{{\"collection\":\"at.linji.thread\",\"rkey\":\"abc\",\"value\":{s}}}", .{json}); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, entry_json, .{}); defer parsed.deinit(); var uri_buf: [256]u8 = undefined; - const t = fromSpaceListEntry(allocator, "did:x", parsed.value, &uri_buf).?; + const t = fromSpaceListEntry("did:x", parsed.value, &uri_buf).?; try std.testing.expectEqualStrings("at://did:x/at.linji.thread/abc", t.uri); try std.testing.expectEqualStrings("课文\"讨论", t.title); - try std.testing.expectEqual(@as(usize, 2), t.tags.len); - try std.testing.expectEqualStrings("week-3", t.tags[1]); } test "rkeyFromUri" { try std.testing.expectEqualStrings("abc", rkeyFromUri("at://did:x/at.linji.thread/abc").?); try std.testing.expect(rkeyFromUri("at://did:x") == null); } + +test "repoFromUri handles canonical and space-qualified forms" { + const space = "at://did:plc:owner/space/at.linji.space/general"; + try std.testing.expectEqualStrings("did:plc:alice", repoFromUri(space, "at://did:plc:alice/at.linji.post/abc").?); + try std.testing.expectEqualStrings("did:plc:alice", repoFromUri(space, "at://did:plc:owner/space/at.linji.space/general/did:plc:alice/at.linji.post/abc").?); + try std.testing.expect(repoFromUri(space, "nonsense") == null); +} diff --git a/src/main.zig b/src/main.zig index 472b3a5..8d4f1c3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,17 +5,20 @@ //! linji accounts //! linji use //! linji space create|add-member|remove-member|members … -//! linji thread create --space --title [--tags a,b] -//! linji thread list --space [--tag ] +//! linji thread create --space --title +//! linji thread list --space //! linji post --space --thread //! linji move --space --post --thread -//! linji read --space [--as ] +//! linji edit --space --post +//! linji delete --space --post +//! linji read --space [--tag ] [--as ] //! linji watch --space [--interval ] const std = @import("std"); const linji = @import("linji"); const accounts = linji.accounts; +const hashtag = linji.hashtag; const oauth = linji.oauth; const pds = linji.pds; const spaces = linji.spaces; @@ -53,7 +56,11 @@ pub fn main(init: std.process.Init) !void { } else if (std.mem.eql(u8, cmd, "post")) { try cmdPost(allocator, io, &args); } else if (std.mem.eql(u8, cmd, "move")) { - try cmdMove(allocator, io, &args); + try cmdMove(allocator, io, &args, .move); + } else if (std.mem.eql(u8, cmd, "edit")) { + try cmdMove(allocator, io, &args, .edit); + } else if (std.mem.eql(u8, cmd, "delete")) { + try cmdMove(allocator, io, &args, .delete); } else if (std.mem.eql(u8, cmd, "space")) { try cmdSpace(allocator, io, &args); } else if (std.mem.eql(u8, cmd, "thread")) { @@ -80,11 +87,13 @@ fn usage(prog: []const u8) void { \\ space create [--as ] --skey create a member-list space, print its URI \\ space add-member|remove-member [--as ] --space --did \\ space members [--as ] --space list member DIDs - \\ thread create [--as ] --space --title [--tags a,b] create a thread, print its AT-URI - \\ thread list [--as ] --space [--tag ] list threads (optionally one tag) + \\ thread create [--as ] --space --title create a thread, print its AT-URI + \\ thread list [--as ] --space list threads \\ post [--as ] --space --thread post into a thread - \\ move [--as ] --space --post --thread move own post to another thread - \\ read [--as ] --space merged thread view of the space + \\ move [--as ] --space --post --thread move a post (own; any as owner) + \\ edit [--as ] --space --post edit a post (own; any as owner) + \\ delete [--as ] --space --post delete a post (own; any as owner) + \\ read [--as ] --space [--tag ] merged thread view; --tag shows threads with #tag \\ watch [--as ] --space [--interval ms] follow new posts \\ , .{prog}); @@ -170,11 +179,14 @@ fn cmdPost(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Ite out("{s}\n", .{uri}); } -fn cmdMove(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator) !void { +const WriteOp = enum { move, edit, delete }; + +fn cmdMove(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, op: WriteOp) !void { var as_name: ?[]const u8 = null; var space: ?[]const u8 = null; var post_uri: ?[]const u8 = null; var thread_uri: ?[]const u8 = null; + var text_parts: std.ArrayList([]const u8) = .empty; while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--as")) { as_name = args.next() orelse return fatal("--as needs a value"); @@ -184,9 +196,12 @@ fn cmdMove(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Ite post_uri = args.next() orelse return fatal("--post needs a value"); } else if (std.mem.eql(u8, arg, "--thread")) { thread_uri = args.next() orelse return fatal("--thread needs a value"); - } else return fatal("unknown move flag"); + } else { + try text_parts.append(allocator, arg); + } } - if (space == null or post_uri == null or thread_uri == null) return fatal("move needs --space, --post and --thread"); + if (space == null or post_uri == null) return fatal("needs --space and --post"); + if (op == .move and thread_uri == null) return fatal("move needs --thread"); var store = try accounts.Store.load(allocator, io); const account = if (as_name) |name| @@ -194,7 +209,15 @@ fn cmdMove(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Ite else (store.defaultAccount() orelse return fatal("no default account — run linji login first")); - try spaces.movePost(allocator, io, &store, account, space.?, post_uri.?, thread_uri.?); + switch (op) { + .move => try spaces.movePost(allocator, io, &store, account, space.?, post_uri.?, thread_uri.?), + .edit => { + if (text_parts.items.len == 0) return fatal("edit needs new text"); + const text = try std.mem.join(allocator, " ", text_parts.items); + try spaces.editPost(allocator, io, &store, account, space.?, post_uri.?, text); + }, + .delete => try spaces.deletePost(allocator, io, &store, account, space.?, post_uri.?), + } out("ok\n", .{}); } @@ -203,8 +226,6 @@ fn cmdThread(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.I var as_name: ?[]const u8 = null; var space: ?[]const u8 = null; var title: ?[]const u8 = null; - var tags_raw: ?[]const u8 = null; - var tag_filter: ?[]const u8 = null; while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--as")) { as_name = args.next() orelse return fatal("--as needs a value"); @@ -212,10 +233,6 @@ fn cmdThread(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.I space = args.next() orelse return fatal("--space needs a value"); } else if (std.mem.eql(u8, arg, "--title")) { title = args.next() orelse return fatal("--title needs a value"); - } else if (std.mem.eql(u8, arg, "--tags")) { - tags_raw = args.next() orelse return fatal("--tags needs a value"); - } else if (std.mem.eql(u8, arg, "--tag")) { - tag_filter = args.next() orelse return fatal("--tag needs a value"); } else return fatal("unknown thread flag"); } if (space == null) return fatal("thread needs --space"); @@ -228,32 +245,13 @@ fn cmdThread(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.I if (std.mem.eql(u8, sub, "create")) { if (title == null) return fatal("thread create needs --title"); - const tags = if (tags_raw) |raw| try thread.parseTags(allocator, raw) else &.{}; - const uri = try spaces.createThread(allocator, io, &store, account, space.?, title.?, tags); + const uri = try spaces.createThread(allocator, io, &store, account, space.?, title.?); out("{s}\n", .{uri}); } else if (std.mem.eql(u8, sub, "list")) { const view = try spaces.readSpace(allocator, io, &store, account, space.?); - // exact match - case folding/normalization is the AppView's job - const filter = if (tag_filter) |raw| std.mem.trim(u8, raw, " \t\r\n") else null; var buf: std.Io.Writer.Allocating = .init(allocator); for (view.threads) |t| { - if (filter) |f| { - var hit = false; - for (t.tags) |tag| { - if (std.mem.eql(u8, tag, f)) hit = true; - } - if (!hit) continue; - } - buf.writer.print("{s}\n {s}", .{ t.uri, t.title }) catch unreachable; - if (t.tags.len > 0) { - buf.writer.print(" [", .{}) catch unreachable; - for (t.tags, 0..) |tag, i| { - if (i != 0) buf.writer.print(",", .{}) catch unreachable; - buf.writer.print("{s}", .{tag}) catch unreachable; - } - buf.writer.print("]", .{}) catch unreachable; - } - buf.writer.print("\n", .{}) catch unreachable; + buf.writer.print("{s}\n {s}\n", .{ t.uri, t.title }) catch unreachable; } if (buf.written().len == 0) { out("(no threads)\n", .{}); @@ -311,12 +309,15 @@ fn cmdSpace(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.It fn cmdRead(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, follow: bool) !void { var space: ?[]const u8 = null; var as_name: ?[]const u8 = null; + var tag: ?[]const u8 = null; var interval_ms: u64 = 2000; while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--space")) { space = args.next() orelse return fatal("--space needs a value"); } else if (std.mem.eql(u8, arg, "--as")) { as_name = args.next() orelse return fatal("--as needs a value"); + } else if (std.mem.eql(u8, arg, "--tag")) { + tag = args.next() orelse return fatal("--tag needs a value"); } else if (std.mem.eql(u8, arg, "--interval")) { const v = args.next() orelse return fatal("--interval needs a value"); interval_ms = try std.fmt.parseInt(u64, v, 10); @@ -333,7 +334,7 @@ fn cmdRead(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Ite if (!follow) { const view = try spaces.readSpace(allocator, io, &store, account, space.?); - try renderView(allocator, &store, view, null); + try renderView(allocator, &store, view, tag); return; } @@ -346,17 +347,17 @@ fn cmdRead(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Ite try seen.put(try allocator.dupe(u8, p.uri), {}); try fresh.append(allocator, p); } - if (fresh.items.len > 0) try renderView(allocator, &store, .{ .threads = view.threads, .posts = fresh.items }, null); + if (fresh.items.len > 0) try renderView(allocator, &store, .{ .threads = view.threads, .posts = fresh.items }, tag); io.sleep(std.Io.Duration.fromMilliseconds(@intCast(interval_ms)), .awake) catch {}; } } -fn renderView(allocator: std.mem.Allocator, store: *accounts.Store, view: spaces.SpaceView, thread_filter: ?[]const u8) !void { +/// Render posts grouped by thread. With `tag`, only threads containing a +/// message with the inline hashtag are shown (exact match - normalization +/// is the frontend's job); the whole thread is still rendered. +fn renderView(allocator: std.mem.Allocator, store: *accounts.Store, view: spaces.SpaceView, tag: ?[]const u8) !void { var groups: std.StringArrayHashMapUnmanaged(std.ArrayList(post.Post)) = .empty; for (view.posts) |p| { - if (thread_filter) |t| { - if (!std.mem.eql(u8, p.thread, t)) continue; - } const gop = try groups.getOrPut(allocator, p.thread); if (!gop.found_existing) gop.value_ptr.* = .empty; try gop.value_ptr.append(allocator, p); @@ -365,6 +366,13 @@ fn renderView(allocator: std.mem.Allocator, store: *accounts.Store, view: spaces var buf: std.Io.Writer.Allocating = .init(allocator); var it = groups.iterator(); while (it.next()) |entry| { + if (tag) |t| { + var hit = false; + for (entry.value_ptr.items) |p| { + if (try hashtag.contains(allocator, p.text, t)) hit = true; + } + if (!hit) continue; + } const thread_uri = entry.key_ptr.*; const title = view.threadTitle(thread_uri) orelse thread_uri; buf.writer.print("== {s} ==\n", .{title}) catch unreachable; @@ -373,7 +381,7 @@ fn renderView(allocator: std.mem.Allocator, store: *accounts.Store, view: spaces buf.writer.print("{s} {s}\n {s}\n", .{ handle, p.created_at, p.text }) catch unreachable; } } - if (groups.count() == 0) { + if (buf.written().len == 0) { out("(no posts)\n", .{}); return; } diff --git a/tests/scenario_space.zig b/tests/scenario_space.zig index 40bc375..5c035bf 100644 --- a/tests/scenario_space.zig +++ b/tests/scenario_space.zig @@ -4,9 +4,12 @@ //! linji binary through the full membership + thread lifecycle: //! //! 1. community account creates a member-list space, adds alice + bob -//! 2. alice and bob create threads (tags normalized); both post; merged -//! read groups by thread title -//! 2b. bob moves his stray post to the other thread; read reflects it +//! 2. alice and bob create threads; both post; merged read groups by +//! thread title; inline hashtags surface threads via read --tag +//! 2b. bob moves his stray post to the other thread; read reflects it; +//! a trace message stays in the old thread +//! 2c. authors edit their own posts; the owner edits/deletes anyone's; +//! non-author edits/moves/deletes are denied //! 3. eve (never a member) cannot read, post, or create threads //! 4. removing bob revokes his read AND write immediately //! 5. re-adding bob restores both; final read shows all messages @@ -103,24 +106,20 @@ pub fn main(init: std.process.Init) !void { try expectContains(r.stdout, "did:plc:bobtest", "bob listed"); } - // --- 2. threads: create, tag-filter, post, grouped read --- - const t1_out = try linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "alice.test", "--space", space_uri, "--title", "课文讨论", "--tags", "定课, Week 3" }, "alice creates thread"); + // --- 2. threads: create, post, grouped read, hashtag search --- + const t1_out = try linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "alice.test", "--space", space_uri, "--title", "课文讨论" }, "alice creates thread"); const t1 = std.mem.trim(u8, t1_out.stdout, " \r\n\t"); try expectContains(t1, "at://did:plc:alicetest/at.linji.thread/", "thread uri shape"); const t2_out = try linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "bob.test", "--space", space_uri, "--title", "随喜" }, "bob creates thread"); const t2 = std.mem.trim(u8, t2_out.stdout, " \r\n\t"); { const r = try linji(allocator, io, home, linji_bin, &.{ "thread", "list", "--as", "bob.test", "--space", space_uri }, "bob lists threads"); - try expectContains(r.stdout, "课文讨论 [定课,Week 3]", "tags stored raw in list"); + try expectContains(r.stdout, "课文讨论", "first thread listed"); try expectContains(r.stdout, "随喜", "second thread listed"); } - { - const r = try linji(allocator, io, home, linji_bin, &.{ "thread", "list", "--as", "bob.test", "--space", space_uri, "--tag", "定课" }, "filter by tag"); - try expectContains(r.stdout, "课文讨论", "tagged thread matches"); - try expectAbsent(r.stdout, "随喜", "untagged thread filtered out"); - } _ = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "alice.test", "--space", space_uri, "--thread", t1, "空间里的第一条,alice。" }, "alice space post"); - _ = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "空间里的第二条,bob。" }, "bob space post"); + const bob_post_out = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "空间里的第二条,bob。 #BlueSky" }, "bob space post"); + const bob_post_uri = std.mem.trim(u8, bob_post_out.stdout, " \r\n\t"); const stray_out = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t2, "岔出去的一句。" }, "bob stray post"); const stray_uri = std.mem.trim(u8, stray_out.stdout, " \r\n\t"); { @@ -129,17 +128,42 @@ pub fn main(init: std.process.Init) !void { try expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "alice before bob"); try expectContains(r.stdout, "== 随喜 ==", "second thread header"); } + { + const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test", "--tag", "BlueSky" }, "search by inline tag"); + try expectContains(r.stdout, "== 课文讨论 ==", "tagged message's thread shown"); + try expectAbsent(r.stdout, "== 随喜 ==", "untagged thread filtered out"); + } // --- 2b. migration: bob moves his stray post into 课文讨论 --- _ = try linji(allocator, io, home, linji_bin, &.{ "move", "--as", "bob.test", "--space", space_uri, "--post", stray_uri, "--thread", t1 }, "bob moves stray post"); { const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after move"); try expectOrder(r.stdout, "空间里的第二条,bob。", "岔出去的一句。", "moved post lands after thread's posts"); - try expectAbsent(r.stdout, "== 随喜 ==", "emptied thread has no group"); + try expectContains(r.stdout, "== 随喜 ==", "old thread keeps its trace"); + try expectContains(r.stdout, "↦ 一条消息被移动到「课文讨论」", "trace names the target thread"); } // move it back so later steps see the original layout _ = try linji(allocator, io, home, linji_bin, &.{ "move", "--as", "bob.test", "--space", space_uri, "--post", stray_uri, "--thread", t2 }, "bob moves post back"); + // --- 2c. edits + owner moderation powers --- + try linjiFails(allocator, io, home, linji_bin, &.{ "edit", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri, "越权改别人。" }, "alice editing bob's post denied"); + try linjiFails(allocator, io, home, linji_bin, &.{ "move", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri, "--thread", t2 }, "alice moving bob's post denied"); + try linjiFails(allocator, io, home, linji_bin, &.{ "delete", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri }, "alice deleting bob's post denied"); + _ = try linji(allocator, io, home, linji_bin, &.{ "edit", "--as", "bob.test", "--space", space_uri, "--post", bob_post_uri, "空间里的第二条,bob。(修订) #BlueSky" }, "bob edits own post"); + _ = try linji(allocator, io, home, linji_bin, &.{ "edit", "--as", "community.test", "--space", space_uri, "--post", bob_post_uri, "空间里的第二条,bob。(社区修订) #BlueSky" }, "owner edits bob's post"); + { + const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after edits"); + try expectContains(r.stdout, "空间里的第二条,bob。(社区修订)", "owner edit visible to members"); + try expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "edited post keeps its slot"); + } + const doomed_out = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "将被删除。" }, "bob posts doomed message"); + const doomed_uri = std.mem.trim(u8, doomed_out.stdout, " \r\n\t"); + _ = try linji(allocator, io, home, linji_bin, &.{ "delete", "--as", "community.test", "--space", space_uri, "--post", doomed_uri }, "owner deletes bob's post"); + { + const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after delete"); + try expectAbsent(r.stdout, "将被删除。", "deleted post gone"); + } + // --- 3. eve (never a member) is denied all three ways --- try linjiFails(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "eve.test" }, "eve read denied"); try linjiFails(allocator, io, home, linji_bin, &.{ "post", "--as", "eve.test", "--space", space_uri, "--thread", t1, "我不该能发这条。" }, "eve post denied");