Compare commits

...

1 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 70559910b1 Add Tasker Markdown export and project logo picker 2026-07-10 10:19:42 +03:00
8 changed files with 1118 additions and 248 deletions

View File

@ -24,8 +24,7 @@ function ProjectAttributes(props: Props) {
const { t } = useTranslation();
const { control } = useFormContext<IProject>();
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 (
<div className="flex flex-wrap items-center gap-2">
<Controller
@ -86,6 +85,7 @@ function ProjectAttributes(props: Props) {
buttonVariant="border-with-text"
buttonClassName={projectAttributeChipClassName}
buttonContainerClassName="!h-10"
optionsClassName="!z-[190]"
showUserDetails
tabIndex={getIndex("lead")}
/>

View File

@ -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
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={CopyLinkIcon} />
</Tooltip>
<IssueMarkdownExportButton workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issue}

View File

@ -0,0 +1,201 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, type MouseEvent } from "react";
import { Download } from "lucide-react";
import { observer } from "mobx-react";
// plane imports
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { buildIssueMarkdownExport, downloadIssueMarkdownExport } from "./markdown-export";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
isArchived?: boolean;
className?: string;
};
export const IssueMarkdownExportButton = observer(function IssueMarkdownExportButton(props: Props) {
const { workspaceSlug, projectId, issueId, isArchived = false, className } = props;
const [isExporting, setIsExporting] = useState(false);
const { isMobile } = usePlatformOS();
const { getProjectById, getProjectIdentifierById } = useProject();
const { getUserDetails } = useMember();
const { fetchProjectLabels, getLabelById } = useLabel();
const { fetchModules, getModuleNameById } = useModule();
const { fetchAllCycles, getCycleNameById } = useCycle();
const { fetchProjectStates, getStateById } = useProjectState();
const {
issue: { getIssueById },
link,
attachment,
subIssues,
relation,
comment,
activity,
reaction,
fetchIssue,
fetchLinks,
fetchAttachments,
fetchSubIssues,
fetchRelations,
fetchComments,
fetchActivities,
fetchReactions,
} = useIssueDetail();
const issue = getIssueById(issueId);
if (!issue) return <></>;
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<HTMLButtonElement>) => {
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 (
<Tooltip tooltipContent="Скачать Markdown" isMobile={isMobile}>
<IconButton
variant="secondary"
size="lg"
onClick={handleDownloadMarkdown}
icon={Download}
loading={isExporting}
className={className}
/>
</Tooltip>
);
});

View File

@ -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<TIssue> | 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<string, TIssue[]>;
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<string, string> = {
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<string | undefined>) => {
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(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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 === "<p></p>") 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<TIssue>, projectIdentifier?: string) =>
projectIdentifier && issue.sequence_id ? `${projectIdentifier}-${issue.sequence_id}` : valueOrEmpty(issue.id);
const issueLine = (issue: Partial<TIssue>, 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<TIssue>, context: TIssueMarkdownExportContext) =>
namesByIds(issue.assignee_ids ?? [], context.getUserDisplayName);
const formatIssueFacts = (issue: Partial<TIssue>, 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);
};

View File

@ -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<HTMLButtonElement>) => {
const handleCopyText = async (e: React.MouseEvent<HTMLButtonElement>) => {
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(() => {
await deleteIssue(workspaceSlug, projectId, issueId);
setPeekIssue(undefined);
});
} catch (_error) {
setToast({
title: t("toast.error"),
@ -173,6 +180,15 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{actionSlot}
{issueDetails?.project_id && (
<IssueMarkdownExportButton
workspaceSlug={workspaceSlug}
projectId={issueDetails.project_id}
issueId={issueId}
isArchived={isArchived}
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
/>
)}
{showSubscription && currentUser && !isArchived && (
<IssueSubscription
workspaceSlug={workspaceSlug}

View File

@ -11,7 +11,7 @@ import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// plane imports
import { Button } from "@plane/propel/button";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { LockIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
@ -32,6 +32,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
import { ProjectService } from "@/services/project";
// local imports
import { ProjectNetworkIcon } from "./project-network-icon";
import { ProjectLogoPickerModal } from "./project-logo-picker-modal";
export interface IProjectDetailsForm {
project: IProject;
@ -45,7 +46,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
const { project, workspaceSlug, projectId, isAdmin } = props;
const { t } = useTranslation();
// states
const [isOpen, setIsOpen] = useState(false);
const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { updateProject } = useProject();
@ -192,6 +193,13 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
};
return (
<>
<ProjectLogoPickerModal
isOpen={isLogoPickerOpen}
logo={watch("logo_props")}
onChange={(logo) => setValue("logo_props", logo, { shouldDirty: true })}
onClose={() => setIsLogoPickerOpen(false)}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative h-44 w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
@ -199,42 +207,15 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
<div className="flex min-w-0 flex-grow gap-3 truncate">
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[1.2rem] bg-white/10 px-2.5 py-2.5 backdrop-blur-2xl">
<Controller
control={control}
name="logo_props"
render={({ field: { value, onChange } }) => (
<EmojiPicker
iconType="material"
closeOnSelect={false}
isOpen={isOpen}
handleToggle={(val: boolean) => 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={<Logo logo={value} size={28} />}
// 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
}
<button
type="button"
onClick={() => setIsLogoPickerOpen(true)}
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/[0.06] transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-60"
aria-label="Изменить иконку проекта"
disabled={!isAdmin}
/>
)}
/>
>
<Logo logo={watch("logo_props")} size={28} />
</button>
<div className="flex flex-col gap-1 truncate text-on-color">
<span className="truncate text-16 font-semibold">{watch("name")}</span>
<span className="flex items-center gap-2 text-13">
@ -448,5 +429,6 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
</div>
</div>
</form>
</>
);
}

View File

@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { EmojiIconPickerTypes, EmojiRoot, IconRoot, emojiToString } from "@plane/propel/emoji-icon-picker";
import { CloseIcon } from "@plane/propel/icons";
import type { TLogoProps } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
type Props = {
isOpen: boolean;
logo: TLogoProps;
onChange: (logo: TLogoProps) => void;
onClose: () => void;
};
export function ProjectLogoPickerModal(props: Props) {
const { isOpen, logo, onChange, onClose } = props;
const defaultTab =
logo?.in_use === EmojiIconPickerTypes.EMOJI ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON;
const defaultIconColor = logo?.in_use === EmojiIconPickerTypes.ICON ? (logo.icon?.color ?? "#6d7b8a") : "#6d7b8a";
const [activeTab, setActiveTab] = useState(defaultTab);
useEffect(() => {
if (isOpen) setActiveTab(defaultTab);
}, [defaultTab, isOpen]);
const handleEmojiChange = (emoji: string) => {
onChange({
in_use: EmojiIconPickerTypes.EMOJI,
emoji: { value: emojiToString(emoji) },
});
onClose();
};
const handleIconChange = (icon: { name: string; color: string }) => {
onChange({
in_use: EmojiIconPickerTypes.ICON,
icon,
});
onClose();
};
return (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.XL}
className="overflow-hidden"
>
<div className="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<h3 className="text-16 font-semibold text-primary">Изменить иконку проекта</h3>
<p className="mt-1 text-12 text-secondary">Выберите emoji или иконку с цветом.</p>
</div>
<button
type="button"
onClick={onClose}
className="grid size-8 place-items-center rounded-lg text-tertiary transition-colors hover:bg-layer-1 hover:text-primary"
aria-label="Закрыть выбор иконки"
>
<CloseIcon className="size-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-1 px-5 pt-4" role="tablist" aria-label="Тип иконки">
<button
type="button"
role="tab"
aria-selected={activeTab === EmojiIconPickerTypes.EMOJI}
onClick={() => setActiveTab(EmojiIconPickerTypes.EMOJI)}
className={
activeTab === EmojiIconPickerTypes.EMOJI
? "rounded-md border border-strong bg-surface-1 py-2 text-13 text-primary"
: "rounded-md border border-subtle bg-layer-1 py-2 text-13 text-placeholder hover:bg-layer-1/60 hover:text-tertiary"
}
>
Emoji
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === EmojiIconPickerTypes.ICON}
onClick={() => setActiveTab(EmojiIconPickerTypes.ICON)}
className={
activeTab === EmojiIconPickerTypes.ICON
? "rounded-md border border-strong bg-surface-1 py-2 text-13 text-primary"
: "rounded-md border border-subtle bg-layer-1 py-2 text-13 text-placeholder hover:bg-layer-1/60 hover:text-tertiary"
}
>
Иконка
</button>
</div>
{activeTab === EmojiIconPickerTypes.EMOJI && (
<div className="h-[25rem] overflow-hidden" role="tabpanel">
<EmojiRoot onChange={handleEmojiChange} searchPlaceholder="Поиск emoji" />
</div>
)}
{activeTab === EmojiIconPickerTypes.ICON && (
<div className="h-[25rem] overflow-y-auto" role="tabpanel">
<IconRoot iconType="material" defaultColor={defaultIconColor} onChange={handleIconChange} />
</div>
)}
</ModalCore>
);
}

View File

@ -5,7 +5,9 @@
*/
export * from "./emoji-picker";
export * from "./emoji";
export * from "./helper";
export * from "./icon";
export * from "./logo";
export * from "./lucide-icons";
export * from "./material-icons";