Migrate frontend in typescript

This commit is contained in:
pjmalandrino 2026-03-21 15:58:43 +01:00
parent 81076dd308
commit 8871c9162c
60 changed files with 1021 additions and 389 deletions

View file

@ -35,7 +35,10 @@ jobs:
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-asyncio httpx
pip install pytest pytest-asyncio httpx ruff
- name: Lint
run: ruff check .
- name: Run tests
run: pytest tests/ -v
@ -59,6 +62,12 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Lint
run: npx eslint src/
- name: Type check
run: npm run type-check
- name: Run tests
run: npm run test:run

View file

@ -48,13 +48,14 @@ ruff check . --fix # lint with auto-fix
ruff format . # format
```
### Frontend — ESLint + Prettier
### Frontend — TypeScript + ESLint + Prettier
```bash
cd frontend
npx eslint src/ # lint
npx prettier --check src/ # check formatting
npx prettier --write src/ # auto-format
npm run type-check # type check (vue-tsc)
npx eslint src/ # lint
npx prettier --check src/ # check formatting
npx prettier --write src/ # auto-format
```
## Running Tests
@ -64,7 +65,7 @@ npx prettier --write src/ # auto-format
cd document-parser
pytest tests/ -v
# Frontend (87 tests)
# Frontend (81 tests)
cd frontend
npm run test:run
```

View file

@ -38,7 +38,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
| Service | Stack | Role |
|---------|-------|------|
| **frontend** | Vue 3, Vite, Pinia | UI, PDF viewer, results display |
| **frontend** | Vue 3, TypeScript, Vite, Pinia | UI, PDF viewer, results display |
| **document-parser** | FastAPI, Docling, SQLite, pdf2image | REST API, document parsing, storage |
### Backend structure (clean architecture)
@ -80,7 +80,7 @@ frontend/src/
│ ├── document/ # Document store, API, upload, list
│ ├── history/ # History store, API, navigation
│ └── settings/ # Settings store
└── shared/ # Shared utilities (i18n, http, format)
└── shared/ # Shared utilities (types, i18n, http, format)
```
## Quick Start
@ -128,7 +128,7 @@ cd document-parser
pip install pytest pytest-asyncio httpx
pytest tests/ -v
# Frontend (87 tests)
# Frontend (81 tests)
cd frontend
npm run test:run
```
@ -167,7 +167,7 @@ GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
| Workflow | Trigger | What it does |
|----------|---------|--------------|
| **CI** | push to `main`, pull requests | Backend tests (99) + Frontend tests (87) + build |
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests (99) + Frontend tests (81) + build |
| **Release** | push tag `v*` | Build & push multi-arch Docker image to `ghcr.io` |
To publish a new version:
@ -199,7 +199,7 @@ All Docker images are multi-arch (linux/amd64 + linux/arm64). No GPU required.
## Tech Stack
- **Frontend**: Vue 3, Vite, Pinia, DOMPurify
- **Frontend**: Vue 3, TypeScript, Vite, Pinia, DOMPurify
- **Backend**: FastAPI, Docling 2.x, SQLite (aiosqlite), pdf2image
- **CI**: GitHub Actions
- **Infra**: Docker Compose + Nginx

7
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}

View file

@ -1,15 +1,24 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue'
export default [
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.{js,vue}'],
files: ['src/**/*.{ts,js,vue}'],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
},
},
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
// Formatting handled by Prettier

View file

@ -8,6 +8,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/app/main.js"></script>
<script type="module" src="/src/app/main.ts"></script>
</body>
</html>

View file

@ -16,12 +16,16 @@
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^5.2.0",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^5.4.0",
"vitest": "^2.1.0"
"vitest": "^2.1.0",
"vue-tsc": "^2.2.12"
}
},
"node_modules/@babel/helper-string-parser": {
@ -946,6 +950,16 @@
"win32"
]
},
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -964,6 +978,273 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
"integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/type-utils": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.57.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
"dev": true,
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.1",
"@typescript-eslint/types": "^8.57.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
"dev": true,
"dependencies": {
"@typescript-eslint/project-service": "8.57.1",
"@typescript-eslint/tsconfig-utils": "8.57.1",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.57.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1083,6 +1364,32 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
"integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
"dev": true,
"dependencies": {
"@volar/source-map": "2.4.15"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
"integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
"dev": true
},
"node_modules/@volar/typescript": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
"integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
"dev": true,
"dependencies": {
"@volar/language-core": "2.4.15",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
@ -1139,11 +1446,69 @@
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/compiler-vue2": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
"dev": true,
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/language-core": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
"integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
"dev": true,
"dependencies": {
"@volar/language-core": "2.4.15",
"@vue/compiler-dom": "^3.5.0",
"@vue/compiler-vue2": "^2.7.16",
"@vue/shared": "^3.5.0",
"alien-signals": "^1.0.3",
"minimatch": "^9.0.3",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@vue/language-core/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
@ -1226,6 +1591,12 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
"integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
"dev": true
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -1392,6 +1763,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1718,6 +2095,23 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -1812,6 +2206,15 @@
"node": ">=8"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"bin": {
"he": "bin/he"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -1995,6 +2398,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -2089,6 +2498,12 @@
"node": ">=6"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -2127,6 +2542,18 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
@ -2369,6 +2796,22 @@
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
@ -2396,6 +2839,18 @@
"node": ">=14.0.0"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -2420,6 +2875,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
"dev": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/utils": "8.57.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -2581,6 +3072,12 @@
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true
},
"node_modules/vue": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
@ -2709,6 +3206,22 @@
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
"dev": true,
"dependencies": {
"@volar/typescript": "2.4.15",
"@vue/language-core": "2.2.12"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vue-tsc --noEmit && vite build",
"type-check": "vue-tsc --noEmit",
"preview": "vite preview",
"test": "vitest --watch",
"test:run": "vitest run",
@ -23,11 +24,15 @@
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^5.2.0",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^5.4.0",
"vitest": "^2.1.0"
"vitest": "^2.1.0",
"vue-tsc": "^2.2.12"
}
}

View file

@ -26,13 +26,13 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { AppSidebar } from '../shared/ui/index.js'
import { useSettingsStore } from '../features/settings/store.js'
import { useDocumentStore } from '../features/document/store.js'
import { useI18n } from '../shared/i18n.js'
import { AppSidebar } from '../shared/ui/index'
import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store'
import { useI18n } from '../shared/i18n'
useSettingsStore()
const { t } = useI18n()

View file

@ -1,6 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router/index.js'
import { router } from './router'
import App from './App.vue'
const app = createApp(App)

View file

@ -1,39 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes = [
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('../../pages/HomePage.vue')
component: () => import('../../pages/HomePage.vue'),
},
{
path: '/studio',
name: 'studio',
component: () => import('../../pages/StudioPage.vue')
component: () => import('../../pages/StudioPage.vue'),
},
{
path: '/history',
name: 'history',
component: () => import('../../pages/HistoryPage.vue')
component: () => import('../../pages/HistoryPage.vue'),
},
{
path: '/documents',
name: 'documents',
component: () => import('../../pages/DocumentsPage.vue')
component: () => import('../../pages/DocumentsPage.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('../../pages/SettingsPage.vue')
component: () => import('../../pages/SettingsPage.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
redirect: '/'
}
redirect: '/',
},
]
export const router = createRouter({
history: createWebHistory(),
routes
routes,
})

View file

@ -1,24 +0,0 @@
import { apiFetch } from '../../shared/api/http.js'
export function createAnalysis(documentId, pipelineOptions = null) {
const body = { documentId }
if (pipelineOptions) {
body.pipelineOptions = pipelineOptions
}
return apiFetch('/api/analyses', {
method: 'POST',
body: JSON.stringify(body)
})
}
export function fetchAnalyses() {
return apiFetch('/api/analyses')
}
export function fetchAnalysis(id) {
return apiFetch(`/api/analyses/${id}`)
}
export function deleteAnalysis(id) {
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
}

View file

@ -1,11 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api.js'
import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api'
vi.mock('../../shared/api/http.js', () => ({
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http.js'
import { apiFetch } from '../../shared/api/http'
describe('analysis API', () => {
beforeEach(() => {

View file

@ -0,0 +1,28 @@
import type { Analysis, PipelineOptions } from '../../shared/types'
import { apiFetch } from '../../shared/api/http'
export function createAnalysis(
documentId: string,
pipelineOptions: PipelineOptions | null = null,
): Promise<Analysis> {
const body: Record<string, unknown> = { documentId }
if (pipelineOptions) {
body.pipelineOptions = pipelineOptions
}
return apiFetch<Analysis>('/api/analyses', {
method: 'POST',
body: JSON.stringify(body),
})
}
export function fetchAnalyses(): Promise<Analysis[]> {
return apiFetch<Analysis[]>('/api/analyses')
}
export function fetchAnalysis(id: string): Promise<Analysis> {
return apiFetch<Analysis>(`/api/analyses/${id}`)
}
export function deleteAnalysis(id: string): Promise<unknown> {
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
}

View file

@ -1,58 +0,0 @@
/**
* Bbox scaling utilities for mapping Docling TOPLEFT coordinates
* to canvas pixel coordinates.
*
* Docling bbox format (after backend normalization): [l, t, r, b]
* All values are in page coordinate space (points, 1pt = 1/72 inch).
* Origin: top-left (y=0 at top of page).
*
* The preview image is a faithful rasterization of the PDF page.
* CSS `max-width: 100%; height: auto` preserves the aspect ratio,
* so sx and sy are always equal. We compute both for completeness.
*/
/**
* Compute scale factors from page coordinates to displayed pixels.
*
* @param {number} displayWidth - img.clientWidth (CSS pixels)
* @param {number} displayHeight - img.clientHeight (CSS pixels)
* @param {number} pageWidth - Page width in Docling points
* @param {number} pageHeight - Page height in Docling points
* @returns {{ sx: number, sy: number }}
*/
export function computeScale(displayWidth, displayHeight, pageWidth, pageHeight) {
return {
sx: displayWidth / pageWidth,
sy: displayHeight / pageHeight,
}
}
/**
* Convert a Docling bbox [l, t, r, b] to a canvas rect { x, y, w, h }.
*
* @param {number[]} bbox - [left, top, right, bottom] in page points
* @param {{ sx: number, sy: number }} scale
* @returns {{ x: number, y: number, w: number, h: number }}
*/
export function bboxToRect(bbox, scale) {
const [l, t, r, b] = bbox
return {
x: l * scale.sx,
y: t * scale.sy,
w: (r - l) * scale.sx,
h: (b - t) * scale.sy,
}
}
/**
* Test if a point (px, py) falls inside a rect.
*
* @param {number} px
* @param {number} py
* @param {{ x: number, y: number, w: number, h: number }} rect
* @returns {boolean}
*/
export function pointInRect(px, py, rect) {
return px >= rect.x && px <= rect.x + rect.w &&
py >= rect.y && py <= rect.y + rect.h
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { computeScale, bboxToRect, pointInRect } from './bboxScaling.js'
import { computeScale, bboxToRect, pointInRect } from './bboxScaling'
describe('computeScale', () => {
it('returns 1:1 when display matches page', () => {

View file

@ -0,0 +1,27 @@
import type { Scale, Rect } from '../../shared/types'
export function computeScale(
displayWidth: number,
displayHeight: number,
pageWidth: number,
pageHeight: number,
): Scale {
return {
sx: displayWidth / pageWidth,
sy: displayHeight / pageHeight,
}
}
export function bboxToRect(bbox: [number, number, number, number], scale: Scale): Rect {
const [l, t, r, b] = bbox
return {
x: l * scale.sx,
y: t * scale.sy,
w: (r - l) * scale.sx,
h: (b - t) * scale.sy,
}
}
export function pointInRect(px: number, py: number, rect: Rect): boolean {
return px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h
}

View file

@ -1,4 +1,4 @@
export { useAnalysisStore } from './store.js'
export { useAnalysisStore } from './store'
export { default as AnalysisPanel } from './ui/AnalysisPanel.vue'
export { default as ResultTabs } from './ui/ResultTabs.vue'
export { default as MarkdownViewer } from './ui/MarkdownViewer.vue'

View file

@ -1,19 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAnalysisStore } from './store.js'
import { useAnalysisStore } from './store'
vi.mock('../../shared/api/http.js', () => ({
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
}))
vi.mock('./api.js', () => ({
vi.mock('./api', () => ({
fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(),
createAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
}))
import * as api from './api.js'
import * as api from './api'
// ---------------------------------------------------------------------------
// API layer — body construction with pipeline options
@ -24,17 +24,17 @@ describe('createAnalysis — pipeline options body construction', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset the real module to get the unmocked version
vi.doUnmock('./api.js')
vi.doUnmock('./api')
})
// We test the mock integration (store → api) separately below.
// Here we verify the raw apiFetch call shape.
it('sends body without pipelineOptions when null', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const { createAnalysis: real } = await import('./api.js')
const { createAnalysis: real } = await import('./api')
await real('doc-1', null)
expect(realApiFetch).toHaveBeenCalledWith('/api/analyses', {
@ -44,10 +44,10 @@ describe('createAnalysis — pipeline options body construction', () => {
})
it('sends body without pipelineOptions when undefined', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const { createAnalysis: real } = await import('./api.js')
const { createAnalysis: real } = await import('./api')
await real('doc-1')
expect(realApiFetch).toHaveBeenCalledWith('/api/analyses', {
@ -57,7 +57,7 @@ describe('createAnalysis — pipeline options body construction', () => {
})
it('includes pipelineOptions in body when provided', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const opts = {
@ -73,7 +73,7 @@ describe('createAnalysis — pipeline options body construction', () => {
images_scale: 1.0,
}
const { createAnalysis: real } = await import('./api.js')
const { createAnalysis: real } = await import('./api')
await real('doc-1', opts)
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
@ -82,12 +82,12 @@ describe('createAnalysis — pipeline options body construction', () => {
})
it('includes only specified fields in partial options', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const opts = { do_ocr: false }
const { createAnalysis: real } = await import('./api.js')
const { createAnalysis: real } = await import('./api')
await real('doc-1', opts)
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
@ -95,11 +95,11 @@ describe('createAnalysis — pipeline options body construction', () => {
})
it('does not include pipelineOptions key when options is empty object treated as falsy', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
// Empty object is truthy in JS, so it SHOULD be included
const { createAnalysis: real } = await import('./api.js')
const { createAnalysis: real } = await import('./api')
await real('doc-1', {})
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)

View file

@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAnalysisStore } from './store.js'
import { useAnalysisStore } from './store'
vi.mock('./api.js', () => ({
vi.mock('./api', () => ({
fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(),
createAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
}))
import * as api from './api.js'
import * as api from './api'
describe('useAnalysisStore', () => {
beforeEach(() => {

View file

@ -1,36 +1,42 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as api from './api.js'
import type { Analysis, Page, PipelineOptions } from '../../shared/types'
import * as api from './api'
export const useAnalysisStore = defineStore('analysis', () => {
const analyses = ref([])
const currentAnalysis = ref(null)
const analyses = ref<Analysis[]>([])
const currentAnalysis = ref<Analysis | null>(null)
const running = ref(false)
const error = ref(null)
const pollingInterval = ref(null)
const error = ref<string | null>(null)
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
const currentPages = computed(() => {
const currentPages = computed<Page[]>(() => {
if (!currentAnalysis.value?.pagesJson) return []
try {
return JSON.parse(currentAnalysis.value.pagesJson)
return JSON.parse(currentAnalysis.value.pagesJson) as Page[]
} catch {
return []
}
})
function clearError() { error.value = null }
function clearError(): void {
error.value = null
}
async function load() {
async function load(): Promise<void> {
try {
error.value = null
analyses.value = await api.fetchAnalyses()
} catch (e) {
error.value = e.message || 'Failed to load analyses'
error.value = (e as Error).message || 'Failed to load analyses'
console.error('Failed to load analyses', e)
}
}
async function run(documentId, pipelineOptions = null) {
async function run(
documentId: string,
pipelineOptions: PipelineOptions | null = null,
): Promise<Analysis> {
running.value = true
error.value = null
try {
@ -41,26 +47,26 @@ export const useAnalysisStore = defineStore('analysis', () => {
return analysis
} catch (e) {
running.value = false
error.value = e.message || 'Failed to start analysis'
error.value = (e as Error).message || 'Failed to start analysis'
console.error('Failed to start analysis', e)
throw e
}
}
function startPolling(id) {
function startPolling(id: string): void {
stopPolling()
pollingInterval.value = setInterval(async () => {
try {
const updated = await api.fetchAnalysis(id)
currentAnalysis.value = updated
const idx = analyses.value.findIndex(a => a.id === id)
const idx = analyses.value.findIndex((a) => a.id === id)
if (idx !== -1) analyses.value[idx] = updated
if (updated.status === 'COMPLETED' || updated.status === 'FAILED') {
stopPolling()
running.value = false
}
} catch (e) {
error.value = e.message || 'Polling error'
error.value = (e as Error).message || 'Polling error'
console.error('Polling error', e)
stopPolling()
running.value = false
@ -68,32 +74,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
}, 2000)
}
function stopPolling() {
function stopPolling(): void {
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
}
async function select(id) {
async function select(id: string): Promise<void> {
try {
currentAnalysis.value = await api.fetchAnalysis(id)
} catch (e) {
error.value = e.message || 'Failed to load analysis'
error.value = (e as Error).message || 'Failed to load analysis'
console.error('Failed to load analysis', e)
}
}
async function remove(id) {
async function remove(id: string): Promise<void> {
try {
await api.deleteAnalysis(id)
analyses.value = analyses.value.filter(a => a.id !== id)
analyses.value = analyses.value.filter((a) => a.id !== id)
if (currentAnalysis.value?.id === id) currentAnalysis.value = null
} catch (e) {
error.value = e.message || 'Failed to delete analysis'
error.value = (e as Error).message || 'Failed to delete analysis'
console.error('Failed to delete analysis', e)
}
}
return { analyses, currentAnalysis, currentPages, running, error, clearError, load, run, select, remove, stopPolling }
return {
analyses,
currentAnalysis,
currentPages,
running,
error,
clearError,
load,
run,
select,
remove,
stopPolling,
}
})

View file

@ -14,7 +14,7 @@
<PagePreview
:document-id="selectedDoc.id"
:page="currentPage"
:page-count="selectedDoc.pageCount"
:page-count="selectedDoc.pageCount ?? undefined"
@update:page="currentPage = $event"
/>
</div>
@ -44,11 +44,11 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useDocumentStore } from '../../document/store.js'
import { useAnalysisStore } from '../store.js'
import { DocumentUpload, DocumentList, PagePreview } from '../../document/index.js'
import { useDocumentStore } from '../../document/store'
import { useAnalysisStore } from '../store'
import { DocumentUpload, DocumentList, PagePreview } from '../../document/index'
const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore()

View file

@ -35,11 +35,12 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
import type { Page, PageElement } from '../../../shared/types'
const ELEMENT_COLORS = {
const ELEMENT_COLORS: Record<string, string> = {
title: '#EF4444',
section_header: '#F97316',
text: '#3B82F6',
@ -51,65 +52,62 @@ const ELEMENT_COLORS = {
caption: '#EAB308'
}
const props = defineProps({
/** The <img> element to overlay onto */
imageEl: { type: Object, default: null },
/** Page data { page_number, width, height, elements[] } for current page */
pageData: { type: Object, default: null },
/** Index of the element to highlight (from ResultTabs hover) */
highlightedIndex: { type: Number, default: -1 }
})
const props = defineProps<{
imageEl: HTMLImageElement | null
pageData: Page | null
highlightedIndex: number
}>()
const emit = defineEmits(['highlight-element'])
const emit = defineEmits<{
'highlight-element': [index: number]
}>()
const hiddenTypes = reactive(new Set())
const canvasRef = ref(null)
const hoveredElement = ref(null)
const tooltipStyle = ref({})
const hiddenTypes = reactive(new Set<string>())
const canvasRef = ref<HTMLCanvasElement | null>(null)
const hoveredElement = ref<PageElement | null>(null)
const tooltipStyle = ref<Record<string, string>>({})
const visibleElements = computed(() => {
if (!props.pageData) return []
return props.pageData.elements.filter(e => !hiddenTypes.has(e.type))
return props.pageData.elements.filter((e: PageElement) => !hiddenTypes.has(e.type))
})
function toggleType(type) {
function toggleType(type: string): void {
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
else hiddenTypes.add(type)
draw()
}
function countElements(type) {
function countElements(type: string): number {
if (!props.pageData) return 0
return props.pageData.elements.filter(e => e.type === type).length
return props.pageData.elements.filter((e: PageElement) => e.type === type).length
}
function draw() {
function draw(): void {
const canvas = canvasRef.value
const img = props.imageEl
if (!canvas || !img) return
// Wait until image is loaded (clientWidth > 0)
if (!img.clientWidth || !img.clientHeight) return
canvas.width = img.clientWidth
canvas.height = img.clientHeight
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (!props.pageData) return
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
// Build a map of content-filtered indices to match ResultTabs ordering
const allElements = (props.pageData.elements || [])
const contentElements = allElements.filter(e => e.content)
const contentElements = allElements.filter((e: PageElement) => e.content)
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
// Check if this element is the highlighted one
const elContentIdx = contentElements.indexOf(el)
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
@ -122,7 +120,7 @@ function draw() {
}
}
function onMouseMove(e) {
function onMouseMove(e: MouseEvent): void {
const canvas = canvasRef.value
const img = props.imageEl
if (!canvas || !img || !props.pageData) return
@ -133,9 +131,9 @@ function onMouseMove(e) {
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const contentElements = (props.pageData.elements || []).filter(e => e.content)
const contentElements = (props.pageData.elements || []).filter((el: PageElement) => el.content)
let found = null
let found: PageElement | null = null
let foundIdx = -1
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
@ -155,8 +153,7 @@ function onMouseMove(e) {
}
}
// Redraw on resize
let resizeObserver = null
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (props.imageEl) {
resizeObserver = new ResizeObserver(() => draw())

View file

@ -25,18 +25,19 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n.js'
import { useI18n } from '../../../shared/i18n'
import type { Page, PageElement } from '../../../shared/types'
const { t } = useI18n()
const props = defineProps({
pages: { type: Array, default: () => [] }
pages: { type: Array as () => Page[], default: () => [] }
})
const images = computed(() => {
const result = []
const result: (PageElement & { page: number })[] = []
for (const page of props.pages) {
for (const el of (page.elements || [])) {
if (el.type === 'picture') {

View file

@ -2,18 +2,18 @@
<div class="markdown-viewer" v-html="rendered" />
</template>
<script setup>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useI18n } from '../../../shared/i18n.js'
import { useI18n } from '../../../shared/i18n'
const props = defineProps({ content: String })
const { t } = useI18n()
const rendered = computed(() => {
if (!props.content) return `<p class="empty">${t('results.noMarkdown')}</p>`
return DOMPurify.sanitize(marked.parse(props.content))
return DOMPurify.sanitize(marked.parse(props.content) as string)
})
</script>

View file

@ -96,14 +96,15 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useAnalysisStore } from '../store.js'
import { useAnalysisStore } from '../store'
import MarkdownViewer from './MarkdownViewer.vue'
import ImageGallery from './ImageGallery.vue'
import { useI18n } from '../../../shared/i18n.js'
import { useI18n } from '../../../shared/i18n'
import type { PageElement } from '../../../shared/types'
const ELEMENT_COLORS = {
const ELEMENT_COLORS: Record<string, string> = {
title: '#EF4444',
section_header: '#F97316',
text: '#3B82F6',
@ -157,7 +158,7 @@ const pageMarkdown = computed(() => {
.join('\n\n')
})
function formatElement(el) {
function formatElement(el: PageElement) {
if (!el.content) return ''
const indent = ' '.repeat(Math.max(0, (el.level || 0) - 1))
switch (el.type) {
@ -184,7 +185,7 @@ function formatElement(el) {
// --- Copy to clipboard ---
const copiedMarkdown = ref(false)
const copiedElements = reactive({})
const copiedElements: Record<number, boolean> = reactive({})
async function copyMarkdown() {
try {
@ -194,7 +195,7 @@ async function copyMarkdown() {
} catch { /* clipboard not available */ }
}
async function copyElement(idx, content) {
async function copyElement(idx: number, content: string) {
try {
await navigator.clipboard.writeText(content)
copiedElements[idx] = true

View file

@ -32,7 +32,7 @@
<div class="canvas-container" ref="containerRef">
<img
v-if="documentId"
:src="previewUrl"
:src="previewUrl ?? undefined"
class="page-image"
ref="imageRef"
@load="onImageLoad"
@ -58,12 +58,13 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, watch, nextTick, reactive } from 'vue'
import { getPreviewUrl } from '../../document/api.js'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js'
import { getPreviewUrl } from '../../document/api'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
import type { Page, PageElement } from '../../../shared/types'
const ELEMENT_COLORS = {
const ELEMENT_COLORS: Record<string, string> = {
section_header: '#F97316',
text: '#3B82F6',
table: '#8B5CF6',
@ -74,17 +75,17 @@ const ELEMENT_COLORS = {
}
const props = defineProps({
pages: { type: Array, default: () => [] },
pages: { type: Array as () => Page[], default: () => [] },
documentId: String
})
const selectedPage = ref(1)
const hiddenTypes = reactive(new Set())
const containerRef = ref(null)
const imageRef = ref(null)
const canvasRef = ref(null)
const hoveredElement = ref(null)
const tooltipStyle = ref({})
const hiddenTypes = reactive(new Set<string>())
const containerRef = ref<HTMLDivElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const hoveredElement = ref<PageElement | null>(null)
const tooltipStyle = ref<Record<string, string>>({})
const imageSize = ref({ width: 0, height: 0 })
const currentPageData = computed(() => {
@ -101,13 +102,13 @@ const previewUrl = computed(() => {
return getPreviewUrl(props.documentId, selectedPage.value)
})
function toggleType(type) {
function toggleType(type: string) {
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
else hiddenTypes.add(type)
drawOverlay()
}
function countElements(type) {
function countElements(type: string) {
if (!currentPageData.value) return 0
return currentPageData.value.elements.filter(e => e.type === type).length
}
@ -128,6 +129,7 @@ function drawOverlay() {
canvas.height = img.clientHeight
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const page = currentPageData.value
@ -148,7 +150,7 @@ function drawOverlay() {
}
}
function onMouseMove(e) {
function onMouseMove(e: MouseEvent) {
const canvas = canvasRef.value
const page = currentPageData.value
const img = imageRef.value
@ -160,7 +162,7 @@ function onMouseMove(e) {
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
let found = null
let found: PageElement | null = null
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
found = el

View file

@ -1,27 +0,0 @@
import { apiFetch } from '../../shared/api/http.js'
export function fetchDocuments() {
return apiFetch('/api/documents')
}
export function fetchDocument(id) {
return apiFetch(`/api/documents/${id}`)
}
export async function uploadDocument(file) {
const formData = new FormData()
formData.append('file', file)
return apiFetch('/api/documents/upload', {
method: 'POST',
body: formData,
skipContentType: true
})
}
export function deleteDocument(id) {
return apiFetch(`/api/documents/${id}`, { method: 'DELETE' })
}
export function getPreviewUrl(id, page = 1, dpi = 150) {
return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}`
}

View file

@ -1,11 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchDocuments, fetchDocument, uploadDocument, deleteDocument, getPreviewUrl } from './api.js'
import { fetchDocuments, fetchDocument, uploadDocument, deleteDocument, getPreviewUrl } from './api'
vi.mock('../../shared/api/http.js', () => ({
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http.js'
import { apiFetch } from '../../shared/api/http'
describe('document API', () => {
beforeEach(() => {

View file

@ -0,0 +1,28 @@
import type { Document } from '../../shared/types'
import { apiFetch } from '../../shared/api/http'
export function fetchDocuments(): Promise<Document[]> {
return apiFetch<Document[]>('/api/documents')
}
export function fetchDocument(id: string): Promise<Document> {
return apiFetch<Document>(`/api/documents/${id}`)
}
export async function uploadDocument(file: File): Promise<Document> {
const formData = new FormData()
formData.append('file', file)
return apiFetch<Document>('/api/documents/upload', {
method: 'POST',
body: formData,
skipContentType: true,
})
}
export function deleteDocument(id: string): Promise<unknown> {
return apiFetch(`/api/documents/${id}`, { method: 'DELETE' })
}
export function getPreviewUrl(id: string, page = 1, dpi = 150): string {
return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}`
}

View file

@ -1,4 +1,4 @@
export { useDocumentStore } from './store.js'
export { useDocumentStore } from './store'
export { default as DocumentUpload } from './ui/DocumentUpload.vue'
export { default as DocumentList } from './ui/DocumentList.vue'
export { default as PagePreview } from './ui/PagePreview.vue'

View file

@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDocumentStore } from './store.js'
import { useDocumentStore } from './store'
vi.mock('./api.js', () => ({
vi.mock('./api', () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
deleteDocument: vi.fn(),
}))
import * as api from './api.js'
import * as api from './api'
describe('useDocumentStore', () => {
beforeEach(() => {

View file

@ -1,26 +1,29 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from './api.js'
import type { Document } from '../../shared/types'
import * as api from './api'
export const useDocumentStore = defineStore('document', () => {
const documents = ref([])
const selectedId = ref(null)
const documents = ref<Document[]>([])
const selectedId = ref<string | null>(null)
const uploading = ref(false)
const error = ref(null)
const error = ref<string | null>(null)
function clearError() { error.value = null }
function clearError(): void {
error.value = null
}
async function load() {
async function load(): Promise<void> {
try {
error.value = null
documents.value = await api.fetchDocuments()
} catch (e) {
error.value = e.message || 'Failed to load documents'
error.value = (e as Error).message || 'Failed to load documents'
console.error('Failed to load documents', e)
}
}
async function upload(file) {
async function upload(file: File): Promise<Document> {
uploading.value = true
error.value = null
try {
@ -29,7 +32,7 @@ export const useDocumentStore = defineStore('document', () => {
selectedId.value = doc.id
return doc
} catch (e) {
error.value = e.message || 'Failed to upload document'
error.value = (e as Error).message || 'Failed to upload document'
console.error('Failed to upload document', e)
throw e
} finally {
@ -37,18 +40,18 @@ export const useDocumentStore = defineStore('document', () => {
}
}
async function remove(id) {
async function remove(id: string): Promise<void> {
try {
await api.deleteDocument(id)
documents.value = documents.value.filter(d => d.id !== id)
documents.value = documents.value.filter((d) => d.id !== id)
if (selectedId.value === id) selectedId.value = null
} catch (e) {
error.value = e.message || 'Failed to delete document'
error.value = (e as Error).message || 'Failed to delete document'
console.error('Failed to delete document', e)
}
}
function select(id) {
function select(id: string): void {
selectedId.value = id
}

View file

@ -23,9 +23,9 @@
</div>
</template>
<script setup>
import { useDocumentStore } from '../store.js'
import { formatSize } from '../../../shared/format.js'
<script setup lang="ts">
import { useDocumentStore } from '../store'
import { formatSize } from '../../../shared/format'
const store = useDocumentStore()
</script>

View file

@ -22,29 +22,30 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import { useDocumentStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
import { useDocumentStore } from '../store'
import { useI18n } from '../../../shared/i18n'
const store = useDocumentStore()
const { t } = useI18n()
const fileInput = ref(null)
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
function openFilePicker() {
fileInput.value?.click()
}
async function onFileSelect(e) {
const file = e.target.files?.[0]
async function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) await store.upload(file)
e.target.value = ''
target.value = ''
}
async function onDrop(e) {
async function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer.files?.[0]
const file = e.dataTransfer?.files?.[0]
if (file && file.type === 'application/pdf') {
await store.upload(file)
}

View file

@ -6,7 +6,7 @@
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</button>
<button class="nav-btn" :disabled="pageCount && page >= pageCount" @click="$emit('update:page', page + 1)">
<button class="nav-btn" :disabled="!!(pageCount && page >= pageCount)" @click="$emit('update:page', page + 1)">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</button>
</div>
@ -23,9 +23,9 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed } from 'vue'
import { getPreviewUrl } from '../api.js'
import { getPreviewUrl } from '../api'
const props = defineProps({
documentId: String,

View file

@ -1,2 +1,2 @@
export { useHistoryStore } from './store.js'
export { useHistoryStore } from './store'
export { default as HistoryList } from './ui/HistoryList.vue'

View file

@ -7,23 +7,23 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('../analysis/api.js', () => ({
vi.mock('../analysis/api', () => ({
fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(),
createAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
}))
vi.mock('../document/api.js', () => ({
vi.mock('../document/api', () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
deleteDocument: vi.fn(),
getPreviewUrl: vi.fn(),
}))
import { useHistoryStore } from './store.js'
import { useAnalysisStore } from '../analysis/store.js'
import { useDocumentStore } from '../document/store.js'
import { useHistoryStore } from './store'
import { useAnalysisStore } from '../analysis/store'
import { useDocumentStore } from '../document/store'
describe('History → Studio navigation', () => {
beforeEach(() => {
@ -33,7 +33,7 @@ describe('History → Studio navigation', () => {
describe('History store provides data for navigation', () => {
it('analyses contain documentId for document selection', async () => {
const { fetchAnalyses } = await import('../analysis/api.js')
const { fetchAnalyses } = await import('../analysis/api')
fetchAnalyses.mockResolvedValue([
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
@ -49,7 +49,7 @@ describe('History → Studio navigation', () => {
describe('Analysis store select() restores analysis state', () => {
it('select() sets currentAnalysis from fetched data', async () => {
const { fetchAnalysis } = await import('../analysis/api.js')
const { fetchAnalysis } = await import('../analysis/api')
const analysis = {
id: 'a1',
documentId: 'd1',
@ -67,7 +67,7 @@ describe('History → Studio navigation', () => {
})
it('select() allows document store to select the associated document', async () => {
const { fetchAnalysis } = await import('../analysis/api.js')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockResolvedValue({
id: 'a1',
documentId: 'd1',
@ -126,7 +126,7 @@ describe('History → Studio navigation', () => {
describe('Full restore flow (store-level integration)', () => {
it('restores completed analysis: selects analysis + document + verifier mode', async () => {
const { fetchAnalysis } = await import('../analysis/api.js')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockResolvedValue({
id: 'a1',
documentId: 'd1',
@ -153,7 +153,7 @@ describe('History → Studio navigation', () => {
})
it('restores failed analysis: selects analysis + document, stays in configurer mode', async () => {
const { fetchAnalysis } = await import('../analysis/api.js')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockResolvedValue({
id: 'a2',
documentId: 'd2',
@ -176,7 +176,7 @@ describe('History → Studio navigation', () => {
})
it('handles missing analysis gracefully', async () => {
const { fetchAnalysis } = await import('../analysis/api.js')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockRejectedValue(new Error('Not found'))
vi.spyOn(console, 'error').mockImplementation(() => {})

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { useHistoryStore } from './store.js'
import { useAnalysisStore } from '../analysis/store.js'
import { useHistoryStore } from './store'
import { useAnalysisStore } from '../analysis/store'
describe('useHistoryStore', () => {
it('is a re-export of useAnalysisStore', () => {

View file

@ -1 +1 @@
export { useAnalysisStore as useHistoryStore } from '../analysis/store.js'
export { useAnalysisStore as useHistoryStore } from '../analysis/store'

View file

@ -30,20 +30,21 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAnalysisStore } from '../../analysis/store.js'
import { useI18n } from '../../../shared/i18n.js'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import type { Analysis } from '../../../shared/types'
const store = useAnalysisStore()
const router = useRouter()
const { t } = useI18n()
function openAnalysis(analysis) {
function openAnalysis(analysis: Analysis) {
router.push({ name: 'studio', query: { analysisId: analysis.id } })
}
function statusClass(status) {
function statusClass(status: string) {
return {
'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING',
@ -52,14 +53,14 @@ function statusClass(status) {
}
}
function formatDate(iso) {
function formatDate(iso: string) {
if (!iso) return ''
return new Date(iso).toLocaleString()
}
function duration(analysis) {
function duration(analysis: Analysis) {
if (!analysis.startedAt || !analysis.completedAt) return ''
const ms = new Date(analysis.completedAt) - new Date(analysis.startedAt)
const ms = new Date(analysis.completedAt!).getTime() - new Date(analysis.startedAt!).getTime()
const secs = Math.round(ms / 1000)
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
}

View file

@ -1,2 +1,2 @@
export { useSettingsStore } from './store.js'
export { useSettingsStore } from './store'
export { default as SettingsPanel } from './ui/SettingsPanel.vue'

View file

@ -1,10 +1,11 @@
import { defineStore } from 'pinia'
import { ref, watch, watchEffect } from 'vue'
import type { Locale, Theme } from '../../shared/types'
export const useSettingsStore = defineStore('settings', () => {
const apiUrl = ref('http://localhost:8000')
const theme = ref(localStorage.getItem('docling-theme') || 'dark')
const locale = ref(localStorage.getItem('docling-locale') || 'fr')
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
watch(theme, (v) => localStorage.setItem('docling-theme', v))
watch(locale, (v) => localStorage.setItem('docling-locale', v))
@ -13,8 +14,12 @@ export const useSettingsStore = defineStore('settings', () => {
document.documentElement.classList.toggle('light', theme.value === 'light')
})
function setTheme(t) { theme.value = t }
function setLocale(l) { locale.value = l }
function setTheme(t: Theme): void {
theme.value = t
}
function setLocale(l: Locale): void {
locale.value = l
}
return { apiUrl, theme, locale, setTheme, setLocale }
})

View file

@ -27,9 +27,9 @@
</div>
</template>
<script setup>
import { useSettingsStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
<script setup lang="ts">
import { useSettingsStore } from '../store'
import { useI18n } from '../../../shared/i18n'
const store = useSettingsStore()
const { t } = useI18n()

View file

@ -35,16 +35,16 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useDocumentStore } from '../features/document/store.js'
import { useI18n } from '../shared/i18n.js'
import { formatSize } from '../shared/format.js'
import { useDocumentStore } from '../features/document/store'
import { useI18n } from '../shared/i18n'
import { formatSize } from '../shared/format'
const docStore = useDocumentStore()
const { t } = useI18n()
function formatDate(iso) {
function formatDate(iso: string) {
if (!iso) return ''
return new Date(iso).toLocaleString()
}

View file

@ -9,10 +9,10 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { onMounted } from 'vue'
import { HistoryList, useHistoryStore } from '../features/history/index.js'
import { useI18n } from '../shared/i18n.js'
import { HistoryList, useHistoryStore } from '../features/history/index'
import { useI18n } from '../shared/i18n'
const historyStore = useHistoryStore()
const { t } = useI18n()

View file

@ -53,13 +53,14 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useDocumentStore } from '../features/document/store.js'
import { useHistoryStore } from '../features/history/index.js'
import { DocumentUpload } from '../features/document/index.js'
import { useI18n } from '../shared/i18n.js'
import { useDocumentStore } from '../features/document/store'
import { useHistoryStore } from '../features/history/index'
import { DocumentUpload } from '../features/document/index'
import { useI18n } from '../shared/i18n'
import type { Document } from '../shared/types'
const router = useRouter()
const documentStore = useDocumentStore()
@ -70,7 +71,7 @@ const docCount = computed(() => documentStore.documents.length)
const analysisCount = computed(() => historyStore.analyses.length)
const recentDocs = computed(() => documentStore.documents.slice(0, 5))
function openInStudio(doc) {
function openInStudio(doc: Document) {
documentStore.select(doc.id)
router.push('/studio')
}

View file

@ -9,9 +9,9 @@
</div>
</template>
<script setup>
import { SettingsPanel } from '../features/settings/index.js'
import { useI18n } from '../shared/i18n.js'
<script setup lang="ts">
import { SettingsPanel } from '../features/settings/index'
import { useI18n } from '../shared/i18n'
const { t } = useI18n()
</script>

View file

@ -281,16 +281,17 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDocumentStore } from '../features/document/store.js'
import { useAnalysisStore } from '../features/analysis/store.js'
import { DocumentUpload, DocumentList } from '../features/document/index.js'
import { ResultTabs } from '../features/analysis/index.js'
import { useDocumentStore } from '../features/document/store'
import { useAnalysisStore } from '../features/analysis/store'
import { DocumentUpload, DocumentList } from '../features/document/index'
import { ResultTabs } from '../features/analysis/index'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import { getPreviewUrl } from '../features/document/api.js'
import { useI18n } from '../shared/i18n.js'
import { getPreviewUrl } from '../features/document/api'
import { useI18n } from '../shared/i18n'
import type { PipelineOptions } from '../shared/types'
const route = useRoute()
const router = useRouter()
@ -302,8 +303,8 @@ const mode = ref('configurer')
const currentPage = ref(1)
const visualMode = ref(false)
const highlightedElementIndex = ref(-1)
const pdfImageRef = ref(null)
const bboxOverlayRef = ref(null)
const pdfImageRef = ref<HTMLImageElement | null>(null)
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
// --- Resizable right panel ---
const RIGHT_PANEL_MIN = 280
@ -311,7 +312,7 @@ const RIGHT_PANEL_MAX_RATIO = 0.7
const rightPanelWidth = ref(380)
let resizing = false
function onResizeStart(e) {
function onResizeStart(e: MouseEvent) {
e.preventDefault()
resizing = true
document.body.style.cursor = 'col-resize'
@ -320,7 +321,7 @@ function onResizeStart(e) {
document.addEventListener('mouseup', onResizeEnd)
}
function onResizeMove(e) {
function onResizeMove(e: MouseEvent) {
if (!resizing) return
const maxWidth = window.innerWidth * RIGHT_PANEL_MAX_RATIO
const newWidth = window.innerWidth - e.clientX
@ -337,7 +338,7 @@ function onResizeEnd() {
nextTick(() => bboxOverlayRef.value?.draw())
}
const pipelineOptions = reactive({
const pipelineOptions = reactive<PipelineOptions>({
do_ocr: true,
do_table_structure: true,
table_mode: 'accurate',
@ -372,8 +373,8 @@ const previewUrl = computed(() => {
return getPreviewUrl(selectedDoc.value.id, currentPage.value)
})
function onPageInput(e) {
const val = parseInt(e.target.value)
function onPageInput(e: Event) {
const val = parseInt((e.target as HTMLInputElement).value)
if (!val || val < 1) return
const max = selectedDoc.value?.pageCount || val
currentPage.value = Math.min(val, max)
@ -403,7 +404,7 @@ onMounted(async () => {
// Restore analysis from history via query param
const analysisId = route.query.analysisId
if (analysisId) {
await analysisStore.select(analysisId)
await analysisStore.select(analysisId as string)
const analysis = analysisStore.currentAnalysis
if (analysis) {
documentStore.select(analysis.documentId)

View file

@ -1,15 +0,0 @@
export async function apiFetch(url, options = {}) {
const headers = { ...options.headers }
if (!options.skipContentType) {
headers['Content-Type'] = 'application/json'
}
const response = await fetch(url, {
...options,
headers
})
if (!response.ok) throw new Error(`API error: ${response.status}`)
if (response.status === 204) return null
return response.json()
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { apiFetch } from './http.js'
import { apiFetch } from './http'
describe('apiFetch', () => {
beforeEach(() => {

View file

@ -0,0 +1,19 @@
interface FetchOptions extends RequestInit {
skipContentType?: boolean
}
export async function apiFetch<T = unknown>(url: string, options: FetchOptions = {}): Promise<T> {
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) }
if (!options.skipContentType) {
headers['Content-Type'] = 'application/json'
}
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) throw new Error(`API error: ${response.status}`)
if (response.status === 204) return null as T
return response.json() as Promise<T>
}

View file

@ -1,10 +0,0 @@
/**
* Format a byte count as a human-readable size string.
* @param {number|null|undefined} bytes
* @returns {string}
*/
export function formatSize(bytes) {
if (!bytes) return ''
const mb = bytes / (1024 * 1024)
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`
}

View file

@ -0,0 +1,5 @@
export function formatSize(bytes: number | null | undefined): string {
if (!bytes) return ''
const mb = bytes / (1024 * 1024)
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`
}

View file

@ -2,12 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock settings store before importing i18n
vi.mock('../features/settings/store.js', () => ({
vi.mock('../features/settings/store', () => ({
useSettingsStore: vi.fn(),
}))
import { useSettingsStore } from '../features/settings/store.js'
import { useI18n } from './i18n.js'
import { useSettingsStore } from '../features/settings/store'
import { useI18n } from './i18n'
describe('useI18n', () => {
beforeEach(() => {

View file

@ -1,6 +1,10 @@
import { useSettingsStore } from '../features/settings/store.js'
import type { Locale } from './types'
import { useSettingsStore } from '../features/settings/store'
const messages = {
type MessageMap = Record<string, string>
type Messages = Record<Locale, MessageMap>
const messages: Messages = {
fr: {
// Sidebar
'nav.home': 'Accueil',
@ -188,16 +192,16 @@ const messages = {
'settings.themeDark': 'Dark',
'settings.themeLight': 'Light',
'settings.language': 'Language',
}
},
}
export function useI18n() {
const settings = useSettingsStore()
function t(key, params = {}) {
function t(key: string, params: Record<string, string | number> = {}): string {
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
for (const [k, v] of Object.entries(params)) {
str = str.replace(`{${k}}`, v)
str = str.replace(`{${k}}`, String(v))
}
return str
}

View file

@ -0,0 +1,79 @@
export interface Document {
id: string
filename: string
contentType: string | null
fileSize: number | null
pageCount: number | null
createdAt: string
}
export interface PipelineOptions {
do_ocr?: boolean
do_table_structure?: boolean
table_mode?: 'accurate' | 'fast'
do_code_enrichment?: boolean
do_formula_enrichment?: boolean
do_picture_classification?: boolean
do_picture_description?: boolean
generate_picture_images?: boolean
generate_page_images?: boolean
images_scale?: number
}
export type AnalysisStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED'
export interface Analysis {
id: string
documentId: string
documentFilename: string | null
status: AnalysisStatus
contentMarkdown: string | null
contentHtml: string | null
pagesJson: string | null
errorMessage: string | null
startedAt: string | null
completedAt: string | null
createdAt: string
}
export interface PageElement {
type: string
bbox: [number, number, number, number]
content: string
level: number
}
// Backend serializes with snake_case (dataclasses.asdict)
export interface Page {
page_number: number
width: number
height: number
elements: PageElement[]
}
export type ElementType =
| 'title'
| 'section_header'
| 'text'
| 'table'
| 'picture'
| 'list'
| 'formula'
| 'code'
| 'caption'
| 'floating'
export interface Scale {
sx: number
sy: number
}
export interface Rect {
x: number
y: number
w: number
h: number
}
export type Locale = 'fr' | 'en'
export type Theme = 'dark' | 'light'

View file

@ -33,9 +33,9 @@
</aside>
</template>
<script setup>
<script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n.js'
import { useI18n } from '../i18n'
const route = useRoute()
const { t } = useI18n()

View file

@ -3,18 +3,18 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": false,
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.js", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}