import {avatarTemplate, escapeText, localizeDate} from "fwtoolkit" import type {Node} from "prosemirror-model" import {READ_ONLY_ROLES} from "../index.js" import {serializeHelp} from "../document_template/index.js" import {serializeComment} from "../comments/editors/index.js" import type {DocInfo, EditorUser} from "../types.js" export interface MarginBoxFilterOptions { track?: boolean comments?: boolean info?: boolean help?: boolean warning?: boolean commentsResolved?: boolean commentsOnlyMajor?: boolean commentsAuthor?: number trackAuthor?: number author?: number assigned?: number } export interface CommentAnswer { id: number | string user: number username: string date: number answer: unknown[] } export interface Comment { id: number user: number username: string date: number comment: unknown[] answers?: CommentAnswer[] isMajor?: boolean resolved?: boolean hidden?: boolean isGlobal?: boolean assignedUser?: number assignedUsername?: string } export interface TrackData { user: number username: string date: number before?: | string[] | {type: string; attrs?: {order?: number; level?: number}} after?: string[] } export interface Participant { id: number name: string } interface ContactPerson { id: number | string type?: string name?: string avatar?: string } export interface CommentMarginBox { type: "comment" data: Comment view: string active: boolean pos?: number } export interface TrackMarginBox { type: "insertion" | "deletion" | "format_change" | "block_change" data: TrackData node: Node view: string active: boolean pos?: number } export interface HelpMarginBox { type: "help" data: {active?: boolean; help: unknown} active?: boolean } export interface WarningMarginBox { type: "warning" data: {active?: boolean; warning: string} active?: boolean } export type MarginBox = | CommentMarginBox | TrackMarginBox | HelpMarginBox | WarningMarginBox interface MarginBoxOptionComment { answer?: boolean id: number | string commentId?: number | string user: number resolved?: boolean } /** A template for an answer to a comment */ const COMMENT_SHOW_MORE_THRESHOLD = 120 const COMMENT_TRUNCATE_LENGTH = 110 function commentContentTemplate({ serialized, isGlobal }: { serialized: {html: string; text: string} isGlobal: boolean }): string { if (isGlobal || serialized.text.length <= COMMENT_SHOW_MORE_THRESHOLD) { return `

${serialized.html}

` } const truncatedText = `${escapeText( serialized.text.slice(0, COMMENT_TRUNCATE_LENGTH - 1) )}…` return `

${truncatedText}

` } /** A template for an answer to a comment */ const answerCommentTemplate = ({ answer, author, commentId, activeCommentAnswerId, active, user, isGlobal }: { answer: CommentAnswer author: ContactPerson | undefined commentId: number | string activeCommentAnswerId: number | string | undefined active: boolean user: EditorUser isGlobal: boolean }) => { const serialized = serializeComment(answer.answer) return `
${author ? avatarTemplate({user: author}) : ''}
${escapeText(author?.name || answer.username)}

${localizeDate(answer.date)}

${ active && answer.id === activeCommentAnswerId ? `
` : `
${commentContentTemplate({serialized, isGlobal})}
${ !isGlobal && serialized.text.length > COMMENT_SHOW_MORE_THRESHOLD ? `${gettext("show more")}` : "" }
${ answer.user === user.id ? `` : "" }` }
` } interface SingleCommentTemplateProps { comment: Comment author: ContactPerson | undefined active: boolean editComment: boolean } /** A template to show one individual comment */ const singleCommentTemplate = ({ comment, author, active, editComment }: SingleCommentTemplateProps) => { const serialized = serializeComment(comment.comment) return `
${author ? avatarTemplate({user: author}) : ''}
${escapeText(author?.name || comment.username)}

${localizeDate(comment.date)}

${ active && editComment ? '
' : `${commentContentTemplate({ serialized, isGlobal: !!comment.isGlobal })}` }
${ !editComment && !comment.isGlobal && serialized.text.length > COMMENT_SHOW_MORE_THRESHOLD ? `${gettext("show more")}` : "" } ${ !active && (comment.answers?.length || 0) > 0 ? `+${comment.answers!.length} ${gettext("replies")}` : "" }
` } /** A template for the editor of a first comment before it has been saved (not an answer to a comment). */ const firstCommentTemplate = ({ comment, author }: { comment: Comment author: ContactPerson | undefined }) => `
${author ? avatarTemplate({user: author}) : ''}
${escapeText(author?.name || comment.username)}

${localizeDate(comment.date)}

` const helpTemplate = ({ help, filterOptions }: { help: {active?: boolean; help: unknown} filterOptions: MarginBoxFilterOptions }) => { if (!filterOptions.help || !filterOptions.info) { return '' } else { return `
${serializeHelp(help.help)}
` } } const warningTemplate = ({ warning, filterOptions }: { warning: {active?: boolean; warning: string} filterOptions: MarginBoxFilterOptions }) => { if (!filterOptions.warning || !filterOptions.info) { return '' } else { return `
${warning.warning}
` } } const commentTemplate = ({ comment, view, active, editComment, activeCommentAnswerId, user, docInfo, filterOptions }: { comment: Comment view: string active: boolean editComment: boolean activeCommentAnswerId: number | string | undefined user: EditorUser docInfo: DocInfo filterOptions: MarginBoxFilterOptions }) => { if ( !filterOptions.comments || (filterOptions.commentsOnlyMajor && !comment.isMajor) || (!filterOptions.commentsResolved && comment.resolved) || (filterOptions.commentsAuthor && comment.user !== filterOptions.commentsAuthor) || (filterOptions.assigned && comment.assignedUser !== filterOptions.assigned) || comment.hidden ) { return '' } const author = comment.user === docInfo.owner?.id ? docInfo.owner : docInfo.owner?.contacts.find( contact => contact.id === comment.user && contact.type === "user" ), assignedUser = comment.assignedUser ? comment.assignedUser === docInfo.owner?.id ? docInfo.owner : docInfo.owner?.contacts.find( contact => contact.id === comment.assignedUser && contact.type === "user" ) || { name: comment.assignedUsername || "" } : false, assignedUsername = assignedUser ? assignedUser.name : false return `
${ comment.isGlobal ? `
${gettext("Document comment")}
` : "" }
${ comment.comment.length === 0 ? firstCommentTemplate({comment, author}) : singleCommentTemplate({comment, author, active, editComment}) } ${ assignedUsername ? `
${gettext("Assigned to")} ${escapeText(assignedUsername || "")}
` : "" } ${ comment.answers ? comment.answers .map(answer => answerCommentTemplate({ answer, author: answer.user === docInfo.owner?.id ? docInfo.owner : docInfo.owner?.contacts.find( contact => contact.id === answer.user && contact.type === "user" ), commentId: comment.id, active, activeCommentAnswerId, user, isGlobal: !!comment.isGlobal }) ) .join("") : "" } ${ comment.id > 0 && ((comment.user === user.id && !READ_ONLY_ROLES.includes(docInfo.access_rights || "")) || docInfo.access_rights === "write") && !editComment ? `` : "" }
${ active && !activeCommentAnswerId && !editComment && 0 < comment.comment.length && !READ_ONLY_ROLES.includes(docInfo.access_rights || "") ? `
` : "" }
` } const ACTIONS: Record = { insertion: gettext("Insertion"), deletion: gettext("Deletion"), format_change: gettext("Format change"), block_change: gettext("Block change"), insertion_paragraph: gettext("New paragraph"), insertion_heading: gettext("New heading"), insertion_citation: gettext("Inserted citation"), insertion_blockquote: gettext("Wrapped into blockquote"), insertion_code_block: gettext("Added code block"), insertion_figure: gettext("Inserted figure"), insertion_list_item: gettext("New list item"), insertion_table: gettext("Inserted table"), insertion_keyword: gettext("New keyword: %(keyword)s"), deletion_paragraph: gettext("Merged paragraph"), deletion_heading: gettext("Merged heading"), deletion_citation: gettext("Deleted citation"), deletion_blockquote: gettext("Unwrapped blockquote"), deletion_code_block: gettext("Removed code block"), deletion_figure: gettext("Deleted figure"), deletion_list_item: gettext("Lifted list item"), deletion_table: gettext("Delete table"), deletion_keyword: gettext("Deleted keyword: %(keyword)s"), block_change_paragraph: gettext("Changed into paragraph"), block_change_heading: gettext("Changed into heading %(level)s"), block_change_code_block: gettext("Changed into code block") } const FORMAT_MARK_NAMES: Record = { em: gettext("Emphasis"), strong: gettext("Strong"), underline: gettext("Underline") } const formatChangeTemplate = ({ before, after }: { before: string[] after: string[] }) => { let returnText = "" if (before.length) { returnText += `
${gettext("Removed")}: ${before.map(markName => FORMAT_MARK_NAMES[markName]).join(", ")}
` } if (after.length) { returnText += `
${gettext("Added")}: ${after.map(markName => FORMAT_MARK_NAMES[markName]).join(", ")}
` } return returnText } const BLOCK_NAMES: Record = { paragraph: gettext("Paragraph"), heading1: gettext("Heading 1"), heading2: gettext("Heading 2"), heading3: gettext("Heading 3"), heading4: gettext("Heading 4"), heading5: gettext("Heading 5"), heading6: gettext("Heading 6"), code_block: gettext("Code block"), ordered_list: gettext("Ordered list"), bullet_list: gettext("Bullet list") } const blockChangeTemplate = ( {before}: {before: {type: string; attrs?: {order?: number}}}, node: Node ) => `
${gettext("Was")}: ${BLOCK_NAMES[before.type]}${before.type === "ordered_list" && node.type.name === "ordered_list" ? `, ${gettext("start")}: ${before.attrs?.order}` : ""}
` const trackTemplate = ({ type, data, node, active, docInfo, filterOptions }: { type: TrackMarginBox["type"] data: TrackData node: Node active: boolean docInfo: DocInfo filterOptions: MarginBoxFilterOptions }) => { if ( !filterOptions.track || (filterOptions.trackAuthor && data.user !== filterOptions.trackAuthor) ) { return '' } const author = data.user === docInfo.owner?.id ? docInfo.owner : docInfo.owner?.contacts.find( contact => contact.id === data.user && contact.type === "user" ), nodeActionType = `${type}_${node.type.name}` return `
${author ? avatarTemplate({user: author}) : ''}
${escapeText(author?.name || data.username)}

${node.type.name === "text" ? `${gettext("ca.")} ` : ""}${localizeDate(data.date * 60000, "minutes")}

${interpolate(ACTIONS[nodeActionType] ? ACTIONS[nodeActionType] : ACTIONS[type], node.attrs as unknown as (string | number)[], true)}
${type === "format_change" ? formatChangeTemplate({before: data.before as string[], after: data.after || []}) : type === "block_change" ? blockChangeTemplate({before: data.before as {type: string; attrs?: {order?: number}}}, node) : ""}
${ docInfo.access_rights === "write" ? `
` : "" }
` } export const marginboxFilterTemplate = ({ marginBoxes, filterOptions, pastParticipants }: { marginBoxes: MarginBox[] filterOptions: MarginBoxFilterOptions pastParticipants: Participant[] }) => { const comments = marginBoxes.find(box => box.type === "comment") const tracks = marginBoxes.find(box => ["insertion", "deletion", "format_change", "block_change"].includes( box.type ) ) const help = marginBoxes.find(box => box.type === "help") const warning = marginBoxes.find(box => box.type === "warning") let filterHTML = "" if (comments || filterOptions.commentsOnlyMajor) { filterHTML += `
${gettext("Comments")}
  • ${gettext("Author")}
    • ${gettext("Any")}
    • ${pastParticipants .map( user => `
    • ${escapeText(user.name)}
    • ` ) .join("")}
  • ${gettext("Assignee")}
    • ${gettext("Any/None")}
    • ${pastParticipants .map( user => `
    • ${escapeText(user.name)}
    • ` ) .join("")}
` } if (tracks) { filterHTML += `
${gettext("Tracking")}
  • ${gettext("Author")}
    • ${gettext("Any")}
    • ${pastParticipants .map( user => `
    • ${escapeText(user.name)}
    • ` ) .join("")}
` } if (help || warning) { filterHTML += `
${gettext("Informational")}
` } return filterHTML } /** A template to display global document comments in the main column */ export const globalCommentsTemplate = ({ globalComments, editComment, activeCommentAnswerId, user, docInfo, filterOptions }: { globalComments: CommentMarginBox[] editComment: boolean activeCommentAnswerId: number | string | undefined user: EditorUser docInfo: DocInfo filterOptions: MarginBoxFilterOptions }) => `
${globalComments .map(mBox => commentTemplate({ comment: mBox.data, view: mBox.view, active: mBox.active, activeCommentAnswerId, editComment, user, docInfo, filterOptions }) ) .join("")}
` /** A template to display all the margin boxes (comments, deletion/insertion notifications) */ export const marginBoxesTemplate = ({ marginBoxes, editComment, activeCommentAnswerId, user, docInfo, filterOptions }: { marginBoxes: MarginBox[] editComment: boolean activeCommentAnswerId: number | string | undefined user: EditorUser docInfo: DocInfo filterOptions: MarginBoxFilterOptions }) => `
${marginBoxes .map(mBox => { let returnValue = "" switch (mBox.type) { case "comment": returnValue = commentTemplate({ comment: mBox.data, view: mBox.view, active: mBox.active, activeCommentAnswerId, editComment, user, docInfo, filterOptions }) break case "insertion": case "deletion": case "format_change": case "block_change": returnValue = trackTemplate({ type: mBox.type, node: mBox.node, data: mBox.data, active: mBox.active, docInfo, filterOptions }) break case "help": returnValue = helpTemplate({help: mBox.data, filterOptions}) break case "warning": returnValue = warningTemplate({ warning: mBox.data, filterOptions }) break default: break } return returnValue }) .join("")}
` function getAssignees(docInfo: DocInfo): ContactPerson[] { return docInfo.owner ? [docInfo.owner, ...docInfo.owner.contacts] : [] } export const marginBoxOptions = ( comment: MarginBoxOptionComment, user: EditorUser, docInfo: DocInfo ) => { return `
${ !comment.answer ? `
    ${ comment.user === user.id ? `
  • ${gettext("Edit")}
  • ` : "" }
  • ${gettext("Assign to")}
    • ${gettext("No-one")}
    • ${getAssignees(docInfo) .filter(contact => contact.type !== "userinvite") .map( contact => `
    • ${escapeText(contact.name || "")}
    • ` ) .join("")}
  • ${ comment.resolved ? `${gettext("Recreate")}` : `${gettext("Resolve")}` }
  • ${gettext("Delete")}
` : `
  • ${gettext("Edit")}
  • ${gettext("Delete")}
` }
` }