diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index fb8679b..fb20dbe 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -154,6 +154,9 @@ export interface paths { * * You can connect the block to multiple channels at once (up to 20). * + * Specify target channels using either `channels` (preferred) or `channel_ids` (legacy), but not both. + * The `channels` form allows per-channel position and connection metadata. + * * **Authentication required.** */ post: operations["createBlock"]; @@ -393,6 +396,9 @@ export interface paths { * @description Connects a block or channel to one or more channels. * Returns the created connection(s). * + * Specify target channels using either `channels` (preferred) or `channel_ids` (legacy), but not both. + * The `channels` form allows per-channel position and connection metadata. + * * **Authentication required.** */ post: operations["createConnection"]; @@ -418,7 +424,14 @@ export interface paths { * are returned as part of channel contents. */ get: operations["getConnection"]; - put?: never; + /** + * Update a connection + * @description Updates a connection's metadata. Uses merge semantics: new keys are added, + * existing keys are updated, keys set to null are removed. + * + * **Authentication required.** + */ + put: operations["updateConnection"]; post?: never; /** * Delete a connection @@ -623,6 +636,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v3/users/{id}/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user groups + * @description Returns paginated list of groups the user belongs to (as owner or member). + * When authenticated as the target user, includes private groups. + * Otherwise only public groups are returned. + */ + get: operations["getUserGroups"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v3/groups/{id}": { parameters: { query?: never; @@ -894,6 +929,28 @@ export interface components { */ initials: string; }; + /** + * @description Arbitrary key-value pairs stored on an entity. + * Keys are alphanumeric/underscore, max 40 characters. + * Values are scalars (string, number, boolean). Max 50 keys, 32KB total. + * @example { + * "status": "reviewed", + * "score": 0.95, + * "featured": true + * } + */ + Metadata: { + [key: string]: string | number | boolean; + }; + /** + * @description Arbitrary key-value pairs to set on an entity. Uses merge semantics: + * new keys are added, existing keys are updated, keys set to null are removed. + * Keys must be alphanumeric/underscore, max 40 characters. + * Values must be scalars (string, number, boolean) or null (to delete). Max 50 keys, 32KB total. + */ + MetadataInput: { + [key: string]: string | number | boolean | null; + }; /** @description Embedded connection representation used when connection is nested in other resources */ EmbeddedConnection: { /** @@ -902,7 +959,7 @@ export interface components { */ id: number; /** - * @description Position of the item within the channel + * @description Position of the item within the channel (1-indexed) * @example 1 */ position: number; @@ -911,6 +968,8 @@ export interface components { * @example false */ pinned: boolean; + /** @description Custom key-value metadata */ + metadata?: components["schemas"]["Metadata"] | null; /** * Format: date-time * @description When the item was connected @@ -1262,6 +1321,23 @@ export interface components { | "created_at_desc" | "updated_at_asc" | "updated_at_desc"; + /** + * @description Sort order for groups. + * - `name_asc`: Alphabetical (default) + * - `name_desc`: Reverse alphabetical + * - `created_at_desc`: Newest first + * - `created_at_asc`: Oldest first + * - `updated_at_desc`: Recently updated first + * - `updated_at_asc`: Least recently updated first + * @enum {string} + */ + GroupSort: + | "name_asc" + | "name_desc" + | "created_at_asc" + | "created_at_desc" + | "updated_at_asc" + | "updated_at_desc"; /** * @description Limit search to a specific context. * - `all`: Everything accessible to the user (default) @@ -1326,6 +1402,15 @@ export interface components { * ] */ ChannelIds: (number | string)[]; + /** @description A channel to connect to, with optional position and connection metadata. */ + ConnectTo: { + /** @description Channel ID or slug. */ + id: number | string; + /** @description Position to insert at within this channel (1-indexed). */ + position?: number; + /** @description Connection metadata for this specific connection. */ + metadata?: components["schemas"]["Metadata"]; + }; /** @description A presigned S3 upload URL for a single file */ PresignedFile: { /** @@ -1392,6 +1477,8 @@ export interface components { * @example Beige flags */ alt_text?: string; + /** @description Custom key-value metadata to set on the new block. */ + metadata?: components["schemas"]["Metadata"]; }; /** @description Response returned when a batch is accepted for processing */ BatchResponse: { @@ -1511,6 +1598,8 @@ export interface components { */ updated_at: string; user: components["schemas"]["EmbeddedUser"]; + /** @description Custom key-value metadata */ + metadata?: components["schemas"]["Metadata"] | null; /** @description Source URL and metadata (if block was created from a URL) */ source?: components["schemas"]["BlockSource"] | null; /** @description HATEOAS links for navigation */ @@ -1920,6 +2009,8 @@ export interface components { * @example 2023-01-15T14:45:00Z */ updated_at: string; + /** @description Custom key-value metadata */ + metadata?: components["schemas"]["Metadata"] | null; owner: components["schemas"]["ChannelOwner"]; counts: components["schemas"]["ChannelCounts"]; /** @@ -2083,6 +2174,14 @@ export interface components { /** @description Paginated list of channels with total count */ ChannelListResponse: components["schemas"]["ChannelList"] & components["schemas"]["PaginatedResponse"]; + /** @description Data payload containing an array of groups */ + GroupList: { + /** @description Array of groups */ + data: components["schemas"]["Group"][]; + }; + /** @description Paginated list of groups with total count */ + GroupListResponse: components["schemas"]["GroupList"] & + components["schemas"]["PaginatedResponse"]; /** @description Paginated list of connectable content (blocks and channels) */ ConnectableListResponse: components["schemas"]["ConnectableList"] & components["schemas"]["PaginatedResponse"]; @@ -2241,6 +2340,11 @@ export interface components { * @example created_at_desc */ ContentSortParam: components["schemas"]["ContentSort"]; + /** + * @description Sort groups by name or date. + * @example name_asc + */ + GroupSortParam: components["schemas"]["GroupSort"]; /** * @description Sort channel contents. Use `position` for the owner's manual * arrangement, or sort by date. Defaults to `position_desc`. @@ -2483,15 +2587,22 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["BlockInput"] & { - channel_ids: components["schemas"]["ChannelIds"]; - /** - * @description Position to insert the block in the channel. - * Only valid when connecting to a single channel. - * @example 0 - */ - insert_at?: number; - }; + "application/json": components["schemas"]["BlockInput"] & + ( + | { + channel_ids: components["schemas"]["ChannelIds"]; + /** + * @description Position to insert the block in the channel (1-indexed). + * Only valid when connecting to a single channel. + * @example 1 + */ + insert_at?: number; + } + | { + /** @description Target channels with optional per-channel position and connection metadata. */ + channels: components["schemas"]["ConnectTo"][]; + } + ); }; }; responses: { @@ -2648,6 +2759,11 @@ export interface operations { * @example A descriptive alt text for accessibility */ alt_text?: string; + /** + * @description Custom key-value metadata. Uses merge semantics: new keys are added, + * existing keys are updated, keys set to null are removed. + */ + metadata?: components["schemas"]["MetadataInput"]; }; }; }; @@ -2852,6 +2968,8 @@ export interface operations { * @example 12345 */ group_id?: number; + /** @description Custom key-value metadata to set on the new channel. */ + metadata?: components["schemas"]["Metadata"]; }; }; }; @@ -2923,6 +3041,11 @@ export interface operations { * @example Updated description */ description?: string | null; + /** + * @description Custom key-value metadata. Uses merge semantics: new keys are added, + * existing keys are updated, keys set to null are removed. + */ + metadata?: components["schemas"]["MetadataInput"]; }; }; }; @@ -2983,13 +3106,20 @@ export interface operations { connectable_id: number; /** @description Type of the connectable. */ connectable_type: components["schemas"]["ConnectableType"]; - channel_ids: components["schemas"]["ChannelIds"]; - /** - * @description Position to insert at within the channel. Only valid - * when connecting to a single channel. - */ - position?: number; - }; + } & ( + | { + channel_ids: components["schemas"]["ChannelIds"]; + /** + * @description Position to insert at within the channel (1-indexed). + * Only valid when connecting to a single channel. + */ + position?: number; + } + | { + /** @description Target channels with optional per-channel position and connection metadata. */ + channels: components["schemas"]["ConnectTo"][]; + } + ); }; }; responses: { @@ -3039,6 +3169,45 @@ export interface operations { 429: components["responses"]["RateLimitResponse"]; }; }; + updateConnection: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource ID */ + id: components["parameters"]["IdParam"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Custom key-value metadata. Uses merge semantics: new keys are added, + * existing keys are updated, keys set to null are removed. + */ + metadata?: components["schemas"]["MetadataInput"]; + }; + }; + }; + responses: { + /** @description Connection updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Connection"]; + }; + }; + 400: components["responses"]["ValidationErrorResponse"]; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 422: components["responses"]["UnprocessableEntityResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; deleteConnection: { parameters: { query?: never; @@ -3079,7 +3248,7 @@ export interface operations { "application/json": { /** @default insert_at */ movement?: components["schemas"]["Movement"]; - /** @description Target position (required when movement is insert_at) */ + /** @description Target position, 1-indexed (required when movement is insert_at) */ position?: number; }; }; @@ -3425,6 +3594,49 @@ export interface operations { 429: components["responses"]["RateLimitResponse"]; }; }; + getUserGroups: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort groups by name or date. + * @example name_asc + */ + sort?: components["parameters"]["GroupSortParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of groups the user belongs to */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; getGroup: { parameters: { query?: never; diff --git a/src/api/types.ts b/src/api/types.ts index 7855fbf..d453948 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -35,6 +35,9 @@ export type ChannelContentSort = Schemas["ChannelContentSort"]; export type ContentSort = Schemas["ContentSort"]; export type ConnectionFilter = Schemas["ConnectionFilter"]; export type UserTier = Schemas["UserTier"]; +export type GroupSort = Schemas["GroupSort"]; +export type Metadata = Schemas["Metadata"]; +export type MetadataInput = Schemas["MetadataInput"]; export type PresignedFile = Schemas["PresignedFile"]; export type PaginationMeta = Schemas["PaginationMeta"]; diff --git a/src/commands/add.tsx b/src/commands/add.tsx index 20c2765..86ea0ba 100644 --- a/src/commands/add.tsx +++ b/src/commands/add.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { client, getData } from "../api/client"; +import type { Metadata } from "../api/types"; import { readStdin } from "../lib/args"; import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; @@ -13,6 +14,8 @@ interface Props { originalSourceUrl?: string; originalSourceTitle?: string; insertAt?: number; + metadata?: Metadata; + connectionMetadata?: Metadata; } export function AddCommand({ @@ -24,6 +27,8 @@ export function AddCommand({ originalSourceUrl, originalSourceTitle, insertAt, + metadata, + connectionMetadata, }: Props) { const { data, error, loading } = useCommand(async () => { const resolvedValue = valueProp ?? (await readStdin()); @@ -38,13 +43,19 @@ export function AddCommand({ client.POST("/v3/blocks", { body: { value: resolvedValue, - channel_ids: [ch.id], + channels: [ + { + id: ch.id, + position: insertAt, + metadata: connectionMetadata, + }, + ], title, description, alt_text: altText, original_source_url: originalSourceUrl, original_source_title: originalSourceTitle, - insert_at: insertAt, + metadata, }, }), ); diff --git a/src/commands/block.tsx b/src/commands/block.tsx index c6de489..a51318b 100644 --- a/src/commands/block.tsx +++ b/src/commands/block.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import { client, getData } from "../api/client"; -import type { ConnectionSort } from "../api/types"; +import type { ConnectionSort, MetadataInput } from "../api/types"; import { BlockContent } from "../components/BlockContent"; import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; @@ -28,18 +28,20 @@ export function BlockUpdateCommand({ description, content, altText, + metadata, }: { id: number; title?: string; description?: string; content?: string; altText?: string; + metadata?: MetadataInput; }) { const { data, error, loading } = useCommand(() => getData( client.PUT("/v3/blocks/{id}", { params: { path: { id } }, - body: { title, description, content, alt_text: altText }, + body: { title, description, content, alt_text: altText, metadata }, }), ), ); diff --git a/src/commands/channel.tsx b/src/commands/channel.tsx index ee97eba..5f19bf9 100644 --- a/src/commands/channel.tsx +++ b/src/commands/channel.tsx @@ -1,6 +1,11 @@ import { Box, Text, useApp } from "ink"; import { client, getData } from "../api/client"; -import type { ChannelContentSort, Visibility } from "../api/types"; +import type { + ChannelContentSort, + Metadata, + MetadataInput, + Visibility, +} from "../api/types"; import { BlockItem } from "../components/BlockItem"; import { ChannelBlockViewer } from "../components/ChannelBlockViewer"; import { @@ -11,6 +16,7 @@ import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; import { useStackNavigator } from "../hooks/useStackNavigator"; import { plural, timeAgo } from "../lib/format"; +import { formatMetadata } from "../lib/metadata"; import { clearTerminalViewport } from "../lib/terminalViewport"; import { visibilityLabel } from "../lib/theme"; @@ -147,6 +153,9 @@ function StaticChannelView({ {plural(channel.counts.contents, "block")} Updated {timeAgo(channel.updated_at)} + {channel.metadata && ( + Metadata {formatMetadata(channel.metadata)} + )} @@ -228,16 +237,18 @@ export function ChannelCreateCommand({ visibility, description, groupId, + metadata, }: { title: string; visibility?: Visibility; description?: string; groupId?: number; + metadata?: Metadata; }) { const { data, error, loading } = useCommand(() => getData( client.POST("/v3/channels", { - body: { title, visibility, description, group_id: groupId }, + body: { title, visibility, description, group_id: groupId, metadata }, }), ), ); @@ -264,17 +275,19 @@ export function ChannelUpdateCommand({ title, visibility, description, + metadata, }: { slug: string; title?: string; visibility?: Visibility; description?: string; + metadata?: MetadataInput; }) { const { data, error, loading } = useCommand(() => getData( client.PUT("/v3/channels/{id}", { params: { path: { id: slug } }, - body: { title, visibility, description }, + body: { title, visibility, description, metadata }, }), ), ); diff --git a/src/commands/connect.tsx b/src/commands/connect.tsx index ed5b7e5..c2c2ba2 100644 --- a/src/commands/connect.tsx +++ b/src/commands/connect.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import { client, getData } from "../api/client"; -import type { ConnectableType } from "../api/types"; +import type { ConnectableType, Metadata } from "../api/types"; import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; @@ -9,6 +9,7 @@ interface Props { channel: string; connectableType?: ConnectableType; position?: number; + metadata?: Metadata; } export function ConnectCommand({ @@ -16,6 +17,7 @@ export function ConnectCommand({ channel, connectableType = "Block", position, + metadata, }: Props) { const { data, error, loading } = useCommand(async () => { const ch = await getData( @@ -28,8 +30,7 @@ export function ConnectCommand({ body: { connectable_id: blockId, connectable_type: connectableType, - channel_ids: [ch.id], - position, + channels: [{ id: ch.id, position, metadata }], }, }); return { channel: ch }; diff --git a/src/commands/connection.tsx b/src/commands/connection.tsx index 942c70d..8591c46 100644 --- a/src/commands/connection.tsx +++ b/src/commands/connection.tsx @@ -1,9 +1,15 @@ import { Box, Text } from "ink"; import { client, getData } from "../api/client"; -import type { ConnectionFilter, ConnectionSort, Movement } from "../api/types"; +import type { + ConnectionFilter, + ConnectionSort, + MetadataInput, + Movement, +} from "../api/types"; import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; import { plural } from "../lib/format"; +import { formatMetadata } from "../lib/metadata"; import { channelColor, INDICATORS } from "../lib/theme"; export function ConnectionGetCommand({ id }: { id: number }) { @@ -29,6 +35,37 @@ export function ConnectionGetCommand({ id }: { id: number }) { {data.connected_by && ( Connected by {data.connected_by.name} )} + {data.metadata && ( + Metadata {formatMetadata(data.metadata)} + )} + + ); +} + +export function ConnectionUpdateCommand({ + id, + metadata, +}: { + id: number; + metadata: MetadataInput; +}) { + const { data, error, loading } = useCommand(() => + getData( + client.PUT("/v3/connections/{id}", { + params: { path: { id } }, + body: { metadata }, + }), + ), + ); + + if (loading) return ; + if (error) return โœ• {error}; + if (!data) return null; + + return ( + + โœ“ + Updated connection {data.id} ); } diff --git a/src/commands/user.tsx b/src/commands/user.tsx index 200bdb9..963020e 100644 --- a/src/commands/user.tsx +++ b/src/commands/user.tsx @@ -5,6 +5,7 @@ import type { ContentSort, ContentTypeFilter, FollowableType, + GroupSort, } from "../api/types"; import { BlockItem } from "../components/BlockItem"; import { Spinner } from "../components/Spinner"; @@ -172,3 +173,43 @@ export function UserFollowingCommand({ ); } + +export function UserGroupsCommand({ + slug, + page = 1, + per, + sort, +}: { + slug: string; + page?: number; + per?: number; + sort?: GroupSort; +}) { + const { data, error, loading } = useCommand(() => + getData( + client.GET("/v3/users/{id}/groups", { + params: { path: { id: slug }, query: { page, per, sort } }, + }), + ), + ); + + if (loading) return ; + if (error) return โœ• {error}; + if (!data) return null; + + if (data.data.length === 0) return No groups; + + return ( + + {data.data.map((group) => ( + + {group.name} @{group.slug} + + ))} + + {"\n"}Page {data.meta.current_page}/{data.meta.total_pages} ยท{" "} + {plural(data.meta.total_count, "group")} + + + ); +} diff --git a/src/components/BlockContent.tsx b/src/components/BlockContent.tsx index 437552b..b735e8c 100644 --- a/src/components/BlockContent.tsx +++ b/src/components/BlockContent.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Box, Text } from "ink"; import type { Block } from "../api/types"; import { formatFileSize, timeAgo } from "../lib/format"; +import { formatMetadata } from "../lib/metadata"; import { blockTextColor } from "../lib/theme"; import { TerminalImage } from "./TerminalImage"; @@ -74,6 +75,7 @@ export function BlockContent({ value: `${block.updated_at} (${timeAgo(block.updated_at)})`, }, { label: "By", value: block.user.name }, + { label: "Metadata", value: formatMetadata(block.metadata) }, ...(previewImage ? [ { label: "Image", value: previewImage.filename }, diff --git a/src/lib/metadata.test.ts b/src/lib/metadata.test.ts new file mode 100644 index 0000000..a30ea62 --- /dev/null +++ b/src/lib/metadata.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { metadataInputFlag, parseMetadata } from "./metadata"; + +test("parseMetadata parses key=value scalars", () => { + assert.deepEqual(parseMetadata("status=reviewed,score=0.95,featured=true"), { + status: "reviewed", + score: 0.95, + featured: true, + }); +}); + +test("parseMetadata parses JSON object values", () => { + assert.deepEqual( + parseMetadata('{"status":"reviewed","score":1,"featured":false}'), + { + status: "reviewed", + score: 1, + featured: false, + }, + ); +}); + +test("metadataInputFlag allows null values for merge removal", () => { + assert.deepEqual(metadataInputFlag({ metadata: "status=null" }), { + status: null, + }); +}); + +test("parseMetadata rejects nested values", () => { + assert.throws( + () => parseMetadata('{"tags":["nested"]}'), + /Invalid metadata value/, + ); +}); + +test("parseMetadata rejects invalid keys", () => { + assert.throws(() => parseMetadata("bad-key=value"), /Invalid metadata key/); +}); + +test("parseMetadata preserves strings that don't round-trip as numbers", () => { + assert.deepEqual(parseMetadata("sku=00123,zip=05401,decimal=1.10"), { + sku: "00123", + zip: "05401", + decimal: "1.10", + }); +}); + +test("parseMetadata supports backslash-escaped commas in values", () => { + assert.deepEqual(parseMetadata("note=hello\\, world,status=ok"), { + note: "hello, world", + status: "ok", + }); +}); diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts new file mode 100644 index 0000000..78a0f13 --- /dev/null +++ b/src/lib/metadata.ts @@ -0,0 +1,170 @@ +import type { Metadata, MetadataInput } from "../api/types"; +import type { Flags } from "./args"; + +type MetadataValue = string | number | boolean; +type MetadataInputValue = MetadataValue | null; + +const KEY_PATTERN = /^[A-Za-z0-9_]{1,40}$/; +const NUMBER_PATTERN = /^-?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseScalar(raw: string): MetadataInputValue { + const value = raw.trim(); + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + if (NUMBER_PATTERN.test(value)) { + const numberValue = Number(value); + // Only coerce to number when the value round-trips losslessly. This + // preserves strings like "00123", "1.10", or "1e2" that a user clearly + // wants to keep as strings (e.g. SKUs, phone numbers, zip codes). + if (Number.isFinite(numberValue) && String(numberValue) === value) { + return numberValue; + } + } + return value; +} + +/** + * Split a key=value list on commas while respecting backslash escapes. + * `\,` inserts a literal comma; `\\` inserts a literal backslash. This lets + * users embed commas in values (e.g. `note=hello\,world`) without dropping + * to JSON form. + */ +function splitEntries(raw: string): string[] { + const entries: string[] = []; + let current = ""; + for (let i = 0; i < raw.length; i++) { + const char = raw[i]!; + if (char === "\\" && i + 1 < raw.length) { + current += raw[i + 1]!; + i++; + continue; + } + if (char === ",") { + entries.push(current); + current = ""; + continue; + } + current += char; + } + entries.push(current); + return entries.filter((part) => part.trim()); +} + +function validateMetadataValue( + key: string, + value: unknown, + allowNull: boolean, +): MetadataInputValue { + if (!KEY_PATTERN.test(key)) { + throw new Error( + `Invalid metadata key: ${key}. Use 1-40 alphanumeric or underscore characters.`, + ); + } + + if (value === null) { + if (allowNull) return null; + throw new Error(`Metadata value for ${key} cannot be null here.`); + } + + if (typeof value === "string" || typeof value === "boolean") return value; + + if (typeof value === "number" && Number.isFinite(value)) return value; + + throw new Error( + `Invalid metadata value for ${key}. Expected string, number, boolean${ + allowNull ? ", or null" : "" + }.`, + ); +} + +function parseMetadataObject(raw: string): Record { + const trimmed = raw.trim(); + if (trimmed.startsWith("{")) { + const parsed = JSON.parse(trimmed) as unknown; + if (!isRecord(parsed)) throw new Error("--metadata JSON must be an object"); + return parsed; + } + + return Object.fromEntries( + splitEntries(trimmed).map((part) => { + const separator = part.indexOf("="); + if (separator <= 0) { + throw new Error( + `Invalid metadata entry: ${part}. Use key=value pairs or a JSON object.`, + ); + } + const key = part.slice(0, separator).trim(); + const value = parseScalar(part.slice(separator + 1)); + return [key, value]; + }), + ); +} + +export function parseMetadata( + raw: string, + options: { allowNull?: boolean } = {}, +): Metadata | MetadataInput { + const allowNull = options.allowNull ?? false; + const parsed = parseMetadataObject(raw); + const entries = Object.entries(parsed); + + if (entries.length > 50) { + throw new Error("Metadata supports at most 50 keys."); + } + + const metadata: Record = {}; + for (const [key, value] of entries) { + metadata[key] = validateMetadataValue(key, value, allowNull); + } + + return metadata as Metadata | MetadataInput; +} + +export function metadataFlag( + flags: Flags, + key = "metadata", + options: { allowNull?: boolean } = {}, +): Metadata | MetadataInput | undefined { + const value = flags[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`--${key} requires a value`); + return parseMetadata(value, options); +} + +export function entityMetadataFlag( + flags: Flags, + key = "metadata", +): Metadata | undefined { + return metadataFlag(flags, key, { allowNull: false }) as Metadata | undefined; +} + +export function metadataInputFlag( + flags: Flags, + key = "metadata", +): MetadataInput | undefined { + return metadataFlag(flags, key, { allowNull: true }) as + | MetadataInput + | undefined; +} + +export function requireMetadataInputFlag( + flags: Flags, + key = "metadata", +): MetadataInput { + const metadata = metadataInputFlag(flags, key); + if (!metadata) throw new Error(`Missing required flag: --${key}`); + return metadata; +} + +export function formatMetadata(metadata: Metadata | null | undefined): string { + if (!metadata || Object.keys(metadata).length === 0) return ""; + return Object.entries(metadata) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${String(value)}`) + .join(", "); +} diff --git a/src/lib/registry.tsx b/src/lib/registry.tsx index d5f718d..160dc9f 100644 --- a/src/lib/registry.tsx +++ b/src/lib/registry.tsx @@ -8,6 +8,7 @@ import type { ContentTypeFilter, FileExtension, FollowableType, + GroupSort, Movement, SearchScope, SearchSort, @@ -48,6 +49,7 @@ import { ConnectionGetCommand, ConnectionDeleteCommand, ConnectionMoveCommand, + ConnectionUpdateCommand, BlockConnectionsCommand, ChannelConnectionsCommand, ChannelFollowersCommand, @@ -72,6 +74,7 @@ import { UserContentsCommand, UserFollowersCommand, UserFollowingCommand, + UserGroupsCommand, UserViewCommand, } from "../commands/user"; import { VersionCommand } from "../commands/version"; @@ -79,6 +82,11 @@ import { WhoamiCommand } from "../commands/whoami"; import { config } from "./config"; import type { DestructiveCommandConfig } from "./destructive-confirmation"; import { uploadLocalFile } from "./upload"; +import { + entityMetadataFlag, + metadataInputFlag, + requireMetadataInputFlag, +} from "./metadata"; import { CLI_PACKAGE_NAME, getCliVersion } from "./version"; interface HelpLine { @@ -146,17 +154,17 @@ export const commands: CommandDefinition[] = [ }, { usage: - "channel create [--visibility <public|private|closed>] [--description <text>] [--group-id <id>]", + "channel create <title> [--visibility <public|private|closed>] [--description <text>] [--group-id <id>] [--metadata <json|key=value>]", description: "Options", }, { usage: - 'channel create "My Research" --visibility private --group-id 123', + 'channel create "My Research" --visibility private --metadata status=draft', description: "Example", }, { usage: - "channel update <slug> [--title <text>] [--description <text>] [--visibility <public|private|closed>]", + "channel update <slug> [--title <text>] [--description <text>] [--visibility <public|private|closed>] [--metadata <json|key=value>]", description: "Options", }, { @@ -201,6 +209,7 @@ export const commands: CommandDefinition[] = [ visibility={visibility} description={flag(flags, "description")} groupId={intFlag(flags, "group-id")} + metadata={entityMetadataFlag(flags)} /> ); case "update": @@ -210,6 +219,7 @@ export const commands: CommandDefinition[] = [ title={flag(flags, "title")} visibility={visibility} description={flag(flags, "description")} + metadata={metadataInputFlag(flags)} /> ); case "delete": @@ -266,6 +276,7 @@ export const commands: CommandDefinition[] = [ visibility, description: flag(flags, "description"), group_id: intFlag(flags, "group-id"), + metadata: entityMetadataFlag(flags), }, }), ); @@ -277,6 +288,7 @@ export const commands: CommandDefinition[] = [ title: flag(flags, "title"), visibility, description: flag(flags, "description"), + metadata: metadataInputFlag(flags), }, }), ); @@ -346,7 +358,7 @@ export const commands: CommandDefinition[] = [ { usage: "block 12345", description: "Example" }, { usage: - "block update <id> [--title <text>] [--description <text>] [--content <text>] [--alt-text <text>]", + "block update <id> [--title <text>] [--description <text>] [--content <text>] [--alt-text <text>] [--metadata <json|key=value>]", description: "Options", }, { @@ -383,6 +395,7 @@ export const commands: CommandDefinition[] = [ description={flag(flags, "description")} content={flag(flags, "content")} altText={flag(flags, "alt-text")} + metadata={metadataInputFlag(flags)} /> ); case "comments": @@ -420,6 +433,7 @@ export const commands: CommandDefinition[] = [ description: flag(flags, "description"), content: flag(flags, "content"), alt_text: flag(flags, "alt-text"), + metadata: metadataInputFlag(flags), }, }), ); @@ -576,6 +590,14 @@ export const commands: CommandDefinition[] = [ usage: "add <channel> <value> --insert-at 1", description: "Example", }, + { + usage: "add <channel> <value> --metadata status=reviewed", + description: "Example", + }, + { + usage: "add <channel> <value> --connection-metadata placement=homepage", + description: "Example", + }, ], render(args, flags) { const argValue = args.slice(1).join(" ").trim() || undefined; @@ -589,6 +611,8 @@ export const commands: CommandDefinition[] = [ originalSourceUrl={flag(flags, "original-source-url")} originalSourceTitle={flag(flags, "original-source-title")} insertAt={intFlag(flags, "insert-at")} + metadata={entityMetadataFlag(flags)} + connectionMetadata={entityMetadataFlag(flags, "connection-metadata")} /> ); }, @@ -603,18 +627,29 @@ export const commands: CommandDefinition[] = [ const stdin = argValue ? undefined : await readStdin(); const value = argValue ?? stdin; if (!value) throw new Error("Missing required argument: value"); + const metadata = entityMetadataFlag(flags); + const connectionMetadata = entityMetadataFlag( + flags, + "connection-metadata", + ); return getData( client.POST("/v3/blocks", { body: { value, - channel_ids: [ch.id], + channels: [ + { + id: ch.id, + position: intFlag(flags, "insert-at"), + metadata: connectionMetadata, + }, + ], title: flag(flags, "title"), description: flag(flags, "description"), alt_text: flag(flags, "alt-text"), original_source_url: flag(flags, "original-source-url"), original_source_title: flag(flags, "original-source-title"), - insert_at: intFlag(flags, "insert-at"), + metadata, }, }), ); @@ -765,11 +800,12 @@ export const commands: CommandDefinition[] = [ help: [ { usage: - "connect <id> <channel> [--type <Block|Channel>] [--position <n>]", + "connect <id> <channel> [--type <Block|Channel>] [--position <n>] [--metadata <json|key=value>]", description: "Options", }, { - usage: "connect <id> <channel> --type Channel --position 1", + usage: + "connect <id> <channel> --type Channel --position 1 --metadata status=reviewed", description: "Example", }, ], @@ -780,20 +816,28 @@ export const commands: CommandDefinition[] = [ channel={requireArg(args, 1, "channel")} connectableType={flagAs<"Block" | "Channel">(flags, "type")} position={intFlag(flags, "position")} + metadata={entityMetadataFlag(flags)} /> ); }, async json(args, flags) { - await client.POST("/v3/connections", { - body: { - connectable_id: idArg(args, 0, "block id"), - channel_ids: [requireArg(args, 1, "channel")], - connectable_type: - flagAs<"Block" | "Channel">(flags, "type") || "Block", - position: intFlag(flags, "position"), - }, - }); - return { connected: true }; + const response = await getData( + client.POST("/v3/connections", { + body: { + connectable_id: idArg(args, 0, "block id"), + connectable_type: + flagAs<"Block" | "Channel">(flags, "type") || "Block", + channels: [ + { + id: requireArg(args, 1, "channel"), + position: intFlag(flags, "position"), + metadata: entityMetadataFlag(flags), + }, + ], + }, + }), + ); + return { connected: true, ...response }; }, }, @@ -806,6 +850,14 @@ export const commands: CommandDefinition[] = [ { usage: "connection 67890", description: "Example" }, { usage: "connection delete <id>", description: "Options" }, { usage: "connection delete 67890", description: "Example" }, + { + usage: "connection update <id> --metadata <json|key=value>", + description: "Options", + }, + { + usage: "connection update 67890 --metadata status=reviewed,score=1", + description: "Example", + }, { usage: "connection move <id> [--movement <move_to_top|move_to_bottom|insert_at>] [--position <n>]", @@ -836,6 +888,13 @@ export const commands: CommandDefinition[] = [ position={intFlag(flags, "position")} /> ); + case "update": + return ( + <ConnectionUpdateCommand + id={idArg(args, 1, "connection id")} + metadata={requireMetadataInputFlag(flags)} + /> + ); default: return <ConnectionGetCommand id={idArg(args, 0, "connection id")} />; } @@ -858,6 +917,13 @@ export const commands: CommandDefinition[] = [ }, }), ); + case "update": + return getData( + client.PUT("/v3/connections/{id}", { + params: { path: { id: idArg(args, 1, "connection id") } }, + body: { metadata: requireMetadataInputFlag(flags) }, + }), + ); default: return getData( client.GET("/v3/connections/{id}", { @@ -950,6 +1016,14 @@ export const commands: CommandDefinition[] = [ usage: "user following <slug> --type User --sort connected_at_desc", description: "Example", }, + { + usage: "user groups <slug> [--page <n>] [--per <n>] [--sort <s>]", + description: "Options", + }, + { + usage: "user groups <slug> --sort updated_at_desc", + description: "Example", + }, ], session: { args: "<slug>", desc: "View a user profile" }, render(args, flags) { @@ -984,6 +1058,15 @@ export const commands: CommandDefinition[] = [ sort={flagAs<ConnectionSort>(flags, "sort")} /> ); + case "groups": + return ( + <UserGroupsCommand + slug={requireArg(args, 1, "slug")} + page={optPage(flags)} + per={optPer(flags)} + sort={flagAs<GroupSort>(flags, "sort")} + /> + ); default: return <UserViewCommand slug={requireArg(args, 0, "slug")} />; } @@ -1032,6 +1115,19 @@ export const commands: CommandDefinition[] = [ }, }), ); + case "groups": + return getData( + client.GET("/v3/users/{id}/groups", { + params: { + path: { id: requireArg(args, 1, "slug") }, + query: { + page: page(flags), + per: per(flags), + sort: flagAs<GroupSort>(flags, "sort"), + }, + }, + }), + ); default: return getData( client.GET("/v3/users/{id}", { @@ -1386,8 +1482,14 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { flag: "--group-id <id>", description: "Create channel under a group", }, + { + flag: "--metadata <json|key=value>", + description: "Custom channel metadata", + }, + ], + examples: [ + 'arena channel create "Team Notes" --group-id 123 --metadata status=draft', ], - examples: ['arena channel create "Team Notes" --group-id 123'], }, update: { summary: "Update a channel.", @@ -1399,8 +1501,14 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { flag: "--visibility <public|private|closed>", description: "New visibility", }, + { + flag: "--metadata <json|key=value>", + description: "Merge channel metadata; use null to remove keys", + }, + ], + examples: [ + 'arena channel update my-research --title "New Title" --metadata status=published', ], - examples: ['arena channel update my-research --title "New Title"'], }, delete: { summary: "Delete a channel.", @@ -1450,9 +1558,13 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { { flag: "--description <text>", description: "New description" }, { flag: "--content <text>", description: "Text content" }, { flag: "--alt-text <text>", description: "Image alt text" }, + { + flag: "--metadata <json|key=value>", + description: "Merge block metadata; use null to remove keys", + }, ], examples: [ - 'arena block update 12345 --title "New Title" --description "Updated"', + 'arena block update 12345 --title "New Title" --metadata status=reviewed', ], }, comments: { @@ -1506,10 +1618,18 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { flag: "--insert-at <n>", description: "Insert position within the channel", }, + { + flag: "--metadata <json|key=value>", + description: "Custom block metadata", + }, + { + flag: "--connection-metadata <json|key=value>", + description: "Metadata for the channel connection", + }, ], examples: [ 'arena add my-channel "Hello world"', - 'arena add my-channel https://example.com --alt-text "Cover image" --insert-at 1', + 'arena add my-channel https://example.com --alt-text "Cover image" --insert-at 1 --metadata status=reviewed', 'echo "piped text" | arena add my-channel', ], seeAlso: ["upload", "batch", "channel"], @@ -1582,14 +1702,21 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { options: [ { flag: "--type <Block|Channel>", description: "Connectable type" }, { flag: "--position <n>", description: "Insertion position" }, + { + flag: "--metadata <json|key=value>", + description: "Metadata for the created connection", + }, + ], + examples: [ + "arena connect 12345 my-channel --type Channel --position 1 --metadata status=reviewed", ], - examples: ["arena connect 12345 my-channel --type Channel --position 1"], seeAlso: ["connection", "block", "channel"], }, connection: { - summary: "Inspect, move, or delete a connection.", + summary: "Inspect, update, move, or delete a connection.", usage: [ "arena connection <id>", + "arena connection update <id> --metadata <json|key=value>", "arena connection delete <id>", "arena connection move <id> [flags]", ], @@ -1602,9 +1729,14 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { flag: "--position <n>", description: "Target position (for move subcommand)", }, + { + flag: "--metadata <json|key=value>", + description: "Merge metadata for update; use null to remove keys", + }, ], examples: [ "arena connection 67890", + "arena connection update 67890 --metadata status=reviewed", "arena connection move 67890 --movement insert_at --position 1", ], seeAlso: ["connect"], @@ -1661,6 +1793,16 @@ export const commandHelpDocs: Record<string, CommandHelpDoc> = { "arena user following damon-zucconi --type User --sort connected_at_desc", ], }, + groups: { + summary: "List groups a user belongs to.", + usage: ["arena user groups <slug> [flags]"], + options: [ + { flag: "--page <n>", description: "Page number" }, + { flag: "--per <n>", description: "Items per page" }, + { flag: "--sort <s>", description: "Sort order" }, + ], + examples: ["arena user groups damon-zucconi --sort updated_at_desc"], + }, }, seeAlso: ["group", "search"], },