diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts index e59cc5542..a705bf2c6 100644 --- a/application/i18n/locales/en/vault.ts +++ b/application/i18n/locales/en/vault.ts @@ -5,6 +5,11 @@ export const enVaultMessages: Messages = { 'notes.search.placeholder': 'Search notes, hosts, tags...', 'notes.action.newNote': 'New Note', 'notes.action.newGroup': 'New Folder', + 'notes.action.importMarkdown': 'Import Markdown', + 'notes.import.toast.success': 'Imported {count} note(s)', + 'notes.import.toast.noNotes': 'No Markdown files were imported.', + 'notes.import.toast.failed': 'Failed to import Markdown files.', + 'notes.codeBlock.copied': 'Copied', 'notes.group.prompt': 'Folder path', 'notes.group.all': 'Notebook', 'notes.group.ungrouped': 'Ungrouped', diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts index eb78fc7d9..17c75bc62 100644 --- a/application/i18n/locales/ru/vault.ts +++ b/application/i18n/locales/ru/vault.ts @@ -5,6 +5,11 @@ export const ruVaultMessages: Messages = { 'notes.search.placeholder': 'Поиск заметок, хостов, тегов...', 'notes.action.newNote': 'Новая заметка', 'notes.action.newGroup': 'Новая папка', + 'notes.action.importMarkdown': 'Импорт Markdown', + 'notes.import.toast.success': 'Импортировано заметок: {count}', + 'notes.import.toast.noNotes': 'Markdown-файлы не были импортированы.', + 'notes.import.toast.failed': 'Не удалось импортировать Markdown-файлы.', + 'notes.codeBlock.copied': 'Скопировано', 'notes.group.prompt': 'Путь папки', 'notes.group.all': 'Блокнот', 'notes.group.ungrouped': 'Без группы', diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index f8702fb4a..70dbe2728 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -5,6 +5,11 @@ export const zhCNVaultMessages: Messages = { 'notes.search.placeholder': '搜索笔记、主机、标签...', 'notes.action.newNote': '新建笔记', 'notes.action.newGroup': '新建文件夹', + 'notes.action.importMarkdown': '导入 Markdown', + 'notes.import.toast.success': '已导入 {count} 条笔记', + 'notes.import.toast.noNotes': '没有导入任何 Markdown 文件。', + 'notes.import.toast.failed': '导入 Markdown 文件失败。', + 'notes.codeBlock.copied': '已复制', 'notes.group.prompt': '文件夹路径', 'notes.group.all': '笔记本', 'notes.group.ungrouped': '未分组', diff --git a/components/notes/InlineMarkdownEditor.test.tsx b/components/notes/InlineMarkdownEditor.test.tsx index 67833dd28..914306fb0 100644 --- a/components/notes/InlineMarkdownEditor.test.tsx +++ b/components/notes/InlineMarkdownEditor.test.tsx @@ -273,3 +273,34 @@ test("note code block frame is borderless and language picker is compact", () => /\.netcatty-mdx-editor--preview\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*display:\s*none\s*!important;/s, ); }); + +test("getCodeMirrorBlockText reads rendered code block lines", () => { + const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8"); + + assert.match(source, /export const getCodeMirrorBlockText/); + assert.match(source, /\.cm-content \.cm-line/); + assert.match(source, /\.join\("\\n"\)/); +}); + +test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () => { + const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8"); + + assert.match(source, /export const annotateNoteCodeBlockCopyButtons/); + assert.match(source, /data-note-code-copy/); + assert.match(source, /getCodeMirrorBlockText\(wrapper\)/); + assert.match(source, /onCopy\(text\)/); +}); + +test("note code blocks expose a hover copy action in edit and preview modes", () => { + const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8"); + + assert.match(source, /annotateNoteCodeBlockCopyButtons/); + assert.match(source, /notes\.codeBlock\.copied/); + assert.match(source, /copyToClipboard/); + assert.match(styles, /\.netcatty-note-code-copy/); + assert.match( + styles, + /\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]:hover\s+\.netcatty-note-code-copy/s, + ); +}); diff --git a/components/notes/InlineMarkdownEditor.tsx b/components/notes/InlineMarkdownEditor.tsx index 6902b26db..955fcea1c 100644 --- a/components/notes/InlineMarkdownEditor.tsx +++ b/components/notes/InlineMarkdownEditor.tsx @@ -34,8 +34,10 @@ import { getNearestEditorFromDOMNode, } from "lexical"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useI18n } from "../../application/i18n/I18nProvider"; import { resolveRenderedMarkdownLinkHref } from "../../domain/notes"; import { buildSshNoteLinkOpenHost } from "../../domain/sshDeepLink"; +import { copyToClipboard } from "../keychain/utils"; import { cn } from "../../lib/utils"; import type { Host } from "../../types"; @@ -322,6 +324,64 @@ export const getHostPickerTriggerRange = (textBeforeCursor: string): { }; }; +export const getCodeMirrorBlockText = (wrapper: Element): string => { + const lines = wrapper.querySelectorAll(".cm-content .cm-line"); + if (lines.length > 0) { + return Array.from(lines) + .map((line) => line.textContent?.replace(/\u00a0/g, " ") ?? "") + .join("\n"); + } + + const content = wrapper.querySelector(".cm-content"); + return content?.textContent?.replace(/\u00a0/g, " ") ?? ""; +}; + +export const annotateNoteCodeBlockCopyButtons = ( + container: HTMLElement, + { + copyLabel, + copiedLabel, + onCopy, + }: { + copyLabel: string; + copiedLabel: string; + onCopy: (text: string) => Promise; + }, +): void => { + container.querySelectorAll('[class*="_codeMirrorWrapper_"]').forEach((wrapper) => { + if (!(wrapper instanceof HTMLElement)) return; + if (wrapper.querySelector("[data-note-code-copy]")) return; + + const button = document.createElement("button"); + button.type = "button"; + button.dataset.noteCodeCopy = "true"; + button.className = "netcatty-note-code-copy"; + button.title = copyLabel; + button.setAttribute("aria-label", copyLabel); + button.textContent = copyLabel; + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + void (async () => { + const text = getCodeMirrorBlockText(wrapper); + const ok = await onCopy(text); + if (!ok) return; + button.dataset.copied = "true"; + button.textContent = copiedLabel; + button.title = copiedLabel; + window.setTimeout(() => { + delete button.dataset.copied; + button.textContent = copyLabel; + button.title = copyLabel; + }, 1500); + })(); + }); + + wrapper.appendChild(button); + }); +}; + const deleteLexicalTextRange = (range: Range, onUpdate: () => void): boolean => { const rangeContainer = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement @@ -358,6 +418,7 @@ export function InlineMarkdownEditor({ onOpenExternalLink, previewEmptyLabel, }: InlineMarkdownEditorProps) { + const { t } = useI18n(); const editorRef = useRef(null); const latestMarkdownRef = useRef(value); const containerRef = useRef(null); @@ -522,6 +583,22 @@ export function InlineMarkdownEditor({ return () => window.cancelAnimationFrame(frame); }, [annotateHostLinks, value]); + const annotateCodeBlockCopyButtons = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + annotateNoteCodeBlockCopyButtons(container, { + copyLabel: t("action.copy"), + copiedLabel: t("notes.codeBlock.copied"), + onCopy: copyToClipboard, + }); + }, [t]); + + useEffect(() => { + const frame = window.requestAnimationFrame(annotateCodeBlockCopyButtons); + return () => window.cancelAnimationFrame(frame); + }, [annotateCodeBlockCopyButtons, editorMode, value]); + const commitMarkdown = useCallback((markdown: string) => { if (markdown === latestMarkdownRef.current) return; latestMarkdownRef.current = markdown; diff --git a/components/notes/NotesManager.test.tsx b/components/notes/NotesManager.test.tsx index 5361a3dcf..509317135 100644 --- a/components/notes/NotesManager.test.tsx +++ b/components/notes/NotesManager.test.tsx @@ -212,9 +212,19 @@ test("NotesManager renders empty state", () => { assert.match(markup, /No notes yet/); assert.match(markup, /New Note/); + assert.match(markup, /Import Markdown/); assert.doesNotMatch(markup, /data-notes-drop-zone="root"/); }); +test("NotesManager exposes markdown import controls", () => { + const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8"); + + assert.match(source, /notes\.action\.importMarkdown/); + assert.match(source, /importMarkdownFilesToVaultNotes/); + assert.match(source, /accept="\.md,\.markdown,\.txt,text\/markdown,text\/plain"/); + assert.match(source, /multiple/); +}); + test("NotesManager sidebar mode renders list without editor by default", () => { const markup = renderNotes([note()], "sidebar"); diff --git a/components/notes/NotesManager.tsx b/components/notes/NotesManager.tsx index 09a03dc95..3752d0691 100644 --- a/components/notes/NotesManager.tsx +++ b/components/notes/NotesManager.tsx @@ -11,6 +11,7 @@ import { PencilLine, Plus, Search, + Upload, X, } from "lucide-react"; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -25,6 +26,7 @@ import { isNoteGroupInside, joinNoteGroupPath, matchesVaultNoteSearch, + importMarkdownFilesToVaultNotes, normalizeNoteGroups, normalizeVaultNotes, remapExpandedNoteGroupPaths, @@ -37,6 +39,7 @@ import { STORAGE_KEY_VAULT_NOTES_TREE_WIDTH, } from "../../infrastructure/config/storageKeys"; import { cn } from "../../lib/utils"; +import { readTextFile } from "../../lib/readTextFile"; import type { Host, VaultNote } from "../../types"; import { Button } from "../ui/button"; import { LazyLoadBoundary } from "../ui/lazy-load-boundary"; @@ -50,6 +53,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown"; import { Input } from "../ui/input"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { toast } from "../ui/toast"; import { VaultTreeGroupRow, VaultTreeInlineRenameInput, @@ -353,6 +357,7 @@ export const NotesManager: React.FC = ({ { min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH }, ); const searchInputRef = useRef(null); + const importFileInputRef = useRef(null); const groups = useMemo(() => normalizeNoteGroups(noteGroups), [noteGroups]); const groupOrderByPath = useMemo( @@ -497,6 +502,51 @@ export const NotesManager: React.FC = ({ addNoteToGroup(getNoteActionTargetGroup(selectedNote, selectedGroup)); }; + const handleImportMarkdownFiles = useCallback(async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return; + + const files = Array.from(fileList); + const targetGroup = getNoteActionTargetGroup(selectedNote, selectedGroup); + + try { + const result = await importMarkdownFilesToVaultNotes( + files, + sortedNotes, + targetGroup, + readTextFile, + ); + + if (result.importedCount === 0) { + toast.error(t("notes.import.toast.noNotes")); + return; + } + + onUpdateNotes(result.notes); + if (targetGroup) expandPath(targetGroup); + + const lastImported = result.notes[result.notes.length - 1]; + const nextSelection = getNoteSelectionState(lastImported, isSidebarMode); + setSelectedNoteId(nextSelection.selectedNoteId); + setSelectedGroup(nextSelection.selectedGroup); + setOverlayNoteId(nextSelection.overlayNoteId); + + toast.success(t("notes.import.toast.success", { count: result.importedCount })); + } catch { + toast.error(t("notes.import.toast.failed")); + } finally { + if (importFileInputRef.current) { + importFileInputRef.current.value = ""; + } + } + }, [ + isSidebarMode, + onUpdateNotes, + selectedGroup, + selectedNote, + sortedNotes, + t, + ]); + const duplicateNoteById = (noteId: string) => { const source = sortedNotes.find((note) => note.id === noteId); if (!source) return; @@ -1061,6 +1111,16 @@ export const NotesManager: React.FC = ({ return (
+ { + void handleImportMarkdownFiles(event.target.files); + }} + />
{shouldShowNotesTree && (

{t("notes.empty.title")}

{t("notes.empty.desc")}

- +
+ + +
)} diff --git a/components/vault/useVaultImportHandlers.ts b/components/vault/useVaultImportHandlers.ts index d818039a2..da1a92f4c 100644 --- a/components/vault/useVaultImportHandlers.ts +++ b/components/vault/useVaultImportHandlers.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { sanitizeHost } from "../../domain/host"; +import { readTextFile } from "../../lib/readTextFile"; import { importVaultHostsFromText, type VaultImportFormat } from "../../domain/vaultImport"; import type { Host, ManagedSource } from "../../types"; import type { ImportOptions } from "./ImportVaultDialog"; @@ -27,33 +28,6 @@ export function useVaultImportHandlers({ setIsImportOpen, t, }: UseVaultImportHandlersOptions) { - const readTextFile = useCallback(async (file: File): Promise => { - const buf = await file.arrayBuffer(); - const bytes = new Uint8Array(buf); - - let encoding: string = "utf-8"; - let offset = 0; - - if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { - encoding = "utf-16le"; - offset = 2; - } else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { - encoding = "utf-16be"; - offset = 2; - } else if ( - bytes.length >= 3 && - bytes[0] === 0xef && - bytes[1] === 0xbb && - bytes[2] === 0xbf - ) { - encoding = "utf-8"; - offset = 3; - } - - const decoder = new TextDecoder(encoding); - return decoder.decode(bytes.slice(offset)); - }, []); - const handleImportFileSelected = useCallback( async (format: VaultImportFormat, file: File, options?: ImportOptions) => { setIsImportOpen(false); @@ -249,7 +223,6 @@ export function useVaultImportHandlers({ onUpdateCustomGroups, onUpdateHosts, onUpdateManagedSources, - readTextFile, setIsImportOpen, t, ], diff --git a/domain/notes.test.ts b/domain/notes.test.ts index 52e2ed65a..038943280 100644 --- a/domain/notes.test.ts +++ b/domain/notes.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildVaultNoteFromMarkdownImport, + deriveNoteImportTitle, + importMarkdownFilesToVaultNotes, matchesVaultNoteSearch, normalizeNoteGroups, normalizeVaultNotes, @@ -131,3 +134,60 @@ test("resolveRenderedMarkdownLinkHref avoids guessing duplicate labels", () => { "about:blank", ); }); + +test("deriveNoteImportTitle prefers the first markdown heading", () => { + assert.equal( + deriveNoteImportTitle("runbook.md", "# Failover\n\nPromote replica"), + "Failover", + ); + assert.equal( + deriveNoteImportTitle("deploy-notes.markdown", "No heading here"), + "deploy-notes", + ); + assert.equal( + deriveNoteImportTitle("README.txt", ""), + "README", + ); +}); + +test("buildVaultNoteFromMarkdownImport creates a note in the target group", () => { + const note = buildVaultNoteFromMarkdownImport({ + fileName: "runbook.md", + content: "# Runbook\n\nRestart sshd", + group: "Ops", + order: 1000, + }); + + assert.equal(note.title, "Runbook"); + assert.equal(note.content, "# Runbook\n\nRestart sshd"); + assert.equal(note.group, "Ops"); + assert.equal(note.order, 1000); +}); + +test("importMarkdownFilesToVaultNotes appends notes and skips unsupported files", async () => { + const existing = [sanitizeVaultNote({ + id: "existing", + title: "Existing", + content: "body", + createdAt: 1, + updatedAt: 1, + order: 1000, + })]; + const files = [ + new File(["# Imported\n\nBody"], "imported.md", { type: "text/markdown" }), + new File(["ignored"], "notes.json", { type: "application/json" }), + ]; + + const result = await importMarkdownFilesToVaultNotes( + files, + existing, + "Ops", + async (file) => file.text(), + ); + + assert.equal(result.importedCount, 1); + assert.equal(result.skippedCount, 1); + assert.equal(result.notes.length, 2); + assert.equal(result.notes[1].title, "Imported"); + assert.equal(result.notes[1].group, "Ops"); +}); diff --git a/domain/notes.ts b/domain/notes.ts index 54fecf782..5287b91ab 100644 --- a/domain/notes.ts +++ b/domain/notes.ts @@ -1,5 +1,5 @@ import type { Host, VaultNote } from "./models"; -import { normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder"; +import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder"; const cleanStringArray = (values: unknown): string[] | undefined => { if (!Array.isArray(values)) return undefined; @@ -279,3 +279,69 @@ export const resolveRenderedMarkdownLinkHref = ( const uniqueMatches = Array.from(new Set(matches)); return uniqueMatches.length === 1 ? uniqueMatches[0] : renderedHref; }; + +const NOTE_IMPORT_TITLE_EXTENSIONS = /\.(md|markdown|txt)$/i; + +export const deriveNoteImportTitle = (fileName: string, content: string): string => { + const headingMatch = /^#\s+(.+?)\s*$/m.exec(content); + if (headingMatch?.[1]) return headingMatch[1].trim(); + + const baseName = fileName.replace(NOTE_IMPORT_TITLE_EXTENSIONS, "").trim(); + return baseName || "Untitled note"; +}; + +export const buildVaultNoteFromMarkdownImport = ({ + fileName, + content, + group, + order, +}: { + fileName: string; + content: string; + group: string | null; + order: number; +}): VaultNote => { + const now = Date.now(); + return sanitizeVaultNote({ + title: deriveNoteImportTitle(fileName, content), + content, + group: group || undefined, + createdAt: now, + updatedAt: now, + order, + }); +}; + +export const importMarkdownFilesToVaultNotes = async ( + files: File[], + existingNotes: VaultNote[], + targetGroup: string | null, + readFile: (file: File) => Promise, +): Promise<{ notes: VaultNote[]; importedCount: number; skippedCount: number }> => { + const imported: VaultNote[] = []; + let skippedCount = 0; + let orderBase = existingNotes; + + for (const file of files) { + if (!/\.(md|markdown|txt)$/i.test(file.name)) { + skippedCount += 1; + continue; + } + + const content = await readFile(file); + const note = buildVaultNoteFromMarkdownImport({ + fileName: file.name, + content, + group: targetGroup, + order: getNextVaultOrder([...orderBase, ...imported]), + }); + imported.push(note); + orderBase = [...orderBase, note]; + } + + return { + notes: normalizeVaultNotes([...existingNotes, ...imported]), + importedCount: imported.length, + skippedCount, + }; +}; diff --git a/index.css b/index.css index 12b6240ec..beba94925 100644 --- a/index.css +++ b/index.css @@ -1036,6 +1036,7 @@ html[data-theme-transition] *::after { } .netcatty-mdx-editor [class*="_codeMirrorWrapper_"] { + position: relative; margin: 0.85rem 0 1rem; border: 0 !important; border-radius: 6px; @@ -1044,6 +1045,41 @@ html[data-theme-transition] *::after { overflow: hidden; } +.netcatty-note-code-copy { + position: absolute; + top: 0.35rem; + right: 0.35rem; + z-index: 20; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.45rem; + padding: 0 0.45rem; + border: 0; + border-radius: 4px; + background: hsl(var(--background) / 0.92); + color: hsl(var(--muted-foreground)); + font-size: 11px; + line-height: 1; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background-color 0.15s; +} + +.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:hover .netcatty-note-code-copy, +.netcatty-note-code-copy[data-copied="true"], +.netcatty-note-code-copy:focus-visible { + opacity: 1; +} + +.netcatty-note-code-copy:hover { + background: hsl(var(--secondary)); + color: hsl(var(--foreground)); +} + +.netcatty-note-code-copy[data-copied="true"] { + color: hsl(var(--foreground)); +} + .netcatty-mdx-editor [class*="_codeMirrorToolbar_"] { position: static !important; right: auto !important; diff --git a/lib/readTextFile.ts b/lib/readTextFile.ts new file mode 100644 index 000000000..1b103e01f --- /dev/null +++ b/lib/readTextFile.ts @@ -0,0 +1,26 @@ +export async function readTextFile(file: File): Promise { + const buf = await file.arrayBuffer(); + const bytes = new Uint8Array(buf); + + let encoding: string = "utf-8"; + let offset = 0; + + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + encoding = "utf-16le"; + offset = 2; + } else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + encoding = "utf-16be"; + offset = 2; + } else if ( + bytes.length >= 3 && + bytes[0] === 0xef && + bytes[1] === 0xbb && + bytes[2] === 0xbf + ) { + encoding = "utf-8"; + offset = 3; + } + + const decoder = new TextDecoder(encoding); + return decoder.decode(bytes.slice(offset)); +}