diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 2b6aaefc3e..687487f5f2 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -60,7 +60,9 @@ "identifier": "opener:allow-open-path", "allow": [ { "path": "$APPDATA/*" }, - { "path": "$APPDATA/**" } + { "path": "$APPDATA/**" }, + { "path": "$DOWNLOAD/*" }, + { "path": "$DOWNLOAD/**" } ] }, { @@ -78,7 +80,9 @@ "identifier": "fs:allow-write-file", "allow": [ { "path": "$APPDATA/*" }, - { "path": "$APPDATA/**" } + { "path": "$APPDATA/**" }, + { "path": "$DOWNLOAD/*" }, + { "path": "$DOWNLOAD/**" } ] }, { diff --git a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx index 7f0a368541..f8e6a66a1f 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx @@ -1,27 +1,30 @@ -import { useRightPanel } from "@/contexts"; -import { MessageCircleMore } from "lucide-react"; +// Temporarily commented out chat functionality +// import { useRightPanel } from "@/contexts"; +// import { MessageCircleMore } from "lucide-react"; import { EventChip } from "./event-chip"; import { ParticipantsChip } from "./participants-chip"; import { PastNotesChip } from "./past-notes-chip"; +import { ShareChip } from "./share-chip"; import { TagChip } from "./tag-chip"; -function StartChatButton({ isVeryNarrow }: { isVeryNarrow: boolean }) { - const { togglePanel } = useRightPanel(); +// Temporarily commented out StartChatButton +// function StartChatButton({ isVeryNarrow }: { isVeryNarrow: boolean }) { +// const { togglePanel } = useRightPanel(); - const handleChatClick = () => { - togglePanel("chat"); - }; +// const handleChatClick = () => { +// togglePanel("chat"); +// }; - return ( - - ); -} +// return ( +// +// ); +// } export default function NoteHeaderChips({ sessionId, @@ -45,7 +48,9 @@ export default function NoteHeaderChips({ - + + {/* Temporarily commented out chat button */} + {/* */} ); diff --git a/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx new file mode 100644 index 0000000000..124308904e --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx @@ -0,0 +1,61 @@ +import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; +import { Share2, TextSelect } from "lucide-react"; +import { useState } from "react"; +import { SharePopoverContent, useShareLogic } from "../share-button-header"; + +interface ShareChipProps { + isVeryNarrow?: boolean; +} + +export function ShareChip({ isVeryNarrow = false }: ShareChipProps) { + const [open, setOpen] = useState(false); + const { hasEnhancedNote, handleOpenStateChange } = useShareLogic(); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (hasEnhancedNote) { + handleOpenStateChange(newOpen); + } + }; + + return ( + + + + + + {hasEnhancedNote ? : } + + + ); +} + +function SharePlaceholderContent() { + return ( +
+
Share Enhanced Note
+
+
+ +
+

+ Enhanced Note Required +

+

+ Complete your meeting to generate an enhanced note, then share it via PDF, email, Obsidian, and more. +

+
+
+ ); +} diff --git a/apps/desktop/src/components/editor-area/note-header/share-button-header.tsx b/apps/desktop/src/components/editor-area/note-header/share-button-header.tsx new file mode 100644 index 0000000000..aa1467c326 --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/share-button-header.tsx @@ -0,0 +1,721 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; +import { join } from "@tauri-apps/api/path"; +import { message } from "@tauri-apps/plugin-dialog"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { openPath, openUrl } from "@tauri-apps/plugin-opener"; +import { BookText, Check, ChevronDown, ChevronUp, Copy, FileText, HelpCircle, Mail } from "lucide-react"; +import { useState } from "react"; + +import { useHypr } from "@/contexts"; +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { Session, Tag } from "@hypr/plugin-db"; +import { commands as dbCommands } from "@hypr/plugin-db"; +import { + client, + commands as obsidianCommands, + getVault, + patchVaultByFilename, + putVaultByFilename, +} from "@hypr/plugin-obsidian"; +import { html2md } from "@hypr/tiptap/shared"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; +import { useSession } from "@hypr/utils/contexts"; +import { exportToPDF, getAvailableThemes, type ThemeName } from "../../toolbar/utils/pdf-export"; + +interface DirectAction { + id: "copy"; + title: string; + icon: React.ReactNode; + description: string; +} + +interface ExportCard { + id: "pdf" | "email" | "obsidian"; + title: string; + icon: React.ReactNode; + description: string; + docsUrl: string; +} + +interface ExportResult { + type: "copy" | "pdf" | "email" | "obsidian"; + path?: string; + url?: string; + success?: boolean; +} + +interface ObsidianFolder { + value: string; + label: string; +} + +const exportHandlers = { + copy: async (session: Session): Promise => { + try { + let textToCopy = ""; + + if (session.enhanced_memo_html) { + textToCopy = html2md(session.enhanced_memo_html); + } else if (session.raw_memo_html) { + textToCopy = html2md(session.raw_memo_html); + } else { + textToCopy = session.title || "No content available"; + } + + await navigator.clipboard.writeText(textToCopy); + return { type: "copy", success: true }; + } catch (error) { + console.error("Failed to copy to clipboard:", error); + throw new Error("Failed to copy note to clipboard"); + } + }, + + pdf: async (session: Session, theme: ThemeName = "default"): Promise => { + const path = await exportToPDF(session, theme); + if (path) { + await message(`Meeting summary saved to your 'Downloads' folder ("${path}")`); + } + return { type: "pdf", path }; + }, + + email: async ( + session: Session, + sessionParticipants?: Array<{ full_name: string | null; email: string | null }>, + ): Promise => { + let bodyContent = "Here is the meeting summary: \n\n"; + + if (session.enhanced_memo_html) { + bodyContent += html2md(session.enhanced_memo_html); + } else if (session.raw_memo_html) { + bodyContent += html2md(session.raw_memo_html); + } else { + bodyContent += "No content available"; + } + + if (sessionParticipants && sessionParticipants.length > 0) { + const participantNames = sessionParticipants + .filter(p => p.full_name) + .map(p => p.full_name) + .join(", "); + + if (participantNames) { + bodyContent += `\n\nMeeting Participants: ${participantNames}`; + } + } + + bodyContent += "\n\nSent with Hyprnote (www.hyprnote.com)\n\n"; + + const participantEmails = sessionParticipants + ?.filter(participant => participant.email && participant.email.trim()) + ?.map(participant => participant.email!) + ?.join(",") || ""; + + const subject = encodeURIComponent(session.title); + const body = encodeURIComponent(bodyContent); + + const to = participantEmails ? `&to=${encodeURIComponent(participantEmails)}` : ""; + + const url = `mailto:?subject=${subject}&body=${body}${to}`; + return { type: "email", url }; + }, + + obsidian: async ( + session: Session, + selectedFolder: string, + sessionTags: Tag[] | undefined, + sessionParticipants: Array<{ full_name: string | null }> | undefined, + includeTranscript: boolean = false, + ): Promise => { + const [baseFolder, apiKey, baseUrl] = await Promise.all([ + obsidianCommands.getBaseFolder(), + obsidianCommands.getApiKey(), + obsidianCommands.getBaseUrl(), + ]); + + client.setConfig({ + fetch: tauriFetch, + auth: apiKey!, + baseUrl: baseUrl!, + }); + + const filename = `${session.title.replace(/[^a-zA-Z0-9 ]/g, "").replace(/\s+/g, "-")}.md`; + + let finalPath: string; + if (selectedFolder === "default") { + finalPath = baseFolder ? await join(baseFolder!, filename) : filename; + } else { + finalPath = await join(selectedFolder, filename); + } + + let convertedMarkdown = session.enhanced_memo_html ? html2md(session.enhanced_memo_html) : ""; + + // Add transcript if requested + if (includeTranscript && session.words && session.words.length > 0) { + const transcriptText = convertWordsToTranscript(session.words); + if (transcriptText) { + convertedMarkdown += "\n\n---\n\n## Full Transcript\n\n" + transcriptText; + } + } + + await putVaultByFilename({ + client, + path: { filename: finalPath }, + body: convertedMarkdown, + bodySerializer: null, + headers: { + "Content-Type": "text/markdown", + }, + }); + + // Update frontmatter + const targets = [ + { target: "date", value: session?.created_at ?? new Date().toISOString() }, + ...(sessionTags && sessionTags.length > 0 + ? [{ + target: "tags", + value: sessionTags.map(tag => tag.name), + }] + : []), + ...(sessionParticipants && sessionParticipants.filter(participant => participant.full_name).length > 0 + ? [{ + target: "attendees", + value: sessionParticipants.map(participant => participant.full_name).filter(Boolean), + }] + : []), + ]; + + for (const { target, value } of targets) { + await patchVaultByFilename({ + client, + path: { filename: finalPath }, + headers: { + "Operation": "replace", + "Target-Type": "frontmatter", + "Target": target, + "Create-Target-If-Missing": "true", + }, + body: value as any, + }); + } + + const url = await obsidianCommands.getDeepLinkUrl(finalPath); + return { type: "obsidian", url }; + }, +}; + +function getDefaultSelectedFolder(folders: ObsidianFolder[], sessionTags: Tag[]): string { + if (!sessionTags || sessionTags.length === 0) { + return "default"; + } + + const tagNames = sessionTags.map((tag: Tag) => tag.name.toLowerCase()); + + for (const tagName of tagNames) { + const exactMatch = folders.find(folder => folder.value.toLowerCase() === tagName); + if (exactMatch) { + return exactMatch.value; + } + } + + for (const tagName of tagNames) { + const partialMatch = folders.find(folder => folder.value.toLowerCase().includes(tagName)); + if (partialMatch) { + return partialMatch.value; + } + } + + return "default"; +} + +async function fetchObsidianFolders(): Promise { + try { + const [apiKey, baseUrl] = await Promise.all([ + obsidianCommands.getApiKey(), + obsidianCommands.getBaseUrl(), + ]); + + client.setConfig({ + fetch: tauriFetch, + auth: apiKey!, + baseUrl: baseUrl!, + }); + + const response = await getVault({ client }); + + const folders = response.data?.files + ?.filter(item => item.endsWith("/")) + ?.map(folder => ({ + value: folder.slice(0, -1), + label: folder.slice(0, -1), + })) || []; + + return [ + { value: "default", label: "Default (Root)" }, + ...folders, + ]; + } catch (error) { + console.error("Failed to fetch Obsidian folders:", error); + + obsidianCommands.getDeepLinkUrl("").then((url) => { + openUrl(url); + }).catch((error) => { + console.error("Failed to open Obsidian:", error); + }); + + return [{ value: "default", label: "Default (Root)" }]; + } +} + +function convertWordsToTranscript(words: any[]): string { + if (!words || words.length === 0) { + return ""; + } + + const lines: string[] = []; + let currentSpeaker: any = null; + let currentText = ""; + + for (const word of words) { + const isSameSpeaker = (!currentSpeaker && !word.speaker) + || (currentSpeaker?.type === "unassigned" && word.speaker?.type === "unassigned" + && currentSpeaker.value?.index === word.speaker.value?.index) + || (currentSpeaker?.type === "assigned" && word.speaker?.type === "assigned" + && currentSpeaker.value?.id === word.speaker.value?.id); + + if (!isSameSpeaker) { + if (currentText.trim()) { + const speakerLabel = getSpeakerLabel(currentSpeaker); + lines.push(`[${speakerLabel}]\n${currentText.trim()}`); + } + + currentSpeaker = word.speaker; + currentText = word.text; + } else { + currentText += " " + word.text; + } + } + + if (currentText.trim()) { + const speakerLabel = getSpeakerLabel(currentSpeaker); + lines.push(`[${speakerLabel}]\n${currentText.trim()}`); + } + + return lines.join("\n\n"); +} + +function getSpeakerLabel(speaker: any): string { + if (!speaker) { + return "Speaker"; + } + + if (speaker.type === "assigned" && speaker.value?.label) { + return speaker.value.label; + } + + if (speaker.type === "unassigned" && typeof speaker.value?.index === "number") { + if (speaker.value.index === 0) { + return "You"; + } + return `Speaker ${speaker.value.index}`; + } + + return "Speaker"; +} + +// Custom hook for share functionality +export function useShareLogic() { + const { userId } = useHypr(); + const param = useParams({ from: "/app/note/$id", shouldThrow: true }); + const session = useSession(param.id, (s) => s.session); + + const [expandedId, setExpandedId] = useState(null); + const [selectedObsidianFolder, setSelectedObsidianFolder] = useState("default"); + const [selectedPdfTheme, setSelectedPdfTheme] = useState("default"); + const [includeTranscript, setIncludeTranscript] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + const hasEnhancedNote = !!session?.enhanced_memo_html; + + const isObsidianConfigured = useQuery({ + queryKey: ["integration", "obsidian", "enabled"], + queryFn: async () => { + const [enabled, apiKey, baseUrl] = await Promise.all([ + obsidianCommands.getEnabled(), + obsidianCommands.getApiKey(), + obsidianCommands.getBaseUrl(), + ]); + return enabled && apiKey && baseUrl; + }, + }); + + const obsidianFolders = useQuery({ + queryKey: ["obsidian", "folders"], + queryFn: () => fetchObsidianFolders(), + enabled: false, + }); + + const sessionTags = useQuery({ + queryKey: ["session", "tags", param.id], + queryFn: () => dbCommands.listSessionTags(param.id), + enabled: false, + staleTime: 5 * 60 * 1000, + }); + + const sessionParticipants = useQuery({ + queryKey: ["session", "participants", param.id], + queryFn: () => dbCommands.sessionListParticipants(param.id), + enabled: false, + staleTime: 5 * 60 * 1000, + }); + + const directActions: DirectAction[] = [ + { + id: "copy", + title: "Copy Note", + icon: , + description: "", + }, + ]; + + const exportOptions: ExportCard[] = [ + { + id: "pdf", + title: "PDF", + icon: , + description: "Save as PDF document", + docsUrl: "https://docs.hyprnote.com/sharing#pdf", + }, + { + id: "email", + title: "Email", + icon: , + description: "Share via email", + docsUrl: "https://docs.hyprnote.com/sharing#email", + }, + isObsidianConfigured.data + ? { + id: "obsidian", + title: "Obsidian", + icon: , + description: "Export to Obsidian", + docsUrl: "https://docs.hyprnote.com/sharing#obsidian", + } + : null, + ].filter(Boolean) as ExportCard[]; + + const toggleExpanded = (id: string) => { + setExpandedId(expandedId === id ? null : id); + + if (id === "obsidian" && expandedId !== id && isObsidianConfigured.data) { + Promise.all([ + obsidianFolders.refetch(), + sessionTags.refetch(), + ]).then(([foldersResult, tagsResult]) => { + const freshFolders = foldersResult.data; + const freshTags = tagsResult.data; + + if (freshFolders && freshFolders.length > 0) { + const defaultFolder = getDefaultSelectedFolder(freshFolders, freshTags ?? []); + setSelectedObsidianFolder(defaultFolder); + } + }).catch((error) => { + console.error("Error fetching Obsidian data:", error); + setSelectedObsidianFolder("default"); + }); + } + }; + + const exportMutation = useMutation({ + mutationFn: async ({ session, optionId }: { session: Session; optionId: string }) => { + const start = performance.now(); + let result: ExportResult | null = null; + + if (optionId === "copy") { + result = await exportHandlers.copy(session); + } else if (optionId === "pdf") { + result = await exportHandlers.pdf(session, selectedPdfTheme); + } else if (optionId === "email") { + try { + // fetch participants directly, bypassing cache + const freshParticipants = await dbCommands.sessionListParticipants(param.id); + result = await exportHandlers.email(session, freshParticipants); + } catch (participantError) { + console.warn("Failed to fetch participants, sending email without them:", participantError); + result = await exportHandlers.email(session, undefined); + } + } else if (optionId === "obsidian") { + sessionTags.refetch(); + sessionParticipants.refetch(); + + let sessionTagsData = sessionTags.data; + let sessionParticipantsData = sessionParticipants.data; + + if (!sessionTagsData) { + const tagsResult = await sessionTags.refetch(); + sessionTagsData = tagsResult.data; + } + + if (!sessionParticipantsData) { + const participantsResult = await sessionParticipants.refetch(); + sessionParticipantsData = participantsResult.data; + } + + result = await exportHandlers.obsidian( + session, + selectedObsidianFolder, + sessionTagsData, + sessionParticipantsData, + includeTranscript, + ); + } + + const elapsed = performance.now() - start; + if (elapsed < 800) { + await new Promise((resolve) => setTimeout(resolve, 800 - elapsed)); + } + + return result; + }, + onMutate: ({ optionId }) => { + analyticsCommands.event({ + event: "share_triggered", + distinct_id: userId, + type: optionId, + }); + }, + onSuccess: (result) => { + if (result?.type === "copy" && result.success) { + setCopySuccess(true); + // Reset after 2 seconds + setTimeout(() => setCopySuccess(false), 2000); + } else if (result?.type === "pdf" && result.path) { + openPath(result.path); + } else if (result?.type === "email" && result.url) { + openUrl(result.url); + } else if (result?.type === "obsidian" && result.url) { + openUrl(result.url); + } + }, + onError: (error) => { + console.error(error); + message(JSON.stringify(error), { title: "Error", kind: "error" }); + }, + }); + + const handleExport = (optionId: string) => { + exportMutation.mutate({ session, optionId }); + }; + + const resetExpandedState = () => { + setExpandedId(null); + setCopySuccess(false); + }; + + const handleOpenStateChange = (isOpen: boolean) => { + if (isOpen) { + isObsidianConfigured.refetch().then((configResult) => { + if (configResult.data) { + obsidianFolders.refetch(); + } + }); + + analyticsCommands.event({ + event: "share_option_expanded", + distinct_id: userId, + }); + } else { + resetExpandedState(); + } + }; + + return { + session, + hasEnhancedNote, + expandedId, + selectedObsidianFolder, + setSelectedObsidianFolder, + selectedPdfTheme, + setSelectedPdfTheme, + includeTranscript, + setIncludeTranscript, + copySuccess, + isObsidianConfigured, + obsidianFolders, + directActions, + exportOptions, + exportMutation, + toggleExpanded, + handleExport, + handleOpenStateChange, + resetExpandedState, + }; +} + +// Reusable Share Content Component +export function SharePopoverContent() { + const { + expandedId, + selectedObsidianFolder, + setSelectedObsidianFolder, + selectedPdfTheme, + setSelectedPdfTheme, + includeTranscript, + setIncludeTranscript, + copySuccess, + obsidianFolders, + directActions, + exportOptions, + exportMutation, + toggleExpanded, + handleExport, + } = useShareLogic(); + + return ( +
+
Share Enhanced Note
+
+ {/* Direct action buttons */} + {directActions.map((action) => { + const isLoading = exportMutation.isPending && exportMutation.variables?.optionId === action.id; + const isSuccess = action.id === "copy" && copySuccess; + + return ( +
+ +
+ ); + })} + + {/* Expandable export options */} + {exportOptions.map((option) => { + const expanded = expandedId === option.id; + + return ( +
+ + {expanded && ( +
+
+

{option.description}

+ +
+ + {option.id === "pdf" && ( +
+ + +
+ )} + + {option.id === "obsidian" && ( + <> +
+ + +
+ +
+ +
+ + )} + + +
+ )} +
+ ); + })} +
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/components/organization-profile/recent-notes.tsx b/apps/desktop/src/components/organization-profile/recent-notes.tsx index 90dcd7d784..c72165e18c 100644 --- a/apps/desktop/src/components/organization-profile/recent-notes.tsx +++ b/apps/desktop/src/components/organization-profile/recent-notes.tsx @@ -67,7 +67,7 @@ export function RecentNotes({ organizationId, members }: RecentNotesProps) { ) : (

- No recent notes for this organization + No recent notes with this organization

)} diff --git a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx index 54ae4aa3bc..eb3e2bc1dd 100644 --- a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx +++ b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx @@ -3,7 +3,7 @@ import { useMatch } from "@tanstack/react-router"; import { DeleteNoteButton } from "@/components/toolbar/buttons/delete-note-button"; import { NewNoteButton } from "@/components/toolbar/buttons/new-note-button"; import { NewWindowButton } from "@/components/toolbar/buttons/new-window-button"; -import { ShareButton } from "@/components/toolbar/buttons/share-button"; +// import { ShareButton } from "@/components/toolbar/buttons/share-button"; import { useLeftSidebar } from "@/contexts"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { cn } from "@hypr/ui/lib/utils"; @@ -57,7 +57,7 @@ export function MainToolbar() { {isMain && ( <> {(organizationMatch || humanMatch) && } - {isNote && } + {/*isNote && */} diff --git a/apps/desktop/src/components/toolbar/buttons/share-button.tsx b/apps/desktop/src/components/toolbar/buttons/share-button.tsx index 2d4be60077..63bc0374d4 100644 --- a/apps/desktop/src/components/toolbar/buttons/share-button.tsx +++ b/apps/desktop/src/components/toolbar/buttons/share-button.tsx @@ -23,7 +23,7 @@ import { Button } from "@hypr/ui/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; import { useSession } from "@hypr/utils/contexts"; -import { exportToPDF } from "../utils/pdf-export"; +import { exportToPDF, getAvailableThemes, type ThemeName } from "../utils/pdf-export"; export function ShareButton() { const param = useParams({ from: "/app/note/$id", shouldThrow: false }); @@ -38,6 +38,7 @@ function ShareButtonInNote() { const [open, setOpen] = useState(false); const [expandedId, setExpandedId] = useState(null); const [selectedObsidianFolder, setSelectedObsidianFolder] = useState("default"); + const [selectedPdfTheme, setSelectedPdfTheme] = useState("default"); const [includeTranscript, setIncludeTranscript] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const hasEnhancedNote = !!session?.enhanced_memo_html; @@ -161,7 +162,7 @@ function ShareButtonInNote() { if (optionId === "copy") { result = await exportHandlers.copy(session); } else if (optionId === "pdf") { - result = await exportHandlers.pdf(session); + result = await exportHandlers.pdf(session, selectedPdfTheme); } else if (optionId === "email") { try { // fetch participants directly, bypassing cache @@ -331,6 +332,29 @@ function ShareButtonInNote() { + {option.id === "pdf" && ( +
+ + +
+ )} + {option.id === "obsidian" && ( <>
@@ -440,8 +464,11 @@ const exportHandlers = { } }, - pdf: async (session: Session): Promise => { - const path = await exportToPDF(session); + pdf: async (session: Session, theme: ThemeName = "default"): Promise => { + const path = await exportToPDF(session, theme); + if (path) { + await message(`Meeting summary saved to your 'Downloads' folder ("${path}")`); + } return { type: "pdf", path }; }, diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index 3c8cf3fdb3..851636151d 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -1,8 +1,11 @@ -import { appDataDir } from "@tauri-apps/api/path"; +import { downloadDir } from "@tauri-apps/api/path"; import { writeFile } from "@tauri-apps/plugin-fs"; import { jsPDF } from "jspdf"; import { commands as dbCommands, type Event, type Human, type Session } from "@hypr/plugin-db"; +import { getPDFTheme, type ThemeName } from "./pdf-themes"; + +export { getAvailableThemes, getPDFTheme, type PDFTheme, type ThemeName } from "./pdf-themes"; export type SessionData = Session & { participants?: Human[]; @@ -11,26 +14,61 @@ export type SessionData = Session & { interface TextSegment { text: string; - bold?: boolean; - italic?: boolean; - isHeader?: number; // 1, 2, 3 for h1, h2, h3 + isHeader?: number; isListItem?: boolean; + listType?: "ordered" | "unordered"; + listLevel?: number; + listItemNumber?: number; + bulletType?: "filled-circle" | "hollow-circle" | "square" | "triangle"; +} + +interface ListContext { + type: "ordered" | "unordered"; + level: number; + counters: number[]; } -// TODO: -// 1. Tiptap already has structured output - toJSON(). Should be cleaner than htmlToStructuredText. -// 2. Fetch should happen outside. This file should be only do the rendering. (Ideally writeFile should be happened outside too) -// 3. exportToPDF should be composed with multiple steps. +const getOrderedListMarker = (counter: number, level: number): string => { + switch (level) { + case 0: + return `${counter}.`; + case 1: + return `${String.fromCharCode(96 + counter)}.`; + default: + return `${toRomanNumeral(counter)}.`; + } +}; + +const toRomanNumeral = (num: number): string => { + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"]; + + let result = ""; + for (let i = 0; i < values.length; i++) { + while (num >= values[i]) { + result += numerals[i]; + num -= values[i]; + } + } + return result; +}; const htmlToStructuredText = (html: string): TextSegment[] => { if (!html) { return []; } + const cleanedHtml = html + .replace(/<\/?strong>/gi, "") + .replace(/<\/?b>/gi, "") + .replace(/<\/?em>/gi, "") + .replace(/<\/?i>/gi, ""); + const tempDiv = document.createElement("div"); - tempDiv.innerHTML = html; + tempDiv.innerHTML = cleanedHtml; const segments: TextSegment[] = []; + const listStack: ListContext[] = []; const processNode = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { @@ -52,35 +90,106 @@ const htmlToStructuredText = (html: string): TextSegment[] => { case "h3": segments.push({ text: element.textContent || "", isHeader: 3 }); break; - case "strong": - case "b": - segments.push({ text: element.textContent || "", bold: true }); + + case "ul": + processListContainer(element, "unordered"); break; - case "em": - case "i": - segments.push({ text: element.textContent || "", italic: true }); + case "ol": + processListContainer(element, "ordered"); break; case "li": - segments.push({ text: `• ${element.textContent || ""}`, isListItem: true }); + processListItem(element); break; + case "p": if (element.textContent?.trim()) { - // Process inline formatting within paragraphs processInlineFormatting(element, segments); - segments.push({ text: "\n" }); // Add paragraph break + segments.push({ text: "\n" }); } break; case "br": segments.push({ text: "\n" }); break; default: - // For other elements, process children Array.from(node.childNodes).forEach(processNode); break; } } }; + const processListContainer = (listElement: Element, type: "ordered" | "unordered") => { + const level = listStack.length; + + const counters = [...(listStack[listStack.length - 1]?.counters || [])]; + if (counters.length <= level) { + counters[level] = 0; + } + + listStack.push({ type, level, counters }); + + Array.from(listElement.children).forEach((child, index) => { + if (child.tagName.toLowerCase() === "li") { + if (type === "ordered") { + counters[level] = index + 1; + } + processNode(child); + } + }); + + listStack.pop(); + + if (level === 0) { + segments.push({ text: "\n" }); + } + }; + + const processListItem = (liElement: Element) => { + const currentList = listStack[listStack.length - 1]; + if (!currentList) { + return; + } + + const { type, level, counters } = currentList; + + const textContent = getListItemText(liElement); + + const bulletTypes = ["filled-circle", "hollow-circle", "square"] as const; + + segments.push({ + text: type === "ordered" + ? `${getOrderedListMarker(counters[level], level)} ${textContent}` + : textContent, + isListItem: true, + listType: type, + listLevel: level, + listItemNumber: type === "ordered" ? counters[level] : undefined, + bulletType: type === "unordered" + ? (level <= 2 ? bulletTypes[level] : "square") + : undefined, + }); + + Array.from(liElement.children).forEach(child => { + if (child.tagName.toLowerCase() === "ul" || child.tagName.toLowerCase() === "ol") { + processNode(child); + } + }); + }; + + const getListItemText = (liElement: Element): string => { + let text = ""; + for (const child of liElement.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + text += child.textContent || ""; + } else if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as Element; + if (!["ul", "ol"].includes(element.tagName.toLowerCase())) { + text += element.textContent || ""; + } + } + } + return text.trim(); + }; + const processInlineFormatting = (element: Element, segments: TextSegment[]) => { Array.from(element.childNodes).forEach(child => { if (child.nodeType === Node.TEXT_NODE) { @@ -90,23 +199,10 @@ const htmlToStructuredText = (html: string): TextSegment[] => { } } else if (child.nodeType === Node.ELEMENT_NODE) { const childElement = child as Element; - const tagName = childElement.tagName.toLowerCase(); const text = childElement.textContent || ""; if (text.trim()) { - switch (tagName) { - case "strong": - case "b": - segments.push({ text, bold: true }); - break; - case "em": - case "i": - segments.push({ text, italic: true }); - break; - default: - segments.push({ text }); - break; - } + segments.push({ text }); } } }); @@ -116,7 +212,6 @@ const htmlToStructuredText = (html: string): TextSegment[] => { return segments; }; -// Split text into lines that fit within the PDF width const splitTextToLines = (text: string, pdf: jsPDF, maxWidth: number): string[] => { const words = text.split(" "); const lines: string[] = []; @@ -141,7 +236,6 @@ const splitTextToLines = (text: string, pdf: jsPDF, maxWidth: number): string[] return lines; }; -// Fetch additional session data (participants and event info) const fetchSessionMetadata = async (sessionId: string): Promise<{ participants: Human[]; event: Event | null }> => { try { const [participants, event] = await Promise.all([ @@ -155,10 +249,70 @@ const fetchSessionMetadata = async (sessionId: string): Promise<{ participants: } }; -export const exportToPDF = async (session: SessionData): Promise => { +const drawVectorBullet = ( + pdf: jsPDF, + bulletType: "filled-circle" | "hollow-circle" | "square" | "triangle", + x: number, + y: number, + size: number = 1.0, + color: readonly [number, number, number] = [50, 50, 50], // Accept color parameter +) => { + // Save current state + const currentFillColor = pdf.getFillColor(); + const currentDrawColor = pdf.getDrawColor(); + + pdf.setFillColor(...color); + pdf.setDrawColor(...color); + pdf.setLineWidth(0.2); + + const bulletY = y - (size / 2); + + switch (bulletType) { + case "filled-circle": + pdf.circle(x, bulletY, size * 0.85, "F"); + break; + + case "hollow-circle": + pdf.circle(x, bulletY, size * 0.85, "S"); + break; + + case "square": + const squareSize = size * 1.4; + pdf.rect( + x - squareSize / 2, + bulletY - squareSize / 2, + squareSize, + squareSize, + "F", + ); + break; + + case "triangle": + const triangleSize = size * 1.15; + pdf.triangle( + x, + bulletY - triangleSize / 2, // top point + x - triangleSize / 2, + bulletY + triangleSize / 2, // bottom left + x + triangleSize / 2, + bulletY + triangleSize / 2, // bottom right + "F", + ); + break; + } + + pdf.setFillColor(currentFillColor); + pdf.setDrawColor(currentDrawColor); +}; + +export const exportToPDF = async ( + session: SessionData, + themeName: ThemeName = "default", +): Promise => { const { participants, event } = await fetchSessionMetadata(session.id); - // Generate filename + const PDF_STYLES = getPDFTheme(themeName); + const filename = session?.title ? `${session.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.pdf` : `note_${new Date().toISOString().split("T")[0]}.pdf`; @@ -169,48 +323,60 @@ export const exportToPDF = async (session: SessionData): Promise => { const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 20; const maxWidth = pageWidth - (margin * 2); - const lineHeight = 6; + const lineHeight = 5.5; + + const applyBackgroundColor = () => { + if ( + PDF_STYLES.colors.background[0] !== 255 + || PDF_STYLES.colors.background[1] !== 255 + || PDF_STYLES.colors.background[2] !== 255 + ) { + pdf.setFillColor(...PDF_STYLES.colors.background); + pdf.rect(0, 0, pageWidth, pageHeight, "F"); + } + }; + + const addNewPage = () => { + pdf.addPage(); + applyBackgroundColor(); + }; let yPosition = margin; - // Add title with text wrapping + applyBackgroundColor(); + const title = session?.title || "Untitled Note"; pdf.setFontSize(16); - pdf.setFont("helvetica", "bold"); - pdf.setTextColor(0, 0, 0); // Black + pdf.setFont(PDF_STYLES.font, "bold"); + pdf.setTextColor(...PDF_STYLES.colors.headers); - // Split title into multiple lines if it's too long const titleLines = splitTextToLines(title, pdf, maxWidth); for (const titleLine of titleLines) { pdf.text(titleLine, margin, yPosition); yPosition += lineHeight; } - yPosition += lineHeight; // Extra space after title + yPosition += lineHeight; - // Add creation date ONLY if there's no event info if (!event && session?.created_at) { pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); const createdAt = `Created: ${new Date(session.created_at).toLocaleDateString()}`; pdf.text(createdAt, margin, yPosition); yPosition += lineHeight; } - // Add event info if available if (event) { pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color - // Event name if (event.name) { pdf.text(`Event: ${event.name}`, margin, yPosition); yPosition += lineHeight; } - // Event date/time if (event.start_date) { const startDate = new Date(event.start_date); const endDate = event.end_date ? new Date(event.end_date) : null; @@ -223,7 +389,6 @@ export const exportToPDF = async (session: SessionData): Promise => { pdf.text(dateText, margin, yPosition); yPosition += lineHeight; - // Time const timeText = endDate ? `Time: ${startDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${ endDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) @@ -234,11 +399,10 @@ export const exportToPDF = async (session: SessionData): Promise => { } } - // Add participants if available if (participants && participants.length > 0) { pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); const participantNames = participants .filter(p => p.full_name) @@ -256,83 +420,94 @@ export const exportToPDF = async (session: SessionData): Promise => { } } - // Add attribution with clickable "Hyprnote" pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); pdf.text("Summarized by ", margin, yPosition); - // Calculate width of "Summarized by " to position "Hyprnote" const madeByWidth = pdf.getTextWidth("Summarized by "); - pdf.setTextColor(37, 99, 235); // Blue color for Hyprnote + pdf.setTextColor(...PDF_STYLES.colors.hyprnoteLink); - // Create clickable link for Hyprnote const hyprnoteText = "Hyprnote"; pdf.textWithLink(hyprnoteText, margin + madeByWidth, yPosition, { url: "https://www.hyprnote.com" }); yPosition += lineHeight * 2; - // Add separator line - pdf.setDrawColor(200, 200, 200); // Light gray line + pdf.setDrawColor(...PDF_STYLES.colors.separatorLine); pdf.line(margin, yPosition, pageWidth - margin, yPosition); yPosition += lineHeight; - // Convert HTML to structured text and add content const segments = htmlToStructuredText(session?.enhanced_memo_html || "No content available"); for (const segment of segments) { - // Check if we need a new page if (yPosition > pageHeight - margin) { - pdf.addPage(); + addNewPage(); yPosition = margin; } - // Set font style based on segment properties if (segment.isHeader) { const headerSizes = { 1: 14, 2: 13, 3: 12 }; pdf.setFontSize(headerSizes[segment.isHeader as keyof typeof headerSizes]); - pdf.setFont("helvetica", "bold"); - pdf.setTextColor(0, 0, 0); // Black for headers - yPosition += lineHeight; // Extra space before headers + pdf.setFont(PDF_STYLES.font, "bold"); + pdf.setTextColor(...PDF_STYLES.colors.headers); + yPosition += lineHeight; } else { pdf.setFontSize(12); - const fontStyle = segment.bold && segment.italic - ? "bolditalic" - : segment.bold - ? "bold" - : segment.italic - ? "italic" - : "normal"; - pdf.setFont("helvetica", fontStyle); - pdf.setTextColor(50, 50, 50); // Dark gray for content + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.mainContent); } - // Handle list items with indentation - const xPosition = segment.isListItem ? margin + 5 : margin; + let xPosition = margin; + let bulletSpace = 0; + + if (segment.isListItem && segment.listLevel !== undefined) { + const baseIndent = 5; + const levelIndent = 8; + xPosition = margin + baseIndent + (segment.listLevel * levelIndent); + + bulletSpace = segment.listType === "ordered" ? 0 : 6; + } - // Split long text into multiple lines - const lines = splitTextToLines(segment.text, pdf, maxWidth - (segment.isListItem ? 5 : 0)); + const effectiveMaxWidth = maxWidth - (xPosition - margin) - bulletSpace; + const lines = splitTextToLines(segment.text, pdf, effectiveMaxWidth); for (let i = 0; i < lines.length; i++) { if (yPosition > pageHeight - margin) { - pdf.addPage(); + addNewPage(); yPosition = margin; } - pdf.text(lines[i], xPosition, yPosition); + if ( + segment.isListItem + && segment.listType === "unordered" + && segment.bulletType + && i === 0 + ) { + drawVectorBullet( + pdf, + segment.bulletType, + xPosition + 2, + yPosition - 1, + 1.0, + PDF_STYLES.colors.bullets, + ); + } + + const textXPosition = xPosition + bulletSpace; + + pdf.text(lines[i], textXPosition, yPosition); yPosition += lineHeight; } - // Add extra space after headers and paragraphs if (segment.isHeader || segment.text === "\n") { - yPosition += lineHeight * 0.5; + yPosition += lineHeight * 0.25; } } const pdfArrayBuffer = pdf.output("arraybuffer"); const uint8Array = new Uint8Array(pdfArrayBuffer); - const downloadsPath = await appDataDir(); + const downloadsPath = await downloadDir(); const filePath = downloadsPath.endsWith("/") ? `${downloadsPath}${filename}` : `${downloadsPath}/${filename}`; diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts new file mode 100644 index 0000000000..aa5614f782 --- /dev/null +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -0,0 +1,251 @@ +export type ThemeName = + | "default" + | "light" + | "dark" + | "corporate" + | "ocean" + | "sunset" + | "forest" + | "cyberpunk" + | "retro" + | "spring" + | "summer" + | "winter" + | "homebrew"; + +export interface PDFTheme { + font: string; + colors: { + background: readonly [number, number, number]; + mainContent: readonly [number, number, number]; + headers: readonly [number, number, number]; + metadata: readonly [number, number, number]; + hyprnoteLink: readonly [number, number, number]; + separatorLine: readonly [number, number, number]; + bullets: readonly [number, number, number]; + }; +} + +export const getPDFTheme = (themeName: ThemeName): PDFTheme => { + const themes: Record = { + default: { + font: "helvetica", + colors: { + background: [255, 255, 255], // Pure white (kept as requested) + mainContent: [33, 33, 33], // Dark charcoal + headers: [0, 0, 0], // Black + metadata: [102, 102, 102], // Medium gray + hyprnoteLink: [59, 130, 246], // Blue + separatorLine: [229, 229, 229], // Light gray + bullets: [75, 85, 99], // Slate gray + }, + }, + + light: { + font: "helvetica", + colors: { + background: [248, 250, 252], // Very light blue + mainContent: [30, 58, 138], // Deep blue + headers: [15, 23, 42], // Navy + metadata: [100, 116, 139], // Steel blue + hyprnoteLink: [37, 99, 235], // Bright blue + separatorLine: [186, 230, 253], // Light sky blue + bullets: [59, 130, 246], // Blue + }, + }, + + dark: { + font: "helvetica", + colors: { + background: [15, 15, 15], // Almost black + mainContent: [220, 220, 220], // Light gray + headers: [255, 255, 255], // White + metadata: [140, 140, 140], // Medium gray + hyprnoteLink: [96, 165, 250], // Light blue + separatorLine: [40, 40, 40], // Dark gray + bullets: [180, 180, 180], // Light gray + }, + }, + + corporate: { + font: "times", + colors: { + background: [255, 255, 255], // Pure white (kept unchanged) + mainContent: [15, 23, 42], // Slate 900 + headers: [30, 41, 59], // Slate 800 + metadata: [100, 116, 139], // Slate 500 + hyprnoteLink: [30, 64, 175], // Professional blue + separatorLine: [203, 213, 225], // Slate 300 + bullets: [51, 65, 85], // Slate 700 + }, + }, + + ocean: { + font: "helvetica", + colors: { + background: [240, 249, 255], // Light ocean blue + mainContent: [7, 89, 133], // Deep ocean blue + headers: [12, 74, 110], // Ocean blue + metadata: [14, 116, 144], // Teal + hyprnoteLink: [6, 182, 212], // Cyan + separatorLine: [165, 243, 252], // Light cyan + bullets: [34, 211, 238], // Bright cyan + }, + }, + + sunset: { + font: "times", + colors: { + background: [255, 247, 237], // Warm cream + mainContent: [120, 53, 15], // Dark brown + headers: [194, 65, 12], // Orange red + metadata: [156, 105, 23], // Amber + hyprnoteLink: [234, 88, 12], // Bright orange + separatorLine: [254, 215, 170], // Peach + bullets: [251, 146, 60], // Orange + }, + }, + + forest: { + font: "helvetica", + colors: { + background: [236, 253, 245], // Mint green + mainContent: [20, 83, 45], // Forest green + headers: [5, 46, 22], // Dark forest green + metadata: [21, 128, 61], // Green + hyprnoteLink: [34, 197, 94], // Bright green + separatorLine: [167, 243, 208], // Light green + bullets: [74, 222, 128], // Lime green + }, + }, + + cyberpunk: { + font: "helvetica", + colors: { + background: [3, 7, 18], // Deep space black + mainContent: [0, 255, 204], // Matrix green + headers: [0, 255, 255], // Electric cyan + metadata: [102, 204, 255], // Neon blue + hyprnoteLink: [255, 0, 255], // Electric magenta + separatorLine: [0, 102, 153], // Dark blue + bullets: [0, 255, 128], // Bright green + }, + }, + + retro: { + font: "courier", + colors: { + background: [139, 69, 19], // Dark brown (much darker!) + mainContent: [255, 248, 220], // Cream text + headers: [255, 215, 0], // Gold + metadata: [222, 184, 135], // Burlywood + hyprnoteLink: [255, 140, 0], // Dark orange + separatorLine: [160, 82, 45], // Saddle brown + bullets: [255, 165, 0], // Orange + }, + }, + + spring: { + font: "courier", + colors: { + background: [254, 249, 195], // Light yellow green + mainContent: [56, 142, 60], // Green + headers: [27, 94, 32], // Dark green + metadata: [76, 175, 80], // Light green + hyprnoteLink: [139, 195, 74], // Lime + separatorLine: [200, 230, 201], // Very light green + bullets: [104, 159, 56], // Olive green + }, + }, + + summer: { + font: "helvetica", + colors: { + background: [255, 235, 59], // Bright yellow + mainContent: [191, 54, 12], // Deep red orange + headers: [213, 0, 0], // Red + metadata: [255, 87, 34], // Orange red + hyprnoteLink: [255, 152, 0], // Orange + separatorLine: [255, 193, 7], // Amber + bullets: [244, 67, 54], // Red + }, + }, + + winter: { + font: "times", + colors: { + background: [233, 242, 251], // Icy blue + mainContent: [13, 71, 161], // Deep blue + headers: [25, 118, 210], // Blue + metadata: [66, 165, 245], // Light blue + hyprnoteLink: [33, 150, 243], // Sky blue + separatorLine: [187, 222, 251], // Very light blue + bullets: [100, 181, 246], // Light blue + }, + }, + + homebrew: { + font: "courier", + colors: { + background: [0, 0, 0], // Terminal black + mainContent: [0, 255, 0], // Terminal green + headers: [0, 255, 128], // Bright terminal green + metadata: [128, 255, 128], // Light terminal green + hyprnoteLink: [0, 255, 255], // Terminal cyan + separatorLine: [0, 128, 0], // Dark green + bullets: [0, 255, 0], // Terminal green + }, + }, + }; + + return themes[themeName] || themes.default; +}; + +export const getAvailableThemes = (): ThemeName[] => { + return [ + "default", + "light", + "dark", + "corporate", + "ocean", + "sunset", + "forest", + "cyberpunk", + "retro", + "spring", + "summer", + "winter", + "homebrew", + ]; +}; + +export const getThemePreview = (themeName: ThemeName) => { + const theme = getPDFTheme(themeName); + return { + name: themeName, + font: theme.font, + primaryColor: theme.colors.headers, + backgroundColor: theme.colors.background, + description: getThemeDescription(themeName), + }; +}; + +const getThemeDescription = (themeName: ThemeName): string => { + const descriptions: Record = { + default: "Clean charcoal text on white with Helvetica", + light: "Deep blues on light blue with Helvetica", + dark: "Light text on deep black with Helvetica", + corporate: "Professional navy on white with Times", + ocean: "Ocean blues on light cyan with Helvetica", + sunset: "Warm browns and oranges on cream with Times", + forest: "Forest greens on mint background with Courier", + cyberpunk: "Matrix green on space black with Courier", + retro: "Gold text on dark brown with Courier", + spring: "Fresh greens on yellow-green with Courier", + summer: "Bright reds on yellow with Courier", + winter: "Deep blues on icy background with Times", + homebrew: "Classic terminal green on black with Courier", + }; + + return descriptions[themeName] || descriptions.default; +}; diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index 70122f0450..45c98b5fff 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -1124,8 +1124,12 @@ msgid "No past notes with this contact" msgstr "No past notes with this contact" #: src/components/organization-profile/recent-notes.tsx:70 -msgid "No recent notes for this organization" -msgstr "No recent notes for this organization" +#~ msgid "No recent notes for this organization" +#~ msgstr "No recent notes for this organization" + +#: src/components/organization-profile/recent-notes.tsx:70 +msgid "No recent notes with this organization" +msgstr "No recent notes with this organization" #: src/components/settings/components/ai/stt-view.tsx:248 #~ msgid "No speech-to-text models available or failed to load." diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 51b7401b73..a27d5768d6 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -1124,7 +1124,11 @@ msgid "No past notes with this contact" msgstr "" #: src/components/organization-profile/recent-notes.tsx:70 -msgid "No recent notes for this organization" +#~ msgid "No recent notes for this organization" +#~ msgstr "" + +#: src/components/organization-profile/recent-notes.tsx:70 +msgid "No recent notes with this organization" msgstr "" #: src/components/settings/components/ai/stt-view.tsx:248