Merge pull request #144 from scub-france/fix/decoupling-audit
fix(decoupling): eliminate cross-feature imports & typed health endpoint
This commit is contained in:
commit
6d0c4dd192
22 changed files with 276 additions and 239 deletions
|
|
@ -26,6 +26,16 @@ class _CamelModel(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(_CamelModel):
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
engine: str
|
||||||
|
deployment_mode: str
|
||||||
|
database: str
|
||||||
|
max_page_count: int | None = None
|
||||||
|
max_file_size_mb: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class DocumentResponse(_CamelModel):
|
class DocumentResponse(_CamelModel):
|
||||||
id: str
|
id: str
|
||||||
filename: str
|
filename: str
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from api.analyses import router as analyses_router
|
from api.analyses import router as analyses_router
|
||||||
from api.documents import router as documents_router
|
from api.documents import router as documents_router
|
||||||
|
from api.schemas import HealthResponse
|
||||||
from infra.rate_limiter import RateLimiterMiddleware
|
from infra.rate_limiter import RateLimiterMiddleware
|
||||||
from infra.settings import settings
|
from infra.settings import settings
|
||||||
from persistence.analysis_repo import SqliteAnalysisRepository
|
from persistence.analysis_repo import SqliteAnalysisRepository
|
||||||
|
|
@ -141,8 +142,8 @@ app.include_router(documents_router)
|
||||||
app.include_router(analyses_router)
|
app.include_router(analyses_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health", response_model=HealthResponse)
|
||||||
async def health() -> dict[str, str | int]:
|
async def health() -> HealthResponse:
|
||||||
"""Health check endpoint — verifies database connectivity."""
|
"""Health check endpoint — verifies database connectivity."""
|
||||||
db_status = "ok"
|
db_status = "ok"
|
||||||
try:
|
try:
|
||||||
|
|
@ -153,15 +154,12 @@ async def health() -> dict[str, str | int]:
|
||||||
logger.warning("Health check: database unreachable", exc_info=True)
|
logger.warning("Health check: database unreachable", exc_info=True)
|
||||||
|
|
||||||
status = "ok" if db_status == "ok" else "degraded"
|
status = "ok" if db_status == "ok" else "degraded"
|
||||||
result: dict[str, str | int] = {
|
return HealthResponse(
|
||||||
"status": status,
|
status=status,
|
||||||
"version": settings.app_version,
|
version=settings.app_version,
|
||||||
"engine": settings.conversion_engine,
|
engine=settings.conversion_engine,
|
||||||
"deploymentMode": settings.deployment_mode,
|
deployment_mode=settings.deployment_mode,
|
||||||
"database": db_status,
|
database=db_status,
|
||||||
}
|
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
|
||||||
if settings.max_page_count > 0:
|
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
|
||||||
result["maxPageCount"] = settings.max_page_count
|
)
|
||||||
if settings.max_file_size_mb > 0:
|
|
||||||
result["maxFileSizeMb"] = settings.max_file_size_mb
|
|
||||||
return result
|
|
||||||
|
|
|
||||||
|
|
@ -45,26 +45,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const rechunking = ref(false)
|
|
||||||
|
|
||||||
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
|
||||||
rechunking.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
|
|
||||||
if (currentAnalysis.value?.id === jobId) {
|
|
||||||
currentAnalysis.value = await api.fetchAnalysis(jobId)
|
|
||||||
}
|
|
||||||
return chunks
|
|
||||||
} catch (e) {
|
|
||||||
error.value = (e as Error).message || 'Failed to rechunk'
|
|
||||||
console.error('Failed to rechunk', e)
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
rechunking.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
pipelineOptions: PipelineOptions | null = null,
|
pipelineOptions: PipelineOptions | null = null,
|
||||||
|
|
@ -157,12 +137,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
currentPages,
|
currentPages,
|
||||||
currentChunks,
|
currentChunks,
|
||||||
running,
|
running,
|
||||||
rechunking,
|
|
||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
load,
|
load,
|
||||||
run,
|
run,
|
||||||
rechunk,
|
|
||||||
select,
|
select,
|
||||||
remove,
|
remove,
|
||||||
stopPolling,
|
stopPolling,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { rechunkAnalysis, createAnalysis } from '../analysis/api'
|
import { rechunkAnalysis } from './api'
|
||||||
|
|
||||||
vi.mock('../../shared/api/http', () => ({
|
vi.mock('../../shared/api/http', () => ({
|
||||||
apiFetch: vi.fn(),
|
apiFetch: vi.fn(),
|
||||||
|
|
@ -12,30 +12,6 @@ describe('chunking API', () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('createAnalysis sends chunkingOptions when provided', async () => {
|
|
||||||
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
|
|
||||||
apiFetch.mockResolvedValue(job)
|
|
||||||
|
|
||||||
const chunkingOpts = { chunker_type: 'hybrid' as const, max_tokens: 256 }
|
|
||||||
await createAnalysis('doc-1', null, chunkingOpts)
|
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ documentId: 'doc-1', chunkingOptions: chunkingOpts }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createAnalysis omits chunkingOptions when null', async () => {
|
|
||||||
apiFetch.mockResolvedValue({ id: '1' })
|
|
||||||
|
|
||||||
await createAnalysis('doc-1', null, null)
|
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ documentId: 'doc-1' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rechunkAnalysis sends POST to rechunk endpoint', async () => {
|
it('rechunkAnalysis sends POST to rechunk endpoint', async () => {
|
||||||
const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }]
|
const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }]
|
||||||
apiFetch.mockResolvedValue(chunks)
|
apiFetch.mockResolvedValue(chunks)
|
||||||
|
|
|
||||||
9
frontend/src/features/chunking/api.ts
Normal file
9
frontend/src/features/chunking/api.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Chunk, ChunkingOptions } from '../../shared/types'
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
|
||||||
|
export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||||
|
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ chunkingOptions }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { default as ChunkPanel } from './ui/ChunkPanel.vue'
|
export { default as ChunkPanel } from './ui/ChunkPanel.vue'
|
||||||
|
export { useChunkingStore } from './store'
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,30 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useAnalysisStore } from '../analysis/store'
|
import { useChunkingStore } from './store'
|
||||||
|
|
||||||
vi.mock('../analysis/api', () => ({
|
vi.mock('./api', () => ({
|
||||||
createAnalysis: vi.fn(),
|
|
||||||
fetchAnalyses: vi.fn().mockResolvedValue([]),
|
|
||||||
fetchAnalysis: vi.fn(),
|
|
||||||
deleteAnalysis: vi.fn(),
|
|
||||||
rechunkAnalysis: vi.fn(),
|
rechunkAnalysis: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import * as api from '../analysis/api'
|
import * as api from './api'
|
||||||
|
|
||||||
describe('analysis store — chunking', () => {
|
describe('useChunkingStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('currentChunks parses chunksJson from current analysis', () => {
|
it('starts with default state', () => {
|
||||||
const store = useAnalysisStore()
|
const store = useChunkingStore()
|
||||||
const chunks = [
|
expect(store.rechunking).toBe(false)
|
||||||
{
|
expect(store.error).toBeNull()
|
||||||
text: 'chunk1',
|
|
||||||
headings: ['H1'],
|
|
||||||
sourcePage: 1,
|
|
||||||
tokenCount: 10,
|
|
||||||
bboxes: [{ page: 1, bbox: [10, 20, 100, 80] }],
|
|
||||||
},
|
|
||||||
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20, bboxes: [] },
|
|
||||||
]
|
|
||||||
store.currentAnalysis = {
|
|
||||||
id: 'j1',
|
|
||||||
documentId: 'd1',
|
|
||||||
documentFilename: null,
|
|
||||||
status: 'COMPLETED',
|
|
||||||
contentMarkdown: null,
|
|
||||||
contentHtml: null,
|
|
||||||
pagesJson: null,
|
|
||||||
chunksJson: JSON.stringify(chunks),
|
|
||||||
hasDocumentJson: true,
|
|
||||||
errorMessage: null,
|
|
||||||
startedAt: null,
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
}
|
|
||||||
expect(store.currentChunks).toEqual(chunks)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('currentChunks returns empty array when no chunksJson', () => {
|
it('rechunk calls API and returns chunks', async () => {
|
||||||
const store = useAnalysisStore()
|
|
||||||
store.currentAnalysis = {
|
|
||||||
id: 'j1',
|
|
||||||
documentId: 'd1',
|
|
||||||
documentFilename: null,
|
|
||||||
status: 'COMPLETED',
|
|
||||||
contentMarkdown: null,
|
|
||||||
contentHtml: null,
|
|
||||||
pagesJson: null,
|
|
||||||
chunksJson: null,
|
|
||||||
hasDocumentJson: false,
|
|
||||||
errorMessage: null,
|
|
||||||
startedAt: null,
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
}
|
|
||||||
expect(store.currentChunks).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rechunk calls API and refreshes analysis', async () => {
|
|
||||||
const store = useAnalysisStore()
|
|
||||||
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }]
|
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }]
|
||||||
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
|
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
|
||||||
vi.mocked(api.fetchAnalysis).mockResolvedValue({
|
|
||||||
id: 'j1',
|
|
||||||
documentId: 'd1',
|
|
||||||
documentFilename: null,
|
|
||||||
status: 'COMPLETED',
|
|
||||||
contentMarkdown: null,
|
|
||||||
contentHtml: null,
|
|
||||||
pagesJson: null,
|
|
||||||
chunksJson: JSON.stringify(chunks),
|
|
||||||
hasDocumentJson: true,
|
|
||||||
errorMessage: null,
|
|
||||||
startedAt: null,
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
})
|
|
||||||
|
|
||||||
store.currentAnalysis = {
|
|
||||||
id: 'j1',
|
|
||||||
documentId: 'd1',
|
|
||||||
documentFilename: null,
|
|
||||||
status: 'COMPLETED',
|
|
||||||
contentMarkdown: null,
|
|
||||||
contentHtml: null,
|
|
||||||
pagesJson: null,
|
|
||||||
chunksJson: null,
|
|
||||||
hasDocumentJson: true,
|
|
||||||
errorMessage: null,
|
|
||||||
startedAt: null,
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const store = useChunkingStore()
|
||||||
const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 })
|
const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 })
|
||||||
|
|
||||||
expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', {
|
expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', {
|
||||||
|
|
@ -114,28 +35,31 @@ describe('analysis store — chunking', () => {
|
||||||
expect(store.rechunking).toBe(false)
|
expect(store.rechunking).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('run passes chunkingOptions to API', async () => {
|
it('rechunk sets rechunking during execution', async () => {
|
||||||
const store = useAnalysisStore()
|
let resolve: (v: any) => void
|
||||||
vi.mocked(api.createAnalysis).mockResolvedValue({
|
vi.mocked(api.rechunkAnalysis).mockImplementation(
|
||||||
id: 'j1',
|
() =>
|
||||||
documentId: 'd1',
|
new Promise((r) => {
|
||||||
documentFilename: null,
|
resolve = r
|
||||||
status: 'PENDING',
|
}),
|
||||||
contentMarkdown: null,
|
)
|
||||||
contentHtml: null,
|
|
||||||
pagesJson: null,
|
|
||||||
chunksJson: null,
|
|
||||||
hasDocumentJson: false,
|
|
||||||
errorMessage: null,
|
|
||||||
startedAt: null,
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.run('d1', null, { chunker_type: 'hierarchical' })
|
const store = useChunkingStore()
|
||||||
|
const promise = store.rechunk('j1', { chunker_type: 'hybrid' })
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, {
|
expect(store.rechunking).toBe(true)
|
||||||
chunker_type: 'hierarchical',
|
resolve!([])
|
||||||
})
|
await promise
|
||||||
|
expect(store.rechunking).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechunk handles errors', async () => {
|
||||||
|
vi.mocked(api.rechunkAnalysis).mockRejectedValue(new Error('fail'))
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useChunkingStore()
|
||||||
|
await expect(store.rechunk('j1', { chunker_type: 'hybrid' })).rejects.toThrow('fail')
|
||||||
|
expect(store.rechunking).toBe(false)
|
||||||
|
expect(store.error).toBe('fail')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
25
frontend/src/features/chunking/store.ts
Normal file
25
frontend/src/features/chunking/store.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Chunk, ChunkingOptions } from '../../shared/types'
|
||||||
|
import * as api from './api'
|
||||||
|
|
||||||
|
export const useChunkingStore = defineStore('chunking', () => {
|
||||||
|
const rechunking = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||||
|
rechunking.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
return await api.rechunkAnalysis(jobId, chunkingOptions)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Failed to rechunk'
|
||||||
|
console.error('Failed to rechunk', e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
rechunking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rechunking, error, rechunk }
|
||||||
|
})
|
||||||
|
|
@ -60,11 +60,11 @@
|
||||||
<button
|
<button
|
||||||
class="chunk-btn primary"
|
class="chunk-btn primary"
|
||||||
data-e2e="chunk-btn"
|
data-e2e="chunk-btn"
|
||||||
:disabled="!canRechunk || analysisStore.rechunking"
|
:disabled="!canRechunk || chunkingStore.rechunking"
|
||||||
@click="doRechunk"
|
@click="doRechunk"
|
||||||
>
|
>
|
||||||
<div v-if="analysisStore.rechunking" class="spinner-sm" />
|
<div v-if="chunkingStore.rechunking" class="spinner-sm" />
|
||||||
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
{{ chunkingStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Batch mode notice -->
|
<!-- Batch mode notice -->
|
||||||
|
|
@ -111,11 +111,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
|
<div class="chunk-empty" v-else-if="!chunkingStore.rechunking">
|
||||||
<p>
|
<p>
|
||||||
{{
|
{{ chunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks') }}
|
||||||
analysisStore.currentChunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks')
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -132,7 +130,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { useAnalysisStore } from '../../analysis/store'
|
import { useChunkingStore } from '../store'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
import { usePagination } from '../../../shared/composables/usePagination'
|
import { usePagination } from '../../../shared/composables/usePagination'
|
||||||
import { PaginationBar } from '../../../shared/ui'
|
import { PaginationBar } from '../../../shared/ui'
|
||||||
|
|
@ -140,13 +138,18 @@ import type { Chunk, ChunkBbox, ChunkingOptions } from '../../../shared/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentPage: number
|
currentPage: number
|
||||||
|
analysisId: string | null
|
||||||
|
analysisStatus: string | null
|
||||||
|
hasDocumentJson: boolean
|
||||||
|
chunks: Chunk[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlight-bboxes': [bboxes: ChunkBbox[]]
|
'highlight-bboxes': [bboxes: ChunkBbox[]]
|
||||||
|
rechunked: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const analysisStore = useAnalysisStore()
|
const chunkingStore = useChunkingStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const configOpen = ref(true)
|
const configOpen = ref(true)
|
||||||
|
|
@ -159,19 +162,15 @@ const options = reactive<Required<ChunkingOptions>>({
|
||||||
})
|
})
|
||||||
|
|
||||||
const canRechunk = computed(() => {
|
const canRechunk = computed(() => {
|
||||||
const analysis = analysisStore.currentAnalysis
|
return props.analysisStatus === 'COMPLETED' && props.hasDocumentJson
|
||||||
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** True when the analysis was batched (document_json unavailable). */
|
/** True when the analysis was batched (document_json unavailable). */
|
||||||
const isBatchedAnalysis = computed(() => {
|
const isBatchedAnalysis = computed(() => {
|
||||||
const analysis = analysisStore.currentAnalysis
|
return props.analysisStatus === 'COMPLETED' && !props.hasDocumentJson
|
||||||
return analysis?.status === 'COMPLETED' && !analysis.hasDocumentJson
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageChunks = computed(() =>
|
const pageChunks = computed(() => props.chunks.filter((c) => c.sourcePage === props.currentPage))
|
||||||
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
|
|
||||||
)
|
|
||||||
const pagination = usePagination(pageChunks, { pageSize: 20 })
|
const pagination = usePagination(pageChunks, { pageSize: 20 })
|
||||||
|
|
||||||
function globalIndex(localIdx: number): number {
|
function globalIndex(localIdx: number): number {
|
||||||
|
|
@ -192,8 +191,9 @@ function onChunkLeave() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRechunk() {
|
async function doRechunk() {
|
||||||
if (!analysisStore.currentAnalysis) return
|
if (!props.analysisId) return
|
||||||
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
|
await chunkingStore.rechunk(props.analysisId, { ...options })
|
||||||
|
emit('rechunked')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { Document } from '../../shared/types'
|
import type { Document } from '../../shared/types'
|
||||||
import { useFeatureFlagStore } from '../feature-flags/store'
|
import { appMaxFileSizeMb } from '../../shared/appConfig'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
|
|
||||||
export const useDocumentStore = defineStore('document', () => {
|
export const useDocumentStore = defineStore('document', () => {
|
||||||
const flags = useFeatureFlagStore()
|
|
||||||
const documents = ref<Document[]>([])
|
const documents = ref<Document[]>([])
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
|
@ -26,7 +25,7 @@ export const useDocumentStore = defineStore('document', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload(file: File): Promise<Document> {
|
async function upload(file: File): Promise<Document> {
|
||||||
const maxMb = flags.maxFileSizeMb
|
const maxMb = appMaxFileSizeMb.value
|
||||||
if (maxMb > 0 && file.size > maxMb * 1024 * 1024) {
|
if (maxMb > 0 && file.size > maxMb * 1024 * 1024) {
|
||||||
error.value = `File too large (max ${maxMb} MB)`
|
error.value = `File too large (max ${maxMb} MB)`
|
||||||
throw new Error(error.value)
|
throw new Error(error.value)
|
||||||
|
|
|
||||||
|
|
@ -33,24 +33,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useDocumentStore } from '../store'
|
import { useDocumentStore } from '../store'
|
||||||
import { useFeatureFlagStore } from '../../feature-flags/store'
|
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import { appMaxFileSizeMb, appMaxPageCount } from '../../../shared/appConfig'
|
||||||
|
|
||||||
const emit = defineEmits<{ uploaded: [docId: string] }>()
|
const emit = defineEmits<{ uploaded: [docId: string] }>()
|
||||||
|
|
||||||
const store = useDocumentStore()
|
const store = useDocumentStore()
|
||||||
const flags = useFeatureFlagStore()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
|
|
||||||
const uploadHint = computed(() => {
|
const uploadHint = computed(() => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (flags.maxFileSizeMb > 0) {
|
if (appMaxFileSizeMb.value > 0) {
|
||||||
parts.push(t('upload.maxSize').replace('{n}', String(flags.maxFileSizeMb)))
|
parts.push(t('upload.maxSize').replace('{n}', String(appMaxFileSizeMb.value)))
|
||||||
}
|
}
|
||||||
if (flags.maxPageCount > 0) {
|
if (appMaxPageCount.value > 0) {
|
||||||
parts.push(t('upload.maxPages').replace('{n}', String(flags.maxPageCount)))
|
parts.push(t('upload.maxPages').replace('{n}', String(appMaxPageCount.value)))
|
||||||
}
|
}
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiFetch } from '../../shared/api/http'
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
import { appMaxFileSizeMb, appMaxPageCount } from '../../shared/appConfig'
|
||||||
|
|
||||||
type ConversionEngine = 'local' | 'remote'
|
type ConversionEngine = 'local' | 'remote'
|
||||||
type DeploymentMode = 'self-hosted' | 'huggingface'
|
type DeploymentMode = 'self-hosted' | 'huggingface'
|
||||||
|
|
@ -64,6 +65,8 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
|
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
|
||||||
maxPageCount.value = data.maxPageCount ?? 0
|
maxPageCount.value = data.maxPageCount ?? 0
|
||||||
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
|
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
|
||||||
|
appMaxFileSizeMb.value = maxFileSizeMb.value
|
||||||
|
appMaxPageCount.value = maxPageCount.value
|
||||||
if (data.version) appVersion.value = data.version
|
if (data.version) appVersion.value = data.version
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
|
||||||
10
frontend/src/features/history/api.ts
Normal file
10
frontend/src/features/history/api.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Analysis } from '../../shared/types'
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
|
||||||
|
export function fetchHistory(): Promise<Analysis[]> {
|
||||||
|
return apiFetch<Analysis[]>('/api/analyses')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteHistoryEntry(id: string): Promise<unknown> {
|
||||||
|
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,11 @@ vi.mock('../analysis/api', () => ({
|
||||||
deleteAnalysis: vi.fn(),
|
deleteAnalysis: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
fetchHistory: vi.fn(),
|
||||||
|
deleteHistoryEntry: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../document/api', () => ({
|
vi.mock('../document/api', () => ({
|
||||||
fetchDocuments: vi.fn(),
|
fetchDocuments: vi.fn(),
|
||||||
uploadDocument: vi.fn(),
|
uploadDocument: vi.fn(),
|
||||||
|
|
@ -33,8 +38,8 @@ describe('History → Studio navigation', () => {
|
||||||
|
|
||||||
describe('History store provides data for navigation', () => {
|
describe('History store provides data for navigation', () => {
|
||||||
it('analyses contain documentId for document selection', async () => {
|
it('analyses contain documentId for document selection', async () => {
|
||||||
const { fetchAnalyses } = await import('../analysis/api')
|
const { fetchHistory } = await import('./api')
|
||||||
fetchAnalyses.mockResolvedValue([
|
fetchHistory.mockResolvedValue([
|
||||||
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
|
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
|
||||||
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
|
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,70 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useHistoryStore } from './store'
|
import { useHistoryStore } from './store'
|
||||||
import { useAnalysisStore } from '../analysis/store'
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
fetchHistory: vi.fn(),
|
||||||
|
deleteHistoryEntry: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as api from './api'
|
||||||
|
|
||||||
describe('useHistoryStore', () => {
|
describe('useHistoryStore', () => {
|
||||||
it('is a re-export of useAnalysisStore', () => {
|
beforeEach(() => {
|
||||||
expect(useHistoryStore).toBe(useAnalysisStore)
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with empty state', () => {
|
||||||
|
const store = useHistoryStore()
|
||||||
|
expect(store.analyses).toEqual([])
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() fetches analyses', async () => {
|
||||||
|
const analyses = [
|
||||||
|
{ id: 'a1', status: 'COMPLETED' },
|
||||||
|
{ id: 'a2', status: 'FAILED' },
|
||||||
|
]
|
||||||
|
vi.mocked(api.fetchHistory).mockResolvedValue(analyses)
|
||||||
|
|
||||||
|
const store = useHistoryStore()
|
||||||
|
await store.load()
|
||||||
|
|
||||||
|
expect(store.analyses).toEqual(analyses)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() handles errors gracefully', async () => {
|
||||||
|
vi.mocked(api.fetchHistory).mockRejectedValue(new Error('network'))
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useHistoryStore()
|
||||||
|
await store.load()
|
||||||
|
|
||||||
|
expect(store.error).toBe('network')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remove() deletes and filters from list', async () => {
|
||||||
|
vi.mocked(api.deleteHistoryEntry).mockResolvedValue(null)
|
||||||
|
|
||||||
|
const store = useHistoryStore()
|
||||||
|
store.analyses = [{ id: 'a1' }, { id: 'a2' }] as any[]
|
||||||
|
await store.remove('a1')
|
||||||
|
|
||||||
|
expect(store.analyses).toEqual([{ id: 'a2' }])
|
||||||
|
expect(api.deleteHistoryEntry).toHaveBeenCalledWith('a1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remove() handles errors gracefully', async () => {
|
||||||
|
vi.mocked(api.deleteHistoryEntry).mockRejectedValue(new Error('fail'))
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useHistoryStore()
|
||||||
|
store.analyses = [{ id: 'a1' }] as any[]
|
||||||
|
await store.remove('a1')
|
||||||
|
|
||||||
|
expect(store.error).toBe('fail')
|
||||||
|
expect(store.analyses).toEqual([{ id: 'a1' }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1 +1,31 @@
|
||||||
export { useAnalysisStore as useHistoryStore } from '../analysis/store'
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Analysis } from '../../shared/types'
|
||||||
|
import * as api from './api'
|
||||||
|
|
||||||
|
export const useHistoryStore = defineStore('history', () => {
|
||||||
|
const analyses = ref<Analysis[]>([])
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
error.value = null
|
||||||
|
analyses.value = await api.fetchHistory()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Failed to load history'
|
||||||
|
console.error('Failed to load history', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.deleteHistoryEntry(id)
|
||||||
|
analyses.value = analyses.value.filter((a) => a.id !== id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Failed to delete analysis'
|
||||||
|
console.error('Failed to delete analysis', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { analyses, error, load, remove }
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,11 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAnalysisStore } from '../../analysis/store'
|
import { useHistoryStore } from '../store'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
import type { Analysis } from '../../../shared/types'
|
import type { Analysis } from '../../../shared/types'
|
||||||
|
|
||||||
const store = useAnalysisStore()
|
const store = useHistoryStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch, watchEffect } from 'vue'
|
import { ref, watch, watchEffect } from 'vue'
|
||||||
import type { Locale, Theme } from '../../shared/types'
|
import type { Locale, Theme } from '../../shared/types'
|
||||||
|
import { appLocale } from '../../shared/appConfig'
|
||||||
|
|
||||||
function safeGetItem(key: string): string | null {
|
function safeGetItem(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -24,7 +25,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
||||||
|
|
||||||
watch(theme, (v) => safeSetItem('docling-theme', v))
|
watch(theme, (v) => safeSetItem('docling-theme', v))
|
||||||
watch(locale, (v) => safeSetItem('docling-locale', v))
|
watch(
|
||||||
|
locale,
|
||||||
|
(v) => {
|
||||||
|
safeSetItem('docling-locale', v)
|
||||||
|
appLocale.value = v
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,12 @@
|
||||||
<div v-if="mode === 'prepare' && chunkingEnabled" class="prepare-panel">
|
<div v-if="mode === 'prepare' && chunkingEnabled" class="prepare-panel">
|
||||||
<ChunkPanel
|
<ChunkPanel
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
|
:analysis-id="analysisStore.currentAnalysis?.id ?? null"
|
||||||
|
:analysis-status="analysisStore.currentAnalysis?.status ?? null"
|
||||||
|
:has-document-json="analysisStore.currentAnalysis?.hasDocumentJson ?? false"
|
||||||
|
:chunks="analysisStore.currentChunks"
|
||||||
@highlight-bboxes="highlightedChunkBboxes = $event"
|
@highlight-bboxes="highlightedChunkBboxes = $event"
|
||||||
|
@rechunked="onRechunked"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -563,6 +568,12 @@ function addMore() {
|
||||||
documentStore.selectedId = null
|
documentStore.selectedId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onRechunked() {
|
||||||
|
if (analysisStore.currentAnalysis?.id) {
|
||||||
|
await analysisStore.select(analysisStore.currentAnalysis.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear highlights when switching modes or pages
|
// Clear highlights when switching modes or pages
|
||||||
watch(mode, () => {
|
watch(mode, () => {
|
||||||
highlightedElementIndex.value = -1
|
highlightedElementIndex.value = -1
|
||||||
|
|
|
||||||
11
frontend/src/shared/appConfig.ts
Normal file
11
frontend/src/shared/appConfig.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Locale } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide reactive configuration — populated by feature stores,
|
||||||
|
* consumed by shared utilities and other features.
|
||||||
|
* Breaks the dependency cycle between features and shared.
|
||||||
|
*/
|
||||||
|
export const appLocale = ref<Locale>('fr')
|
||||||
|
export const appMaxFileSizeMb = ref<number>(0)
|
||||||
|
export const appMaxPageCount = ref<number>(0)
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { appLocale } from './appConfig'
|
||||||
|
|
||||||
// Mock settings store before importing i18n
|
|
||||||
vi.mock('../features/settings/store', () => ({
|
|
||||||
useSettingsStore: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { useSettingsStore } from '../features/settings/store'
|
|
||||||
import { useI18n } from './i18n'
|
import { useI18n } from './i18n'
|
||||||
|
|
||||||
describe('useI18n', () => {
|
describe('useI18n', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
appLocale.value = 'fr'
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns French translation by default', () => {
|
it('returns French translation by default', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('nav.studio')).toBe('Studio')
|
expect(t('nav.studio')).toBe('Studio')
|
||||||
expect(t('nav.history')).toBe('Historique')
|
expect(t('nav.history')).toBe('Historique')
|
||||||
|
|
@ -25,7 +15,7 @@ describe('useI18n', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns English translation when locale is en', () => {
|
it('returns English translation when locale is en', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
appLocale.value = 'en'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('nav.history')).toBe('History')
|
expect(t('nav.history')).toBe('History')
|
||||||
|
|
@ -33,36 +23,30 @@ describe('useI18n', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to French when key missing in current locale', () => {
|
it('falls back to French when key missing in current locale', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'de' })
|
appLocale.value = 'de' as any
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('nav.studio')).toBe('Studio')
|
expect(t('nav.studio')).toBe('Studio')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns key when not found in any locale', () => {
|
it('returns key when not found in any locale', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('unknown.key')).toBe('unknown.key')
|
expect(t('unknown.key')).toBe('unknown.key')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('interpolates parameters', () => {
|
it('interpolates parameters', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
appLocale.value = 'en'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('results.pageOf', { current: 3, total: 10 })).toBe('Page 3 of 10')
|
expect(t('results.pageOf', { current: 3, total: 10 })).toBe('Page 3 of 10')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('interpolates parameters in French', () => {
|
it('interpolates parameters in French', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('results.pageOf', { current: 1, total: 5 })).toBe('Page 1 sur 5')
|
expect(t('results.pageOf', { current: 1, total: 5 })).toBe('Page 1 sur 5')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has history tab keys in French', () => {
|
it('has history tab keys in French', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('history.tabAnalyses')).toBe('Analyses')
|
expect(t('history.tabAnalyses')).toBe('Analyses')
|
||||||
expect(t('history.tabDocuments')).toBe('Documents')
|
expect(t('history.tabDocuments')).toBe('Documents')
|
||||||
|
|
@ -70,7 +54,7 @@ describe('useI18n', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has history tab keys in English', () => {
|
it('has history tab keys in English', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
appLocale.value = 'en'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('history.tabAnalyses')).toBe('Analyses')
|
expect(t('history.tabAnalyses')).toBe('Analyses')
|
||||||
|
|
@ -79,10 +63,7 @@ describe('useI18n', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has detailed pipeline option hints in French', () => {
|
it('has detailed pipeline option hints in French', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
// Hints should be longer than simple labels
|
|
||||||
expect(t('config.ocrHint').length).toBeGreaterThan(40)
|
expect(t('config.ocrHint').length).toBeGreaterThan(40)
|
||||||
expect(t('config.tableStructureHint').length).toBeGreaterThan(40)
|
expect(t('config.tableStructureHint').length).toBeGreaterThan(40)
|
||||||
expect(t('config.codeEnrichmentHint').length).toBeGreaterThan(40)
|
expect(t('config.codeEnrichmentHint').length).toBeGreaterThan(40)
|
||||||
|
|
@ -94,7 +75,7 @@ describe('useI18n', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has detailed pipeline option hints in English', () => {
|
it('has detailed pipeline option hints in English', () => {
|
||||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
appLocale.value = 'en'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
expect(t('config.ocrHint').length).toBeGreaterThan(40)
|
expect(t('config.ocrHint').length).toBeGreaterThan(40)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Locale } from './types'
|
import type { Locale } from './types'
|
||||||
import { useSettingsStore } from '../features/settings/store'
|
import { appLocale } from './appConfig'
|
||||||
|
|
||||||
type MessageMap = Record<string, string>
|
type MessageMap = Record<string, string>
|
||||||
type Messages = Record<Locale, MessageMap>
|
type Messages = Record<Locale, MessageMap>
|
||||||
|
|
@ -268,10 +268,8 @@ const messages: Messages = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useI18n() {
|
export function useI18n() {
|
||||||
const settings = useSettingsStore()
|
|
||||||
|
|
||||||
function t(key: string, params: Record<string, string | number> = {}): string {
|
function t(key: string, params: Record<string, string | number> = {}): string {
|
||||||
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
|
let str = messages[appLocale.value]?.[key] || messages['fr'][key] || key
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
str = str.replaceAll(`{${k}}`, String(v))
|
str = str.replaceAll(`{${k}}`, String(v))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue