From 70559910b1518aa578da52b6c14c42a0603b0a4e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 10 Jul 2026 10:19:42 +0300 Subject: [PATCH] Add Tasker Markdown export and project logo picker --- .../components/projects/create/attributes.tsx | 4 +- .../issue-detail-quick-actions.tsx | 2 + .../issue-detail/markdown-export-button.tsx | 201 +++++++ .../issues/issue-detail/markdown-export.ts | 558 ++++++++++++++++++ .../issues/peek-overview/header.tsx | 28 +- .../apps/web/core/components/project/form.tsx | 462 +++++++-------- .../project/project-logo-picker-modal.tsx | 109 ++++ .../propel/src/emoji-icon-picker/index.ts | 2 + 8 files changed, 1118 insertions(+), 248 deletions(-) create mode 100644 plane-src/apps/web/core/components/issues/issue-detail/markdown-export-button.tsx create mode 100644 plane-src/apps/web/core/components/issues/issue-detail/markdown-export.ts create mode 100644 plane-src/apps/web/core/components/project/project-logo-picker-modal.tsx diff --git a/plane-src/apps/web/ce/components/projects/create/attributes.tsx b/plane-src/apps/web/ce/components/projects/create/attributes.tsx index 0e5b129..8aca750 100644 --- a/plane-src/apps/web/ce/components/projects/create/attributes.tsx +++ b/plane-src/apps/web/ce/components/projects/create/attributes.tsx @@ -24,8 +24,7 @@ function ProjectAttributes(props: Props) { const { t } = useTranslation(); const { control } = useFormContext(); const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile); - const projectAttributeChipClassName = - "nodedc-modal-chip !h-10 !rounded-[1.25rem] !px-4 !py-2 !text-13"; + const projectAttributeChipClassName = "nodedc-modal-chip !h-10 !rounded-[1.25rem] !px-4 !py-2 !text-13"; return (
diff --git a/plane-src/apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx b/plane-src/apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx index 63f3307..5663ba2 100644 --- a/plane-src/apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx +++ b/plane-src/apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx @@ -23,6 +23,7 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // local imports import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns"; +import { IssueMarkdownExportButton } from "./markdown-export-button"; import { IssueSubscription } from "./subscription"; type Props = { @@ -150,6 +151,7 @@ export const IssueDetailQuickActions = observer(function IssueDetailQuickActions + ; + + const issueProjectId = issue.project_id ?? projectId; + const projectIdentifier = getProjectIdentifierById(issueProjectId); + const project = getProjectById(issueProjectId); + const workItemLink = generateWorkItemLink({ + workspaceSlug, + projectId: issueProjectId, + issueId, + projectIdentifier, + sequenceId: issue.sequence_id, + isArchived, + }); + + const getDisplayName = (userId: string | null | undefined) => { + if (!userId) return undefined; + const user = getUserDetails(userId); + return user?.display_name || user?.first_name || user?.email || userId; + }; + + const getIssueListByIds = (issueIds: string[] | undefined) => + (issueIds ?? []).flatMap((currentIssueId) => { + const currentIssue = getIssueById(currentIssueId); + return currentIssue ? [currentIssue] : []; + }); + + const refreshExportData = async () => { + await fetchIssue(workspaceSlug, issueProjectId, issueId); + await Promise.allSettled([ + fetchLinks(workspaceSlug, issueProjectId, issueId), + fetchAttachments(workspaceSlug, issueProjectId, issueId), + fetchSubIssues(workspaceSlug, issueProjectId, issueId), + fetchRelations(workspaceSlug, issueProjectId, issueId), + fetchComments(workspaceSlug, issueProjectId, issueId), + fetchActivities(workspaceSlug, issueProjectId, issueId), + fetchReactions(workspaceSlug, issueProjectId, issueId), + fetchProjectStates(workspaceSlug, issueProjectId), + fetchProjectLabels(workspaceSlug, issueProjectId), + fetchModules(workspaceSlug, issueProjectId), + fetchAllCycles(workspaceSlug, issueProjectId), + ]); + }; + + const buildExportData = () => { + const exportIssue = getIssueById(issueId) ?? issue; + const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const relationMap = relation.getRelationsByIssueId(issueId) ?? {}; + const reactionsByEmoji = reaction.getReactionsByIssueId(issueId) ?? {}; + + return buildIssueMarkdownExport({ + workspaceSlug, + projectName: project?.name, + projectIdentifier, + workItemUrl: `${originURL}${workItemLink}`, + issue: exportIssue, + subIssues: getIssueListByIds(subIssues.subIssuesByIssueId(issueId)), + relations: Object.fromEntries( + Object.entries(relationMap).map(([relationType, relatedIssueIds]) => [ + relationType, + getIssueListByIds(Array.isArray(relatedIssueIds) ? relatedIssueIds : []), + ]) + ), + links: (link.getLinksByIssueId(issueId) ?? []).flatMap((linkId) => { + const issueLink = link.getLinkById(linkId); + return issueLink ? [issueLink] : []; + }), + attachments: (attachment.getAttachmentsByIssueId(issueId) ?? []).flatMap((attachmentId) => { + const issueAttachment = attachment.getAttachmentById(attachmentId); + return issueAttachment ? [issueAttachment] : []; + }), + comments: (comment.getCommentsByIssueId(issueId) ?? []).flatMap((commentId) => { + const issueComment = comment.getCommentById(commentId); + return issueComment ? [issueComment] : []; + }), + activities: (activity.getActivitiesByIssueId(issueId) ?? []).flatMap((activityId) => { + const issueActivity = activity.getActivityById(activityId); + return issueActivity ? [issueActivity] : []; + }), + reactions: Object.entries(reactionsByEmoji).map(([emoji, reactionIds]) => { + const actors = reactionIds + .map((reactionId) => { + const issueReaction = reaction.getReactionById(reactionId); + return issueReaction?.display_name || getDisplayName(issueReaction?.actor); + }) + .filter(Boolean); + + return `${emoji}: ${actors.length > 0 ? actors.join(", ") : reactionIds.length}`; + }), + getIssueById: (currentIssueId) => (currentIssueId ? getIssueById(currentIssueId) : undefined), + getUserDisplayName: getDisplayName, + getStateName: (stateId) => (stateId ? getStateById(stateId)?.name : undefined), + getLabelName: (labelId) => (labelId ? getLabelById(labelId)?.name : undefined), + getModuleName: (moduleId) => (moduleId ? getModuleNameById(moduleId) : undefined), + getCycleName: (cycleId) => (cycleId ? getCycleNameById(cycleId) : undefined), + }); + }; + + const handleDownloadMarkdown = async (event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + if (isExporting) return; + setIsExporting(true); + + try { + await refreshExportData(); + const exportResult = buildExportData(); + downloadIssueMarkdownExport(exportResult); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Markdown скачан", + message: exportResult.fileName, + }); + } catch (error) { + console.error("Error exporting work item markdown:", error); + setToast({ + title: "Не удалось скачать Markdown", + type: TOAST_TYPE.ERROR, + }); + } finally { + setIsExporting(false); + } + }; + + return ( + + + + ); +}); diff --git a/plane-src/apps/web/core/components/issues/issue-detail/markdown-export.ts b/plane-src/apps/web/core/components/issues/issue-detail/markdown-export.ts new file mode 100644 index 0000000..b800692 --- /dev/null +++ b/plane-src/apps/web/core/components/issues/issue-detail/markdown-export.ts @@ -0,0 +1,558 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TIssue, TIssueActivity, TIssueAttachment, TIssueComment, TIssueLink } from "@plane/types"; +import { orderBy } from "lodash-es"; +// local imports +import { + extractIssueStructuredContent, + type TIssueStructuredBlock, +} from "../issue-detail-widgets/structured-content.helpers"; + +type TIssueLookup = (issueId: string | null | undefined) => TIssue | Partial | undefined; +type TEntityNameLookup = (entityId: string | null | undefined) => string | undefined; + +export type TIssueMarkdownExportContext = { + workspaceSlug: string; + projectName?: string; + projectIdentifier?: string; + workItemUrl?: string; + issue: TIssue; + subIssues: TIssue[]; + relations: Record; + links: TIssueLink[]; + attachments: TIssueAttachment[]; + comments: TIssueComment[]; + activities: TIssueActivity[]; + reactions: string[]; + getIssueById: TIssueLookup; + getUserDisplayName: TEntityNameLookup; + getStateName: TEntityNameLookup; + getLabelName: TEntityNameLookup; + getModuleName: TEntityNameLookup; + getCycleName: TEntityNameLookup; +}; + +type TMarkdownExportResult = { + fileName: string; + markdown: string; +}; + +const emptyValue = "Нет"; + +const relationLabels: Record = { + blocking: "Блокирует", + blocked_by: "Заблокировано", + duplicate: "Дубликат", + relates_to: "Связано", + related: "Связано", +}; + +const normalizeText = (value: unknown) => `${value ?? ""}`.trim(); + +const removeControlCharacters = (value: string) => + Array.from(value) + .map((character) => (character.charCodeAt(0) < 32 ? " " : character)) + .join(""); + +const valueOrEmpty = (value: unknown) => { + const normalized = normalizeText(value); + return normalized || emptyValue; +}; + +const listValue = (values: Array) => { + const normalizedValues = values.map((value) => normalizeText(value)).filter(Boolean); + return normalizedValues.length > 0 ? normalizedValues.join(", ") : emptyValue; +}; + +const formatDate = (value: string | Date | null | undefined) => { + if (!value) return emptyValue; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return `${value}`; + + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +}; + +const formatBytes = (value: number | null | undefined) => { + if (!value || value < 0) return emptyValue; + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +}; + +const normalizeMarkdownWhitespace = (value: string) => + value + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]+\n/g, "\n") + .trim(); + +const plainTextFromHtml = (html: string) => + normalizeMarkdownWhitespace( + html + .replace(//gi, "\n") + .replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + ); + +const nodeChildrenToMarkdown = (node: Node, depth = 0): string => + Array.from(node.childNodes) + .map((childNode) => nodeToMarkdown(childNode, depth)) + .join(""); + +const normalizeListItem = (value: string) => value.trim().replace(/\n+/g, "\n "); + +const nodeToMarkdown = (node: Node, depth = 0): string => { + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; + if (node.nodeType !== Node.ELEMENT_NODE) return ""; + + const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); + const childMarkdown = () => nodeChildrenToMarkdown(element, depth); + + if (tagName === "br") return "\n"; + + if (tagName === "p" || tagName === "div") { + const body = normalizeMarkdownWhitespace(childMarkdown()); + return body ? `${body}\n\n` : ""; + } + + if (/^h[1-6]$/.test(tagName)) { + const level = Number(tagName.slice(1)); + const body = normalizeMarkdownWhitespace(childMarkdown()); + return body ? `${"#".repeat(level)} ${body}\n\n` : ""; + } + + if (tagName === "strong" || tagName === "b") { + const body = childMarkdown().trim(); + return body ? `**${body}**` : ""; + } + + if (tagName === "em" || tagName === "i") { + const body = childMarkdown().trim(); + return body ? `_${body}_` : ""; + } + + if (tagName === "code") { + const body = childMarkdown().trim(); + return body ? `\`${body}\`` : ""; + } + + if (tagName === "pre") { + const body = element.textContent?.trim() ?? ""; + return body ? `\n\`\`\`\n${body}\n\`\`\`\n\n` : ""; + } + + if (tagName === "blockquote") { + const body = normalizeMarkdownWhitespace(childMarkdown()); + return body + ? `${body + .split("\n") + .map((line) => `> ${line}`) + .join("\n")}\n\n` + : ""; + } + + if (tagName === "a") { + const body = normalizeMarkdownWhitespace(childMarkdown()) || element.getAttribute("href") || ""; + const href = element.getAttribute("href"); + return href ? `[${body}](${href})` : body; + } + + if (tagName === "img") { + const src = element.getAttribute("src"); + if (!src) return ""; + return `![${element.getAttribute("alt") ?? ""}](${src})`; + } + + if (tagName === "ul") { + return `${Array.from(element.children) + .filter((childElement) => childElement.tagName.toLowerCase() === "li") + .map( + (childElement) => `${" ".repeat(depth)}- ${normalizeListItem(nodeChildrenToMarkdown(childElement, depth + 1))}` + ) + .join("\n")}\n\n`; + } + + if (tagName === "ol") { + return `${Array.from(element.children) + .filter((childElement) => childElement.tagName.toLowerCase() === "li") + .map( + (childElement, index) => + `${" ".repeat(depth)}${index + 1}. ${normalizeListItem(nodeChildrenToMarkdown(childElement, depth + 1))}` + ) + .join("\n")}\n\n`; + } + + if (tagName === "li") return `${" ".repeat(depth)}- ${normalizeListItem(childMarkdown())}\n`; + + return childMarkdown(); +}; + +const htmlToMarkdown = (html: string | null | undefined) => { + const normalizedHtml = normalizeText(html); + if (!normalizedHtml || normalizedHtml === "

") return ""; + + if (typeof document === "undefined") return plainTextFromHtml(normalizedHtml); + + const template = document.createElement("template"); + template.innerHTML = normalizedHtml; + + return normalizeMarkdownWhitespace(nodeChildrenToMarkdown(template.content)); +}; + +const absoluteUrl = (url: string | null | undefined) => { + const normalizedUrl = normalizeText(url); + if (!normalizedUrl) return undefined; + if (/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl; + if (typeof window !== "undefined" && normalizedUrl.startsWith("/")) + return `${window.location.origin}${normalizedUrl}`; + return normalizedUrl; +}; + +const issueIdentifier = (issue: Partial, projectIdentifier?: string) => + projectIdentifier && issue.sequence_id ? `${projectIdentifier}-${issue.sequence_id}` : valueOrEmpty(issue.id); + +const issueLine = (issue: Partial, context: TIssueMarkdownExportContext) => { + const projectIdentifier = + issue.project_id === context.issue.project_id + ? context.projectIdentifier + : context.getIssueById(issue.id)?.project_id === context.issue.project_id + ? context.projectIdentifier + : undefined; + + return `${issueIdentifier(issue, projectIdentifier)} — ${valueOrEmpty(issue.name)}`; +}; + +const userName = (userId: string | null | undefined, context: TIssueMarkdownExportContext) => + context.getUserDisplayName(userId) ?? valueOrEmpty(userId); + +const namesByIds = (ids: string[] | null | undefined, lookup: TEntityNameLookup) => + listValue((ids ?? []).map((id) => lookup(id) ?? id)); + +const issueStateName = (stateId: string | null | undefined, context: TIssueMarkdownExportContext) => + context.getStateName(stateId) ?? valueOrEmpty(stateId); + +const issueAssignees = (issue: Partial, context: TIssueMarkdownExportContext) => + namesByIds(issue.assignee_ids ?? [], context.getUserDisplayName); + +const formatIssueFacts = (issue: Partial, context: TIssueMarkdownExportContext) => [ + ` - Статус: ${issueStateName(issue.state_id, context)}`, + ` - Приоритет: ${valueOrEmpty(issue.priority)}`, + ` - Исполнители: ${issueAssignees(issue, context)}`, + ` - Старт: ${formatDate(issue.start_date)}`, + ` - Дедлайн: ${formatDate(issue.target_date)}`, + ` - Завершено: ${formatDate(issue.completed_at)}`, +]; + +const renderStructuredBlocks = (blocks: TIssueStructuredBlock[]) => { + if (blocks.length === 0) return emptyValue; + + return blocks + .map((block, index) => { + const title = valueOrEmpty(block.title) === emptyValue ? `Блок ${index + 1}` : block.title.trim(); + + if (block.type === "text") { + return [`### ${title}`, "", normalizeMarkdownWhitespace(block.body) || emptyValue].join("\n"); + } + + const items = + block.items.length > 0 + ? block.items.map((item) => `- [${item.checked ? "x" : " "}] ${valueOrEmpty(item.text)}`).join("\n") + : emptyValue; + + return [`### ${title}`, "", items].join("\n"); + }) + .join("\n\n"); +}; + +const renderSubIssues = (context: TIssueMarkdownExportContext) => { + if (context.subIssues.length === 0) return emptyValue; + + return context.subIssues + .map((subIssue) => + [ + `- ${issueLine(subIssue, context)}`, + ...formatIssueFacts(subIssue, context), + ` - Project UUID: ${valueOrEmpty(subIssue.project_id)}`, + ` - Issue UUID: ${valueOrEmpty(subIssue.id)}`, + ].join("\n") + ) + .join("\n\n"); +}; + +const renderRelations = (context: TIssueMarkdownExportContext) => { + const relationSections = Object.entries(context.relations).filter(([, issues]) => issues.length > 0); + if (relationSections.length === 0) return emptyValue; + + return relationSections + .map(([relationType, issues]) => { + const relationTitle = relationLabels[relationType] ?? relationType; + const relationIssueList = issues.map((issue) => `- ${issueLine(issue, context)}`).join("\n"); + + return [`### ${relationTitle}`, "", relationIssueList].join("\n"); + }) + .join("\n\n"); +}; + +const renderLinks = (links: TIssueLink[]) => { + if (links.length === 0) return emptyValue; + + return links + .map((link) => { + const url = valueOrEmpty(link.url); + const title = valueOrEmpty(link.title); + + return [`- ${title}: ${url}`, ` - ID: ${link.id}`, ` - Created: ${formatDate(link.created_at)}`].join("\n"); + }) + .join("\n"); +}; + +const renderAttachments = (attachments: TIssueAttachment[]) => { + if (attachments.length === 0) return emptyValue; + + return attachments + .map((attachment) => { + const beamViewer = attachment.attributes?.beamViewer; + const lines = [ + `- ${valueOrEmpty(attachment.attributes?.name)}`, + ` - ID: ${attachment.id}`, + ` - Type: ${valueOrEmpty(attachment.attributes?.type)}`, + ` - Size: ${formatBytes(attachment.attributes?.size)}`, + ` - URL: ${valueOrEmpty(absoluteUrl(attachment.asset_url))}`, + ` - Created: ${formatDate(attachment.created_at)}`, + ` - Updated: ${formatDate(attachment.updated_at)}`, + ` - Created by: ${valueOrEmpty(attachment.created_by)}`, + ` - Updated by: ${valueOrEmpty(attachment.updated_by)}`, + ]; + + if (beamViewer) { + lines.push( + ` - BIM viewer: ${valueOrEmpty(beamViewer.viewerUrl)}`, + ` - BIM download: ${valueOrEmpty(beamViewer.downloadUrl)}`, + ` - BIM source: ${valueOrEmpty(beamViewer.conversion?.sourceSrc)}`, + ` - BIM status: ${valueOrEmpty(beamViewer.conversion?.status)}`, + ` - BIM version: ${valueOrEmpty(beamViewer.version)}` + ); + } + + return lines.join("\n"); + }) + .join("\n\n"); +}; + +const renderComments = (comments: TIssueComment[]) => { + if (comments.length === 0) return emptyValue; + + return orderBy(comments, (comment) => new Date(comment.created_at).getTime(), "asc") + .map((comment) => { + const author = comment.actor_detail?.display_name || comment.actor_detail?.first_name || comment.actor; + const body = htmlToMarkdown(comment.comment_html) || valueOrEmpty(comment.comment_stripped); + const attachments = + comment.attachments && comment.attachments.length > 0 + ? `\n\nAttachments:\n\`\`\`json\n${JSON.stringify(comment.attachments, null, 2)}\n\`\`\`` + : ""; + + return [`### ${valueOrEmpty(author)} — ${formatDate(comment.created_at)}`, "", body, attachments].join("\n"); + }) + .join("\n\n"); +}; + +const formatActivityValue = ( + field: string | undefined, + value: string | undefined, + context: TIssueMarkdownExportContext +) => { + if (!value) return emptyValue; + + const resolveSingleValue = (item: string) => { + const normalizedItem = item.trim(); + if (!normalizedItem) return undefined; + + if (field === "state") return context.getStateName(normalizedItem) ?? normalizedItem; + if (field === "assignees") return context.getUserDisplayName(normalizedItem) ?? normalizedItem; + if (field === "labels") return context.getLabelName(normalizedItem) ?? normalizedItem; + if (field === "modules") return context.getModuleName(normalizedItem) ?? normalizedItem; + if (field === "cycle") return context.getCycleName(normalizedItem) ?? normalizedItem; + + return normalizedItem; + }; + + return listValue(value.split(",").map(resolveSingleValue)); +}; + +const renderActivities = (activities: TIssueActivity[], context: TIssueMarkdownExportContext) => { + if (activities.length === 0) return emptyValue; + + return orderBy(activities, (activity) => new Date(activity.created_at).getTime(), "asc") + .map((activity) => { + const actor = activity.actor_detail?.display_name || activity.actor_detail?.first_name || activity.actor; + const field = valueOrEmpty(activity.field); + const oldValue = formatActivityValue(activity.field, activity.old_value, context); + const newValue = formatActivityValue(activity.field, activity.new_value, context); + const base = `- ${formatDate(activity.created_at)} — ${valueOrEmpty(actor)} — ${valueOrEmpty(activity.verb)} — ${field}: ${oldValue} -> ${newValue}`; + const comment = normalizeText(activity.comment); + + return comment ? `${base}\n - Comment: ${comment}` : base; + }) + .join("\n"); +}; + +const renderReactions = (reactions: string[]) => + reactions.length > 0 ? reactions.map((reaction) => `- ${reaction}`).join("\n") : emptyValue; + +const buildRawSnapshot = (context: TIssueMarkdownExportContext) => ({ + issue: context.issue, + sub_issues: context.subIssues, + relations: context.relations, + links: context.links, + attachments: context.attachments, + comments: context.comments, + activities: context.activities, + reactions: context.reactions, +}); + +const sanitizeFileNameSegment = (value: unknown) => + removeControlCharacters(valueOrEmpty(value)) + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, " ") + .trim(); + +const buildFileName = (context: TIssueMarkdownExportContext) => { + const projectName = sanitizeFileNameSegment(context.projectName || context.projectIdentifier || "TASKER"); + const sequence = context.issue.sequence_id + ? `${context.issue.sequence_id}` + : sanitizeFileNameSegment(context.issue.id); + const title = sanitizeFileNameSegment(context.issue.name); + const baseName = `${projectName} ${sequence}_${title}`.slice(0, 180).trim(); + + return `${baseName || "tasker-card"}.md`; +}; + +export const buildIssueMarkdownExport = (context: TIssueMarkdownExportContext): TMarkdownExportResult => { + const { issue } = context; + const parsedContent = extractIssueStructuredContent(issue.detail_layout, issue.description_html); + const parentIssue = issue.parent_id ? context.getIssueById(issue.parent_id) || issue.parent : undefined; + const projectDisplayName = context.projectName || context.projectIdentifier || issue.project_id; + const labels = namesByIds(issue.label_ids, context.getLabelName); + const modules = namesByIds(issue.module_ids ?? [], context.getModuleName); + const cycle = context.getCycleName(issue.cycle_id) ?? valueOrEmpty(issue.cycle_id); + const description = htmlToMarkdown(parsedContent.bodyHtml) || emptyValue; + const identifier = issueIdentifier(issue, context.projectIdentifier); + + const markdown = normalizeMarkdownWhitespace( + [ + `# ${identifier} — ${valueOrEmpty(issue.name)}`, + "", + "## Карточка", + "", + `- Workspace: ${context.workspaceSlug}`, + `- Project: ${valueOrEmpty(projectDisplayName)}`, + `- Project ID: ${valueOrEmpty(issue.project_id)}`, + `- Project identifier: ${valueOrEmpty(context.projectIdentifier)}`, + `- Issue ID: ${issue.id}`, + `- Sequence: ${valueOrEmpty(issue.sequence_id)}`, + `- Link: ${valueOrEmpty(context.workItemUrl)}`, + `- Status: ${issueStateName(issue.state_id, context)}`, + `- Priority: ${valueOrEmpty(issue.priority)}`, + `- Estimate: ${valueOrEmpty(issue.estimate_point)}`, + `- Assignees: ${issueAssignees(issue, context)}`, + `- Labels: ${labels}`, + `- Modules: ${modules}`, + `- Cycle: ${cycle}`, + `- Parent: ${parentIssue ? issueLine(parentIssue, context) : emptyValue}`, + `- Created: ${formatDate(issue.created_at)}`, + `- Created by: ${userName(issue.created_by, context)}`, + `- Updated: ${formatDate(issue.updated_at)}`, + `- Updated by: ${userName(issue.updated_by, context)}`, + `- Start date: ${formatDate(issue.start_date)}`, + `- Target date: ${formatDate(issue.target_date)}`, + `- Completed: ${formatDate(issue.completed_at)}`, + `- Archived: ${formatDate(issue.archived_at)}`, + `- External source: ${valueOrEmpty(issue.external_source)}`, + `- External ID: ${valueOrEmpty(issue.external_id)}`, + "", + "## Описание", + "", + description, + "", + "## Структурные блоки", + "", + renderStructuredBlocks(parsedContent.blocks), + "", + "## Подзадачи", + "", + renderSubIssues(context), + "", + "## Связи", + "", + renderRelations(context), + "", + "## Ссылки", + "", + renderLinks(context.links), + "", + "## Файлы", + "", + renderAttachments(context.attachments), + "", + "## Комментарии", + "", + renderComments(context.comments), + "", + "## Реакции", + "", + renderReactions(context.reactions), + "", + "## История изменений", + "", + renderActivities(context.activities, context), + "", + "## Raw data snapshot", + "", + "```json", + JSON.stringify(buildRawSnapshot(context), null, 2), + "```", + ].join("\n") + ); + + return { + fileName: buildFileName(context), + markdown, + }; +}; + +export const downloadIssueMarkdownExport = ({ fileName, markdown }: TMarkdownExportResult) => { + if (typeof document === "undefined") return; + + const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; diff --git a/plane-src/apps/web/core/components/issues/peek-overview/header.tsx b/plane-src/apps/web/core/components/issues/peek-overview/header.tsx index 175bd60..755f93a 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/header.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/header.tsx @@ -26,6 +26,7 @@ import { IssueSubscription } from "../issue-detail/subscription"; import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns"; import { NameDescriptionUpdateStatus } from "../issue-update-status"; import { IconButton } from "@plane/propel/icon-button"; +import { IssueMarkdownExportButton } from "../issue-detail/markdown-export-button"; export type TPeekModes = "side-peek" | "modal" | "full-screen"; @@ -104,25 +105,31 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader isArchived, }); - const handleCopyText = (e: React.MouseEvent) => { + const handleCopyText = async (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - copyUrlToClipboard(workItemLink).then(() => { + + try { + await copyUrlToClipboard(workItemLink); setToast({ type: TOAST_TYPE.SUCCESS, title: t("common.link_copied"), message: t("common.link_copied_to_clipboard"), }); - }); + } catch (_error) { + setToast({ + title: t("toast.error"), + type: TOAST_TYPE.ERROR, + }); + } }; const handleDeleteIssue = async () => { try { const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue; - return deleteIssue(workspaceSlug, projectId, issueId).then(() => { - setPeekIssue(undefined); - }); + await deleteIssue(workspaceSlug, projectId, issueId); + setPeekIssue(undefined); } catch (_error) { setToast({ title: t("toast.error"), @@ -173,6 +180,15 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
{actionSlot} + {issueDetails?.project_id && ( + + )} {showSubscription && currentUser && !isArchived && ( -
-
- -
-
-
- ( - setIsOpen(val)} - className="flex items-center justify-center" - buttonClassName="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/[0.06]" - label={} - // TODO: fix types - onChange={(val: any) => { - let logoValue = {}; - - if (val?.type === "emoji") - logoValue = { - value: val.value, - }; - else if (val?.type === "icon") logoValue = val.value; - - onChange({ - in_use: val?.type, - [val?.type]: logoValue, - }); - setIsOpen(false); - }} - defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined} - defaultOpen={ - value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON - } - disabled={!isAdmin} - /> - )} - /> -
- {watch("name")} - - {watch("identifier")} . - - {project.network === 0 && } - {currentNetwork && t(currentNetwork?.i18n_label)} + <> + setValue("logo_props", logo, { shouldDirty: true })} + onClose={() => setIsLogoPickerOpen(false)} + /> +
+
+
+ +
+
+
+ +
+ {watch("name")} + + {watch("identifier")} . + + {project.network === 0 && } + {currentNetwork && t(currentNetwork?.i18n_label)} + - +
+
+
+
+
+ ( + + )} + />
-
-
- ( - +
+
+

{t("common.project_name")}

+ ( + )} /> -
-
-
-
-
-
-

{t("common.project_name")}

- ( - - )} - /> - {errors?.name?.message} -
-
-

{t("description")}

- ( -