feat: add mobile shell and unified search
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
This commit is contained in:
parent
f505a03d34
commit
5ca3709b32
98 changed files with 5168 additions and 2093 deletions
|
|
@ -30,6 +30,7 @@ import { registerAdsHandlers } from './ads-handlers'
|
|||
import { registerSupportHandlers } from './support-handlers'
|
||||
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
|
||||
import { registerSuggestionHandlers } from './suggestion-handlers'
|
||||
import { registerUnifiedSearchHandlers } from './unified-search-handlers'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
|
||||
const logger = getLogger('ipc')
|
||||
|
|
@ -44,6 +45,7 @@ export function registerAllIpcHandlers(): void {
|
|||
registerKeyBindingHandlers()
|
||||
registerLLMHandlers()
|
||||
registerHistoryHandlers()
|
||||
registerUnifiedSearchHandlers()
|
||||
registerDictionaryHandlers()
|
||||
registerInstructionHandlers()
|
||||
registerMemoHandlers()
|
||||
|
|
|
|||
19
apps/desktop/src/main/ipc/unified-search-handlers.ts
Normal file
19
apps/desktop/src/main/ipc/unified-search-handlers.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { D3ROError, ErrorCode, ipcError, ipcSuccess } from '@d3ro/core/errors'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import {
|
||||
getUnifiedSearchService,
|
||||
validateUnifiedSearchParams,
|
||||
} from '../services/UnifiedSearchService'
|
||||
|
||||
export function registerUnifiedSearchHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.UNIFIED_SEARCH.QUERY, (_event, params: unknown) => {
|
||||
try {
|
||||
const validatedParams = validateUnifiedSearchParams(params)
|
||||
return ipcSuccess(getUnifiedSearchService().search(validatedParams))
|
||||
} catch (error) {
|
||||
if (error instanceof D3ROError) return ipcError(error.code, error.message, error.details)
|
||||
return ipcError(ErrorCode.DBQueryFailed, 'Unified search failed')
|
||||
}
|
||||
})
|
||||
}
|
||||
182
apps/desktop/src/main/services/UnifiedSearchService.ts
Normal file
182
apps/desktop/src/main/services/UnifiedSearchService.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// 기록과 회의를 스키마 변경 없이 함께 찾는 로컬 통합 검색.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { count, desc, or } from 'drizzle-orm'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
UnifiedSearchParams,
|
||||
UnifiedSearchResponse,
|
||||
UnifiedSearchResult,
|
||||
} from '@d3ro/core/types'
|
||||
import { getDatabase } from '../db'
|
||||
import { containsPattern } from '../db/like-pattern'
|
||||
import { history, meetingSessions, type History, type MeetingSessionRow } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('UnifiedSearchService')
|
||||
const DEFAULT_LIMIT = 30
|
||||
const MAX_LIMIT = 50
|
||||
const MAX_QUERY_LENGTH = 200
|
||||
const EXCERPT_LENGTH = 180
|
||||
|
||||
export function validateUnifiedSearchParams(value: unknown): UnifiedSearchParams {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search parameters are required')
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>
|
||||
if (typeof candidate.query !== 'string') {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search query must be a string', {
|
||||
field: 'query',
|
||||
})
|
||||
}
|
||||
if (
|
||||
candidate.limit !== undefined
|
||||
&& (typeof candidate.limit !== 'number' || !Number.isFinite(candidate.limit))
|
||||
) {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search limit must be a finite number', {
|
||||
field: 'limit',
|
||||
})
|
||||
}
|
||||
|
||||
return candidate.limit === undefined
|
||||
? { query: candidate.query }
|
||||
: { query: candidate.query, limit: candidate.limit as number }
|
||||
}
|
||||
|
||||
export function normalizeUnifiedSearchQuery(value: string): string {
|
||||
return value.slice(0, MAX_QUERY_LENGTH).trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function includesQuery(value: string | null | undefined, query: string): boolean {
|
||||
return value?.toLocaleLowerCase().includes(query.toLocaleLowerCase()) === true
|
||||
}
|
||||
|
||||
export function createUnifiedSearchExcerpt(
|
||||
value: string | null | undefined,
|
||||
query: string,
|
||||
maxLength = EXCERPT_LENGTH,
|
||||
): string {
|
||||
const text = (value ?? '').replace(/\s+/g, ' ').trim()
|
||||
if (text.length <= maxLength) return text
|
||||
|
||||
const matchAt = text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase())
|
||||
if (matchAt < 0) return `${text.slice(0, maxLength - 1).trimEnd()}…`
|
||||
|
||||
const context = Math.max(20, Math.floor((maxLength - query.length) / 2))
|
||||
const start = Math.max(0, matchAt - context)
|
||||
const end = Math.min(text.length, start + maxLength)
|
||||
const body = text.slice(start, end).trim()
|
||||
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`
|
||||
}
|
||||
|
||||
function recordResult(row: History, query: string): UnifiedSearchResult {
|
||||
const titleMatched = includesQuery(row.title, query)
|
||||
const matchingContent = [row.polishedText, row.originalText, row.summaryText]
|
||||
.find((value) => includesQuery(value, query))
|
||||
const displayContent = matchingContent ?? row.polishedText ?? row.originalText ?? row.summaryText ?? row.title
|
||||
|
||||
return {
|
||||
source: 'record',
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
excerpt: createUnifiedSearchExcerpt(displayContent, query),
|
||||
matchedField: titleMatched ? 'title' : 'content',
|
||||
occurredAt: row.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function meetingResult(row: MeetingSessionRow, query: string): UnifiedSearchResult {
|
||||
const titleMatched = includesQuery(row.title, query)
|
||||
const matchingTranscript = [row.editedTranscript, row.rawTranscript]
|
||||
.find((value) => includesQuery(value, query))
|
||||
const displayContent = matchingTranscript ?? row.editedTranscript ?? row.rawTranscript ?? row.title
|
||||
|
||||
return {
|
||||
source: 'meeting',
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
excerpt: createUnifiedSearchExcerpt(displayContent, query),
|
||||
matchedField: titleMatched ? 'title' : 'transcript',
|
||||
occurredAt: row.startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeUnifiedSearchResults(
|
||||
records: readonly UnifiedSearchResult[],
|
||||
meetings: readonly UnifiedSearchResult[],
|
||||
limit: number,
|
||||
): UnifiedSearchResult[] {
|
||||
return [...records, ...meetings]
|
||||
.sort((left, right) => right.occurredAt - left.occurredAt || left.id.localeCompare(right.id))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
class UnifiedSearchService extends EventEmitter {
|
||||
search(value: unknown): UnifiedSearchResponse {
|
||||
const params = validateUnifiedSearchParams(value)
|
||||
const query = normalizeUnifiedSearchQuery(params.query)
|
||||
const limit = Math.max(1, Math.min(Math.floor(params.limit ?? DEFAULT_LIMIT), MAX_LIMIT))
|
||||
if (query.length === 0) return { query, results: [], total: 0 }
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const pattern = containsPattern(query)
|
||||
const recordConditions = or(
|
||||
pattern.match(history.title),
|
||||
pattern.match(history.originalText),
|
||||
pattern.match(history.polishedText),
|
||||
pattern.match(history.summaryText),
|
||||
)
|
||||
const meetingConditions = or(
|
||||
pattern.match(meetingSessions.title),
|
||||
pattern.match(meetingSessions.editedTranscript),
|
||||
pattern.match(meetingSessions.rawTranscript),
|
||||
)
|
||||
|
||||
const recordTotal = db.select({ count: count() }).from(history).where(recordConditions).get()?.count ?? 0
|
||||
const recordRows = db
|
||||
.select()
|
||||
.from(history)
|
||||
.where(recordConditions)
|
||||
.orderBy(desc(history.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
const meetingTotal = db.select({ count: count() }).from(meetingSessions).where(meetingConditions).get()?.count ?? 0
|
||||
const meetingRows = db
|
||||
.select()
|
||||
.from(meetingSessions)
|
||||
.where(meetingConditions)
|
||||
.orderBy(desc(meetingSessions.startedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
|
||||
const results = mergeUnifiedSearchResults(
|
||||
recordRows.map((row) => recordResult(row, query)),
|
||||
meetingRows.map((row) => meetingResult(row, query)),
|
||||
limit,
|
||||
)
|
||||
return { query, results, total: recordTotal + meetingTotal }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`Unified search failed: ${message}`)
|
||||
throw new D3ROError(ErrorCode.DBQueryFailed, 'Unified search failed', { cause: message })
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
let instance: UnifiedSearchService | null = null
|
||||
|
||||
export function getUnifiedSearchService(): UnifiedSearchService {
|
||||
if (instance === null) instance = new UnifiedSearchService()
|
||||
return instance
|
||||
}
|
||||
|
||||
export function resetUnifiedSearchServiceForTests(): void {
|
||||
instance?.dispose()
|
||||
instance = null
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ import type {
|
|||
HistorySearchParams,
|
||||
HistorySetFavoriteParams,
|
||||
HistoryAudioSource,
|
||||
UnifiedSearchParams,
|
||||
UnifiedSearchResponse,
|
||||
DictionaryQueryParams,
|
||||
DictionaryPage,
|
||||
DictionaryEntry,
|
||||
|
|
@ -426,6 +428,12 @@ const electronAPI = {
|
|||
on(IPC_CHANNELS.HISTORY.ADDED, cb)
|
||||
},
|
||||
|
||||
// ── Unified search (records + meetings) ───────────────
|
||||
unifiedSearch: {
|
||||
query: (params: UnifiedSearchParams) =>
|
||||
invoke<UnifiedSearchResponse>(IPC_CHANNELS.UNIFIED_SEARCH.QUERY, params),
|
||||
},
|
||||
|
||||
// ── Dictionary ─────────────────────────────────────────
|
||||
dictionary: {
|
||||
getAll: (params: DictionaryQueryParams) =>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CommandsPage } from '../pages/CommandsPage'
|
|||
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
|
||||
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
|
||||
import { MeetingModePage } from '../pages/MeetingModePage'
|
||||
import { UnifiedSearchPage } from '../pages/UnifiedSearchPage'
|
||||
import { SettingsModal } from './SettingsModal'
|
||||
import { OPEN_SETTINGS_EVENT, readOpenSettingsTab, DEFAULT_SETTINGS_TAB } from '../navigation/settingsTabs'
|
||||
import type { SettingsTabId } from '../navigation/settingsTabs'
|
||||
|
|
@ -132,10 +133,16 @@ function Shell(): React.ReactElement {
|
|||
}
|
||||
}, [t, navigate])
|
||||
|
||||
// 키보드: Ctrl+1..3 = 1급 화면, Ctrl+, = 설정. 입력 중에는 무시.
|
||||
// 키보드: Ctrl+K = 통합 검색, Ctrl+1..3 = 1급 화면, Ctrl+, = 설정.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || isTypingTarget(e.target)) return
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return
|
||||
if (e.key.toLocaleLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
if (route !== 'search') navigate('search')
|
||||
return
|
||||
}
|
||||
if (isTypingTarget(e.target)) return
|
||||
const index = ['1', '2', '3'].indexOf(e.key)
|
||||
if (index >= 0) {
|
||||
e.preventDefault()
|
||||
|
|
@ -147,7 +154,7 @@ function Shell(): React.ReactElement {
|
|||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [navigate])
|
||||
}, [navigate, route])
|
||||
|
||||
const openSettingsTab = useCallback(
|
||||
(tab: SettingsTabId) => {
|
||||
|
|
@ -186,6 +193,7 @@ function Shell(): React.ReactElement {
|
|||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
{route === 'home' && <HomePage />}
|
||||
{route === 'search' && <UnifiedSearchPage />}
|
||||
{route === 'records' && <HistoryPage />}
|
||||
{route === 'meetings' && <MeetingModePage />}
|
||||
{route === 'conversation' && <VoiceConversationPage />}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ export interface SearchInputProps {
|
|||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
/** 포커스 단축키. '/' 만 지원. 빈 문자열이면 끈다 */
|
||||
/** 포커스 단축키. '/' 또는 'Ctrl K'. 빈 문자열이면 끈다 */
|
||||
shortcut?: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
|
|
@ -21,17 +22,34 @@ function isTypingTarget(target: EventTarget | null): boolean {
|
|||
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: SearchInputProps): React.ReactElement {
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
shortcut = '/',
|
||||
autoFocus = false,
|
||||
}: SearchInputProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcut !== '/') return undefined
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return
|
||||
const slash = shortcut === '/'
|
||||
&& e.key === '/'
|
||||
&& !e.ctrlKey
|
||||
&& !e.metaKey
|
||||
&& !e.altKey
|
||||
&& !isTypingTarget(e.target)
|
||||
const commandK = shortcut === 'Ctrl K'
|
||||
&& (e.ctrlKey || e.metaKey)
|
||||
&& !e.altKey
|
||||
&& !e.shiftKey
|
||||
&& e.key.toLocaleLowerCase() === 'k'
|
||||
if (!slash && !commandK) return
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (shortcut !== '/' && shortcut !== 'Ctrl K') return undefined
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [shortcut])
|
||||
|
|
@ -39,6 +57,7 @@ export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: Se
|
|||
return (
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Home,
|
||||
Library,
|
||||
MessagesSquare,
|
||||
Search,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
Users,
|
||||
|
|
@ -31,6 +32,7 @@ const ICON = 18
|
|||
|
||||
const ROUTE_META: Record<AppRoute, { labelKey: TranslationKey; icon: React.ReactElement }> = {
|
||||
home: { labelKey: 'nav.home', icon: <Home size={ICON} /> },
|
||||
search: { labelKey: 'unifiedSearch.title', icon: <Search size={ICON} /> },
|
||||
records: { labelKey: 'nav.records', icon: <HistoryIcon size={ICON} /> },
|
||||
meetings: { labelKey: 'nav.meeting', icon: <Users size={ICON} /> },
|
||||
conversation: { labelKey: 'nav.aiChat', icon: <MessagesSquare size={ICON} /> },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SettingsTabId } from './settingsTabs'
|
|||
|
||||
export type AppRoute =
|
||||
| 'home'
|
||||
| 'search'
|
||||
| 'records'
|
||||
| 'meetings'
|
||||
| 'conversation'
|
||||
|
|
|
|||
251
apps/desktop/src/renderer/pages/UnifiedSearchPage.tsx
Normal file
251
apps/desktop/src/renderer/pages/UnifiedSearchPage.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// 기록과 회의 제목·전사를 함께 찾고, 원본 상세로 돌려보내는 읽기 전용 검색 화면.
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Box, ButtonBase, IconButton, Skeleton, Typography } from '@mui/material'
|
||||
import { AlertTriangle, ArrowLeft, FileText, Search as SearchIcon, SearchX, Users } from 'lucide-react'
|
||||
import type { UnifiedSearchMatchedField, UnifiedSearchResponse, UnifiedSearchResult } from '@d3ro/core/types'
|
||||
import { PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import {
|
||||
d3roFocusRing,
|
||||
d3roPalette,
|
||||
d3roRadius,
|
||||
d3roTransition,
|
||||
typoSx,
|
||||
} from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { EmptyStateCard, PageHeader, SearchInput } from '../components/shared'
|
||||
import { useAppNavigation, useRouteRequest } from '../navigation/app-navigation'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 180
|
||||
|
||||
type SearchState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; response: UnifiedSearchResponse }
|
||||
| { status: 'error' }
|
||||
|
||||
function resultIcon(source: UnifiedSearchResult['source']): React.ReactElement {
|
||||
return source === 'record' ? <FileText size={18} aria-hidden /> : <Users size={18} aria-hidden />
|
||||
}
|
||||
|
||||
export function UnifiedSearchPage(): React.ReactElement {
|
||||
const { t, locale } = useI18n()
|
||||
const { navigate, back } = useAppNavigation()
|
||||
const [query, setQuery] = useState('')
|
||||
const [state, setState] = useState<SearchState>({ status: 'idle' })
|
||||
const [retryNonce, setRetryNonce] = useState(0)
|
||||
const requestSequence = useRef(0)
|
||||
|
||||
useRouteRequest('search', (params) => {
|
||||
setQuery(params.query ?? '')
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim()
|
||||
if (!normalized) {
|
||||
requestSequence.current += 1
|
||||
setState({ status: 'idle' })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sequence = ++requestSequence.current
|
||||
const timer = window.setTimeout(() => {
|
||||
setState({ status: 'loading' })
|
||||
void window.electronAPI.unifiedSearch
|
||||
.query({ query: normalized })
|
||||
.then((result) => {
|
||||
if (requestSequence.current !== sequence) return
|
||||
setState(result.success ? { status: 'ready', response: result.data } : { status: 'error' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestSequence.current === sequence) setState({ status: 'error' })
|
||||
})
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
if (requestSequence.current === sequence) requestSequence.current += 1
|
||||
}
|
||||
}, [query, retryNonce])
|
||||
|
||||
const openResult = useCallback(
|
||||
(result: UnifiedSearchResult) => {
|
||||
if (result.source === 'record') navigate('records', { recordId: result.id })
|
||||
else navigate('meetings', { meetingSessionId: result.id })
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const matchLabel = useCallback(
|
||||
(field: UnifiedSearchMatchedField): string => {
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return t('unifiedSearch.matchedTitle')
|
||||
case 'content':
|
||||
return t('unifiedSearch.matchedContent')
|
||||
case 'transcript':
|
||||
return t('unifiedSearch.matchedTranscript')
|
||||
}
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
const resultCount = state.status === 'ready'
|
||||
? t('unifiedSearch.resultCount', { count: state.response.total })
|
||||
: undefined
|
||||
|
||||
let body: React.ReactNode
|
||||
if (state.status === 'idle') {
|
||||
body = (
|
||||
<EmptyStateCard
|
||||
icon={<SearchIcon />}
|
||||
message={t('unifiedSearch.promptTitle')}
|
||||
description={t('unifiedSearch.promptDescription')}
|
||||
/>
|
||||
)
|
||||
} else if (state.status === 'loading') {
|
||||
body = (
|
||||
<Box aria-busy="true" aria-label={t('unifiedSearch.loading')} sx={{ pt: 1 }}>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', gap: 2, px: 1.5, py: 1.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
|
||||
<Skeleton variant="circular" width={18} height={18} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width={`${52 + index * 7}%`} />
|
||||
<Skeleton variant="text" width={`${78 - index * 5}%`} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
} else if (state.status === 'error') {
|
||||
body = (
|
||||
<EmptyStateCard
|
||||
icon={<AlertTriangle />}
|
||||
message={t('unifiedSearch.loadErrorTitle')}
|
||||
description={t('unifiedSearch.loadErrorDescription')}
|
||||
action={
|
||||
<PhysicalButton tone="glass" size="small" onClick={() => setRetryNonce((value) => value + 1)}>
|
||||
{t('unifiedSearch.retry')}
|
||||
</PhysicalButton>
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else if (state.response.results.length === 0) {
|
||||
body = (
|
||||
<EmptyStateCard
|
||||
icon={<SearchX />}
|
||||
message={t('unifiedSearch.noResultsTitle')}
|
||||
description={t('unifiedSearch.noResultsDescription', { query: state.response.query })}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<Box component="ul" aria-label={t('unifiedSearch.resultsLabel')} sx={{ m: 0, p: 0, listStyle: 'none' }}>
|
||||
{state.response.results.map((result) => {
|
||||
const sourceLabel = result.source === 'record'
|
||||
? t('unifiedSearch.sourceRecord')
|
||||
: t('unifiedSearch.sourceMeeting')
|
||||
const title = result.title?.trim() || (result.source === 'record'
|
||||
? t('unifiedSearch.untitledRecord')
|
||||
: t('unifiedSearch.untitledMeeting'))
|
||||
|
||||
return (
|
||||
<Box component="li" key={`${result.source}:${result.id}`}>
|
||||
<ButtonBase
|
||||
onClick={() => openResult(result)}
|
||||
aria-label={t('unifiedSearch.openResult', { source: sourceLabel, title })}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 72,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '24px minmax(0, 1fr) auto',
|
||||
alignItems: 'start',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 1.5,
|
||||
textAlign: 'left',
|
||||
color: d3roPalette.text.secondary,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
transition: d3roTransition('background-color', 'color'),
|
||||
'&:hover': { bgcolor: d3roPalette.surface.overlaySoft },
|
||||
'&:focus-visible': d3roFocusRing,
|
||||
}}
|
||||
>
|
||||
<Box aria-hidden sx={{ color: d3roPalette.text.tertiary, pt: 0.25 }}>
|
||||
{resultIcon(result.source)}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography component="h2" sx={{ ...typoSx('compact'), fontWeight: 500, color: d3roPalette.text.primary }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary }}>
|
||||
{sourceLabel} · {matchLabel(result.matchedField)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('compact'),
|
||||
color: d3roPalette.text.secondary,
|
||||
mt: 0.5,
|
||||
overflowWrap: 'anywhere',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{result.excerpt || t('unifiedSearch.noPreview')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
component="time"
|
||||
dateTime={new Date(result.occurredAt).toISOString()}
|
||||
sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary, whiteSpace: 'nowrap', pt: 0.25 }}
|
||||
>
|
||||
{new Date(result.occurredAt).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && query.trim().length === 0) back()
|
||||
}}
|
||||
sx={{ maxWidth: 1000, mx: 'auto', px: 5, py: 4, pb: 10 }}
|
||||
>
|
||||
<PageHeader
|
||||
title={t('unifiedSearch.title')}
|
||||
count={resultCount}
|
||||
description={t('unifiedSearch.description')}
|
||||
action={
|
||||
<IconButton aria-label={t('unifiedSearch.back')} onClick={back} sx={{ color: d3roPalette.text.secondary }}>
|
||||
<ArrowLeft size={18} aria-hidden />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('unifiedSearch.placeholder')}
|
||||
shortcut="Ctrl K"
|
||||
autoFocus
|
||||
/>
|
||||
<Box role="status" aria-live="polite" sx={{ minHeight: 18, mb: 1 }}>
|
||||
{resultCount && (
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary }}>
|
||||
{resultCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{body}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
66
apps/desktop/tests/main/ipc/unified-search-handlers.test.ts
Normal file
66
apps/desktop/tests/main/ipc/unified-search-handlers.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
||||
const handlers = vi.hoisted(
|
||||
() => new Map<string, (event: unknown, params: unknown) => unknown>()
|
||||
)
|
||||
const search = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, params: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/UnifiedSearchService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/main/services/UnifiedSearchService')>()
|
||||
return {
|
||||
...actual,
|
||||
getUnifiedSearchService: () => ({ search }),
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
handlers.clear()
|
||||
const mod = await import('../../../src/main/ipc/unified-search-handlers')
|
||||
mod.registerUnifiedSearchHandlers()
|
||||
})
|
||||
|
||||
describe('UNIFIED_SEARCH.QUERY', () => {
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ query: 42 },
|
||||
{ query: '계획', limit: Number.NaN },
|
||||
{ query: '계획', limit: Number.NEGATIVE_INFINITY },
|
||||
])('returns ConfigInvalidValue for malformed renderer input: %o', async (params) => {
|
||||
const handler = handlers.get(IPC_CHANNELS.UNIFIED_SEARCH.QUERY)
|
||||
if (!handler) throw new Error('unified search handler was not registered')
|
||||
|
||||
const result = await handler({}, params)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { code: ErrorCode.ConfigInvalidValue },
|
||||
})
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes validated input to the service and returns IPC success', async () => {
|
||||
const handler = handlers.get(IPC_CHANNELS.UNIFIED_SEARCH.QUERY)
|
||||
if (!handler) throw new Error('unified search handler was not registered')
|
||||
search.mockReturnValue({ query: '계획', results: [], total: 0 })
|
||||
|
||||
const result = await handler({}, { query: '계획', limit: 10 })
|
||||
|
||||
expect(search).toHaveBeenCalledWith({ query: '계획', limit: 10 })
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { query: '계획', results: [], total: 0 },
|
||||
})
|
||||
})
|
||||
})
|
||||
185
apps/desktop/tests/main/services/UnifiedSearchService.test.ts
Normal file
185
apps/desktop/tests/main/services/UnifiedSearchService.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import type { History, MeetingSessionRow } from '../../../src/main/db/schema'
|
||||
|
||||
interface MockBuilderOptions {
|
||||
get?: unknown
|
||||
all?: unknown[]
|
||||
error?: Error
|
||||
}
|
||||
|
||||
function createMockBuilder(options: MockBuilderOptions = {}) {
|
||||
const builder = {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
orderBy: vi.fn(),
|
||||
limit: vi.fn(),
|
||||
get: vi.fn(),
|
||||
all: vi.fn(),
|
||||
}
|
||||
builder.from.mockImplementation(() => {
|
||||
if (options.error !== undefined) throw options.error
|
||||
return builder
|
||||
})
|
||||
builder.where.mockReturnValue(builder)
|
||||
builder.orderBy.mockReturnValue(builder)
|
||||
builder.limit.mockReturnValue(builder)
|
||||
builder.get.mockReturnValue(options.get ?? null)
|
||||
builder.all.mockReturnValue(options.all ?? [])
|
||||
return builder
|
||||
}
|
||||
|
||||
const selectQueue: ReturnType<typeof createMockBuilder>[] = []
|
||||
const mockDb = {
|
||||
select: vi.fn(() => selectQueue.shift() ?? createMockBuilder()),
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({
|
||||
getDatabase: () => mockDb,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const record: History = {
|
||||
id: 'record-1',
|
||||
title: '제품 메모',
|
||||
originalText: '초기 원문',
|
||||
polishedText: '다음 분기 출시 계획을 정리했습니다.',
|
||||
focusedApp: null,
|
||||
focusedAppName: null,
|
||||
focusedAppWindowTitle: null,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
audioLocalPath: null,
|
||||
duration: 10,
|
||||
detectedLanguage: 'ko',
|
||||
micDevice: null,
|
||||
wordCount: 6,
|
||||
sttModel: null,
|
||||
llmModel: null,
|
||||
sttLatencyMs: null,
|
||||
llmLatencyMs: null,
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
appVersion: '1.9.0',
|
||||
summaryText: null,
|
||||
isFavorite: false,
|
||||
}
|
||||
|
||||
const meeting: MeetingSessionRow = {
|
||||
id: 'meeting-1',
|
||||
title: '주간 회의',
|
||||
status: 'completed',
|
||||
startedAt: 200,
|
||||
endedAt: 300,
|
||||
durationMs: 100,
|
||||
rawTranscript: '출시 계획을 다음 주에 검토합니다.',
|
||||
editedTranscript: null,
|
||||
minutesMarkdown: null,
|
||||
minutesJson: null,
|
||||
sttModel: null,
|
||||
llmModel: null,
|
||||
sttLatencyMs: null,
|
||||
llmLatencyMs: null,
|
||||
errorMessage: null,
|
||||
createdAt: 200,
|
||||
updatedAt: 300,
|
||||
}
|
||||
|
||||
describe('UnifiedSearchService', () => {
|
||||
beforeEach(() => {
|
||||
selectQueue.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes input and creates a bounded excerpt around the match', async () => {
|
||||
const { createUnifiedSearchExcerpt, normalizeUnifiedSearchQuery } = await import(
|
||||
'../../../src/main/services/UnifiedSearchService'
|
||||
)
|
||||
expect(normalizeUnifiedSearchQuery(' 출시\n 계획 ')).toBe('출시 계획')
|
||||
expect(normalizeUnifiedSearchQuery('a'.repeat(250))).toHaveLength(200)
|
||||
expect(normalizeUnifiedSearchQuery(` ${'a'.repeat(199)} b`)).toBe('a'.repeat(199))
|
||||
const excerpt = createUnifiedSearchExcerpt(`앞부분 ${'x'.repeat(80)} 출시 계획 ${'y'.repeat(80)}`, '출시 계획', 60)
|
||||
expect(excerpt).toContain('출시 계획')
|
||||
expect(excerpt.startsWith('…')).toBe(true)
|
||||
expect(excerpt.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('merges records and meeting transcripts by occurrence time and preserves source ids', async () => {
|
||||
selectQueue.push(
|
||||
createMockBuilder({ get: { count: 1 } }),
|
||||
createMockBuilder({ all: [record] }),
|
||||
createMockBuilder({ get: { count: 1 } }),
|
||||
createMockBuilder({ all: [meeting] }),
|
||||
)
|
||||
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
|
||||
'../../../src/main/services/UnifiedSearchService'
|
||||
)
|
||||
resetUnifiedSearchServiceForTests()
|
||||
|
||||
const response = getUnifiedSearchService().search({ query: '계획', limit: 10 })
|
||||
|
||||
expect(response.total).toBe(2)
|
||||
expect(response.results.map(({ source, id }) => ({ source, id }))).toEqual([
|
||||
{ source: 'meeting', id: 'meeting-1' },
|
||||
{ source: 'record', id: 'record-1' },
|
||||
])
|
||||
expect(response.results[0]).toMatchObject({
|
||||
matchedField: 'transcript',
|
||||
excerpt: '출시 계획을 다음 주에 검토합니다.',
|
||||
})
|
||||
expect(response.results[1]).toMatchObject({ matchedField: 'content' })
|
||||
})
|
||||
|
||||
it('returns immediately for an empty query', async () => {
|
||||
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
|
||||
'../../../src/main/services/UnifiedSearchService'
|
||||
)
|
||||
resetUnifiedSearchServiceForTests()
|
||||
expect(getUnifiedSearchService().search({ query: ' ' })).toEqual({
|
||||
query: '',
|
||||
results: [],
|
||||
total: 0,
|
||||
})
|
||||
expect(mockDb.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ query: 42 },
|
||||
{ query: '계획', limit: Number.NaN },
|
||||
{ query: '계획', limit: Number.POSITIVE_INFINITY },
|
||||
{ query: '계획', limit: '10' },
|
||||
])('rejects malformed runtime parameters with ConfigInvalidValue: %o', async (params) => {
|
||||
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
|
||||
'../../../src/main/services/UnifiedSearchService'
|
||||
)
|
||||
resetUnifiedSearchServiceForTests()
|
||||
|
||||
expect(() => getUnifiedSearchService().search(params)).toThrowError(
|
||||
expect.objectContaining({ code: ErrorCode.ConfigInvalidValue }),
|
||||
)
|
||||
expect(mockDb.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps database failures to the IPC error contract', async () => {
|
||||
selectQueue.push(createMockBuilder({ error: new Error('database unavailable') }))
|
||||
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
|
||||
'../../../src/main/services/UnifiedSearchService'
|
||||
)
|
||||
resetUnifiedSearchServiceForTests()
|
||||
expect(() => getUnifiedSearchService().search({ query: '계획' })).toThrowError(
|
||||
expect.objectContaining({ code: ErrorCode.DBQueryFailed }),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue