Ctrl K
ComunidadGuías…

packages/contracts/comments.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<typeof anchorSchema>;

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);