From 33a49e81007c8ad20c06263c0a91e2e8bbcb51f3 Mon Sep 17 00:00:00 2001 From: serenolux <59664950+sjoon21@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:52:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=8C=98K=EC=97=90=20=EC=9D=B4=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=EC=9D=98=20=EB=8F=99=EC=9E=91=EA=B3=BC=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=C2=B7=ED=83=9C=EA=B7=B8=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노트를 열었을 때 즐겨찾기·태그 편집·형식별 내보내기·Obsidian을 맨 앞에 보이고, 입력하면 폴더·태그를 골라 사이드바를 그것으로 거른다. 내보내기는 MeetingView가 exportReq를 받아 노트 머리 메뉴와 같은 함수로 한다. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 1 + WORDS.md | 4 +++- app/src/App.tsx | 35 +++++++++++++++++++++++++++++------ app/src/CommandPalette.tsx | 6 ++++-- app/src/MeetingView.tsx | 16 +++++++++++++++- app/src/en/palette.ts | 3 +++ app/src/palette.test.ts | 13 +++++++++++++ app/src/palette.ts | 12 +++++++++--- docs/en/usage.md | 2 +- docs/usage.md | 2 +- 10 files changed, 79 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2c4a6e5..2559846 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,6 +32,7 @@ | 2026-11-05 | v3.97 | **남은 UI 부채 두 개(0.5.4 화면 검토의 3·5번).** (1) 안내 말풍선: 자리가 다른 것에 덮여 있으면(`elementFromPoint`로 자리 가운데의 맨 위 요소가 그 자리가 아니면, 안내 상자 자신은 `data-coachmark`로 뺀다) 띄우지 않는다: AI 어시스턴트 서랍이 내보내기 단추를 덮은 채 안내가 서랍 위에 뜨던 것. 대화 내용 안내(`transcript`)는 첫 시각 칩 **위**에 칩 왼쪽에 맞춰(`align="start"`) 띄워 대화 줄을 가리지 않는다(잠깐 제목 줄을 가린다). (2) 빈 요약: 회색 한 줄 대신 점선 상자(아이콘·`아직 요약이 없습니다`·`위에서 … ‘요약 만들기’`)로 위의 주 동작을 가리킨다. | | 2026-11-05 | v3.98 | **사전: 고친 낱말 제안(PR6, 소유자 선택: 자동 바꾸기 없이 제안만).** Rust `meetings::corrections`가 녹음 노트(메모·예시 노트 제외)에서 `corrected_text`가 원문과 다른 줄을 [원문, 고친 문장]으로 모아(5000쌍까지) `glossary_corrections`로 넘기고, 화면은 기존 `vocab.termFromEdit`(낱말 1~2개·소리가 비슷함·용어처럼 보임)로 낱말을 골라 `dictionary.suggestFromCorrections`가 세고 거른다(사전에 있음·무시함 제외, 많이 고친 순 8개). 사전 화면에 `고쳐 쓴 낱말` 칩: 누르면 추가, ×는 무시(localStorage). 글자를 저절로 바꾸지 않아 측정이 필요 없다. 규칙이 화면 한 곳(vocab.ts)에만 있도록 코어 명령 대신 Rust가 원문만 읽어 넘긴다. | | 2026-11-05 | v3.99 | **의존성 줄이기: onnxruntime을 뺐다(소유자 선택: 지금 모델을 MLX로).** 목소리 프로필의 CAM++ ONNX를 `onnx_mlx`가 그대로 실행한다: protobuf에서 그래프·가중치·메타데이터를 직접 읽고(필드 몇 개뿐이라 `onnx` 패키지 없이), 이 모델이 쓰는 26개 연산만 구현한다(모양 계산은 numpy, 텐서는 MLX, 모르는 연산은 실행 전 오류). 받는 파일·sha256·`MODEL_ID`는 그대로라 **등록한 목소리를 다시 등록하지 않는다**. onnxruntime 1.30과 비교: 무작위 입력 5가지 길이에서 cosine 1.0·최대 차이 약 1e-5, `bench/voice_eval.py`(AI Hub 4세션 40명 1427발화, 지표만) 결과가 RTF(0.0085 → 0.0082) 말고 모두 같다. 검사: `tests/test_onnx_mlx.py`(테스트 안에서 만든 작은 ONNX로 protobuf·연산을 numpy와 비교, slow는 실제 모델을 onnxruntime으로 미리 뽑아 둔 임베딩과 비교). 후보였던 mlx-community CAM++는 출력 512차원·가중치 6MB로 우리 모델(192차원·28MB)과 달라 쓰지 않았다. 번들 감소량은 다음 번들 빌드에서 잰다(개발 환경의 onnxruntime 폴더는 75MB). | +| 2026-11-06 | v3.100 | **⌘K 넓히기: 이 노트의 동작과 폴더·태그로 가기(브랜치 `feat/palette-note-actions`, 앱만).** 노트를 열었을 때 맨 앞 `이 노트` 묶음: 즐겨찾기(`org.setFavorite`)·태그 편집(`org.requestTagFocus`, 사이드바 줄 메뉴와 같은 통로)·형식별 내보내기·Obsidian. 내보내기는 `useNoteExport`가 MeetingView 안에 있어 App이 `exportReq {id, format, nonce}`를 내려 주고 MeetingView가 노트를 다 읽은 뒤 한 번 `exp.exportAs`/`toObsidian`을 부른다(노트 머리 메뉴와 같은 함수: Markdown·Word는 넣을 내용 고르기 창). 목록이 길어지지 않게 `PaletteAction.typed`(빈 입력에서는 숨기고 찾을 때만)를 두어 빈 입력에서는 Markdown·Word만 보인다. 입력이 있으면 `폴더·태그` 묶음: 고르면 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고(`clearFilters` → `selectFolder`/`toggleTag`) 노트 목록을 펼친다. 폴더로 옮기기·인쇄는 고르는 창·쓰던 메모 저장이 끼어 넣지 않았다. WORDS §4.30. | | 2026-10-30 | v3.78 | **한국어 파인튜닝 엔진을 8bit 변환본으로 교체(브랜치 `feat/univa-8bit`).** 0.5.2에 올린 `asr-ko-univa`가 원본 bf16(4.7GB)이었다(변환·업로드 위치를 정하지 않고 허브 원본을 그대로 등록했다). `mlx_audio.convert -q --q-bits 8 --q-group-size 64 --q-mode affine`로 변환해(표준 엔진과 같은 설정, `model.safetensors` 2,463,307,541바이트로 공식 Qwen3-ASR 1.7B 8bit와 같다) 소유자 계정에 `voyagar/Qwen3-ASR-1.7B-ko-mlx-8bit`(공개, Apache-2.0, 커밋 `8fcc32c4`, 모델 카드에 원 제작자·원본 revision·변환 명령)로 올리고 등록 목록을 바꿨다. ID `asr-ko-univa`·`level_note`는 그대로라 사용자가 고른 엔진 설정은 유지되고, 새 폴더(`qwen3-asr-1.7b-ko-8bit`)를 받을 때까지는 받아 둔 다른 엔진으로 변환한다. 0.5.2로 bf16을 받은 폴더(`qwen3-asr-1.7b-ko-bf16`)는 등록 밖이 되어 `models prune`으로 지운다. 간이 점검: 원본과 출력 차이 CER 2.5, 처리 시간 약 30% 단축(docs/design/asr-engines.md). | | 2026-10-30 | v3.77 | **0.5.2 릴리스 준비(브랜치 `fix/mic-config-change-crash`).** 버전 0.5.2를 다섯 매니페스트와 잠금 파일에 맞췄다. 0.5.1 이후: 한국어 파인튜닝 엔진 `asr-ko-univa` 등록과 모델 화면 변환 엔진 라디오 목록(v3.75), 장치 전환 중 녹음 프로그램 크래시 수정(v3.76, 이슈 #57). 릴리스 노트 `docs/releases/v0.5.2.md`, 로드맵 항목. | | 2026-10-30 | v3.76 | **녹음 중 입력 장치가 바뀌면 `tractus-rec`가 죽던 것(이슈 #57, 브랜치 `fix/mic-config-change-crash`).** `AVAudioEngineConfigurationChange` → `Recorder.restart` → `MicSource.open`의 `installTap`이 Objective-C 예외(NSException)를 던져 Swift `do/catch`를 지나 프로세스가 abort됐다(66분 녹음 중 크래시, `recording.json`의 `duration`이 null로 남음). ① `RecorderKit.micFormatProblem`: tap 전에 채널·표본 속도 0과 하드웨어 포맷 불일치를 오류로 던진다(`restart`의 경고와 watchdog 2초 재시도로 넘어감). 음성 처리(에코 제거) 중에는 두 포맷이 다를 수 있어 표본 속도 일치는 보지 않는다. ② `MicSource.stop()`마다 새 `AVAudioEngine`으로 바꾸고 관찰자를 다시 단다(장치가 바뀐 뒤 같은 엔진의 입력 노드가 낡은 포맷을 쥐는 것을 피함). ③ 새 SwiftPM 타깃 `ObjCCatch`(Objective-C, `TractusObjC.catching { }`)로 `installTap`을 감싸 NSException을 Swift 오류로 바꾼다(`recorder/`의 tools-version 5.10·플랫폼은 그대로). 이 Mac의 실제 마이크로 일반·에코 제거 녹음이 정상임은 확인했고, **실제 장치 전환(블루투스 연결·해제)은 재현하지 못했다.** 비정상 종료로 `duration`이 null인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. | diff --git a/WORDS.md b/WORDS.md index 134626d..9af9922 100644 --- a/WORDS.md +++ b/WORDS.md @@ -880,7 +880,9 @@ LLM 프롬프트(요약·AI 질문)도 이름 없는 참석자를 `참석자 N` | --- | --- | | 창 이름 (`aria-label`) | `빠른 실행` | | 입력 칸 | placeholder·`aria-label` `노트·동작 찾기` | -| 묶음 제목 | 빈 입력 `최근 노트`(최근에 고친 5개) · `동작`, 입력이 있으면 `노트`(제목 일치, 사이드바 검색과 같은 규칙) · `동작`(이름에 낱말이 모두 든 것) · `찾기` | +| 묶음 제목 | 노트를 열었으면 맨 앞에 `이 노트`. 빈 입력 `최근 노트`(최근에 고친 5개) · `동작`, 입력이 있으면 `노트`(제목 일치, 사이드바 검색과 같은 규칙) · `동작`(이름에 낱말이 모두 든 것) · `폴더·태그` · `찾기` | +| 이 노트 (2026-11-06) | `즐겨찾기에 추가`/`즐겨찾기에서 빼기` · `태그 편집…`(사이드바 줄 메뉴와 같은 말, 노트 머리의 태그 칸에 초점) · `내보내기: {format}`(형식 이름은 내보내기 메뉴와 같다. 빈 입력에서는 Markdown·Word만, 나머지는 `내보내기`나 확장자로 찾을 때) · `Obsidian으로 보내기`. 노트 머리의 메뉴와 같은 함수다 | +| 폴더·태그 (2026-11-06) | 폴더는 경로(`상위/하위`), 태그는 `#태그`. 고르면 사이드바 거르기가 그 폴더(태그) 하나로 바뀌고 노트 목록이 접혀 있으면 펼친다 | | 동작 | `녹음 시작` · `글쓰기`(⌘N) · `파일 가져오기` · `AI 어시스턴트`(⌘J) · `홈` · `사전` · `설정` · `모델` · `시작 점검` · `노트 목록 접기`/`노트 목록 펼치기`(⌘B). 새 노트 메뉴와 같은 이유로 막힌 동작은 흐리게 하고 오른쪽에 이유(`이미 녹음 중입니다` 등)를 보이며 고를 수 없다. 모델이 없는 것 같은 기능 잠금은 실행하면 그 단추와 똑같이 알린다 | | 내용 검색 | `내용에서 ‘{q}’ 찾기` (스마트 검색 화면으로 간다) | | 결과 없음 | `맞는 것이 없습니다` | diff --git a/app/src/App.tsx b/app/src/App.tsx index 71b8659..f798834 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -32,10 +32,10 @@ import { ErrorDetail } from "./ErrorDetail"; import { t } from "./i18n"; import { bindIndexer, indexer } from "./indexer"; import { baseName, errorView, isImeKey, isRecLive, IDLE_JOB, JOB_ERROR, IMPORT_EXTS, isImportable, isTypingTarget, recMode, REC_MODES, type RecMode } from "./lib"; -import { MeetingView } from "./MeetingView"; +import { MeetingView, type ExportReq } from "./MeetingView"; import type { NewNoteAction } from "./menu"; import { NewNoteMenu } from "./NewNoteMenu"; -import { dropMemo, flushMemos, isMemo, settleMemo } from "./memo"; +import { dropMemo, exportFormatsFor, flushMemos, isMemo, settleMemo } from "./memo"; import { RecordView } from "./RecordView"; import { deleteDialogText, deletedText, deleteFailure, deleteReduce, IDLE_DELETE, type DeleteTarget } from "./remove"; import { SearchView } from "./SearchView"; @@ -45,17 +45,17 @@ import { AfterRecordDialog } from "./AfterRecordDialog"; import { afterRecord, ASK_BLOCKS_RECORD, effectiveChoice, rememberedChoice, stopAction, type AskChoice } from "./afterRecord"; import { LIVE_BLOCKS_RECORD, recordBlock } from "./recordBusy"; import { recButton } from "./pause"; -import { BULK_MIN, CONVERT_ALL, CONVERT_ALL_TITLE, WAIT_OTHER, WAIT_RECORDING, type ConvertControls } from "./recorded"; +import { BULK_MIN, CONVERT_ALL, CONVERT_ALL_TITLE, isRecorded, WAIT_OTHER, WAIT_RECORDING, type ConvertControls } from "./recorded"; import { NoteLibrary } from "./NoteLibrary"; import { QuickFind } from "./QuickFind"; import { CommandPalette } from "./CommandPalette"; import type { PaletteAction } from "./palette"; -import { touchNote as touchNoteIn, type Folder } from "./organize"; +import { folderPath, touchNote as touchNoteIn, type Folder } from "./organize"; import { OrganizeContext, useOrganizeState } from "./useOrganize"; import { PanelResizer, PanelToggle, usePanels } from "./PanelToggle"; import { PANEL_TEXT, RAIL_REC_LABEL, railPendingLabel, railRecState, toggleLabel } from "./panels"; import { check } from "@tauri-apps/plugin-updater"; -import { AudioLines, BookA, Box, House, Mic, PanelLeft, Settings, Sparkles, SquarePen, Stethoscope, Upload, X } from "lucide-react"; +import { AudioLines, BookA, Box, Download, Folder as FolderIcon, House, Mic, PanelLeft, Settings, Sparkles, SquarePen, Star, Stethoscope, Tag, Upload, X } from "lucide-react"; import { Button, buttonVariants } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { cn } from "@/components/ui/utils"; @@ -95,6 +95,7 @@ export function App() { // 전체 회의에 질문 (6.3): 오른쪽 서랍. 닫아도 대화는 남는다(앱을 끄기 전까지) const [askOpen, setAskOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); + const [exportReq, setExportReq] = useState(null); // ⌘K의 노트 내보내기 요청 const askInput = useRef(null); const askButton = useRef(null); const closeAsk = useCallback(() => { @@ -656,6 +657,27 @@ export function App() { { id: "checkup", label: t("시작 점검"), icon: , run: caps.openCheckup }, { id: "left", label: leftToggle.label, icon: , kbd: PANEL_TEXT.left.key, run: () => void panels.toggle("left") }, ]; + const COMMON_EXPORTS = ["md", "docx"]; // 빈 입력에서도 보이는 내보내기 형식. 나머지는 `내보내기`로 찾는다 + // 지금 연 노트의 동작: 즐겨찾기·태그는 사이드바 줄 메뉴와, 내보내기는 노트 머리의 내보내기 메뉴와 같은 함수다 (내보내기는 MeetingView가 exportReq를 받아 한다) + const openNote = view.kind === "meeting" ? meetings.find((m) => m.id === view.id) : undefined; + const noteActions: PaletteAction[] = openNote + ? [ + { id: "favorite", label: openNote.favorite ? t("즐겨찾기에서 빼기") : t("즐겨찾기에 추가"), icon: , run: () => void org.setFavorite(openNote.id, !openNote.favorite) }, + { id: "tags", label: t("태그 편집…"), icon: , run: () => org.requestTagFocus(openNote.id) }, + ...exportFormatsFor(isMemo(openNote), isRecorded(openNote)).map((f) => ({ id: `export-${f.format}`, label: t("내보내기: {format}", { format: f.label }), keywords: f.ext, icon: , typed: !COMMON_EXPORTS.includes(f.format), run: () => setExportReq({ id: openNote.id, format: f.format, nonce: Date.now() }) })), + { id: "obsidian", label: t("Obsidian으로 보내기"), keywords: t("내보내기"), icon: , run: () => setExportReq({ id: openNote.id, format: "obsidian", nonce: Date.now() }) }, + ] + : []; + // 폴더·태그로 바로 가기: 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고 노트 목록을 펼친다 + const showFiltered = (apply: () => void) => { + org.clearFilters(); + apply(); + if (panels.state.left) void panels.toggle("left"); + }; + const jumps: PaletteAction[] = [ + ...folders.map((f) => ({ id: `folder-${f.id}`, label: folderPath(folders, f.id), keywords: t("폴더"), icon: , run: () => showFiltered(() => org.selectFolder(f.id)) })), + ...org.tags.map(({ tag }) => ({ id: `tag-${tag}`, label: `#${tag}`, keywords: t("태그"), icon: , run: () => showFiltered(() => org.toggleTag(tag)) })), + ]; const waitReason = recording && liveRec ? WAIT_RECORDING : WAIT_OTHER; const convertControls: ConvertControls | undefined = view.kind === "meeting" ? { slot: convert.slotOf(view.id), waitReason, gate: convertGate, onConvert: convertNote } : undefined; @@ -859,6 +881,7 @@ export function App() { attach={attachControls} convert={convertControls} summaryStale={noteEpoch.stale && noteEpoch.id === view.id} + exportReq={exportReq?.id === view.id ? exportReq : undefined} /> )} {view.kind === "search" && } @@ -905,7 +928,7 @@ export function App() { /> )} {afterAsk && } - {paletteOpen && void go({ kind: "search", q })} onClose={() => setPaletteOpen(false)} />} + {paletteOpen && void go({ kind: "search", q })} onClose={() => setPaletteOpen(false)} />} {dropAsk && ( void; onSearch: (q: string) => void; onClose: () => void }) { +const NONE: readonly PaletteAction[] = []; + +export function CommandPalette({ notes, actions, noteActions = NONE, jumps = NONE, onOpenNote, onSearch, onClose }: { notes: readonly MeetingInfo[]; actions: readonly PaletteAction[]; noteActions?: readonly PaletteAction[]; jumps?: readonly PaletteAction[]; onOpenNote: (id: string) => void; onSearch: (q: string) => void; onClose: () => void }) { const dialog = useRef(null); const [q, setQ] = useState(""); - const sections = useMemo(() => paletteSections(notes, actions, q), [notes, actions, q]); + const sections = useMemo(() => paletteSections(notes, actions, q, noteActions, jumps), [notes, actions, q, noteActions, jumps]); const items = useMemo(() => sections.flatMap((s) => s.items), [sections]); const [pick, setPick] = useState(0); const id = useId(); diff --git a/app/src/MeetingView.tsx b/app/src/MeetingView.tsx index e65b180..16aec74 100644 --- a/app/src/MeetingView.tsx +++ b/app/src/MeetingView.tsx @@ -38,7 +38,7 @@ import { LOCK_NOTICE, type AttachControls } from "./attach"; import { ConvertPanel } from "./ConvertPanel"; import { useConvertJob } from "./convertJobs"; import { CONVERT_FIRST, deleteBlockedWhileConverting, isRecorded, keptNotice, recordedStats, type ConvertControls } from "./recorded"; -import { isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo"; +import { exportFormatsFor, isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/utils"; import { PolishBar } from "./PolishControls"; @@ -59,6 +59,9 @@ import { SegmentRow } from "@/components/note/SegmentRow"; import { MemoRow } from "@/components/note/MemoRow"; import { livePolish, nextSuggestion, pendingIds, suggestionMarks } from "./polish"; +/** 노트 머리의 내보내기 메뉴와 같은 일을 밖에서 부탁한다: format은 EXPORT_FORMATS의 형식 또는 "obsidian" */ +export type ExportReq = { id: string; format: string; nonce: number }; + export function MeetingView({ id, seek, @@ -69,6 +72,7 @@ export function MeetingView({ attach, convert, summaryStale = false, + exportReq, }: { id: string; seek?: Seek; @@ -83,6 +87,8 @@ export function MeetingView({ convert?: ConvertControls; /** 녹음을 방금 추가해 열었다: 요약이 있어도 아직 새 내용이 반영되지 않았다 */ summaryStale?: boolean; + /** ⌘K 빠른 실행의 내보내기 요청 (nonce가 바뀌면 새 요청) */ + exportReq?: ExportReq; }) { const note = useNote({ id, summaryStale, notify, onEdited, onChanged }); const { detail, commit, summaryRev, refresh: refreshNote } = note.data; @@ -184,6 +190,14 @@ export function MeetingView({ const active = segmentIndexAt(segments, pos); const exp = useNoteExport({ id, detail, memoNote, selectedSummary, serial, notify }); + const doneReq = useRef(0); + useEffect(() => { + if (!exportReq || !detail || exportReq.nonce === doneReq.current) return; // 노트를 다 읽은 뒤에 한 번만 + doneReq.current = exportReq.nonce; + if (exportReq.format === "obsidian") return void exp.toObsidian(); + const f = exportFormatsFor(memoNote, isRecorded(detail.info)).find((x) => x.format === exportReq.format); + if (f) void exp.exportAs(f); + }, [exportReq, detail]); // exp는 렌더마다 새로 만들어 넣지 않는다 const railState = useRail(memoNote, memoRef, setMemoPick); const { railCollapsed, openMemoLine } = railState; diff --git a/app/src/en/palette.ts b/app/src/en/palette.ts index 3d8568c..4a45741 100644 --- a/app/src/en/palette.ts +++ b/app/src/en/palette.ts @@ -8,4 +8,7 @@ export default { "내용에서 ‘{q}’ 찾기": "Search contents for “{q}”", "맞는 것이 없습니다": "Nothing matches", "새 메모": "new memo", + "이 노트": "This note", + "폴더·태그": "Folders & tags", + "내보내기: {format}": "Export: {format}", } satisfies Dict; diff --git a/app/src/palette.test.ts b/app/src/palette.test.ts index aaffd16..9c80597 100644 --- a/app/src/palette.test.ts +++ b/app/src/palette.test.ts @@ -25,6 +25,19 @@ describe("묶음", () => { expect(paletteSections(NOTES, ACTIONS, "메모").map((x) => x.title)).toEqual(["동작", "찾기"]); // 찾기 말로도 맞는다 }); + test("이 노트의 동작은 맨 앞에, 폴더·태그는 입력이 있을 때만 동작 뒤에", () => { + const mine = [act("favorite", "즐겨찾기에 추가"), act("export-md", "내보내기: Markdown", { keywords: "md" })]; + const jumps = [act("folder-1", "프로젝트/예산"), act("tag-x", "#회고")]; + expect(paletteSections(NOTES, ACTIONS, "", mine, jumps).map((x) => x.title)).toEqual(["이 노트", "최근 노트", "동작"]); + expect(paletteSections(NOTES, ACTIONS, "예산", mine, jumps).map((x) => x.title)).toEqual(["노트", "폴더·태그", "찾기"]); + const rare = act("export-srt", "내보내기: SRT 자막", { typed: true }); + expect(paletteSections(NOTES, ACTIONS, "", [...mine, rare])[0].items).toHaveLength(2); // 찾을 때만 나온다 + expect(paletteSections(NOTES, ACTIONS, "srt", [...mine, rare])[0].items).toHaveLength(1); + const md = paletteSections(NOTES, ACTIONS, "md", mine, jumps); + expect(md[0].title).toBe("이 노트"); + expect(md[0].items.map((i) => (i.kind === "action" ? i.action.id : ""))).toEqual(["export-md"]); + }); + test("동작은 이름·찾기 말에 낱말이 모두 있어야 맞는다 (대소문자 무시)", () => { expect(actionMatches(ACTIONS[2], "VOCAB")).toBe(true); expect(actionMatches(ACTIONS[0], "녹음 시작")).toBe(true); diff --git a/app/src/palette.ts b/app/src/palette.ts index 833275a..b496ca3 100644 --- a/app/src/palette.ts +++ b/app/src/palette.ts @@ -14,6 +14,8 @@ export type PaletteAction = { keywords?: string; icon?: ReactNode; kbd?: string; + /** 빈 입력에서는 보이지 않고 찾을 때만 나온다 (자주 쓰지 않는 것으로 목록을 길게 만들지 않게) */ + typed?: boolean; /** 지금 할 수 없으면 그 이유. 고를 수 없다 */ blocked?: string; run: () => void; @@ -30,13 +32,17 @@ export function actionMatches(a: Pick, quer return query.toLowerCase().split(/\s+/).filter(Boolean).every((w) => hay.includes(w)); } -/** 입력에 맞는 묶음. 빈 입력: 최근 노트 + 모든 동작. 입력이 있으면 맞는 노트·동작 + 맨 끝 `내용에서 찾기`. 빈 묶음은 뺀다 */ -export function paletteSections(notes: readonly MeetingInfo[], actions: readonly PaletteAction[], query: string): PaletteSection[] { +/** 입력에 맞는 묶음. 빈 입력: 이 노트의 동작 + 최근 노트 + 모든 동작. 입력이 있으면 맞는 동작·노트 + 폴더·태그 + 맨 끝 `내용에서 찾기`. 빈 묶음은 뺀다. + * noteActions: 지금 연 노트의 동작 (노트를 열었을 때만). jumps: 폴더·태그로 거르기 (입력이 있을 때만: 빈 목록을 길게 만들지 않게) */ +export function paletteSections(notes: readonly MeetingInfo[], actions: readonly PaletteAction[], query: string, noteActions: readonly PaletteAction[] = [], jumps: readonly PaletteAction[] = []): PaletteSection[] { const q = query.trim(); const found = q ? quickMatches(notes, q) : [...notes].sort(byUpdated).slice(0, RECENT_NOTES); + const match = (list: readonly PaletteAction[]): PaletteItem[] => list.filter((a) => (q ? actionMatches(a, q) : !a.typed)).map((action) => ({ kind: "action", action })); const sections: PaletteSection[] = [ + { title: t("이 노트"), items: match(noteActions) }, { title: q ? t("노트") : t("최근 노트"), items: found.map((note) => ({ kind: "note", note })) }, - { title: t("동작"), items: actions.filter((a) => !q || actionMatches(a, q)).map((action) => ({ kind: "action", action })) }, + { title: t("동작"), items: match(actions) }, + { title: t("폴더·태그"), items: q ? match(jumps) : [] }, { title: t("찾기"), items: q ? [{ kind: "search", q }] : [] }, ]; return sections.filter((s) => s.items.length); diff --git a/docs/en/usage.md b/docs/en/usage.md index 0b86560..0026bc9 100644 --- a/docs/en/usage.md +++ b/docs/en/usage.md @@ -53,7 +53,7 @@ How to create, refine and find notes in the app. Terminal commands are in [CLI](
Finding, organizing, exporting, deleting -- **Quick actions:** ⌘K. A window in the middle of the screen finds notes by title, or runs actions such as Start recording, Write, Vocabulary and Settings. Pick `Search contents for “…”` at the bottom to search inside notes. +- **Quick actions:** ⌘K. A window in the middle of the screen finds notes by title, or runs actions such as Start recording, Write, Vocabulary and Settings. Pick `Search contents for “…”` at the bottom to search inside notes. With a note open, `This note` at the top favorites it, edits its tags or exports it; type a folder or tag name to show only those notes in the sidebar. - **Search:** the `Search notes` box in the sidebar, or ⌘K's `Search contents`. Shows word matches and `Similar meaning` results together. Without the smart search model, it finds word matches only. - **Layout:** Drag the edge of the note list or the side panel to resize it (double-click to reset). Choose the theme and language (`System`, `한국어`, `English`) in `Settings` › `Appearance`. - **Organizing:** Add a favorite (★), tags (up to 20) and a folder (up to 3 levels deep) from the note header. You can drag notes onto folders in the sidebar, and sort the list by `Last modified`, `Date created`, `Title` or `Length`. diff --git a/docs/usage.md b/docs/usage.md index 79f22f9..4c5151a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -53,7 +53,7 @@
찾기, 정리, 내보내기, 삭제 -- **빠른 실행:** ⌘K. 화면 가운데 창에서 노트를 제목으로 찾아 열거나, 녹음 시작·글쓰기·사전·설정 같은 동작을 실행합니다. 맨 아래 `내용에서 ‘…’ 찾기`를 고르면 노트 내용을 검색합니다. +- **빠른 실행:** ⌘K. 화면 가운데 창에서 노트를 제목으로 찾아 열거나, 녹음 시작·글쓰기·사전·설정 같은 동작을 실행합니다. 맨 아래 `내용에서 ‘…’ 찾기`를 고르면 노트 내용을 검색합니다. 노트를 연 채로 열면 맨 앞 `이 노트`에서 즐겨찾기·태그 편집·내보내기를 바로 하고, 폴더나 태그 이름을 치면 그 폴더(태그)의 노트만 사이드바에 보입니다. - **검색:** 사이드바 `노트 검색` 칸이나 ⌘K의 `내용에서 찾기`. 단어 일치와 `비슷한 뜻`을 함께 보여 줍니다. 스마트 검색 모델이 없으면 단어 일치로만 찾습니다. - **화면:** 노트 목록과 오른쪽 패널의 경계를 끌어 폭을 바꿉니다(두 번 누르면 기본 폭). `설정` › `화면`에서 테마와 언어(`시스템 설정`·`한국어`·`English`)를 고릅니다. - **정리:** 노트 머리에서 즐겨찾기(★), 태그(20개까지), 폴더(3단계까지)를 붙입니다. 사이드바에서 폴더로 끌어 놓을 수 있고, 목록은 `최근 수정 순`·`만든 순`·`제목 순`·`길이 순`으로 봅니다.