import { z } from "zod"; import { uuid } from "./index.ts"; /** * Comments and reactions on the text of a shared work * (docs/specs/COMMENTS.md). Shared by the server, the web app and the * agent tools: limits, the fixed reaction set and the wire shapes. */ /** A comment body, in characters (code points). */ export const COMMENT_MAX_CHARS = 2000; /** New comments on one link per day. */ export const COMMENTS_PER_SHARE_PER_DAY = 200; /** Comments and reactions of one author per hour. */ export const COMMENT_ACTIONS_PER_AUTHOR_PER_HOUR = 60; /** Comments of one link a reader is shown: the newest ones (`truncated`). */ export const COMMENTS_SHOWN_PER_SHARE = 5000; /** Reaction groups (fragment and emoji) of one link, the oldest first. */ export const REACTION_GROUPS_SHOWN_PER_SHARE = 1000; /** Letters about comments to one address per day. */ export const COMMENT_MAIL_PER_ADDRESS_PER_DAY = 30; /** Characters of a comment quoted in a letter. */ export const COMMENT_MAIL_EXCERPT = 300; /** Context kept on each side of a quote. */ export const ANCHOR_CONTEXT_CHARS = 32; export const ANCHOR_EXACT_MAX = 2000; /** * The installation's comment mode (COMMENTS_MODE): on — recipients comment * and react; owner-notes — only the work's owner (and their agent) writes, * recipients read; off — no discussions. */ export const COMMENTS_MODES = ["on", "owner-notes", "off"] as const; export type CommentsMode = (typeof COMMENTS_MODES)[number]; export const REACTIONS = ["👍", "👎", "🎉", "🤔", "❤️", "👀", "✅"] as const; export type Reaction = (typeof REACTIONS)[number]; const noControl = (value: string) => // Tabs and newlines are text; other control and bidi-override characters // are not, and could disguise a comment. !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/.test(value); export const anchorSchema = z .object({ exact: z.string().min(1).max(ANCHOR_EXACT_MAX).refine(noControl), prefix: z .string() .max(ANCHOR_CONTEXT_CHARS * 2) .refine(noControl) .default(""), suffix: z .string() .max(ANCHOR_CONTEXT_CHARS * 2) .refine(noControl) .default(""), }) .strict() .refine((anchor) => anchor.exact.trim().length > 0, { message: "The quote is empty", }); export type CommentAnchor = z.infer; export const commentBody = z .string() .transform((value) => value.replace(/\r\n?/g, "\n").trim()) .refine((value) => value.length > 0, { message: "Напишите комментарий." }) .refine((value) => [...value].length <= COMMENT_MAX_CHARS, { message: `Комментарий \u2014 до ${COMMENT_MAX_CHARS} символов.`, }) .refine(noControl, { message: "Уберите управляющие символы." }); const shareToken = z.string().regex(/^[A-Za-z0-9_-]{43}$/); /** * The name shown under a person's comments, chosen before the first one: * everyone with the link sees it. Not an address. */ export const commentName = z .string() .transform((value) => value.replace(/\s+/g, " ").trim()) .refine((value) => value.length >= 1 && [...value].length <= 40, { message: "Имя — от 1 до 40 символов.", }) .refine(noControl, { message: "Уберите управляющие символы." }) .refine((value) => !value.includes("@"), { message: "Имя не должно быть адресом почты.", }); export const commentSettingsSchema = z .object({ displayName: commentName.optional(), commentMail: z.boolean().optional(), }) .strict() .refine((value) => value.displayName !== undefined || value.commentMail !== undefined); export const commentMailOffSchema = z.object({ token: z.string().max(400) }).strict(); /** A recipient's request: the link's own token identifies the thread set. */ export const sharedCommentsSchema = z.object({ token: shareToken }).strict(); const commentFields = { body: commentBody, /** Required before an account's first comment (see commentName). */ displayName: commentName.optional(), anchor: anchorSchema.nullable().optional(), parentId: uuid.optional(), }; const replyHasNoQuote = (value: { parentId?: string; anchor?: unknown }) => !(value.parentId && value.anchor); const noQuoteMessage = { message: "A reply has no quote of its own" }; /** A recipient writes in the thread set of the link they opened. */ export const sharedCreateCommentSchema = z .object({ token: shareToken, ...commentFields }) .strict() .refine(replyHasNoQuote, noQuoteMessage); /** The owner writes in the thread set of one of the work's links. */ export const ownerCreateCommentSchema = z .object({ shareId: uuid, ...commentFields }) .strict() .refine(replyHasNoQuote, noQuoteMessage); const reactFields = { emoji: z.enum(REACTIONS), anchor: anchorSchema.nullable().optional(), }; export const sharedReactSchema = z.object({ token: shareToken, ...reactFields }).strict(); export const ownerReactSchema = z.object({ shareId: uuid, ...reactFields }).strict(); export const sharedCommentActionSchema = z.object({ token: shareToken, commentId: uuid }).strict(); export const sharedResolveSchema = z .object({ token: shareToken, commentId: uuid, resolved: z.boolean().default(true), }) .strict(); export const resolveSchema = z.object({ resolved: z.boolean().default(true) }).strict(); export type CommentAuthor = { /** Display name; never an address. */ name: string; /** The work's owner (the shelf the link belongs to). */ owner: boolean; /** The person asking. */ me: boolean; }; export type CommentView = { id: string; parentId: string | null; author: CommentAuthor | null; /** Plain text: shown as text, never as HTML; links are not clickable. */ body: string; anchor: CommentAnchor | null; /** The fragment signature (reactions on the same fragment share it); "" without a quote. */ sig: string; revisionId: string; revisionNumber: number; createdAt: string; resolvedAt: string | null; deleted: boolean; /** Waits for the operator: visible to its author and the owner only. */ held: boolean; canDelete: boolean; canResolve: boolean; }; export type CommentThread = CommentView & { replies: CommentView[] }; export type ReactionGroup = { /** '' for the whole work, else the fragment's signature. */ sig: string; anchor: CommentAnchor | null; emoji: Reaction; count: number; mine: boolean; }; export type ShareDiscussion = { /** The installation's mode; owner-notes: only the owner writes. */ mode: CommentsMode; shareId: string; /** The version the link shows now; anchors of other versions may be lost. */ revisionId: string; revisionNumber: number; state: "active" | "revoked" | "expired"; threads: CommentThread[]; reactions: ReactionGroup[]; /** * The link has more comments or reaction groups than are shown * (COMMENTS_SHOWN_PER_SHARE, REACTION_GROUPS_SHOWN_PER_SHARE): the oldest * comments and the newest reaction groups are left out. */ truncated: boolean; }; /** The person asking, as far as comments are concerned. */ export type CommentViewer = { signedIn: boolean; name: string | null; owner: boolean; /** The name under comments was chosen; until then a comment asks for it. */ nameChosen: boolean; /** Letters about comments are on. */ commentMail: boolean; }; /** What a link's recipient gets. */ export type SharedComments = ShareDiscussion & { viewer: CommentViewer }; /** What the owner gets on the work page: every link of the work. */ export type WorkComments = { mode: CommentsMode; artifactId: string; unread: number; shares: ShareDiscussion[]; viewer: CommentViewer; }; /** Structured refusal of a patch edit (HTTP 422 / tool error). */ export type EditFailure = { code: "edit_failed"; editIndex: number; otherEditIndex?: number; reason: "empty_old_text" | "not_found" | "ambiguous" | "overlap" | "no_change"; occurrences?: number; message: string; }; export const editSchema = z .object({ oldText: z.string().max(1_000_000), newText: z.string().max(1_000_000), }) .strict(); export const editsSchema = z.array(editSchema).min(1).max(50);