Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9733b944a | ||
|
|
f6f08eef61 | ||
|
|
67f469acfd | ||
|
|
d6328e7b58 | ||
|
|
46e08abad1 | ||
|
|
f6f4dad0ec | ||
|
|
077bfc0b6a | ||
|
|
c6a1f8f54f | ||
|
|
4038346de0 | ||
|
|
14d0b6b7ee | ||
|
|
d32ee1110b | ||
|
|
b0a1685dda | ||
|
|
a9540c5750 | ||
|
|
9a834e04ab | ||
|
|
8c9e881922 | ||
|
|
16e8bbf811 | ||
|
|
7b53f64249 | ||
|
|
4505fe77c4 | ||
|
|
37b059aa82 | ||
|
|
70cb9ca6a2 | ||
|
|
b812afabbc | ||
|
|
62eabb2944 | ||
|
|
fdc0b5b15a | ||
|
|
62e191abfc | ||
|
|
331c614330 | ||
|
|
6da7fa6786 | ||
|
|
c8d35b2ee6 | ||
|
|
ba73af8ced | ||
|
|
185d603a94 | ||
|
|
9804cf9a6d | ||
|
|
a4ea322c75 | ||
|
|
7e5cd85b8a | ||
|
|
8c665fb568 | ||
|
|
7d8fa18201 | ||
|
|
b413ca9a0b | ||
|
|
dfd8d5929c | ||
|
|
1b5319fde1 | ||
|
|
ff38dd086e | ||
|
|
7b02bc9ab4 | ||
|
|
380ca29f2c | ||
|
|
7389d26520 | ||
|
|
298ea082c0 | ||
|
|
ffefe4eb16 | ||
|
|
257e35198d | ||
|
|
38569c2f9f | ||
|
|
66d9c17985 | ||
|
|
03e115b65b | ||
|
|
804e6313b3 | ||
|
|
ed5cf597f9 | ||
|
|
4deb54b3a3 | ||
|
|
fe4cb9c47c | ||
|
|
1a9a555bee | ||
|
|
d7b0998af8 | ||
|
|
8aaf19479f | ||
|
|
069d119d03 | ||
|
|
a842d68038 | ||
|
|
e5cd656c6e | ||
|
|
ed4c1dcf07 | ||
|
|
b27b38d32b | ||
|
|
0cea78b3b1 | ||
|
|
efb59230e7 | ||
|
|
9d91da6656 | ||
|
|
a80df670be | ||
|
|
c8b19d507a | ||
|
|
da00aa4826 | ||
|
|
e213ad183e | ||
|
|
2ba7138881 | ||
|
|
1dd14e0bfd | ||
|
|
4631168d1d | ||
|
|
50ffca3bdc | ||
|
|
19c987585a | ||
|
|
2dcbcd0949 | ||
|
|
e46437d967 | ||
|
|
82fe053c7d | ||
|
|
757614eb57 | ||
|
|
08f97e9e29 | ||
|
|
0fbdf0ef1b | ||
|
|
73d798015d | ||
|
|
85e2912d62 | ||
|
|
004bf6f10b | ||
|
|
bbf23df6ab | ||
|
|
0a560d5f7b | ||
|
|
b6a03fff42 | ||
|
|
ae6b997b72 | ||
|
|
96d426a2e1 | ||
|
|
e78248c296 | ||
|
|
8212a9453a | ||
|
|
704ad94cb9 | ||
|
|
68bb9c3068 | ||
|
|
ed50aef6f7 | ||
|
|
1b4a6ff0cf | ||
|
|
99fa464dca | ||
|
|
fd41175ad8 | ||
|
|
fc93f550e0 | ||
|
|
f3818181d4 | ||
|
|
727ca278ca | ||
|
|
90f21d35cf | ||
|
|
0d0a8c013f | ||
|
|
7f24fe7d01 | ||
|
|
301c325af7 | ||
|
|
29d1332178 | ||
|
|
23fdc4d8f2 | ||
|
|
cf30dfb48c | ||
|
|
ec32030afc | ||
|
|
553e1dd4ee | ||
|
|
cdaea4142b | ||
|
|
5426022273 | ||
|
|
15a217eca4 | ||
|
|
ed031baf92 | ||
|
|
65a0221557 | ||
|
|
b2e9b8aecc | ||
|
|
d37010c6ae | ||
|
|
45c313824e | ||
|
|
7058d0c0b2 | ||
|
|
593bb9d9c2 | ||
|
|
db72e6f055 | ||
|
|
8fd69b4b50 | ||
|
|
ca721c128c | ||
|
|
b09a642f66 | ||
|
|
21ee86ffa0 | ||
|
|
02396641f6 | ||
|
|
a583281edb | ||
|
|
8b950a8031 | ||
|
|
a1355977d9 | ||
|
|
f3bfd01f93 | ||
|
|
143b1d436b | ||
|
|
3f11f4715c | ||
|
|
9af68cfcd7 | ||
|
|
32a177f5aa | ||
|
|
6c448b3060 | ||
|
|
4eaefc2c73 | ||
|
|
1673531b43 | ||
|
|
5112c7b0ef | ||
|
|
88b73f663f | ||
|
|
8bbf973097 | ||
|
|
14a45c9136 | ||
|
|
d2dec1be06 | ||
|
|
317065f986 | ||
|
|
24b56ac0e7 | ||
|
|
b46b98c5a7 | ||
|
|
3655ef8a7e | ||
|
|
3cf1c33b69 | ||
|
|
61caa47745 | ||
|
|
797d9763dd | ||
|
|
d2953d98f9 | ||
|
|
93d0bda627 | ||
|
|
5b956f8d95 | ||
|
|
6a10051d78 | ||
|
|
c3c5f11e9b | ||
|
|
790f97e501 | ||
|
|
cc22ffe6df | ||
|
|
72622f3dfb | ||
|
|
bea23820ab | ||
|
|
6d4a45797e | ||
|
|
dec46cd5ab | ||
|
|
98a6dd8a2c | ||
|
|
2de9f91671 | ||
|
|
e49fb46be7 | ||
|
|
8e0924a98c | ||
|
|
1781dd5b34 | ||
|
|
2a8bd44a06 | ||
|
|
f1799f86b1 | ||
|
|
602a673ca1 | ||
|
|
aca33c2a93 | ||
|
|
d08ed961bc | ||
|
|
bd41b5c54e | ||
|
|
22ade84ece | ||
|
|
8cfcf28b4e | ||
|
|
5bdf86c157 | ||
|
|
2a3a4635bd | ||
|
|
d3225fd5a8 | ||
|
|
326150f5cf | ||
|
|
ddc2eb327a | ||
|
|
8444b18c2c | ||
|
|
90c680414c | ||
|
|
2508c37ef2 | ||
|
|
52b9fcd814 | ||
|
|
70e17b9024 | ||
|
|
feefc51d6f | ||
|
|
ec5dfc4df2 | ||
|
|
bfe49d7a79 | ||
|
|
a2c6bd7f87 | ||
|
|
a6fb27d508 | ||
|
|
0d3f26a5ff | ||
|
|
e5da0c2fc5 | ||
|
|
70618aec1d | ||
|
|
199007f378 | ||
|
|
2d445828cb | ||
|
|
7cdb9ca5a1 | ||
|
|
093d04b7ca | ||
|
|
46bc9637fc | ||
|
|
cb402ac942 | ||
|
|
48023620e6 | ||
|
|
0bd2c12c13 | ||
|
|
72f55fe6ce | ||
|
|
8fe096d873 | ||
|
|
7ed303785c | ||
|
|
23ff41c974 | ||
|
|
4287303575 | ||
|
|
aa06ae534b | ||
|
|
c93b7fb2fd | ||
|
|
0b3464aa9e | ||
|
|
7d3250306a | ||
|
|
8230356d27 | ||
|
|
edd9ce4afd | ||
|
|
8e0ca92161 | ||
|
|
c51bf0eab0 | ||
|
|
b6be12969b | ||
|
|
a15328093a | ||
|
|
8db9f4447f | ||
|
|
4b9a450be3 | ||
|
|
659c227e6a | ||
|
|
01dec0a0ec | ||
|
|
2fdca72f02 | ||
|
|
e1d3b22c51 | ||
|
|
b822196e66 | ||
|
|
fa294f0a51 | ||
|
|
88c7a5ead9 | ||
|
|
557cea719d | ||
|
|
cbe51a2849 | ||
|
|
2f88c22fda | ||
|
|
29b93c933e | ||
|
|
306951837c | ||
|
|
6653c7965a | ||
|
|
b587147c81 | ||
|
|
866b3008cc | ||
|
|
57a7eb480d | ||
|
|
c997183fca | ||
|
|
661989d85d | ||
|
|
62378a9352 | ||
|
|
b4fefe483e | ||
|
|
31d8c2603e | ||
|
|
7977cb89aa | ||
|
|
bd8d6ccf63 | ||
|
|
2c4f9c6967 | ||
|
|
b03b057f66 | ||
|
|
31b7f4c569 | ||
|
|
1a27e95c1a | ||
|
|
044395500f | ||
|
|
bc7875bc8e | ||
|
|
0403be4516 | ||
|
|
f91a386566 | ||
|
|
f85482214e | ||
|
|
daab917ed1 | ||
|
|
8acfd1f19a | ||
|
|
32b1c0438b | ||
|
|
83279b536f | ||
|
|
9f382e4960 | ||
|
|
6c2a9f716a | ||
|
|
85efc068c4 | ||
|
|
c4b797f0b4 | ||
|
|
0a0cecf178 | ||
|
|
2d03586ae0 | ||
|
|
21ee6265d3 | ||
|
|
9792c6a08b | ||
|
|
eef01ee32a | ||
|
|
994a6e1749 | ||
|
|
fa4b17d9e9 | ||
|
|
4d0d1420df | ||
|
|
9a868fd465 | ||
|
|
d3b8062b6e | ||
|
|
dab76c86f3 | ||
|
|
4379f0c844 | ||
|
|
36c1546098 | ||
|
|
5fc110a1b2 | ||
|
|
9c73dd8d51 | ||
|
|
06a986d01c | ||
|
|
a002acb2b2 | ||
|
|
1a26827a5f | ||
|
|
4104ed9fa2 | ||
|
|
ff2d95aad0 | ||
|
|
66039028a5 | ||
|
|
fccee296a5 | ||
|
|
23f4f9f4f2 | ||
|
|
d1841a057d | ||
|
|
671befe610 | ||
|
|
8248bb0846 | ||
|
|
4431546a3a | ||
|
|
83d21bad1a | ||
|
|
5466457a06 | ||
|
|
2c7b8b525a | ||
|
|
1116957c19 | ||
|
|
5ec54d3cc2 | ||
|
|
c2d7000edf | ||
|
|
91d7074a07 | ||
|
|
d0bc5ebbd0 | ||
|
|
1ce3a6d65c | ||
|
|
75a28da8d9 | ||
|
|
0f4a7405db | ||
|
|
dd52f379bf | ||
|
|
cc2ec9e869 | ||
|
|
feeb46b873 | ||
|
|
0cdebc5b40 | ||
|
|
8cf7b55747 | ||
|
|
89b0a5a024 | ||
|
|
75291eac42 | ||
|
|
88185c70e0 | ||
|
|
3090fe62fe | ||
|
|
6388718b87 | ||
|
|
12eaa9f659 | ||
|
|
ac9382d12e | ||
|
|
ba30d1e7ce | ||
|
|
c58eb9314e | ||
|
|
53cd202a93 | ||
|
|
76a5b23e96 | ||
|
|
f42e37489b | ||
|
|
218c77dafb | ||
|
|
bbe109e4e5 | ||
|
|
bc47f79378 | ||
|
|
dcf82e7603 | ||
|
|
6eff157686 | ||
|
|
a358f772bd | ||
|
|
a21546028a | ||
|
|
02564cadfa | ||
|
|
739b359de2 | ||
|
|
0f400d72cd | ||
|
|
0f1ae237a0 | ||
|
|
0f6bb52776 | ||
|
|
946c0098a9 | ||
|
|
24b91b0d62 | ||
|
|
0fcb917a8d | ||
|
|
0f9e45dcb1 | ||
|
|
b55eacad78 | ||
|
|
6d7f95186d | ||
|
|
51a5162e66 | ||
|
|
2cf9915208 | ||
|
|
c7f652ace5 | ||
|
|
697f25700b | ||
|
|
dea1d73943 | ||
|
|
027eab1368 | ||
|
|
28ced406bc | ||
|
|
fff658d551 | ||
|
|
4a6e907f3f | ||
|
|
da58b97096 | ||
|
|
a0cae4a6a4 | ||
|
|
ccb0fb3e97 | ||
|
|
69e86c239b | ||
|
|
00f32ce9c2 | ||
|
|
8c4ce5e3c0 | ||
|
|
3b53de1b33 | ||
|
|
182e40800e | ||
|
|
5c5ef561cf | ||
|
|
4674328303 | ||
|
|
6e8a51b21a | ||
|
|
9e552aa87d | ||
|
|
47dc79683f | ||
|
|
7af347b6be | ||
|
|
7720d9b96f | ||
|
|
fb87c9f7c3 | ||
|
|
ca132c2668 | ||
|
|
71646ccfb0 | ||
|
|
1f44fc9dfd | ||
|
|
0c2b70d3f9 | ||
|
|
652bcb9eef | ||
|
|
8c5a5fd6b2 | ||
|
|
1e9ecf656f | ||
|
|
21117da114 | ||
|
|
67043a76e5 | ||
|
|
9f05059800 | ||
|
|
05ac6cd756 | ||
|
|
bc8083fd6b | ||
|
|
af05604e99 | ||
|
|
e22967ada8 | ||
|
|
247d78374c | ||
|
|
0b3a7d5c95 |
@@ -0,0 +1,13 @@
|
||||
# Binary files — prevent CRLF corruption on Windows
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.zip binary
|
||||
*.exe binary
|
||||
*.msi binary
|
||||
*.AppImage binary
|
||||
*.deb binary
|
||||
+71
-1
@@ -82,7 +82,7 @@ jobs:
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST)
|
||||
run: bandit -r backend/ -c pyproject.toml 2>/dev/null || bandit -r backend/ --skip B101
|
||||
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
|
||||
|
||||
- name: Pip-audit (dependency vulnerabilities)
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
|
||||
@@ -94,8 +94,78 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate VERSION file
|
||||
run: |
|
||||
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
|
||||
echo "$VERSION" > backend/VERSION
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t obsigate:ci .
|
||||
|
||||
- name: Verify image
|
||||
run: docker images obsigate:ci
|
||||
|
||||
# ── E2E Tests (Playwright) ─────────────────────────────────────────
|
||||
e2e:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
docker create --name obsigate-e2e -p 2029:8080 \
|
||||
-v $(pwd)/test_vault:/vaults/TestVault \
|
||||
-v $(pwd)/test_dir:/vaults/TestDir \
|
||||
-e VAULT_1_NAME=TestVault \
|
||||
-e VAULT_1_PATH=/vaults/TestVault \
|
||||
-e DIR_1_NAME=TestDir \
|
||||
-e DIR_1_PATH=/vaults/TestDir \
|
||||
-e OBSIGATE_AUTH_ENABLED=false \
|
||||
obsigate:ci
|
||||
# Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin
|
||||
# du job container, inexistant sur l'hôte → montage vide. Les -v
|
||||
# créent quand même /vaults/* dans le container ; on y copie les
|
||||
# fixtures avant le démarrage (l'indexeur scanne au démarrage).
|
||||
docker cp test_vault/. obsigate-e2e:/vaults/TestVault
|
||||
docker cp test_dir/. obsigate-e2e:/vaults/TestDir
|
||||
docker start obsigate-e2e
|
||||
# Le port publié est joignable via l'IP de la passerelle (localhost
|
||||
# du job container ne voit pas le port publié sur l'hôte).
|
||||
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
|
||||
echo "Gateway IP: $GW"
|
||||
# Wait for health check
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://$GW:2029/api/health"; then echo "Health OK"; break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf "http://$GW:2029/api/health" >/dev/null || { echo "App not reachable at $GW:2029"; exit 1; }
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
|
||||
echo "Using BASE_URL=http://$GW:2029"
|
||||
BASE_URL="http://$GW:2029" npx playwright test --project=chromium-desktop --reporter=list
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker rm -f obsigate-e2e
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Desktop Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
workflow_dispatch: # permet de lancer manuellement
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: [self-hosted, windows, desktop] # runner sur ta machine Windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
run: rustup default stable && rustup update
|
||||
|
||||
- name: Setup Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0"
|
||||
|
||||
- name: Setup Python 3.11 embed
|
||||
run: |
|
||||
curl -L -o python-embed.zip "https://www.python.org/ftp/python/3.11.15/python-3.11.15-embed-amd64.zip"
|
||||
powershell Expand-Archive -Path python-embed.zip -DestinationPath desktop/python-embed
|
||||
echo import site >> desktop/python-embed/python311._pth
|
||||
cd desktop/python-embed
|
||||
./python.exe -m pip install --no-cache-dir -r ../../backend/requirements.txt
|
||||
|
||||
- name: Stage backend + frontend for bundle
|
||||
working-directory: desktop
|
||||
run: |
|
||||
xcopy /E /I /Q /Y "..\backend" "backend"
|
||||
xcopy /E /I /Q /Y "..\frontend" "frontend"
|
||||
|
||||
- name: Build MSI
|
||||
working-directory: desktop
|
||||
run: cargo tauri build --bundles msi
|
||||
|
||||
- name: Upload MSI artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: obsigate-windows-msi
|
||||
path: desktop/target/release/bundle/msi/*.msi
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish to Gitea Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
$MSI = Get-ChildItem desktop/target/release/bundle/msi/*.msi | Select-Object -First 1
|
||||
echo "MSI ready: $MSI"
|
||||
|
||||
build-linux:
|
||||
runs-on: [self-hosted, linux, desktop] # runner sur une machine Linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
run: rustup default stable && rustup update
|
||||
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev
|
||||
|
||||
- name: Setup Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0"
|
||||
|
||||
- name: Setup Python venv
|
||||
run: |
|
||||
python3 -m venv desktop/python-embed/venv
|
||||
source desktop/python-embed/venv/bin/activate
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Stage backend + frontend for bundle
|
||||
working-directory: desktop
|
||||
run: |
|
||||
cp -r ../backend backend
|
||||
cp -r ../frontend frontend
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: desktop
|
||||
run: cargo tauri build --bundles appimage
|
||||
|
||||
- name: Build deb
|
||||
working-directory: desktop
|
||||
run: cargo tauri build --bundles deb
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: obsigate-linux
|
||||
path: |
|
||||
desktop/target/release/bundle/appimage/*.AppImage
|
||||
desktop/target/release/bundle/deb/*.deb
|
||||
retention-days: 30
|
||||
+17
-1
@@ -1,14 +1,30 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
# Scripts de debogage temporaires (analyse de logs e2e)
|
||||
_*.py
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
test_vault/
|
||||
.venv/
|
||||
.venv-e2e/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
config.json
|
||||
data/
|
||||
.coverage
|
||||
.coverage
|
||||
odysseus/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
**/.obsigate-backup/
|
||||
|
||||
# Desktop build artifacts
|
||||
desktop/target/
|
||||
desktop/python-embed/
|
||||
desktop/frontend-static/
|
||||
backend/VERSION
|
||||
|
||||
|
||||
+80
-1
@@ -5,6 +5,85 @@ Toutes les modifications notables d'ObsiGate sont documentées dans ce fichier.
|
||||
Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements non publiés sont dans la section
|
||||
> [2.0.0 — Unreleased](#200--unreleased). La dernière version publiée est **1.8.0**.
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] — Unreleased
|
||||
|
||||
> ObsiGate passe à **2.0.0** avec sa **version bureau native** et de nombreuses fonctionnalités
|
||||
> de productivité (branch main, 341 commits après le tag v1.8.0).
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Application bureau native (Tauri 2)** — Porte d'entrée desktop pour vaults Obsidian (`desktop/`)
|
||||
- Shell Rust + Tauri 2 (tray icon, notifications OS, auto-update depuis Gitea)
|
||||
- Backend Python embarqué (python-embed) avec health check, démarrage ~2s
|
||||
- Sélecteur de dossiers natif, config persistante des vaults (type VAULT vs DIR)
|
||||
- Fenêtre console masquée, arrêt propre via tray, associations de fichiers `.md`
|
||||
- Packages : Windows MSI + NSIS, Linux .deb + AppImage
|
||||
- Pipeline CI/CD Desktop (`.gitea/workflows/desktop-build.yml`)
|
||||
- Thème système auto (clair/sombre) + RAM réduite (~80 MB au repos)
|
||||
- Écran de chargement inline (staging backend/frontend pour le bundle desktop)
|
||||
- **Internationalisation FR/EN complète** — 1357 clés i18n, préférence serveur, guide entièrement traduit
|
||||
- **Vue multi-panneaux (Split View #75)** — PaneManager + TabManager par panneau
|
||||
- Split droite/bas, drag & drop cross-pane, drag-to-split (bord droit/bas)
|
||||
- Boutons de split, navigation clavier, fermeture auto des panneaux vides
|
||||
- **Mermaid.js** — Rendu de diagrammes (22 templates, thème dark synchronisé)
|
||||
- Header bar Code/Preview, zoom, export SVG + PNG, plein écran / focus 480px
|
||||
- Pré-processeur Obsidian (`[[liens]]`, `![[img]]`, `==highlight==`)
|
||||
- **Gestionnaire de Backups complet** — Page, filtres, purge, restauration
|
||||
- Diff viewer (unifié + côte à côte), preview 100 Ko, compression gzip
|
||||
- Backup automatique, auto-nettoyage (max_backups_per_file)
|
||||
- **Support Excalidraw (#78)** — Fichiers `.excalidraw` / `.excalidraw.md`
|
||||
- Décompression lz-string, création depuis modale/menu contextuel
|
||||
- **Éditeur Forge + autocomplétion IA** — Auto-height, Mermaid QI, ghost text
|
||||
- Autocomplétion Ollama (qwen2.5-coder), Alt+/, auto-continuation listes/checkboxes
|
||||
- **Support PDF + multi-format** — PDF avec TOC, images/CSV/JSON/TXT
|
||||
- **15 thèmes clair/sombre (#65)** — Système de thèmes configurable
|
||||
- **Mode hors-ligne PWA (#59)** — Badge hors-ligne + gestion des conflits
|
||||
- **Tests E2E Playwright (#58)** — Suite desktop (44 tests) + CI
|
||||
|
||||
### Modifié
|
||||
|
||||
- `desktop/` — Nouveau : application Tauri (Cargo.toml, build.rs, `src/main.rs`, scripts build)
|
||||
- `frontend/` — i18n, split view, Mermaid, Excalidraw, Forge, thèmes
|
||||
- `backend/` — Endpoints backups, Excalidraw, multi-format, versioning git
|
||||
- `docs/ROADMAP.md` — Feuille de route passée à 2.0.0-dev, items #75/#78 complétés
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] — 2026-06-04
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Palette de commandes** — Navigation rapide fichiers + commandes
|
||||
- Raccourcis : `Ctrl+P` fichiers, `Ctrl+Alt+Espace` commandes, `Ctrl+Shift+Espace`
|
||||
- Navigateur de dossiers : créer/supprimer fichiers/dossiers par sélection
|
||||
- Opérateurs `+include` / `-exclude` dans la recherche globale
|
||||
- 6 nouvelles commandes : créer/supprimer fichiers/dossiers, éditer, pop-out
|
||||
- **Barre d'outils mobile de bas de page** — Type Obsidian 📱
|
||||
- Explorateur, recherche, palette, changement d'onglet
|
||||
- Indicateur de vault contextuel sous le header, pull-to-refresh
|
||||
- **Diff viewer des backups** — Comparaison unifiée + côte à côte, restauration
|
||||
- **Indicateur AI Actif** — Badge/statut IA dans le header (lecture runtime des clés)
|
||||
- **Page d'accueil de vault** — Liste récursive des fichiers par date
|
||||
- **Drag & drop de fichiers** dans l'arborescence
|
||||
- **Guide d'aide enrichi** — Recherche, scroll spy, slide-over mobile, section webhooks
|
||||
- **Indexation inversée non-bloquante** (`run_in_executor`)
|
||||
- **Infrastructure de tests** — Couverture pytest portée à 70% (+109 tests)
|
||||
|
||||
### Modifié
|
||||
|
||||
- `frontend/` — Palette, mobile toolbar, aide, AI indicator, vault home
|
||||
- `backend/` — Indexation non-bloquante, statut IA, diff/restauration backups
|
||||
- `docs/` — Guide d'utilisation enrichi (navigation clavier), CHANGELOG/README/ROADMAP
|
||||
|
||||
### Corrigé
|
||||
|
||||
- Correctifs UI mobile, palette, aide et AI toolbar (thème clair, menu clipping)
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] — 2026-05-30
|
||||
@@ -209,7 +288,7 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- `includeHidden`, `hiddenWhitelist` — UI + env vars
|
||||
- Filtrage côté client (hideHiddenFiles)
|
||||
|
||||
### Ajouté
|
||||
### Modifié
|
||||
|
||||
- `backend/attachment_indexer.py` — Scan et indexation d'images
|
||||
- `backend/image_processor.py` — Prétraitement Markdown pour les syntaxes d'images
|
||||
|
||||
@@ -31,6 +31,9 @@ RUN apt-get update \
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Copy pre-generated VERSION file (generated by build.sh before docker build)
|
||||
COPY backend/VERSION ./backend/VERSION
|
||||
|
||||
# Create non-root user for security + data directory for auth persistence
|
||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
||||
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
|
||||
|
||||
+871
@@ -0,0 +1,871 @@
|
||||
# ObsiGate
|
||||
|
||||
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : juin 2026.
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
[](https://git.dracodev.net/Projets/ObsiGate/actions)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Recherche...] [☀/🌙 Thème] ObsiGate │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENT AREA │
|
||||
│ ▼ Recettes │ 📄 Titre du fichier │
|
||||
│ 📁 Soupes │ Tags: #recette #rapide │
|
||||
│ 📄 Pizza │ [Contenu Markdown rendu] │
|
||||
│ ▼ IT │ │
|
||||
│ 📁 Docker │ │
|
||||
│ Tags Cloud │ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table des matières
|
||||
|
||||
- [Fonctionnalités](#fonctionnalites)
|
||||
- [Prérequis](#prerequis)
|
||||
- [Installation rapide](#installation-rapide)
|
||||
- [Configuration détaillée](#configuration-detaillee)
|
||||
- [Variables d'environnement](#variables-denvironnement)
|
||||
- [🔒 Authentification](#authentification)
|
||||
- [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
|
||||
- [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
|
||||
- [Rendu d'images Obsidian](#rendu-dimages-obsidian)
|
||||
- [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
|
||||
- [Utilisation](#utilisation)
|
||||
- [API](#api)
|
||||
- [Recherche avancée](#recherche-avancee)
|
||||
- [Dépannage](#depannage)
|
||||
- [Performance](#performance)
|
||||
- [Sécurité](#securite)
|
||||
- [Stack technique](#stack-technique)
|
||||
- [Architecture](#architecture)
|
||||
- [Développement](#developpement)
|
||||
- [Licence](#licence)
|
||||
- [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
|
||||
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri
|
||||
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
|
||||
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
|
||||
- **📜 Historique de recherche** : Persisté en localStorage (max 50 entrées, LIFO, dédupliqué)
|
||||
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
|
||||
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
- **📡 Server-Sent Events** : Notifications SSE pour les changements d'index avec reconnexion automatique
|
||||
- **➕ Gestion dynamique des vaults** : Ajout/suppression de vaults via API sans redémarrage
|
||||
- **🖥️ Application desktop native** : Tauri (Rust) + backend Python embarqué, sans Docker ni navigateur
|
||||
- **🐳 Docker multi-platform** : linux/amd64, linux/arm64, linux/arm/v7, linux/386
|
||||
- **🔒 Authentification** : JWT + Argon2id, sessions persistantes, contrôle d'accès par vault
|
||||
- **🛡️ Sécurité** : Rate limiting, audit log, backup automatique, redaction de secrets, headers CSP, protection path traversal, utilisateur non-root
|
||||
- **⚡ Performance** : Compression GZip, Cache-Control immutable, index inversé incrémental, search sans I/O disque
|
||||
- **❤️ Healthcheck** : Endpoint `/api/health` intégré pour Docker et monitoring
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Prérequis
|
||||
|
||||
### Système requis
|
||||
- **Docker** >= 20.10
|
||||
- **docker-compose** >= 2.0
|
||||
- **Espace disque** : ~200MB pour l'image Docker
|
||||
|
||||
### Systèmes supportés
|
||||
- Linux (Ubuntu, Debian, CentOS, etc.)
|
||||
- macOS (Intel et Apple Silicon)
|
||||
- Windows (avec Docker Desktop)
|
||||
- NAS compatibles Docker (Synology, QNAP, etc.)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Installation rapide
|
||||
|
||||
### 1. Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone https://git.dracodev.net/Projets/ObsiGate.git
|
||||
cd ObsiGate
|
||||
```
|
||||
|
||||
### 2. Configurer vos vaults et vos secrets
|
||||
|
||||
Éditez `docker-compose.yml` pour ajouter vos vaults Obsidian :
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /chemin/absolu/vers/votre/vault:/vaults/NomDeVotreVault:ro
|
||||
```
|
||||
|
||||
> **Important** : Le chemin doit être absolu et le volume en lecture seule (`:ro`)
|
||||
|
||||
Créez votre fichier `.env` pour l'authentification et les secrets :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Éditez .env pour configurer vos mots de passe et options
|
||||
```
|
||||
|
||||
> **Ne committez jamais `.env` !** Il est dans `.gitignore`. Utilisez `.env.example` comme référence.
|
||||
|
||||
### 3. Lancer l'application
|
||||
|
||||
```bash
|
||||
chmod +x build.sh # une seule fois
|
||||
./build.sh # build + déploiement en une commande
|
||||
```
|
||||
|
||||
> Options utiles : `./build.sh --help`, `./build.sh --cache` (rebuild rapide), `./build.sh --build-only` (construire sans démarrer).
|
||||
|
||||
### 4. Accéder à l'interface
|
||||
|
||||
Ouvrez votre navigateur sur : **http://localhost:2020**
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration détaillée
|
||||
|
||||
### Étape 1 : Préparation des vaults
|
||||
|
||||
1. **Localisez vos vaults Obsidian** sur votre système
|
||||
2. **Notez les chemins absolus** vers chaque dossier
|
||||
3. **Vérifiez les permissions** : Docker doit pouvoir lire ces dossiers
|
||||
|
||||
### Étape 2 : Configuration docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2020:8080" # Port local 2020 → Port conteneur 8080
|
||||
volumes:
|
||||
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
|
||||
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
|
||||
- ./data:/app/data # Persistance des données d'auth
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
- VAULT_1_PATH=/vaults/Recettes
|
||||
- VAULT_2_NAME=IT
|
||||
- VAULT_2_PATH=/vaults/IT
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
env_file:
|
||||
- .env # Contient OBSIGATE_ADMIN_PASSWORD et autres secrets
|
||||
```
|
||||
|
||||
### Étape 3 : Build & déploiement
|
||||
|
||||
```bash
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
**Alternative manuelle :**
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Compatibilité Docker** : l'image utilise une variante minimale d'`uvicorn` et `fastapi 0.110.3` afin d'éviter les dépendances optionnelles natives (`watchfiles`, `uvloop`, …) qui peuvent échouer au build sur Alpine, ARM ou i386.
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Variables d'environnement
|
||||
|
||||
Les vaults sont configurées par paires `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1, 2, 3…) :
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|----------|-------------|---------|
|
||||
| `VAULT_1_NAME` | Nom affiché de la vault | `Recettes` |
|
||||
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
|
||||
| `VAULT_1_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `06_Boite_a_Outils/6.2_Attachments` |
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Scan d'images au démarrage (défaut : true) | `true` |
|
||||
|
||||
**Règles de nommage :** lettres, chiffres et tirets uniquement ; pas d'espaces ; le nom doit correspondre au chemin dans le conteneur.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Authentification
|
||||
|
||||
> **Désactivée par défaut** — Compatible avec toutes les installations existantes.
|
||||
|
||||
Système optionnel basé sur **JWT + Argon2id** avec contrôle d'accès par vault.
|
||||
|
||||
### Activer l'authentification
|
||||
|
||||
1. `cp .env.example .env`
|
||||
2. Éditez `.env` :
|
||||
```bash
|
||||
OBSIGATE_AUTH_ENABLED=true
|
||||
OBSIGATE_ADMIN_USER=admin
|
||||
OBSIGATE_ADMIN_PASSWORD=votre_mot_de_passe # Laissez vide = auto-généré (voir logs)
|
||||
# OBSIGATE_SECURE_COOKIES=false # true si derrière HTTPS
|
||||
```
|
||||
3. Dans `docker-compose.yml` : `env_file: - .env`
|
||||
|
||||
> **Ne mettez jamais de mot de passe dans `docker-compose.yml` !** Utilisez toujours `.env`.
|
||||
|
||||
### Premier démarrage
|
||||
|
||||
Si aucun utilisateur n'existe, ObsiGate crée automatiquement un compte admin et **affiche le mot de passe une seule fois dans les logs** :
|
||||
|
||||
```bash
|
||||
docker-compose logs obsigate | grep -A4 "PREMIER"
|
||||
```
|
||||
|
||||
### Gestion des utilisateurs via CLI
|
||||
|
||||
```bash
|
||||
# Créer un utilisateur
|
||||
docker exec obsigate python backend/create_admin.py create alice MonMotDePasse --role user --vaults Recettes IT
|
||||
|
||||
# Créer un admin avec accès total
|
||||
docker exec obsigate python backend/create_admin.py create bob SecretPass --role admin --vaults "*"
|
||||
|
||||
# Lister / supprimer
|
||||
docker exec obsigate python backend/create_admin.py list
|
||||
docker exec obsigate python backend/create_admin.py delete alice
|
||||
```
|
||||
|
||||
### Interface d'administration
|
||||
|
||||
Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, création/édition/suppression d'utilisateurs, assignation des vaults, activation/désactivation de comptes.
|
||||
|
||||
### Contrôle d'accès par vault
|
||||
|
||||
| Valeur vaults | Accès |
|
||||
|---------------|-------|
|
||||
| `["*"]` | Toutes les vaults (y compris futures) — défaut admin |
|
||||
| `["Recettes", "IT"]` | Uniquement ces vaults |
|
||||
| `[]` | Aucun accès |
|
||||
|
||||
### Variables d'environnement d'auth
|
||||
|
||||
| Variable | Description | Défaut |
|
||||
|----------|-------------|--------|
|
||||
| `OBSIGATE_AUTH_ENABLED` | Activer l'authentification | `false` |
|
||||
| `OBSIGATE_ADMIN_USER` | Nom de l'admin auto-créé | `admin` |
|
||||
| `OBSIGATE_ADMIN_PASSWORD` | Mot de passe admin (vide = auto-généré) | *(auto)* |
|
||||
| `OBSIGATE_SECURE_COOKIES` | Cookie `Secure` (HTTPS uniquement) | `false` |
|
||||
| `OBSIGATE_ACCESS_TOKEN_TTL` | Durée de vie token JWT (secondes) | `3600` |
|
||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie refresh token (secondes) | `2592000` |
|
||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
|
||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (secondes) | `900` |
|
||||
|
||||
### Volume pour la persistance
|
||||
|
||||
Les données d'auth (`users.json`, `secret.key`) sont stockées dans `/app/data` :
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data # Persistance des utilisateurs et clé JWT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ➕ Ajouter une nouvelle vault
|
||||
|
||||
### Méthode 1 : Édition directe
|
||||
|
||||
1. `docker-compose down`
|
||||
2. Ajoutez le volume : `- /nouveau/chemin/vault:/vaults/NouvelleVault:ro`
|
||||
3. Ajoutez les variables : `VAULT_4_NAME=NouvelleVault` + `VAULT_4_PATH=/vaults/NouvelleVault`
|
||||
4. Redémarrez : `./build.sh`
|
||||
|
||||
### Méthode 2 : Hot-reload (recommandé)
|
||||
|
||||
1. Ajoutez volume + variables comme ci-dessus
|
||||
2. `./build.sh`
|
||||
3. `curl http://localhost:2020/api/index/reload`
|
||||
|
||||
### Méthode 3 : API dynamique (sans redémarrage)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:2020/api/vaults/add \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "NouvelleVault", "path": "/vaults/NouvelleVault"}'
|
||||
|
||||
curl -X DELETE http://localhost:2020/api/vaults/NouvelleVault
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔨 Build & déploiement avec build.sh
|
||||
|
||||
### Utilisation de base
|
||||
|
||||
```bash
|
||||
chmod +x build.sh # une seule fois
|
||||
./build.sh # build from scratch + démarrage
|
||||
```
|
||||
|
||||
### Options disponibles
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--help`, `-h` | Affiche l'aide complète |
|
||||
| `--build-only` | Construit l'image sans démarrer le conteneur |
|
||||
| `--no-cache` | Rebuild complet sans cache Docker **(défaut)** |
|
||||
| `--cache` | Utilise le cache Docker (plus rapide si peu de changements) |
|
||||
| `--progress=plain` | Sortie verbeuse (recommandé pour le debug) |
|
||||
| `--progress=tty` | Sortie interactive avec barres de progression |
|
||||
|
||||
### Ce que fait le script
|
||||
|
||||
1. **Vérifie** Docker et Docker Compose (versions)
|
||||
2. **Valide** `docker-compose.yml` (présence + syntaxe)
|
||||
3. **Vérifie chaque volume** monté (avertit si la source n'existe pas)
|
||||
4. **Construit** l'image Docker (multi-stage, ~180MB)
|
||||
5. **Démarre** le conteneur (`docker compose up -d`)
|
||||
6. **Affiche** statut + logs en temps réel
|
||||
|
||||
### Arrêter / redémarrer
|
||||
|
||||
```bash
|
||||
docker compose down # Arrêter
|
||||
docker compose up -d # Redémarrer sans rebuild
|
||||
docker compose logs -f # Voir les logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Rendu d'images Obsidian
|
||||
|
||||
ObsiGate supporte **toutes les syntaxes d'images Obsidian** avec résolution intelligente multi-stratégies.
|
||||
|
||||
### Syntaxes supportées
|
||||
|
||||
1. **Markdown standard avec attributs HTML** : `[<img width="180" src="path/to/image.svg"/>](https://example.com)`
|
||||
2. **Wiki-link embed chemin complet** : `![[06_Boite_a_Outils/6.2_Attachments/image.svg]]`
|
||||
3. **Wiki-link embed nom de fichier** : `![[image.svg]]`
|
||||
4. **Markdown standard** : ``
|
||||
|
||||
### Résolution intelligente (7 stratégies, par priorité)
|
||||
|
||||
1. Chemin absolu
|
||||
2. Dossier d'attachements configuré (`VAULT_N_ATTACHMENTS_PATH`)
|
||||
3. Index de démarrage (match unique)
|
||||
4. Même répertoire que le fichier markdown
|
||||
5. Racine du vault
|
||||
6. Index de démarrage (match le plus proche)
|
||||
7. Fallback : placeholder stylisé `[image not found: filename.ext]`
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- VAULT_1_NAME=MonVault
|
||||
- VAULT_1_PATH=/vaults/MonVault
|
||||
- VAULT_1_ATTACHMENTS_PATH=Assets/Images # Chemin relatif
|
||||
- VAULT_1_SCAN_ATTACHMENTS=true # Activer le scan (défaut)
|
||||
```
|
||||
|
||||
### Rescan manuel
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:2020/api/attachments/rescan/MonVault
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop (Tauri) — Application native
|
||||
|
||||
ObsiGate Desktop est une application native construite avec [Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend Python et le frontend dans un exécutable standalone — zéro Docker, zéro ligne de commande.
|
||||
|
||||
> 🚧 **Version 2.0.0 — binaires en cours de stabilisation.** Pour l'instant, le build depuis les sources est recommandé.
|
||||
|
||||
### Fonctionnalités desktop natives
|
||||
|
||||
| Fonctionnalité | Web | Desktop |
|
||||
|---|---|---|
|
||||
| Accès fichiers local | Via upload | Natif (sélecteur dossier) |
|
||||
| Thème système | Manuel | Auto (suit OS dark/light) |
|
||||
| Notifications | Service Worker | Natif OS |
|
||||
| Associations `.md` | ❌ | ✅ « Ouvrir avec ObsiGate » |
|
||||
| Tray icon | ❌ | ✅ Barre des tâches |
|
||||
| Auto-update | ❌ | ✅ Vérifie les releases Gitea |
|
||||
| Mode hors-ligne | Limité | Complet (backend local) |
|
||||
|
||||
### Téléchargement (binaires pré-buildés)
|
||||
|
||||
Les releases sont publiées sur [Gitea](https://git.dracodev.net/Projets/ObsiGate/releases) :
|
||||
|
||||
| Plateforme | Format |
|
||||
|---|---|
|
||||
| **Linux** | `.deb` + `.AppImage` |
|
||||
| **Windows** | `.msi` + `.exe` (NSIS) |
|
||||
|
||||
```bash
|
||||
# Linux — .deb (Debian/Ubuntu/Deepin)
|
||||
sudo dpkg -i obsigate_2.0.0_amd64.deb
|
||||
# Linux — .AppImage (toute distrib)
|
||||
chmod +x ObsiGate_2.0.0_amd64.AppImage && ./ObsiGate_2.0.0_amd64.AppImage
|
||||
```
|
||||
|
||||
```cmd
|
||||
REM Windows : double-cliquer sur ObsiGate_2.0.0_x64.msi (ou le setup NSIS)
|
||||
```
|
||||
|
||||
### Démarrage
|
||||
|
||||
1. **Lancez l'application** depuis le menu ou la ligne de commande
|
||||
2. Le backend Python démarre automatiquement sur `127.0.0.1:17890` (splash « Démarrage… » pendant le boot)
|
||||
3. La fenêtre s'ouvre et charge l'interface ObsiGate
|
||||
4. **Premier lancement** : sélectionnez le dossier de vos vaults Obsidian via le sélecteur natif
|
||||
5. Pour fermer : icône tray → Quitter (arrêt propre du backend)
|
||||
|
||||
### Build depuis les sources
|
||||
|
||||
Guide détaillé : [desktop/README.md](./desktop/README.md).
|
||||
|
||||
#### Prérequis communs
|
||||
|
||||
| Outil | Version | Installation |
|
||||
|---|---|---|
|
||||
| Rust (cargo) | ≥ 1.75 | `rustup` |
|
||||
| Tauri CLI | ≥ 2.0 | `cargo install tauri-cli` |
|
||||
| Git | — | — |
|
||||
| Dépendances système Linux | — | `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev` |
|
||||
|
||||
> **Important — staging :** `tauri.conf.json` embarque `backend/**` et `frontend/**` **depuis le dossier `desktop/`**.
|
||||
> Les scripts de build copient automatiquement `../backend` et `../frontend` dans `desktop/` avant `cargo tauri build`.
|
||||
> Sans ce staging, le build échoue avec « glob pattern backend/**/* path not found ».
|
||||
|
||||
#### 🪟 Windows — `build-windows.bat`
|
||||
|
||||
```cmd
|
||||
REM Prérequis (via Scoop) : rustup, curl, git
|
||||
scoop install rustup curl git
|
||||
rustup default stable
|
||||
cargo install tauri-cli
|
||||
|
||||
cd desktop
|
||||
build-windows.bat
|
||||
```
|
||||
|
||||
Étapes du script :
|
||||
|
||||
1. Tue les processus Python résiduels (`taskkill /F /IM python.exe`)
|
||||
2. Télécharge **Python 3.11 embed** (python.org) → `desktop\python-embed\` + activation de pip (`python311._pth`)
|
||||
3. `pip install -r ..\backend\requirements.txt` dans l'embed
|
||||
4. **Staging** : copie `..\backend` et `..\frontend` dans `desktop\`
|
||||
5. `cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis`
|
||||
6. Copie `python-embed` à côté de l'exécutable (`target\x86_64-pc-windows-msvc\release\`) pour le mode dev local
|
||||
7. Nettoie les dossiers stagés
|
||||
|
||||
→ **Artefact :** `desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis\ObsiGate_2.0.0_x64-setup.exe`
|
||||
|
||||
#### 🐧 Linux — `build-linux.sh`
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
chmod +x build-linux.sh
|
||||
./build-linux.sh
|
||||
```
|
||||
|
||||
Étapes du script :
|
||||
|
||||
1. Vérifie Rust + Tauri CLI, installe les dépendances système (apt)
|
||||
2. Crée un venv Python `desktop/python-embed/venv` + `pip install -r ../backend/requirements.txt`
|
||||
3. **Staging** : copie `../backend` et `../frontend` dans `desktop/`
|
||||
4. `cargo tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage`
|
||||
5. Copie le runtime (`python-embed/`, `backend/`, `frontend/`) à côté de l'exécutable
|
||||
|
||||
→ **Artefacts :**
|
||||
|
||||
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/deb/obsigate_2.0.0_amd64.deb`
|
||||
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/appimage/ObsiGate_2.0.0_amd64.AppImage`
|
||||
|
||||
### 🤖 Builds CI/CD — artefacts automatiques
|
||||
|
||||
**Oui** — le workflow [`.gitea/workflows/desktop-build.yml`](./.gitea/workflows/desktop-build.yml) construit les binaires desktop à chaque push sur `main` touchant `desktop/**`, `frontend/**` ou `backend/**` (et manuellement via `workflow_dispatch`), sur des **runners self-hosted** :
|
||||
|
||||
| Job | Runner | Artefacts (conservés 30 jours) |
|
||||
|---|---|---|
|
||||
| `build-windows` | `[self-hosted, windows, desktop]` | `desktop/target/release/bundle/msi/*.msi` |
|
||||
| `build-linux` | `[self-hosted, linux, desktop]` | `*.AppImage` + `*.deb` |
|
||||
|
||||
- Les artefacts sont téléchargeables depuis la page **Actions** du run Gitea.
|
||||
- La publication en **Gitea Release** est prévue sur les tags `v*` (étape `Publish to Gitea Release`).
|
||||
- Le workflow web [`.gitea/workflows/ci.yml`](./.gitea/workflows/ci.yml) gère de son côté lint → tests → sécurité → build Docker → e2e Playwright.
|
||||
|
||||
### Architecture Desktop
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Tauri (Rust) │
|
||||
│ ├─ Webview (webview système) │
|
||||
│ │ └─ Frontend (HTML/JS/CSS) │
|
||||
│ └─ Sidecar Python │
|
||||
│ └─ uvicorn backend.main:app │
|
||||
│ └─ port 127.0.0.1:17890 │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Cycle de vie : Tauri spawn le backend Python → health check → splash de démarrage → redirection vers la webview. À la fermeture : kill propre du backend.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Utilisation
|
||||
|
||||
### Interface web
|
||||
|
||||
1. **Navigation** : Cliquez sur les vaults dans la sidebar pour les développer
|
||||
2. **Recherche** : Utilisez la barre de recherche pour chercher dans toutes les vaults
|
||||
3. **Tags** : Cliquez sur les tags pour filtrer les contenus
|
||||
4. **Wikilinks** : Les liens `[[page]]` sont cliquables et navigables
|
||||
5. **Images** : Toutes les syntaxes d'images Obsidian sont rendues automatiquement
|
||||
6. **Thème** : Basculez entre thème clair/sombre avec l'icône 🌙/☀️
|
||||
|
||||
### Raccourcis clavier
|
||||
|
||||
| Action | Raccourci |
|
||||
|--------|-----------|
|
||||
| Recherche | `Ctrl + K` ou `/` |
|
||||
| Toggle thème | `Ctrl + T` |
|
||||
| Focus recherche | `Esc` |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API
|
||||
|
||||
ObsiGate expose une API REST complète :
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|----------|-------------|---------|------|
|
||||
| `/api/health` | Health check (status, version, stats) | GET | Non |
|
||||
| `/api/auth/status` | Statut auth (activé, utilisateurs présents) | GET | Non |
|
||||
| `/api/auth/login` | Connexion (access token + cookie refresh) | POST | Non |
|
||||
| `/api/auth/refresh` | Renouveler l'access token via cookie refresh | POST | Cookie |
|
||||
| `/api/auth/logout` | Déconnexion + révocation refresh token | POST | Oui |
|
||||
| `/api/auth/me` | Infos utilisateur courant | GET | Oui |
|
||||
| `/api/auth/change-password` | Changer son mot de passe | POST | Oui |
|
||||
| `/api/auth/admin/users` | Lister / créer des utilisateurs | GET/POST | Admin |
|
||||
| `/api/auth/admin/users/{u}` | Modifier / supprimer un utilisateur | PATCH/DELETE | Admin |
|
||||
| `/api/vaults` | Liste des vaults (filtrée par permissions) | GET | Oui |
|
||||
| `/api/browse/{vault}?path=` | Navigation dans les dossiers | GET | Oui |
|
||||
| `/api/file/{vault}?path=` | Contenu rendu d'un fichier | GET | Oui |
|
||||
| `/api/file/{vault}/raw?path=` | Contenu brut d'un fichier | GET | Oui |
|
||||
| `/api/file/{vault}/download?path=` | Téléchargement d'un fichier | GET | Oui |
|
||||
| `/api/file/{vault}/save?path=` | Sauvegarder un fichier | PUT | Oui |
|
||||
| `/api/file/{vault}?path=` | Supprimer un fichier | DELETE | Oui |
|
||||
| `/api/search/advanced` | Recherche avancée TF-IDF | GET | Oui |
|
||||
| `/api/suggest` / `/api/tags/suggest` | Autocomplétion | GET | Oui |
|
||||
| `/api/tags?vault=` | Tags uniques avec compteurs | GET | Oui |
|
||||
| `/api/index/reload` | Force un re-scan des vaults | GET | Admin |
|
||||
| `/api/events` | Flux SSE temps réel | GET | Oui |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Gestion dynamique des vaults | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
|
||||
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
|
||||
| `/api/diagnostics` | Statistiques index et mémoire | GET | Admin |
|
||||
|
||||
> Quand `OBSIGATE_AUTH_ENABLED=false`, tous les endpoints sont accessibles sans token.
|
||||
> Tous les endpoints exposent des schémas Pydantic documentés ; doc interactive sur `/docs` (Swagger UI).
|
||||
|
||||
**Exemples :**
|
||||
|
||||
```bash
|
||||
curl http://localhost:2020/api/health
|
||||
curl http://localhost:2020/api/vaults
|
||||
curl "http://localhost:2020/api/search/advanced?q=recette%20tag:cuisine&vault=all&limit=20&offset=0&sort=relevance"
|
||||
curl "http://localhost:2020/api/suggest?q=piz&vault=all"
|
||||
curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Recherche avancée
|
||||
|
||||
### Syntaxe de requête
|
||||
|
||||
| Opérateur | Description | Exemple |
|
||||
|-----------|-------------|---------|
|
||||
| `tag:<nom>` | Filtrer par tag | `tag:recette docker` |
|
||||
| `#<nom>` | Raccourci tag | `#linux serveur` |
|
||||
| `vault:<nom>` | Filtrer par vault | `vault:IT kubernetes` |
|
||||
| `title:<texte>` | Filtrer par titre | `title:pizza` |
|
||||
| `path:<texte>` | Filtrer par chemin | `path:recettes/soupes` |
|
||||
| `ext:<type>` | Filtrer par type de fichier | `ext:md kubernetes` |
|
||||
| `"phrase exacte"` | Recherche de phrase | `tag:"multi mots"` |
|
||||
|
||||
Les opérateurs sont combinables : `tag:linux vault:IT ext:md serveur web`.
|
||||
|
||||
### Raccourcis clavier
|
||||
|
||||
| Raccourci | Action |
|
||||
|-----------|--------|
|
||||
| `Ctrl+K` / `Cmd+K` | Focaliser la barre de recherche |
|
||||
| `/` | Focaliser la recherche (hors champ texte) |
|
||||
| `↑` / `↓` | Naviguer dans les suggestions |
|
||||
| `Enter` | Sélectionner la suggestion active ou lancer la recherche |
|
||||
| `Escape` | Fermer les suggestions / quitter la recherche |
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
- **TF-IDF** : scoring par fréquence pondérée des termes
|
||||
- **Boost titre** : correspondances dans le titre ×3
|
||||
- **Normalisation des accents** : `resume` trouve `résumé`
|
||||
- **Snippets surlignés** (`<mark>`), **facettes** (compteurs par vault/tag), **pagination** (50/page), **tri** pertinence/date, **chips** de filtres, **historique** (50 recherches)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Dépannage
|
||||
|
||||
**Port déjà utilisé :**
|
||||
|
||||
```bash
|
||||
sudo netstat -tulpn | grep 2020
|
||||
# Changer le port dans docker-compose.yml : ports: - "2021:8080"
|
||||
```
|
||||
|
||||
**Vault non trouvée :** chemins absolus, permissions de lecture, redémarrer le conteneur après modification.
|
||||
|
||||
**Build échoue :**
|
||||
|
||||
```bash
|
||||
docker system prune -f
|
||||
docker compose down
|
||||
./build.sh --progress=plain
|
||||
# Si l'échec persiste :
|
||||
./build.sh --progress=plain 2>&1 | tee build.log
|
||||
```
|
||||
|
||||
**Logs pour debugging :**
|
||||
|
||||
```bash
|
||||
docker compose logs -f obsigate
|
||||
docker compose logs --tail=100 obsigate
|
||||
```
|
||||
|
||||
**Desktop :** les logs du backend sont dans `%APPDATA%\ObsiGate\logs\backend.log` (Windows) / `~/.config/obsigate/logs/backend.log` (Linux).
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
| Métrique | Estimation |
|
||||
|----------|------------|
|
||||
| **Indexation** | ~1–2s pour 1 000 fichiers markdown |
|
||||
| **Recherche avancée** | < 10ms pour la plupart des requêtes (index inversé + TF-IDF) |
|
||||
| **Résolution wikilinks** | O(1) via table de lookup |
|
||||
| **Mémoire** | ~80–150MB par 1 000 fichiers (contenu capé à 100 KB/fichier) |
|
||||
| **Image Docker** | ~180MB (multi-stage) |
|
||||
| **CPU** | Non-bloquant ; recherche offloadée sur thread pool dédié |
|
||||
|
||||
### Paramètres recommandés par taille de vault
|
||||
|
||||
| Taille | Fichiers | `search_workers` | `prefix_max_expansions` | `max_content_size` |
|
||||
|--------|----------|-------------------|--------------------------|---------------------|
|
||||
| Petit | < 500 | 1 | 50 | 100 000 |
|
||||
| Moyen | 500–5 000 | 2 | 50 | 100 000 |
|
||||
| Grand | 5 000+ | 4 | 30 | 50 000 |
|
||||
|
||||
Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
|
||||
### Optimisations clés
|
||||
|
||||
- **Index inversé avec set-intersection** + prefix matching par recherche binaire
|
||||
- **ThreadPoolExecutor** : recherche CPU-bound hors de l'event loop asyncio
|
||||
- **InvertedIndex incrémental** : hooks `add_document`/`remove_document`, plus de rebuild O(N)
|
||||
- **Compression GZip** (~70% de bande passante économisée) + **Cache-Control immutable** (1 an)
|
||||
- **Race condition guard** (`currentSearchId` + AbortController), progress bar, timeout 30s
|
||||
- **Rendu Markdown singleton**, debounced icon rendering, recherche sans I/O disque
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Sécurité
|
||||
|
||||
- **Path traversal** : tous les endpoints fichier valident que le chemin résolu reste dans la vault
|
||||
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
||||
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
||||
- **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp
|
||||
- **Secret redaction** : masquage automatique des JWT, clés API, tokens dans les aperçus
|
||||
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
||||
- **Atomic writes** : tmp+replace pour users.json, shares.json, webhooks.json
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Stack technique
|
||||
|
||||
- **Backend** : Python 3.11 + FastAPI 0.110 + Uvicorn
|
||||
- **Auth** : python-jose (JWT HS256) + argon2-cffi (Argon2id)
|
||||
- **File Watcher** : watchdog 4.x (inotify natif + fallback polling)
|
||||
- **Frontend** : Vanilla JS + HTML + CSS (zéro framework, zéro build)
|
||||
- **Rendu Markdown** : mistune 3.x
|
||||
- **PDF Export** : WeasyPrint 60+
|
||||
- **Desktop** : Tauri v2 (Rust) + Python embarqué
|
||||
- **Image Docker** : python:3.11-slim (multi-stage)
|
||||
- **Stockage utilisateurs** : JSON local (`data/users.json`) — aucune base de données
|
||||
- **Architecture** : SPA + API REST + SSE
|
||||
|
||||
---
|
||||
|
||||
## 🏠 Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────────────────────┐
|
||||
│ Navigateur │◄───►│ FastAPI (backend/main.py) │
|
||||
│ (SPA) │ REST │ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ │ │ indexer.py │ │ search.py │ │
|
||||
│ app.js │ │ │ (scan+cache) │ │ (in-memory) │ │
|
||||
│ style.css │ │ └───────┬────── └──────┬───────┘ │
|
||||
│ index.html │ │ │ │ │
|
||||
└─────────────────┘ │ ┌───┴──────────────┴───┐ │
|
||||
│ │ Index en mémoire │ │
|
||||
│ │ (fichiers, tags, │ │
|
||||
│ │ contenu, lookup) │ │
|
||||
│ └───────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Flux de données :**
|
||||
|
||||
1. Au démarrage, `indexer.py` scanne tous les vaults en parallèle (thread pool, non-bloquant)
|
||||
2. Contenu, tags (YAML + inline) et métadonnées mis en cache en mémoire
|
||||
3. Table de lookup O(1) pour la résolution des wikilinks
|
||||
4. `watcher.py` surveille les fichiers (watchdog natif ou polling)
|
||||
5. Modifications → mise à jour incrémentale de l'index
|
||||
6. Changements notifiés au frontend via SSE
|
||||
7. Recherche sur l'index mémoire (zéro I/O disque)
|
||||
8. Le frontend SPA communique via REST + SSE
|
||||
|
||||
---
|
||||
|
||||
## 📝 Développement
|
||||
|
||||
### 🧪 Tests et CI — règles obligatoires (avant chaque push)
|
||||
|
||||
> ⚠️ **Règle absolue — contributeurs humains et agents IA** : ne jamais faire
|
||||
> de `git push` sans avoir exécuté **localement** la séquence complète
|
||||
> ci-dessous avec **100 % de succès**. Le push ne sert pas à découvrir les
|
||||
> échecs : la CI est un filet de sécurité, pas un outil de diagnostic.
|
||||
|
||||
#### Vérifications locales avant commit
|
||||
|
||||
| Étape | Commande | Job CI équivalent |
|
||||
|---|---|---|
|
||||
| Lint backend | `ruff check backend/` | `lint` |
|
||||
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Tests backend | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
|
||||
#### Tests E2E locaux (`npm run test:e2e`)
|
||||
|
||||
Le script `scripts/run-e2e-local.sh` reproduit fidèlement les conditions du
|
||||
job CI `e2e` (`.gitea/workflows/ci.yml`) :
|
||||
|
||||
- backend démarré nativement (venv `.venv-e2e`, Python 3.11 via `uv`) ;
|
||||
- authentification désactivée (`OBSIGATE_AUTH_ENABLED=false`) ;
|
||||
- fixtures `test_vault` (TestVault) et `test_dir` (TestDir) ;
|
||||
- port `2029` (baseURL de `playwright.config.ts`) ;
|
||||
- projet Playwright `chromium-desktop` uniquement (comme la CI).
|
||||
|
||||
Prérequis : `uv`, Node.js ≥ 20, navigateurs Playwright
|
||||
(`npx playwright install chromium`). Le venv est créé automatiquement au
|
||||
premier lancement. Docker n'est pas nécessaire.
|
||||
|
||||
```bash
|
||||
npm run test:e2e # suite complète
|
||||
bash scripts/run-e2e-local.sh --headed # navigateur visible
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
|
||||
```
|
||||
|
||||
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
|
||||
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
|
||||
localement jusqu'à 100 %, puis seulement commiter.
|
||||
|
||||
#### Règles d'écriture des tests (leçon apprise)
|
||||
|
||||
- **Vérifier chaque sélecteur dans le DOM réel** avant de l'utiliser dans un
|
||||
test. Bug réel rencontré : un test ciblait `#cp-input` alors que l'input de
|
||||
la palette porte uniquement la classe `.cp-input` (aucun id) — le test ne
|
||||
pouvait passer dans aucun environnement.
|
||||
- Tout test nouveau ou modifié doit être **exécuté localement** (au minimum
|
||||
avec `-g "nom du test"`) avant le push.
|
||||
- Pas de contournements qui masquent une instabilité (`waitForTimeout`
|
||||
arbitraires, fallback silencieux) : traiter la cause racine.
|
||||
|
||||
#### Commits et push
|
||||
|
||||
- Message de commit : sujet à l'impératif ≤ 50 caractères ; corps (72 col.)
|
||||
uniquement s'il apporte une information utile.
|
||||
- Un commit = un changement logique (ne pas mêler fix et refactoring).
|
||||
- Push uniquement quand les 5 étapes locales sont vertes. Pipeline CI :
|
||||
`lint → test → security → build → e2e`.
|
||||
|
||||
### Structure du projet
|
||||
|
||||
```
|
||||
ObsiGate/
|
||||
├── backend/ # API FastAPI
|
||||
│ ├── main.py # Endpoints, Pydantic models, rendu markdown
|
||||
│ ├── indexer.py # Scan des vaults, index en mémoire, lookup table
|
||||
│ ├── search.py # Moteur de recherche fulltext avec scoring
|
||||
│ ├── watcher.py # Surveillance fichiers (watchdog + debounce)
|
||||
│ ├── auth/ # Module d'authentification (JWT + Argon2id)
|
||||
│ └── create_admin.py # CLI gestion utilisateurs
|
||||
├── frontend/ # Interface web (Vanilla JS, zéro framework)
|
||||
│ ├── index.html # Page SPA + écran de login + splash de boot
|
||||
│ ├── js/ # Modules ES (app, viewer, editor, graph, …)
|
||||
│ └── style.css # Styles (CSS variables, thèmes, responsive)
|
||||
├── desktop/ # Application native Tauri (Rust + Python embarqué)
|
||||
│ ├── src/main.rs # Shell natif : cycle de vie backend, tray, menus
|
||||
│ ├── build-windows.bat
|
||||
│ └── build-linux.sh
|
||||
├── data/ # Données persistantes (créé au démarrage)
|
||||
├── Dockerfile # Multi-stage, healthcheck, non-root
|
||||
├── docker-compose.yml # Déploiement avec healthcheck et auth env vars
|
||||
├── build.sh # Build & déploiement automatisé
|
||||
└── docs/ # ROADMAP, guides, audits
|
||||
```
|
||||
|
||||
### Contribuer
|
||||
|
||||
Voir [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md) pour les détails.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licence
|
||||
|
||||
Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour les détails.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
- **Issues** : [git.dracodev.net/Projets/ObsiGate/issues](https://git.dracodev.net/Projets/ObsiGate/issues)
|
||||
- **Documentation** : [git.dracodev.net/Projets/ObsiGate/wiki](https://git.dracodev.net/Projets/ObsiGate/wiki)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.0.0-dev).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.0.0-dev | Dernière mise à jour : Juin 2026*
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 1.8.0 | **Dernière mise à jour :** 2026-06-03
|
||||
> Voir aussi [CHANGELOG.md](./CHANGELOG.md), [docs/ROADMAP.md](./docs/ROADMAP.md), [AUDIT_TECHNIQUE.md](./docs/AUDIT_TECHNIQUE_2026-05-27.md)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Complété (v1.0.0 → v1.7.0)
|
||||
|
||||
### AI Editor (v1.7.0)
|
||||
- [x] Toolbar dropdown menus : Edit, Tone, Translate, Generate, Rewrite, Toolbox
|
||||
- [x] Multi-provider : DeepSeek, OpenRouter, Gemini
|
||||
- [x] 16 endpoints REST `/api/ai/{action}` + backend `ai.py` / `ai_routes.py`
|
||||
- [x] Auto-save silencieux (2s debounce), loading toasts, cache toolbar si pas de clé API
|
||||
- [x] Messages d'erreur clairs (401 → clé invalide, etc.)
|
||||
|
||||
### Vue graphe — Phases 3 & 4 (v1.6.0)
|
||||
- [x] Filtre par type (dossier/fichier/.md/autre), mode focus, plein écran, export PNG
|
||||
- [x] Barnes-Hut O(n log n), cache graphe
|
||||
- [x] Header flat design, sticky panels, navigation historique ← → ↑
|
||||
- [x] Ctrl+survol → aperçu contenu formaté
|
||||
|
||||
### Performance & CI/CD (v1.6.0)
|
||||
- [x] `sortedcontainers` — O(log n) insert/remove dans l'index inversé
|
||||
- [x] CI/CD Pipeline Gitea Actions (lint → test → security → build)
|
||||
- [x] Ruff linting — 0 erreur
|
||||
- [x] Mypy type checking — 0 erreur
|
||||
- [x] Pytest — **284 tests, 70% coverage** (main.py 61%, router.py 67%, watcher.py 62%)
|
||||
- [x] Tests API main.py (84 tests), Auth API (13 tests), Watcher mocké (14 tests)
|
||||
- [x] Bandit SAST + Pip-audit + Docker build + coverage artifact
|
||||
|
||||
### Split app.js en modules ES (v1.5.1)
|
||||
- [x] 8 875 lignes → 16 modules dans `frontend/js/`
|
||||
- [x] Validateur imports/exports CI
|
||||
- [x] Tests unitaires frontend (Node.js)
|
||||
|
||||
### Stemming français (v1.5.1)
|
||||
- [x] `snowballstemmer` — recherche accent-insensitive et morphologique
|
||||
- [x] Performance O(T) au lieu de O(S×T), crash guard IndexError
|
||||
|
||||
### Fonctionnalités avancées (v1.5.0)
|
||||
- [x] Publication publique de documents (lien partageable avec token)
|
||||
- [x] Webhooks HTTP avec signature HMAC-SHA256
|
||||
- [x] Dashboard statistiques (fichiers, tags, taille, vaults)
|
||||
- [x] Documentation OpenAPI enrichie (Swagger + Redoc)
|
||||
- [x] Gestion des conflits Syncthing
|
||||
- [x] Index inversé incrémental (hook pattern)
|
||||
|
||||
### Sécurité (v1.4.0)
|
||||
- [x] JWT + Argon2id
|
||||
- [x] Rate limiting login (IP + compte)
|
||||
- [x] Audit log avec rotation
|
||||
- [x] Backup automatique avant écriture
|
||||
- [x] Redaction de secrets
|
||||
- [x] Headers CSP
|
||||
- [x] Protection path traversal
|
||||
- [x] Utilisateur non-root Docker
|
||||
|
||||
### UX / Qualité
|
||||
- [x] `.dockerignore`, `.env` → `.env.example`, Ruff config, Mypy config
|
||||
- [x] Compression GZip (SSE-safe), Cache-Control immutable
|
||||
- [x] Recherche sans I/O disque
|
||||
- [x] Backlinks panel
|
||||
- [x] Fichiers non-supportés (UI download)
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Prochaines étapes (par priorité)
|
||||
|
||||
### ✅ 1. Tests & Couverture — FAIT
|
||||
- Coverage **49% → 70%** ✅
|
||||
- ~~Cibles : main.py (26%), watcher.py (23%), router.py (37%)~~ — **FAIT**
|
||||
- ~~Tests E2E Playwright (fondations)~~ — reste à faire
|
||||
|
||||
### ✅ 2. Export PDF — FAIT
|
||||
- WeasyPrint déjà intégré ✅
|
||||
- Route API `/api/file/{vault_name}/pdf` ✅
|
||||
- Bouton UI "PDF" dans les actions fichier ✅
|
||||
- Export dans la vue publique `/s/{token}/pdf` ✅
|
||||
- GTK/Pango installé dans le Dockerfile ✅
|
||||
|
||||
### ✅ 3. Palette de commandes (Ctrl+ALT+SPACE) — FAIT
|
||||
- Navigation rapide fichiers + commandes (> prefix) ✅
|
||||
- Module `frontend/js/palette.js` + intégré dans app.js ✅
|
||||
|
||||
### ✅ 4. Barre d'outils mobile + Palette fichiers/commandes 📱 — FAIT
|
||||
- **Effort** : 3-5 jours | **Impact** : 🟡 UX mobile
|
||||
- **Fichiers** : `frontend/js/mobile-toolbar.js`, `frontend/index.html`, `frontend/style.css`, `frontend/js/app.js`
|
||||
|
||||
#### ✅ Phase 1 — Barre de navigation persistante
|
||||
- [x] **Bottom bar fixe** : barre collée en bas de l'écran sur mobile/tablette (responsive, breakpoint < 768px)
|
||||
- [x] **Icônes** : explorateur 📂, recherche 🔍, palette ⌨️, onglets 📑
|
||||
- [x] **Compteur d'onglets** : badge indiquant le nombre de fichiers ouverts
|
||||
- [x] **safe-area-inset-bottom** pour les iPhone avec notch
|
||||
- [x] **backdrop-filter blur** pour l'effet vitré
|
||||
|
||||
#### ✅ Phase 2 — Page d'accueil vault (quand aucun fichier ouvert)
|
||||
- [x] **Dashboard existant** réutilisé : showWelcome() affiche stats + récents + bookmarks
|
||||
- [x] **Palette accessible** depuis la barre pour ouvrir/créer des fichiers
|
||||
|
||||
#### ✅ Phase 3 — Palette de fichiers (Ctrl+P style)
|
||||
- [x] **Recherche rapide** via `/api/suggest` avec opérateurs +include -exclude
|
||||
- [x] **Raccourci** : bouton ⌨️ dans la barre du bas + tap pour fichiers, long-press pour commandes
|
||||
- [x] **Résultats** avec preview du chemin (vault/path)
|
||||
- [x] **Navigation clavier** : flèches ↑↓ + Entrée pour ouvrir, browse répertoires
|
||||
|
||||
#### ✅ Phase 4 — Palette de commandes (Ctrl+Shift+P style)
|
||||
- [x] **Liste des actions** : 12 commandes (créer/supprimer fichier/dossier, thème, réindex, etc.)
|
||||
- [x] **Long-press** sur le bouton palette → palette de commandes
|
||||
- [x] **Clic droit** sur le bouton palette → palette de commandes
|
||||
- [x] **Fuzzy search** : filtrer les commandes en tapant
|
||||
|
||||
#### ✅ Phase 5 — Gestion des fichiers ouverts (tabs)
|
||||
- [x] **Indicateur d'onglets** dans la barre du bas : badge compteur
|
||||
- [x] **Switcher rapide** : bottom sheet animé avec liste des fichiers ouverts
|
||||
- [x] **Fermeture** : bouton ✕ par onglet dans le switcher
|
||||
- [x] **Overlay** : tap à l'extérieur pour fermer le sheet
|
||||
|
||||
### ✅ 5. Drag & drop de fichiers — FAIT
|
||||
- **Effort** : 1-2 jours | **Impact** : 🟡
|
||||
- Déplacer/réorganiser dans l'arborescence ✅
|
||||
- **Fichiers** : `backend/main.py` (endpoint `POST /api/move/{vault_name}`), `frontend/js/dragdrop.js`, `frontend/js/app.js`, `frontend/style.css`
|
||||
- [x] **Endpoint backend** : `POST /api/move/{vault_name}` — déplace fichiers et dossiers vers un autre répertoire parent, même vault
|
||||
- [x] **Drag & drop frontend** : glisser-déposer les `.tree-item` (fichiers et dossiers) vers les dossiers ou la racine d'un vault
|
||||
- [x] **Indicateurs visuels** : opacité réduite sur l'élément source, outline en pointillé sur les cibles valides, surbrillance sur la cible active
|
||||
- [x] **Protection** : pas de drop sur soi-même, pas de drop dans un enfant, pas de cross-vault
|
||||
- [x] **SSE + index** : mise à jour de l'index et broadcast SSE après move
|
||||
|
||||
### ✅ 6. Diff viewer backups — FAIT
|
||||
- **Effort** : 1 jour | **Impact** : 🟢
|
||||
- Comparer versions sauvegardées dans `.obsigate-backup/` ✅
|
||||
- **Fichiers** : `backend/main.py` (3 endpoints + helpers), `frontend/js/backups.js`, `frontend/js/ui.js`, `frontend/style.css`
|
||||
- [x] **`GET /api/file/{vault}/backups`** — liste tous les backups horodatés d'un fichier (triés plus récent d'abord)
|
||||
- [x] **`GET /api/file/{vault}/diff`** — diff unifié entre deux versions (backup vs backup, ou backup vs actuel)
|
||||
- [x] **`POST /api/file/{vault}/restore`** — restaure une version backup (backup automatique de la version actuelle avant restauration)
|
||||
- [x] **UI modale** : panneau avec sélecteur gauche (backup) / droite (version actuelle ou autre backup), diff coloré (vert/rouge), bouton Restaurer avec confirmation
|
||||
- [x] **Menu contextuel** : clic droit sur un fichier → "Backups / Versions"
|
||||
- [x] **Coloration syntaxique diff** : lignes ajoutées en vert, supprimées en rouge, en-têtes hunk en accent
|
||||
|
||||
### 7. Fichiers récents par vault
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟡
|
||||
- Dashboard filtré par vault actif
|
||||
|
||||
### 8. Git tags semver
|
||||
- **Effort** : 10 min | **Impact** : 🟢
|
||||
- `git tag v1.7.0 && git push --tags`
|
||||
|
||||
### 9. Filtres search avancés
|
||||
- **Effort** : 1 jour | **Impact** : 🟡
|
||||
- Opérateurs `created:`, `modified:`, `size:`
|
||||
|
||||
### 10. Déduplication IGNORED_DIRS
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟢
|
||||
- `indexer.py` et `watcher.py` ont des définitions séparées → mutualiser
|
||||
|
||||
### ✅ 11. Indicateur AI Actif — FAIT
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟡
|
||||
- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statuts de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est active, configurée, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flash indiquant le statut de la tâche AI en cours.
|
||||
- **Fichiers** : `backend/main.py` (`/api/ai/status`), `frontend/js/ai.js`, `frontend/js/sync.js`, `frontend/style.css`
|
||||
- [x] **Badge header flash blanc** : le `#sync-badge` devient blanc avec animation flash quand une tâche AI est en cours
|
||||
- [x] **Événements** : `ai-processing-start` / `ai-processing-end` dispatchés depuis `ai.js`
|
||||
- [x] **Section IA dans panneau sync** : statut (configurée/non configurée/traitement en cours) avec icône et couleur
|
||||
- [x] **Endpoint `/api/ai/status`** : vérifie si au moins un provider AI est configuré
|
||||
|
||||
### 12. Page d'accueil de voute — FAIT (récursif)
|
||||
- **Effort** : 1 jour | **Impact** : 🟡
|
||||
- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien, récursivement (répertoire + tous les sous-répertoires). Si l'utilisateur change de répertoire dans cette voute, la liste se met à jour automatiquement. ✅
|
||||
- **Fichiers** : `backend/main.py` (`/api/vault/{vault}/files`), `frontend/js/vaulthome.js`, `frontend/js/sidebar.js`, `frontend/style.css`
|
||||
- [x] **Endpoint `/api/vault/{vault}/files?dir=...&recursive=true`** : liste les fichiers d'un répertoire et de tous ses sous-répertoires récursivement (`rglob`), triés par date de modification décroissante
|
||||
- [x] **Champ `rel_dir`** : chaque fichier indique son sous-répertoire relatif pour afficher le chemin dans les résultats
|
||||
- [x] **Affichage style résultats de recherche** : chaque fichier utilise le même look `.search-result-item` (extension, icône, titre, métadonnées avec `sous-dossier/ · date · taille`)
|
||||
- [x] **Badge « récursif »** : indicateur visuel dans le header confirmant le mode récursif
|
||||
- [x] **Breadcrumb navigable** : fil d'Ariane cliquable (vault → dossier → sous-dossier)
|
||||
- [x] **Mise à jour automatique** : quand on clique sur un dossier dans l'arborescence, la page se met à jour
|
||||
- [x] **Déclenchement** : affiché automatiquement quand un vault spécifique est sélectionné
|
||||
|
||||
### ✅ 13. Indexation non-bloquante — FAIT
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟡
|
||||
- L'étape "Building inverted index..." au démarrage bloquait l'event loop asyncio, empêchant le serveur de répondre aux requêtes HTTP (navigation, refresh) jusqu'à la fin de l'indexation.
|
||||
- **Fichiers** : `backend/main.py`, `backend/search.py`
|
||||
- [x] **`init_inverted_index()` dans `run_in_executor`** : exécuté dans le `ThreadPoolExecutor` au lieu de la boucle asyncio principale
|
||||
- [x] **`get_inverted_index()` sans rebuild auto** : ne déclenche plus de rebuild bloquant — retourne l'index vide si pas encore prêt (les recherches retournent 0 résultats jusqu'à la fin de l'indexation)
|
||||
- [x] **Navigation + refresh fonctionnels immédiatement** : le serveur répond aux requêtes HTTP dès le démarrage, même pendant l'indexation
|
||||
|
||||
---
|
||||
|
||||
## 📋 Backlog (P3/P4 — Long terme)
|
||||
|
||||
- **Mode hors-ligne PWA complet** : IndexedDB pour recherche offline + synchro
|
||||
- **OAuth2/OIDC** : Google, GitHub, authentification SSO
|
||||
- **Plugins système** : Extensions utilisateur (custom renderers, search operators)
|
||||
- **Collaboration temps réel** : WebSocket + CRDT (Yjs) pour édition simultanée
|
||||
- **i18n** : Support anglais + français
|
||||
- **MFA** : TOTP/WebAuthn
|
||||
- **Thèmes personnalisés** : CSS variables exposées
|
||||
- **Export multi-formats** : HTML, MD bundle, ePub
|
||||
- **Notifications web** : Push API pour changements de vault
|
||||
- **Health check enrichi** : État de l'index, mémoire, uptime, clients SSE
|
||||
+72
-43
@@ -4,42 +4,71 @@ Supports: DeepSeek, OpenRouter, Google Gemini.
|
||||
Configured via environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from typing import Optional, Literal
|
||||
|
||||
logger = logging.getLogger("obsigate.ai")
|
||||
|
||||
ProviderName = Literal["deepseek", "openrouter", "gemini"]
|
||||
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama"]
|
||||
|
||||
# Provider configurations
|
||||
PROVIDERS = {
|
||||
"deepseek": {
|
||||
"api_key": os.getenv("DEEPSEEK_API_KEY", "").strip(),
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"openrouter": {
|
||||
"api_key": os.getenv("OPENROUTER_API_KEY", "").strip(),
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"gemini": {
|
||||
"api_key": os.getenv("GEMINI_API_KEY", "").strip(),
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
|
||||
"auth_header": None, # Uses query param ?key=
|
||||
},
|
||||
}
|
||||
# Provider configurations — keys loaded from file or .env
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
|
||||
def _read_ai_keys() -> dict:
|
||||
if not AI_KEYS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(AI_KEYS_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_ai_key(env_name: str) -> str:
|
||||
"""Get AI key: stored file first, then .env fallback."""
|
||||
keys = _read_ai_keys()
|
||||
if keys.get(env_name):
|
||||
return keys[env_name]
|
||||
return os.getenv(env_name, "").strip()
|
||||
|
||||
def _load_provider_keys():
|
||||
"""Load AI keys from stored file, falling back to .env."""
|
||||
return {
|
||||
"deepseek": {
|
||||
"api_key": get_ai_key("DEEPSEEK_API_KEY"),
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"openrouter": {
|
||||
"api_key": get_ai_key("OPENROUTER_API_KEY"),
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"gemini": {
|
||||
"api_key": get_ai_key("GEMINI_API_KEY"),
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
|
||||
"auth_header": None,
|
||||
},
|
||||
"ollama": {
|
||||
"api_key": "ollama",
|
||||
"base_url": os.getenv("OLLAMA_BASE_URL", "http://ollama:11434/v1"),
|
||||
"model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
}
|
||||
|
||||
PROVIDERS = _load_provider_keys()
|
||||
|
||||
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
|
||||
|
||||
|
||||
def _get_provider_config(provider: Optional[ProviderName] = None) -> dict:
|
||||
def _get_provider_config(provider: ProviderName | None = None) -> dict:
|
||||
"""Get provider config, falling back to default if requested provider unavailable."""
|
||||
p = provider or DEFAULT_PROVIDER
|
||||
if p not in PROVIDERS:
|
||||
@@ -55,7 +84,7 @@ def _get_provider_config(provider: Optional[ProviderName] = None) -> dict:
|
||||
return {"name": p, **cfg}
|
||||
|
||||
|
||||
async def _call_deepseek_openrouter(prompt: str, system: str, provider: Optional[ProviderName] = None,
|
||||
async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None,
|
||||
temperature: float = 0.7, max_tokens: int = 2048) -> str:
|
||||
"""Call OpenAI-compatible API (DeepSeek, OpenRouter)."""
|
||||
cfg = _get_provider_config(provider)
|
||||
@@ -105,7 +134,7 @@ async def _call_gemini(prompt: str, system: str, temperature: float = 0.7, max_t
|
||||
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
||||
|
||||
|
||||
async def ai_complete(prompt: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str:
|
||||
"""Generic AI completion. Routes to appropriate provider."""
|
||||
cfg = _get_provider_config(provider)
|
||||
if cfg["name"] == "gemini":
|
||||
@@ -120,7 +149,7 @@ Your responses should be direct and concise. When editing text, return ONLY the
|
||||
no explanations or markdown fences."""
|
||||
|
||||
|
||||
async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_improve_writing(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Improve writing quality while preserving meaning."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Improve the following text. Fix grammar, clarity, and flow. Preserve the original language and meaning.\n\n{text}",
|
||||
@@ -128,7 +157,7 @@ async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None)
|
||||
)
|
||||
|
||||
|
||||
async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_fix_spelling(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Fix spelling and grammar errors."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Fix all spelling and grammar errors in this text. Return only the corrected text.\n\n{text}",
|
||||
@@ -136,7 +165,7 @@ async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) ->
|
||||
)
|
||||
|
||||
|
||||
async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_make_shorter(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Make text more concise."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Make this text shorter and more concise while preserving the key information.\n\n{text}",
|
||||
@@ -144,7 +173,7 @@ async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) ->
|
||||
)
|
||||
|
||||
|
||||
async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_make_longer(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Expand text with more detail."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Expand this text with more detail, examples, or explanation while keeping the same tone.\n\n{text}",
|
||||
@@ -152,7 +181,7 @@ async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) ->
|
||||
)
|
||||
|
||||
|
||||
async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_simplify(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Simplify language."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Simplify this text. Use clearer, more straightforward language. Avoid jargon.\n\n{text}",
|
||||
@@ -160,7 +189,7 @@ async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str
|
||||
)
|
||||
|
||||
|
||||
async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_change_tone(text: str, tone: str, provider: ProviderName | None = None) -> str:
|
||||
"""Change the tone of the text."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Rewrite this text in a {tone} tone. Preserve the original meaning.\n\n{text}",
|
||||
@@ -168,7 +197,7 @@ async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName]
|
||||
)
|
||||
|
||||
|
||||
async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_translate(text: str, target_lang: str, provider: ProviderName | None = None) -> str:
|
||||
"""Translate text to target language."""
|
||||
# Gemini is better at translation
|
||||
if provider is None and PROVIDERS["gemini"]["api_key"]:
|
||||
@@ -184,7 +213,7 @@ async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderN
|
||||
)
|
||||
|
||||
|
||||
async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_explain(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Explain the selected text."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Explain the following text clearly and concisely:\n\n{text}",
|
||||
@@ -192,7 +221,7 @@ async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
)
|
||||
|
||||
|
||||
async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_summarize(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Summarize the selected text."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Summarize the following text concisely:\n\n{text}",
|
||||
@@ -200,7 +229,7 @@ async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> st
|
||||
)
|
||||
|
||||
|
||||
async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_continue_writing(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Continue writing from the selected text."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Continue writing from where this text leaves off. Match the style and tone:\n\n{text}",
|
||||
@@ -208,7 +237,7 @@ async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None
|
||||
)
|
||||
|
||||
|
||||
async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_custom_rewrite(text: str, instruction: str, provider: ProviderName | None = None) -> str:
|
||||
"""Rewrite text based on a custom instruction."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Rewrite the following text according to this instruction: {instruction}\n\n{text}",
|
||||
@@ -216,7 +245,7 @@ async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[Prov
|
||||
)
|
||||
|
||||
|
||||
async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_convert_to_list(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Convert paragraph text to a markdown list."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Convert this text into a well-organized markdown bullet list. Extract key points.\n\n{text}",
|
||||
@@ -224,7 +253,7 @@ async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None)
|
||||
)
|
||||
|
||||
|
||||
async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_convert_to_table(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Convert text to a markdown table."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Convert this information into a markdown table. Choose appropriate columns.\n\n{text}",
|
||||
@@ -232,7 +261,7 @@ async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None
|
||||
)
|
||||
|
||||
|
||||
async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_generate_frontmatter(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Generate YAML frontmatter for a markdown document."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Generate YAML frontmatter for this markdown document. Include: titre, tags (as list), catégorie, statut, date. Return ONLY the YAML between --- markers.\n\n{text}",
|
||||
@@ -240,7 +269,7 @@ async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] =
|
||||
)
|
||||
|
||||
|
||||
async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Inline completion — suggest continuation."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}",
|
||||
@@ -248,7 +277,7 @@ async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None)
|
||||
)
|
||||
|
||||
|
||||
async def ai_convert_to_canvas(text: str, provider: Optional[ProviderName] = None) -> str:
|
||||
async def ai_convert_to_canvas(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Convert text to a Mermaid diagram or canvas representation."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Convert this content into a Mermaid.js diagram if applicable, or a structured outline. Choose the best format.\n\n{text}",
|
||||
|
||||
+63
-15
@@ -1,16 +1,29 @@
|
||||
"""ObsiGate AI — API routes for AI-powered editor features."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.ai import (
|
||||
ai_improve_writing, ai_fix_spelling, ai_make_shorter, ai_make_longer,
|
||||
ai_simplify, ai_change_tone, ai_translate, ai_explain, ai_summarize,
|
||||
ai_continue_writing, ai_custom_rewrite, ai_convert_to_list,
|
||||
ai_convert_to_table, ai_generate_frontmatter, ai_inline_complete,
|
||||
ai_convert_to_canvas, DEFAULT_PROVIDER, PROVIDERS,
|
||||
DEFAULT_PROVIDER,
|
||||
PROVIDERS,
|
||||
ai_change_tone,
|
||||
ai_continue_writing,
|
||||
ai_convert_to_canvas,
|
||||
ai_convert_to_list,
|
||||
ai_convert_to_table,
|
||||
ai_custom_rewrite,
|
||||
ai_explain,
|
||||
ai_fix_spelling,
|
||||
ai_generate_frontmatter,
|
||||
ai_improve_writing,
|
||||
ai_inline_complete,
|
||||
ai_make_longer,
|
||||
ai_make_shorter,
|
||||
ai_simplify,
|
||||
ai_summarize,
|
||||
ai_translate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.ai_routes")
|
||||
@@ -24,7 +37,7 @@ async def api_status():
|
||||
Reads environment variables at runtime so that changes to
|
||||
.env or Docker environment are reflected immediately.
|
||||
"""
|
||||
import os as _os
|
||||
from backend.ai import get_ai_key
|
||||
provider_keys = {
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"openrouter": "OPENROUTER_API_KEY",
|
||||
@@ -32,24 +45,59 @@ async def api_status():
|
||||
}
|
||||
providers = {}
|
||||
for name, env_var in provider_keys.items():
|
||||
has_key = bool(_os.getenv(env_var, "").strip())
|
||||
has_key = bool(get_ai_key(env_var))
|
||||
providers[name] = {
|
||||
"available": has_key,
|
||||
"model": PROVIDERS[name]["model"] if has_key else None,
|
||||
}
|
||||
|
||||
# ---- Autocomplete (Ollama) status ----
|
||||
ollama_cfg = PROVIDERS.get("ollama", {})
|
||||
ollama_url = (ollama_cfg.get("base_url") or "").rstrip("/v1").rstrip("/")
|
||||
ollama_model = ollama_cfg.get("model", "")
|
||||
autocomplete = {
|
||||
"available": False,
|
||||
"server_ok": False,
|
||||
"model_loaded": False,
|
||||
"model": ollama_model,
|
||||
"error": None,
|
||||
}
|
||||
if ollama_url:
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Check server health + list loaded models
|
||||
r = await client.get(f"{ollama_url}/api/tags")
|
||||
if r.status_code == 200:
|
||||
autocomplete["server_ok"] = True
|
||||
data = r.json()
|
||||
models = [m.get("name", "") for m in data.get("models", [])]
|
||||
# Check if our model (with or without tag) is loaded
|
||||
model_base = ollama_model.split(":")[0]
|
||||
autocomplete["model_loaded"] = any(
|
||||
m == ollama_model or m.startswith(model_base + ":")
|
||||
for m in models
|
||||
)
|
||||
autocomplete["available"] = autocomplete["model_loaded"]
|
||||
else:
|
||||
autocomplete["error"] = f"HTTP {r.status_code}"
|
||||
except Exception as e:
|
||||
autocomplete["error"] = str(e)
|
||||
|
||||
return {
|
||||
"configured": any(p["available"] for p in providers.values()),
|
||||
"default_provider": DEFAULT_PROVIDER,
|
||||
"providers": providers,
|
||||
"autocomplete": autocomplete,
|
||||
}
|
||||
|
||||
|
||||
class AIRequest(BaseModel):
|
||||
text: str = Field(..., description="Input text to process", min_length=1)
|
||||
instruction: Optional[str] = Field(None, description="Custom instruction for rewrite")
|
||||
target_lang: Optional[str] = Field(None, description="Target language for translation")
|
||||
tone: Optional[str] = Field(None, description="Target tone (professional, casual, etc.)")
|
||||
provider: Optional[str] = Field(None, description="AI provider override")
|
||||
instruction: str | None = Field(None, description="Custom instruction for rewrite")
|
||||
target_lang: str | None = Field(None, description="Target language for translation")
|
||||
tone: str | None = Field(None, description="Target tone (professional, casual, etc.)")
|
||||
provider: str | None = Field(None, description="AI provider override")
|
||||
|
||||
|
||||
class AIResponse(BaseModel):
|
||||
@@ -66,7 +114,7 @@ async def _handle(action, request: AIRequest):
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"AI error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI service error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"AI service error: {e!s}")
|
||||
|
||||
|
||||
@router.post("/improve", response_model=AIResponse)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
|
||||
@@ -10,16 +10,16 @@ logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
|
||||
|
||||
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
|
||||
attachment_index: Dict[str, Dict[str, List[Path]]] = {}
|
||||
attachment_index: dict[str, dict[str, list[Path]]] = {}
|
||||
|
||||
# Resolution cache: {(vault_name, filename): resolved_path}
|
||||
_resolution_cache: Dict[tuple, Optional[Path]] = {}
|
||||
_resolution_cache: dict[tuple, Path | None] = {}
|
||||
|
||||
# Thread-safe lock for index updates
|
||||
_attachment_lock = threading.Lock()
|
||||
|
||||
|
||||
def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
|
||||
def clear_resolution_cache(vault_name: str | None = None) -> None:
|
||||
"""Clear the resolution cache for a specific vault or all vaults.
|
||||
|
||||
Args:
|
||||
@@ -29,12 +29,12 @@ def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
|
||||
if vault_name is None:
|
||||
_resolution_cache.clear()
|
||||
else:
|
||||
keys_to_remove = [k for k in _resolution_cache.keys() if k[0] == vault_name]
|
||||
keys_to_remove = [k for k in _resolution_cache if k[0] == vault_name]
|
||||
for key in keys_to_remove:
|
||||
del _resolution_cache[key]
|
||||
|
||||
|
||||
def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> Dict[str, List[Path]]:
|
||||
def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> dict[str, list[Path]]:
|
||||
"""Synchronously scan a vault directory for image attachments.
|
||||
|
||||
Walks the vault tree and builds a filename -> absolute path mapping
|
||||
@@ -49,7 +49,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict |
|
||||
Dict mapping lowercase filenames to lists of absolute paths.
|
||||
"""
|
||||
vault_root = Path(vault_path)
|
||||
index: Dict[str, List[Path]] = {}
|
||||
index: dict[str, list[Path]] = {}
|
||||
|
||||
if not vault_root.exists():
|
||||
logger.warning(f"Vault path does not exist for attachment scan: {vault_path}")
|
||||
@@ -84,7 +84,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict |
|
||||
return index
|
||||
|
||||
|
||||
async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> None:
|
||||
async def build_attachment_index(vault_config: dict[str, dict[str, Any]]) -> None:
|
||||
"""Build the attachment index for all configured vaults.
|
||||
|
||||
Runs vault scans concurrently in a thread pool, then performs
|
||||
@@ -101,9 +101,9 @@ async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> Non
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
new_index: Dict[str, Dict[str, List[Path]]] = {}
|
||||
new_index: dict[str, dict[str, list[Path]]] = {}
|
||||
|
||||
tasks: list[tuple[str, asyncio.Future[Dict[str, List[Path]]]]] = []
|
||||
tasks: list[tuple[str, asyncio.Future[dict[str, list[Path]]]]] = []
|
||||
for name, config in vault_config.items():
|
||||
vault_path = config.get("path")
|
||||
if not vault_path:
|
||||
@@ -159,9 +159,9 @@ def resolve_image_path(
|
||||
image_src: str,
|
||||
vault_name: str,
|
||||
vault_root: Path,
|
||||
current_file_path: Optional[Path] = None,
|
||||
attachments_path: Optional[str] = None
|
||||
) -> Optional[Path]:
|
||||
current_file_path: Path | None = None,
|
||||
attachments_path: str | None = None
|
||||
) -> Path | None:
|
||||
"""Resolve an image source path using multi-strategy resolution.
|
||||
|
||||
Applies 7 resolution strategies in priority order:
|
||||
@@ -193,8 +193,8 @@ def resolve_image_path(
|
||||
resolved = None
|
||||
|
||||
# Strategy 1: Absolute path
|
||||
if src_path.is_absolute():
|
||||
if src_path.exists() and src_path.is_file():
|
||||
if src_path.is_absolute() and src_path.exists() and src_path.is_file():
|
||||
|
||||
resolved = src_path
|
||||
logger.debug(f"Image resolved via strategy 1 (absolute): {image_src}")
|
||||
|
||||
@@ -279,7 +279,7 @@ def resolve_image_path(
|
||||
return resolved
|
||||
|
||||
|
||||
def get_attachment_stats(vault_name: Optional[str] = None) -> Dict[str, int]:
|
||||
def get_attachment_stats(vault_name: str | None = None) -> dict[str, int]:
|
||||
"""Get attachment statistics for a vault or all vaults.
|
||||
|
||||
Args:
|
||||
|
||||
+7
-8
@@ -8,11 +8,10 @@ Format: JSON lines (one JSON object per line) for easy parsing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("obsigate.audit")
|
||||
|
||||
@@ -51,7 +50,7 @@ def log_file_save(
|
||||
vault_name: str,
|
||||
file_path: str,
|
||||
size: int,
|
||||
ip: Optional[str] = None,
|
||||
ip: str | None = None,
|
||||
):
|
||||
"""Log a file save (PUT) operation."""
|
||||
_write_entry({
|
||||
@@ -69,7 +68,7 @@ def log_file_delete(
|
||||
username: str,
|
||||
vault_name: str,
|
||||
file_path: str,
|
||||
ip: Optional[str] = None,
|
||||
ip: str | None = None,
|
||||
):
|
||||
"""Log a file delete operation."""
|
||||
_write_entry({
|
||||
@@ -85,7 +84,7 @@ def log_file_delete(
|
||||
def log_config_change(
|
||||
username: str,
|
||||
changes: dict,
|
||||
ip: Optional[str] = None,
|
||||
ip: str | None = None,
|
||||
):
|
||||
"""Log a configuration change."""
|
||||
_write_entry({
|
||||
@@ -97,7 +96,7 @@ def log_config_change(
|
||||
})
|
||||
|
||||
|
||||
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[str] = None):
|
||||
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
|
||||
"""Log a vault addition."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -109,7 +108,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[
|
||||
})
|
||||
|
||||
|
||||
def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
|
||||
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||
"""Log a vault removal."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -120,7 +119,7 @@ def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
|
||||
})
|
||||
|
||||
|
||||
def get_recent_entries(limit: int = 100, action: Optional[str] = None) -> list:
|
||||
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||
"""Read the most recent audit log entries.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
# Revoked token JTIs persisted to data/revoked_tokens.json.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from jose import jwt, JWTError
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.jwt")
|
||||
|
||||
@@ -76,7 +76,7 @@ def create_refresh_token(username: str) -> tuple:
|
||||
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
def decode_token(token: str) -> dict | None:
|
||||
"""Decode and validate a JWT. Returns None if invalid/expired."""
|
||||
try:
|
||||
return jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# FastAPI dependencies for authentication and authorization.
|
||||
# Reads JWT from Authorization header OR access_token cookie.
|
||||
|
||||
import os
|
||||
import logging
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from .jwt_handler import decode_token
|
||||
from .user_store import get_user
|
||||
@@ -26,8 +26,8 @@ def is_auth_enabled() -> bool:
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> Optional[dict]:
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict | None:
|
||||
"""Extract and validate the current user from JWT.
|
||||
|
||||
Reads token from Authorization header first, falls back to access_token cookie.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError
|
||||
from argon2.exceptions import VerificationError, VerifyMismatchError
|
||||
|
||||
ph = PasswordHasher(
|
||||
time_cost=2,
|
||||
|
||||
+71
-24
@@ -2,24 +2,37 @@
|
||||
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
|
||||
# and admin user CRUD.
|
||||
|
||||
import re
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Response, Request, Depends
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, validator
|
||||
from typing import List, Optional
|
||||
|
||||
from backend.ratelimit import is_rate_limited
|
||||
from backend.ratelimit import record_failure as rl_record_failure
|
||||
from backend.ratelimit import record_success as rl_record_success
|
||||
|
||||
from .user_store import (
|
||||
get_user, get_all_users, create_user, update_user, delete_user,
|
||||
record_login_success, record_login_failure, is_locked, has_users,
|
||||
)
|
||||
from .jwt_handler import (
|
||||
create_access_token, create_refresh_token, decode_token,
|
||||
revoke_token, is_token_revoked,
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
is_token_revoked,
|
||||
revoke_token,
|
||||
)
|
||||
from .middleware import is_auth_enabled, require_admin, require_auth
|
||||
from .password import hash_password, verify_password
|
||||
from .user_store import (
|
||||
create_user,
|
||||
delete_user,
|
||||
get_all_users,
|
||||
get_user,
|
||||
has_users,
|
||||
is_locked,
|
||||
record_login_failure,
|
||||
record_login_success,
|
||||
update_user,
|
||||
)
|
||||
from .middleware import require_auth, require_admin, is_auth_enabled
|
||||
from .password import verify_password, hash_password
|
||||
from backend.ratelimit import is_rate_limited, record_failure as rl_record_failure, record_success as rl_record_success
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.router")
|
||||
|
||||
@@ -48,9 +61,9 @@ class ChangePasswordRequest(BaseModel):
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: Optional[str] = None
|
||||
display_name: str | None = None
|
||||
role: str = "user"
|
||||
vaults: List[str] = []
|
||||
vaults: list[str] = []
|
||||
|
||||
@validator("username")
|
||||
def username_valid(cls, v):
|
||||
@@ -66,11 +79,11 @@ class CreateUserRequest(BaseModel):
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
vaults: Optional[List[str]] = None
|
||||
active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
display_name: str | None = None
|
||||
vaults: list[str] | None = None
|
||||
active: bool | None = None
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
# ── Public endpoints ──────────────────────────────────────────────────
|
||||
@@ -217,18 +230,21 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Logout: revoke refresh token and delete cookie."""
|
||||
"""Logout: revoke refresh token and delete cookies."""
|
||||
refresh_tok = request.cookies.get("refresh_token")
|
||||
if refresh_tok:
|
||||
payload = decode_token(refresh_tok)
|
||||
if payload:
|
||||
revoke_token(payload["jti"])
|
||||
try:
|
||||
revoke_token(payload["jti"])
|
||||
except Exception:
|
||||
pass # token already revoked
|
||||
|
||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||
logger.info(f"User '{current_user['username']}' logged out")
|
||||
return {"message": "Déconnecté avec succès"}
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("access_token", path="/api") # just in case
|
||||
return {"message": "Deconnecte avec succes"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -239,10 +255,41 @@ async def get_me(current_user=Depends(require_auth)):
|
||||
"display_name": current_user["display_name"],
|
||||
"role": current_user["role"],
|
||||
"vaults": current_user["vaults"],
|
||||
"language": current_user.get("language", "fr"),
|
||||
"last_login": current_user.get("last_login"),
|
||||
}
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
"""Fields the user can update on their own profile."""
|
||||
display_name: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
"""Update current user's profile fields (display_name, language)."""
|
||||
from .user_store import update_user
|
||||
updates = {}
|
||||
if req.display_name is not None:
|
||||
updates["display_name"] = req.display_name
|
||||
if req.language is not None:
|
||||
if req.language not in ("fr", "en"):
|
||||
raise HTTPException(400, "language must be 'fr' or 'en'")
|
||||
updates["language"] = req.language
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
updated = update_user(current_user["username"], updates)
|
||||
return {
|
||||
"username": updated["username"],
|
||||
"display_name": updated["display_name"],
|
||||
"role": updated["role"],
|
||||
"vaults": updated["vaults"],
|
||||
"language": updated.get("language", "fr"),
|
||||
"last_login": updated.get("last_login"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
# via tmp+rename to prevent corruption on crash.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from .password import hash_password
|
||||
|
||||
@@ -54,12 +53,12 @@ def has_users() -> bool:
|
||||
return bool(_read()["users"])
|
||||
|
||||
|
||||
def get_user(username: str) -> Optional[dict]:
|
||||
def get_user(username: str) -> dict | None:
|
||||
"""Get a user by username. Returns None if not found."""
|
||||
return _read()["users"].get(username)
|
||||
|
||||
|
||||
def get_all_users() -> List[dict]:
|
||||
def get_all_users() -> list[dict]:
|
||||
"""Get all users WITHOUT password_hash (safe for API responses)."""
|
||||
users = _read()["users"]
|
||||
return [
|
||||
@@ -72,8 +71,8 @@ def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "user",
|
||||
vaults: Optional[List[str]] = None,
|
||||
display_name: Optional[str] = None,
|
||||
vaults: list[str] | None = None,
|
||||
display_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new user. Raises ValueError if username already taken."""
|
||||
data = _read()
|
||||
@@ -88,6 +87,7 @@ def create_user(
|
||||
"role": role,
|
||||
"vaults": vaults or [],
|
||||
"active": True,
|
||||
"language": "fr", # default UI language
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_login": None,
|
||||
"failed_attempts": 0,
|
||||
|
||||
@@ -11,14 +11,15 @@ Docker usage:
|
||||
docker exec obsigate python backend/create_admin.py list
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
# Add parent directory to path for imports
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from backend.auth.user_store import create_user, get_all_users, delete_user
|
||||
from backend.auth.user_store import create_user, delete_user, get_all_users
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
+49
-6
@@ -1,10 +1,10 @@
|
||||
# backend/history.py
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.history")
|
||||
|
||||
@@ -18,7 +18,7 @@ def _get_bookmarks_file(username: str) -> Path:
|
||||
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return HISTORY_DIR / f"{username}_bookmarks.json"
|
||||
|
||||
def _read_data(file: Path) -> List[Dict[str, Any]]:
|
||||
def _read_data(file: Path) -> list[dict[str, Any]]:
|
||||
if not file.exists():
|
||||
return []
|
||||
try:
|
||||
@@ -27,7 +27,7 @@ def _read_data(file: Path) -> List[Dict[str, Any]]:
|
||||
logger.error(f"Failed to read data from {file.name}: {e}")
|
||||
return []
|
||||
|
||||
def _write_data(file: Path, data: List[Dict[str, Any]]):
|
||||
def _write_data(file: Path, data: list[dict[str, Any]]):
|
||||
try:
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
@@ -58,7 +58,7 @@ def record_open(username: str, vault: str, path: str, title: str = ""):
|
||||
|
||||
_write_data(_get_history_file(username), history)
|
||||
|
||||
def get_recent_opened(username: str, vault_filter: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
def get_recent_opened(username: str, vault_filter: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Get the most recently opened files for a user."""
|
||||
if not username:
|
||||
return []
|
||||
@@ -105,7 +105,7 @@ def toggle_bookmark(username: str, vault: str, path: str, title: str = ""):
|
||||
_write_data(b_file, bookmarks)
|
||||
return True
|
||||
|
||||
def get_bookmarks(username: str, vault_filter: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
def get_bookmarks(username: str, vault_filter: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Get the bookmarks for a user."""
|
||||
if not username:
|
||||
return []
|
||||
@@ -123,3 +123,46 @@ def is_bookmarked(username: str, vault: str, path: str) -> bool:
|
||||
return False
|
||||
bookmarks = _read_data(_get_bookmarks_file(username))
|
||||
return any(b["vault"] == vault and b["path"] == path for b in bookmarks)
|
||||
|
||||
|
||||
def update_bookmarks_after_rename(vault: str, old_path: str, new_path: str):
|
||||
"""Update all users' bookmarks when a file is renamed."""
|
||||
if not HISTORY_DIR.exists():
|
||||
return
|
||||
for bfile in HISTORY_DIR.glob("*_bookmarks.json"):
|
||||
try:
|
||||
bookmarks = _read_data(bfile)
|
||||
updated = False
|
||||
for b in bookmarks:
|
||||
if b.get("vault") == vault and b.get("path") == old_path:
|
||||
b["path"] = new_path
|
||||
# Update title from new path
|
||||
new_title = new_path.split("/")[-1].replace(".md", "").replace("-", " ").replace("_", " ")
|
||||
b["title"] = new_title
|
||||
updated = True
|
||||
if updated:
|
||||
_write_data(bfile, bookmarks)
|
||||
logger.info(f"Updated bookmarks in {bfile.name}: {vault}/{old_path} -> {new_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update bookmarks in {bfile.name}: {e}")
|
||||
|
||||
|
||||
def update_history_after_rename(vault: str, old_path: str, new_path: str):
|
||||
"""Update all users' history entries when a file is renamed."""
|
||||
if not HISTORY_DIR.exists():
|
||||
return
|
||||
for hfile in HISTORY_DIR.glob("*.json"):
|
||||
if "_bookmarks" in hfile.name:
|
||||
continue
|
||||
try:
|
||||
history = _read_data(hfile)
|
||||
updated = False
|
||||
for h in history:
|
||||
if h.get("vault") == vault and h.get("path") == old_path:
|
||||
h["path"] = new_path
|
||||
updated = True
|
||||
if updated:
|
||||
_write_data(hfile, history)
|
||||
logger.info(f"Updated history in {hfile.name}: {vault}/{old_path} -> {new_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update history in {hfile.name}: {e}")
|
||||
|
||||
+10
-11
@@ -1,8 +1,7 @@
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import re
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
|
||||
from backend.attachment_indexer import resolve_image_path
|
||||
|
||||
@@ -13,8 +12,8 @@ def preprocess_images(
|
||||
content: str,
|
||||
vault_name: str,
|
||||
vault_root: Path,
|
||||
current_file_path: Optional[Path] = None,
|
||||
attachments_path: Optional[str] = None
|
||||
current_file_path: Path | None = None,
|
||||
attachments_path: str | None = None
|
||||
) -> str:
|
||||
"""Preprocess markdown content to transform all Obsidian image syntaxes.
|
||||
|
||||
@@ -55,8 +54,8 @@ def _process_html_img_in_link(
|
||||
content: str,
|
||||
vault_name: str,
|
||||
vault_root: Path,
|
||||
current_file_path: Optional[Path],
|
||||
attachments_path: Optional[str]
|
||||
current_file_path: Path | None,
|
||||
attachments_path: str | None
|
||||
) -> str:
|
||||
"""Process [<img ... src="path"/>](url) syntax.
|
||||
|
||||
@@ -119,8 +118,8 @@ def _process_wikilink_embeds(
|
||||
content: str,
|
||||
vault_name: str,
|
||||
vault_root: Path,
|
||||
current_file_path: Optional[Path],
|
||||
attachments_path: Optional[str]
|
||||
current_file_path: Path | None,
|
||||
attachments_path: str | None
|
||||
) -> str:
|
||||
"""Process ![[image]] and ![[path/to/image]] wiki-link embeds.
|
||||
|
||||
@@ -158,8 +157,8 @@ def _process_standard_images(
|
||||
content: str,
|
||||
vault_name: str,
|
||||
vault_root: Path,
|
||||
current_file_path: Optional[Path],
|
||||
attachments_path: Optional[str]
|
||||
current_file_path: Path | None,
|
||||
attachments_path: str | None
|
||||
) -> str:
|
||||
"""Process  standard markdown images.
|
||||
|
||||
|
||||
+65
-56
@@ -1,21 +1,22 @@
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import frontmatter
|
||||
|
||||
logger = logging.getLogger("obsigate.indexer")
|
||||
|
||||
# Global in-memory index
|
||||
index: Dict[str, Dict[str, Any]] = {}
|
||||
index: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Vault config: {name: {path, attachmentsPath, scanAttachmentsOnStartup}}
|
||||
vault_config: Dict[str, Dict[str, Any]] = {}
|
||||
vault_config: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Thread-safe lock for index updates
|
||||
_index_lock = threading.Lock()
|
||||
@@ -41,13 +42,13 @@ def set_index_change_hook(hook):
|
||||
_on_index_change = hook
|
||||
|
||||
# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]}
|
||||
_file_lookup: Dict[str, List[Dict[str, str]]] = {}
|
||||
_file_lookup: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
# Backlink index: {vault_name: {relative_path: [{vault, path, title}, ...]}}
|
||||
_backlink_index: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
|
||||
_backlink_index: dict[str, dict[str, list[dict[str, str]]]] = {}
|
||||
|
||||
# O(1) path index for tree filtering: {vault_name: [{path, name, type}, ...]}
|
||||
path_index: Dict[str, List[Dict[str, str]]] = {}
|
||||
path_index: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
# Maximum content size stored per file for in-memory search (bytes)
|
||||
SEARCH_CONTENT_LIMIT = 100_000
|
||||
@@ -57,21 +58,23 @@ SUPPORTED_EXTENSIONS = {
|
||||
".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx",
|
||||
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
|
||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
||||
".cfg", ".ini", ".conf", ".env",
|
||||
".cfg", ".ini", ".conf", ".env", ".pdf",
|
||||
".html", ".css", ".scss", ".less",
|
||||
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
||||
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
||||
".dockerfile", ".makefile", ".cmake",
|
||||
".excalidraw",
|
||||
".excalidraw.md",
|
||||
}
|
||||
|
||||
|
||||
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
|
||||
_DEFAULT_IGNORED = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'}
|
||||
_env_ignored = os.environ.get("OBSIGATE_IGNORED_DIRS", "")
|
||||
IGNORED_DIRS = set(d.strip() for d in _env_ignored.split(",") if d.strip()) if _env_ignored else _DEFAULT_IGNORED.copy()
|
||||
IGNORED_DIRS = {d.strip() for d in _env_ignored.split(",") if d.strip()} if _env_ignored else _DEFAULT_IGNORED.copy()
|
||||
|
||||
|
||||
def load_vault_config() -> Dict[str, Dict[str, Any]]:
|
||||
def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
"""Read VAULT_N_* and DIR_N_* env vars and return vault configuration.
|
||||
|
||||
Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``,
|
||||
@@ -89,7 +92,7 @@ def load_vault_config() -> Dict[str, Dict[str, Any]]:
|
||||
- scanAttachmentsOnStartup: boolean (default True)
|
||||
- type: "VAULT" or "DIR"
|
||||
"""
|
||||
vaults: Dict[str, Dict[str, Any]] = {}
|
||||
vaults: dict[str, dict[str, Any]] = {}
|
||||
n = 1
|
||||
while True:
|
||||
name = os.environ.get(f"VAULT_{n}_NAME")
|
||||
@@ -135,7 +138,7 @@ _CODE_BLOCK_RE = re.compile(r'```[\s\S]*?```', re.MULTILINE)
|
||||
_INLINE_CODE_RE = re.compile(r'`[^`]+`')
|
||||
|
||||
|
||||
def _extract_tags(post: frontmatter.Post) -> List[str]:
|
||||
def _extract_tags(post: frontmatter.Post) -> list[str]:
|
||||
"""Extract tags from frontmatter metadata.
|
||||
|
||||
|
||||
@@ -158,7 +161,7 @@ def _extract_tags(post: frontmatter.Post) -> List[str]:
|
||||
return tags
|
||||
|
||||
|
||||
def _extract_inline_tags(content: str) -> List[str]:
|
||||
def _extract_inline_tags(content: str) -> list[str]:
|
||||
"""Extract inline #tag patterns from markdown content.
|
||||
|
||||
Strips fenced and inline code blocks before scanning to avoid
|
||||
@@ -215,10 +218,10 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
|
||||
match = re.match(r"^---\s*\r?\n.*?\r?\n---\s*\r?\n?", raw, flags=re.DOTALL)
|
||||
if match:
|
||||
content = raw[match.end():]
|
||||
return frontmatter.Post(content, **{})
|
||||
return frontmatter.Post(content)
|
||||
|
||||
|
||||
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Synchronously scan a single vault directory and build file index.
|
||||
|
||||
Walks the vault tree, reads supported files, extracts metadata
|
||||
@@ -236,9 +239,9 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
|
||||
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list).
|
||||
"""
|
||||
vault_root = Path(vault_path)
|
||||
files: List[Dict[str, Any]] = []
|
||||
tag_counts: Dict[str, int] = {}
|
||||
paths: List[Dict[str, str]] = []
|
||||
files: list[dict[str, Any]] = []
|
||||
tag_counts: dict[str, int] = {}
|
||||
paths: list[dict[str, str]] = []
|
||||
|
||||
if not vault_root.exists():
|
||||
logger.warning(f"Vault path does not exist: {vault_path}")
|
||||
@@ -281,26 +284,32 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
# PDF handling — special path (binary, uses pdf_reader)
|
||||
if ext == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
|
||||
raw = extract_pdf_text(fpath, max_chars=100000)
|
||||
pdf_meta = extract_pdf_metadata(fpath)
|
||||
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
tags: list[str] = []
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
tags: list[str] = []
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
|
||||
tags: List[str] = []
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
tags = _extract_tags(post)
|
||||
inline_tags = _extract_inline_tags(post.content)
|
||||
tags = list(set(tags) | set(inline_tags))
|
||||
title = _extract_title(post, fpath)
|
||||
content_preview = post.content[:200].strip()
|
||||
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
tags = _extract_tags(post)
|
||||
# Merge inline #tags found in content body
|
||||
inline_tags = _extract_inline_tags(post.content)
|
||||
tags = list(set(tags) | set(inline_tags))
|
||||
title = _extract_title(post, fpath)
|
||||
content_preview = post.content[:200].strip()
|
||||
|
||||
# Extract wikilinks for backlink index
|
||||
_extract_wikilinks_for_backlinks(
|
||||
vault_name, str(relative).replace("\\", "/"),
|
||||
title, post.content
|
||||
)
|
||||
_extract_wikilinks_for_backlinks(
|
||||
vault_name, str(relative).replace("\\", "/"),
|
||||
title, post.content
|
||||
)
|
||||
|
||||
files.append({
|
||||
"path": str(relative).replace("\\", "/"),
|
||||
@@ -359,13 +368,13 @@ async def build_index(progress_callback=None) -> None:
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _process_vault(name: str, config: Dict[str, Any]):
|
||||
async def _process_vault(name: str, config: dict[str, Any]):
|
||||
vault_path = config["path"]
|
||||
vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config)
|
||||
vault_data["config"] = config
|
||||
|
||||
# Build lookup entries for the new vault
|
||||
new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
|
||||
new_lookup_entries: dict[str, list[dict[str, str]]] = {}
|
||||
for f in vault_data["files"]:
|
||||
entry = {"vault": name, "path": f["path"]}
|
||||
fname = f["path"].rsplit("/", 1)[-1].lower()
|
||||
@@ -413,7 +422,7 @@ async def build_index(progress_callback=None) -> None:
|
||||
await progress_callback("complete", {"total_vaults": len(vault_config), "total_files": total_files})
|
||||
|
||||
|
||||
async def reload_index() -> Dict[str, Any]:
|
||||
async def reload_index() -> dict[str, Any]:
|
||||
"""Force a full re-index of all vaults and return per-vault statistics.
|
||||
|
||||
Returns:
|
||||
@@ -426,7 +435,7 @@ async def reload_index() -> Dict[str, Any]:
|
||||
return stats
|
||||
|
||||
|
||||
async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
|
||||
async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
"""Force a re-index of a single vault and return its statistics.
|
||||
|
||||
Args:
|
||||
@@ -458,7 +467,7 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
|
||||
vault_data["config"] = config
|
||||
|
||||
# Build lookup entries for the vault
|
||||
new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
|
||||
new_lookup_entries: dict[str, list[dict[str, str]]] = {}
|
||||
for f in vault_data["files"]:
|
||||
entry = {"vault": vault_name, "path": f["path"]}
|
||||
fname = f["path"].rsplit("/", 1)[-1].lower()
|
||||
@@ -489,12 +498,12 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
|
||||
return stats
|
||||
|
||||
|
||||
def get_vault_names() -> List[str]:
|
||||
def get_vault_names() -> list[str]:
|
||||
"""Return the list of all indexed vault names."""
|
||||
return list(index.keys())
|
||||
|
||||
|
||||
def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
|
||||
def get_vault_data(vault_name: str) -> dict[str, Any] | None:
|
||||
"""Return the full index data for a vault, or ``None`` if not found."""
|
||||
return index.get(vault_name)
|
||||
|
||||
@@ -507,7 +516,7 @@ def _get_async_lock() -> asyncio.Lock:
|
||||
return _async_index_lock
|
||||
|
||||
|
||||
def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
"""Synchronously read and parse a single file for indexing.
|
||||
|
||||
All files are indexed, including hidden files (starting with '.').
|
||||
@@ -539,7 +548,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
tags: List[str] = []
|
||||
tags: list[str] = []
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
|
||||
@@ -569,7 +578,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
return None
|
||||
|
||||
|
||||
def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dict[str, Any]]:
|
||||
def _remove_file_from_structures(vault_name: str, rel_path: str) -> dict[str, Any] | None:
|
||||
"""Remove a file from all index structures. Returns removed file info or None.
|
||||
|
||||
Must be called under _index_lock or _async_index_lock.
|
||||
@@ -644,7 +653,7 @@ def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing:
|
||||
})
|
||||
|
||||
|
||||
def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
|
||||
def _add_file_to_structures(vault_name: str, file_info: dict[str, Any]):
|
||||
"""Add a file entry to all index structures.
|
||||
|
||||
Must be called under _index_lock or _async_index_lock.
|
||||
@@ -689,7 +698,7 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
|
||||
_on_index_change('add', vault_name, file_info["path"], file_info) # type: ignore[misc]
|
||||
|
||||
|
||||
async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]:
|
||||
async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None:
|
||||
"""Re-index a single file without full rebuild.
|
||||
|
||||
Reads the file, removes the old entry if present, inserts the new one.
|
||||
@@ -736,7 +745,7 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Di
|
||||
return file_info
|
||||
|
||||
|
||||
async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]:
|
||||
async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None:
|
||||
"""Remove a single file from the index.
|
||||
|
||||
Args:
|
||||
@@ -768,7 +777,7 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Di
|
||||
return removed
|
||||
|
||||
|
||||
async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> Optional[Dict[str, Any]]:
|
||||
async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict[str, Any] | None:
|
||||
"""Handle a file move/rename by removing old entry and indexing new location.
|
||||
|
||||
Args:
|
||||
@@ -817,7 +826,7 @@ async def remove_vault_from_index(vault_name: str):
|
||||
logger.info(f"Removed vault '{vault_name}' from index")
|
||||
|
||||
|
||||
async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
||||
async def add_vault_to_index(vault_name: str, vault_path: str) -> dict[str, Any]:
|
||||
"""Add a new vault to the index dynamically.
|
||||
|
||||
Args:
|
||||
@@ -840,7 +849,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]
|
||||
vault_data["config"] = vault_config[vault_name]
|
||||
|
||||
# Build lookup entries for the new vault
|
||||
new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
|
||||
new_lookup_entries: dict[str, list[dict[str, str]]] = {}
|
||||
for f in vault_data["files"]:
|
||||
entry = {"vault": vault_name, "path": f["path"]}
|
||||
fname = f["path"].rsplit("/", 1)[-1].lower()
|
||||
@@ -865,7 +874,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]
|
||||
return stats
|
||||
|
||||
|
||||
def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
|
||||
def find_file_in_index(link_target: str, current_vault: str) -> dict[str, str] | None:
|
||||
"""Find a file matching a wikilink target using O(1) lookup table.
|
||||
|
||||
Searches by filename first, then by full relative path.
|
||||
@@ -937,7 +946,7 @@ def _extract_wikilinks_for_backlinks(
|
||||
})
|
||||
|
||||
|
||||
def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
|
||||
def get_backlinks(vault_name: str, file_path: str) -> list[dict[str, str]]:
|
||||
"""Get all files that link to the given file via wikilinks.
|
||||
|
||||
Searches across all vaults for backlinks pointing to the target file.
|
||||
@@ -955,7 +964,7 @@ def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
|
||||
target_key += ".md"
|
||||
|
||||
results = []
|
||||
for vname, vindex in _backlink_index.items():
|
||||
for vindex in _backlink_index.values():
|
||||
bl = vindex.get(target_key, [])
|
||||
results.extend(bl)
|
||||
return results
|
||||
|
||||
+813
-134
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,13 @@ Used by both the authenticated API and public share views.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from weasyprint import HTML
|
||||
|
||||
logger = logging.getLogger("obsigate.pdf")
|
||||
|
||||
|
||||
def generate_pdf(html_content: str, title: str = "document", base_url: Optional[str] = None) -> bytes:
|
||||
def generate_pdf(html_content: str, title: str = "document", base_url: str | None = None) -> bytes:
|
||||
"""Generate a PDF from HTML content.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""PDF text extraction for ObsiGate — pypdf backend, pymupdf optional fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PDF_READER: str = "pypdf"
|
||||
try:
|
||||
import fitz # pymupdf
|
||||
PDF_READER = "pymupdf"
|
||||
logger.info("PDF reader: pymupdf (high performance)")
|
||||
except ImportError:
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
logger.info("PDF reader: pypdf (pure Python)")
|
||||
except ImportError:
|
||||
PdfReader = None # type: ignore
|
||||
logger.warning("No PDF reader available — install pypdf or pymupdf")
|
||||
|
||||
|
||||
def extract_pdf_text(file_path: Path, max_chars: int = 100000) -> str:
|
||||
"""Extract text from a PDF file. Returns empty string on failure."""
|
||||
if PdfReader is None and PDF_READER == "pypdf":
|
||||
return ""
|
||||
try:
|
||||
if PDF_READER == "pymupdf":
|
||||
return _extract_pymupdf(file_path, max_chars)
|
||||
else:
|
||||
return _extract_pypdf(file_path, max_chars)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract PDF text from %s: %s", file_path, e)
|
||||
return ""
|
||||
|
||||
|
||||
def extract_pdf_metadata(file_path: Path) -> dict:
|
||||
"""Extract metadata from a PDF file."""
|
||||
info = {"pages": 0, "title": "", "author": ""}
|
||||
try:
|
||||
if PDF_READER == "pymupdf":
|
||||
doc = fitz.open(str(file_path))
|
||||
info["pages"] = doc.page_count
|
||||
meta = doc.metadata or {}
|
||||
info["title"] = meta.get("title", "")
|
||||
info["author"] = meta.get("author", "")
|
||||
doc.close()
|
||||
else:
|
||||
reader = PdfReader(str(file_path))
|
||||
info["pages"] = len(reader.pages)
|
||||
meta = reader.metadata or {}
|
||||
if meta:
|
||||
info["title"] = str(meta.get("/Title", ""))
|
||||
info["author"] = str(meta.get("/Author", ""))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract PDF metadata from %s: %s", file_path, e)
|
||||
return info
|
||||
|
||||
|
||||
def extract_pdf_toc(file_path: Path) -> list[dict]:
|
||||
"""Extract table of contents (bookmarks/outline) from a PDF file.
|
||||
|
||||
Returns a list of {title, page, level} dicts, or empty list on failure.
|
||||
"""
|
||||
toc: list[dict] = []
|
||||
try:
|
||||
if PDF_READER == "pymupdf":
|
||||
doc = fitz.open(str(file_path))
|
||||
raw = doc.get_toc(simple=False)
|
||||
for item in raw:
|
||||
toc.append({
|
||||
"title": str(item[1]),
|
||||
"page": item[2],
|
||||
"level": item[0],
|
||||
})
|
||||
doc.close()
|
||||
elif PdfReader is not None:
|
||||
reader = PdfReader(str(file_path))
|
||||
outline = reader.outline
|
||||
if outline:
|
||||
def _flatten(items, level=1):
|
||||
for item in items:
|
||||
if isinstance(item, list):
|
||||
_flatten(item, level + 1)
|
||||
elif hasattr(item, 'title') and hasattr(item, 'page'):
|
||||
page_num = reader.get_page_number(item.page) + 1 if hasattr(item, 'page') and item.page else 1
|
||||
toc.append({
|
||||
"title": str(item.title),
|
||||
"page": page_num,
|
||||
"level": level,
|
||||
})
|
||||
_flatten(outline)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract PDF TOC from %s: %s", file_path, e)
|
||||
return toc
|
||||
|
||||
|
||||
def _extract_pymupdf(file_path: Path, max_chars: int) -> str:
|
||||
doc = fitz.open(str(file_path))
|
||||
parts = []
|
||||
total = 0
|
||||
for page in doc:
|
||||
text = page.get_text()
|
||||
if text:
|
||||
if total + len(text) > max_chars:
|
||||
remaining = max_chars - total
|
||||
if remaining > 0:
|
||||
parts.append(text[:remaining])
|
||||
break
|
||||
parts.append(text)
|
||||
total += len(text)
|
||||
doc.close()
|
||||
return "\f".join(parts)
|
||||
|
||||
|
||||
def _extract_pypdf(file_path: Path, max_chars: int) -> str:
|
||||
reader = PdfReader(str(file_path))
|
||||
parts = []
|
||||
total = 0
|
||||
for page in reader.pages:
|
||||
text = page.extract_text() or ""
|
||||
if text:
|
||||
if total + len(text) > max_chars:
|
||||
remaining = max_chars - total
|
||||
if remaining > 0:
|
||||
parts.append(text[:remaining])
|
||||
break
|
||||
parts.append(text)
|
||||
total += len(text)
|
||||
return "\f".join(parts)
|
||||
@@ -10,11 +10,10 @@ Configuration via environment variables:
|
||||
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Tuple
|
||||
|
||||
logger = logging.getLogger("obsigate.ratelimit")
|
||||
|
||||
@@ -23,7 +22,7 @@ MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10"))
|
||||
WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min
|
||||
|
||||
# --- In-memory store: {ip: [(timestamp, success_bool), ...]} ---
|
||||
_ip_attempts: Dict[str, list] = defaultdict(list)
|
||||
_ip_attempts: dict[str, list] = defaultdict(list)
|
||||
_last_cleanup = time.time()
|
||||
CLEANUP_INTERVAL = 60 # seconds
|
||||
|
||||
@@ -45,7 +44,7 @@ def _cleanup_expired():
|
||||
del _ip_attempts[ip]
|
||||
|
||||
|
||||
def record_failure(ip: str) -> Tuple[int, int]:
|
||||
def record_failure(ip: str) -> tuple[int, int]:
|
||||
"""Record a failed login attempt from an IP.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -12,3 +12,4 @@ sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=60.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
|
||||
@@ -5,11 +5,11 @@ Stored in data/saved_searches.json per user.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.saved_searches")
|
||||
|
||||
@@ -21,7 +21,7 @@ def _get_file(username: str) -> Path:
|
||||
return DATA_DIR / f"{username}_saved_searches.json"
|
||||
|
||||
|
||||
def _read(file: Path) -> List[Dict[str, Any]]:
|
||||
def _read(file: Path) -> list[dict[str, Any]]:
|
||||
if not file.exists():
|
||||
return []
|
||||
try:
|
||||
@@ -30,13 +30,13 @@ def _read(file: Path) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _write(file: Path, data: List[Dict[str, Any]]):
|
||||
def _write(file: Path, data: list[dict[str, Any]]):
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
|
||||
|
||||
def get_saved(username: str) -> List[Dict[str, Any]]:
|
||||
def get_saved(username: str) -> list[dict[str, Any]]:
|
||||
return _read(_get_file(username))
|
||||
|
||||
|
||||
|
||||
+213
-52
@@ -1,13 +1,13 @@
|
||||
from sortedcontainers import SortedList
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from snowballstemmer import stemmer as _snowball_stemmer
|
||||
from sortedcontainers import SortedList
|
||||
|
||||
from backend import indexer as _indexer
|
||||
from backend.indexer import index
|
||||
@@ -86,7 +86,7 @@ def normalize_text(text: str) -> str:
|
||||
return stripped.lower()
|
||||
|
||||
|
||||
def tokenize(text: str) -> List[str]:
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""Split text into normalized tokens (accent-stripped, lowercased words).
|
||||
|
||||
Args:
|
||||
@@ -101,7 +101,7 @@ def tokenize(text: str) -> List[str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag filter helper (unchanged for backward compat)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]:
|
||||
def _normalize_tag_filter(tag_filter: str | None) -> list[str]:
|
||||
"""Parse a comma-separated tag filter string into a clean list.
|
||||
|
||||
Strips whitespace and leading ``#`` from each tag.
|
||||
@@ -154,7 +154,7 @@ def _extract_snippet(content: str, query: str, context_chars: int = SNIPPET_CONT
|
||||
|
||||
def _extract_highlighted_snippet(
|
||||
content: str,
|
||||
query_terms: List[str],
|
||||
query_terms: list[str],
|
||||
context_chars: int = SNIPPET_CONTEXT_CHARS,
|
||||
max_highlights: int = MAX_SNIPPET_HIGHLIGHTS,
|
||||
) -> str:
|
||||
@@ -256,14 +256,13 @@ def _extract_regex_snippet(
|
||||
parts.append(_escape_html(snippet[prev:m.start()]))
|
||||
parts.append(f"<mark>{_escape_html(snippet[m.start():m.end()])}</mark>")
|
||||
prev = m.end()
|
||||
count += 1
|
||||
if prev < len(snippet):
|
||||
parts.append(_escape_html(snippet[prev:]))
|
||||
|
||||
return prefix + "".join(parts) + suffix
|
||||
|
||||
|
||||
def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
|
||||
def _highlight_terms(text: str, terms: list[str], max_highlights: int) -> str:
|
||||
"""Wrap occurrences of *terms* in *text* with ``<mark>`` tags.
|
||||
|
||||
Uses accent-normalized comparison so diacritical variants are matched.
|
||||
@@ -282,7 +281,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
|
||||
|
||||
norm = normalize_text(text)
|
||||
# Collect (start, end) spans for all term matches
|
||||
spans: List[Tuple[int, int]] = []
|
||||
spans: list[tuple[int, int]] = []
|
||||
for term in terms:
|
||||
idx = 0
|
||||
while idx < len(norm):
|
||||
@@ -297,7 +296,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
|
||||
|
||||
# Merge overlapping spans and limit count
|
||||
spans.sort()
|
||||
merged: List[Tuple[int, int]] = [spans[0]]
|
||||
merged: list[tuple[int, int]] = [spans[0]]
|
||||
for s, e in spans[1:]:
|
||||
if s <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
||||
@@ -306,7 +305,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
|
||||
merged = merged[:max_highlights]
|
||||
|
||||
# Build result with highlights
|
||||
parts: List[str] = []
|
||||
parts: list[str] = []
|
||||
prev = 0
|
||||
for s, e in merged:
|
||||
if s > prev:
|
||||
@@ -348,17 +347,17 @@ class InvertedIndex:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.word_index: Dict[str, Dict[str, int]] = defaultdict(dict)
|
||||
self.title_index: Dict[str, List[str]] = defaultdict(list)
|
||||
self.tag_norm_map: Dict[str, str] = {}
|
||||
self.tag_prefix_index: Dict[str, List[str]] = defaultdict(list)
|
||||
self.title_norm_map: Dict[str, List[Dict[str, str]]] = defaultdict(list)
|
||||
self.word_index: dict[str, dict[str, int]] = defaultdict(dict)
|
||||
self.title_index: dict[str, list[str]] = defaultdict(list)
|
||||
self.tag_norm_map: dict[str, str] = {}
|
||||
self.tag_prefix_index: dict[str, list[str]] = defaultdict(list)
|
||||
self.title_norm_map: dict[str, list[dict[str, str]]] = defaultdict(list)
|
||||
self.doc_count: int = 0
|
||||
self.doc_info: Dict[str, Dict[str, Any]] = {}
|
||||
self.doc_vault: Dict[str, str] = {}
|
||||
self.vault_docs: Dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: Dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: "SortedList" = SortedList()
|
||||
self.doc_info: dict[str, dict[str, Any]] = {}
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
@@ -415,12 +414,12 @@ class InvertedIndex:
|
||||
content = file_info.get("content", "")
|
||||
full_text = (file_info.get("title", "") + " " + content)
|
||||
tokens = tokenize(full_text)
|
||||
tf: Dict[str, int] = defaultdict(int)
|
||||
tf: dict[str, int] = defaultdict(int)
|
||||
for token in tokens:
|
||||
tf[token] += 1
|
||||
# Also index stemmed forms (French stemming)
|
||||
# Compute stem frequencies in one pass instead of O(S×T)
|
||||
stem_freqs: Dict[str, int] = defaultdict(int)
|
||||
stem_freqs: dict[str, int] = defaultdict(int)
|
||||
for token, freq in tf.items():
|
||||
stemmed = stem_token(token)
|
||||
if stemmed != token: # only index stem if different
|
||||
@@ -489,12 +488,12 @@ class InvertedIndex:
|
||||
content = file_info.get("content", "")
|
||||
full_text = title + " " + content
|
||||
tokens = tokenize(full_text)
|
||||
tf: Dict[str, int] = defaultdict(int)
|
||||
tf: dict[str, int] = defaultdict(int)
|
||||
for token in tokens:
|
||||
if token:
|
||||
tf[token] += 1
|
||||
# Also compute stems in one pass
|
||||
stem_freqs: Dict[str, int] = defaultdict(int)
|
||||
stem_freqs: dict[str, int] = defaultdict(int)
|
||||
for token, freq in tf.items():
|
||||
stemmed = stem_token(token)
|
||||
if stemmed != token:
|
||||
@@ -615,7 +614,7 @@ class InvertedIndex:
|
||||
return 0.0
|
||||
return tf * self.idf(term)
|
||||
|
||||
def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> List[str]:
|
||||
def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> list[str]:
|
||||
"""Get all tokens starting with *prefix* using binary search.
|
||||
|
||||
Uses a pre-sorted token list for O(log V + k) lookup instead
|
||||
@@ -631,7 +630,7 @@ class InvertedIndex:
|
||||
if not prefix or not self._sorted_tokens:
|
||||
return []
|
||||
lo = self._sorted_tokens.bisect_left(prefix)
|
||||
results: List[str] = []
|
||||
results: list[str] = []
|
||||
for i in range(lo, len(self._sorted_tokens)):
|
||||
if self._sorted_tokens[i].startswith(prefix):
|
||||
results.append(self._sorted_tokens[i])
|
||||
@@ -688,9 +687,9 @@ def get_inverted_index() -> InvertedIndex:
|
||||
def search(
|
||||
query: str,
|
||||
vault_filter: str = "all",
|
||||
tag_filter: Optional[str] = None,
|
||||
tag_filter: str | None = None,
|
||||
limit: int = DEFAULT_SEARCH_LIMIT,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search across indexed vaults with relevance scoring.
|
||||
|
||||
Scoring heuristics (when a text query is provided):
|
||||
@@ -722,7 +721,7 @@ def search(
|
||||
return []
|
||||
|
||||
query_lower = query.lower()
|
||||
results: List[Dict[str, Any]] = []
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for vault_name, vault_data in index.items():
|
||||
if vault_filter != "all" and vault_name != vault_filter:
|
||||
@@ -786,7 +785,7 @@ def search(
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advanced search with TF-IDF scoring
|
||||
# ---------------------------------------------------------------------------
|
||||
def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
|
||||
def _parse_advanced_query(raw_query: str) -> dict[str, Any]:
|
||||
"""Parse an advanced query string into structured filters and free text.
|
||||
|
||||
Supported operators:
|
||||
@@ -803,7 +802,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
|
||||
Returns:
|
||||
Dict with keys ``tags``, ``vault``, ``title``, ``path``, ``ext``, ``terms``.
|
||||
"""
|
||||
parsed: Dict[str, Any] = {
|
||||
parsed: dict[str, Any] = {
|
||||
"tags": [],
|
||||
"vault": None,
|
||||
"title": None,
|
||||
@@ -838,7 +837,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
|
||||
return parsed
|
||||
|
||||
|
||||
def _split_query_tokens(raw: str) -> List[str]:
|
||||
def _split_query_tokens(raw: str) -> list[str]:
|
||||
"""Split a query string respecting quoted phrases.
|
||||
|
||||
``tag:"my tag" hello world`` → ``['tag:my tag', 'hello', 'world']``
|
||||
@@ -849,7 +848,7 @@ def _split_query_tokens(raw: str) -> List[str]:
|
||||
Returns:
|
||||
List of token strings.
|
||||
"""
|
||||
tokens: List[str] = []
|
||||
tokens: list[str] = []
|
||||
i = 0
|
||||
n = len(raw)
|
||||
while i < n:
|
||||
@@ -890,14 +889,14 @@ def _split_query_tokens(raw: str) -> List[str]:
|
||||
|
||||
def _passes_search_filters(
|
||||
file_info: dict,
|
||||
query_terms: List[str],
|
||||
query_terms_raw: List[str],
|
||||
query_terms: list[str],
|
||||
query_terms_raw: list[str],
|
||||
raw_query: str,
|
||||
case_sensitive: bool,
|
||||
whole_word: bool,
|
||||
regex: bool,
|
||||
include_paths: Optional[str],
|
||||
exclude_paths: Optional[str],
|
||||
include_paths: str | None,
|
||||
exclude_paths: str | None,
|
||||
) -> bool:
|
||||
"""Post-filter a candidate by case-sensitive, whole-word, regex, and path filters."""
|
||||
title = file_info.get("title", "")
|
||||
@@ -937,7 +936,7 @@ def _passes_search_filters(
|
||||
return _passes_path_filters(path, include_paths, exclude_paths)
|
||||
|
||||
|
||||
def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[str]) -> bool:
|
||||
def _passes_path_filters(path: str, include: str | None, exclude: str | None) -> bool:
|
||||
"""Check if a file path passes include/exclude glob patterns."""
|
||||
import fnmatch
|
||||
if include:
|
||||
@@ -951,19 +950,159 @@ def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[st
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date and size filter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_date_range(raw: str | None) -> tuple | None:
|
||||
"""Parse a date range filter string.
|
||||
|
||||
Supported formats:
|
||||
- ">2024-01-01" → after (inclusive)
|
||||
- "<2024-06-01" → before (inclusive)
|
||||
- "2024-01-01..2024-06-01" → between (inclusive)
|
||||
- "<7d" → within last 7 days
|
||||
- ">30d" → older than 30 days
|
||||
- "YYYY-MM-DD" → exact date match (created/modified on that day)
|
||||
Returns (min_ts, max_ts) in Unix timestamps, or None if unparseable.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
|
||||
# Relative: <7d, >30d
|
||||
rel_match = re.match(r'^([<>])(\d+)([dh])$', raw)
|
||||
if rel_match:
|
||||
op, num, unit = rel_match.groups()
|
||||
n = int(num)
|
||||
seconds = n * 3600 if unit == 'h' else n * 86400
|
||||
now = time.time()
|
||||
if op == '<':
|
||||
return (now - seconds, now)
|
||||
else:
|
||||
return (0, now - seconds)
|
||||
|
||||
# Range: date1..date2
|
||||
if '..' in raw:
|
||||
parts = raw.split('..', 1)
|
||||
t1 = _parse_date_to_ts(parts[0].strip())
|
||||
t2 = _parse_date_to_ts(parts[1].strip())
|
||||
if t1 is not None and t2 is not None:
|
||||
return (min(t1, t2), max(t1, t2))
|
||||
return None
|
||||
|
||||
# Comparator: >date, <date
|
||||
if raw.startswith('>'):
|
||||
ts = _parse_date_to_ts(raw[1:].strip())
|
||||
return (ts, 9999999999) if ts is not None else None
|
||||
if raw.startswith('<'):
|
||||
ts = _parse_date_to_ts(raw[1:].strip())
|
||||
return (0, ts) if ts is not None else None
|
||||
|
||||
# Exact date
|
||||
ts = _parse_date_to_ts(raw)
|
||||
if ts is not None:
|
||||
return (ts, ts + 86400) # whole day
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_to_ts(s: str) -> float | None:
|
||||
"""Parse a date string to Unix timestamp. Supports YYYY-MM-DD."""
|
||||
s = s.strip()
|
||||
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y'):
|
||||
try:
|
||||
from datetime import datetime as dt_mod
|
||||
return dt_mod.strptime(s, fmt).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _matches_date_range(file_ts: float | None, date_range: tuple) -> bool:
|
||||
"""Check if a file timestamp falls within the given range."""
|
||||
if file_ts is None:
|
||||
return False
|
||||
return date_range[0] <= file_ts <= date_range[1]
|
||||
|
||||
|
||||
def _parse_size_range(raw: str | None) -> tuple | None:
|
||||
"""Parse a size range filter string.
|
||||
|
||||
Supported formats:
|
||||
- ">1MB", ">500KB", ">1GB" → larger than
|
||||
- "<100KB" → smaller than
|
||||
- "500KB..2MB" → between
|
||||
Returns (min_bytes, max_bytes), or None if unparseable.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
|
||||
def _parse_size(s: str) -> int | None:
|
||||
s = s.strip().upper()
|
||||
mult = 1
|
||||
if s.endswith('GB'):
|
||||
mult = 1024 * 1024 * 1024
|
||||
s = s[:-2]
|
||||
elif s.endswith('MB'):
|
||||
mult = 1024 * 1024
|
||||
s = s[:-2]
|
||||
elif s.endswith('KB'):
|
||||
mult = 1024
|
||||
s = s[:-2]
|
||||
elif s.endswith('B') or s.endswith('O'):
|
||||
mult = 1
|
||||
s = s[:-1]
|
||||
try:
|
||||
return int(float(s) * mult)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
if '..' in raw:
|
||||
parts = raw.split('..', 1)
|
||||
lo = _parse_size(parts[0])
|
||||
hi = _parse_size(parts[1])
|
||||
if lo is not None and hi is not None:
|
||||
return (min(lo, hi), max(lo, hi))
|
||||
return None
|
||||
|
||||
if raw.startswith('>'):
|
||||
lo = _parse_size(raw[1:])
|
||||
return (lo, 10**15) if lo is not None else None
|
||||
if raw.startswith('<'):
|
||||
hi = _parse_size(raw[1:])
|
||||
return (0, hi) if hi is not None else None
|
||||
|
||||
# Exact: try parsing as a number (bytes) or with unit
|
||||
exact = _parse_size(raw)
|
||||
if exact is not None:
|
||||
return (exact, exact * 2) # fuzzy match
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _matches_size_range(file_size: int, size_range: tuple) -> bool:
|
||||
"""Check if a file size falls within the given range."""
|
||||
return size_range[0] <= file_size <= size_range[1]
|
||||
|
||||
|
||||
def advanced_search(
|
||||
query: str,
|
||||
vault_filter: str = "all",
|
||||
tag_filter: Optional[str] = None,
|
||||
tag_filter: str | None = None,
|
||||
limit: int = ADVANCED_SEARCH_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
sort_by: str = "relevance",
|
||||
case_sensitive: bool = False,
|
||||
whole_word: bool = False,
|
||||
regex: bool = False,
|
||||
include_paths: Optional[str] = None,
|
||||
exclude_paths: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
include_paths: str | None = None,
|
||||
exclude_paths: str | None = None,
|
||||
created: str | None = None,
|
||||
modified: str | None = None,
|
||||
size: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
|
||||
|
||||
Uses the inverted index for O(k × postings) candidate retrieval instead
|
||||
@@ -1074,15 +1213,37 @@ def advanced_search(
|
||||
)
|
||||
}
|
||||
|
||||
# Date and size filters (from query operators or API params)
|
||||
date_range_created = _parse_date_range(created or parsed.get("created"))
|
||||
if date_range_created:
|
||||
candidates = {
|
||||
dk for dk in candidates
|
||||
if _matches_date_range(inv.doc_info[dk].get("created"), date_range_created)
|
||||
}
|
||||
|
||||
date_range_modified = _parse_date_range(modified or parsed.get("modified"))
|
||||
if date_range_modified:
|
||||
candidates = {
|
||||
dk for dk in candidates
|
||||
if _matches_date_range(inv.doc_info[dk].get("modified"), date_range_modified)
|
||||
}
|
||||
|
||||
size_range = _parse_size_range(size or parsed.get("size"))
|
||||
if size_range:
|
||||
candidates = {
|
||||
dk for dk in candidates
|
||||
if _matches_size_range(inv.doc_info[dk].get("size", 0), size_range)
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Score only the candidates (not all N documents)
|
||||
# ------------------------------------------------------------------
|
||||
scored_results: List[Tuple[float, Dict[str, Any]]] = []
|
||||
facet_tags: Dict[str, int] = defaultdict(int)
|
||||
facet_vaults: Dict[str, int] = defaultdict(int)
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: Dict[str, List[str]] = {}
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
if has_terms:
|
||||
for term in query_terms:
|
||||
if len(term) >= MIN_PREFIX_LENGTH:
|
||||
@@ -1192,7 +1353,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> List[Dict[str, str]]:
|
||||
) -> list[dict[str, str]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1208,7 +1369,7 @@ def suggest_titles(
|
||||
|
||||
inv = get_inverted_index()
|
||||
norm_prefix = normalize_text(prefix)
|
||||
results: List[Dict[str, str]] = []
|
||||
results: list[dict[str, str]] = []
|
||||
seen: set = set()
|
||||
|
||||
for norm_title, entries in inv.title_norm_map.items():
|
||||
@@ -1230,7 +1391,7 @@ def suggest_tags(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest tags matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1248,7 +1409,7 @@ def suggest_tags(
|
||||
norm_prefix = normalize_text(prefix)
|
||||
all_tag_counts = get_all_tags(vault_filter)
|
||||
|
||||
matches: List[Dict[str, Any]] = []
|
||||
matches: list[dict[str, Any]] = []
|
||||
for tag, count in all_tag_counts.items():
|
||||
norm_tag = normalize_text(tag)
|
||||
if norm_prefix in norm_tag:
|
||||
@@ -1262,7 +1423,7 @@ def suggest_tags(
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatible tag aggregation (unchanged API)
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
|
||||
def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
|
||||
"""Aggregate tag counts across vaults, sorted by descending count.
|
||||
|
||||
Args:
|
||||
@@ -1271,7 +1432,7 @@ def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
|
||||
Returns:
|
||||
Dict mapping tag names to their total occurrence count.
|
||||
"""
|
||||
merged: Dict[str, int] = {}
|
||||
merged: dict[str, int] = {}
|
||||
for vault_name, vault_data in index.items():
|
||||
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
|
||||
continue
|
||||
|
||||
@@ -13,8 +13,8 @@ Patterns detected:
|
||||
- Connection strings with passwords
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("obsigate.redactor")
|
||||
|
||||
|
||||
+19
-7
@@ -8,11 +8,10 @@ No authentication required for public share views.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.share")
|
||||
|
||||
@@ -39,7 +38,7 @@ def create_share(
|
||||
vault: str,
|
||||
path: str,
|
||||
created_by: str,
|
||||
expires_in_hours: Optional[int] = None,
|
||||
expires_in_hours: int | None = None,
|
||||
) -> dict:
|
||||
"""Create a new share token for a document."""
|
||||
data = _read()
|
||||
@@ -66,7 +65,7 @@ def create_share(
|
||||
return share
|
||||
|
||||
|
||||
def get_share_by_token(token: str) -> Optional[dict]:
|
||||
def get_share_by_token(token: str) -> dict | None:
|
||||
"""Look up a share by token. Returns None if expired or not found."""
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
@@ -100,7 +99,7 @@ def revoke_share(share_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def list_shares(vault_filter: Optional[str] = None) -> list:
|
||||
def list_shares(vault_filter: str | None = None) -> list:
|
||||
"""List all shares, optionally filtered by vault."""
|
||||
data = _read()
|
||||
shares = list(data["shares"].values())
|
||||
@@ -109,3 +108,16 @@ def list_shares(vault_filter: Optional[str] = None) -> list:
|
||||
# Most recent first
|
||||
shares.sort(key=lambda s: s.get("created_at", ""), reverse=True)
|
||||
return shares
|
||||
|
||||
|
||||
def update_shares_after_rename(vault: str, old_path: str, new_path: str):
|
||||
"""Update all shares when a file is renamed."""
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
|
||||
@@ -10,9 +10,9 @@ Current settings:
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.vault_settings")
|
||||
|
||||
@@ -21,10 +21,10 @@ _SETTINGS_PATH = _BASE_DIR / "data" / "vault_settings.json"
|
||||
_settings_lock = threading.RLock()
|
||||
|
||||
# In-memory cache of vault settings
|
||||
_vault_settings: Dict[str, Dict[str, Any]] = {}
|
||||
_vault_settings: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def load_vault_settings() -> Dict[str, Dict[str, Any]]:
|
||||
def load_vault_settings() -> dict[str, dict[str, Any]]:
|
||||
"""Load vault settings from disk.
|
||||
|
||||
Returns:
|
||||
@@ -78,7 +78,7 @@ def save_vault_settings() -> None:
|
||||
raise
|
||||
|
||||
|
||||
def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]:
|
||||
def get_vault_setting(vault_name: str) -> dict[str, Any] | None:
|
||||
"""Get settings for a specific vault.
|
||||
|
||||
Args:
|
||||
@@ -91,7 +91,7 @@ def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]:
|
||||
return _vault_settings.get(vault_name)
|
||||
|
||||
|
||||
def update_vault_setting(vault_name: str, settings: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def update_vault_setting(vault_name: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update settings for a specific vault.
|
||||
|
||||
Args:
|
||||
@@ -128,7 +128,7 @@ def delete_vault_setting(vault_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_all_vault_settings() -> Dict[str, Dict[str, Any]]:
|
||||
def get_all_vault_settings() -> dict[str, dict[str, Any]]:
|
||||
"""Get all vault settings.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Version management for ObsiGate.
|
||||
|
||||
Uses git describe to generate a SemVer-compatible version string.
|
||||
Format: MAJOR.MINOR.PATCH[-commits-since-tag-gHASH]
|
||||
|
||||
Examples:
|
||||
v1.7.0 (exact tag)
|
||||
v1.7.0-3-gabc1234 (3 commits after v1.7.0)
|
||||
v1.7.0-dev (fallback when Git unavailable)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Get the current version from Git tags."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--dirty=-dirty"],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
tag = result.stdout.strip()
|
||||
# git describe returns v1.7.0 or v1.7.0-3-gabc1234
|
||||
# Remove leading 'v' if present
|
||||
tag = tag.removeprefix("v")
|
||||
return tag
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
# Fallback: try reading from a VERSION file next to this module
|
||||
version_file = Path(__file__).parent / "VERSION"
|
||||
if version_file.exists():
|
||||
return version_file.read_text().strip()
|
||||
|
||||
return "0.0.0-dev"
|
||||
|
||||
|
||||
def get_version_tuple() -> tuple[int, int, int]:
|
||||
"""Return (major, minor, patch) tuple for programmatic use."""
|
||||
raw = get_version()
|
||||
# Strip -suffix for parsing
|
||||
base = raw.split("-")[0]
|
||||
try:
|
||||
parts = base.split(".")
|
||||
major = int(parts[0]) if len(parts) > 0 else 0
|
||||
minor = int(parts[1]) if len(parts) > 1 else 0
|
||||
patch = int(parts[2]) if len(parts) > 2 else 0
|
||||
return (major, minor, patch)
|
||||
except (ValueError, IndexError):
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(get_version())
|
||||
+8
-23
@@ -1,32 +1,17 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
|
||||
from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger("obsigate.watcher")
|
||||
|
||||
# Extensions de fichiers surveillées
|
||||
# Default ignored directories (can be overridden via OBSIGATE_IGNORED_DIRS env var)
|
||||
_DEFAULT_IGNORED_DIRS = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'}
|
||||
|
||||
def _load_ignored_dirs() -> set:
|
||||
"""Load ignored directories from environment or use defaults."""
|
||||
env_val = os.environ.get("OBSIGATE_IGNORED_DIRS", "")
|
||||
if env_val:
|
||||
custom = set(d.strip() for d in env_val.split(",") if d.strip())
|
||||
logger.info(f"Using custom IGNORED_DIRS: {custom}")
|
||||
return custom
|
||||
return _DEFAULT_IGNORED_DIRS.copy()
|
||||
|
||||
IGNORED_DIRS = _load_ignored_dirs()
|
||||
|
||||
|
||||
class VaultEventHandler(FileSystemEventHandler):
|
||||
"""Gestionnaire d'événements filesystem pour une vault Obsidian.
|
||||
@@ -105,10 +90,10 @@ class VaultWatcher:
|
||||
self.polling_interval = polling_interval
|
||||
self.observers: dict[str, object] = {}
|
||||
self.event_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._processor_task: Optional[asyncio.Task] = None
|
||||
self._processor_task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
|
||||
async def start(self, vaults: Dict[str, str]):
|
||||
async def start(self, vaults: dict[str, str]):
|
||||
"""Démarrer la surveillance de toutes les vaults.
|
||||
|
||||
Args:
|
||||
@@ -186,7 +171,7 @@ class VaultWatcher:
|
||||
Accumule les événements pendant debounce_seconds puis appelle
|
||||
le callback une seule fois avec la liste consolidée.
|
||||
"""
|
||||
pending: Dict[str, dict] = {}
|
||||
pending: dict[str, dict] = {}
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
@@ -210,7 +195,7 @@ class VaultWatcher:
|
||||
except Exception as e:
|
||||
logger.error(f"Event processor error: {e}")
|
||||
|
||||
async def _dispatch(self, events: List[dict]):
|
||||
async def _dispatch(self, events: list[dict]):
|
||||
"""Appeler le callback avec les événements consolidés."""
|
||||
try:
|
||||
await self.on_file_change(events)
|
||||
|
||||
+7
-10
@@ -8,19 +8,17 @@ Events: file_created, file_deleted, file_modified, file_renamed,
|
||||
directory_created, directory_deleted, directory_renamed
|
||||
"""
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
logger = logging.getLogger("obsigate.webhooks")
|
||||
|
||||
WEBHOOKS_FILE = Path("data/webhooks.json")
|
||||
@@ -51,7 +49,7 @@ def get_webhooks() -> list:
|
||||
return _read()
|
||||
|
||||
|
||||
def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] = None) -> dict:
|
||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||
webhooks = _read()
|
||||
wh = {
|
||||
"id": str(uuid.uuid4()),
|
||||
@@ -69,7 +67,7 @@ def create_webhook(name: str, url: str, events: List[str], secret: Optional[str]
|
||||
return wh
|
||||
|
||||
|
||||
def update_webhook(wh_id: str, updates: dict) -> Optional[dict]:
|
||||
def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
@@ -110,8 +108,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
||||
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(wh["url"], data=body, headers=headers) as resp:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp:
|
||||
if resp.status < 400:
|
||||
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# =============================================================================
|
||||
# ObsiGate — Script de build et déploiement Docker (Windows PowerShell)
|
||||
# =============================================================================
|
||||
#
|
||||
# Usage :
|
||||
# .\build.ps1 [-Prod | -Test] [OPTIONS]
|
||||
#
|
||||
# Environnements :
|
||||
# -Prod Production (défaut) — docker-compose.yml
|
||||
# -Test Test Windows (Docker Desktop) — docker-compose.test-win.yml
|
||||
#
|
||||
# Options :
|
||||
# -BuildOnly Construit l'image sans démarrer le conteneur
|
||||
# -NoCache Rebuild complet sans cache Docker (défaut)
|
||||
# -UseCache Utilise le cache Docker (plus rapide si peu de changements)
|
||||
# -Help Affiche cette aide
|
||||
#
|
||||
# Exemples :
|
||||
# .\build.ps1 # Production
|
||||
# .\build.ps1 -Test # Test Windows
|
||||
# .\build.ps1 -Test -BuildOnly # Build test sans démarrer
|
||||
# .\build.ps1 -Prod -UseCache # Production avec cache
|
||||
# =============================================================================
|
||||
|
||||
param(
|
||||
[switch]$Prod,
|
||||
[switch]$Test,
|
||||
[switch]$BuildOnly,
|
||||
[switch]$NoCache,
|
||||
[switch]$UseCache,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
# ----- Helpers -----
|
||||
function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Cyan }
|
||||
function Write-Ok { Write-Host "[OK] $args" -ForegroundColor Green }
|
||||
function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow }
|
||||
function Write-Error { Write-Host "[ERR] $args" -ForegroundColor Red }
|
||||
|
||||
# ----- Help -----
|
||||
if ($Help) {
|
||||
Get-Help $PSCommandPath -Detailed
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ----- Select environment -----
|
||||
$EnvMode = "prod"
|
||||
$ComposeEnvName = "production"
|
||||
if ($Test) {
|
||||
$EnvMode = "test-win"
|
||||
$ComposeEnvName = "test Windows"
|
||||
}
|
||||
|
||||
switch ($EnvMode) {
|
||||
"prod" { $ComposeFile = "docker-compose.yml" }
|
||||
"test-win" { $ComposeFile = "docker-compose.test-win.yml" }
|
||||
}
|
||||
|
||||
# ----- Pre-flight checks -----
|
||||
$dockerVersion = docker --version 2>$null
|
||||
if (-not $dockerVersion) {
|
||||
Write-Error "Docker est introuvable. Installez Docker Desktop avant de continuer."
|
||||
exit 1
|
||||
}
|
||||
Write-Ok "Docker détecté"
|
||||
|
||||
$composeVersion = docker compose version 2>$null
|
||||
if (-not $composeVersion) {
|
||||
Write-Error "Docker Compose est introuvable (plugin docker compose requis)."
|
||||
exit 1
|
||||
}
|
||||
Write-Ok $composeVersion.Trim()
|
||||
|
||||
# ----- Validate compose file -----
|
||||
if (-not (Test-Path $ComposeFile)) {
|
||||
Write-Error "$ComposeFile introuvable dans $(Get-Location)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "=== ObsiGate — Build & Deploy ($ComposeEnvName) ==="
|
||||
Write-Info "Fichier compose : $ComposeFile"
|
||||
|
||||
# ----- Test mode: create test directories -----
|
||||
if ($EnvMode -eq "test-win") {
|
||||
Write-Info "Création des dossiers de test..."
|
||||
|
||||
$testVault = "C:\dev\git\python\ObsiGate\test_vault"
|
||||
$testDir = "C:\dev\git\python\ObsiGate\test_dir"
|
||||
|
||||
if (-not (Test-Path $testVault)) {
|
||||
New-Item -ItemType Directory -Force -Path $testVault | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $testDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $testDir | Out-Null
|
||||
}
|
||||
|
||||
$welcomeFile = Join-Path $testVault "Accueil.md"
|
||||
if (-not (Test-Path $welcomeFile)) {
|
||||
@"
|
||||
# Bienvenue dans le vault de test
|
||||
|
||||
Ce vault est utilisé pour les tests de développement d'ObsiGate.
|
||||
"@ | Out-File -Encoding UTF8 -FilePath $welcomeFile
|
||||
Write-Ok "Note de test créée : test_vault\Accueil.md"
|
||||
}
|
||||
}
|
||||
|
||||
# ----- Build the image -----
|
||||
$BuildArgs = @("-f", $ComposeFile)
|
||||
if (-not $UseCache) {
|
||||
$BuildArgs += "--no-cache"
|
||||
Write-Info "Construction de l'image Docker (sans cache)..."
|
||||
} else {
|
||||
Write-Info "Construction de l'image Docker (avec cache)..."
|
||||
}
|
||||
|
||||
docker compose @BuildArgs build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Échec de la construction de l'image Docker."
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Write-Ok "Image construite avec succès"
|
||||
|
||||
# ----- Stop if build-only -----
|
||||
if ($BuildOnly) {
|
||||
Write-Host ""
|
||||
Write-Info "Mode build-only : conteneur non démarré."
|
||||
Write-Info "Pour démarrer manuellement : docker compose -f $ComposeFile up -d"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ----- Start the container -----
|
||||
Write-Info "Démarrage du conteneur..."
|
||||
docker compose -f $ComposeFile up -d --remove-orphans
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Échec du démarrage du conteneur."
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Write-Ok "Conteneur démarré"
|
||||
|
||||
# ----- Show status & tip -----
|
||||
Write-Host ""
|
||||
Write-Info "Statut du conteneur :"
|
||||
docker compose -f $ComposeFile ps
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Application accessible sur http://localhost:2020"
|
||||
if ($EnvMode -eq "prod") {
|
||||
Write-Info "Login : admin / (mot de passe dans .env)"
|
||||
} else {
|
||||
Write-Info "Login : admin / test123"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Info "Logs (Ctrl+C pour quitter) :"
|
||||
docker compose -f $ComposeFile logs --tail=20 -f
|
||||
@@ -4,7 +4,12 @@
|
||||
# =============================================================================
|
||||
#
|
||||
# Usage :
|
||||
# ./build.sh [OPTIONS]
|
||||
# ./build.sh [--prod | --test-win | --test-linux] [OPTIONS]
|
||||
#
|
||||
# Environnements :
|
||||
# --prod Production (défaut) — docker-compose.yml
|
||||
# --test-win Test Windows (Docker Desktop) — docker-compose.test-win.yml
|
||||
# --test-linux Test Linux — docker-compose.test-linux.yml
|
||||
#
|
||||
# Options :
|
||||
# --help, -h Affiche cette aide
|
||||
@@ -16,17 +21,17 @@
|
||||
#
|
||||
# Étapes exécutées :
|
||||
# 1. Vérifie que Docker et Docker Compose sont installés
|
||||
# 2. Valide le fichier docker-compose.yml
|
||||
# 3. Vérifie que les volumes montés existent sur le host
|
||||
# 2. Valide le fichier docker-compose choisi
|
||||
# 3. Crée les dossiers de test si nécessaire (modes test uniquement)
|
||||
# 4. Construit l'image Docker (multi-stage, ~180MB)
|
||||
# 5. Démarre le conteneur en arrière-plan
|
||||
# 6. Affiche le statut et les logs
|
||||
#
|
||||
# Exemples :
|
||||
# ./build.sh # Build from scratch + démarrage
|
||||
# ./build.sh --cache # Build avec cache + démarrage
|
||||
# ./build.sh --build-only # Build uniquement, sans démarrer
|
||||
# ./build.sh --progress=plain # Sortie verbeuse
|
||||
# ./build.sh # Production
|
||||
# ./build.sh --test-win # Test Windows
|
||||
# ./build.sh --test-linux --cache # Test Linux avec cache
|
||||
# ./build.sh --test-win --build-only # Build test Windows sans démarrer
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
@@ -43,6 +48,8 @@ show_help() {
|
||||
}
|
||||
|
||||
# ----- Parse args -----
|
||||
ENV_MODE="prod"
|
||||
COMPOSE_FILE=""
|
||||
BUILD_ONLY=false
|
||||
USE_CACHE=false
|
||||
BUILD_ARGS=()
|
||||
@@ -52,6 +59,15 @@ for arg in "$@"; do
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
--prod)
|
||||
ENV_MODE="prod"
|
||||
;;
|
||||
--test-win)
|
||||
ENV_MODE="test-win"
|
||||
;;
|
||||
--test-linux)
|
||||
ENV_MODE="test-linux"
|
||||
;;
|
||||
--build-only)
|
||||
BUILD_ONLY=true
|
||||
;;
|
||||
@@ -67,6 +83,26 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# ----- Select compose file -----
|
||||
case "$ENV_MODE" in
|
||||
prod)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
COMPOSE_ENV_NAME="production"
|
||||
;;
|
||||
test-win)
|
||||
COMPOSE_FILE="docker-compose.test-win.yml"
|
||||
COMPOSE_ENV_NAME="test Windows"
|
||||
;;
|
||||
test-linux)
|
||||
COMPOSE_FILE="docker-compose.test-linux.yml"
|
||||
COMPOSE_ENV_NAME="test Linux"
|
||||
;;
|
||||
*)
|
||||
error "Environnement inconnu : $ENV_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ----- Pre-flight checks -----
|
||||
if ! command -v docker &>/dev/null; then
|
||||
error "Docker est introuvable. Installez Docker avant de continuer."
|
||||
@@ -87,21 +123,46 @@ fi
|
||||
ok "$($COMPOSE_CMD version --short 2>/dev/null || $COMPOSE_CMD version | head -1)"
|
||||
|
||||
# ----- Validate compose file -----
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||
error "$COMPOSE_FILE introuvable dans $(pwd)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "=== ObsiGate — Build & Deploy ==="
|
||||
echo ""
|
||||
info "=== ObsiGate — Build & Deploy ($COMPOSE_ENV_NAME) ==="
|
||||
info "Fichier compose : $COMPOSE_FILE"
|
||||
|
||||
# ----- Extract volume mount paths from compose file and warn about missing ones -----
|
||||
# ----- Test mode: create test directories -----
|
||||
if [[ "$ENV_MODE" == "test-win" ]]; then
|
||||
info "Création des dossiers de test Windows..."
|
||||
mkdir -p /c/dev/git/python/ObsiGate/test_vault
|
||||
mkdir -p /c/dev/git/python/ObsiGate/test_dir
|
||||
# Create a sample note for the test vault
|
||||
if [[ ! -f /c/dev/git/python/ObsiGate/test_vault/Accueil.md ]]; then
|
||||
echo "# Bienvenue dans le vault de test" > /c/dev/git/python/ObsiGate/test_vault/Accueil.md
|
||||
echo "" >> /c/dev/git/python/ObsiGate/test_vault/Accueil.md
|
||||
echo "Ce vault est utilisé pour les tests de développement d'ObsiGate." >> /c/dev/git/python/ObsiGate/test_vault/Accueil.md
|
||||
ok "Note de test créée : test_vault/Accueil.md"
|
||||
fi
|
||||
elif [[ "$ENV_MODE" == "test-linux" ]]; then
|
||||
info "Création des dossiers de test Linux..."
|
||||
mkdir -p /tmp/obsigate-test-vault
|
||||
mkdir -p /tmp/obsigate-test-dir
|
||||
mkdir -p /tmp/obsigate-test-data
|
||||
chmod -R 777 /tmp/obsigate-test-data 2>/dev/null || true
|
||||
if [[ ! -f /tmp/obsigate-test-vault/Accueil.md ]]; then
|
||||
echo "# Bienvenue dans le vault de test" > /tmp/obsigate-test-vault/Accueil.md
|
||||
echo "" >> /tmp/obsigate-test-vault/Accueil.md
|
||||
echo "Ce vault est utilisé pour les tests de développement d'ObsiGate." >> /tmp/obsigate-test-vault/Accueil.md
|
||||
ok "Note de test créée : test-vault/Accueil.md"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ----- Extract volume mount paths and warn about missing ones -----
|
||||
info "Vérification des volumes montés sur le host..."
|
||||
VOLUME_COUNT=0
|
||||
MISSING_COUNT=0
|
||||
while IFS= read -r line; do
|
||||
# Lines like: - /host/path:/container/path
|
||||
if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+(/[^:]+): ]]; then
|
||||
host_path="${BASH_REMATCH[1]}"
|
||||
VOLUME_COUNT=$((VOLUME_COUNT + 1))
|
||||
@@ -115,7 +176,7 @@ while IFS= read -r line; do
|
||||
done < <(grep -E '^\s+-\s+/' "$COMPOSE_FILE")
|
||||
|
||||
if [[ "$VOLUME_COUNT" -eq 0 ]]; then
|
||||
warn "Aucun volume host détecté dans $COMPOSE_FILE. Pensez à configurer vos vaults."
|
||||
warn "Aucun volume host détecté dans $COMPOSE_FILE."
|
||||
fi
|
||||
if [[ "$MISSING_COUNT" -gt 0 ]]; then
|
||||
warn "$MISSING_COUNT volume(s) absent(s). Vérifiez vos chemins dans $COMPOSE_FILE."
|
||||
@@ -132,27 +193,32 @@ else
|
||||
info "Construction de l'image Docker (avec cache)..."
|
||||
fi
|
||||
|
||||
$COMPOSE_CMD build "${BUILD_FLAGS[@]}" "${BUILD_ARGS[@]}"
|
||||
# Generate VERSION file from git before Docker build
|
||||
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
|
||||
echo "$VERSION" > backend/VERSION
|
||||
info "Version: $VERSION"
|
||||
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" build "${BUILD_FLAGS[@]}" "${BUILD_ARGS[@]}"
|
||||
ok "Image construite avec succès"
|
||||
|
||||
# ----- Stop if build-only -----
|
||||
if [[ "$BUILD_ONLY" == true ]]; then
|
||||
echo ""
|
||||
info "Mode build-only : conteneur non démarré."
|
||||
info "Pour démarrer manuellement : $COMPOSE_CMD up -d"
|
||||
info "Pour démarrer manuellement : $COMPOSE_CMD -f $COMPOSE_FILE up -d"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ----- Start the container -----
|
||||
info "Démarrage du conteneur..."
|
||||
$COMPOSE_CMD up -d --remove-orphans
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" up -d --remove-orphans
|
||||
ok "Conteneur démarré"
|
||||
|
||||
# ----- Show status -----
|
||||
echo ""
|
||||
info "Statut du conteneur :"
|
||||
$COMPOSE_CMD ps
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo ""
|
||||
info "Logs récents (Ctrl+C pour quitter) :"
|
||||
$COMPOSE_CMD logs --tail=20 -f
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20 -f
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "f7b85dfa-bcf6-4e42-b861-7563673620a6",
|
||||
"username": "admin",
|
||||
"display_name": "admin",
|
||||
"password_hash": "$argon2id$v=19$m=65536,t=2,p=2$H4Pa7H4HOWeQoEbj4Ndkbw$c61e9zRHuXXdrLB6uTrjIBo/FFQaaZMaNHkB7CZaHhM",
|
||||
"role": "admin",
|
||||
"vaults": [
|
||||
"*"
|
||||
],
|
||||
"active": true,
|
||||
"created_at": "2026-03-23T19:38:00.742597+00:00",
|
||||
"last_login": null,
|
||||
"failed_attempts": 5,
|
||||
"locked_until": "2026-06-02T14:01:43.472937+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+6045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.0.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
chrono = "0.4"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,302 @@
|
||||
# ObsiGate Desktop
|
||||
|
||||
Application desktop native pour [ObsiGate](https://git.dracodev.net/Projets/ObsiGate) — construite avec [Tauri](https://tauri.app/) (Rust + webview système).
|
||||
|
||||
> 🚧 **Version 2.0.0 — les binaires sont en cours de stabilisation.** Le build depuis les sources est la méthode recommandée pour le moment.
|
||||
|
||||
## Table des matières
|
||||
|
||||
- [Installation (binaires pré-buildés)](#installation-binaires-pre-buildes)
|
||||
- [Build depuis les sources](#build-depuis-les-sources)
|
||||
- [Démarrage](#demarrage)
|
||||
- [Configuration des vaults](#configuration-des-vaults)
|
||||
- [Architecture](#architecture)
|
||||
- [Fonctionnalités natives](#fonctionnalites-natives)
|
||||
- [Dépannage](#depannage)
|
||||
|
||||
---
|
||||
|
||||
## Installation (binaires pré-buildés)
|
||||
|
||||
Les releases sont publiées sur [Gitea](https://git.dracodev.net/Projets/ObsiGate/releases).
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# .deb (Debian/Ubuntu/Deepin) — installation système
|
||||
sudo dpkg -i obsigate_2.0.0_amd64.deb
|
||||
# Lancement : menu applications → ObsiGate, ou :
|
||||
obsigate-desktop
|
||||
|
||||
# .AppImage (toute distribution) — portable, pas d'installation
|
||||
chmod +x ObsiGate_2.0.0_amd64.AppImage
|
||||
./ObsiGate_2.0.0_amd64.AppImage
|
||||
```
|
||||
|
||||
Dépendances runtime (normalement déjà présentes) :
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```cmd
|
||||
:: .msi — installation standard
|
||||
:: Double-cliquer sur ObsiGate_2.0.0_x64.msi
|
||||
:: → Crée un raccourci bureau + entrée menu Démarrer
|
||||
|
||||
:: .exe NSIS — installateur interactif
|
||||
:: Mêmes options, inclut le raccourci
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
> Non supporté pour le moment (priorité Linux/Windows).
|
||||
|
||||
---
|
||||
|
||||
## Build depuis les sources
|
||||
|
||||
### Prérequis communs
|
||||
|
||||
- [Rust](https://rustup.rs/) stable ≥ 1.75
|
||||
- Tauri CLI : `cargo install tauri-cli`
|
||||
- Python 3.11+
|
||||
- Git
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# 1. Dépendances système
|
||||
sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev curl
|
||||
|
||||
# 2. Cloner le projet
|
||||
git clone https://git.dracodev.net/Projets/ObsiGate.git
|
||||
cd ObsiGate/desktop
|
||||
|
||||
# 3. Build (tout-en-un)
|
||||
chmod +x build-linux.sh
|
||||
./build-linux.sh
|
||||
```
|
||||
|
||||
Le script s'occupe de :
|
||||
1. Vérifier Rust + Tauri CLI + dépendances système
|
||||
2. Créer l'environnement Python (venv + requirements)
|
||||
3. Copier le frontend
|
||||
4. Builder Tauri en mode release
|
||||
|
||||
Produits :
|
||||
- `target/release/bundle/deb/obsigate_2.0.0_amd64.deb`
|
||||
- `target/release/bundle/appimage/ObsiGate_2.0.0_amd64.AppImage`
|
||||
|
||||
### Windows (via Scoop)
|
||||
|
||||
```powershell
|
||||
# 1. Installer Scoop (si pas déjà fait)
|
||||
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
|
||||
irm get.scoop.sh | iex
|
||||
|
||||
# 2. Prérequis via Scoop
|
||||
scoop install rustup curl git
|
||||
rustup default stable
|
||||
cargo install tauri-cli
|
||||
|
||||
# 3. Cloner
|
||||
git clone https://git.dracodev.net/Projets/ObsiGate.git
|
||||
cd ObsiGate\desktop
|
||||
|
||||
# 4. Build
|
||||
.\build-windows.bat
|
||||
```
|
||||
|
||||
Le script télécharge automatiquement Python 3.11 embed et les dépendances backend.
|
||||
|
||||
Produits :
|
||||
- `target\release\bundle\msi\ObsiGate_2.0.0_x64.msi`
|
||||
- `target\release\bundle\nsis\ObsiGate_2.0.0_x64-setup.exe`
|
||||
|
||||
---
|
||||
|
||||
## Démarrage
|
||||
|
||||
### Premier lancement
|
||||
|
||||
1. **Lance l'application** (menu ou ligne de commande)
|
||||
- 💡 *Premier lancement recommandé depuis un terminal* pour voir le mot de passe admin généré
|
||||
2. Le backend Python démarre automatiquement → health check sur `127.0.0.1:17890`
|
||||
3. La fenêtre s'ouvre sur l'interface ObsiGate (écran de connexion si l'auth est activée)
|
||||
4. **Connecte-toi** avec le compte admin créé automatiquement (voir [Compte administrateur](#compte-administrateur))
|
||||
5. **Sélectionne le dossier parent de tes vaults** Obsidian via le sélecteur natif
|
||||
6. L'indexation démarre automatiquement
|
||||
|
||||
### Compte administrateur
|
||||
|
||||
Au premier démarrage, un compte administrateur est créé automatiquement :
|
||||
|
||||
- **Login** : `admin` (modifiable via la variable d'environnement `OBSIGATE_ADMIN_USER`)
|
||||
- **Mot de passe** : généré aléatoirement (16 caractères) et affiché **une seule fois** dans les logs
|
||||
|
||||
Le mot de passe apparaît dans la sortie du terminal sous cette forme :
|
||||
```
|
||||
============================================================
|
||||
PREMIER DÉMARRAGE — Compte admin créé automatiquement
|
||||
Utilisateur : admin
|
||||
Mot de passe : XXXXXXXXXXXXXXXX
|
||||
CHANGEZ CE MOT DE PASSE dès la première connexion !
|
||||
============================================================
|
||||
```
|
||||
|
||||
> ⚠️ **Note le mot de passe immédiatement** — il n'est affiché qu'une seule fois.
|
||||
> Si tu rates les logs, supprime le dossier `data/` et relance l'application pour
|
||||
> régénérer un nouveau compte.
|
||||
|
||||
**Prédéfinir le mot de passe** (recommandé) :
|
||||
|
||||
Avant le premier lancement, définis les variables d'environnement suivantes :
|
||||
|
||||
- **Windows** (PowerShell) : `$env:OBSIGATE_ADMIN_PASSWORD="MonMotDePasse"`
|
||||
- **Linux** : `export OBSIGATE_ADMIN_PASSWORD="MonMotDePasse"`
|
||||
|
||||
Tu peux aussi définir `OBSIGATE_ADMIN_USER` pour changer le nom d'utilisateur.
|
||||
|
||||
### Lancements suivants
|
||||
|
||||
- L'application mémorise le dernier dossier configuré
|
||||
- Le backend démarre en ~2 secondes
|
||||
- Les vaults sont ré-indexées au démarrage (incrémental si inchangé)
|
||||
|
||||
### Arrêt
|
||||
|
||||
- **Icône tray → Quitter** (recommandé) : SIGTERM → arrêt propre du backend → fermeture webview
|
||||
- **Fermeture fenêtre (X)** : minimise dans le tray (backend continue de tourner)
|
||||
- **Ctrl+C** dans le terminal (si lancé en dev) : arrêt immédiat
|
||||
|
||||
### Mode développement
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
cargo tauri dev
|
||||
# → Lance le backend Python + ouvre la webview avec hot-reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration des vaults
|
||||
|
||||
Au premier lancement, le sélecteur de dossier natif te demande le **dossier parent** contenant tes vaults Obsidian. Exemple :
|
||||
|
||||
```
|
||||
/home/bruno/Documents/
|
||||
├── Obsidian-Recettes/ ← vault #1
|
||||
├── Obsidian-IT/ ← vault #2
|
||||
└── Obsidian-Perso/ ← vault #3
|
||||
```
|
||||
|
||||
Tous les sous-dossiers contenant un dossier `.obsidian` sont automatiquement détectés comme vaults.
|
||||
|
||||
### Configuration avancée
|
||||
|
||||
Le fichier de config desktop est stocké dans :
|
||||
- **Linux** : `~/.config/obsigate-desktop/config.json`
|
||||
- **Windows** : `%APPDATA%\ObsiGate\config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"vaults_path": "/home/bruno/Documents",
|
||||
"backend_port": 17890,
|
||||
"theme": "system"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
desktop/
|
||||
├── Cargo.toml # Dépendances Rust (Tauri 2, plugins)
|
||||
├── tauri.conf.json # Config Tauri (fenêtre, bundle, plugins)
|
||||
├── build.rs # Script build Tauri
|
||||
├── src/
|
||||
│ └── main.rs # Point d'entrée Rust : spawn backend + webview
|
||||
├── python-embed/
|
||||
│ ├── venv/ # Environnement Python isolé
|
||||
│ └── ... # site-packages gelés
|
||||
├── build-linux.sh # Script build Linux (.deb + .AppImage)
|
||||
├── build-windows.bat # Script build Windows (.msi + NSIS)
|
||||
└── icons/ # Icônes desktop (.ico, .png, .icns)
|
||||
```
|
||||
|
||||
### Cycle de vie
|
||||
|
||||
```
|
||||
Démarrage :
|
||||
1. Tauri lance python-embed/venv/bin/python → uvicorn backend.main:app
|
||||
2. Health check GET /api/health (timeout 15s)
|
||||
3. Webview ouvre http://127.0.0.1:17890
|
||||
|
||||
Arrêt (tray → Quitter ou Ctrl+C) :
|
||||
1. SIGTERM envoyé au processus Python
|
||||
2. Backend s'arrête proprement (10s timeout)
|
||||
3. Tauri ferme la webview
|
||||
4. Processus nettoyé — aucun résidu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités desktop natives
|
||||
|
||||
| Fonctionnalité | Web (Docker) | Desktop (Tauri) |
|
||||
|---|---|---|
|
||||
| Accès fichiers local | Via upload | Natif (sélecteur dossier) |
|
||||
| Thème | Manuel (toggle) | Auto (suit OS dark/light) |
|
||||
| Notifications | Service Worker | Natif OS |
|
||||
| Associations `.md` | ❌ | ✅ « Ouvrir avec ObsiGate » |
|
||||
| Tray icon | ❌ | ✅ Barre des tâches |
|
||||
| Auto-update | ❌ (docker pull) | ✅ Vérifie releases Gitea |
|
||||
| Mode hors-ligne | Limité | Complet (backend local) |
|
||||
| RAM au repos | ~150 MB (Docker) | ~80 MB (natif) |
|
||||
| Démarrage à froid | ~5s (Docker) | ~2s |
|
||||
|
||||
---
|
||||
|
||||
## Dépannage
|
||||
|
||||
### Le backend ne démarre pas
|
||||
|
||||
```bash
|
||||
# Vérifier que le port 17890 est libre
|
||||
ss -tlnp | grep 17890
|
||||
|
||||
# Lancer le backend manuellement pour voir les logs
|
||||
cd ObsiGate
|
||||
python -m uvicorn backend.main:app --host 127.0.0.1 --port 17890 --log-level debug
|
||||
```
|
||||
|
||||
### La fenêtre reste blanche
|
||||
|
||||
- Vérifier que le backend répond : `curl http://127.0.0.1:17890/api/health`
|
||||
- Si "connection refused" → le backend n'a pas démarré (voir section précédente)
|
||||
- Si le healthcheck répond mais la webview est blanche → problème CSP ou webview
|
||||
|
||||
### Erreur libwebkit2gtk non trouvé
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian/Deepin
|
||||
sudo apt install libwebkit2gtk-4.1-0
|
||||
|
||||
# Si l'erreur persiste, installer la version dev
|
||||
sudo apt install libwebkit2gtk-4.1-dev
|
||||
```
|
||||
|
||||
### Build échoue sur Linux
|
||||
|
||||
```bash
|
||||
# Nettoyer et rebuild
|
||||
cargo clean
|
||||
rm -rf python-embed/venv
|
||||
./build-linux.sh
|
||||
|
||||
# Si erreur de linking, vérifier les -dev packages
|
||||
sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev libssl-dev
|
||||
```
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# ObsiGate Desktop — Build Linux (.deb + .AppImage)
|
||||
# Prérequis : Rust, Tauri CLI, libwebkit2gtk, Python 3.11
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
echo "========================================"
|
||||
echo " ObsiGate Desktop — Build Linux"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# ── 1. Vérifier les prérequis ─────────────────────────────────
|
||||
echo "[1/5] Verifying prerequisites..."
|
||||
command -v cargo >/dev/null 2>&1 || { echo "❌ Rust/Cargo not found. Install: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"; exit 1; }
|
||||
command -v tauri >/dev/null 2>&1 || cargo install tauri-cli
|
||||
|
||||
# Check system deps
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev curl 2>/dev/null
|
||||
fi
|
||||
echo "✅ Dependencies OK"
|
||||
|
||||
# ── 2. Préparer Python 3.11 ────────────────────────────────────
|
||||
echo "[2/5] Preparing Python environment..."
|
||||
python3 -m venv python-embed/venv
|
||||
source python-embed/venv/bin/activate
|
||||
pip install --upgrade pip -q
|
||||
pip install -r ../backend/requirements.txt -q
|
||||
echo "✅ Python environment ready"
|
||||
|
||||
# ── 2b. Stage backend + frontend pour le bundle ───────────────
|
||||
# tauri.conf.json embarque backend/** et frontend/** depuis desktop/ :
|
||||
# sans ce staging, tauri_build échoue (« glob pattern backend/**/* path not found »).
|
||||
echo "[2b/5] Staging backend + frontend for bundle..."
|
||||
rm -rf backend frontend
|
||||
cp -r ../backend backend
|
||||
cp -r ../frontend frontend
|
||||
echo "✅ Staged"
|
||||
|
||||
# ── 3. Build Tauri ────────────────────────────────────────────
|
||||
echo "[3/5] Building Tauri application..."
|
||||
cargo tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
echo "✅ Build successful"
|
||||
|
||||
# ── 4. Copier le runtime à côté de l'exécutable ───────────────
|
||||
OUTDIR="target/x86_64-unknown-linux-gnu/release"
|
||||
echo "[4/5] Copying runtime to $OUTDIR..."
|
||||
rm -rf "$OUTDIR/python-embed" "$OUTDIR/backend" "$OUTDIR/frontend"
|
||||
cp -r python-embed "$OUTDIR/"
|
||||
cp -r ../backend "$OUTDIR/"
|
||||
cp -r ../frontend "$OUTDIR/"
|
||||
echo "✅ Runtime + frontend copied"
|
||||
|
||||
# ── 4b. Nettoyer les dossiers stagés ──────────────────────────
|
||||
rm -rf backend frontend
|
||||
echo "✅ Cleaned"
|
||||
|
||||
# ── 5. Résultats ───────────────────────────────────────────────
|
||||
OUTDIR="target/x86_64-unknown-linux-gnu/release"
|
||||
echo "[5/5] Results:"
|
||||
ls -lh "$OUTDIR/bundle/deb/"*.deb 2>/dev/null
|
||||
ls -lh "$OUTDIR/bundle/appimage/"*.AppImage 2>/dev/null
|
||||
echo ""
|
||||
echo "✅ Done! Check $OUTDIR/bundle/"
|
||||
@@ -0,0 +1,88 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM ObsiGate Desktop — Build Windows (NSIS installer)
|
||||
REM Prérequis : Rust, Tauri CLI, NSIS, Python 3.11 embed
|
||||
REM ============================================================
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ========================================
|
||||
echo ObsiGate Desktop — Build Windows
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM ── 0. Kill any leftover backend from a previous run ──────────
|
||||
echo [0/6] Killing leftover Python processes...
|
||||
taskkill /F /IM python.exe 2>nul
|
||||
echo.
|
||||
|
||||
REM ── 1. Vérifier les prérequis ─────────────────────────────────
|
||||
echo [1/6] Verifying prerequisites...
|
||||
where cargo >nul 2>&1 || (echo ❌ Rust/Cargo not found. Install from https://rustup.rs & exit /b 1)
|
||||
where tauri >nul 2>&1 || cargo install tauri-cli
|
||||
echo ✅ Rust + Tauri OK
|
||||
|
||||
REM ── 2. Préparer Python 3.11 embed ─────────────────────────────
|
||||
echo [2/6] Preparing Python embed...
|
||||
echo 📥 Downloading Python 3.11 embed...
|
||||
if exist "python-embed" rmdir /s /q "python-embed"
|
||||
curl -L -o python-embed.zip "https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip"
|
||||
powershell Expand-Archive -Path python-embed.zip -DestinationPath python-embed\
|
||||
del python-embed.zip
|
||||
|
||||
REM Enable pip and site-packages + parent dir for 'import backend'
|
||||
echo import site >> python-embed\python311._pth
|
||||
echo ..>> python-embed\python311._pth
|
||||
|
||||
REM Download get-pip.py (embed Python doesn't include ensurepip)
|
||||
echo 📥 Downloading get-pip.py...
|
||||
curl -L -o get-pip.py "https://bootstrap.pypa.io/get-pip.py"
|
||||
python-embed\python.exe get-pip.py --no-cache-dir
|
||||
del get-pip.py
|
||||
|
||||
REM Install dependencies
|
||||
python-embed\python.exe -m pip install --no-cache-dir -r ..\backend\requirements.txt
|
||||
echo ✅ Python embed ready
|
||||
|
||||
REM ── 2b. Copier backend + frontend localement pour le bundle ────
|
||||
echo [2b/6] Staging backend + frontend for bundle...
|
||||
if exist "backend" rmdir /s /q "backend"
|
||||
if exist "frontend" rmdir /s /q "frontend"
|
||||
xcopy /E /I /Q /Y "..\backend" "backend"
|
||||
xcopy /E /I /Q /Y "..\frontend" "frontend"
|
||||
echo ✅ Staged
|
||||
|
||||
REM ── 3. Build Tauri ────────────────────────────────────────────
|
||||
echo [3/6] Building Tauri application (NSIS + MSI installers)...
|
||||
cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis,msi
|
||||
if errorlevel 1 (
|
||||
echo ⚠️ Standard bundle build failed, trying fallback to NSIS only...
|
||||
cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
if errorlevel 1 (
|
||||
echo ❌ Build failed!
|
||||
goto :cleanup
|
||||
)
|
||||
)
|
||||
echo ✅ Build successful
|
||||
|
||||
REM ── 4. Copier python-embed vers output dir (dev local) ─────────
|
||||
echo [4/6] Copying python-embed to output dir...
|
||||
set "OUTDIR=target\x86_64-pc-windows-msvc\release"
|
||||
if exist "%OUTDIR%\python-embed" rmdir /s /q "%OUTDIR%\python-embed" 2>nul
|
||||
xcopy /E /I /Q /Y "python-embed" "%OUTDIR%\python-embed"
|
||||
echo ..>> "%OUTDIR%\python-embed\python311._pth"
|
||||
echo ✅ Dev runtime ready
|
||||
|
||||
:cleanup
|
||||
REM ── 5. Nettoyer les dossiers stagés ───────────────────────────
|
||||
echo [5/6] Cleaning up staged dirs...
|
||||
if exist "backend" rmdir /s /q "backend"
|
||||
if exist "frontend" rmdir /s /q "frontend"
|
||||
echo ✅ Cleaned
|
||||
|
||||
REM ── 6. Résultats ───────────────────────────────────────────────
|
||||
echo [6/6] Binaries generated:
|
||||
dir /b "%OUTDIR%\bundle\nsis\*.exe" 2>nul
|
||||
dir /b "%OUTDIR%\bundle\msi\*.msi" 2>nul
|
||||
echo.
|
||||
echo ✅ Done! Installers generated in: %OUTDIR%\bundle\
|
||||
endlocal
|
||||
@@ -0,0 +1,31 @@
|
||||
fn main() {
|
||||
// Get version from git describe (same as backend/version.py)
|
||||
let version = std::process::Command::new("git")
|
||||
.args(["describe", "--tags", "--dirty=-dirty"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().trim_start_matches('v').to_string())
|
||||
.unwrap_or_else(|| "0.0.0-dev".to_string());
|
||||
|
||||
// Extract short hash for display
|
||||
let short_hash = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// SemVer base (strip -suffix)
|
||||
let semver_base = version.split('-').next().unwrap_or("0.0.0");
|
||||
|
||||
println!("cargo:rustc-env=GIT_VERSION={}", version);
|
||||
println!("cargo:rustc-env=GIT_HASH={}", short_hash);
|
||||
println!("cargo:rustc-env=SEMVER={}", semver_base);
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads/main");
|
||||
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for ObsiGate Desktop",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"dialog:default",
|
||||
"notification:default",
|
||||
"fs:default",
|
||||
"process:default",
|
||||
"store:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
@@ -0,0 +1,834 @@
|
||||
//! ObsiGate Desktop — Native Tauri shell for ObsiGate web app.
|
||||
//!
|
||||
//! Spawns a Python FastAPI backend, then opens the frontend in a
|
||||
//! native webview. No terminal window on Windows.
|
||||
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri::Manager;
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
struct BackendProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
exe_dir: PathBuf,
|
||||
restarting: Mutex<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct VaultConfig {
|
||||
name: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct DirConfig {
|
||||
name: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct AppConfig {
|
||||
vault_path: Option<String>,
|
||||
vaults: Vec<VaultConfig>,
|
||||
dirs: Vec<DirConfig>,
|
||||
window_x: Option<f64>,
|
||||
window_y: Option<f64>,
|
||||
window_width: Option<f64>,
|
||||
window_height: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vault_path: None,
|
||||
vaults: Vec::new(),
|
||||
dirs: Vec::new(),
|
||||
window_x: None,
|
||||
window_y: None,
|
||||
window_width: Some(1200.0),
|
||||
window_height: Some(800.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn app_data_dir() -> PathBuf {
|
||||
app_data_dir_impl().unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn app_data_dir_impl() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("APPDATA")
|
||||
.ok()
|
||||
.map(|p| PathBuf::from(p).join("ObsiGate"))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.map(|p| PathBuf::from(p).join("obsigate"))
|
||||
.or_else(|| {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|p| PathBuf::from(p).join(".config").join("obsigate"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
app_data_dir().join("config.json")
|
||||
}
|
||||
|
||||
fn logs_dir() -> PathBuf {
|
||||
app_data_dir().join("logs")
|
||||
}
|
||||
|
||||
fn backend_log_path() -> PathBuf {
|
||||
logs_dir().join("backend.log")
|
||||
}
|
||||
|
||||
fn load_config() -> AppConfig {
|
||||
let path = config_path();
|
||||
if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
AppConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn save_config(config: &AppConfig) {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(config) {
|
||||
let _ = fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backend lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
|
||||
let python = if cfg!(target_os = "windows") {
|
||||
exe_dir.join("python-embed").join("python.exe")
|
||||
} else {
|
||||
exe_dir.join("python-embed").join("bin").join("python3")
|
||||
};
|
||||
|
||||
// Ensure log directory exists
|
||||
let log_file = backend_log_path();
|
||||
if let Some(parent) = log_file.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let log = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
.map_err(|e| format!("Cannot open log file: {}", e))?;
|
||||
|
||||
info!("Starting backend: {}", python.display());
|
||||
|
||||
// Use app data dir as CWD so 'data/' writes go to a writable location (%APPDATA%\ObsiGate)
|
||||
let data_dir = app_data_dir();
|
||||
let _ = fs::create_dir_all(data_dir.join("data"));
|
||||
|
||||
let mut cmd = Command::new(&python);
|
||||
cmd.current_dir(&data_dir)
|
||||
.env("PYTHONPATH", exe_dir);
|
||||
|
||||
// Hide console window on Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
// Pass vault config as env vars (VAULT_N_NAME, VAULT_N_PATH, DIR_N_NAME, DIR_N_PATH)
|
||||
let config = load_config();
|
||||
for (i, vault) in config.vaults.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
cmd.env(format!("VAULT_{}_NAME", n), &vault.name);
|
||||
cmd.env(format!("VAULT_{}_PATH", n), &vault.path);
|
||||
}
|
||||
for (i, dir) in config.dirs.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
cmd.env(format!("DIR_{}_NAME", n), &dir.name);
|
||||
cmd.env(format!("DIR_{}_PATH", n), &dir.path);
|
||||
}
|
||||
|
||||
let child = cmd
|
||||
.args([
|
||||
"-m", "uvicorn",
|
||||
"backend.main:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "17890",
|
||||
"--log-level", "info",
|
||||
])
|
||||
.stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?))
|
||||
.stderr(Stdio::from(log))
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start Python backend: {}", e))?;
|
||||
|
||||
info!("Backend PID: {}", child.id());
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
async fn wait_for_backend() -> Result<(), String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = "http://127.0.0.1:17890/api/health";
|
||||
|
||||
for i in 0..30 {
|
||||
match client.get(url).timeout(Duration::from_secs(2)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!("Backend ready (attempt {})", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Backend did not become ready within 15 seconds".to_string())
|
||||
}
|
||||
|
||||
fn kill_backend(child: &mut Child) {
|
||||
info!("Stopping backend PID {}...", child.id());
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Send SIGTERM first, wait, then SIGKILL
|
||||
let pid = child.id();
|
||||
let _ = Command::new("kill").arg("-TERM").arg(pid.to_string()).status();
|
||||
for _ in 0..10 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
_ => std::thread::sleep(Duration::from_millis(500)),
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = child.wait();
|
||||
info!("Backend stopped");
|
||||
}
|
||||
|
||||
// ── Tauri Commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_backend_url() -> String {
|
||||
"http://127.0.0.1:17890".to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_version() -> String {
|
||||
env!("GIT_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config() -> AppConfig {
|
||||
load_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_path(path: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.vault_path = Some(path);
|
||||
save_config(&config);
|
||||
info!("Vault path saved: {}", config.vault_path.as_deref().unwrap_or("none"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_path() -> Result<Option<String>, String> {
|
||||
Ok(load_config().vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let path = app.dialog().file().blocking_pick_folder();
|
||||
match path {
|
||||
Some(p) => {
|
||||
let p_str = p.to_string();
|
||||
let mut config = load_config();
|
||||
config.vault_path = Some(p_str.clone());
|
||||
save_config(&config);
|
||||
Ok(p_str)
|
||||
}
|
||||
None => Err("No folder selected".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_system_theme(app: tauri::AppHandle) -> String {
|
||||
use tauri::Theme;
|
||||
match app.get_webview_window("main").unwrap().theme() {
|
||||
Ok(Theme::Dark) => "dark".to_string(),
|
||||
_ => "light".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
// Avoid duplicates
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
config.vaults.push(VaultConfig { name: name.clone(), path });
|
||||
save_config(&config);
|
||||
info!("Vault added: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_vault(name: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
save_config(&config);
|
||||
info!("Vault removed: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_vaults() -> Vec<VaultConfig> {
|
||||
load_config().vaults
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_dir(name: String, path: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.dirs.retain(|d| d.name != name);
|
||||
config.dirs.push(DirConfig { name: name.clone(), path });
|
||||
save_config(&config);
|
||||
info!("Dir added: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_dir(name: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.dirs.retain(|d| d.name != name);
|
||||
save_config(&config);
|
||||
info!("Dir removed: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_dirs() -> Vec<DirConfig> {
|
||||
load_config().dirs
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_backend(state: tauri::State<BackendProcess>) -> Result<String, String> {
|
||||
let mut restarting = state.restarting.lock().map_err(|e| e.to_string())?;
|
||||
if *restarting {
|
||||
return Ok("already_restarting".to_string());
|
||||
}
|
||||
*restarting = true;
|
||||
drop(restarting);
|
||||
|
||||
info!("User requested backend restart");
|
||||
|
||||
// Kill existing
|
||||
let mut child_guard = state.child.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(ref mut c) = *child_guard {
|
||||
kill_backend(c);
|
||||
}
|
||||
*child_guard = None;
|
||||
drop(child_guard);
|
||||
|
||||
// Spawn new
|
||||
match spawn_backend(&state.exe_dir) {
|
||||
Ok(new_child) => {
|
||||
let mut guard = state.child.lock().map_err(|e| e.to_string())?;
|
||||
*guard = Some(new_child);
|
||||
drop(guard);
|
||||
|
||||
// Fire and forget — the monitoring loop will detect when it's ready
|
||||
info!("Backend restart initiated");
|
||||
Ok("restarting".to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
let mut r = state.restarting.lock().map_err(|e| e.to_string())?;
|
||||
*r = false;
|
||||
Err(format!("Failed to restart backend: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_backend_health(state: tauri::State<'_, BackendProcess>) -> Result<String, String> {
|
||||
// Check if the backend is actually alive (drop guard before await)
|
||||
let is_alive = {
|
||||
let mut child_guard = state.child.lock().map_err(|e| e.to_string())?;
|
||||
match &mut *child_guard {
|
||||
Some(child) => matches!(child.try_wait(), Ok(None)),
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
|
||||
if !is_alive {
|
||||
return Ok("dead".to_string());
|
||||
}
|
||||
|
||||
// Health check HTTP
|
||||
match reqwest::get("http://127.0.0.1:17890/api/health").await {
|
||||
Ok(resp) if resp.status().is_success() => Ok("healthy".to_string()),
|
||||
_ => Ok("unhealthy".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_window_state(
|
||||
_app: tauri::AppHandle,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.window_x = Some(x);
|
||||
config.window_y = Some(y);
|
||||
config.window_width = Some(width);
|
||||
config.window_height = Some(height);
|
||||
save_config(&config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_window_state() -> AppConfig {
|
||||
load_config()
|
||||
}
|
||||
|
||||
// Build About message once at compile time
|
||||
const ABOUT_MSG: &str = concat!(
|
||||
"ObsiGate Desktop ", env!("GIT_VERSION"),
|
||||
"\\n\\nPorte d\\'entrée native pour vos vaults Obsidian.",
|
||||
"\\nConstruit avec Tauri + Rust.",
|
||||
"\\n\\n© Bruno Charest — Licence MIT",
|
||||
"\\nhttps://git.dracodev.net/Projets/ObsiGate"
|
||||
);
|
||||
|
||||
// ── Entry Point ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
let log_dir = logs_dir();
|
||||
let _ = fs::create_dir_all(&log_dir);
|
||||
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
.format(|buf, record| {
|
||||
writeln!(
|
||||
buf,
|
||||
"{} [{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
record.level(),
|
||||
record.args()
|
||||
)
|
||||
})
|
||||
.try_init();
|
||||
|
||||
info!("ObsiGate Desktop {} starting", env!("GIT_VERSION"));
|
||||
|
||||
// Resolve exe directory
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
info!("Exe dir: {}", exe_dir.display());
|
||||
|
||||
// Load config for window state
|
||||
let mut config = load_config();
|
||||
|
||||
// First run: create sensible defaults per platform
|
||||
if config.vaults.is_empty() {
|
||||
info!("First run — creating default vault configuration");
|
||||
#[cfg(target_os = "windows")]
|
||||
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Default".to_string());
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string());
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian");
|
||||
// Create the vault directory if it doesn't exist
|
||||
if !vault_dir.exists() {
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
}
|
||||
}
|
||||
config.dirs = vec![
|
||||
DirConfig { name: "bruno".to_string(), path: home.clone() },
|
||||
];
|
||||
config.vaults = vec![
|
||||
VaultConfig {
|
||||
name: "Obsidian".to_string(),
|
||||
path: vault_dir.to_string_lossy().to_string(),
|
||||
},
|
||||
];
|
||||
config.vault_path = Some(home);
|
||||
save_config(&config);
|
||||
info!("Default vault config saved");
|
||||
}
|
||||
|
||||
// Spawn Python backend
|
||||
let backend = match spawn_backend(&exe_dir) {
|
||||
Ok(child) => {
|
||||
info!("Backend started PID {}", child.id());
|
||||
child
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start backend: {}", e);
|
||||
// We still launch the UI so the user sees the error page
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let backend_state = BackendProcess {
|
||||
child: Mutex::new(Some(backend)),
|
||||
exe_dir: exe_dir.clone(),
|
||||
restarting: Mutex::new(false),
|
||||
};
|
||||
|
||||
// Build Tauri app
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// Focus existing window when second instance launched
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.show();
|
||||
}
|
||||
}))
|
||||
.manage(backend_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_backend_url,
|
||||
get_version,
|
||||
get_config,
|
||||
save_vault_path,
|
||||
get_vault_path,
|
||||
pick_vault_folder,
|
||||
get_system_theme,
|
||||
restart_backend,
|
||||
check_backend_health,
|
||||
save_window_state,
|
||||
get_window_state,
|
||||
add_vault,
|
||||
remove_vault,
|
||||
list_vaults,
|
||||
add_dir,
|
||||
remove_dir,
|
||||
list_dirs,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Build native menu bar (File / Edit / Help)
|
||||
let _ = build_native_menu(app);
|
||||
|
||||
// Restore window position/size
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let (Some(x), Some(y)) = (config.window_x, config.window_y) {
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
if let (Some(w), Some(h)) = (config.window_width, config.window_height) {
|
||||
let _ = window.set_size(tauri::PhysicalSize::new(w, h));
|
||||
}
|
||||
}
|
||||
|
||||
// Build tray menu
|
||||
let _ = build_tray_menu(app);
|
||||
|
||||
// Wait for backend health check (non-blocking)
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match wait_for_backend().await {
|
||||
Ok(()) => {
|
||||
if let Some(window) = handle.get_webview_window("main") {
|
||||
// Met à jour le splash inline avant la redirection
|
||||
let _ = window.eval(
|
||||
r#"if (window.__setBootStatus) window.__setBootStatus("Backend prêt — chargement de l'interface…")"#,
|
||||
);
|
||||
match window.eval("window.location.href = 'http://127.0.0.1:17890'") {
|
||||
Ok(_) => info!("Redirect to backend OK"),
|
||||
Err(e) => error!("Redirect eval failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Backend failed: {}", e);
|
||||
if let Some(window) = handle.get_webview_window("main") {
|
||||
let _ = window.eval(&format!(
|
||||
r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: http://127.0.0.1:17890 | Logs: {}</p></div>'"#,
|
||||
e.replace('\'', "\\'").replace('"', "\\\""),
|
||||
backend_log_path().display().to_string().replace('\\', "\\\\"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Background monitor: detect backend crash
|
||||
let handle2 = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let state = handle2.state::<BackendProcess>();
|
||||
// Drop guard before any await
|
||||
let died = {
|
||||
let mut child_guard = match state.child.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => break,
|
||||
};
|
||||
match &mut *child_guard {
|
||||
Some(child) => child.try_wait().ok().flatten().is_some(),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
|
||||
if died {
|
||||
warn!("Backend process died unexpectedly!");
|
||||
if let Some(window) = handle2.get_webview_window("main") {
|
||||
let _ = window.eval(
|
||||
r#"if (typeof showBackendCrashBanner === 'function') showBackendCrashBanner();"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If restarting, check if new backend is ready
|
||||
let restarting = state.restarting.lock().ok().map(|r| *r).unwrap_or(false);
|
||||
if restarting {
|
||||
if wait_for_backend().await.is_ok() {
|
||||
let _ = state.restarting.lock().map(|mut r| *r = false);
|
||||
if let Some(window) = handle2.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload()");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
// Save window state before closing
|
||||
if let Ok(pos) = window.outer_position() {
|
||||
if let Ok(size) = window.outer_size() {
|
||||
let mut config = load_config();
|
||||
config.window_x = Some(pos.x as f64);
|
||||
config.window_y = Some(pos.y as f64);
|
||||
config.window_width = Some(size.width as f64);
|
||||
config.window_height = Some(size.height as f64);
|
||||
save_config(&config);
|
||||
}
|
||||
}
|
||||
// Kill backend
|
||||
let state = window.state::<BackendProcess>();
|
||||
if let Ok(mut guard) = state.child.lock() {
|
||||
if let Some(ref mut child) = *guard {
|
||||
kill_backend(child);
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running ObsiGate Desktop");
|
||||
|
||||
app.run(|_app_handle, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
info!("ObsiGate Desktop exiting");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Native Menu Bar ──────────────────────────────────────────────────────────
|
||||
|
||||
fn build_native_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::menu::{
|
||||
MenuBuilder, MenuItemBuilder, SubmenuBuilder, PredefinedMenuItem,
|
||||
};
|
||||
|
||||
// File menu
|
||||
let new_window = MenuItemBuilder::with_id("file_new_window", "Nouvelle fenêtre")
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
.build(app)?;
|
||||
let close_window = MenuItemBuilder::with_id("file_close", "Fermer")
|
||||
.accelerator("CmdOrCtrl+W")
|
||||
.build(app)?;
|
||||
let file_sep = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("file_quit", "Quitter")
|
||||
.accelerator("CmdOrCtrl+Q")
|
||||
.build(app)?;
|
||||
let file_menu = SubmenuBuilder::new(app, "Fichier")
|
||||
.item(&new_window)
|
||||
.item(&close_window)
|
||||
.item(&file_sep)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
// Edit menu
|
||||
let undo = MenuItemBuilder::with_id("edit_undo", "Annuler")
|
||||
.accelerator("CmdOrCtrl+Z")
|
||||
.build(app)?;
|
||||
let redo = MenuItemBuilder::with_id("edit_redo", "Rétablir")
|
||||
.accelerator("CmdOrCtrl+Shift+Z")
|
||||
.build(app)?;
|
||||
let edit_sep = PredefinedMenuItem::separator(app)?;
|
||||
let cut = MenuItemBuilder::with_id("edit_cut", "Couper")
|
||||
.accelerator("CmdOrCtrl+X")
|
||||
.build(app)?;
|
||||
let copy = MenuItemBuilder::with_id("edit_copy", "Copier")
|
||||
.accelerator("CmdOrCtrl+C")
|
||||
.build(app)?;
|
||||
let paste = MenuItemBuilder::with_id("edit_paste", "Coller")
|
||||
.accelerator("CmdOrCtrl+V")
|
||||
.build(app)?;
|
||||
let select_all = MenuItemBuilder::with_id("edit_select_all", "Tout sélectionner")
|
||||
.accelerator("CmdOrCtrl+A")
|
||||
.build(app)?;
|
||||
let edit_menu = SubmenuBuilder::new(app, "Édition")
|
||||
.item(&undo)
|
||||
.item(&redo)
|
||||
.item(&edit_sep)
|
||||
.item(&cut)
|
||||
.item(©)
|
||||
.item(&paste)
|
||||
.item(&edit_sep.clone())
|
||||
.item(&select_all)
|
||||
.build()?;
|
||||
|
||||
// Help menu
|
||||
let about = MenuItemBuilder::with_id("help_about", "À propos d'ObsiGate")
|
||||
.build(app)?;
|
||||
let help_menu = SubmenuBuilder::new(app, "Aide")
|
||||
.item(&about)
|
||||
.build()?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&file_menu)
|
||||
.item(&edit_menu)
|
||||
.item(&help_menu)
|
||||
.build()?;
|
||||
|
||||
app.set_menu(menu)?;
|
||||
|
||||
// Handle menu events
|
||||
app.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
"file_quit" => {
|
||||
info!("Quit from menu");
|
||||
app.exit(0);
|
||||
}
|
||||
"file_close" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
"help_about" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let js = format!("alert('{}')", ABOUT_MSG);
|
||||
let _ = window.eval(&js);
|
||||
}
|
||||
}
|
||||
"edit_undo" | "edit_redo" | "edit_cut" | "edit_copy" | "edit_paste" | "edit_select_all" => {
|
||||
// Let the webview handle these natively
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tray Menu ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_tray_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::TrayIconBuilder,
|
||||
};
|
||||
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quitter")
|
||||
.accelerator("CmdOrCtrl+Q")
|
||||
.build(app)?;
|
||||
let open = MenuItemBuilder::with_id("open", "Ouvrir ObsiGate")
|
||||
.build(app)?;
|
||||
let about = MenuItemBuilder::with_id("about", "À propos")
|
||||
.build(app)?;
|
||||
let separator = tauri::menu::PredefinedMenuItem::separator(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&open)
|
||||
.item(&separator)
|
||||
.item(&about)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().cloned().unwrap())
|
||||
.tooltip("ObsiGate")
|
||||
.menu(&menu)
|
||||
.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
"open" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
info!("Quit from tray menu");
|
||||
app.exit(0);
|
||||
}
|
||||
"about" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let js = format!("alert('{}')", ABOUT_MSG);
|
||||
let _ = window.eval(&js);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click { button, .. } = event {
|
||||
if button == tauri::tray::MouseButton::Left {
|
||||
// Left click: toggle window visibility
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.0.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
"devUrl": "http://localhost:17890",
|
||||
"beforeDevCommand": "cd .. && python -m uvicorn backend.main:app --host 127.0.0.1 --port 17890 --log-level info",
|
||||
"beforeBuildCommand": "echo 'Frontend already built — using static files'"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "ObsiGate",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"label": "main"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:* https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh; style-src 'self' 'unsafe-inline' http://127.0.0.1:* https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' http://127.0.0.1:* https://fonts.gstatic.com; img-src 'self' data: blob: http://127.0.0.1:* https:; frame-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*;"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"python-embed/**/*",
|
||||
"backend/**/*",
|
||||
"frontend/**/*"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["md", "markdown", "txt"],
|
||||
"name": "ObsiGate Markdown",
|
||||
"description": "Fichier Markdown ObsiGate",
|
||||
"role": "Editor"
|
||||
}
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"startMenuFolder": "ObsiGate"
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["libwebkit2gtk-4.1-0", "libgtk-3-0", "libayatana-appindicator3-1"]
|
||||
},
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": ""
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"single-instance": null,
|
||||
"store": null,
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://git.dracodev.net/api/v1/repos/Projets/ObsiGate/releases/latest"
|
||||
],
|
||||
"pubkey": "OBSIGATE_UPDATE_PUBKEY_PLACEHOLDER",
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Quick validation of desktop build structure
|
||||
echo "=== Desktop build structure validation ==="
|
||||
|
||||
errors=0
|
||||
|
||||
# Check required files exist
|
||||
for f in Cargo.toml tauri.conf.json build.rs src/main.rs python-embed/sidecar.py \
|
||||
build-windows.bat build-linux.sh README.md; do
|
||||
if [ -f "desktop/$f" ]; then
|
||||
echo "✅ desktop/$f"
|
||||
else
|
||||
echo "❌ MISSING: desktop/$f"
|
||||
((errors++))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check directories
|
||||
for d in desktop/icons desktop/src; do
|
||||
if [ -d "$d" ]; then
|
||||
echo "✅ $d/"
|
||||
else
|
||||
echo "❌ MISSING: $d/"
|
||||
((errors++))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check Cargo.toml has Tauri dependency
|
||||
if grep -q 'tauri' desktop/Cargo.toml; then
|
||||
echo "✅ Cargo.toml references Tauri"
|
||||
else
|
||||
echo "❌ Cargo.toml missing Tauri dependency"
|
||||
((errors++))
|
||||
fi
|
||||
|
||||
# Check tauri.conf.json is valid JSON
|
||||
if python3 -c "import json; json.load(open('desktop/tauri.conf.json'))" 2>/dev/null; then
|
||||
echo "✅ tauri.conf.json is valid JSON"
|
||||
else
|
||||
echo "❌ tauri.conf.json has JSON syntax errors"
|
||||
((errors++))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $errors -eq 0 ]; then
|
||||
echo "✅ All checks passed — build structure is valid"
|
||||
else
|
||||
echo "❌ $errors error(s) found"
|
||||
fi
|
||||
exit $errors
|
||||
@@ -0,0 +1,53 @@
|
||||
# =============================================================================
|
||||
# ObsiGate — Environnement de TEST Linux
|
||||
# =============================================================================
|
||||
# Utilisé par : build.sh --test-linux
|
||||
# Ports : 2021 (app), 11435 (Ollama) — ports distincts pour éviter conflits
|
||||
# Vaults : dossiers temporaires (/tmp/obsigate-test-*)
|
||||
# Auth : activé, admin / test123
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate-test
|
||||
user: "1000:1000"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2021:8080"
|
||||
volumes:
|
||||
- /tmp/obsigate-test-vault:/vaults/TestVault
|
||||
- /tmp/obsigate-test-dir:/vaults/TestDir
|
||||
- /tmp/obsigate-test-data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
- AI_DEFAULT_PROVIDER=deepseek
|
||||
- OLLAMA_BASE_URL=http://ollama:11435/v1
|
||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: obsigate-test-ollama
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "11435:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
ollama serve &
|
||||
sleep 5
|
||||
ollama pull qwen2.5-coder:1.5b
|
||||
wait
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
@@ -0,0 +1,55 @@
|
||||
# =============================================================================
|
||||
# ObsiGate — Environnement de TEST Windows (Docker Desktop)
|
||||
# =============================================================================
|
||||
# Utilisé par : build.sh --test-win | build.ps1 -Test
|
||||
# Ports : 2020 (app), 11434 (Ollama)
|
||||
# Vaults : chemins Windows via Docker Desktop (/c/dev/git/...)
|
||||
# Auth : activé, admin / test123
|
||||
# NOTE : Pas de user: "1000:1000" → tourne en root (évite les problèmes
|
||||
# de permissions NTFS sur les volumes bind-mount Windows)
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate-test
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2020:8080"
|
||||
volumes:
|
||||
- /c/dev/git/python/ObsiGate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/ObsiGate/test_dir:/vaults/TestDir
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
- AI_DEFAULT_PROVIDER=deepseek
|
||||
- OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: obsigate-test-ollama
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
ollama serve &
|
||||
sleep 5
|
||||
ollama pull qwen2.5-coder:1.5b
|
||||
wait
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
obsigate_data:
|
||||
@@ -1,20 +0,0 @@
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
user: "1000:1000"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2020:8080"
|
||||
volumes:
|
||||
- C:\dev\git\python\obsigate\test-vault:/vaults/TestVault
|
||||
- C:\dev\git\python\obsigate\data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- OBSIGATE_AUTH_ENABLED=false
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
- OBSIGATE_WATCHER_ENABLED=false
|
||||
+32
-3
@@ -1,3 +1,12 @@
|
||||
# =============================================================================
|
||||
# ObsiGate — Déploiement PRODUCTION (serveur Linux)
|
||||
# =============================================================================
|
||||
# Utilisé par : build.sh --prod (défaut)
|
||||
# Ports : 2020 (app), 11434 (Ollama)
|
||||
# Vaults : chemins absolus Linux (/NFS/..., /DOCKER_CONFIG/...)
|
||||
# Auth : activé, mot de passe admin dans .env
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
@@ -41,10 +50,30 @@ services:
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# Auth configuration (edit values in .env file)
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → définir dans .env (pas ici !)
|
||||
# - OBSIGATE_SECURE_COOKIES=false # Set true if behind HTTPS reverse proxy
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
# OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS
|
||||
- OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: obsigate-ollama
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
ollama serve &
|
||||
sleep 5
|
||||
ollama pull qwen2.5-coder:1.5b
|
||||
wait
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
## 📋 Table des matières
|
||||
|
||||
1. [Résumé exécutif](#1-résumé-exécutif)
|
||||
2. [Fonctionnalités actuelles](#2-fonctionnalités-actuelles)
|
||||
1. [Résumé exécutif](#1-resume-executif)
|
||||
2. [Fonctionnalités actuelles](#2-fonctionnalites-actuelles)
|
||||
3. [Forces](#3-forces)
|
||||
4. [Points d'amélioration](#4-points-damélioration)
|
||||
5. [Roadmap suggérée](#5-roadmap-suggérée)
|
||||
4. [Points d'amélioration](#4-points-damelioration)
|
||||
5. [Roadmap suggérée](#5-roadmap-suggeree)
|
||||
6. [Checklist de professionnalisation](#6-checklist-de-professionnalisation)
|
||||
7. [Notes de révision V1.1](#7-notes-de-révision-v11)
|
||||
7. [Notes de révision V1.1](#7-notes-de-revision-v11)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Guide de Développement, Build Local & Publication des Releases Gitea — ObsiGate
|
||||
|
||||
Ce guide décrit la procédure pas-à-pas pour **tester**, **compiler localement** l'application Desktop (Windows et Linux), **effectuer les commits & pushs**, puis **publier les binaires** sur la section **Releases / Publications** de Gitea (`https://git.dracodev.net/Projets/ObsiGate/releases`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Tests et Prévisualisation Locale
|
||||
|
||||
### A. Backend & Frontend (Tests unitaires & Linting)
|
||||
```powershell
|
||||
# Tests backend Python (FastAPI)
|
||||
.\.venv\Scripts\python.exe -m pytest tests/
|
||||
|
||||
# Linting backend (ruff & mypy)
|
||||
.\.venv\Scripts\python.exe -m ruff check backend/
|
||||
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
|
||||
|
||||
# Validation & tests unitaires frontend
|
||||
node tests/frontend/validate-imports.mjs
|
||||
node tests/frontend/unit.test.mjs
|
||||
```
|
||||
|
||||
### B. Mode Développement Desktop (Tauri 2 Live Preview)
|
||||
Pour développer l'application desktop avec rechargement à chaud (Hot Reload) :
|
||||
```powershell
|
||||
cd desktop
|
||||
cargo tauri dev
|
||||
```
|
||||
*Le backend Python et l'interface WebView Tauri démarreront automatiquement sur `http://127.0.0.1:17890`.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Compilation Locale de l'Application Desktop
|
||||
|
||||
### A. Windows (Installeur `.exe` NSIS et Paquet `.msi`)
|
||||
1. Ouvrez un terminal PowerShell ou CMD sur votre machine Windows.
|
||||
2. Déplacez-vous dans le dossier `desktop/` et exécutez le script :
|
||||
```powershell
|
||||
cd desktop
|
||||
.\build-windows.bat
|
||||
```
|
||||
3. **Binaires générés dans** `desktop/target/x86_64-pc-windows-msvc/release/bundle/` :
|
||||
- `ObsiGate_x.y.z_x64-setup.exe` (Installeur utilisateur NSIS)
|
||||
- `ObsiGate_x.y.z_x64_en-US.msi` (Paquet d'installation MSI)
|
||||
|
||||
### B. Linux (Exécutable `.AppImage` et Paquet `.deb`)
|
||||
1. Ouvrez un terminal Bash (Machine Linux ou WSL2 / Conteneur Docker).
|
||||
2. Déplacez-vous dans le dossier `desktop/` et exécutez le script :
|
||||
```bash
|
||||
cd desktop
|
||||
chmod +x build-linux.sh
|
||||
./build-linux.sh
|
||||
```
|
||||
3. **Binaires générés dans** `desktop/target/x86_64-unknown-linux-gnu/release/bundle/` :
|
||||
- `ObsiGate_x.y.z_amd64.AppImage` (Exécutable portable Linux)
|
||||
- `obsigate_x.y.z_amd64.deb` (Paquet Debian/Ubuntu)
|
||||
|
||||
---
|
||||
|
||||
## 3. Commit, Push & Tagging Git
|
||||
|
||||
Une fois les modifications testées et validées :
|
||||
|
||||
1. **Commit et Push du code source** :
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: préparation release v2.0.0"
|
||||
git push origin main
|
||||
```
|
||||
*Ceci déclenchera la vérification CI standard sur Gitea (lint, tests unitaires, build Docker).*
|
||||
|
||||
2. **Création du Tag de Version** :
|
||||
```bash
|
||||
git tag -a v2.0.0 -m "Release v2.0.0"
|
||||
git push origin v2.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Publication des Binaires sur Gitea Releases
|
||||
|
||||
Un script automatisé (`scripts/publish_release.py`) se charge de téléverser vos binaires compilés localement et de générer l'empreinte cryptographique `checksums.txt` (SHA256).
|
||||
|
||||
### A. Obtenir un Jeton d'Accès Gitea (Une seule fois)
|
||||
1. Connectez-vous sur votre instance Gitea : `https://git.dracodev.net`.
|
||||
2. Cliquez sur votre **Profil** $\rightarrow$ **Paramètres** $\rightarrow$ **Applications**.
|
||||
3. Dans **Gérer les jetons d'accès personnels**, entrez le nom `ObsiGate-Release` et cochez le jeton avec accès en écriture sur les dépôts (`repo`).
|
||||
4. Copiez le jeton généré.
|
||||
|
||||
### B. Exécution de la Publication
|
||||
|
||||
**Option 1 — Définir la variable d'environnement (Recommandé) :**
|
||||
- **Windows PowerShell** :
|
||||
```powershell
|
||||
$env:GITEA_TOKEN="votre_jeton_gitea_ici"
|
||||
.\.venv\Scripts\python.exe scripts/publish_release.py --tag v2.0.0
|
||||
```
|
||||
- **Linux / Bash** :
|
||||
```bash
|
||||
export GITEA_TOKEN="votre_jeton_gitea_ici"
|
||||
python3 scripts/publish_release.py --tag v2.0.0
|
||||
```
|
||||
|
||||
**Option 2 — Utiliser l'option `--token` :**
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts/publish_release.py --tag v2.0.0 --token "votre_jeton_gitea_ici"
|
||||
```
|
||||
|
||||
**Option 3 — Tester à blanc avec `--dry-run` (Sans modifier Gitea) :**
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts/publish_release.py --tag v2.0.0 --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Résultat Attendu dans Gitea
|
||||
|
||||
Une fois le script exécuté, visitez la page [ObsiGate Releases](https://git.dracodev.net/Projets/ObsiGate/releases).
|
||||
|
||||
Vous y trouverez la section de publication officielle contenant :
|
||||
- 📦 `ObsiGate_2.0.0_x64-setup.exe` (Installeur Windows)
|
||||
- 📦 `ObsiGate_2.0.0_x64_en-US.msi` (Package MSI Windows)
|
||||
- 📦 `ObsiGate_2.0.0_amd64.AppImage` (Executable autonome Linux)
|
||||
- 📦 `obsigate_2.0.0_amd64.deb` (Paquet Debian/Ubuntu)
|
||||
- 🔐 `checksums.txt` (Empreintes de sécurité SHA-256)
|
||||
- 📄 Code source `.zip` / `.tar.gz` (générés automatiquement)
|
||||
@@ -0,0 +1,178 @@
|
||||
> **IMPORTANT — Lire d'abord**
|
||||
> Ce document utilise un **format tabulaire simple et rigoureux** conçu pour être
|
||||
> maintenu à la fois par un humain (éditeur texte) et par un agent IA. Les règles
|
||||
> exactes sont définies dans la section **« Comment fonctionne ce document »**.
|
||||
|
||||
# 🐛 ObsiGate — Suivi des Bugs / TODO de Correction
|
||||
|
||||
> **Rôle du document** : registre unique et vivant des bugs découverts et des corrections
|
||||
> à apporter au projet **ObsiGate** (application Web + desktop Tauri).
|
||||
> C'est l'**outil de communication commun** entre l'utilisateur humain et l'IA
|
||||
> qui effectue les corrections dans le code.
|
||||
|
||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||
- **Dernière mise à jour** : *(à tenir à jour à chaque modification)*
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Comment fonctionne ce document (À LIRE ABSOLUMENT)
|
||||
|
||||
Ce document est un **contrat de travail partagé**. Suivez les règles ci-dessous pour
|
||||
qu'un humain OU un agent IA puisse le lire, le modifier et l'exploiter sans ambiguïté.
|
||||
|
||||
### 1. Une table = un état (workflow par rangée)
|
||||
|
||||
Chaque bug/TODO est une **rangée** dans la section [📋 Registre des bugs / TODOs](#-registre-des-bugs--todos).
|
||||
Une rangée ne change **jamais de table** au fil de sa vie ; c'est la **colonne « Statut »**
|
||||
qui encode son avancement :
|
||||
|
||||
| Statut (colonne) | Signification | Qui peut le poser |
|
||||
|---|---|---|
|
||||
| `🔴 ouvert` | Bug confirmé / tâche à faire, en attente de traitement | Utilisateur **ou** IA |
|
||||
| `🟠 en cours` | Un agent IA a **commencé** à corriger | IA uniquement |
|
||||
| `🟢 corrigé` | La correction est écrite **et** vérifiée (tests OK) | IA uniquement |
|
||||
| `✅ vérifié` | L'utilisateur (ou les tests) a **validé** le correctif | Utilisateur uniquement |
|
||||
| `⚪ abandonné` | Décision de ne pas corriger (documentée dans notes) | Utilisateur ou IA |
|
||||
|
||||
### 2. Cycle de vie d'un bug (workflow)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[🔴 ouvert<br/>Découverte d'un bug] --> B[🟠 en cours<br/>IA travaille dessus]
|
||||
B --> C[🟢 corrigé<br/>Correctif écrit + tests OK]
|
||||
C --> D[✅ vérifié<br/>Re-validation par l'utilisateur]
|
||||
C --> E[⚪ abandonné<br/>Refus / hors périmètre]
|
||||
D --> F[✅ Fermé : lignée conservée en historique]
|
||||
```
|
||||
|
||||
1. **Un bug est signalé** → posé en `🔴 ouvert` par l'utilisateur **ou** l'IA (à l'issue d'une
|
||||
investigation ou d'une session de test).
|
||||
2. **L'IA prend en charge** → passe le statut en `🟠 en cours` **avant** de commencer à coder,
|
||||
et crée une entrée dans [🛠️ Journal des interventions AI](#-journal-des-interventions-ai).
|
||||
3. **La correction est terminée** → l'IA exécute les tests pertinents, passe le statut en
|
||||
`🟢 corrigé` et remplit la colonne « Correctif / Commit ».
|
||||
4. **Validation finale** → c'est l'utilisateur qui passe en `✅ vérifié` une fois le correctif
|
||||
contrôlé. Les rangées `✅ vérifié` sont **déplacées dans l'historique** périodiquement.
|
||||
5. **Abandon** → justifié obligatoirement dans la colonne « Notes / Scope ».
|
||||
|
||||
> ⚠️ **Ne JAMAIS supprimer** une rangée terminée : déplacez-la dans la section
|
||||
> [📜 Historique des bugs résolus](#-historique-des-bugs-résolus) à la place.
|
||||
|
||||
### 3. Ordre de lecture pour un agent IA
|
||||
|
||||
Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
1. **Lire ce fichier en entier** (en particulier ce guide et le registre).
|
||||
2. **Identifier** les rangées de statut `🔴 ouvert` avec un **scope `IA`** et une
|
||||
**priorité** élevée qui lui sont attribuées (colonne « Assigné »).
|
||||
3. **Poser le statut `🟠 en cours`** sur la rangée choisie **avant** toute modification de code.
|
||||
4. Corriger en respectant la [checklist de vérification](#-checklist-de-traitement-dun-bug-rappel-pour-toute-ia)
|
||||
et les [conventions du projet](CONTRIBUTING.md).
|
||||
5. Revenir mettre le statut à `🟢 corrigé`, documenter le correctif, et **journaliser** son passage.
|
||||
6. Ne pas marquer `✅ vérifié` lui-même (c'est un droit utilisateur, sauf mention contraire explicite).
|
||||
|
||||
### 4. Identifiants uniques (ID)
|
||||
|
||||
- Chaque bug porte un **ID stable** `BUG-###` ou `TODO-###` (voir le format dans le registre).
|
||||
- L'ID **ne change jamais**, même après résolution. Il permet de retrouver le bug dans
|
||||
l'historique et dans le journal d'interventions.
|
||||
- Le format du numéro est `###` = 3 chiffres incrémental (001, 002, 003…).
|
||||
- Si vous (IA) créez un nouveau bug, **reprenez le plus grand numéro existant + 1** dans le
|
||||
registre, quel que soit l'ordre des lignes.
|
||||
|
||||
### 5. Comment mettre à jour ce document (bonnes pratiques)
|
||||
|
||||
- **Formatage** : garder les colonnes alignées par des espaces pour la lisibilité brute.
|
||||
Une rangée = une seule ligne de tableau Markdown.
|
||||
- **Copier-coller** : pour ajouter un bug, dupliquez une ligne existante puis modifiez les champs.
|
||||
- **Ne pas casser** : conservez le séparateur d'en-tête `|---|---|…|` et les **champs fixes**
|
||||
(#, Titre, Statut, Priorité, Scope, Assigné, Zone, Cmd de repro, Correctif/Commit, Notes).
|
||||
- **Frontière des rôles** : un agent IA ne doit **jamais** se passer à lui-même une rangée en
|
||||
`✅ vérifié` ; il s'arrête à `🟢 corrigé` (sauf si l'utilisateur l'y autorise explicitement).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Registre des bugs / TODOs
|
||||
|
||||
> **Légende colonnes :**
|
||||
> - **Statut** : `🔴 ouvert` | `🟠 en cours` | `🟢 corrigé` | `✅ vérifié` | `⚪ abandonné`
|
||||
> - **Priorité** : `P0` (critique/bloquant) · `P1` (élevée) · `P2` (normale) · `P3` (basse/cosmétique)
|
||||
> - **Scope** : `📱 frontend` · `⚙️ backend` · `🔌 api` · `🧩 tests` · `🖥️ desktop` · `📦 build` · `📄 docs` · `🤖 ia` · `❓ inconnu` (À affiner par l'IA)
|
||||
> - **Assigné** : `IA` (à traiter par un agent) · `USER` (à traiter par l'utilisateur) · `—` (non attribué)
|
||||
> - **Zone** : chemin/nom de fichier concerné (ex. `frontend/app.js`, `backend/search.py`)
|
||||
> - **Cmd de repro** : commande ou scénario permettant de reproduire / vérifier (vide si N/C)
|
||||
|
||||
### Bugs ouverts (à traiter)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| *BUG-001* | L'ouverture des fichier PDF ne fonctionne pas et donne l'erreur Internal Server Error | 🔴 ouvert | P1 | fichier PDF | IA | |
|
||||
https://og.dracodev.net/api/file/Recettes/pdf/stream?path=98_Boite_Outils%2F98.2_Attachments%2FBi%C3%A8re%20blonde%20envoy%C3%A9%20par%20Desja.pdf| — | l'erreur se retrouve dans la section network de la console web et dans l'interface web lors du chargement d'un pdf |
|
||||
| *BUG-002* | l'ouverture d'un fichier .excalidraw ne fonctionne pas et affiche toujours Loading *Excalidraw…* | 🔴 ouvert | P1 | fichier .excalidaw | IA | | | — | test d'accès réaliser via cloudflare et réseau local démontre que ce problème est au 2 endroits |
|
||||
| | | | | | | | | | |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| *(exemple)* TODO-002 | Rendre l'index inversé incrémental (40k+ fichiers) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/indexer.py`, `backend/search.py` | Recherche sur très gros vault | — | Exemple à remplacer. Cf. plan.md |
|
||||
| *(À remplir)* | | | | | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Journal des interventions AI
|
||||
|
||||
> Chaque passage d'un agent IA qui modifie du code ou l'état du registre est journalisé.
|
||||
> **Règle** : l'IA ajoute **une ligne** à la fin de ce tableau à chaque session de correction.
|
||||
> Colonnes : `Date` (YYYY-MM-DD) · `ID(s) traité(s)` · `Action` · `Fichiers modifiés` · `Résumé` · `Statut après`.
|
||||
|
||||
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
||||
|---|---|---|---|---|---|
|
||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||
|
||||
---
|
||||
|
||||
## 📜 Historique des bugs résolus
|
||||
|
||||
> Les rangées `✅ vérifié` (ou `⚪ abandonné`) sont déplacées ici régulièrement pour garder
|
||||
> le **registre actuel** court et lisible. Conservez l'ID d'origine pour la traçabilité.
|
||||
|
||||
| # | Titre | Date résolution | Résolu par | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| *(aucun pour l'instant)* | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Procédure de vérification d'une correction (pour l'IA)
|
||||
|
||||
Quand une correction est écrite, l'agent IA doit **avant** de passer en `🟢 corrigé` :
|
||||
|
||||
1. **Exécuter les tests** du projet :
|
||||
- Backend : `python -m pytest tests/ -v` (ou `pytest`)
|
||||
- S'il existe des tests ciblés sur la zone modifiée, les lancer en priorité.
|
||||
2. **Vérifier le lancement** du module concerné (import sans erreur, endpoint répond).
|
||||
3. **Relire sa propre diff** pour éviter les régressions.
|
||||
4. Mettre la colonne « Correctif / Commit » à jour **puis** le statut en `🟢 corrigé`.
|
||||
5. Si un test échoue ou que la correction est incomplète, **rester en `🟠 en cours`** et le noter.
|
||||
|
||||
---
|
||||
|
||||
## ✔️ Checklist de traitement d'un bug (rappel pour toute IA)
|
||||
|
||||
- [ ] Bug identifié avec un **ID** et repris dans le [registre](#-registre-des-bugs--todos)
|
||||
- [ ] Statut posé en `🟠 en cours` avant de coder
|
||||
- [ ] Correction conforme aux [conventions du projet](CONTRIBUTING.md)
|
||||
- [ ] Tests exécutés et **vert** (colonne « Correctif / Commit » renseignée)
|
||||
- [ ] Journal des interventions AI mis à jour
|
||||
- [ ] Statut passé en `🟢 corrigé` (pas `✅ vérifié` sauf autorisation)
|
||||
- [ ] (Après validation utilisateur) rangée déplacée dans l'historique
|
||||
|
||||
---
|
||||
|
||||
## 📚 Liens utiles
|
||||
|
||||
- **Guide de contribution** : [CONTRIBUTING.md](CONTRIBUTING.md) (conventions de code)
|
||||
- **Structure du projet** : [README.fr.md](../README.fr.md)
|
||||
- **Tests** : répertoire `tests/` — voir CONTRIBUTING
|
||||
- **Analyse/décisions complémentaires** : [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md), [ROADMAP.md](ROADMAP.md)
|
||||
+856
-109
@@ -1,138 +1,885 @@
|
||||
# ObsiGate — Roadmap (détaillé)
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Date :** 2026-06-02 | **Version :** 1.7.0
|
||||
> **Dernier audit :** [AUDIT_TECHNIQUE_2026-05-27.md](./AUDIT_TECHNIQUE_2026-05-27.md)
|
||||
> **Version :** 2.0.0-dev | **Dernière mise à jour :** 2026-06-18
|
||||
> Voir aussi [CHANGELOG.md](./CHANGELOG.md), [AUDIT_TECHNIQUE.md](./docs/AUDIT_TECHNIQUE_2026-05-27.md)
|
||||
|
||||
---
|
||||
|
||||
## Légende
|
||||
|
||||
- ✅ Complété
|
||||
- 🟡 En cours / Partiel
|
||||
- ⬜ À faire
|
||||
- 🔴 Critique
|
||||
| Icône | Signification |
|
||||
|---|---|
|
||||
| ✅ | Terminé |
|
||||
| 🔵 | En cours |
|
||||
| ⚪ | Prévu / Backlog |
|
||||
| 🔴 Impact | Critique / Bloquant |
|
||||
| 🟡 Impact | Utile / Attendu |
|
||||
| 🟢 Impact | Nice-to-have / Confort |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités complétées (depuis le dernier audit)
|
||||
## ✅ Complété (v1.0.0 → v2.0.0-dev)
|
||||
|
||||
### AI Editor (v1.7.0 — NOUVEAU)
|
||||
- **Toolbar dropdown** : Edit (improve/fix/shorter/longer/simplify), Tone (professional/casual), Translate, Generate (explain/summarize/continue), Custom Rewrite, Toolbox (to list/to table/frontmatter/to canvas)
|
||||
- **Multi-provider** : DeepSeek, OpenRouter, Gemini — configurable via `.env`
|
||||
- **Backend** : `backend/ai.py` + `backend/ai_routes.py` — 16 endpoints REST
|
||||
- **Frontend** : `frontend/js/ai.js` — toolbar intégrée à CodeMirror 6
|
||||
- Auto-save silencieux (2s), loading toasts, cache si pas de clé API
|
||||
### Fondations (v1.0.0 → v1.4.0)
|
||||
|
||||
### Vue graphique — Améliorations (v1.7.0)
|
||||
- **Header flat design 2 lignes** : titre flexible + recherche dédiée + barre statut
|
||||
- **Sticky panels** : tuile info (top-left/right), preview pleine hauteur (droite), boutons close
|
||||
- **Navigation historique** : ← → (avant/arrière) + ↑ (parent)
|
||||
- **Ctrl+survol** : aperçu contenu `/raw` formaté dans tooltip
|
||||
- Filtre tag, hover foncé unifié
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | FastAPI backend — CRUD fichiers, vaults, recherche full-text | 5j | 🔴 |
|
||||
| 2 | Moteur TF-IDF + stemming français (`snowballstemmer`) | 2j | 🔴 |
|
||||
| 3 | Watchdog — indexation temps réel avec debounce | 1j | 🟡 |
|
||||
| 4 | Interface SPA vanilla JS — sidebar, viewer, éditeur CodeMirror | 8j | 🔴 |
|
||||
| 5 | Sécurité : JWT + Argon2id, rate limiting, audit log, CSP headers | 3j | 🔴 |
|
||||
| 6 | Protection path traversal, utilisateur non-root Docker | 0.5j | 🔴 |
|
||||
| 7 | Compression GZip SSE-safe, Cache-Control immutable | 0.5j | 🟢 |
|
||||
| 8 | PWA : manifest, service worker, mode standalone | 1j | 🟡 |
|
||||
|
||||
### Vue graphique — Phases 1-4 (v1.6.0)
|
||||
- [x] Full-vault view (`scope=full`), filtre tag (`?tag=`), backlinks entrants
|
||||
- [x] Métriques de nœud : `incoming_count`, `outgoing_count`, `tag_count`
|
||||
- [x] Recherche visuelle, tooltips survol, couleurs adaptatives CSS
|
||||
- [x] Slider profondeur (0-3), filtre type (dossier/fichier/.md/autre)
|
||||
- [x] Mode focus, plein écran, export PNG
|
||||
- [x] Barnes-Hut O(n log n), cache graphe
|
||||
### UX & Productivité (v1.5.0 → v1.6.0)
|
||||
|
||||
### Qualité & Tests (v1.6.0)
|
||||
- [x] CI/CD Gitea Actions complet : lint (ruff 0 err), type (mypy 0 err), test (180 tests), security (bandit + pip-audit), build (Docker)
|
||||
- [x] sortedcontainers → SortedList pour O(log n) insert/remove
|
||||
- [x] Frontend tests Node.js : validate-imports.mjs + unit.test.mjs
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 9 | Publication publique de documents (lien partageable, token) | 1j | 🟡 |
|
||||
| 10 | Webhooks HTTP avec signature HMAC-SHA256 | 1j | 🟡 |
|
||||
| 11 | Dashboard statistiques (fichiers, tags, taille, vaults) | 0.5j | 🟡 |
|
||||
| 12 | Gestion des conflits Syncthing | 0.5j | 🟢 |
|
||||
| 13 | Index inversé incrémental (hook pattern) | 1j | 🟡 |
|
||||
| 14 | Backlinks panel dans le viewer | 0.5j | 🟡 |
|
||||
| 15 | Fichiers non-supportés → UI download | 0.3j | 🟢 |
|
||||
| 16 | Redaction de secrets (`secret_redactor.py`) | 0.5j | 🟡 |
|
||||
| 17 | Backup automatique avant écriture, restauration | 1j | 🟡 |
|
||||
| 18 | Vue graphe — Barnes-Hut, focus, plein écran, export PNG | 3j | 🟡 |
|
||||
| 19 | Header flat design, sticky panels, navigation historique ← → ↑ | 1j | 🟢 |
|
||||
| 20 | Ctrl+survol → aperçu contenu formaté | 0.5j | 🟢 |
|
||||
|
||||
### Recherche (v1.5.1)
|
||||
- [x] Stemming français via snowballstemmer
|
||||
- [x] Performance O(T) au lieu de O(S×T), crash guard IndexError
|
||||
### Architecture (v1.5.1)
|
||||
|
||||
### Refactoring (v1.5.1)
|
||||
- [x] Split `app.js` (8 875 lignes → 16 modules ES) dans `frontend/js/`
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 21 | Split `app.js` (8 875 lignes) → 16 modules ES | 3j | 🔴 |
|
||||
| 22 | Validateur imports/exports CI + tests unitaires frontend Node.js | 0.5j | 🟡 |
|
||||
|
||||
### Fonctionnel (v1.5.0)
|
||||
- [x] Publication publique de documents (token unique, expiration, lecture seule)
|
||||
- [x] Dashboard statistiques (4 cartes : fichiers, tags, taille, vaults)
|
||||
- [x] Webhooks HTTP avec signature HMAC-SHA256 optionnelle
|
||||
- [x] Documentation OpenAPI enrichie (modèles Pydantic documentés)
|
||||
- [x] Gestion conflits Syncthing (détection + résolution)
|
||||
- [x] Index inversé incrémental (hook pattern via `set_index_change_hook`)
|
||||
- [x] Export PDF (WeasyPrint intégré, accessible dans la vue publique)
|
||||
### CI/CD & Qualité (v1.6.0)
|
||||
|
||||
### Corrections (v1.4.0)
|
||||
- [x] Rate limiting login (IP + compte)
|
||||
- [x] Secret redactor (JWT, API keys, tokens, connection strings)
|
||||
- [x] Audit log JSON lines avec rotation 10MB
|
||||
- [x] Backup automatique avant écriture (`.obsigate-backup/`)
|
||||
- [x] Backlinks panel (index inversé + UI)
|
||||
- [x] Gestion fichiers non-supportés (UI message + download)
|
||||
- [x] Clic simple/double clic arborescence
|
||||
- [x] IGNORED_DIRS configurable via `OBSIGATE_IGNORED_DIRS`
|
||||
- [x] Timeout session configurable (`OBSIGATE_ACCESS_TOKEN_TTL`)
|
||||
- [x] TOC scroll avec caractères accentués (slugify unifié Unicode)
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 23 | Pipeline Gitea Actions : lint → test → security → build | 1j | 🔴 |
|
||||
| 24 | Ruff (0 erreur) + Mypy (0 erreur) + Bandit SAST + Pip-audit | 0.5j | 🟡 |
|
||||
| 25 | Pytest : 285 tests, 63% coverage | 3j | 🔴 |
|
||||
|
||||
### Quick Wins (v1.4.0)
|
||||
- [x] `.dockerignore` — contexte de build réduit
|
||||
- [x] `.env` pour les secrets (mot de passe retiré de docker-compose.yml)
|
||||
- [x] Compression GZip (SSE-safe)
|
||||
- [x] Cache-Control pour assets statiques
|
||||
### AI Editor (v1.7.0)
|
||||
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 26 | Toolbar : Edit, Tone, Translate, Generate, Rewrite, Toolbox | 2j | 🟡 |
|
||||
| 27 | Multi-provider : DeepSeek, OpenRouter, Gemini | 1j | 🟡 |
|
||||
| 28 | 16 endpoints REST `/api/ai/{action}` + backend `ai.py` / `ai_routes.py` | 2j | 🟡 |
|
||||
| 29 | Auto-save silencieux (2s debounce), loading toasts | 0.5j | 🟢 |
|
||||
|
||||
### Fonctionnalités avancées (v1.8.0 → v1.9.0)
|
||||
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 30 | Export PDF via WeasyPrint (endpoint API + lien share public) | 1j | 🟡 |
|
||||
| 31 | Palette de commandes `Ctrl+Alt+Space` | 1j | 🟡 |
|
||||
| 32 | Barre d'outils mobile + palette fichiers/commandes 📱 | 1j | 🟡 |
|
||||
| 33 | Drag & drop de fichiers | 1j | 🟡 |
|
||||
| 34 | Filtres recherche avancés : `created:`, `modified:`, `size:` | 0.5j | 🟡 |
|
||||
| 35 | Fichiers récents par vault | 0.5j | 🟡 |
|
||||
| 36 | Indicateur AI Actif (header + toast) | 0.3j | 🟢 |
|
||||
| 37 | Page d'accueil de vault (liste récursive par date, style recherche) | 0.5j | 🟡 |
|
||||
| 38 | Indexation non-bloquante (background thread) | 0.5j | 🟡 |
|
||||
| 39 | Git tags semver (v1.8.0, v1.9.0) | 0.2j | 🟢 |
|
||||
|
||||
### Gestion des Backups (v1.9.0)
|
||||
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 40 | Diff viewer : unifié + côte à côte, restauration depuis backup | 1j | 🟡 |
|
||||
| 41 | Gestionnaire de backups : page complète, filtre, suppression, purge | 1.5j | 🟡 |
|
||||
| 42 | Purge par vault avec confirmation, preview contenu (100 Ko) | 0.5j | 🟡 |
|
||||
| 43 | Compression gzip des vieux backups (niveau 6) | 0.5j | 🟢 |
|
||||
| 44 | Backup automatique périodique (`POST /api/backups/auto`) | 1j | 🟡 |
|
||||
| 45 | Restauration depuis le gestionnaire (extraction timestamp, confirmation) | 0.5j | 🟡 |
|
||||
| 46 | Auto-nettoyage : `max_backups_per_file` (défaut 10) | 0.5j | 🟡 |
|
||||
|
||||
### Mermaid.js (v2.0.0-dev)
|
||||
|
||||
| # | Feature | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 47 | CDN mermaid@11, `securityLevel: strict`, `startOnLoad: false` | 0.5j | 🟡 |
|
||||
| 48 | `renderMermaidBlocks()` — parse, rendu SVG, bloc d'erreur stylisé | 1j | 🟡 |
|
||||
| 49 | 22 templates (flowchart → sankey-beta) | 0.5j | 🟡 |
|
||||
| 50 | Live preview dans l'éditeur (debounce 500ms, panneau `#mermaid-live-preview`) | 1j | 🟡 |
|
||||
| 51 | Thème dark/light synchronisé, export SVG + PNG | 1j | 🟡 |
|
||||
| 52 | Rendu inline dans le viewer Markdown | 0.5j | 🟡 |
|
||||
| 53 | Zoom molette + drag + boutons +/- | 0.5j | 🟢 |
|
||||
| 54 | Mode plein écran avec header bar (icônes zoom, copy, download, close) | 0.5j | 🟢 |
|
||||
| 55 | Focus mode — clic diagramme → panneau latéral 480px | 0.3j | 🟡 |
|
||||
| 56 | Pré-processeur Obsidian (`[[liens]]`, `![[img]]`, `==highlight==`) | 0.3j | 🟡 |
|
||||
| 57 | Header bar : type de diagramme + toggle Code/Preview + copy SVG + download PNG | 1j | 🟡 |
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Prochaines étapes
|
||||
## 🔵 En cours (P1)
|
||||
|
||||
### P1 — Tests & Qualité
|
||||
|
||||
| # | Item | Effort | Impact |
|
||||
|---|------|--------|--------|
|
||||
| ✅ | ~~Tests unitaires backend (175 tests, 49% coverage)~~ | Fait | 🟢 |
|
||||
| ⬜ | Coverage → 70%+ (main.py 26%, watcher.py 23%, router.py 37%) | 2-3j | 🟢 |
|
||||
| ⬜ | Tests E2E (Playwright) | 3-5j | 🟢 |
|
||||
| ⬜ | Git tags semver (`git tag v1.7.0`) | 10min | 🟡 |
|
||||
|
||||
### P2 — Fonctionnalités UX
|
||||
|
||||
| # | Item | Effort | Impact |
|
||||
|---|------|--------|--------|
|
||||
| ⬜ | Palette de commandes (Ctrl+P) | 1j | 🟡 |
|
||||
| ⬜ | Drag & drop fichiers dans arborescence | 1-2j | 🟡 |
|
||||
| ⬜ | Diff viewer (comparer backups `.obsigate-backup/`) | 1j | 🟢 |
|
||||
| ⬜ | Filtres search avancés (created:, modified:, size:) | 1j | 🟡 |
|
||||
| ⬜ | Fichiers récents par vault (dashboard) | 0.5j | 🟡 |
|
||||
| ⬜ | Déduplication IGNORED_DIRS (indexer.py / watcher.py) | 0.5j | 🟢 |
|
||||
| ⬜ | Export PDF — route API + UI dédiée | 1j | 🟡 |
|
||||
|
||||
### P3/P4 — Long terme
|
||||
|
||||
| # | Item | Effort |
|
||||
|---|------|--------|
|
||||
| ⬜ | Mode hors-ligne PWA (IndexedDB + synchro) | 5-7j |
|
||||
| ⬜ | OAuth2/OIDC (Google, GitHub, SSO) | 3-5j |
|
||||
| ⬜ | Plugin système (extensions utilisateur) | 5-10j |
|
||||
| ⬜ | Collaboration temps réel (WebSocket + Yjs) | 5-10j |
|
||||
| ⬜ | i18n (anglais + français) | 2-3j |
|
||||
| ⬜ | MFA (TOTP/WebAuthn) | 3-5j |
|
||||
| ⬜ | Thèmes personnalisés (CSS variables exposées) | 1j |
|
||||
| ⬜ | Export multi-formats (HTML, MD bundle, ePub) | 2-3j |
|
||||
| ⬜ | Notifications web Push API | 2-3j |
|
||||
| ⬜ | Health check enrichi (index, mémoire, uptime, SSE) | 1j |
|
||||
| ⬜ | Rotation des logs Python (RotatingFileHandler) | 0.5j |
|
||||
### 58. Tests E2E Playwright ✅ FAIT
|
||||
- **Effort :** 2-3 jours | **Impact :** 🔴
|
||||
- **Description :** Tests navigateur automatisés pour les flows critiques : login, navigation vault, recherche full-text, ouverture/édition/sauvegarde de fichier, rendu Mermaid, export PDF.
|
||||
- **Implémentation :** 44 tests Playwright (chromium-desktop) + 12 tests mobile dans `tests/e2e/obsigate.spec.ts`. Intégré au CI Gitea (job `e2e` après `build`).
|
||||
- **Sous-tâches :**
|
||||
- [x] Installation Playwright + config (`playwright.config.ts`)
|
||||
- [x] Fixtures : vault de test (utilise les vaults Docker existants)
|
||||
- [x] Test : dashboard → stats, tabs, Quick Help, sidebar
|
||||
- [x] Test : recherche full-text → résultats, snippets, tri pertinence/date
|
||||
- [x] Test : ouverture fichier → viewer Markdown, métadonnées
|
||||
- [x] Test : éditeur → Forge, basic modal, Ctrl+S
|
||||
- [x] Test : rendu Mermaid dans le viewer + preview Forge
|
||||
- [x] Test : export PDF → bouton présent
|
||||
- [x] Test : mode sombre → toggle, persistence localStorage
|
||||
- [x] Test : responsive mobile → layout, recherche, barre flottante
|
||||
- [x] Test : barre flottante résultats → compteur, nav, toggles Aa/wd
|
||||
- [x] Test : sauvegardes → filtres Tous/Recherches/Répertoires
|
||||
- [x] Test : raccourcis clavier → Ctrl+K, /, Escape
|
||||
- [x] Test : menu contextuel répertoire
|
||||
- [x] Intégration CI : job `e2e` dans `.gitea/workflows/ci.yml`
|
||||
|
||||
---
|
||||
|
||||
## Chronologie suggérée
|
||||
## ⚪ Backlog — Priorité 3 (P3)
|
||||
|
||||
| Semaine | Actions |
|
||||
|---------|---------|
|
||||
| **S23** (cette semaine) | ✅ Quick Wins + Sécurité (rate limiting, secret redactor, audit, backup) |
|
||||
| **S24** | ✅ Publication publique, Webhooks, Dashboard, OpenAPI |
|
||||
| **S25** | ✅ Split app.js en modules ES, Stemming français |
|
||||
| **S26** | ✅ Graph view (phases 1-4), CI/CD, sortedcontainers |
|
||||
| **S27** | ✅ AI Editor + Graph improvements (sticky panels, nav history) |
|
||||
| **S28** | Coverage 70%+, Export PDF, Palette commandes |
|
||||
| **S29+** | Fonctionnalités P2 au fil de l'eau |
|
||||
### 59. Mode hors-ligne PWA complet ✅ FAIT
|
||||
- **Effort :** 3-4 jours | **Impact :** 🟡
|
||||
- **Description :** Service worker avancé avec IndexedDB pour permettre la navigation et la recherche en mode hors-ligne, avec file de synchronisation au retour réseau.
|
||||
- **Implémentation :** `offline-db.js` (377 lignes) + `offline.js` (209 lignes). IndexedDB 3 stores (files, content, pending). Badge hors-ligne dans le header. Modale résolution de conflits.
|
||||
- **Sous-tâches :**
|
||||
- [x] IndexedDB : stockage local de l'index des fichiers (paths, titles, tags)
|
||||
- [x] Moteur de recherche offline via IndexedDB (cursor + filtre)
|
||||
- [x] Cache des fichiers markdown récemment ouverts (derniers 50, prune)
|
||||
- [x] Stratégie de cache : Network First avec fallback IndexedDB
|
||||
- [x] File de synchronisation : modifications offline → appliquées au retour réseau
|
||||
- [x] UI indicateur : badge « Hors-ligne » + compteur de modifications en attente
|
||||
- [x] Gestion des conflits : détection et résolution manuelle (choix version locale vs serveur)
|
||||
|
||||
### 61. Plugins système — Extensions utilisateur
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟢
|
||||
- **Description :** Système de plugins permettant aux utilisateurs d'étendre ObsiGate avec des renderers personnalisés, des opérateurs de recherche, et des hooks d'UI. Inspiré du modèle de plugins Obsidian.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
|
||||
- [ ] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`
|
||||
- [ ] Sandbox d'exécution : Web Worker isolé pour le code plugin
|
||||
- [ ] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller)
|
||||
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index)
|
||||
- [ ] Hot-reload : activation/désactivation sans rechargement de page
|
||||
- [ ] Sécurité : manifest de permissions, validation de signature, CSP restrictif
|
||||
|
||||
### 62. Collaboration temps réel — Édition simultanée
|
||||
- **Effort :** 5-7 jours | **Impact :** 🟢
|
||||
- **Description :** Permettre à plusieurs utilisateurs d'éditer le même document markdown en même temps, comme Google Docs. Chaque personne voit en temps réel ce que les autres tapent, avec leur curseur affiché en couleur.
|
||||
- **WebSocket** : connexion persistante bidirectionnelle entre le navigateur et le serveur. Contrairement à HTTP où le client doit constamment demander « y a-t-il du nouveau ? » (polling), le WebSocket permet au serveur de pousser les changements instantanément. Une room WebSocket est créée par fichier ouvert — tous les utilisateurs qui éditent le même fichier rejoignent la même room.
|
||||
- **Yjs + CRDT** : Yjs est une bibliothèque qui implémente un algorithme CRDT (Conflict-free Replicated Data Type). Imagine deux personnes qui tapent en même temps au même endroit — sans CRDT, on aurait un conflit et du texte perdu. Avec CRDT, les deux modifications sont fusionnées mathématiquement sans perte. Chaque caractère reçoit un identifiant unique, et l'ordre final est déterministe même si les opérations arrivent dans le désordre. Pas besoin de verrouiller le fichier ni de résoudre des conflits manuellement.
|
||||
- **Awareness** : chaque utilisateur voit le curseur des autres (position, sélection) représenté par un nom et une couleur. Un indicateur dans la barre d'outils montre qui est connecté.
|
||||
- **Persistance** : le serveur sauvegarde périodiquement le document (debounce 2s après la dernière modification) pour que les changements survivent à une déconnexion.
|
||||
- **Pourquoi c'est important :** Permet le travail d'équipe sur la documentation, les notes de réunion, les spécifications techniques, les brainstorms. C'est le passage d'ObsiGate de « outil personnel » à « outil d'équipe ».
|
||||
- **Sous-tâches :**
|
||||
- [ ] Serveur WebSocket : endpoint `/ws/collab/{vault}/{path}` avec gestion des rooms
|
||||
- [ ] Intégration Yjs : `Y.Doc` partagé, `Y.Text` pour le contenu markdown
|
||||
- [ ] Awareness : curseurs colorés par utilisateur, sélections visibles
|
||||
- [ ] Synchro backend : persistance périodique du document (debounce 2s)
|
||||
- [ ] Gestion des droits : vérification `check_vault_access` par connexion WS
|
||||
- [ ] UI : indicateur de présence (avatars dans la barre d'outils éditeur)
|
||||
- [ ] UI : curseurs distants dans CodeMirror (extension collaborative)
|
||||
- [ ] Gestion des déconnexions : reconnexion automatique, merge state au retour
|
||||
- [ ] Tests de charge : 5+ utilisateurs simultanés sur le même fichier
|
||||
|
||||
### 63. Internationalisation (i18n) — Multilingue
|
||||
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Statut :** ✅ Terminé
|
||||
- **Description :** Support de l'anglais et du français via un système de clés de traduction.
|
||||
- **Sous-tâches :**
|
||||
- [x] Extraction des chaînes : ~1200 clés UI extraites
|
||||
- [x] Format : JSON `fr.json` + `en.json` dans `frontend/locales/` → 1206 clés parfaitement synchronisées
|
||||
- [x] Fonction `t(key)` → `frontend/js/i18n.js` avec `_applyDOM()`, `data-i18n`, `data-i18n-attr`, `data-i18n-placeholder`, `data-i18n-html`, support des templates `{var}`
|
||||
- [x] Sélecteur de langue dans les paramètres (persisté localStorage `obsigate-lang`)
|
||||
- [x] Traduction des messages backend → les toast/showToast sont maintenant i18n dans tous les fichiers JS
|
||||
- [x] Documentation multilingue → `README.md` + `README.fr.md`
|
||||
- [x] Nettoyage des clés inutilisées → locales nettoyées
|
||||
- [x] Tous les fichiers JS utilisent `t()` → plus de texte FR en dur (ai.js, sync.js, graph.js, autocomplete.js)
|
||||
- [x] Interface principale : dashboard, sidebar, editor, search, settings → EN/FR complet
|
||||
- [x] Guide d'utilisation : 18 sections (Intro → Astuces) → tous les paragraphes traduits
|
||||
- [x] Thèmes, palette de commandes, raccourcis, webhooks → EN/FR complet
|
||||
- [x] Messages système : toasts, statuts, événements → EN/FR complet
|
||||
|
||||
### 64. MFA — Authentification multi-facteurs
|
||||
- **Effort :** 2 jours | **Impact :** 🟡
|
||||
- **Description :** Ajout d'un second facteur d'authentification obligatoire pour les comptes administrateur. Deux méthodes sont proposées :
|
||||
- **TOTP** (Time-based One-Time Password) : l'utilisateur scanne un QR code avec son app d'authentification (Google Authenticator, Authy, Bitwarden) qui génère un code à 6 chiffres renouvelé toutes les 30 secondes. Au login, après avoir saisi son mot de passe, l'utilisateur doit entrer le code affiché sur son téléphone. Même si le mot de passe est volé, le compte reste protégé car l'attaquant n'a pas le téléphone.
|
||||
- **WebAuthn** (clés de sécurité physiques) : l'utilisateur enregistre une clé USB (YubiKey, SoloKey) ou utilise la biométrie de son appareil (empreinte digitale, Face ID, Windows Hello). Au login, le navigateur demande de toucher la clé physique ou de scanner le doigt. C'est le niveau de sécurité le plus élevé — résistant au phishing car la clé vérifie le domaine du site avant de répondre.
|
||||
- **Codes de secours** : 8 codes à usage unique imprimables, à conserver en lieu sûr, qui permettent de se connecter même si on perd son téléphone ou sa clé. Chaque code ne fonctionne qu'une seule fois.
|
||||
- **Pourquoi c'est important :** Le vol de mot de passe est la cause #1 de brèches de sécurité. Avec un vault Obsidian contenant des notes personnelles, projets sensibles, secrets et tokens API, l'authentification par simple mot de passe n'est plus suffisante. Le MFA empêche 99.9% des attaques de prise de compte automatisées (source : Microsoft Security).
|
||||
- **Sous-tâches :**
|
||||
- [ ] TOTP : génération de secret, QR code, vérification code 6 chiffres
|
||||
- [ ] WebAuthn : enregistrement de clé, assertion, attestation
|
||||
- [ ] UI : page « Sécurité du compte » avec activation/désactivation MFA
|
||||
- [ ] Flow login : mot de passe → challenge TOTP si activé
|
||||
- [ ] Recovery codes : 8 codes de backup à usage unique
|
||||
- [ ] Stockage : `totp_secret` + `webauthn_credential_id` dans `users.json`
|
||||
|
||||
### 65. Thèmes personnalisés — CSS variables
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢
|
||||
- **Description :** Exposition de variables CSS pour permettre aux utilisateurs de créer des thèmes personnalisés. Presets inclus : light, dark, high-contrast, sepia.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Audit des variables CSS existantes → liste des 30+ variables
|
||||
- [ ] Fichier `themes.json` avec presets (light, dark, high-contrast, sepia)
|
||||
- [ ] UI : sélecteur de thème dans les paramètres (aperçu live)
|
||||
- [ ] Import/export de thème personnalisé (JSON)
|
||||
- [ ] Application dynamique sans rechargement (`document.documentElement.style.setProperty`)
|
||||
|
||||
### 66. Export multi-formats
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢
|
||||
- **Description :** Export de notes individuelles ou de vaults entiers en HTML standalone, bundle Markdown (.zip), et ePub pour liseuses.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Export HTML standalone : CSS inliné, images en base64, navigation inter-fichiers
|
||||
- [ ] Export MD bundle : ZIP du vault avec structure préservée
|
||||
- [ ] Export ePub : conversion markdown → ePub via `markdown` + `ebooklib`
|
||||
- [ ] UI : bouton « Exporter » dans le viewer (fichier unique) + dans le menu vault (export complet)
|
||||
- [ ] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
|
||||
|
||||
### 74. Support complet des documents PDF
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟡
|
||||
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement. Actuellement, les PDF sont traités comme des fichiers binaires non supportés (message « Ce fichier est binaire et ne peut pas être affiché » + bouton download).
|
||||
- **Architecture actuelle :**
|
||||
- `SUPPORTED_EXTENSIONS` (`backend/indexer.py:56`) : ne contient pas `.pdf` → les PDF sont ignorés par l'indexeur, le file watcher, et l'arborescence
|
||||
- `api_file_view()` (`backend/main.py:2303`) : UnicodeDecodeError sur lecture → retourne `unsupported: true`
|
||||
- `frontend/js/viewer.js:377` : si `data.unsupported` → affiche le message binaire + bouton download
|
||||
- `pdf_export.py` : exporte du MD → PDF (WeasyPrint) — aucun rapport avec la lecture de PDF existants
|
||||
- Icone PDF déjà présente dans `EXT_ICONS` frontend (`.pdf` → `file-text`) — inutilisée
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Backend — Extraction de texte PDF (1-1.5 jour)
|
||||
- [ ] **A1. Dépendance** : Ajouter `pymupdf` (PyMuPDF/fitz) à `requirements.txt` — bibliothèque C performante avec extraction texte + métadonnées, déjà compatible avec l'image Docker (libs système GTK/Pango déjà présentes pour WeasyPrint). Alternative légère : `pypdf` (pure Python, pas de deps système) si pymupdf pose problème.
|
||||
- [ ] **A2. Module `backend/pdf_reader.py`** : Créer un module dédié avec les fonctions :
|
||||
- `extract_pdf_text(file_path: Path) -> str` : extrait tout le texte du PDF, page par page, avec séparateur `\f` entre pages. Gère les PDF encodés, protégés par mot de passe (retourne erreur explicite), et corrompus.
|
||||
- `extract_pdf_metadata(file_path: Path) -> dict` : extrait titre, auteur, sujet, nombre de pages, taille.
|
||||
- `extract_pdf_preview(file_path: Path, max_chars: int = 100000) -> str` : extrait les N premiers caractères pour l'indexation (limité par `SEARCH_CONTENT_LIMIT`).
|
||||
- [ ] **A3. Fallback pypdf** : Si pymupdf non disponible (exception d'import), fallback automatique sur `pypdf` avec un log warning. Code structuré avec une interface abstraite (`PdfReader` protocol) pour swap transparent.
|
||||
|
||||
##### B. Backend — Indexation des PDF (1 jour)
|
||||
- [ ] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.pdf` au set dans `backend/indexer.py:56`. Déclencher un rebuild complet de l'index (incrémental via le file watcher pour les nouveaux PDFs).
|
||||
- [ ] **B2. Modification de `index_document()`** (`backend/indexer.py:524`) : Dans la fonction d'indexation, détecter l'extension `.pdf` et appeler `extract_pdf_text()` au lieu de `read_text()`. Le texte extrait alimente le pipeline TF-IDF existant — aucun changement nécessaire dans `search.py`.
|
||||
- [ ] **B3. Métadonnées PDF dans le document info** : Enrichir la structure de retour de `index_document()` avec les champs spécifiques PDF : `page_count`, `pdf_title` (titre extrait des métadonnées, prioritaire sur le nom de fichier), `pdf_author`.
|
||||
- [ ] **B4. Gestion d'erreur robuste** : PDF corrompu → log warning + skip (ne pas bloquer l'indexation). PDF volumineux (>50 Mo) → log info + extraction tronquée à `SEARCH_CONTENT_LIMIT`. Timeout d'extraction configurable (30s par défaut).
|
||||
|
||||
##### C. Backend — API endpoints PDF (0.5 jour)
|
||||
- [ ] **C1. Modification de `api_file_view()`** (`backend/main.py:2270`) : Avant la tentative de `read_text()`, détecter `.pdf` par extension. Pour les PDF :
|
||||
- Extraire le texte avec `extract_pdf_text()`
|
||||
- Extraire les métadonnées (pages, auteur)
|
||||
- Retourner une réponse structurée : `is_pdf: true`, `page_count`, `pdf_metadata`, `html` (aperçu texte formaté), `raw_length`
|
||||
- Le champ `html` contient un rendu texte simple (pas de markdown) : texte paginé ou première page formatée
|
||||
- [ ] **C2. Nouvel endpoint `GET /api/file/{vault}/pdf/stream`** : Sert le fichier PDF brut avec `Content-Type: application/pdf` et `Content-Disposition: inline` pour visualisation dans le navigateur. Supporte le `Range` header (HTTP 206 Partial Content) pour le streaming progressif des gros PDFs — essentiel pour la performance sur des documents volumineux.
|
||||
- [ ] **C3. Nouvel endpoint `GET /api/file/{vault}/pdf/info`** : Retourne les métadonnées seules (pages, titre, auteur) sans le contenu — permet à l'UI d'afficher les infos avant de charger le PDF lourd.
|
||||
- [ ] **C4. Endpoint download** : Déjà fonctionnel (`/api/file/{vault}/download`) — aucun changement nécessaire.
|
||||
|
||||
##### D. Frontend — Arborescence de fichiers (0.5 jour)
|
||||
- [ ] **D1. Icône et filtre** : L'icône PDF (`file-text` de Lucide) est déjà mappée dans `EXT_ICONS` (`frontend/js/utils.js:129`). Une fois `.pdf` dans `SUPPORTED_EXTENSIONS`, les PDFs apparaissent automatiquement dans l'arborescence via l'API `list_directory`. Aucun changement UI nécessaire.
|
||||
- [ ] **D2. Distinction visuelle** (optionnel) : Sous-titre léger sous le nom du fichier dans l'arborescence indiquant le nombre de pages (ex: « 12 pages ») pour différencier rapidement les PDF des MD. Donnée disponible via l'API `pdf/info`.
|
||||
- [ ] **D3. Drag & drop et upload** : Le mécanisme d'upload existant (`POST /api/file/{vault}/upload`) fonctionne déjà pour tout type de fichier. Vérifier que le MIME type `application/pdf` est correctement détecté et que le watcher réindexe automatiquement.
|
||||
|
||||
##### E. Frontend — Viewer PDF (1 jour)
|
||||
- [ ] **E1. Rendu inline natif** : Utiliser le visualiseur PDF intégré du navigateur via `<iframe>` pointant sur `/api/file/{vault}/pdf/stream?path=...`. Approche optimale :
|
||||
- Zéro dépendance JS supplémentaire
|
||||
- Rendu identique à Chrome/Firefox/Safari natif
|
||||
- Support natif du zoom, recherche dans le document, navigation par pages, rotation
|
||||
- L'iframe s'adapte en hauteur (`height: 100%` du content-area)
|
||||
- [ ] **E2. Détection dans le viewer** : Dans `frontend/js/viewer.js`, fonction `renderFileContent()` — ajouter une branche après la détection `data.unsupported` :
|
||||
- Si `data.is_pdf === true` → render l'iframe PDF au lieu du viewer markdown
|
||||
- Si le navigateur ne supporte pas le rendu PDF inline → fallback sur l'UI « binaire » avec bouton download + bouton « Ouvrir dans un nouvel onglet »
|
||||
- [ ] **E3. Barre d'outils PDF** : Dans la barre d'outils du viewer (celle qui a déjà les boutons Copier, Source, .md, PDF, Éditer, pop-out), pour les fichiers PDF :
|
||||
- Remplacer « Copier » / « Source » / « Éditer » par des actions spécifiques PDF
|
||||
- Bouton « Télécharger » (.pdf) — déjà existant, fonctionne
|
||||
- Bouton « Plein écran » — ouvre le PDF dans un nouvel onglet en plein écran
|
||||
- Badge « N pages » indiquant le nombre de pages
|
||||
- Bouton « pop-out » — gardé, ouvre le viewer PDF dans une popup séparée
|
||||
- [ ] **E4. Thème** : L'iframe PDF est en dehors du DOM applicatif donc pas affecté par le thème dark/light. Ajouter un message discret « Le PDF s'affiche avec le thème de votre navigateur » si `_currentTheme === 'dark'` (les PDFs en fond blanc dans un thème sombre peuvent surprendre).
|
||||
- [ ] **E5. Responsive** : L'iframe s'adapte à la largeur du content-area. En mode mobile, hauteur ajustée à la viewport. La toolbar mobile existante fonctionne avec les actions PDF.
|
||||
|
||||
##### F. Frontend — Recherche (0.5 jour)
|
||||
- [ ] **F1. Résultats de recherche** : Les PDFs apparaissent dans les résultats via le TF-IDF existant (le texte extrait est indexé). Ajouter un badge visuel « PDF » à côté du titre dans les résultats de recherche pour distinguer les PDFs des MD — utiliser l'icône `file-text`.
|
||||
- [ ] **F2. Snippets de recherche** : Les extraits de contexte montrent le texte extrait du PDF avec surlignage des termes recherchés — fonctionnement identique aux MD via le mécanisme de snippet existant dans `search.py`.
|
||||
- [ ] **F3. Filtres de recherche avancés** : Ajouter `ext:pdf` comme filtre pour limiter la recherche aux PDFs uniquement (complément aux filtres `created:`, `modified:`, `size:` déjà prévus #34).
|
||||
|
||||
##### G. Docker & Dépendances (0.5 jour)
|
||||
- [ ] **G1. requirements.txt** : Ajouter `pymupdf>=1.24.0` (sinon `pypdf>=4.0` en fallback).
|
||||
- [ ] **G2. Dockerfile** : Vérifier que l'image `python:3.11-slim` dispose des libs système nécessaires pour pymupdf. Si besoin, ajouter `libmupdf-dev` ou utiliser `pypdf` (pure Python) pour éviter la complexité. Recommandation : pypdf pour la simplicité Docker, pymupdf en option pour la performance.
|
||||
- [ ] **G3. Configuration** : Ajouter `OBSIGATE_PDF_MAX_SIZE_MB` (défaut 50) pour limiter la taille des PDFs indexés et `OBSIGATE_PDF_EXTRACT_TIMEOUT` (défaut 30s).
|
||||
|
||||
##### H. Tests (1 jour)
|
||||
- [ ] **H1. Tests unitaires backend** :
|
||||
- `test_pdf_reader.py` : extraction texte PDF simple, PDF vide, PDF avec uniquement des images (OCR non requis — retourne chaîne vide), PDF protégé par mot de passe, PDF corrompu, extraction métadonnées
|
||||
- Fixtures : créer un PDF de test minimal (2 pages, texte simple) via `reportlab` dans les fixtures de test
|
||||
- `test_pdf_indexing.py` : vérifier qu'un PDF dans un vault est correctement indexé, que le texte est recherchable, que `index_document()` gère l'extension `.pdf`
|
||||
- `test_pdf_api.py` : endpoint `view` retourne `is_pdf: true`, endpoint `pdf/stream` retourne `application/pdf`, endpoint `pdf/info` retourne les métadonnées
|
||||
- [ ] **H2. Tests frontend** :
|
||||
- Test d'intégration : naviguer vers un fichier PDF → l'iframe est rendue
|
||||
- Test : fichier PDF dans les résultats de recherche
|
||||
- Test : téléchargement de PDF fonctionnel
|
||||
- [ ] **H3. CI** : Ajouter la fixture PDF de test dans les artefacts de CI. Les tests PDF sont sautés si pymupdf/pypdf n'est pas disponible.
|
||||
|
||||
##### I. Documentation utilisateur (inclus dans l'effort)
|
||||
- [ ] **I1.** Mettre à jour README.md : mentionner le support PDF dans les formats supportés
|
||||
- [ ] **I2.** Ajouter une note dans la FAQ : « Comment visualiser un PDF dans ObsiGate ? »
|
||||
- [ ] **I3.** Documenter les limitations : pas d'OCR (PDFs scannés non recherchables), pas d'annotation PDF, pas d'édition de PDF
|
||||
|
||||
##### J. Points d'attention / Risques
|
||||
- **Performance** : Un PDF de 500 pages peut générer beaucoup de texte → `SEARCH_CONTENT_LIMIT` (100 Ko) limite l'indexation au début du document. Pour les PDFs volumineux, envisager une extraction paginée avec `SEARCH_CONTENT_LIMIT` réparti sur les N premières pages.
|
||||
- **Sécurité** : Les PDFs malveillants (injections JS, liens externes) ne sont pas exécutés dans l'iframe par défaut (sandbox du navigateur). Ajouter `sandbox="allow-same-origin"` sur l'iframe pour renforcer.
|
||||
- **Mémoire** : pymupdf charge le PDF entier en mémoire. Pour les très gros PDFs (>200 Mo), utiliser le streaming ou `pypdf` qui supporte la lecture paresseuse.
|
||||
- **Compatibilité navigateurs** : Le rendu PDF natif fonctionne sur Chrome, Firefox, Edge, Safari. Safari iOS a des limitations sur les iframes PDF. Prévoir le fallback « ouvrir dans un nouvel onglet » pour ces cas.
|
||||
- **PDFs dans les vaults Obsidian** : Obsidian Desktop ne gère pas nativement les PDFs (affichage via iframe système). ObsiGate apporte une valeur ajoutée en offrant la visualisation + recherche.
|
||||
|
||||
---
|
||||
|
||||
*Document généré le 2026-06-02 — Mis à jour depuis l'audit du 2026-05-27.*
|
||||
*Voir aussi : [ROADMAP.md](../ROADMAP.md) (version simplifiée), [CHANGELOG.md](../CHANGELOG.md)*
|
||||
### 75. Éditeur multi-panneaux (Split View) ✅ Complété
|
||||
- **Effort :** 5-7 jours (réalisé) | **Impact :** 🟡
|
||||
- **Statut :** Fonctionnel — toutes les sous-tâches implémentées (reste tests I2/I3)
|
||||
- **Fichiers clés :** `frontend/js/pane-manager.js` (1082 loc), `frontend/js/viewer.js` (modifié), `frontend/js/ui.js` (modifié), `frontend/js/dashboard.js` (modifié), `frontend/js/palette.js` (modifié), `frontend/style.css` (modifié), `tests/test_pane_manager.py` (22 tests)
|
||||
- **Description :** Système de panneaux divisés permettant d'afficher plusieurs documents côte à côte ou superposés, à la manière d'un éditeur de code (VS Code). Drag & drop des onglets entre les panneaux, drag-to-split, menu contextuel, raccourcis clavier, palette de commandes.
|
||||
- **Architecture :**
|
||||
- **PaneManager** — orchestre la grille de panneaux (1-4), crée/détruit les instances PaneTabManager
|
||||
- **PaneTabManager (factory `createPaneTabManager(paneId)`)** — chaque panneau a son instance indépendante avec ses propres onglets, cache, état
|
||||
- **Singleton TabManager** — délègue automatiquement vers le PaneTabManager actif quand en mode split ; gère les onglets en single-pane
|
||||
- **Migration automatique** — les onglets migrent singleton→PaneTabManager au premier split, et inversement au collapse
|
||||
- **getContentArea()** — helper global dans viewer.js, priorise `window._activePaneContentArea` (set par PaneTabManager)
|
||||
- **Implémenté :**
|
||||
|
||||
##### ✅ A. PaneManager — Gestionnaire de panneaux
|
||||
- [x] **A1.** Module `PaneManager` + factory `createPaneTabManager` — état indépendant par panneau
|
||||
- [x] **A2.** Création dynamique du DOM — `.pane-grid` avec `.pane-container`, IDs suffixés `-N`
|
||||
- [x] **A3.** Redimensionnement — drag handles, double-clic reset 50/50, contrainte 20-80%, localStorage
|
||||
- [x] **A4.** Indépendance des panneaux — chaque panneau a ses propres onglets, scroll, cache, source view
|
||||
|
||||
##### ✅ B. Refactoring du TabManager
|
||||
- [x] **B1.** Découplage du DOM — `init(tabBar, tabList, contentArea)` par paramètre
|
||||
- [x] **B2.** Factory `createPaneTabManager(paneId)` — instances indépendantes
|
||||
- [x] **B3.** Compatibilité ascendante — pane 0 réutilise les IDs existants, panes 1+ suffixés `-N`
|
||||
- [x] **B4.** Synchronisation state global — `setActivePane()` met à jour `currentVault`/`currentPath`
|
||||
|
||||
##### ✅ C. Actions de division
|
||||
- [x] **C1.** Menu contextuel — « Diviser à droite », « Diviser en bas », « Fermer le panneau »
|
||||
- [x] **C1b.** Sous-menu « Déplacer vers... » — flyout hover listant les autres panneaux
|
||||
- [x] **C2.** Raccourcis clavier — `Ctrl+Alt+\` (split R), `Ctrl+Alt+Shift+\` (split D), `Ctrl+Alt+W` (close pane), `Ctrl+Shift+Alt+W` (close others), `Ctrl+Alt+←/→/↑/↓` (navigate)
|
||||
- [x] **C3.** Boutons dans la barre d'onglets — ⊞→ et ⊞↓ visibles au survol
|
||||
- [x] **C4.** Fermeture panneau — retour single-pane si dernier, focus voisin, onglets perdus (pas déplacés)
|
||||
|
||||
##### ✅ D. Drag & Drop
|
||||
- [x] **D1.** Drag cross-pane — `text/plain` JSON `{paneId, tabId, index}`, indicateurs visuels
|
||||
- [x] **D2.** Drag depuis l'arborescence — format `tree:{vault,path}`, drop sur panneau spécifique
|
||||
- [x] **D3.** Drag-to-split — zone droite 28% → split right, zone basse 28% → split down, overlay bleu
|
||||
|
||||
##### ✅ E. Rendu et performance
|
||||
- [x] **E1.** Rendu indépendant — `window._activePaneContentArea` override pour `renderFile()`
|
||||
- [x] **E2.** Cache de contenu — chaque PaneTabManager a son propre `_tabCache`, pas de refetch
|
||||
- [x] **E3.** Rendu paresseux — contenu masqué via `display:none` sur panneaux inactifs
|
||||
- [x] **E4.** Verrouillage éditeur — même fichier déjà ouvert ailleurs → focus le panneau existant
|
||||
|
||||
##### ✅ F. CSS & Design
|
||||
- [x] **F1-F5.** Grid layout, resize handles avec hover accent, barre onglets par panneau, variables CSS (dark/light), responsive <768px
|
||||
|
||||
##### ✅ G. Persistance et restauration
|
||||
- [x] **G1.** localStorage `obsigate-panes` — layout, panes[{id, width, activeTab, tabs[]}]
|
||||
- [x] **G2.** Restauration au chargement — reconstruction grille + onglets + activation
|
||||
- [x] **G3.** Reset dans la palette de commandes — « 🔄 Réinitialiser les panneaux »
|
||||
|
||||
##### ✅ H. Compatibilité
|
||||
- [x] **H1.** Palette de commandes — « Diviser à droite », « Diviser en bas », « Fermer le panneau », « Panneau suivant/précédent » (catégorie Panneaux)
|
||||
- [x] **H2.** Pop-out inchangé
|
||||
- [x] **H3.** Mermaid scoped au content-area
|
||||
- [x] **H4.** AI Editor en modale (inchangé)
|
||||
- [x] **H5.** Ctrl+S par panneau
|
||||
|
||||
##### ✅ I. Tests
|
||||
- [x] **I1.** 22 tests statiques (structure, CSS, brace balance, régression backend)
|
||||
- [x] **I4.** 307 tests de régression passent
|
||||
- [ ] **I2.** Tests d'intégration frontend (JS DOM)
|
||||
- [ ] **I3.** Tests E2E Playwright (#58)
|
||||
|
||||
##### Reste à faire (0)
|
||||
- [x] **C1b.** Sous-menu « Déplacer vers... » dans le menu contextuel des onglets
|
||||
- [x] **D2.** Drag & drop depuis l'arborescence vers un panneau spécifique
|
||||
- [x] **E3.** Rendu paresseux (mémoire) — contenu masqué via `display:none` sur panneaux inactifs
|
||||
- [x] **E4.** Verrouillage éditeur multi-panneau — même fichier ouvert dans 2 panneaux → focus le panneau existant
|
||||
- [x] **G3.** Bouton reset dans la palette de commandes (🔄 Réinitialiser les panneaux)
|
||||
- [ ] **I2.** Tests d'intégration frontend (JSDOM ou similaire)
|
||||
- [ ] **I3.** Tests E2E Playwright
|
||||
|
||||
---
|
||||
|
||||
### 76. BooksLM — Console AI contextuelle par répertoire (style NotebookLM)
|
||||
- **Effort :** 5-6 jours | **Impact :** 🟡
|
||||
- **Description :** Console de chat AI contextuelle accessible via le menu contextuel des répertoires dans l'arborescence. Au clic sur « BooksLM », un panneau de chat s'ouvre à droite du viewer et indexe automatiquement toutes les ressources markdown (et PDF via #74) du répertoire courant et de ses sous-répertoires récursivement comme contexte pour un assistant AI. L'assistant peut répondre à des questions, résumer, synthétiser, et croiser l'information à travers tous les documents du scope — exactement comme NotebookLM de Google, mais pour n'importe quel répertoire de votre vault Obsidian.
|
||||
- **Fonctionnement général :**
|
||||
- L'utilisateur fait un clic-droit sur un répertoire dans l'arborescence → option « BooksLM »
|
||||
- Un panneau latéral (450-500px) s'ouvre à droite, poussant le viewer existant
|
||||
- Le backend collecte tous les fichiers `.md` (et `.pdf` si #74 est complété) récursivement
|
||||
- Le contenu est assemblé en un contexte système pour le LLM (fenêtre de contexte optimisée)
|
||||
- L'utilisateur peut chatter avec l'AI qui a une connaissance complète du répertoire
|
||||
- L'historique de chat est optionnellement sauvegardé (localStorage ou fichier `.books-lm.json` dans le répertoire)
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Backend — Collecte et préparation du contexte (1.5-2 jours)
|
||||
- [ ] **A1. Nouvel endpoint `POST /api/ai/bookslm/context`** : Reçoit `{vault, directory}` → parcourt récursivement le répertoire → lit tous les fichiers supportés → retourne un objet `{files: [{path, title, content, type: "md"|"pdf"}], total_chars, file_count, directory_tree}`
|
||||
- [ ] **A2. Limites configurables** : `BOOKSLM_MAX_FILES` (défaut 200), `BOOKSLM_MAX_TOTAL_CHARS` (défaut 200 000), `BOOKSLM_MAX_FILE_CHARS` (défaut 30 000 par fichier). Les fichiers au-delà sont tronqués avec un message `[... continue dans le fichier]`.
|
||||
- [ ] **A3. Filtrage intelligent** : Ignorer les fichiers cachés (`.` préfixe), les dossiers `_attachments/`, les fichiers binaires non-supportés. Respecter `.gitignore` ou `.obsigate-ignore` si présent.
|
||||
- [ ] **A4. Streaming du contexte** : Pour les très gros répertoires, l'endpoint supporte le streaming SSE pour informer l'UI de la progression (« Indexation de 45/127 fichiers... »).
|
||||
|
||||
##### B. Backend — Endpoint chat BooksLM (1 jour)
|
||||
- [ ] **B1. Endpoint `POST /api/ai/bookslm/chat`** : Reçoit `{vault, directory, message, conversation_history: [{role, content}]}` → construit le contexte système à partir des fichiers du répertoire → appelle le provider AI configuré → stream la réponse via SSE.
|
||||
- [ ] **B2. Prompt système** : Template par défaut optimisé : « Tu es un assistant de recherche qui aide à comprendre et analyser les documents d'un répertoire. Voici le contenu de tous les documents disponibles. Réponds en te basant UNIQUEMENT sur ces documents. Cite tes sources avec le nom du fichier. Si l'information n'est pas dans les documents, dis-le clairement. »
|
||||
- [ ] **B3. Mode « Sources »** : Chaque réponse inclut les fichiers référencés (détectés via mention de titre ou contenu). L'UI affiche des badges de source cliquables.
|
||||
- [ ] **B4. Mise en cache du contexte** : Le contexte du répertoire est caché en mémoire (hash du contenu) pour éviter de re-parser tous les fichiers à chaque message. Invalidé si un fichier est modifié (watcher).
|
||||
- [ ] **B5. Provider** : Utilise la même abstraction provider que l'AI Editor (#27) — DeepSeek, OpenRouter, Gemini. Ajouter `BOOKSLM_DEFAULT_MODEL` dans `.env` (défaut : `DEEPSEEK_MODEL`).
|
||||
|
||||
##### C. Frontend — Panneau de chat BooksLM (2 jours)
|
||||
- [ ] **C1. Module `frontend/js/bookslm.js`** : Nouveau module ES avec la classe `BooksLM` :
|
||||
- Gère l'état : `_isOpen`, `_currentDirectory`, `_messages[]`, `_contextFiles[]`, `_isLoading`
|
||||
- Crée le DOM du panneau : conteneur latéral `.bookslm-panel` (450px, redimensionnable via poignée)
|
||||
- Header : titre « BooksLM », nom du répertoire courant, bouton fermer, bouton « Nouvelle conversation »
|
||||
- Zone de messages : scrollable, bulles utilisateur (droite) et assistant (gauche) avec Markdown rendu
|
||||
- Zone d'entrée : `textarea` avec Ctrl+Enter pour envoyer, bouton envoyer
|
||||
- Barre d'état : nombre de fichiers indexés, nombre total de caractères
|
||||
- [ ] **C2. Intégration au menu contextuel** : Dans `frontend/js/context-menu.js`, ajouter l'option « 🧠 BooksLM » pour les nœuds de type `directory` dans l'arborescence. Visible seulement si le vault est accessible.
|
||||
- [ ] **C3. Rendu Markdown dans le chat** : Utiliser le renderer Markdown existant (ou un sous-ensemble simplifié) pour afficher les réponses de l'AI avec support du **gras**, *italique*, `code`, listes, et tableaux.
|
||||
- [ ] **C4. Streaming des réponses** : Connexion SSE pour afficher la réponse de l'AI token par token (effet « typing » naturel).
|
||||
- [ ] **C5. Badges de sources** : Après chaque réponse, afficher les fichiers sources mentionnés sous forme de badges cliquables qui ouvrent le fichier dans le viewer principal.
|
||||
- [ ] **C6. Mode plein écran** : Bouton pour basculer en mode plein écran (cache la sidebar, le panneau prend tout l'espace). Utile pour les sessions de recherche intense.
|
||||
|
||||
##### D. Frontend — Actions et UX (0.5-1 jour)
|
||||
- [ ] **D1. Copier la réponse** : Bouton copie sur chaque message assistant.
|
||||
- [ ] **D2. Régénérer** : Bouton pour régénérer la dernière réponse (utile si la réponse est hors-sujet).
|
||||
- [ ] **D3. Exporter la conversation** : Bouton pour exporter l'historique en Markdown → sauvegarder comme note dans le répertoire courant.
|
||||
- [ ] **D4. Historique des conversations** : Stockage dans `localStorage` par clé `bookslm-history-{vault}-{directory}`. Liste déroulante dans le header pour charger une conversation précédente.
|
||||
- [ ] **D5. Indicateur de contexte** : Barre de progression montrant l'utilisation du contexte (% de la limite `BOOKSLM_MAX_TOTAL_CHARS`). Si le répertoire est trop gros, suggérer de réduire le scope.
|
||||
- [ ] **D6. Suggestions de questions** : Après l'indexation, afficher 3 questions suggérées basées sur les titres et métadonnées des fichiers (« Résume ce répertoire », « Quels sont les thèmes principaux ? », « Y a-t-il des contradictions entre ces documents ? »).
|
||||
|
||||
##### E. CSS & Design (0.5 jour)
|
||||
- [ ] **E1. Panneau latéral** : Animation slide-in depuis la droite (300ms ease-out). Ombre portée pour séparation visuelle.
|
||||
- [ ] **E2. Poignée de redimensionnement** : Similaire à `.sidebar-resize-handle`, curseur `col-resize`, largeur min 350px, max 800px. Persistance dans localStorage.
|
||||
- [ ] **E3. Bulles de chat** : Style cohérent avec le thème actuel. Messages utilisateur avec accent-color, messages assistant avec fond `var(--surface2)`.
|
||||
- [ ] **E4. Responsive** : Sur mobile (<768px), le panneau passe en plein écran (pas de split view). Navigation par swipe pour revenir au viewer.
|
||||
- [ ] **E5. Thème sombre/clair** : Toutes les variables CSS utilisent les customs properties existantes → compatibilité automatique.
|
||||
|
||||
##### F. Intégration et compatibilité (0.5 jour)
|
||||
- [ ] **F1. Compatibilité Split View (#75)** : Si le split view est actif, BooksLM s'ouvre en remplacement du panneau le plus à droite (ou en 3e colonne). Le panneau BooksLM est traité comme un type spécial de pane dans le PaneManager.
|
||||
- [ ] **F2. Compatibilité AI Editor (#26-29)** : BooksLM utilise le même système de provider AI. Les clés API configurées pour l'AI Editor fonctionnent pour BooksLM.
|
||||
- [ ] **F3. Compatibilité PDF (#74)** : Si le support PDF est implémenté, les PDFs dans le répertoire sont inclus dans le contexte (texte extrait).
|
||||
- [ ] **F4. Palette de commandes (#31)** : Ajouter les commandes « BooksLM: Ouvrir pour le répertoire courant » et « BooksLM: Nouvelle conversation ».
|
||||
|
||||
##### G. Tests (1 jour)
|
||||
- [ ] **G1. Tests unitaires backend** :
|
||||
- `test_bookslm_context.py` : collecte récursive, respect des limites, filtrage fichiers cachés, streaming SSE
|
||||
- `test_bookslm_chat.py` : construction du prompt, caching du contexte, invalidation après modification
|
||||
- [ ] **G2. Tests d'intégration frontend** :
|
||||
- Ouverture du panneau BooksLM depuis le menu contextuel
|
||||
- Envoi d'un message et affichage de la réponse
|
||||
- Badges de sources cliquables
|
||||
- Export de conversation
|
||||
- Redimensionnement du panneau
|
||||
- [ ] **G3. Tests E2E (Playwright, #58)** :
|
||||
- Test : clic-droit sur répertoire → BooksLM → panneau visible
|
||||
- Test : chat fonctionnel → message envoyé → réponse reçue
|
||||
- Test : fermeture et réouverture → historique restauré
|
||||
|
||||
##### H. Points d'attention / Risques
|
||||
- **Taille du contexte** : Un répertoire avec 500 fichiers markdown peut facilement dépasser 1M de caractères → essentiel de tronquer intelligemment (préférer les fichiers modifiés récemment, ou prioriser selon la structure : README.md, index.md en premier).
|
||||
- **Coût API** : Chaque message envoie le contexte complet au LLM → potentiellement coûteux en tokens. Ajouter un avertissement si le contexte dépasse 100K tokens estimés.
|
||||
- **Performance** : La collecte récursive de 200+ fichiers peut prendre plusieurs secondes → l'UI doit montrer la progression (streaming SSE) et le backend doit être non-bloquant (background task).
|
||||
- **Sécurité** : Ne pas envoyer les fichiers ignorés (`.env`, `.git/`, `.hermes/`, tokens, secrets) dans le contexte. Le `secret_redactor.py` existant (#16) doit être appliqué au contenu avant envoi au LLM.
|
||||
- **Privacy** : Si le provider AI est externe (OpenRouter, DeepSeek, Gemini), le contenu des fichiers est envoyé à un tiers → warning dans l'UI avec option d'activer/désactiver par vault.
|
||||
- **Scope répertoire** : Ne PAS inclure les fichiers hors du répertoire (pas de remontée au parent ou de traversée de vault). Le scope est strictement le répertoire + sous-répertoires.
|
||||
|
||||
---
|
||||
|
||||
### 78. Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
|
||||
|
||||
- **Effort :** 3-4 jours | **Impact :** 🟡 | **Statut :** ⚪ Prévu
|
||||
|
||||
- **Description :** Prise en charge native des fichiers `.excalidraw` dans ObsiGate avec un éditeur visuel complet intégré. L'utilisateur peut ouvrir un fichier `.excalidraw` depuis l'arborescence et obtenir l'éditeur de diagrammes Excalidraw directement dans ObsiGate — dessiner, modifier, sauvegarder, comme dans l'app Excalidraw standalone, mais intégré au flux de travail du vault Obsidian.
|
||||
|
||||
- **Pourquoi c'est important :** Excalidraw est devenu le standard de fait pour les diagrammes et croquis dans l'écosystème Obsidian (plugin communautaire avec 1M+ téléchargements). Les utilisateurs créent des `.excalidraw` dans leur vault et s'attendent à pouvoir les visualiser et éditer. Actuellement ObsiGate traite ces fichiers comme du JSON brut — illisible. Avec l'éditeur intégré, ObsiGate devient un viewer/éditeur Excalidraw à part entière, supprimant le besoin d'ouvrir Obsidian Desktop ou l'app web Excalidraw séparément.
|
||||
|
||||
- **Fonctionnement général :**
|
||||
- L'utilisateur clique sur un fichier `.excalidraw` dans l'arborescence → ObsiGate détecte l'extension et le type de contenu (`type: "excalidraw"` dans le JSON)
|
||||
- Au lieu du viewer markdown ou JSON brut, une **iframe sandbox** charge l'éditeur Excalidraw avec les données du fichier
|
||||
- L'utilisateur peut dessiner, ajouter des formes, du texte, des flèches, des images — l'expérience Excalidraw complète
|
||||
- Les modifications sont sauvegardées automatiquement (Ctrl+S ou auto-save) via `postMessage` → le parent écrit dans le fichier via l'API ObsiGate
|
||||
- L'éditeur respecte le thème sombre/clair d'ObsiGate
|
||||
|
||||
- **Architecture :**
|
||||
```
|
||||
ObsiGate SPA (content-area)
|
||||
└── <iframe sandbox="allow-scripts allow-same-origin">
|
||||
└── /frontend/excalidraw-editor.html
|
||||
├── import * as ExcalidrawLib from "esm.sh/@excalidraw/excalidraw"
|
||||
├── React + ReactDOM (fournis par Excalidraw)
|
||||
├── Écoute postMessage("init", {data, theme})
|
||||
└── Poste postMessage("save", {data}) au parent
|
||||
```
|
||||
|
||||
- **Choix technique — Pourquoi une iframe plutôt qu'une intégration directe ?**
|
||||
- **Isolation** : Excalidraw est un composant React avec son propre DOM virtuel, ses propres polices, et des styles CSS globaux. L'iframe empêche les conflits CSS avec ObsiGate (variables CSS, polices, z-index des modales).
|
||||
- **Sandbox** : L'iframe isole le code d'Excalidraw — une erreur dans l'éditeur ne crashe pas l'application principale.
|
||||
- **Chargement lazy** : Excalidraw pèse ~2 Mo minifié + React ~40 Ko. L'iframe n'est chargée QUE quand l'utilisateur ouvre un fichier `.excalidraw`. Pas d'impact sur le temps de chargement initial.
|
||||
- **Communication standard** : `postMessage` est une API web native, simple et sécurisée. L'iframe n'a pas accès au DOM parent, seulement au canal de messages.
|
||||
- **CSS indépendant** : Le thème sombre/clair est passé comme paramètre → l'iframe applique son propre thème sans toucher aux variables CSS d'ObsiGate.
|
||||
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Fichier `frontend/excalidraw-editor.html` — Éditeur autonome (1.5 jour)
|
||||
- [ ] **A1. Structure HTML** : Page minimale avec un `<div id="excalidraw-container">` en plein écran. Pas de header ObsiGate — tout l'espace est pour le canvas.
|
||||
- [ ] **A2. Import Excalidraw** :
|
||||
```html
|
||||
<script type="module">
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/excalidraw@0.18.0";
|
||||
window.ExcalidrawLib = ExcalidrawLib;
|
||||
</script>
|
||||
```
|
||||
Version épinglée (`@0.18.0`) pour la stabilité. Mise à jour manuelle testée.
|
||||
- [ ] **A3. Configuration du chemin d'assets** : Définir `window.EXCALIDRAW_ASSET_PATH` pour pointer vers le CDN des fonts/polices d'Excalidraw (nécessaire pour le rendu des polices handwriting).
|
||||
- [ ] **A4. Initialisation React** : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
|
||||
```html
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/react@18";
|
||||
import ReactDOM from "https://esm.sh/react-dom@18";
|
||||
window.React = React;
|
||||
window.ReactDOM = ReactDOM;
|
||||
</script>
|
||||
```
|
||||
- [ ] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
|
||||
- [ ] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) :
|
||||
- Bouton « 💾 Sauvegarder » → envoie les données au parent
|
||||
- Badge « Modifié » (disparaît après sauvegarde)
|
||||
- Indicateur de thème 🌙/☀️
|
||||
- Optionnel : bouton « Export PNG » et « Export SVG » (natif Excalidraw)
|
||||
- [ ] **A7. Communication postMessage** :
|
||||
- Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light).
|
||||
- Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde.
|
||||
- Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête.
|
||||
- Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification.
|
||||
- [ ] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
|
||||
|
||||
##### B. Backend — Détection et API (0.5 jour)
|
||||
- [ ] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
|
||||
- [ ] **B2. Icône** : Ajouter `.excalidraw` dans `EXT_ICONS` (`frontend/js/utils.js`) → icône `pen-tool` ou `edit-3` (Lucide).
|
||||
- [ ] **B3. Détection dans `api_file_view()`** : Dans `backend/main.py`, pour les fichiers `.excalidraw` :
|
||||
- Lire le JSON
|
||||
- Vérifier `data.get("type") === "excalidraw"`
|
||||
- Retourner `is_excalidraw: true` + les données parsées (`elements`, `appState`, `files`)
|
||||
- Si le JSON est invalide ou n'est pas un fichier Excalidraw valide → fallback sur le viewer JSON standard
|
||||
- [ ] **B4. Endpoint de sauvegarde** : Le endpoint existant `PUT /api/file/{vault}` fonctionne déjà pour écrire du contenu. L'iframe envoie le JSON modifié via postMessage → le parent appelle l'API existante. Aucun nouvel endpoint nécessaire.
|
||||
- [ ] **B5. Indexation du contenu texte** : Extraire le texte des éléments Excalidraw (`element.text` pour les éléments de type `text`) pour l'indexation TF-IDF. Permet de rechercher du texte présent dans les diagrammes.
|
||||
- [ ] **B6. Contenu initial pour nouveaux fichiers** : Définir le squelette JSON minimum pour un fichier `.excalidraw` vide :
|
||||
```json
|
||||
{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}
|
||||
```
|
||||
Ce squelette est retourné par le backend quand on crée un fichier `.excalidraw` (utilisé par `POST /api/file/{vault}`).
|
||||
|
||||
##### C. Frontend — Intégration dans le viewer (1 jour)
|
||||
- [ ] **C1. Module `frontend/js/excalidraw-viewer.js`** (nouveau) : Fonction `renderExcalidraw(container, data, vault, path)` :
|
||||
- Crée une `<iframe>` avec `src="/frontend/excalidraw-editor.html"` et `sandbox="allow-scripts allow-same-origin"`
|
||||
- Stocke une référence à l'iframe pour la communication
|
||||
- Attend le message `ready` de l'iframe
|
||||
- Envoie `postMessage({type: "init", data: {elements, appState, files}, theme})` à l'iframe
|
||||
- Écoute les messages `save` → appelle `saveFile(vault, path, JSON.stringify(data))` via l'API existante
|
||||
- Écoute les messages `modified` → met à jour l'indicateur dans la barre d'onglets
|
||||
- Gère le thème : écoute `themeChanged` → envoie `postMessage({type: "theme", theme})` à l'iframe
|
||||
- [ ] **C2. Dispatch dans `viewer.js`** : Dans `renderFileContent()` ou `renderFile()` :
|
||||
- Après la détection `data.is_json`, ajouter une branche : si `data.is_excalidraw === true` → appeler `renderExcalidraw(container, data, vaultName, filePath)`
|
||||
- Ne PAS passer par le viewer markdown standard
|
||||
- [ ] **C3. Barre d'outils contextuelle** : Dans la toolbar du viewer (celle avec Copier/Source/Éditer/PDF/pop-out) :
|
||||
- Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet)
|
||||
- Garder « Télécharger » (.excalidraw) et « pop-out »
|
||||
- Badge « Excalidraw » avec icône `pen-tool`
|
||||
- [ ] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API.
|
||||
- [ ] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
|
||||
- [ ] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
|
||||
- [ ] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
|
||||
- Ajouter `<option value=".excalidraw">Excalidraw (.excalidraw)</option>` dans le `<select id="file-ext-select">` (après `.json`)
|
||||
- Quand l'extension `.excalidraw` est sélectionnée, le backend crée le fichier avec le squelette JSON minimum (B6)
|
||||
- Après création → `openFile(vault, path)` → le viewer détecte `is_excalidraw: true` → l'iframe s'ouvre avec le canvas vierge
|
||||
- Fonctionne aussi via la palette de commandes `Ctrl+Alt+Space` → « Nouveau fichier » (action `create-file` existante)
|
||||
- [ ] **C8. Création via le menu contextuel de l'arborescence** : Dans `frontend/js/context-menu.js`, ajouter une option « 🎨 Nouveau diagramme Excalidraw » dans le menu contextuel des répertoires → ouvre directement la modale avec `.excalidraw` pré-sélectionné.
|
||||
|
||||
##### D. CSS & Design (0.5 jour)
|
||||
- [ ] **D1. Styles de l'iframe dans ObsiGate** : L'iframe occupe 100% du content-area (`width: 100%; height: 100%; border: none;`). Aucun padding ni marge.
|
||||
- [ ] **D2. Thème dark/light** : L'iframe reçoit le thème courant → Excalidraw applique son thème interne (`theme="dark"` ou `theme="light"`). Les couleurs sont cohérentes avec ObsiGate grâce à la palette d'Excalidraw.
|
||||
- [ ] **D3. Écran de chargement** : Pendant le chargement de l'iframe (React + Excalidraw ~2 Mo), afficher un spinner « Chargement de l'éditeur Excalidraw... » dans le content-area. L'iframe envoie `ready` → le spinner disparaît.
|
||||
- [ ] **D4. Responsive** : L'iframe s'adapte à la largeur du panneau. En mode mobile (<768px), l'éditeur Excalidraw est utilisable (UI tactile native).
|
||||
|
||||
##### E. Gestion des conflits et edge cases (0.5 jour)
|
||||
- [ ] **E1. Fichier modifié à l'extérieur** : Si le fichier est modifié par Syncthing/watcher pendant l'édition → détecter via le watcher → afficher un bandeau « Ce fichier a été modifié à l'extérieur. Recharger ? » avec boutons [Recharger] [Ignorer].
|
||||
- [ ] **E2. Plusieurs onglets** : Deux onglets sur le même fichier `.excalidraw` → le second détecte que le fichier est déjà ouvert → focus l'onglet existant (comportement existant du `TabManager` #E4).
|
||||
- [ ] **E3. Fichier vide ou nouveau** : Couvert par C7/C8 — la création d'un `.excalidraw` produit un canvas vierge avec le squelette JSON minimum (B6). L'iframe gère nativement le cas `elements: []`.
|
||||
- [ ] **E4. Fichier corrompu** : Si le JSON ne contient pas `type: "excalidraw"` ou est invalide → fallback sur le viewer JSON standard avec un message « Ce fichier .excalidraw semble corrompu ».
|
||||
- [ ] **E5. Pop-out** : Le bouton pop-out fonctionne — il ouvre l'éditeur dans une popup séparée avec sa propre iframe. Utile pour éditer sur un deuxième écran.
|
||||
- [ ] **E6. Annulation (Ctrl+Z)** : Natif dans Excalidraw — l'historique d'annulation est géré par l'état interne de l'iframe. Pas besoin d'interaction avec le parent.
|
||||
|
||||
##### F. Tests (0.5 jour)
|
||||
- [ ] **F1. Tests backend** :
|
||||
- `test_excalidraw_detection.py` : fichier `.excalidraw` valide → `is_excalidraw: true`, JSON invalide → fallback JSON, fichier sans `type: excalidraw` → fallback
|
||||
- `test_excalidraw_search.py` : texte extrait des éléments → recherchable via TF-IDF
|
||||
- [ ] **F2. Tests frontend** :
|
||||
- Chargement de l'iframe avec des données de test
|
||||
- Communication postMessage (init → ready → save)
|
||||
- Changement de thème propagé à l'iframe
|
||||
- [ ] **F3. Tests E2E (Playwright, #58)** :
|
||||
- Ouvrir un fichier `.excalidraw` → l'iframe se charge → le canvas Excalidraw est visible
|
||||
- Dessiner un rectangle → sauvegarder → recharger → le rectangle est toujours là
|
||||
- Basculer thème sombre → l'iframe passe en dark mode
|
||||
|
||||
##### G. Points d'attention / Risques
|
||||
- **Taille du bundle** : React + ReactDOM + Excalidraw ≈ 2.5 Mo minifié. Chargé depuis `esm.sh` (CDN global, cache HTTP). L'impact n'est perceptible qu'à la première ouverture d'un `.excalidraw`. Solution : précharger l'iframe en arrière-plan (`<link rel="prefetch">`) après le chargement de l'app.
|
||||
- **Performance React dans iframe** : React dans une iframe fonctionne parfaitement — c'est un contexte JavaScript indépendant. Testé sur Chrome, Firefox, Safari, Edge.
|
||||
- **CORS et esm.sh** : Les modules ESM depuis `esm.sh` sont servis avec les headers CORS appropriés. L'iframe est same-origin (`/frontend/excalidraw-editor.html`) donc pas de problème.
|
||||
- **Mises à jour d'Excalidraw** : La version est épinglée (`@0.18.0`). Pour mettre à jour, changer le numéro dans le HTML + tester. Le format de données `.excalidraw` est stable (v2 depuis 2021).
|
||||
- **Sécurité postMessage** : Vérifier `event.origin` dans les deux sens. L'iframe n'accepte que les messages de `window.parent`. Le parent n'accepte que les messages de l'iframe connue. Pas de `"*"` en production.
|
||||
- **Tauri Desktop (#77)** : L'iframe se charge depuis le filesystem local (`tauri://localhost/frontend/excalidraw-editor.html`). Les imports ESM depuis `esm.sh` fonctionnent si le réseau est disponible. Pour le mode offline, bundler Excalidraw dans l'app desktop (à traiter dans #77, pas ici).
|
||||
- **Pas d'édition collaborative** : Cette implémentation est mono-utilisateur. La collaboration temps réel (#62) pourra être étendue aux fichiers `.excalidraw` ultérieurement via le même mécanisme Yjs.
|
||||
|
||||
##### H. Documentation utilisateur
|
||||
- [ ] **H1.** Mettre à jour README : ajouter `.excalidraw` dans les formats supportés
|
||||
- [ ] **H2.** Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw »
|
||||
- [ ] **H3.** Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 67. Notifications web — Push API
|
||||
- **Effort :** 2 jours | **Impact :** 🟢
|
||||
- **Description :** Recevoir des notifications sur le bureau ou le téléphone quand un fichier est modifié, ajouté ou supprimé dans un de vos vaults, même si ObsiGate n'est pas ouvert dans le navigateur.
|
||||
- **Fonctionnement** : Le navigateur s'abonne auprès du serveur via la Push API (standard W3C). Le serveur stocke l'abonnement (endpoint + clés de chiffrement). Quand un fichier change (détecté par le watcher existant), le serveur envoie une notification chiffrée au push service du navigateur (Firebase pour Chrome, APNs pour Safari, etc.), qui la relaye au navigateur même s'il est fermé. Le service worker ObsiGate affiche alors la notification système.
|
||||
- **Contenu** : titre du fichier modifié, nom du vault, type d'action (créé/modifié/supprimé). Un clic sur la notification ouvre directement le fichier dans ObsiGate.
|
||||
- **Configuration** : activation/désactivation par vault. L'utilisateur choisit pour quels vaults il reçoit des notifications.
|
||||
- **VAPID** : protocole d'authentification volontaire qui permet au serveur de s'identifier auprès du push service sans avoir à s'enregistrer comme application. Une paire de clés publique/privée est générée — la clé publique est partagée avec le navigateur, la clé privée reste sur le serveur.
|
||||
- **Pourquoi c'est important :** Collaborer sans avoir à constamment rafraîchir l'interface pour voir si quelqu'un a modifié quelque chose. Particulièrement utile en équipe ou pour les vaults partagés via Syncthing — on sait immédiatement quand une note est mise à jour.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Souscription Push : endpoint `POST /api/push/subscribe` (stockage `subscription` + `vault`)
|
||||
- [ ] Envoi : webhook interne `on_file_change` → dispatch notification via Web Push
|
||||
- [ ] Configuration VAPID : clés publique/privée dans `config.json`
|
||||
- [ ] UI : permission navigateur + toggle activer/désactiver par vault
|
||||
- [ ] Payload : titre du fichier, vault, action (created/modified/deleted)
|
||||
- [ ] Clic sur notification → ouvre le fichier dans ObsiGate
|
||||
|
||||
### 68. Health check enrichi
|
||||
- **Effort :** 1 jour | **Impact :** 🟢
|
||||
- **Description :** Un endpoint `/api/health` qui ne se contente pas de dire « je suis vivant », mais donne un diagnostic complet de l'état du serveur. Essentiel pour le monitoring et le debugging.
|
||||
- **Métriques exposées :**
|
||||
- **Index** : nombre de fichiers indexés, nombre de tokens, date de la dernière indexation complète. Permet de détecter si l'indexeur est bloqué ou ne tourne plus.
|
||||
- **Mémoire** : consommation RAM du processus (RSS), heap Python utilisé. Permet de détecter les fuites mémoire avant qu'elles ne crashent le serveur.
|
||||
- **Uptime** : depuis quand le serveur tourne. Simple, mais indispensable pour corréler un problème avec un redémarrage.
|
||||
- **Connexions** : nombre de connexions SSE actives (recherche en cours, streaming AI, etc.). Permet de savoir combien d'utilisateurs sont connectés.
|
||||
- **Backups** : nombre total, âge du backup le plus ancien, espace disque consommé. Permet de détecter si les backups s'accumulent anormalement.
|
||||
- **Disque** : espace libre sur la partition `/data`. Évite le crash silencieux quand le disque est plein.
|
||||
- **Format** : JSON structuré, facile à intégrer dans des outils de monitoring (Prometheus, Grafana, Uptime Kuma, Healthchecks.io).
|
||||
- **Sécurité** : l'endpoint public `/api/health` retourne `ok` ou `degraded`, l'endpoint détaillé est protégé par authentification admin.
|
||||
- **Pourquoi c'est important :** Actuellement, la seule façon de savoir si ObsiGate a un problème est de constater que ça ne marche plus. Avec un health check enrichi, on peut configurer des alertes automatiques (via Uptime Kuma ou un cron) qui préviennent AVANT que l'utilisateur ne remarque le problème.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Métriques index : nombre de fichiers, nombre de tokens, génération courante
|
||||
- [ ] Métriques mémoire : RSS, heap used (via `psutil` ou `/proc/self/status`)
|
||||
- [ ] Métriques uptime : `time.time() - server_start_time`
|
||||
- [ ] Métriques backups : nombre total, âge du plus vieux, espace disque
|
||||
- [ ] Format réponse JSON structuré : `{ status, uptime, index, memory, backups, connections }`
|
||||
- [ ] Endpoint séparé `GET /api/health/detailed` (protégé admin)
|
||||
|
||||
### 69. Éditeur mobile natif — Interface tactile optimisée
|
||||
- **Effort :** 2-3 jours | **Impact :** 🟢
|
||||
- **Description :** Refonte de l'expérience mobile pour l'édition : barre d'outils contextuelle, presse-papiers optimisé, gestes tactiles (swipe pour actions rapides), mode lecture plein écran.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Barre d'outils mobile flottante : **bold**, *italic*, `code`, liste, lien (inspirée de l'éditeur Obsidian mobile)
|
||||
- [ ] Raccourcis swipe : gauche → backlinks, droite → table des matières
|
||||
- [ ] Mode lecture : cache sidebar + header, plein écran, swipe horizontal pour page suivante
|
||||
- [ ] Presse-papiers : bouton « Coller » persistant (iOS contourne restriction clipboard)
|
||||
- [ ] Adaptation CodeMirror : hauteur ajustable, police agrandissable (pinch zoom)
|
||||
|
||||
### 70. Recherche sémantique — Embeddings vectoriels
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟢
|
||||
- **Description :** La recherche actuelle (TF-IDF) ne trouve que les documents contenant EXACTEMENT les mots tapés. La recherche sémantique comprend le SENS de la requête et trouve des documents pertinents même s'ils utilisent des mots différents.
|
||||
- **Exemple concret :** Vous cherchez « comment sauvegarder mes données ». La recherche TF-IDF ne trouvera que les documents contenant « sauvegarder » ET « données ». La recherche sémantique trouvera aussi un document titré « Stratégie de backup automatique » ou « Protection contre la perte de fichiers » parce qu'elle comprend que ces phrases parlent de la même chose.
|
||||
- **Fonctionnement technique :**
|
||||
- Chaque document (ou chunk de ~512 tokens) est converti en un **vecteur** (une liste de 384 nombres) par un modèle de langage léger comme `all-MiniLM-L6-v2` (80 Mo, s'exécute en ~2ms par document sur CPU). Ce vecteur capture le sens — deux phrases qui veulent dire la même chose auront des vecteurs très proches.
|
||||
- Au moment de la recherche, la requête utilisateur est elle aussi convertie en vecteur.
|
||||
- On calcule la **similarité cosinus** entre le vecteur de la requête et les vecteurs de tous les documents. Les documents avec la similarité la plus élevée sont retournés.
|
||||
- **Recherche hybride** : on combine le score TF-IDF (pertinence par mots-clés exacts) et le score sémantique (pertinence par sens) via RRF (Reciprocal Rank Fusion) — les documents bien classés par les deux méthodes remontent en premier.
|
||||
- **Stockage** : les vecteurs sont stockés avec FAISS (Facebook AI Similarity Search), une bibliothèque optimisée qui permet de chercher parmi des millions de vecteurs en quelques millisecondes.
|
||||
- **Indexation** : les embeddings sont générés une fois à l'indexation du fichier (pas à chaque recherche). Un fichier modifié voit son embedding regénéré automatiquement par le watcher.
|
||||
- **Pourquoi c'est important :** La recherche par mots-clés échoue dans ~30% des cas où l'utilisateur ne se souvient pas des mots exacts utilisés dans ses notes. La recherche sémantique résout ce problème. C'est particulièrement utile pour les gros vaults (500+ notes) où on ne peut pas tout parcourir manuellement.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Génération d'embeddings : modèle `all-MiniLM-L6-v2` via `sentence-transformers` (Python) ou appel API externe
|
||||
- [ ] Stockage : index vectoriel avec `numpy` + `faiss` (ou `usearch` pour performance)
|
||||
- [ ] Indexation : embedding par chunk de 512 tokens avec recouvrement
|
||||
- [ ] Recherche hybride : combinaison TF-IDF + similarité cosinus (RRF — Reciprocal Rank Fusion)
|
||||
- [ ] UI : toggle « Recherche sémantique » dans la barre de recherche
|
||||
- [ ] UI : score de similarité dans les résultats
|
||||
|
||||
### 71. Tableau de bord administrateur
|
||||
- **Effort :** 2 jours | **Impact :** 🟢
|
||||
- **Description :** Une page web dédiée accessible uniquement aux administrateurs qui centralise tout le monitoring et la gestion du serveur ObsiGate en un seul endroit. Un cockpit de pilotage pour le sysadmin.
|
||||
- **Widgets temps réel** (rafraîchis via SSE) :
|
||||
- **CPU / RAM / Disque** : jauges visuelles avec seuils d'alerte (vert < 70%, orange < 90%, rouge > 90%). Permet de voir en un coup d'œil si le serveur est en surcharge.
|
||||
- **Requêtes par minute** : graphique sparkline des dernières 24h. Permet de détecter les pics d'activité anormaux (attaques, bots, bug qui spam l'API).
|
||||
- **Utilisateurs actifs** : nombre de sessions connectées en ce moment, compteur de recherches en cours.
|
||||
- **Gestion des utilisateurs** :
|
||||
- Tableau triable/filtrable de tous les comptes (nom, rôle, date de création, dernière connexion, nombre de vaults).
|
||||
- Création, édition, suppression d'utilisateurs. Attribution de rôles (admin/user/readonly).
|
||||
- Réinitialisation de mot de passe administrateur.
|
||||
- **Logs d'audit visuels** :
|
||||
- Tableau chronologique des 500 dernières actions : qui a fait quoi, quand, depuis quelle IP.
|
||||
- Filtres par utilisateur, type d'action (login, création fichier, suppression, modification settings), plage de dates.
|
||||
- Export CSV pour analyse externe.
|
||||
- **Statistiques backups** : graphique d'évolution du nombre et de la taille des backups par vault. Détection automatique des vaults sans backup récent.
|
||||
- **Pourquoi c'est important :** Actuellement, administrer ObsiGate nécessite de se connecter en SSH au serveur et de lire des fichiers JSON. Le dashboard rend toutes ces opérations accessibles depuis l'interface web, avec des visuels qui permettent de diagnostiquer un problème en 10 secondes au lieu de 10 minutes de CLI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Widgets temps réel : CPU, mémoire, espace disque, requêtes/min (rafraîchissement SSE)
|
||||
- [ ] Gestion utilisateurs : tableau triable, création/édition/suppression, filtre par rôle
|
||||
- [ ] Logs d'audit : visualisation des 500 dernières entrées, filtre par utilisateur/action/date
|
||||
- [ ] Backup stats : graphique d'évolution (taille totale, nombre par vault, âge moyen)
|
||||
- [ ] Protection : accès restreint au rôle `admin` uniquement
|
||||
|
||||
### 72. API publique documentée — OpenAPI 3.1
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢
|
||||
- **Description :** Une page de documentation interactive et auto-générée de toutes les API REST d'ObsiGate, accessible via un bouton dans l'interface. L'équivalent d'un manuel technique mais qui se teste en direct.
|
||||
- **OpenAPI 3.1** : c'est le format standard mondial pour décrire une API REST. Un seul fichier JSON/YAML contient la description de tous les endpoints, leurs paramètres, les formats de réponse, les codes d'erreur, et les modèles de données. Ce standard est supporté par des centaines d'outils.
|
||||
- **Swagger UI** : une interface web qui lit le fichier OpenAPI et génère automatiquement une documentation interactive. L'utilisateur voit chaque endpoint, peut remplir les paramètres dans un formulaire, cliquer « Execute » et voir la réponse réelle de l'API en direct. Parfait pour les développeurs qui veulent intégrer ObsiGate à leurs scripts ou comprendre comment fonctionne l'API.
|
||||
- **Redoc** : une alternative à Swagger UI, plus propre et orientée lecture, idéale pour la documentation publique.
|
||||
- **Contenu documenté :**
|
||||
- Les 40+ endpoints existants, regroupés par catégorie (Fichiers, Vaults, Recherche, Auth, AI, Backups).
|
||||
- Chaque endpoint avec description, paramètres obligatoires/optionnels, exemples de requête et réponse.
|
||||
- Les modèles de données Pydantic exposés comme schémas JSON (ex: structure d'un `FileInfo`, d'un `SearchResult`).
|
||||
- Les codes d'erreur possibles avec leur signification.
|
||||
- **Auto-génération** : FastAPI génère déjà partiellement le schéma OpenAPI. Le travail consiste à compléter les docstrings manquantes, ajouter `response_model` sur les endpoints qui n'en ont pas, et enrichir avec des exemples.
|
||||
- **Pourquoi c'est important :** Une API sans documentation est comme un logiciel sans interface — techniquement fonctionnel mais inutilisable. Avec une doc OpenAPI, ObsiGate devient intégrable dans n'importe quel écosystème. Un développeur peut en 5 minutes comprendre comment uploader un fichier, chercher dans un vault, ou récupérer le contenu d'une note — et écrire un script qui automatise ses workflows.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Audit des endpoints existants → compléter les docstrings manquants
|
||||
- [ ] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle)
|
||||
- [ ] Exemples dans les schémas : `examples=[...]` pour les endpoints clés
|
||||
- [ ] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups)
|
||||
- [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend
|
||||
- [ ] Page de documentation intégrée : lien dans le menu header (« API »)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
- **Effort :** 6-8 jours | **Impact :** 🟢
|
||||
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
|
||||
- [ ] Transport : WebSocket ou polling HTTPS avec compression
|
||||
- [ ] Merge strategy : LWW (Last Writer Wins) avec historique des deux versions
|
||||
- [ ] Détection de changements : hash SHA-256 par fichier, journal des modifications
|
||||
- [ ] UI : page « Synchronisation » avec statut par appareil, historique des syncs
|
||||
- [ ] Conflits : UI de résolution manuelle (diff côte à côte entre version locale et distante)
|
||||
- [ ] Chiffrement : optionnel, chiffrement AES-256-GCM avant transmission
|
||||
- [ ] Pairing : échange de clé publique + code QR pour appairage des appareils
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours (P2) — Application Desktop
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
|
||||
- **Description :** Packager ObsiGate en application desktop native autonome. L'utilisateur télécharge un `.exe` (Windows) ou `.AppImage` (Linux), l'installe, et lance ObsiGate comme n'importe quelle app — sans Docker, sans terminal, sans navigateur. Le backend Python est embarqué, le frontend s'affiche dans une webview native. L'expérience est identique à l'application web, avec des capacités supplémentaires (accès fichiers natif, notifications OS, tray icon).
|
||||
|
||||
- **Architecture :**
|
||||
```
|
||||
ObsiGate.exe (Tauri shell ~5 Mo)
|
||||
├── python-embed/ ← Python 3.11 embarqué (~30 Mo)
|
||||
│ ├── backend/ ← Code FastAPI existant
|
||||
│ └── site-packages/ ← Dépendances gelées
|
||||
├── frontend/ ← HTML/CSS/JS (identique au web)
|
||||
└── obsigate-desktop ← Binaire Rust (lance Python + ouvre webview)
|
||||
```
|
||||
|
||||
- **Pourquoi Tauri plutôt qu'Electron ?**
|
||||
- Binaire de ~35-40 Mo contre ~180 Mo pour Electron (pas de Chromium embarqué)
|
||||
- RAM idle ~50 Mo contre ~200 Mo — la webview utilise le moteur du navigateur système
|
||||
- Rust gère le cycle de vie du backend Python (spawn, health check, kill propre)
|
||||
- Signature de code native Windows/macOS pour éviter les faux positifs antivirus
|
||||
- Auto-update natif via le mécanisme de Tauri (vérifie un endpoint JSON)
|
||||
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Initialisation du projet Tauri (1 jour)
|
||||
- [ ] Installer Rust + toolchain Tauri : `cargo install tauri-cli`
|
||||
- [ ] Initialiser `tauri init` dans `/desktop/` avec config Windows/Linux
|
||||
- [ ] Configurer `tauri.conf.json` : fenêtre 1200×800, sans cadre, titre "ObsiGate"
|
||||
- [ ] Configurer le build : cibles `.msi`/`.nsis` (Windows), `.deb`/`.AppImage` (Linux)
|
||||
- [ ] Ajouter les icônes desktop (`.ico` Windows, `.png` Linux) dans `desktop/icons/`
|
||||
|
||||
##### B. Intégration du backend Python (2-3 jours)
|
||||
- [ ] Bundle Python : créer un dossier `python-embed/` avec `python3.11-embed` + `site-packages/` (requirements.txt gelés)
|
||||
- [ ] Script `sidecar.py` : lance uvicorn sur `localhost:17890`, log dans `%APPDATA%/ObsiGate/logs/`
|
||||
- [ ] Code Rust `main.rs` : spawn le sidecar comme processus fils, health check (boucle `GET /api/health` avec timeout 10s), kill propre au `SIGTERM`
|
||||
- [ ] Menu tray : icône dans la barre des tâches avec options « Ouvrir ObsiGate », « Quitter »
|
||||
- [ ] Gestion du port : détecter si 17890 est déjà utilisé → incrémenter (17891, 17892...)
|
||||
|
||||
##### C. Fonctionnalités desktop natives (2-3 jours) — ✅ COMPLÉTÉ
|
||||
- [x] **Sélecteur de dossier** : `pick_vault_folder` via `tauri_plugin_dialog` → ajoute le vault dans config.json
|
||||
- [x] **Thème système** : `get_system_theme` lit le thème OS → appliqué automatiquement
|
||||
- [x] **Notifications natives** : `tauri-plugin-notification` intégré — remplace le service worker Push API
|
||||
- [x] **Associations de fichiers** : `.md` → « Ouvrir avec ObsiGate » dans `tauri.conf.json`
|
||||
- [x] **Menu natif** : Fichier (Nouvelle fenêtre, Fermer, Quitter) / Édition (Annuler, Rétablir, Couper, Copier, Coller, Tout sélectionner) / Aide (À propos)
|
||||
- [x] **Raccourcis clavier** : `Ctrl+N`, `Ctrl+W`, `Ctrl+Q`, `Ctrl+Z`, `Ctrl+Shift+Z`, `Ctrl+X/C/V/A`
|
||||
- [x] **Tray icon** : menu contextuel (Ouvrir, À propos, Quitter) + toggle fenêtre au clic gauche
|
||||
- [x] **Single instance** : `tauri-plugin-single-instance` — deuxième lancement focus la fenêtre existante
|
||||
- [x] **Auto-update** : `tauri-plugin-updater` configuré → vérifie les releases Gitea
|
||||
- [x] **Pas de terminal visible** : `#![windows_subsystem = "windows"]` + `CREATE_NO_WINDOW` sur le processus Python
|
||||
- [x] **Persistance fenêtre** : position/taille sauvegardée dans `config.json`
|
||||
|
||||
##### D. Build et distribution (2 jours)
|
||||
- [x] **CI/CD automatisé** : workflow Gitea Actions `.gitea/workflows/desktop-build.yml` — build Windows + Linux à chaque push sur `main` (si `desktop/` modifié), upload des artefacts `.msi`/`.AppImage`/`.deb` en release
|
||||
- [ ] **Build Windows** : `tauri build --target x86_64-pc-windows-msvc` → `.msi` + `.exe` installer
|
||||
- [ ] **Build Linux** : `tauri build --target x86_64-unknown-linux-gnu` → `.deb`, `.rpm`, `.AppImage`
|
||||
- [x] **Auto-update** : `tauri-plugin-updater` → vérifie `https://git.dracodev.net/api/v1/repos/Projets/ObsiGate/releases/latest`
|
||||
- [ ] **Signature de code** : configurer le certificat (optionnel mais recommandé pour Windows)
|
||||
- [ ] **Page de release** : intégrer le build desktop dans les releases Gitea + README d'installation
|
||||
|
||||
##### E. Expérience utilisateur (1 jour)
|
||||
- [x] Écran de chargement pendant le démarrage du backend (« ObsiGate démarre... » avec spinner) — splash inline `#boot-splash` dans `index.html`, retiré quand `app.js` signale le boot ; statut mis à jour depuis Rust
|
||||
- [ ] Gestion des erreurs : backend crash → message explicite + bouton « Redémarrer »
|
||||
- [ ] Sauvegarde des préférences desktop (taille fenêtre, position, dernier vault)
|
||||
- [ ] Première expérience : wizard « Choisissez votre vault » au premier lancement
|
||||
- [ ] Icône dans le menu Démarrer / dock Linux avec jumplist (vaults récents)
|
||||
|
||||
##### F. Tests (1 jour)
|
||||
- [ ] Test : installation → premier lancement → wizard vault → ouverture fichier
|
||||
- [ ] Test : tray icon → réduire dans la barre → restaurer
|
||||
- [ ] Test : notifications natives → fichier modifié → popup OS
|
||||
- [ ] Test : association `.md` → double-clic → ouvre dans ObsiGate
|
||||
- [ ] Test : auto-update → nouvelle version dispo → téléchargement → installation
|
||||
- [ ] Test : cleanup → désinstallation propre (pas de fichiers résiduels)
|
||||
|
||||
- **Prérequis techniques :**
|
||||
- Rust ≥ 1.75 (stable) — installé via `rustup`
|
||||
- Tauri CLI ≥ 2.0 — `cargo install tauri-cli`
|
||||
- Python 3.11 embed — téléchargé depuis python.org
|
||||
- NSIS (Windows) — pour le générateur d'installateur `.exe`
|
||||
- AppImageKit (Linux) — pour le packaging portable
|
||||
|
||||
---
|
||||
|
||||
## 📊 Résumé des efforts
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #57 | ~65 jours |
|
||||
| 🔵 P1 | ✅ #58 (Playwright E2E) | Terminé |
|
||||
| 🔵 P2 | 🔨 #77 (Tauri Desktop) | 8-12 jours |
|
||||
| ⚪ P3 | #59, #61-66, #74, #75, #76, #78 (11 items) | 35-46 jours |
|
||||
| ⚪ P4 | #67 → #73 (7 items) | 18-23 jours |
|
||||
| **Total restant** | **20 items** | **63-84 jours** |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
|
||||
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
|
||||
- Le mode hors-ligne (#59) et l'i18n (#63) sont les P3 ayant le meilleur rapport effort/valeur.
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# 🎯 Analyse : Architecture de Toolbar pour ObsiGate
|
||||
|
||||
> **Date** : 2026-06-05
|
||||
> **Auteur** : Hermes-Claw (Agent IA)
|
||||
> **Contexte** : Recherche et recommandation du meilleur design de barre d'outils pour l'éditeur ObsiGate
|
||||
> **Fonctionnalités cibles** : Édition Markdown, Actions IA, Insertion PDF, Insertion Images
|
||||
|
||||
---
|
||||
|
||||
## 📋 Sources d'inspiration
|
||||
|
||||
L'analyse repose sur l'étude des éditeurs leaders du marché :
|
||||
|
||||
| Éditeur | Pattern clé | Pertinence pour ObsiGate |
|
||||
|---------|-------------|--------------------------|
|
||||
| **Notion** | Slash menu + Bubble menu + Quick insert `+` | ⭐⭐⭐⭐⭐ Forte |
|
||||
| **Novel.sh** | Slash menu + AI autocomplete (`++`) | ⭐⭐⭐⭐⭐ Forte |
|
||||
| **Medium** | Bubble menu minimal sur sélection | ⭐⭐⭐⭐ Élevée |
|
||||
| **Dropbox Paper** | Quick insert `+` + Slash commands | ⭐⭐⭐⭐ Élevée |
|
||||
| **Cursor / Windsurf** | AI Side Panel + Inline AI | ⭐⭐⭐⭐ Élevée |
|
||||
| **Google Docs** | Top toolbar traditionnelle | ⭐⭐ Faible (trop chargée) |
|
||||
| **Obsidian** | Command palette + Sidebar | ⭐⭐⭐ Moyenne |
|
||||
| **Tiptap** | API de menus : Fixed / Bubble / Floating / Slash | ⭐⭐⭐⭐⭐ Fondation technique |
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Recommandation : Architecture Multi-Zone
|
||||
|
||||
Les éditeurs modernes n'utilisent **plus une toolbar unique** en haut. Ils distribuent les contrôles dans **5 zones contextuelles** qui apparaissent uniquement quand elles sont pertinentes.
|
||||
|
||||
### Principe fondamental
|
||||
|
||||
> **"Montre le bon contrôle, au bon endroit, au bon moment."**
|
||||
|
||||
Une toolbar fixe traditionnelle (style Google Docs) **ne fonctionne plus** pour un éditeur moderne avec IA intégrée. La complexité est trop grande pour tout afficher en permanence.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Zone 1 — Top Fixed Bar (toujours visible)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ☰ [Obsigate] Document Title... 🔮 AI 📎 ⚙️ 💾 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Contenu
|
||||
| Position | Élément | Description |
|
||||
|----------|---------|-------------|
|
||||
| **Gauche** | ☰ Menu hamburger | Navigation, settings, historique |
|
||||
| **Gauche** | Titre du document | Éditable inline, se sauvegarde automatiquement |
|
||||
| **Droite** | 🔮 **AI Assistant** | Bouton principal, couleur d'accent — le différenciateur produit |
|
||||
| **Droite** | 📎 Quick Insert | Dropdown : Image, PDF, Table, Embed |
|
||||
| **Droite** | ⚙️ Actions | Export, Settings, Share |
|
||||
| **Droite** | 💾 Save indicator | Auto-save status (saved / saving / error) |
|
||||
|
||||
### Design notes
|
||||
- Hauteur : ~48px
|
||||
- Fond : légèrement distinct de l'éditeur (ombre ou bordure)
|
||||
- Le bouton AI est le plus visible (couleur primaire, légère glow)
|
||||
- Barre fixe au scroll (sticky top)
|
||||
|
||||
### Pourquoi
|
||||
Barre minimale, toujours accessible. Les actions document-level (PDF, images) sont à portée mais ne polluent pas l'interface. Le bouton AI est le **hero** — le différenciateur d'ObsiGate face aux autres éditeurs.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Zone 2a — Slash Command Menu (tape `/` sur ligne vide)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 🔍 Filtrer... │
|
||||
│ ──────────────────────────────── │
|
||||
│ 📝 Text Just start │
|
||||
│ ✅ To-do List Track... │
|
||||
│ H1 Heading 1 Big... │
|
||||
│ H2 Heading 2 Medium... │
|
||||
│ H3 Heading 3 Small... │
|
||||
│ • Bullet List Simple... │
|
||||
│ 1. Numbered List Ordered.. │
|
||||
│ 💬 Quote Block... │
|
||||
│ 📋 Code Block Syntax... │
|
||||
│ ── Divider Horiz... │
|
||||
│ ──────────────────────────────── │
|
||||
│ 🖼️ Insert Image Upload... │
|
||||
│ 📄 Insert PDF Attach... │
|
||||
│ 📊 Insert Table Data... │
|
||||
│ 🔗 Insert Link URL... │
|
||||
│ ──────────────────────────────── │
|
||||
│ ✨ AI: Continue Write... │
|
||||
│ ✨ AI: Summarize Condense │
|
||||
│ ✨ AI: Translate To... │
|
||||
│ ✨ AI: Brainstorm Ideas... │
|
||||
│ ✨ AI: Fix grammar Correct │
|
||||
│ ✨ AI: Change tone Formal... │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Design notes
|
||||
- Menu flottant (popover) positionné sous le curseur
|
||||
- Hauteur max : ~400px, scroll si plus
|
||||
- Chaque item : **icône + titre + description courte**
|
||||
- Sections séparées par un divider subtil
|
||||
- Navigation : flèches ↑↓ + Entrée pour sélectionner
|
||||
- Barre de filtrage en haut (taper filtre les résultats)
|
||||
- Fond : blanc/carte avec ombre légère + border-radius 8px
|
||||
- Animation : fade-in + slide-up (150ms)
|
||||
|
||||
### Pourquoi c'est le game-changer
|
||||
Le slash menu **remplace 90% des besoins de toolbar**. Plus besoin d'une barre fixe pour les actions de formatage. L'utilisateur tape `/` et tout est là, accessible au clavier. C'est le pattern qui a fait le succès de Notion.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Zone 2b — Bubble Menu (apparaît sur sélection de texte)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 𝐁 𝐼 S̶ </> 🔗 ⋯ ✨ AI ▾ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Contenu
|
||||
| Icône | Action | Raccourci |
|
||||
|-------|--------|-----------|
|
||||
| **B** | Bold | Ctrl+B |
|
||||
| *I* | Italic | Ctrl+I |
|
||||
| ~~S~~ | Strikethrough | Ctrl+Shift+X |
|
||||
| `</>` | Code inline | Ctrl+E |
|
||||
| 🔗 | Link | Ctrl+K |
|
||||
| ⋯ | More (Quote, Highlight) | — |
|
||||
| ✨ AI ▾ | Dropdown AI actions | — |
|
||||
|
||||
### Dropdown AI (✨)
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| Improve writing | Améliore le style |
|
||||
| Fix spelling & grammar | Corrige les erreurs |
|
||||
| Translate to... | Traduit vers langue cible |
|
||||
| Continue writing | Continue le texte |
|
||||
| Summarize | Résume la sélection |
|
||||
| Change tone | Ajuste le ton (formel/informel) |
|
||||
| Explain | Explique le texte sélectionné |
|
||||
|
||||
### Design notes
|
||||
- Apparaît **au-dessus** de la sélection (évite de cacher le texte)
|
||||
- Position calculée dynamiquement (reste dans le viewport)
|
||||
- Animation : scale-in + fade (120ms)
|
||||
- Fond : blanc + ombre `0 4px 12px rgba(0,0,0,0.15)` + radius 8px
|
||||
- Disparaît quand la sélection est perdue
|
||||
- Délai d'apparition : 50ms après sélection (pas de flash intempestif)
|
||||
|
||||
### Pourquoi
|
||||
Pattern Medium/Notion. Le plus efficace car le curseur est déjà proche de la sélection. Évite les allers-retours avec une toolbar fixe distante.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Zone 3 — Floating Quick Insert (ligne vide)
|
||||
|
||||
```
|
||||
⊕ │___________________________________________
|
||||
```
|
||||
|
||||
### Comportement
|
||||
- Apparaît à gauche d'une ligne vide quand le curseur est dessus
|
||||
- Au clic sur ⊕, ouvre un mini-menu compact (4-5 options)
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 🖼️ Image │
|
||||
│ 📄 PDF │
|
||||
│ 📋 Code Block │
|
||||
│ 📊 Table │
|
||||
│ ✨ AI Actions ▸ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Design notes
|
||||
- Icône ⊕ semi-transparente, devient opaque au hover
|
||||
- Cercle de 24px, centré verticalement sur la ligne
|
||||
- Mini-menu : compact, sans description (icône + texte uniquement)
|
||||
- Animation : fade-in (100ms)
|
||||
|
||||
### Pourquoi
|
||||
Pattern Dropbox Paper. **Ultra-rapide** pour les insertions fréquentes — pas besoin de taper `/` ni d'aller dans la toolbar. Complémentaire au slash menu.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Zone 4 — AI Side Panel (toggle droite)
|
||||
|
||||
```
|
||||
┌──────────────────────────┬──────────────────┐
|
||||
│ │ ✨ AI Assistant │
|
||||
│ Zone d'édition │ ─────────────── │
|
||||
│ │ │
|
||||
│ Ceci est le contenu │ 💬 Chat │
|
||||
│ du document en cours │ ┌────────────┐ │
|
||||
│ d'édition... │ │ Pose ta │ │
|
||||
│ │ │ question.. │ │
|
||||
│ │ └────────────┘ │
|
||||
│ │ [Send] │
|
||||
│ │ │
|
||||
│ │ 📋 Suggestions │
|
||||
│ │ • Improve style │
|
||||
│ │ • Add section │
|
||||
│ │ • Translate │
|
||||
│ │ • Generate PDF │
|
||||
│ │ │
|
||||
└──────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
### Contenu
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| **Chat** | Conversation IA contextuelle (connaît le document) |
|
||||
| **Suggestions** | Actions IA contextuelles basées sur le contenu |
|
||||
| **History** | Historique des interactions IA récentes |
|
||||
| **One-click actions** | "Fix grammar", "Translate to EN", "Generate summary" |
|
||||
|
||||
### Design notes
|
||||
- Panel rétractable (toggle via bouton 🔮 de la Zone 1)
|
||||
- Largeur : 320-380px
|
||||
- Fond légèrement distinct
|
||||
- Slide-in depuis la droite (200ms)
|
||||
- L'éditeur se réduit (responsive, pas d'overlay)
|
||||
- Persiste entre les sessions (état sauvegardé)
|
||||
- Sur mobile : passe en bottom sheet
|
||||
|
||||
### Pourquoi
|
||||
Pour les interactions AI complexes (brainstorm, réécriture multi-paragraphes, recherche). Le bubble menu gère le micro ; le panel gère le macro. Pattern Cursor/Windsurf.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Zone 5 — Drag & Drop Zone (invisible, toujours actif)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
|
||||
│ │ │ │
|
||||
│ │ 🖼️ Déposez vos fichiers ici │ │
|
||||
│ │ Images, PDFs, documents │ │
|
||||
│ │ │ │
|
||||
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Comportement
|
||||
- Zone active sur tout l'éditeur
|
||||
- Au survol d'un fichier : bordure pointillée bleue + overlay semi-transparent
|
||||
- Accepte : images (png, jpg, webp, gif, svg), PDFs, .md, .txt
|
||||
- Feedback visuel pendant le drop : animation + barre de progression
|
||||
|
||||
### Design notes
|
||||
- Bordure pointillée : 2px dashed `#4A90D9`
|
||||
- Overlay : fond bleu à 5% d'opacité
|
||||
- Icône + texte centrés, large
|
||||
- Disparaît quand le drag quitte la zone
|
||||
- Upload progressif avec prévisualisation
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Layout complet intégré
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────┐
|
||||
│ ☰ [ObsiGate] Mon Document... 🔮 AI 📎 Insert ⚙️ 💾 │ ← Zone 1
|
||||
├───────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ⊕ │ ## Introduction │ ✨ AI │ ← Zone 4
|
||||
│ │ │─────── │
|
||||
│ │ Ceci est le contenu du document... │ │
|
||||
│ │ │ Chat │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ 𝐁 𝐼 S̶ </> 🔗 ✨ AI ▾│ ← Zone 2b (sel.) │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Du texte sélectionné ici... │ │
|
||||
│ │ │ │
|
||||
│ ⊕ │ │ │
|
||||
│ │ / │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ 📝 Text │ ← Zone 2a (slash) │ │
|
||||
│ │ │ H1 Heading 1 │ │ │
|
||||
│ │ │ ✨ AI: Improve writing │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
├───────────────────────────────────────────────────────────────────┤
|
||||
│ ⌨️ / pour menu | Ctrl+K pour commandes | 1200 mots │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Barre de statut (footer)
|
||||
- Toujours visible, fine (24-28px)
|
||||
- Affiche : raccourcis utiles, compteur de mots, statut connexion
|
||||
- Icône IA clignotante si l'IA est en train de traiter
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Tableau comparatif : besoin vs solution
|
||||
|
||||
| Besoin ObsiGate | Solution | Pattern utilisé par |
|
||||
|-----------------|----------|---------------------|
|
||||
| Édition Markdown rapide | Slash `/` + Bubble menu | Notion, Medium, Dropbox Paper |
|
||||
| Actions IA fréquentes | Bouton 🔮 proéminent + Bubble AI dropdown + Panel | Cursor, Novel.sh, Windsurf |
|
||||
| Insertion PDF/Images | Slash menu + Quick insert ⊕ + Drag & drop | Notion, Dropbox Paper |
|
||||
| Pas de clutter | Zones contextuelles (rien de visible inutilement) | Medium, iA Writer |
|
||||
| Accessibilité clavier | Tout accessible via `/`, `Ctrl+K`, `Ctrl+P` | Notion, Linear |
|
||||
| Feedback IA | Barre de statut + animations | Cursor, Copilot |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Les 3 patterns différenciateurs
|
||||
|
||||
### 1. Slash Command Menu
|
||||
Le **game-changer**. Plus besoin de toolbar fixe pour 90% des actions. Tu tapes `/` et tout est là, navigable au clavier.
|
||||
|
||||
### 2. L'IA comme citoyen de première classe
|
||||
L'IA n'est **pas cachée** dans un sous-menu. Elle est :
|
||||
- En bouton dédié visible (Zone 1)
|
||||
- Dans le slash menu (Zone 2a)
|
||||
- Dans le bubble menu (Zone 2b)
|
||||
- En panel latéral (Zone 4)
|
||||
|
||||
### 3. Bubble Menu on Selection
|
||||
Plus proche du curseur, plus rapide. Medium l'a popularisé, tout le monde l'a copié depuis.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Implémentation technique recommandée
|
||||
|
||||
### Option A : Tiptap + Custom Extensions (recommandé)
|
||||
|
||||
```javascript
|
||||
// Base : Tiptap (React)
|
||||
import { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
|
||||
// Extensions pour ObsiGate
|
||||
import { SlashCommand } from './extensions/slash-command'
|
||||
import { BubbleMenuAI } from './extensions/bubble-menu-ai'
|
||||
import { QuickInsert } from './extensions/quick-insert'
|
||||
import { AIPanel } from './extensions/ai-panel'
|
||||
import { DropZone } from './extensions/drop-zone'
|
||||
```
|
||||
|
||||
**Avantages** :
|
||||
- Contrôle total sur chaque zone
|
||||
- Écosystème mature (ProseMirror)
|
||||
- Extensions custom faciles à créer
|
||||
- Support React/Vue natif
|
||||
|
||||
### Option B : Novel.sh (rapide)
|
||||
|
||||
```javascript
|
||||
import { Editor } from 'novel'
|
||||
```
|
||||
|
||||
**Avantages** :
|
||||
- 80% du comportement Notion direct
|
||||
- Slash menu + AI autocomplete intégrés
|
||||
- MIT License
|
||||
- Moins flexible que Tiptap pur
|
||||
|
||||
### Bibliothèques UI complémentaires
|
||||
|
||||
| Composant | Librairie | Usage |
|
||||
|-----------|-----------|-------|
|
||||
| Popover / Menu flottant | `@radix-ui/react-popover` | Slash menu, Bubble menu |
|
||||
| Panel rétractable | `@radix-ui/react-dialog` ou custom | AI Panel |
|
||||
| Drag & Drop | `react-dnd` ou natif HTML5 | Zone 5 |
|
||||
| Animations | `framer-motion` ou CSS transitions | Toutes les zones |
|
||||
|
||||
---
|
||||
|
||||
## 📱 Considérations Responsive / Mobile
|
||||
|
||||
### Desktop (>768px)
|
||||
- Toutes les zones actives
|
||||
- AI Panel : rétractable à droite
|
||||
|
||||
### Tablette (480-768px)
|
||||
- AI Panel : overlay (pas de réduction de l'éditeur)
|
||||
- Bubble menu : taille réduite
|
||||
- Slash menu : pleine largeur
|
||||
|
||||
### Mobile (<480px)
|
||||
- AI Panel : bottom sheet
|
||||
- Bubble menu : repositionné en bas d'écran
|
||||
- Top bar : simplifiée (icônes uniquement)
|
||||
- Quick insert ⊕ : plus grand (32px) pour le toucher
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Raccourcis clavier
|
||||
|
||||
| Raccourci | Action | Zone |
|
||||
|-----------|--------|------|
|
||||
| `/` | Slash command menu | 2a |
|
||||
| `Ctrl+B` | Bold | 2b |
|
||||
| `Ctrl+I` | Italic | 2b |
|
||||
| `Ctrl+K` | Insert link | 2b |
|
||||
| `Ctrl+Shift+I` | Insert image | 2a |
|
||||
| `Ctrl+Shift+P` | Insert PDF | 2a |
|
||||
| `Ctrl+J` | Toggle AI Panel | 4 |
|
||||
| `Ctrl+Shift+K` | AI quick action | 2b |
|
||||
| `Ctrl+P` | Quick open / Command palette | — |
|
||||
| `Ctrl+S` | Force save | 1 |
|
||||
| `Ctrl+/` | Show all shortcuts | — |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Prochaines étapes
|
||||
|
||||
1. **Mockup HTML interactif** — Créer un prototype statique pour valider le layout
|
||||
2. **POC Tiptap** — Implémenter les zones 1, 2a, 2b dans un projet test
|
||||
3. **User testing** — Faire tester le prototype pour recueillir du feedback
|
||||
4. **Implémentation progressive** :
|
||||
- Sprint 1 : Zone 1 (Top bar) + Zone 2a (Slash menu)
|
||||
- Sprint 2 : Zone 2b (Bubble menu) + Zone 3 (Quick insert)
|
||||
- Sprint 3 : Zone 4 (AI Panel) + Zone 5 (Drag & drop)
|
||||
5. **Intégration IA** — Brancher le backend AI existant (`backend/ai_routes.py`) au panel et au bubble menu
|
||||
|
||||
---
|
||||
|
||||
## 📚 Références
|
||||
|
||||
- [Tiptap Editor Documentation](https://tiptap.dev/docs)
|
||||
- [Novel.sh — Notion-style Editor](https://novel.sh)
|
||||
- [Notion — Onboarding & Editor Design](https://www.notion.so)
|
||||
- [Medium — Design Blog](https://medium.design)
|
||||
- [Dropbox Paper](https://www.dropbox.com/paper)
|
||||
- [Radix UI Primitives](https://www.radix-ui.com/primitives)
|
||||
- [ProseMirror](https://prosemirror.net)
|
||||
+91
-61
@@ -55,7 +55,7 @@
|
||||
let outlineObserver = null;
|
||||
let activeHeadingId = null;
|
||||
let headingsCache = [];
|
||||
let rightSidebarVisible = true;
|
||||
let rightSidebarVisible = false;
|
||||
let rightSidebarWidth = 280;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -534,6 +534,7 @@
|
||||
li.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
input.value = entry;
|
||||
this._suppressNext = true;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.hide();
|
||||
_triggerAdvancedSearch(entry);
|
||||
@@ -602,6 +603,7 @@
|
||||
const base = words.filter(w => w).join(" ");
|
||||
input.value = (base ? base + " " : "") + "tag:" + item.tag + " ";
|
||||
}
|
||||
this._suppressNext = true;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.hide();
|
||||
input.focus();
|
||||
@@ -1097,13 +1099,9 @@
|
||||
},
|
||||
|
||||
loadState() {
|
||||
const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
|
||||
// Always start closed on login/refresh — user must explicitly toggle TOC
|
||||
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
|
||||
|
||||
if (savedVisible !== null) {
|
||||
rightSidebarVisible = savedVisible === "true";
|
||||
}
|
||||
|
||||
if (savedWidth) {
|
||||
rightSidebarWidth = parseInt(savedWidth) || 280;
|
||||
}
|
||||
@@ -3287,14 +3285,31 @@
|
||||
window.open(pdfUrl, "_blank");
|
||||
});
|
||||
|
||||
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
|
||||
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
|
||||
editBtn.addEventListener("click", () => {
|
||||
openEditor(data.vault, data.path);
|
||||
});
|
||||
|
||||
const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
|
||||
const forgeBtn = el("button", { class: "btn-action btn-forge", title: "Forge (nouvel editeur)" }, [icon("hammer", 14), document.createTextNode("Forge")]);
|
||||
forgeBtn.addEventListener("click", () => {
|
||||
var modal = document.getElementById("editor-modal");
|
||||
var titleEl = document.getElementById("editor-title");
|
||||
var bodyEl = document.getElementById("editor-body");
|
||||
titleEl.textContent = "Forge: " + data.path.split("/").pop();
|
||||
bodyEl.innerHTML = "";
|
||||
var iframe = document.createElement("iframe");
|
||||
iframe.id = "forge-iframe";
|
||||
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
|
||||
iframe.style.cssText = "width:100%;height:82vh;border:none;display:block;";
|
||||
bodyEl.appendChild(iframe);
|
||||
modal.classList.add("active");
|
||||
safeCreateIcons();
|
||||
});
|
||||
|
||||
const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenetre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
|
||||
openNewWindowBtn.addEventListener("click", () => {
|
||||
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
|
||||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`;
|
||||
window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
|
||||
});
|
||||
|
||||
@@ -3379,7 +3394,7 @@
|
||||
// Assemble
|
||||
area.innerHTML = "";
|
||||
area.appendChild(breadcrumb);
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||||
if (fmSection) area.appendChild(fmSection);
|
||||
area.appendChild(mdDiv);
|
||||
area.appendChild(rawDiv);
|
||||
@@ -4069,6 +4084,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initEditorPocBtn() {
|
||||
var btn = document.getElementById("editor-poc-btn");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", function() {
|
||||
closeHeaderMenu();
|
||||
window.open("/editor-poc", "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
function initHelpNavigation() {
|
||||
const helpContent = document.querySelector(".help-content");
|
||||
const helpBody = document.getElementById("help-body");
|
||||
@@ -4871,25 +4895,29 @@
|
||||
function initSearch() {
|
||||
const input = document.getElementById("search-input");
|
||||
if (!input) return;
|
||||
const caseBtn = document.getElementById("search-case-btn");
|
||||
const wordBtn = document.getElementById("search-word-btn");
|
||||
const regexBtn = document.getElementById("search-regex-btn");
|
||||
const caseBtn = document.getElementById("rb-case");
|
||||
const wordBtn = document.getElementById("rb-word");
|
||||
const regexBtn = document.getElementById("rb-regex");
|
||||
const filterBtn = document.getElementById("search-filter-btn");
|
||||
const clearBtn = document.getElementById("search-clear-btn");
|
||||
const filterRow = document.getElementById("search-filter-row");
|
||||
const prevBtn = document.getElementById("search-prev-btn");
|
||||
const nextBtn = document.getElementById("search-next-btn");
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
const prevBtn = document.getElementById("rb-prev");
|
||||
const nextBtn = document.getElementById("rb-next");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
const resultsBar = document.getElementById("results-bar");
|
||||
|
||||
function _showResultsBar() { if (resultsBar) resultsBar.style.display = 'flex'; }
|
||||
function _hideResultsBar() { if (resultsBar) resultsBar.style.display = 'none'; }
|
||||
|
||||
function _updateToggleUI() {
|
||||
caseBtn.classList.toggle("active", searchCaseSensitive);
|
||||
wordBtn.classList.toggle("active", searchWholeWord);
|
||||
regexBtn.classList.toggle("active", searchRegex);
|
||||
filterBtn.classList.toggle("active", searchFilterVisible);
|
||||
if (caseBtn) caseBtn.classList.toggle("active", searchCaseSensitive);
|
||||
if (wordBtn) wordBtn.classList.toggle("active", searchWholeWord);
|
||||
if (regexBtn) regexBtn.classList.toggle("active", searchRegex);
|
||||
if (filterBtn) filterBtn.classList.toggle("active", searchFilterVisible);
|
||||
}
|
||||
|
||||
// Toggle buttons
|
||||
caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; _updateToggleUI(); _research(); });
|
||||
if (caseBtn) caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; _updateToggleUI(); _research(); });
|
||||
if (wordBtn) wordBtn.addEventListener("click", () => { searchWholeWord = !searchWholeWord; _updateToggleUI(); _research(); });
|
||||
if (regexBtn) regexBtn.addEventListener("click", () => { searchRegex = !searchRegex; _updateToggleUI(); _research(); });
|
||||
if (filterBtn) filterBtn.addEventListener("click", () => { searchFilterVisible = !searchFilterVisible; if (filterRow) filterRow.style.display = searchFilterVisible ? "flex" : "none"; _updateToggleUI(); });
|
||||
@@ -4905,7 +4933,7 @@
|
||||
if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) {
|
||||
_searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`;
|
||||
}
|
||||
|
||||
@@ -4913,6 +4941,7 @@
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
_searchResultIdx = _searchResultItems.length > 0 ? 0 : -1;
|
||||
_updateResultHighlight();
|
||||
if (_searchResultItems.length > 0) _showResultsBar(); else _hideResultsBar();
|
||||
}
|
||||
|
||||
window.navigateSearchResults = function(delta) {
|
||||
@@ -4955,7 +4984,7 @@
|
||||
// Initially hide clear button
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
|
||||
// --- Input handler: debounced search + autocomplete dropdown ---
|
||||
// --- Input handler: autocomplete dropdown only (search triggered on Enter or selection) ---
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
@@ -4963,23 +4992,11 @@
|
||||
// Show autocomplete dropdown while typing
|
||||
AutocompleteDropdown.populate(input.value, input.selectionStart);
|
||||
|
||||
// Debounced search execution
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(
|
||||
() => {
|
||||
const q = input.value.trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
advancedSearchOffset = 0;
|
||||
if (q.length >= _getEffective("min_query_length", MIN_SEARCH_LENGTH) || tagFilter) {
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
} else if (q.length === 0) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
},
|
||||
_getEffective("debounce_ms", 300),
|
||||
);
|
||||
// Clear results when input becomes empty
|
||||
if (!hasText) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Focus handler: show history dropdown ---
|
||||
@@ -5003,7 +5020,7 @@
|
||||
AutocompleteDropdown.navigateUp();
|
||||
} else if (e.key === "Enter") {
|
||||
// First: check dropdown suggestions (higher priority than search results)
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
if (AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -5029,30 +5046,28 @@
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) {
|
||||
// Navigate search results when dropdown is closed
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Escape") {
|
||||
AutocompleteDropdown.hide();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
} else {
|
||||
// Dropdown not visible: navigate search results or execute search
|
||||
if (e.key === "ArrowDown") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Enter") {
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5233,9 +5248,11 @@
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
|
||||
// Update match counter
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
// Update match counter + show floating results bar
|
||||
var rb = document.getElementById("results-bar");
|
||||
var countEl = document.getElementById("rb-count");
|
||||
if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`;
|
||||
if (rb) rb.style.display = 'flex';
|
||||
|
||||
// Header with result count and sort controls
|
||||
const header = el("div", { class: "search-results-header" });
|
||||
@@ -6297,6 +6314,12 @@
|
||||
editorView = null;
|
||||
}
|
||||
fallbackEditorEl = null;
|
||||
// Remove forge iframe if present + refresh current file
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame) {
|
||||
forgeFrame.remove();
|
||||
if (currentVault && currentPath) openFile(currentVault, currentPath);
|
||||
}
|
||||
editorVault = null;
|
||||
editorPath = null;
|
||||
}
|
||||
@@ -7819,6 +7842,7 @@
|
||||
initSidebarTabs();
|
||||
initHelpModal();
|
||||
initConfigModal();
|
||||
initEditorPocBtn();
|
||||
initSidebarFilter();
|
||||
initSidebarResize();
|
||||
initEditor();
|
||||
@@ -7828,6 +7852,12 @@
|
||||
FindInPageManager.init();
|
||||
ContextMenuManager.init();
|
||||
|
||||
// Mermaid.js init
|
||||
import('./mermaid-viewer.js').then(function(m) {
|
||||
m.initMermaid();
|
||||
m.setupFocusMode();
|
||||
});
|
||||
|
||||
// Check auth status first
|
||||
const authOk = await AuthManager.initAuth();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><title>Excalidraw Diagnostic</title>
|
||||
<style>
|
||||
body { font-family: monospace; background:#1e1e1e; color:#ddd; padding:20px; }
|
||||
.pass { color:#4caf50; } .fail { color:#f44336; } .info { color:#ff9800; }
|
||||
pre { background:#2d2d2d; padding:10px; border-radius:4px; overflow-x:auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Excalidraw Diagnostic</h2>
|
||||
<pre id="log"></pre>
|
||||
|
||||
<script type="module">
|
||||
const log = document.getElementById('log');
|
||||
function add(msg, cls='') {
|
||||
log.innerHTML += `<span class="${cls}">${msg}</span>\n`;
|
||||
}
|
||||
|
||||
add('--- Step 1: Testing esm.sh connectivity ---');
|
||||
try {
|
||||
const res = await fetch('https://esm.sh/[email protected]', { method: 'HEAD' });
|
||||
add(`✓ esm.sh reachable (HTTP ${res.status})`, 'pass');
|
||||
} catch(e) {
|
||||
add(`✗ esm.sh unreachable: ${e.message}`, 'fail');
|
||||
}
|
||||
|
||||
add('\n--- Step 2: Importing React ---');
|
||||
try {
|
||||
const React = await import('https://esm.sh/[email protected]');
|
||||
add(`✓ React imported (keys: ${Object.keys(React.default||React).slice(0,5).join(',')})`, 'pass');
|
||||
window.React = React;
|
||||
} catch(e) {
|
||||
add(`✗ React import failed: ${e.message}`, 'fail');
|
||||
}
|
||||
|
||||
add('\n--- Step 3: Importing ReactDOM ---');
|
||||
try {
|
||||
const ReactDOM = await import('https://esm.sh/[email protected]');
|
||||
add(`✓ ReactDOM imported`, 'pass');
|
||||
window.ReactDOM = ReactDOM;
|
||||
} catch(e) {
|
||||
add(`✗ ReactDOM import failed: ${e.message}`, 'fail');
|
||||
}
|
||||
|
||||
add('\n--- Step 4: Importing Excalidraw ---');
|
||||
try {
|
||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/[email protected]/dist/prod/';
|
||||
const ExcalidrawLib = await import('https://esm.sh/@excalidraw/[email protected]');
|
||||
add(`✓ Excalidraw imported (keys: ${Object.keys(ExcalidrawLib).slice(0,8).join(',')})`, 'pass');
|
||||
window.ExcalidrawLib = ExcalidrawLib;
|
||||
} catch(e) {
|
||||
add(`✗ Excalidraw import failed: ${e.message}`, 'fail');
|
||||
}
|
||||
|
||||
add('\n--- Step 5: CSP Check ---');
|
||||
add('Check browser console (F12) for CSP violation messages', 'info');
|
||||
add('Look for: "Refused to load the script..." or "violates the following CSP directive..."', 'info');
|
||||
|
||||
add('\n--- Step 6: Browser Info ---');
|
||||
add(`User-Agent: ${navigator.userAgent}`, 'info');
|
||||
add(`ES Modules: ${typeof import.meta !== 'undefined' ? 'supported' : 'NOT supported'}`, 'info');
|
||||
|
||||
add('\n=== Diagnostic complete ===');
|
||||
add('If all ✓ above: Excalidraw should work. Check F12 console for errors.', 'info');
|
||||
add('If any ✗: That\'s the problem. Share the error message.', 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><title>Excalidraw Render Test</title>
|
||||
<style>
|
||||
body { font-family:monospace; background:#1e1e1e; color:#ddd; padding:20px; }
|
||||
.pass { color:#4caf50; } .fail { color:#f44336; }
|
||||
pre { background:#2d2d2d; padding:10px; border-radius:4px; }
|
||||
#app { margin-top:20px; border:1px dashed #555; min-height:100px; padding:10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Excalidraw Render Diagnostic</h2>
|
||||
<pre id="log"></pre>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/[email protected]";
|
||||
import ReactDOM from "https://esm.sh/[email protected]/client";
|
||||
const log = document.getElementById('log');
|
||||
function add(msg, cls='') { log.innerHTML += `<span class="${cls}">${msg}</span>\n`; }
|
||||
|
||||
add('--- Step 1: ReactDOM client ---');
|
||||
try {
|
||||
add(`✓ createRoot: ${typeof ReactDOM.createRoot}`, 'pass');
|
||||
} catch(e) {
|
||||
add(`✗ ${e.message}`, 'fail');
|
||||
}
|
||||
|
||||
add('\n--- Step 2: Testing basic React render ---');
|
||||
try {
|
||||
const root = ReactDOM.createRoot(document.getElementById('app'));
|
||||
root.render(React.createElement('div', null, 'Hello from React 18!'));
|
||||
add('✓ Basic React render OK', 'pass');
|
||||
} catch(e) {
|
||||
add(`✗ ${e.message}`, 'fail');
|
||||
}
|
||||
|
||||
add('\n--- Step 3: Loading Excalidraw ---');
|
||||
try {
|
||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/[email protected]/dist/prod/';
|
||||
const ExcalidrawLib = await import('https://esm.sh/@excalidraw/[email protected]');
|
||||
add(`✓ Excalidraw loaded`, 'pass');
|
||||
|
||||
const { Excalidraw } = ExcalidrawLib;
|
||||
add(` Excalidraw component type: ${typeof Excalidraw}`, 'info');
|
||||
|
||||
add('\n--- Step 4: Rendering Excalidraw ---');
|
||||
const root2 = ReactDOM.createRoot(document.getElementById('app'));
|
||||
try {
|
||||
root2.render(
|
||||
React.createElement(Excalidraw, {
|
||||
initialData: {
|
||||
elements: [],
|
||||
appState: { viewBackgroundColor: '#ffffff' },
|
||||
files: {}
|
||||
},
|
||||
theme: 'dark',
|
||||
excalidrawRef: (api) => {
|
||||
add('✓ excalidrawRef CALLBACK FIRED! Excalidraw is mounted!', 'pass');
|
||||
}
|
||||
})
|
||||
);
|
||||
add(' render() called - waiting for excalidrawRef callback...', 'info');
|
||||
} catch(e) {
|
||||
add(`✗ RENDER ERROR: ${e.message}`, 'fail');
|
||||
add(` Stack: ${e.stack}`, 'info');
|
||||
}
|
||||
} catch(e) {
|
||||
add(`✗ ${e.message}`, 'fail');
|
||||
add(` Stack: ${e.stack}`, 'info');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0"><title>Excalidraw Error Capture</title>
|
||||
<style>
|
||||
body { font-family:monospace; background:#1e1e1e; color:#ddd; padding:20px; }
|
||||
.pass { color:#4caf50; } .fail { color:#f44336; }
|
||||
pre { background:#2d2d2d; padding:10px; border-radius:4px; }
|
||||
#app { margin-top:20px; border:1px dashed #555; min-height:100px; padding:10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Excalidraw Error Boundary Test</h2>
|
||||
<pre id="log"></pre>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/[email protected]";
|
||||
import ReactDOM from "https://esm.sh/[email protected]/client";
|
||||
const log = document.getElementById('log');
|
||||
function add(msg, cls='') { log.innerHTML += `<span class="${cls}">${msg}</span>\n`; }
|
||||
|
||||
// Error boundary to catch rendering errors
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) { super(props); this.state = { error: null }; }
|
||||
static getDerivedStateFromError(error) { return { error }; }
|
||||
componentDidCatch(error, info) {
|
||||
add(`✗ REACT ERROR CAUGHT: ${error.message}`, 'fail');
|
||||
add(` Stack: ${(error.stack||'').split('\n').slice(0,5).join('\n')}`, 'info');
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return React.createElement('div', { style: {color:'red',padding:'20px'} },
|
||||
`Error: ${this.state.error.message}`
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
add('Loading Excalidraw...');
|
||||
try {
|
||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/[email protected]/dist/prod/';
|
||||
const ExcalidrawLib = await import('https://esm.sh/@excalidraw/[email protected]');
|
||||
const { Excalidraw } = ExcalidrawLib;
|
||||
|
||||
add(`Excalidraw keys: ${Object.keys(ExcalidrawLib).slice(0,8).join(', ')}`, 'info');
|
||||
add(`Excalidraw itself: ${typeof Excalidraw}`, 'info');
|
||||
if (typeof Excalidraw === 'function') {
|
||||
add(` .displayName: ${Excalidraw.displayName || 'none'}`, 'info');
|
||||
add(` .name: ${Excalidraw.name || 'none'}`, 'info');
|
||||
}
|
||||
|
||||
// Try rendering with error boundary
|
||||
const root = ReactDOM.createRoot(document.getElementById('app'));
|
||||
|
||||
try {
|
||||
root.render(
|
||||
React.createElement(ErrorBoundary, null,
|
||||
React.createElement(Excalidraw, {
|
||||
theme: 'dark',
|
||||
excalidrawRef: (api) => {
|
||||
add('✓ excalidrawRef CALLBACK FIRED!', 'pass');
|
||||
},
|
||||
onChange: () => {
|
||||
add(' onChange called', 'info');
|
||||
},
|
||||
onPointerUpdate: () => {
|
||||
// noisy, skip
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
add('render() executed - waiting...', 'info');
|
||||
} catch(e) {
|
||||
add(`✗ RENDER CRASH: ${e.message}`, 'fail');
|
||||
add(` ${e.stack}`, 'info');
|
||||
}
|
||||
} catch(e) {
|
||||
add(`✗ IMPORT ERROR: ${e.message}`, 'fail');
|
||||
add(` ${e.stack}`, 'info');
|
||||
}
|
||||
|
||||
add('\nOpen browser console (F12) for more errors.', 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>Excalidraw Final Test</title>
|
||||
<style>
|
||||
body { font-family:monospace; background:#1e1e1e; color:#ddd; padding:20px; }
|
||||
.pass { color:#4caf50; } .fail { color:#f44336; }
|
||||
pre { background:#2d2d2d; padding:10px; border-radius:4px; }
|
||||
#app { margin-top:20px; border:1px dashed #555; min-height:100px; padding:10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Excalidraw Final Test (Import Map)</h2>
|
||||
<pre id="log"></pre>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/[email protected]",
|
||||
"react-dom": "https://esm.sh/[email protected]",
|
||||
"react-dom/": "https://esm.sh/[email protected]/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
const log = document.getElementById('log');
|
||||
function add(msg, cls='') { log.innerHTML += `<span class="${cls}">${msg}</span>\n`; }
|
||||
|
||||
add('React version: ' + React.version, 'info');
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) { super(props); this.state = { error: null }; }
|
||||
static getDerivedStateFromError(error) { return { error }; }
|
||||
componentDidCatch(error, info) {
|
||||
add(`✗ ERROR: ${error.message}`, 'fail');
|
||||
add(` ${(error.stack||'').split('\n').slice(0,3).join('\n')}`, 'info');
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) return React.createElement('div', {style:{color:'red',padding:'20px'}}, 'Error: '+this.state.error.message);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/[email protected]/dist/prod/';
|
||||
const ExcalidrawLib = await import('https://esm.sh/@excalidraw/[email protected]?alias=react:[email protected],react-dom:[email protected]');
|
||||
const { Excalidraw } = ExcalidrawLib;
|
||||
add('Excalidraw loaded', 'pass');
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('app'));
|
||||
root.render(
|
||||
React.createElement(ErrorBoundary, null,
|
||||
React.createElement(Excalidraw, {
|
||||
theme: 'dark',
|
||||
excalidrawRef: (api) => add('✓ EXCALIDRAW MOUNTED!', 'pass'),
|
||||
onChange: () => {},
|
||||
})
|
||||
)
|
||||
);
|
||||
add('render() called - waiting for mount...', 'info');
|
||||
} catch(e) {
|
||||
add(`✗ ${e.message}`, 'fail');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Excalidraw Editor</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #ffffff; }
|
||||
|
||||
/* Toolbar overlay in top-right corner */
|
||||
#excalidraw-toolbar {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
#excalidraw-toolbar button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#excalidraw-toolbar button:hover { background: #f0f0f0; }
|
||||
#excalidraw-toolbar button.primary {
|
||||
background: #6965db;
|
||||
color: #fff;
|
||||
border-color: #6965db;
|
||||
}
|
||||
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
|
||||
|
||||
/* Dirty indicator */
|
||||
#dirty-badge {
|
||||
font-size: 11px;
|
||||
color: #e07b39;
|
||||
font-weight: 500;
|
||||
display: none;
|
||||
}
|
||||
#dirty-badge.visible { display: inline; }
|
||||
|
||||
/* Dark mode overrides (applied via body.dark) */
|
||||
body.dark { background: #1e1e1e; }
|
||||
body.dark #excalidraw-toolbar button {
|
||||
background: #2d2d2d;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark #excalidraw-toolbar button:hover { background: #3d3d3d; }
|
||||
body.dark #excalidraw-toolbar button.primary {
|
||||
background: #6965db;
|
||||
color: #fff;
|
||||
}
|
||||
body.dark #dirty-badge { color: #e0a36b; }
|
||||
|
||||
/* Loading state */
|
||||
#loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
z-index: 999;
|
||||
}
|
||||
body.dark #loading { color: #aaa; }
|
||||
#loading.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">Loading Excalidraw…</div>
|
||||
<div id="root"></div>
|
||||
<div id="excalidraw-toolbar">
|
||||
<span id="dirty-badge">● Modified</span>
|
||||
<button id="btn-save" class="primary" title="Save (Ctrl+S)">💾 Save</button>
|
||||
<button id="btn-export-png" title="Export PNG">🖼 PNG</button>
|
||||
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
|
||||
</div>
|
||||
|
||||
<!-- Import map: force ALL modules (Excalidraw + sub-deps) to use React 18.3.1 -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/[email protected]",
|
||||
"react-dom": "https://esm.sh/[email protected]",
|
||||
"react-dom/": "https://esm.sh/[email protected]/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Single module script -->
|
||||
<script type="module">
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { decompressFromBase64 } from "https://esm.sh/[email protected]";
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]?alias=react:[email protected],react-dom:[email protected]";
|
||||
|
||||
window.React = React;
|
||||
window.ReactDOM = ReactDOM;
|
||||
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
|
||||
window.ExcalidrawLib = ExcalidrawLib;
|
||||
|
||||
const { Excalidraw, exportToBlob, exportToSvg } = ExcalidrawLib;
|
||||
|
||||
// --- State ---
|
||||
let excalidrawAPI = null;
|
||||
let isDirty = false;
|
||||
let currentTheme = "light";
|
||||
let saveTimeout = null;
|
||||
|
||||
// --- DOM refs ---
|
||||
const loadingEl = document.getElementById("loading");
|
||||
const dirtyBadge = document.getElementById("dirty-badge");
|
||||
const btnSave = document.getElementById("btn-save");
|
||||
const btnExportPng = document.getElementById("btn-export-png");
|
||||
const btnExportSvg = document.getElementById("btn-export-svg");
|
||||
|
||||
// --- postMessage helpers ---
|
||||
function sendToParent(msg) {
|
||||
window.parent.postMessage(msg, "*");
|
||||
}
|
||||
|
||||
// Signal that the iframe is loaded and ready to receive init data
|
||||
sendToParent({ type: "ready" });
|
||||
|
||||
function markDirty() {
|
||||
if (!isDirty) {
|
||||
isDirty = true;
|
||||
dirtyBadge.classList.add("visible");
|
||||
sendToParent({ type: "modified", dirty: true });
|
||||
}
|
||||
}
|
||||
|
||||
function markClean() {
|
||||
if (isDirty) {
|
||||
isDirty = false;
|
||||
dirtyBadge.classList.remove("visible");
|
||||
sendToParent({ type: "modified", dirty: false });
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSave() {
|
||||
if (!excalidrawAPI) return;
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
const files = excalidrawAPI.getFiles();
|
||||
sendToParent({
|
||||
type: "save",
|
||||
data: {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements: elements,
|
||||
appState: appState,
|
||||
files: files,
|
||||
},
|
||||
});
|
||||
markClean();
|
||||
btnSave.textContent = "💾 Saved!";
|
||||
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
currentTheme = theme;
|
||||
document.body.classList.toggle("dark", theme === "dark");
|
||||
if (excalidrawAPI) {
|
||||
excalidrawAPI.updateScene({ appState: { theme: theme } });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Excalidraw component ---
|
||||
function App({ initialData, theme }) {
|
||||
const [appState, setAppState] = React.useState(null);
|
||||
|
||||
const excalidrawRef = React.useCallback((api) => {
|
||||
if (api) {
|
||||
excalidrawAPI = api;
|
||||
loadingEl.classList.add("hidden");
|
||||
if (theme === "dark") {
|
||||
api.updateScene({ appState: { theme: "dark" } });
|
||||
}
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const onChange = React.useCallback((elements, state, files) => {
|
||||
markDirty();
|
||||
}, []);
|
||||
|
||||
return React.createElement(Excalidraw, {
|
||||
excalidrawRef: excalidrawRef,
|
||||
initialData: initialData,
|
||||
onChange: onChange,
|
||||
theme: theme,
|
||||
UIOptions: {
|
||||
canvasActions: {
|
||||
export: false,
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
saveAsImage: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Message listener ---
|
||||
window.addEventListener("message", (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg.type !== "string") return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "init":
|
||||
if (msg.data) {
|
||||
let elements, appState, files;
|
||||
// Handle compressed .excalidraw.md format
|
||||
if (msg.data.compressed) {
|
||||
try {
|
||||
const decompressed = decompressFromBase64(msg.data.compressed);
|
||||
const parsed = JSON.parse(decompressed);
|
||||
elements = parsed.elements || [];
|
||||
appState = parsed.appState || {};
|
||||
files = parsed.files || {};
|
||||
} catch(e) {
|
||||
console.error("Failed to decompress Excalidraw data:", e);
|
||||
elements = []; appState = {}; files = {};
|
||||
}
|
||||
} else {
|
||||
elements = msg.data.elements || [];
|
||||
appState = msg.data.appState || {};
|
||||
files = msg.data.files || {};
|
||||
}
|
||||
const initialData = { elements, appState, files };
|
||||
currentTheme = msg.theme || "light";
|
||||
setTheme(currentTheme);
|
||||
|
||||
try {
|
||||
if (typeof ReactDOM.createRoot !== "function") {
|
||||
throw new Error("ReactDOM.createRoot is not available — check react-dom/client import");
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
React.createElement(App, {
|
||||
initialData: initialData,
|
||||
theme: currentTheme,
|
||||
})
|
||||
);
|
||||
} catch (renderErr) {
|
||||
console.error("Excalidraw render failed:", renderErr);
|
||||
loadingEl.textContent = "Error loading Excalidraw: " + renderErr.message;
|
||||
loadingEl.style.color = "#f44336";
|
||||
loadingEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "theme":
|
||||
setTheme(msg.theme || "light");
|
||||
break;
|
||||
|
||||
case "requestSave":
|
||||
triggerSave();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
triggerSave();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Toolbar buttons ---
|
||||
btnSave.addEventListener("click", triggerSave);
|
||||
|
||||
btnExportPng.addEventListener("click", async () => {
|
||||
if (!excalidrawAPI) return;
|
||||
try {
|
||||
const blob = await exportToBlob({
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: excalidrawAPI.getAppState(),
|
||||
files: excalidrawAPI.getFiles(),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "diagram.png";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("PNG export failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
btnExportSvg.addEventListener("click", async () => {
|
||||
if (!excalidrawAPI) return;
|
||||
try {
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: excalidrawAPI.getAppState(),
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "diagram.svg";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("SVG export failed:", err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1703
-1198
File diff suppressed because it is too large
Load Diff
+23
-22
@@ -9,6 +9,7 @@
|
||||
- AI Toolbox (to list, to table, frontmatter, to canvas)
|
||||
*/
|
||||
import { api } from './auth.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// ── API call helper ──
|
||||
async function aiAction(endpoint, text, extra = {}) {
|
||||
@@ -220,7 +221,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
if (!aiConfigured) {
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border-color)';
|
||||
hint.textContent = '⚠️ AI non configuré — ajouter DEEPSEEK_API_KEY, OPENROUTER_API_KEY ou GEMINI_API_KEY dans .env';
|
||||
hint.textContent = t('ai.not_configured');
|
||||
container.appendChild(hint);
|
||||
return;
|
||||
}
|
||||
@@ -256,7 +257,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
});
|
||||
aiBtn.title = 'AI Inline Completion (Ctrl+J) — Complète le texte automatiquement';
|
||||
aiBtn.title = t('ai.inline_completion_title');
|
||||
aiBtn.addEventListener('click', async () => {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
@@ -268,9 +269,9 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
try {
|
||||
const result = await aiAction('inline-complete', text);
|
||||
replaceSelection(v, result, 'append');
|
||||
showToast('AI: complétion ajoutée', 'success');
|
||||
showToast(t('ai.completion_added'), 'success');
|
||||
} catch (e) {
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? 'clé API invalide' : e.message), 'error');
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||||
} finally {
|
||||
aiBtn.innerHTML = origHTML;
|
||||
aiBtn.disabled = false;
|
||||
@@ -278,13 +279,13 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
});
|
||||
|
||||
// ── Edit menu ──
|
||||
const editBtn = createDropdownBtn('Éditer', [
|
||||
{ label: '🪄 Improve writing', action: () => action('improve'), hint: 'Améliore la qualité du texte' },
|
||||
const editBtn = createDropdownBtn(t('ai.edit'), [
|
||||
{ label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') },
|
||||
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' },
|
||||
{ label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' },
|
||||
{ label: '📐 Make longer', action: () => action('make-longer'), hint: 'Ajoute des détails' },
|
||||
{ label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||||
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
|
||||
], 'Modifie le texte sélectionné');
|
||||
], t('ai.hint_edit_selection'));
|
||||
|
||||
// ── Tone menu ──
|
||||
const toneBtn = createDropdownBtn('Ton', [
|
||||
@@ -300,20 +301,20 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
{ label: '🇩🇪 German', action: () => action('translate', { target_lang: 'German' }) },
|
||||
{ label: '🇫🇷 French', action: () => action('translate', { target_lang: 'French' }) },
|
||||
{ label: '🇪🇸 Spanish', action: () => action('translate', { target_lang: 'Spanish' }) },
|
||||
], 'Traduit le texte sélectionné');
|
||||
], t('ai.hint_translate'));
|
||||
|
||||
// ── Generate menu ──
|
||||
const genBtn = createDropdownBtn('Générer', [
|
||||
const genBtn = createDropdownBtn(t('ai.generate'), [
|
||||
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
|
||||
], 'Génère du contenu à partir de la sélection');
|
||||
], t('ai.hint_generate'));
|
||||
|
||||
// ── Custom Rewrite ──
|
||||
const rewriteBtn = document.createElement('button');
|
||||
rewriteBtn.className = 'ai-toolbar-btn';
|
||||
rewriteBtn.innerHTML = '💬 Réécrire';
|
||||
rewriteBtn.title = 'Réécrit le texte selon vos instructions';
|
||||
rewriteBtn.innerHTML = t('ai.rewrite');
|
||||
rewriteBtn.title = t('ai.rewrite_title');
|
||||
Object.assign(rewriteBtn.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'transparent',
|
||||
@@ -327,19 +328,19 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
const instruction = prompt('Instruction de réécriture :', '');
|
||||
const instruction = prompt(t('ai.rewrite_prompt'), '');
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const result = await aiAction('rewrite', text, { instruction });
|
||||
replaceSelection(v, result);
|
||||
showToast('AI: texte réécrit', 'success');
|
||||
showToast(t('ai.text_rewritten'), 'success');
|
||||
} catch (e) {
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? 'clé API invalide' : e.message), 'error');
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Toolbox menu ──
|
||||
const toolboxBtn = createDropdownBtn('🧰 Boîte', [
|
||||
const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [
|
||||
{ label: '📋 Convert to list', action: () => action('to-list') },
|
||||
{ label: '📊 Convert to table', action: () => action('to-table') },
|
||||
{ label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') },
|
||||
@@ -364,10 +365,10 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
if (!text.trim()) {
|
||||
showToast('AI: sélectionnez du texte à traiter', 'warning');
|
||||
showToast(t('ai.select_text'), 'warning');
|
||||
return;
|
||||
}
|
||||
showToast('⏳ AI: traitement en cours...', 'info');
|
||||
showToast(t('ai.processing'), 'info');
|
||||
|
||||
// Show processing indicator in the editor header
|
||||
_showProcessingIndicator(true);
|
||||
@@ -377,13 +378,13 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
try {
|
||||
const result = await aiAction(endpoint, text, extra);
|
||||
replaceSelection(v, result, mode);
|
||||
showToast('AI: texte traité ✓', 'success');
|
||||
showToast(t('ai.text_processed'), 'success');
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
if (msg.includes('401')) {
|
||||
showToast('AI: clé API invalide. Vérifiez DEEPSEEK_API_KEY dans .env', 'error');
|
||||
showToast(t('ai.invalid_api_key'), 'error');
|
||||
} else if (msg.includes('402') || msg.includes('429')) {
|
||||
showToast('AI: quota dépassé ou paiement requis', 'error');
|
||||
showToast(t('ai.quota_exceeded'), 'error');
|
||||
} else {
|
||||
showToast('AI: ' + msg, 'error');
|
||||
}
|
||||
|
||||
+36
-5
@@ -4,6 +4,7 @@
|
||||
|
||||
import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n, t } from './i18n.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
@@ -16,11 +17,21 @@ import * as Legacy from './legacy.js';
|
||||
import { initCommandPalette } from './palette.js';
|
||||
import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
// =========================================================================
|
||||
async function init() {
|
||||
// 1. Init i18n FIRST — loads fallback (FR) + selected locale
|
||||
// Server language check may fail (no auth yet), falls to localStorage
|
||||
// LocalStorage has correct language if user set it previously
|
||||
await initI18n();
|
||||
|
||||
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
UI.initTheme();
|
||||
UI.initHeaderMenu();
|
||||
UI.initCustomDropdowns();
|
||||
@@ -37,6 +48,7 @@ async function init() {
|
||||
Legacy.initSidebarTabs();
|
||||
Legacy.initHelpModal();
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
@@ -47,11 +59,10 @@ async function init() {
|
||||
UI.FindInPageManager.init();
|
||||
UI.ContextMenuManager.init();
|
||||
UI.TabManager.init();
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
if (authOk) {
|
||||
Legacy.initSyncStatus();
|
||||
|
||||
@@ -80,15 +91,35 @@ async function init() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize ObsiGate:", err);
|
||||
UI.showToast("Erreur lors de l'initialisation", "error");
|
||||
UI.showToast(t("error.initialization"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
initGraphView();
|
||||
initMermaid();
|
||||
setupFocusMode();
|
||||
Utils.safeCreateIcons();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init();
|
||||
Sync.init();
|
||||
try {
|
||||
init();
|
||||
Sync.init();
|
||||
} finally {
|
||||
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
|
||||
// initialisée : le splash est retiré et le squelette stylé révélé.
|
||||
window.__OBSIGATE_BOOTED = true;
|
||||
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
|
||||
}
|
||||
// Show version in header
|
||||
fetch('/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const badge = document.getElementById('version-badge');
|
||||
if (badge && d.version) {
|
||||
badge.textContent = d.version;
|
||||
badge.style.display = 'inline-block';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
+41
-12
@@ -2,8 +2,17 @@
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu } from './ui.js';
|
||||
import { t, getLocale, setLocale } from './i18n.js';
|
||||
import { showWelcome } from './viewer.js';
|
||||
|
||||
// ── Global logout handler for header dropdown onclick ────────────────────
|
||||
// Defined at module top level so it's always available, even if
|
||||
// renderUserMenu() hasn't been called yet.
|
||||
// Uses a getter to lazy-resolve AuthManager (defined later in this module).
|
||||
window.handleLogout = () => {
|
||||
AuthManager.logout();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,7 +42,7 @@ async function api(path, opts) {
|
||||
res = await fetch(path, mergedOpts);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") throw err; // let callers handle abort
|
||||
showToast("Erreur réseau — vérifiez votre connexion", "error");
|
||||
showToast(t("auth.network_error"), "error");
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 401 && AuthManager._authEnabled) {
|
||||
@@ -194,7 +203,7 @@ const AuthManager = {
|
||||
userMenu.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="Déconnexion"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
||||
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
||||
safeCreateIcons();
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
@@ -293,11 +302,29 @@ function initLoginForm() {
|
||||
try {
|
||||
await AuthManager.login(username, password, rememberMe);
|
||||
AuthManager.showApp();
|
||||
|
||||
// Re-sync language from server now that we're authenticated
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
if (user.language && user.language !== getLocale()) {
|
||||
await setLocale(user.language);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* non-bloquant */ }
|
||||
|
||||
// Load app data after successful login
|
||||
try {
|
||||
// Dynamic imports to avoid circular dependency with sidebar.js
|
||||
const { loadVaults, loadTags } = await import('./sidebar.js');
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
// Start SSE sync now that auth cookie is set
|
||||
// Start SSE sync now that auth cookie is set (dynamic import to avoid circular dep)
|
||||
const { IndexUpdateManager } = await import('./sync.js');
|
||||
IndexUpdateManager.connect();
|
||||
// Sync offline file index now that we're authenticated
|
||||
const { syncFileIndexFromServer } = await import('./offline.js');
|
||||
syncFileIndexFromServer();
|
||||
// Show dashboard
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
@@ -353,7 +380,7 @@ const AdminPanel = {
|
||||
this._modal.innerHTML = `
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">⚙️ Administration — Utilisateurs</div>
|
||||
<div class="editor-title">⚙️ ${t('admin.title')}</div>
|
||||
<div class="editor-actions">
|
||||
<button class="editor-btn" id="admin-close" title="Fermer">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
@@ -362,7 +389,7 @@ const AdminPanel = {
|
||||
</div>
|
||||
<div class="editor-body" id="admin-body">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">+ Nouvel utilisateur</button>
|
||||
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">${t('admin.new_user')}</button>
|
||||
</div>
|
||||
<div id="admin-users-list" class="admin-users-list"></div>
|
||||
</div>
|
||||
@@ -397,11 +424,13 @@ const AdminPanel = {
|
||||
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table class="admin-table"><thead><tr>' + "<th>Utilisateur</th><th>Rôle</th><th>Vaults</th><th>Statut</th><th>Dernière connexion</th><th>Actions</th>" + "</tr></thead><tbody>";
|
||||
let html = '<table class="admin-table"><thead><tr>' +
|
||||
'<th>' + t('admin.col_user') + '</th><th>' + t('admin.col_role') + '</th><th>' + t('admin.col_vaults') + '</th><th>' + t('admin.col_status') + '</th><th>' + t('admin.col_last_login') + '</th><th>' + t('admin.col_actions') + '</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
users.forEach((u) => {
|
||||
const vaults = u.vaults.includes("*") ? "Toutes" : u.vaults.join(", ") || "Aucune";
|
||||
const vaults = u.vaults.includes("*") ? t('common.all') : u.vaults.join(", ") || t('common.none');
|
||||
const status = u.active ? "✅" : "🔴";
|
||||
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "Jamais";
|
||||
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US', { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : t('common.never');
|
||||
html +=
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
@@ -426,10 +455,10 @@ const AdminPanel = {
|
||||
'<td class="admin-actions">' +
|
||||
'<button class="admin-action-btn" data-action="edit" data-username="' +
|
||||
u.username +
|
||||
'" title="Modifier">✏️</button>' +
|
||||
'" title="' + t('common.edit') + '">✏️</button>' +
|
||||
'<button class="admin-action-btn danger" data-action="delete" data-username="' +
|
||||
u.username +
|
||||
'" title="Supprimer">🗑️</button>' +
|
||||
'" title="' + t('common.delete') + '">🗑️</button>' +
|
||||
"</td></tr>";
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
@@ -532,14 +561,14 @@ const AdminPanel = {
|
||||
async _deleteUser(username) {
|
||||
const currentUser = AuthManager.getUser();
|
||||
if (currentUser && currentUser.username === username) {
|
||||
showToast("Impossible de supprimer son propre compte", "error");
|
||||
showToast(t("auth.cannot_delete_self"), "error");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return;
|
||||
try {
|
||||
await api("/api/auth/admin/users/" + username, { method: "DELETE" });
|
||||
this._loadUsers();
|
||||
showToast("Utilisateur supprimé", "success");
|
||||
showToast(t("auth.user_deleted"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/* ObsiGate — Autocomplete intelligent
|
||||
* Partagé entre éditeur Forge (textarea) et CodeMirror.
|
||||
* Détecte le contexte autour du curseur et suggère des complétions.
|
||||
*
|
||||
* Contexte supporté : mermaid, code fence, frontmatter, markdown, wikilink, table, texte libre.
|
||||
*
|
||||
* API :
|
||||
* suggest(text, cursorPos) → { context, items: [{label, detail, insert, from, to}] }
|
||||
* CompletionSource pour CodeMirror : codeMirrorCompletionSource(context)
|
||||
*/
|
||||
|
||||
// ── Suggestion Data ──────────────────────────────────────────────────
|
||||
var MERMAID_TYPES = [
|
||||
{ label: 'flowchart TD', detail: 'Diagramme de flux (top-down)', insert: 'flowchart TD\n ' },
|
||||
{ label: 'flowchart LR', detail: 'Diagramme de flux (gauche-droite)', insert: 'flowchart LR\n ' },
|
||||
{ label: 'sequenceDiagram', detail: 'Sequence (acteurs + messages)', insert: 'sequenceDiagram\n ' },
|
||||
{ label: 'classDiagram', detail: 'Diagramme de classes UML', insert: 'classDiagram\n ' },
|
||||
{ label: 'stateDiagram-v2', detail: 'Diagramme d\'etats', insert: 'stateDiagram-v2\n ' },
|
||||
{ label: 'erDiagram', detail: 'Diagramme Entite-Relation', insert: 'erDiagram\n ' },
|
||||
{ label: 'journey', detail: 'Parcours utilisateur', insert: 'journey\n ' },
|
||||
{ label: 'gantt', detail: 'Diagramme de Gantt', insert: 'gantt\n ' },
|
||||
{ label: 'pie', detail: 'Diagramme circulaire', insert: 'pie\n ' },
|
||||
{ label: 'gitGraph', detail: 'Historique Git', insert: 'gitGraph\n ' },
|
||||
{ label: 'mindmap', detail: 'Carte mentale', insert: 'mindmap\n ' },
|
||||
{ label: 'timeline', detail: 'Ligne du temps', insert: 'timeline\n ' },
|
||||
{ label: 'quadrantChart', detail: 'Graphique quadrants', insert: 'quadrantChart\n ' },
|
||||
{ label: 'xychart-beta', detail: 'Graphique XY', insert: 'xychart-beta\n ' },
|
||||
{ label: 'block-beta', detail: 'Diagramme de blocs', insert: 'block-beta\n ' }
|
||||
];
|
||||
|
||||
var MERMAID_SYNTAX = {
|
||||
'flowchart': [
|
||||
{ label: 'A-->B', detail: 'Fleche', insert: '-->' },
|
||||
{ label: 'A--text-->B', detail: 'Fleche avec texte', insert: '-- texte -->' },
|
||||
{ label: 'A-.->B', detail: 'Fleche pointillee', insert: '-.->' },
|
||||
{ label: 'A==>B', detail: 'Fleche epaisse', insert: '==>' },
|
||||
{ label: 'subgraph', detail: 'Sous-graphe', insert: 'subgraph titre\n \nend' },
|
||||
{ label: 'style A fill:#xxx', detail: 'Style de noeud', insert: 'style A fill:#333,stroke:#58a6ff' },
|
||||
{ label: 'classDef', detail: 'Classe CSS', insert: 'classDef default fill:#333,stroke:#58a6ff' }
|
||||
],
|
||||
'sequenceDiagram': [
|
||||
{ label: 'participant X', detail: 'Declarer acteur', insert: 'participant Nom' },
|
||||
{ label: 'A->>B: msg', detail: 'Message', insert: '->> : ' },
|
||||
{ label: 'A-->>B: msg', detail: 'Message asynchrone', insert: '-->> : ' },
|
||||
{ label: 'Note over A,B', detail: 'Note', insert: 'Note over A,B: ' },
|
||||
{ label: 'loop text', detail: 'Boucle', insert: 'loop description\n \nend' },
|
||||
{ label: 'alt/else', detail: 'Alternative', insert: 'alt condition\n \nelse\n \nend' },
|
||||
{ label: 'activate A', detail: 'Activer ligne de vie', insert: 'activate A' }
|
||||
],
|
||||
'classDiagram': [
|
||||
{ label: 'class Nom {', detail: 'Declarer classe', insert: 'class Nom {\n \n}' },
|
||||
{ label: '+methode()', detail: 'Methode publique', insert: '+nom() Type' },
|
||||
{ label: '-attribut', detail: 'Attribut prive', insert: '-nom Type' },
|
||||
{ label: 'Heritage', detail: 'Relation heritage', insert: ' <|-- ' },
|
||||
{ label: 'Composition', detail: 'Relation composition', insert: ' *-- ' }
|
||||
],
|
||||
'gantt': [
|
||||
{ label: 'dateFormat', detail: 'Format de date', insert: 'dateFormat YYYY-MM-DD' },
|
||||
{ label: 'section', detail: 'Section Gantt', insert: 'section Nom' },
|
||||
{ label: 'tache : apres', detail: 'Tache avec dependance', insert: ' : after tache1, 3d' }
|
||||
],
|
||||
'pie': [
|
||||
{ label: 'title', detail: 'Titre du pie', insert: 'title Titre du graphique' },
|
||||
{ label: '"Label" : val', detail: 'Valeur', insert: '"Label" : 50' }
|
||||
],
|
||||
'mindmap': [
|
||||
{ label: 'root', detail: 'Racine', insert: 'root((Sujet))' },
|
||||
{ label: 'branche', detail: 'Sous-sujet', insert: '\n Sous-sujet' },
|
||||
{ label: 'leaf', detail: 'Feuille', insert: '\n Detail' }
|
||||
]
|
||||
};
|
||||
|
||||
var CODE_LANGUAGES = [
|
||||
{ label: 'python', detail: 'Python', insert: 'python' },
|
||||
{ label: 'javascript', detail: 'JavaScript', insert: 'javascript' },
|
||||
{ label: 'typescript', detail: 'TypeScript', insert: 'typescript' },
|
||||
{ label: 'bash', detail: 'Bash / Shell', insert: 'bash' },
|
||||
{ label: 'sql', detail: 'SQL', insert: 'sql' },
|
||||
{ label: 'json', detail: 'JSON', insert: 'json' },
|
||||
{ label: 'yaml', detail: 'YAML', insert: 'yaml' },
|
||||
{ label: 'html', detail: 'HTML', insert: 'html' },
|
||||
{ label: 'css', detail: 'CSS', insert: 'css' },
|
||||
{ label: 'dockerfile', detail: 'Dockerfile', insert: 'dockerfile' },
|
||||
{ label: 'mermaid', detail: 'Mermaid (diagrammes)', insert: 'mermaid' },
|
||||
{ label: 'markdown', detail: 'Markdown', insert: 'markdown' },
|
||||
{ label: 'rust', detail: 'Rust', insert: 'rust' },
|
||||
{ label: 'go', detail: 'Go', insert: 'go' },
|
||||
{ label: 'java', detail: 'Java', insert: 'java' }
|
||||
];
|
||||
|
||||
var CODE_SNIPPETS = {
|
||||
python: [
|
||||
{ label: 'def', detail: 'Fonction', insert: 'def nom(params):\n ' },
|
||||
{ label: 'class', detail: 'Classe', insert: 'class Nom:\n def __init__(self):\n ' },
|
||||
{ label: 'if/else', detail: 'Condition', insert: 'if condition:\n \nelse:\n ' },
|
||||
{ label: 'for', detail: 'Boucle for', insert: 'for item in iterable:\n ' },
|
||||
{ label: 'try/except', detail: 'Try-except', insert: 'try:\n \nexcept Exception as e:\n ' },
|
||||
{ label: 'import', detail: 'Import', insert: 'import ' },
|
||||
{ label: 'print', detail: 'Print', insert: 'print()' },
|
||||
{ label: 'lambda', detail: 'Lambda', insert: 'lambda x: ' }
|
||||
],
|
||||
javascript: [
|
||||
{ label: 'function', detail: 'Fonction', insert: 'function nom(params) {\n \n}' },
|
||||
{ label: 'const', detail: 'Constante', insert: 'const nom = ' },
|
||||
{ label: '=>', detail: 'Fleche', insert: '(params) => {\n \n}' },
|
||||
{ label: 'if/else', detail: 'Condition', insert: 'if (cond) {\n \n} else {\n \n}' },
|
||||
{ label: 'for', detail: 'Boucle for', insert: 'for (let i = 0; i < n; i++) {\n \n}' },
|
||||
{ label: 'try/catch', detail: 'Try-catch', insert: 'try {\n \n} catch (e) {\n \n}' },
|
||||
{ label: 'console.log', detail: 'Console log', insert: 'console.log()' },
|
||||
{ label: 'async/await', detail: 'Async function', insert: 'async function nom() {\n await \n}' }
|
||||
],
|
||||
bash: [
|
||||
{ label: '#!/bin/bash', detail: 'Shebang', insert: '#!/bin/bash' },
|
||||
{ label: 'if/fi', detail: 'Condition', insert: 'if [ condition ]; then\n \nfi' },
|
||||
{ label: 'for', detail: 'Boucle for', insert: 'for item in list; do\n \ndone' },
|
||||
{ label: 'case/esac', detail: 'Case', insert: 'case var in\n pattern)\n ;;\nesac' },
|
||||
{ label: 'function', detail: 'Fonction', insert: 'nom() {\n \n}' }
|
||||
],
|
||||
sql: [
|
||||
{ label: 'SELECT', detail: 'Select', insert: 'SELECT colonnes\nFROM table' },
|
||||
{ label: 'JOIN', detail: 'Join', insert: 'JOIN table2 ON table1.id = table2.id' },
|
||||
{ label: 'WHERE', detail: 'Where', insert: 'WHERE condition' },
|
||||
{ label: 'GROUP BY', detail: 'Group by', insert: 'GROUP BY colonne' },
|
||||
{ label: 'ORDER BY', detail: 'Order by', insert: 'ORDER BY colonne DESC' },
|
||||
{ label: 'INSERT INTO', detail: 'Insert', insert: 'INSERT INTO table (cols) VALUES (vals)' },
|
||||
{ label: 'CREATE TABLE', detail: 'Create table', insert: 'CREATE TABLE nom (\n id INTEGER PRIMARY KEY,\n \n)' }
|
||||
]
|
||||
};
|
||||
|
||||
var FRONTMATTER_FIELDS = [
|
||||
{ label: 'title:', detail: 'Titre du document', insert: 'title: ' },
|
||||
{ label: 'tags:', detail: 'Tags (liste)', insert: 'tags:\n - ' },
|
||||
{ label: 'date:', detail: 'Date', insert: 'date: ' + new Date().toISOString().slice(0,10) },
|
||||
{ label: 'status:', detail: 'Statut', insert: 'status: en cours' },
|
||||
{ label: 'category:', detail: 'Categorie', insert: 'category: ' },
|
||||
{ label: 'publish:', detail: 'Publier (true/false)', insert: 'publish: true' },
|
||||
{ label: 'favoris:', detail: 'Favoris (true/false)', insert: 'favoris: true' },
|
||||
{ label: 'author:', detail: 'Auteur', insert: 'author: ' },
|
||||
{ label: 'description:', detail: 'Description', insert: 'description: ' }
|
||||
];
|
||||
|
||||
var MARKDOWN_FORMAT = [
|
||||
{ label: '# H1', detail: 'Titre niveau 1', insert: '# ' },
|
||||
{ label: '## H2', detail: 'Titre niveau 2', insert: '## ' },
|
||||
{ label: '### H3', detail: 'Titre niveau 3', insert: '### ' },
|
||||
{ label: '**gras**', detail: 'Texte en gras', insert: '****' },
|
||||
{ label: '*italique*', detail: 'Texte en italique', insert: '**' },
|
||||
{ label: '`code`', detail: 'Code inline', insert: '``' },
|
||||
{ label: '[lien](url)', detail: 'Lien', insert: '[]()' },
|
||||
{ label: '', detail: 'Image', insert: '![]()' },
|
||||
{ label: '- liste', detail: 'Liste a puces', insert: '- ' },
|
||||
{ label: '1. liste', detail: 'Liste numerotee', insert: '1. ' },
|
||||
{ label: '> citation', detail: 'Citation', insert: '> ' },
|
||||
{ label: '---', detail: 'Separateur horizontal', insert: '\n---\n' },
|
||||
{ label: '> [!note]', detail: 'Callout Note', insert: '> [!note] Titre\n> Contenu' },
|
||||
{ label: '> [!warning]', detail: 'Callout Warning', insert: '> [!warning] Titre\n> Contenu' },
|
||||
{ label: '> [!tip]', detail: 'Callout Tip', insert: '> [!tip] Titre\n> Contenu' },
|
||||
{ label: '> [!danger]', detail: 'Callout Danger', insert: '> [!danger] Titre\n> Contenu' }
|
||||
];
|
||||
|
||||
// ── Context Detection ─────────────────────────────────────────────────
|
||||
function detectContext(text, cursorPos) {
|
||||
var before = text.slice(0, cursorPos);
|
||||
var after = text.slice(cursorPos);
|
||||
var lines = before.split('\n');
|
||||
var currentLine = lines[lines.length - 1] || '';
|
||||
var lineNum = lines.length;
|
||||
|
||||
// Check for code fence context (look backwards for unclosed ```)
|
||||
var inFence = null;
|
||||
var fenceLang = '';
|
||||
var fenceStart = 0;
|
||||
for (var i = lines.length - 1; i >= 0; i--) {
|
||||
var l = lines[i].trim();
|
||||
var m = l.match(/^```(\w*)/);
|
||||
if (m) {
|
||||
if (inFence === null) {
|
||||
inFence = true;
|
||||
fenceLang = m[1] || '';
|
||||
fenceStart = i + 1;
|
||||
} else {
|
||||
inFence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
if (fenceLang === 'mermaid') return { type: 'mermaid', lang: '', line: currentLine };
|
||||
return { type: 'code', lang: fenceLang, line: currentLine };
|
||||
}
|
||||
|
||||
// Check for frontmatter context (between first and second ---)
|
||||
if (lineNum >= 2) {
|
||||
var firstSep = -1, secondSep = -1;
|
||||
for (var j = 0; j < lines.length; j++) {
|
||||
if (lines[j].trim() === '---') {
|
||||
if (firstSep === -1) firstSep = j;
|
||||
else if (secondSep === -1) { secondSep = j; break; }
|
||||
}
|
||||
}
|
||||
if (firstSep === 0 && secondSep === -1) {
|
||||
return { type: 'frontmatter', line: currentLine };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for wikilink
|
||||
if (/\[\[$/.test(currentLine) || /\[\[[^\]]*$/.test(currentLine)) {
|
||||
var partial = (currentLine.match(/\[\[([^\]]*)$/) || ['',''])[1];
|
||||
return { type: 'wikilink', partial: partial, line: currentLine };
|
||||
}
|
||||
|
||||
// Check for markdown link
|
||||
if (/\[.*\]\($/.test(currentLine)) {
|
||||
return { type: 'link', line: currentLine };
|
||||
}
|
||||
|
||||
// Check for triple backtick (about to open fence)
|
||||
if (/```$/.test(before) || /^```\s*$/.test(currentLine)) {
|
||||
return { type: 'fence_open', line: currentLine };
|
||||
}
|
||||
|
||||
// Check if inside a markdown table
|
||||
if (/^\|.*\|$/.test(currentLine.trim())) {
|
||||
return { type: 'table', line: currentLine };
|
||||
}
|
||||
|
||||
// Plain text / markdown
|
||||
return { type: 'markdown', line: currentLine };
|
||||
}
|
||||
|
||||
// ── Suggestion Engine ─────────────────────────────────────────────────
|
||||
function suggest(text, cursorPos) {
|
||||
var ctx = detectContext(text, cursorPos);
|
||||
var items = [];
|
||||
var prefix = '';
|
||||
|
||||
var currentLine = ctx.line;
|
||||
|
||||
switch (ctx.type) {
|
||||
case 'mermaid':
|
||||
// Try to match mermaid type already declared
|
||||
var mermaidMatch = text.match(/```mermaid\s*\n(\w+)/);
|
||||
if (mermaidMatch) {
|
||||
var mmType = mermaidMatch[1];
|
||||
items = MERMAID_SYNTAX[mmType] || [];
|
||||
}
|
||||
if (items.length === 0) {
|
||||
items = MERMAID_TYPES;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
items = CODE_SNIPPETS[ctx.lang] || [];
|
||||
break;
|
||||
|
||||
case 'fence_open':
|
||||
items = CODE_LANGUAGES;
|
||||
break;
|
||||
|
||||
case 'frontmatter':
|
||||
prefix = currentLine.trim();
|
||||
items = FRONTMATTER_FIELDS.filter(function(f) {
|
||||
return f.label.startsWith(prefix) || prefix === '';
|
||||
}).map(function(f) {
|
||||
return { label: f.label, detail: f.detail, insert: prefix ? f.insert : f.insert };
|
||||
});
|
||||
return {
|
||||
context: ctx.type,
|
||||
items: items.length > 0 ? items : FRONTMATTER_FIELDS.map(function(f) {
|
||||
return { label: f.label, detail: f.detail, insert: f.insert };
|
||||
})
|
||||
};
|
||||
|
||||
case 'wikilink':
|
||||
return {
|
||||
context: 'wikilink',
|
||||
items: [],
|
||||
partial: ctx.partial,
|
||||
async: true // requires file index lookup
|
||||
};
|
||||
|
||||
case 'markdown':
|
||||
default:
|
||||
// At start of line: markdown formatting
|
||||
if (currentLine.trim().length < 3) {
|
||||
items = MARKDOWN_FORMAT;
|
||||
} else {
|
||||
items = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var filtered = items.filter(function(item) {
|
||||
return !prefix || item.label.toLowerCase().indexOf(prefix.toLowerCase()) !== -1;
|
||||
});
|
||||
|
||||
return {
|
||||
context: ctx.type,
|
||||
items: filtered.length > 0 ? filtered : items
|
||||
};
|
||||
}
|
||||
|
||||
// ── Wikilink async completion (fetches file list from server) ─────────
|
||||
var _fileCache = null;
|
||||
var _fileCacheTime = 0;
|
||||
|
||||
function searchWikilinks(partial, callback) {
|
||||
var now = Date.now();
|
||||
// Use cached file list if fresh (< 30s)
|
||||
if (_fileCache && (now - _fileCacheTime) < 30000) {
|
||||
callback(filterFiles(partial));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fetch('/api/search/advanced?q=**&vault=all&limit=500')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
_fileCache = (data.results || []).map(function(r) {
|
||||
return {
|
||||
path: r.path,
|
||||
title: r.title || r.path.replace(/\.md$/i, ''),
|
||||
vault: r.vault,
|
||||
name: r.path.split('/').pop().replace(/\.md$/i, '')
|
||||
};
|
||||
});
|
||||
_fileCacheTime = now;
|
||||
callback(filterFiles(partial));
|
||||
})
|
||||
.catch(function() { callback([]); });
|
||||
} catch(e) { callback([]); }
|
||||
}
|
||||
|
||||
function filterFiles(partial) {
|
||||
if (!_fileCache) return [];
|
||||
var q = (partial || '').toLowerCase();
|
||||
return _fileCache.filter(function(f) {
|
||||
return f.name.toLowerCase().indexOf(q) !== -1 || f.path.toLowerCase().indexOf(q) !== -1;
|
||||
}).slice(0, 20).map(function(f) {
|
||||
return {
|
||||
label: f.name,
|
||||
detail: f.path,
|
||||
insert: f.path + ']]',
|
||||
vault: f.vault
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT };
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Backup Manager module for ObsiGate.
|
||||
*
|
||||
* Full-page view to browse, preview, restore, and delete backups across vaults.
|
||||
* Accessible from the backup/diff viewer footer.
|
||||
*/
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
var state = { backups: [], filter: '', preview: null };
|
||||
|
||||
export function openBackupManager() {
|
||||
var area = document.getElementById('content-area');
|
||||
if (!area) return;
|
||||
area.innerHTML = '';
|
||||
|
||||
var container = document.createElement('div');
|
||||
container.className = 'backup-manager';
|
||||
container.id = 'backup-manager';
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="backup-manager-header">' +
|
||||
'<h2><i data-lucide="archive" style="width:20px;height:20px"></i> Gestion des backups</h2>' +
|
||||
'<div class="backup-manager-actions">' +
|
||||
'<input type="text" id="bm-filter" class="backup-manager-filter" placeholder="Filtrer par fichier...">' +
|
||||
'<button class="backup-manager-btn" id="bm-refresh" title="Rafraichir"><i data-lucide="refresh-cw" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="backup-manager-stats" id="bm-stats"></div>' +
|
||||
'<div class="backup-manager-body">' +
|
||||
'<div class="backup-manager-list" id="bm-list"></div>' +
|
||||
'<div class="backup-manager-preview" id="bm-preview" style="display:none">' +
|
||||
'<div class="bm-preview-header">' +
|
||||
'<h3 id="bm-preview-title">Apercu</h3>' +
|
||||
'<button class="backup-manager-btn" id="bm-preview-close" title="Fermer"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'<pre class="bm-preview-content" id="bm-preview-content"></pre>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
area.appendChild(container);
|
||||
safeCreateIcons();
|
||||
|
||||
document.getElementById('bm-filter').addEventListener('input', function() {
|
||||
state.filter = this.value.toLowerCase();
|
||||
render();
|
||||
});
|
||||
document.getElementById('bm-refresh').addEventListener('click', load);
|
||||
document.getElementById('bm-preview-close').addEventListener('click', function() {
|
||||
document.getElementById('bm-preview').style.display = 'none';
|
||||
});
|
||||
|
||||
state.preview = null;
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var list = document.getElementById('bm-list');
|
||||
list.innerHTML = '<div class="tree-loading" style="padding:2rem;text-align:center"><div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div></div>';
|
||||
|
||||
try {
|
||||
var data = await api('/api/backups');
|
||||
state.backups = data.backups || [];
|
||||
updateStats(data);
|
||||
render();
|
||||
} catch (err) {
|
||||
list.innerHTML = '<div class="backup-manager-error">Erreur: ' + escapeHtml(err.message || 'Inconnue') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
var stats = document.getElementById('bm-stats');
|
||||
if (!stats) return;
|
||||
var totalSize = formatSize(data.total_size_bytes || 0);
|
||||
stats.innerHTML =
|
||||
'<span class="bm-stat"><strong>' + data.total + '</strong> backups</span>' +
|
||||
'<span class="bm-stat"><strong>' + totalSize + '</strong> total</span>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var list = document.getElementById('bm-list');
|
||||
if (!list) return;
|
||||
|
||||
var filtered = state.backups;
|
||||
if (state.filter) {
|
||||
filtered = state.backups.filter(function(b) {
|
||||
return b.file.toLowerCase().indexOf(state.filter) !== -1 || b.vault.toLowerCase().indexOf(state.filter) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = '<div class="backup-manager-empty">Aucun backup trouve.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var grouped = {};
|
||||
filtered.forEach(function(b) {
|
||||
if (!grouped[b.vault]) grouped[b.vault] = [];
|
||||
grouped[b.vault].push(b);
|
||||
});
|
||||
|
||||
var html = '';
|
||||
var vaultNames = Object.keys(grouped).sort();
|
||||
vaultNames.forEach(function(vault) {
|
||||
var vaultBackups = grouped[vault];
|
||||
var vaultSize = vaultBackups.reduce(function(s, b) { return s + b.size; }, 0);
|
||||
|
||||
html += '<div class="bm-vault-group">';
|
||||
html += '<div class="bm-vault-header">';
|
||||
html += '<h3 class="bm-vault-name"><i data-lucide="database" style="width:14px;height:14px"></i> ' + escapeHtml(vault) + '</h3>';
|
||||
html += '<span class="bm-vault-meta">' + vaultBackups.length + ' backups · ' + formatSize(vaultSize) + '</span>';
|
||||
html += '<button class="bm-vault-purge" data-vault="' + escapeHtml(vault) + '" title="Supprimer tous les backups de ce vault"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Vider</button>';
|
||||
html += '</div>';
|
||||
|
||||
var byFile = {};
|
||||
vaultBackups.forEach(function(b) {
|
||||
if (!byFile[b.file]) byFile[b.file] = [];
|
||||
byFile[b.file].push(b);
|
||||
});
|
||||
|
||||
var fileNames = Object.keys(byFile).sort();
|
||||
fileNames.forEach(function(file) {
|
||||
var backups = byFile[file];
|
||||
backups.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
var totalFileSize = backups.reduce(function(s, b) { return s + b.size; }, 0);
|
||||
|
||||
html += '<div class="bm-file-group">';
|
||||
html += '<div class="bm-file-header">';
|
||||
html += '<span class="bm-file-name"><i data-lucide="file-text" style="width:14px;height:14px"></i> ' + escapeHtml(file) + '</span>';
|
||||
html += '<span class="bm-file-meta">' + backups.length + ' versions · ' + formatSize(totalFileSize) + '</span>';
|
||||
html += '<button class="bm-file-purge" data-vault="' + escapeHtml(vault) + '" data-file="' + escapeHtml(file) + '" title="Supprimer tous les backups de ce fichier"><i data-lucide="trash-2" style="width:12px;height:12px"></i></button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="bm-backup-list">';
|
||||
backups.forEach(function(b) {
|
||||
var d = new Date(b.timestamp * 1000);
|
||||
var ds = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
html += '<div class="bm-backup-item' + (state.preview === b.full_path ? ' active' : '') + '" data-path="' + escapeHtml(b.full_path) + '" data-vault="' + escapeHtml(b.vault) + '" data-file="' + escapeHtml(b.file) + '">';
|
||||
html += '<span class="bm-backup-date">' + ds + '</span>';
|
||||
html += '<span class="bm-backup-size">' + formatSize(b.size) + '</span>';
|
||||
html += '<div class="bm-backup-actions">';
|
||||
html += '<button class="bm-backup-preview" title="Apercu"><i data-lucide="eye" style="width:12px;height:12px"></i></button>';
|
||||
html += '<button class="bm-backup-restore" title="Restaurer cette version"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>';
|
||||
html += '<button class="bm-backup-delete" data-path="' + escapeHtml(b.full_path) + '" title="Supprimer"><i data-lucide="x" style="width:12px;height:12px"></i></button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
safeCreateIcons();
|
||||
|
||||
// Bind vault purge
|
||||
list.querySelectorAll('.bm-vault-purge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer TOUS les backups du vault "' + btn.dataset.vault + '" ?')) {
|
||||
purgeVault(btn.dataset.vault);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind file purge
|
||||
list.querySelectorAll('.bm-file-purge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer TOUS les backups de "' + btn.dataset.file + '" ?')) {
|
||||
purgeFile(btn.dataset.vault, btn.dataset.file);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind backup item clicks (preview) and action buttons
|
||||
list.querySelectorAll('.bm-backup-item').forEach(function(item) {
|
||||
item.addEventListener('click', function(e) {
|
||||
// Don't trigger preview if clicking an action button
|
||||
if (e.target.closest('button')) return;
|
||||
state.preview = item.dataset.path;
|
||||
render();
|
||||
previewBackup(item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind action buttons
|
||||
list.querySelectorAll('.bm-backup-preview').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = btn.closest('.bm-backup-item');
|
||||
state.preview = item.dataset.path;
|
||||
render();
|
||||
previewBackup(item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.bm-backup-restore').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = btn.closest('.bm-backup-item');
|
||||
restoreBackup(item.dataset.vault, item.dataset.file);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.bm-backup-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer ce backup ?')) {
|
||||
deleteBackups([btn.dataset.path]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function previewBackup(fpath) {
|
||||
var panel = document.getElementById('bm-preview');
|
||||
var content = document.getElementById('bm-preview-content');
|
||||
var title = document.getElementById('bm-preview-title');
|
||||
panel.style.display = 'block';
|
||||
content.textContent = t("common.loading");
|
||||
|
||||
try {
|
||||
var data = await api('/api/backups/content?path=' + encodeURIComponent(fpath));
|
||||
content.textContent = data.content || '(fichier vide)';
|
||||
title.textContent = 'Apercu - ' + (data.name || '');
|
||||
} catch (err) {
|
||||
content.textContent = 'Erreur: ' + (err.message || 'Inconnue');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(vault, file) {
|
||||
// Find the backup timestamp from the currently selected backup
|
||||
var item = document.querySelector('.bm-backup-item.active');
|
||||
if (!item) { showToast(t('backup.select_to_restore'), 'error'); return; }
|
||||
|
||||
var backupPath = item.dataset.path;
|
||||
// Extract timestamp from the filename
|
||||
var parts = backupPath.replace(/\\/g, '/').split('/').pop().split('.');
|
||||
// The timestamp is the second-to-last component before .bak
|
||||
var ts = null;
|
||||
for (var i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'bak' && i > 0 && /^\d+$/.test(parts[i-1])) {
|
||||
ts = parseInt(parts[i-1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ts) { showToast(t('backup.timestamp_not_found'), 'error'); return; }
|
||||
|
||||
if (!confirm('Restaurer "' + file + '" a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.')) return;
|
||||
|
||||
try {
|
||||
var resp = await api('/api/file/' + encodeURIComponent(vault) + '/restore?path=' + encodeURIComponent(file), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: ts }),
|
||||
});
|
||||
if (resp && resp.success) {
|
||||
showToast(t('backup.file_restored'), 'success');
|
||||
load();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + (err.message || 'Inconnue'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackups(paths) {
|
||||
try {
|
||||
var resp = await api('/api/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: paths }),
|
||||
});
|
||||
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeFile(vault, file) {
|
||||
try {
|
||||
var resp = await api('/api/backups/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault: vault, file: file }),
|
||||
});
|
||||
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeVault(vault) {
|
||||
try {
|
||||
var resp = await api('/api/backups/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault: vault }),
|
||||
});
|
||||
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' o';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
+289
-180
@@ -23,20 +23,18 @@ const state = {
|
||||
path: null,
|
||||
backups: [],
|
||||
overlay: null,
|
||||
leftVersion: null, // timestamp of backup shown on left
|
||||
rightVersion: null, // timestamp of backup shown on right, or null for current
|
||||
leftVersion: null,
|
||||
rightVersion: null,
|
||||
diffText: '',
|
||||
diffLeft: '',
|
||||
diffRight: '',
|
||||
diffMode: 'unified',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open the backup/diff viewer for a file.
|
||||
* @param {string} vault - Vault name
|
||||
* @param {string} path - Relative file path
|
||||
*/
|
||||
export async function openBackupViewer(vault, path) {
|
||||
state.vault = vault;
|
||||
state.path = path;
|
||||
@@ -44,23 +42,23 @@ export async function openBackupViewer(vault, path) {
|
||||
state.leftVersion = null;
|
||||
state.rightVersion = null;
|
||||
state.diffText = '';
|
||||
state.diffLeft = '';
|
||||
state.diffRight = '';
|
||||
|
||||
// Create and show overlay
|
||||
const overlay = createOverlay();
|
||||
document.body.appendChild(overlay);
|
||||
state.overlay = overlay;
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
requestAnimationFrame(function() { overlay.classList.add('active'); });
|
||||
|
||||
// Show loading state
|
||||
setBody(renderLoading());
|
||||
|
||||
// Fetch backups
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`);
|
||||
var url = '/api/file/' + encodeURIComponent(vault) + '/backups?path=' + encodeURIComponent(path);
|
||||
var data = await api(url);
|
||||
state.backups = data.backups || [];
|
||||
} catch (err) {
|
||||
const msg = err?.detail || err?.message || 'Erreur inconnue';
|
||||
setBody(renderError(`Erreur API: ${msg}`));
|
||||
var msg = err && err.detail || err && err.message || 'Erreur inconnue';
|
||||
setBody(renderError('Erreur API: ' + msg));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,14 +67,9 @@ export async function openBackupViewer(vault, path) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-select: left = most recent, right = current
|
||||
state.leftVersion = state.backups[0].timestamp;
|
||||
state.rightVersion = null;
|
||||
|
||||
// Render version selectors
|
||||
renderMainView();
|
||||
|
||||
// Load initial diff
|
||||
await loadDiff();
|
||||
}
|
||||
|
||||
@@ -84,147 +77,147 @@ export async function openBackupViewer(vault, path) {
|
||||
// DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _escHandler = null;
|
||||
|
||||
function createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'obsigate-modal-overlay backup-viewer-overlay';
|
||||
overlay.addEventListener('click', (e) => {
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
const panel = document.createElement('div');
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'backup-viewer-panel';
|
||||
panel.id = 'backup-viewer-panel';
|
||||
overlay.appendChild(panel);
|
||||
|
||||
// Event delegation fallback for close button (in case inline onclick fails)
|
||||
panel.addEventListener('click', (e) => {
|
||||
// e.target might be a text node (e.g. "×") — use parentElement as fallback
|
||||
const target = e.target.nodeType === Node.TEXT_NODE ? e.target.parentElement : e.target;
|
||||
const closeBtn = target && target.closest ? target.closest('.backup-close-btn') : null;
|
||||
if (closeBtn) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
// Close button -- static, never replaced by innerHTML
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'editor-btn backup-close-btn';
|
||||
closeBtn.id = 'backup-close';
|
||||
closeBtn.title = 'Fermer';
|
||||
closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;z-index:1002;flex-shrink:0';
|
||||
closeBtn.innerHTML = '<i data-lucide="x" style="width:15px;height:15px"></i>';
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
overlay.appendChild(closeBtn);
|
||||
|
||||
// Escape key
|
||||
_escHandler = function(e) {
|
||||
if (e.key === 'Escape' && state.overlay) close();
|
||||
};
|
||||
document.addEventListener('keydown', _escHandler);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setBody(html) {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
var panel = document.getElementById('backup-viewer-panel');
|
||||
if (panel) panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.overlay) {
|
||||
state.overlay.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
if (state.overlay) state.overlay.remove();
|
||||
state.overlay = null;
|
||||
}, 200);
|
||||
if (!state.overlay) return;
|
||||
if (_escHandler) {
|
||||
document.removeEventListener('keydown', _escHandler);
|
||||
_escHandler = null;
|
||||
}
|
||||
state.overlay.classList.remove('active');
|
||||
setTimeout(function() {
|
||||
if (state.overlay) state.overlay.remove();
|
||||
state.overlay = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Expose globally for inline onclick handlers (bypasses text-node target issues)
|
||||
window._backupViewerClose = close;
|
||||
// Global click handler for close button
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest ? e.target.closest('#backup-close') : null;
|
||||
if (btn && state.overlay) close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderLoading() {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer" onclick="window._backupViewerClose()">×</button>
|
||||
</div>
|
||||
<div class="backup-body">
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des backups...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
return '<div class="backup-header"><h3>Backups</h3></div>' +
|
||||
'<div class="backup-body">' +
|
||||
'<div class="tree-loading" style="padding:2rem;text-align:center">' +
|
||||
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||
'<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des backups...</p>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>${escapeHtml(msg)}</p>
|
||||
</div>`;
|
||||
return '<div class="backup-header"><h3>Backups</h3></div>' +
|
||||
'<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">' +
|
||||
'<p>' + escapeHtml(msg) + '</p></div>';
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return \`
|
||||
<div class="backup-header">
|
||||
<h3>Backups — \${escapeHtml(state.path)}</h3>
|
||||
<button class="backup-close-btn" title="Fermer" onclick="window._backupViewerClose()">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>Aucun backup disponible pour ce fichier.</p>
|
||||
<p style="font-size:0.8rem;margin-top:0.5rem">Les backups sont créés automatiquement avant chaque modification.</p>
|
||||
</div>`;
|
||||
return '<div class="backup-header"><h3>Backups - ' + escapeHtml(state.path) + '</h3></div>' +
|
||||
'<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">' +
|
||||
'<p>Aucun backup disponible pour ce fichier.</p>' +
|
||||
'<p style="font-size:0.8rem;margin-top:0.5rem">Les backups sont crees automatiquement avant chaque modification.</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderMainView() {
|
||||
const b = state.backups;
|
||||
const leftOpts = b.map(bk => {
|
||||
const sel = bk.timestamp === state.leftVersion ? 'selected' : '';
|
||||
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
|
||||
var b = state.backups;
|
||||
var leftOpts = b.map(function(bk) {
|
||||
var sel = bk.timestamp === state.leftVersion ? ' selected' : '';
|
||||
return '<option value="' + bk.timestamp + '"' + sel + '>' + formatTimestamp(bk.timestamp) + ' (' + formatSize(bk.size) + ')</option>';
|
||||
}).join('');
|
||||
|
||||
const rightOpts = [
|
||||
`<option value="current" ${state.rightVersion === null ? 'selected' : ''}>Version actuelle</option>`,
|
||||
...b.map(bk => {
|
||||
const sel = bk.timestamp === state.rightVersion ? 'selected' : '';
|
||||
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
|
||||
}),
|
||||
].join('');
|
||||
var rightOpts = ['<option value="current"' + (state.rightVersion === null ? ' selected' : '') + '>Version actuelle</option>']
|
||||
.concat(b.map(function(bk) {
|
||||
var sel = bk.timestamp === state.rightVersion ? ' selected' : '';
|
||||
return '<option value="' + bk.timestamp + '"' + sel + '>' + formatTimestamp(bk.timestamp) + ' (' + formatSize(bk.size) + ')</option>';
|
||||
})).join('');
|
||||
|
||||
const html = `
|
||||
<div class="backup-header">
|
||||
<div class="backup-header-left">
|
||||
<h3>Backups</h3>
|
||||
<span class="backup-file-path">${escapeHtml(state.path)}</span>
|
||||
</div>
|
||||
<div class="backup-header-right">
|
||||
<span class="backup-count">${b.length} backup${b.length > 1 ? 's' : ''}</span>
|
||||
<button class="backup-close-btn" title="Fermer" onclick="window._backupViewerClose()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
var html =
|
||||
'<div class="backup-header">' +
|
||||
'<div class="backup-header-left">' +
|
||||
'<h3>'+t('backups.title')+'</h3>' +
|
||||
'<span class="backup-file-path">' + escapeHtml(state.path) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="backup-header-right">' +
|
||||
'<span class="backup-count">' + b.length + ' backup' + (b.length > 1 ? 's' : '') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
<div class="backup-toolbar">
|
||||
<div class="backup-selector-group">
|
||||
<label>Comparer :</label>
|
||||
<select class="backup-select" id="backup-left-select">${leftOpts}</select>
|
||||
</div>
|
||||
<span class="backup-vs-label">vs</span>
|
||||
<div class="backup-selector-group">
|
||||
<select class="backup-select" id="backup-right-select">${rightOpts}</select>
|
||||
</div>
|
||||
<button class="backup-restore-btn" id="backup-restore-btn" title="Restaurer la version de gauche">
|
||||
<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer
|
||||
</button>
|
||||
</div>
|
||||
'<div class="backup-toolbar">' +
|
||||
'<div class="backup-selector-group">' +
|
||||
'<label>Comparer :</label>' +
|
||||
'<select class="backup-select" id="backup-left-select">' + leftOpts + '</select>' +
|
||||
'</div>' +
|
||||
'<span class="backup-vs-label">vs</span>' +
|
||||
'<div class="backup-selector-group">' +
|
||||
'<select class="backup-select" id="backup-right-select">' + rightOpts + '</select>' +
|
||||
'</div>' +
|
||||
'<button class="backup-restore-btn" id="backup-restore-btn" title="Restaurer la version de gauche">' +
|
||||
'<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer' +
|
||||
'</button>' +
|
||||
'<button class="backup-diff-mode-btn" id="backup-diff-mode-btn" title="Vue cote a cote">' +
|
||||
'<i data-lucide="columns-2" class="icon-sm"></i> Cote-a-cote' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
|
||||
<div class="backup-diff-container" id="backup-diff-container">
|
||||
<div class="backup-diff-loading">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
'<div class="backup-diff-container" id="backup-diff-container">' +
|
||||
'<div class="backup-diff-loading">' +
|
||||
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
<div class="backup-footer">
|
||||
<span class="backup-footer-hint">Backups automatiques avant chaque modification</span>
|
||||
</div>`;
|
||||
'<div class="backup-footer">' +
|
||||
'<span class="backup-footer-hint">Backups crees lors de la sauvegarde manuelle (Ctrl+S)</span>' +
|
||||
'<button class="backup-footer-manage-btn" id="backup-manage-btn">Gerer tous les backups</button>' +
|
||||
'</div>';
|
||||
|
||||
setBody(html);
|
||||
|
||||
// Bind events
|
||||
bindEvents();
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,29 +225,59 @@ function renderMainView() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindEvents() {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
if (!panel) return;
|
||||
var leftSelect = document.getElementById('backup-left-select');
|
||||
if (leftSelect) {
|
||||
leftSelect.addEventListener('change', async function() {
|
||||
state.leftVersion = parseInt(leftSelect.value);
|
||||
await loadDiff();
|
||||
});
|
||||
}
|
||||
|
||||
// Left select
|
||||
const leftSelect = document.getElementById('backup-left-select');
|
||||
leftSelect?.addEventListener('change', async () => {
|
||||
state.leftVersion = parseInt(leftSelect.value);
|
||||
await loadDiff();
|
||||
});
|
||||
var rightSelect = document.getElementById('backup-right-select');
|
||||
if (rightSelect) {
|
||||
rightSelect.addEventListener('change', async function() {
|
||||
var val = rightSelect.value;
|
||||
state.rightVersion = val === 'current' ? null : parseInt(val);
|
||||
await loadDiff();
|
||||
});
|
||||
}
|
||||
|
||||
// Right select
|
||||
const rightSelect = document.getElementById('backup-right-select');
|
||||
rightSelect?.addEventListener('change', async () => {
|
||||
const val = rightSelect.value;
|
||||
state.rightVersion = val === 'current' ? null : parseInt(val);
|
||||
await loadDiff();
|
||||
});
|
||||
var restoreBtn = document.getElementById('backup-restore-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.addEventListener('click', async function() {
|
||||
await restoreBackup();
|
||||
});
|
||||
}
|
||||
|
||||
// Restore button
|
||||
const restoreBtn = document.getElementById('backup-restore-btn');
|
||||
restoreBtn?.addEventListener('click', async () => {
|
||||
await restoreBackup();
|
||||
});
|
||||
var modeBtn = document.getElementById('backup-diff-mode-btn');
|
||||
var manageBtn = document.getElementById('backup-manage-btn');
|
||||
if (manageBtn) {
|
||||
manageBtn.addEventListener('click', function() {
|
||||
close();
|
||||
import('./backup-manager.js').then(function(m) { m.openBackupManager(); });
|
||||
});
|
||||
}
|
||||
|
||||
if (modeBtn) {
|
||||
modeBtn.addEventListener('click', function() {
|
||||
state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside';
|
||||
var container = document.getElementById('backup-diff-container');
|
||||
if (container) {
|
||||
container.innerHTML = state.diffMode === 'sidebyside'
|
||||
? renderDiffSideBySide(state.diffLeft, state.diffRight)
|
||||
: renderDiff(state.diffText);
|
||||
}
|
||||
var icon = modeBtn.querySelector('i');
|
||||
if (icon) icon.setAttribute('data-lucide', state.diffMode === 'sidebyside' ? 'columns-2' : 'align-justify');
|
||||
var textNodes = [];
|
||||
for (var i = 0; i < modeBtn.childNodes.length; i++) {
|
||||
if (modeBtn.childNodes[i].nodeType === 3) textNodes.push(modeBtn.childNodes[i]);
|
||||
}
|
||||
textNodes.forEach(function(n) { n.remove(); });
|
||||
modeBtn.appendChild(document.createTextNode(state.diffMode === 'sidebyside' ? ' Unifiee' : ' Cote-a-cote'));
|
||||
safeCreateIcons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -262,46 +285,50 @@ function bindEvents() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadDiff() {
|
||||
const container = document.getElementById('backup-diff-container');
|
||||
var container = document.getElementById('backup-diff-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="backup-diff-loading">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
</div>`;
|
||||
container.innerHTML =
|
||||
'<div class="backup-diff-loading">' +
|
||||
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||
'</div>';
|
||||
|
||||
let url = `/api/file/${encodeURIComponent(state.vault)}/diff?path=${encodeURIComponent(state.path)}&version=${state.leftVersion}`;
|
||||
var url = '/api/file/' + encodeURIComponent(state.vault) + '/diff?path=' + encodeURIComponent(state.path) + '&version=' + state.leftVersion;
|
||||
if (state.rightVersion !== null) {
|
||||
url += `&compare_with=${state.rightVersion}`;
|
||||
url += '&compare_with=' + state.rightVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(url);
|
||||
var data = await api(url);
|
||||
state.diffText = data.diff || '';
|
||||
container.innerHTML = renderDiff(state.diffText);
|
||||
state.diffLeft = data.left_content || '';
|
||||
state.diffRight = data.right_content || '';
|
||||
container.innerHTML = state.diffMode === 'sidebyside'
|
||||
? renderDiffSideBySide(state.diffLeft, state.diffRight)
|
||||
: renderDiff(state.diffText);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="backup-diff-error">Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}</div>`;
|
||||
container.innerHTML = '<div class="backup-diff-error">Erreur: ' + escapeHtml((err && err.detail) || (err && err.message) || 'Unknown') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!state.leftVersion) return;
|
||||
|
||||
const confirmed = confirm(
|
||||
`Restaurer "${state.path}" à la version du ${formatTimestamp(state.leftVersion)} ?\n\n` +
|
||||
`La version actuelle sera sauvegardée avant la restauration.`
|
||||
var confirmed = confirm(
|
||||
'Restaurer "' + state.path + '" a la version du ' + formatTimestamp(state.leftVersion) + ' ?\n\n' +
|
||||
'La version actuelle sera sauvegardee avant la restauration.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const restoreBtn = document.getElementById('backup-restore-btn');
|
||||
var restoreBtn = document.getElementById('backup-restore-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restauration...';
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api(
|
||||
`/api/file/${encodeURIComponent(state.vault)}/restore?path=${encodeURIComponent(state.path)}`,
|
||||
var resp = await api(
|
||||
'/api/file/' + encodeURIComponent(state.vault) + '/restore?path=' + encodeURIComponent(state.path),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -310,19 +337,17 @@ async function restoreBackup() {
|
||||
);
|
||||
|
||||
if (resp && resp.success) {
|
||||
showToast('Fichier restauré avec succès', 'success');
|
||||
// Refresh sidebar and close
|
||||
showToast(t('backup.file_restored'), 'success');
|
||||
await refreshSidebarTreePreservingState();
|
||||
close();
|
||||
// Reload current file if open
|
||||
if (typeof TabManager !== 'undefined' && TabManager.reloadCurrent) {
|
||||
TabManager.reloadCurrent();
|
||||
}
|
||||
} else {
|
||||
showToast('Échec de la restauration', 'error');
|
||||
showToast(t('backup.restore_failed'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Erreur: ${err?.detail || err?.message || 'Unknown'}`, 'error');
|
||||
showToast('Erreur: ' + ((err && err.detail) || (err && err.message) || 'Unknown'), 'error');
|
||||
} finally {
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = false;
|
||||
@@ -338,45 +363,129 @@ async function restoreBackup() {
|
||||
|
||||
function renderDiff(diffText) {
|
||||
if (!diffText) {
|
||||
return '<div class="backup-diff-empty">Aucune différence — les versions sont identiques.</div>';
|
||||
return '<div class="backup-diff-empty">Aucune difference - les versions sont identiques.</div>';
|
||||
}
|
||||
|
||||
let html = '<div class="backup-diff-output"><table class="backup-diff-table">';
|
||||
const lines = diffText.split('\n');
|
||||
var html = '<div class="backup-diff-output"><table class="backup-diff-table">';
|
||||
var lines = diffText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
let cssClass = 'diff-context';
|
||||
let prefix = ' ';
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
var cssClass = 'diff-context';
|
||||
var prefix = ' ';
|
||||
if (line.indexOf('@@') === 0) { cssClass = 'diff-hunk'; prefix = ''; }
|
||||
else if (line.indexOf('+') === 0) { cssClass = 'diff-add'; prefix = '+'; }
|
||||
else if (line.indexOf('-') === 0) { cssClass = 'diff-del'; prefix = '-'; }
|
||||
else if (line.indexOf('---') === 0 || line.indexOf('+++') === 0) { cssClass = 'diff-header-line'; prefix = ''; }
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
cssClass = 'diff-hunk';
|
||||
prefix = '';
|
||||
} else if (line.startsWith('+')) {
|
||||
cssClass = 'diff-add';
|
||||
prefix = '+';
|
||||
} else if (line.startsWith('-')) {
|
||||
cssClass = 'diff-del';
|
||||
prefix = '-';
|
||||
} else if (line.startsWith('---') || line.startsWith('+++')) {
|
||||
cssClass = 'diff-header-line';
|
||||
prefix = '';
|
||||
}
|
||||
|
||||
html += `<tr class="${cssClass}"><td class="diff-prefix">${prefix}</td><td class="diff-content"><pre>${escapeHtml(line)}</pre></td></tr>`;
|
||||
html += '<tr class="' + cssClass + '"><td class="diff-prefix">' + prefix + '</td><td class="diff-content"><pre>' + escapeHtml(line) + '</pre></td></tr>';
|
||||
}
|
||||
|
||||
html += '</table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-by-side diff rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderDiffSideBySide(leftText, rightText) {
|
||||
if (!leftText && !rightText) {
|
||||
return '<div class="backup-diff-empty">Aucune difference - les versions sont identiques.</div>';
|
||||
}
|
||||
|
||||
var leftLines = (leftText || '').split('\n');
|
||||
var rightLines = (rightText || '').split('\n');
|
||||
var sm = new DifflibSeqMatcher(leftLines, rightLines);
|
||||
var ops = sm.getOpcodes();
|
||||
|
||||
var html = '<div class="backup-diff-output backup-diff-sidebyside"><table class="backup-diff-table sidebyside"><colgroup><col style="width:50%"><col style="width:50%"></colgroup><tbody>';
|
||||
|
||||
for (var oi = 0; oi < ops.length; oi++) {
|
||||
var op = ops[oi];
|
||||
var tag = op[0], i1 = op[1], i2 = op[2], j1 = op[3], j2 = op[4];
|
||||
|
||||
if (tag === 'equal') {
|
||||
for (var k = 0; k < (i2 - i1); k++) {
|
||||
html += '<tr class="diff-context"><td class="diff-content sidebyside-left"><pre>' + escapeHtml(leftLines[i1 + k]) + '</pre></td><td class="diff-content sidebyside-right"><pre>' + escapeHtml(rightLines[j1 + k]) + '</pre></td></tr>';
|
||||
}
|
||||
} else if (tag === 'delete') {
|
||||
for (var k2 = i1; k2 < i2; k2++) {
|
||||
html += '<tr class="diff-del"><td class="diff-content sidebyside-left"><pre>' + escapeHtml(leftLines[k2]) + '</pre></td><td class="diff-content sidebyside-right diff-empty-cell"></td></tr>';
|
||||
}
|
||||
} else if (tag === 'insert') {
|
||||
for (var k3 = j1; k3 < j2; k3++) {
|
||||
html += '<tr class="diff-add"><td class="diff-content sidebyside-left diff-empty-cell"></td><td class="diff-content sidebyside-right"><pre>' + escapeHtml(rightLines[k3]) + '</pre></td></tr>';
|
||||
}
|
||||
} else if (tag === 'replace') {
|
||||
var maxLen = Math.max(i2 - i1, j2 - j1);
|
||||
for (var k4 = 0; k4 < maxLen; k4++) {
|
||||
var leftLine = (i1 + k4 < i2) ? escapeHtml(leftLines[i1 + k4]) : '';
|
||||
var rightLine = (j1 + k4 < j2) ? escapeHtml(rightLines[j1 + k4]) : '';
|
||||
var leftClass = (i1 + k4 < i2) ? 'diff-del' : 'diff-empty-cell';
|
||||
var rightClass = (j1 + k4 < j2) ? 'diff-add' : 'diff-empty-cell';
|
||||
html += '<tr><td class="diff-content sidebyside-left ' + leftClass + '"><pre>' + leftLine + '</pre></td><td class="diff-content sidebyside-right ' + rightClass + '"><pre>' + rightLine + '</pre></td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Minimal SequenceMatcher for line-based diff
|
||||
function DifflibSeqMatcher(a, b) {
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
}
|
||||
|
||||
DifflibSeqMatcher.prototype.getOpcodes = function() {
|
||||
var m = this.a.length, n = this.b.length;
|
||||
var dp = [];
|
||||
for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; }
|
||||
for (var i = 1; i <= m; i++) {
|
||||
for (var j = 1; j <= n; j++) {
|
||||
if (this.a[i - 1] === this.b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
var ops = [];
|
||||
var i = m, j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && this.a[i - 1] === this.b[j - 1]) {
|
||||
var ei = i - 1, ej = j - 1;
|
||||
while (ei > 0 && ej > 0 && this.a[ei - 1] === this.b[ej - 1]) { ei--; ej--; }
|
||||
if (ei < i) ops.unshift(['equal', ei, i, ej, j]);
|
||||
i = ei; j = ej;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.unshift(['insert', i, i, j - 1, j]);
|
||||
j--;
|
||||
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
|
||||
ops.unshift(['delete', i - 1, i, j, j]);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
var merged = [];
|
||||
for (var k = 0; k < ops.length; k++) {
|
||||
if (merged.length > 0 && merged[merged.length - 1][0] === 'delete' && ops[k][0] === 'insert' &&
|
||||
merged[merged.length - 1][4] === ops[k][3]) {
|
||||
var del = merged.pop();
|
||||
merged.push(['replace', del[1], del[2], ops[k][3], ops[k][4]]);
|
||||
} else {
|
||||
merged.push(ops[k]);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
var d = new Date(ts * 1000);
|
||||
var pad = function(n) { return String(n).padStart(2, '0'); };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
@@ -386,7 +495,7 @@ function formatSize(bytes) {
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
+318
-41
@@ -1,10 +1,11 @@
|
||||
// config.js — extracted from app.js (3872-4865)
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { el, icon, openFile } from './viewer.js';
|
||||
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -95,7 +96,7 @@ function renderRecentList(files) {
|
||||
|
||||
function _humanizeDelta(mtime) {
|
||||
const delta = Date.now() / 1000 - mtime;
|
||||
if (delta < 60) return "à l'instant";
|
||||
if (delta < 60) return t("config.just_now");
|
||||
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
|
||||
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
|
||||
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
|
||||
@@ -218,6 +219,14 @@ function initHelpModal() {
|
||||
});
|
||||
}
|
||||
|
||||
function initEditorPocBtn() {
|
||||
var btn = document.getElementById("editor-poc-btn");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", function() {
|
||||
window.open("/editor-poc", "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
function initHelpNavigation() {
|
||||
const helpBody = document.getElementById("help-body");
|
||||
const navList = document.getElementById("help-nav-list");
|
||||
@@ -601,6 +610,45 @@ function initConfigModal() {
|
||||
const saveHiddenBtn = document.getElementById("cfg-save-hidden-files");
|
||||
if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings);
|
||||
|
||||
// AI Keys
|
||||
const saveAIKeysBtn = document.getElementById("cfg-save-ai-keys");
|
||||
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
|
||||
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
|
||||
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
|
||||
// Load existing keys on modal open
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
|
||||
|
||||
// Profile menu button: open config modal and scroll to profile section
|
||||
var profileBtn = document.getElementById("profile-open-btn");
|
||||
if (profileBtn) {
|
||||
profileBtn.addEventListener("click", function() {
|
||||
var modal = document.getElementById("config-modal");
|
||||
if (modal) modal.classList.add("active");
|
||||
setTimeout(function() {
|
||||
var el = document.getElementById("cfg-profile");
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Logout button — handled inline in index.html (onclick)
|
||||
|
||||
// Config nav search
|
||||
var cfgSearch = document.getElementById("config-nav-search");
|
||||
if (cfgSearch) {
|
||||
cfgSearch.addEventListener("input", function() {
|
||||
var q = cfgSearch.value.toLowerCase();
|
||||
document.querySelectorAll("#config-scroll .config-section").forEach(function(s) {
|
||||
var text = s.textContent.toLowerCase();
|
||||
s.style.display = (q && !text.includes(q)) ? "none" : "";
|
||||
});
|
||||
document.querySelectorAll("#config-nav .help-nav-link").forEach(function(a) {
|
||||
var li = a.closest("li");
|
||||
if (li) li.style.display = (q && !a.textContent.toLowerCase().includes(q)) ? "none" : "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
closeConfigModal();
|
||||
@@ -609,6 +657,12 @@ function initConfigModal() {
|
||||
|
||||
// Load saved frontend config on startup
|
||||
applyFrontendConfig();
|
||||
|
||||
// Init theme picker
|
||||
initThemePicker();
|
||||
|
||||
// Init profile
|
||||
initProfile();
|
||||
}
|
||||
|
||||
function closeConfigModal() {
|
||||
@@ -665,6 +719,7 @@ async function loadConfigFields() {
|
||||
_setField("cfg-tag-boost", data.tag_boost);
|
||||
_setField("cfg-prefix-exp", data.prefix_max_expansions);
|
||||
_setField("cfg-recent-limit", data.recent_files_limit || 20);
|
||||
_setField("cfg-max-backups", data.max_backups_per_file || 10);
|
||||
// Watcher config
|
||||
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
|
||||
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
|
||||
@@ -705,7 +760,7 @@ function saveFrontendConfig() {
|
||||
search_timeout_ms: _getFieldNum("cfg-timeout", 30000),
|
||||
};
|
||||
localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg));
|
||||
showToast("Paramètres client sauvegardés", "success");
|
||||
showToast(t("toast.config_client_saved"), "success");
|
||||
}
|
||||
|
||||
async function saveBackendConfig() {
|
||||
@@ -716,6 +771,7 @@ async function saveBackendConfig() {
|
||||
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
|
||||
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
|
||||
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
|
||||
max_backups_per_file: _getFieldNum("cfg-max-backups", 10),
|
||||
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
|
||||
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
|
||||
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
|
||||
@@ -728,14 +784,14 @@ async function saveBackendConfig() {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("Configuration backend sauvegardée", "success");
|
||||
showToast(t("toast.config_saved"), "success");
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
showToast(errorData.detail || "Erreur de sauvegarde", "error");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save backend config:", err);
|
||||
showToast("Erreur de sauvegarde", "error");
|
||||
showToast(t("config.save_error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,20 +799,20 @@ async function forceReindex() {
|
||||
const btn = document.getElementById("cfg-reindex");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Réindexation...";
|
||||
btn.textContent = t('config.reindexing');
|
||||
}
|
||||
try {
|
||||
await api("/api/index/reload");
|
||||
showToast("Réindexation terminée", "success");
|
||||
showToast(t("toast.reindex_done"), "success");
|
||||
loadDiagnostics();
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
} catch (err) {
|
||||
console.error("Reindex error:", err);
|
||||
showToast("Erreur de réindexation", "error");
|
||||
showToast(t("config.reindex_error"), "error");
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Forcer réindexation";
|
||||
btn.textContent = t('config.force_reindex');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -788,7 +844,7 @@ async function resetConfigDefaults() {
|
||||
console.error("Reset config error:", err);
|
||||
}
|
||||
loadConfigFields();
|
||||
showToast("Configuration réinitialisée", "success");
|
||||
showToast(t("toast.config_reset"), "success");
|
||||
}
|
||||
|
||||
async function loadDiagnostics() {
|
||||
@@ -809,9 +865,9 @@ function renderDiagnostics(container, data) {
|
||||
{
|
||||
title: "Index",
|
||||
rows: [
|
||||
["Fichiers indexés", data.index.total_files],
|
||||
["Tags uniques", data.index.total_tags],
|
||||
["Vaults", Object.keys(data.index.vaults).join(", ")],
|
||||
[t("about.files_indexed"), data.index.total_files],
|
||||
[t("dashboard.stats_tags"), data.index.total_tags],
|
||||
[t("dashboard.stats_vaults"), Object.keys(data.index.vaults).join(", ")],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -861,34 +917,34 @@ function loadAbout() {
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Application",
|
||||
title: t("about.section_app"),
|
||||
rows: [
|
||||
["Nom", "ObsiGate"],
|
||||
["Version", state.APP_VERSION],
|
||||
["Version API", health.version || "—"],
|
||||
["Statut", health.status || "—"],
|
||||
[t("about.name"), "ObsiGate"],
|
||||
[t("about.version"), state.APP_VERSION],
|
||||
[t("about.api_version"), health.version || "—"],
|
||||
[t("about.status"), health.status || "—"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Environnement",
|
||||
title: t("about.section_env"),
|
||||
rows: [
|
||||
["Vaults configurés", health.vaults || "—"],
|
||||
["Fichiers indexés", health.total_files || "—"],
|
||||
["Navigateur", navigator.userAgent.split(" ").pop()],
|
||||
["Plateforme", navigator.platform || "—"],
|
||||
["Langue", navigator.language || "—"],
|
||||
[t("about.vaults_configured"), health.vaults || "—"],
|
||||
[t("about.files_indexed"), health.total_files || "—"],
|
||||
[t("about.browser"), navigator.userAgent.split(" ").pop()],
|
||||
[t("about.platform"), navigator.platform || "—"],
|
||||
[t("about.language"), navigator.language || "—"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Composants",
|
||||
title: t("about.section_components"),
|
||||
rows: [
|
||||
["Backend", "FastAPI (Python)"],
|
||||
["Rendu Markdown", "mistune"],
|
||||
["Surveillance fichiers", "watchdog"],
|
||||
["Frontend", "Vanilla JavaScript"],
|
||||
["Icônes", "Lucide Icons"],
|
||||
["Coloration syntaxe", "highlight.js"],
|
||||
["Éditeur", "CodeMirror 6"],
|
||||
[t("about.backend"), "FastAPI (Python)"],
|
||||
[t("about.markdown_renderer"), "mistune"],
|
||||
[t("about.file_watcher"), "watchdog"],
|
||||
[t("about.frontend"), "Vanilla JavaScript"],
|
||||
[t("about.icons"), "Lucide Icons"],
|
||||
[t("about.syntax_highlight"), "highlight.js"],
|
||||
[t("about.editor"), "CodeMirror 6"],
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -972,7 +1028,7 @@ async function saveHiddenFilesSettings() {
|
||||
const btn = document.getElementById("cfg-save-hidden-files");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Sauvegarde...";
|
||||
btn.textContent = t("config.saving") + "...";
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1001,18 +1057,18 @@ async function saveHiddenFilesSettings() {
|
||||
// Reload vault settings to update the cache
|
||||
await loadVaultSettings();
|
||||
|
||||
showToast("✓ Paramètres sauvegardés", "success");
|
||||
showToast(t("config.settings_saved"), "success");
|
||||
|
||||
// Refresh the UI to apply the filter
|
||||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||||
} catch (err) {
|
||||
console.error("Failed to save hidden files settings:", err);
|
||||
const errorMsg = err.message || "Erreur inconnue";
|
||||
showToast(`Erreur: ${errorMsg}`, "error");
|
||||
const errorMsg = err.message || t("config.error_unknown");
|
||||
showToast(t("config.error_prefix") + ": " + errorMsg, "error");
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "💾 Sauvegarder";
|
||||
btn.textContent = "💾 " + t("config.save");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1047,7 +1103,7 @@ document.addEventListener("click", function(e) {
|
||||
if (e.target.id === "webhook-add-btn") {
|
||||
const name = document.getElementById("webhook-name-input").value.trim();
|
||||
const url = document.getElementById("webhook-url-input").value.trim();
|
||||
if (!url) { showToast("URL requise", "error"); return; }
|
||||
if (!url) { showToast(t("config.url_required"), "error"); return; }
|
||||
api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error"));
|
||||
}
|
||||
});
|
||||
@@ -1120,13 +1176,13 @@ export async function openShareDialog(vault, path) {
|
||||
document.body.appendChild(ta); ta.select(); document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
showToast("Lien copié !", "success");
|
||||
showToast(t("config.link_copied"), "success");
|
||||
div.remove();
|
||||
});
|
||||
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
|
||||
showToast("Partage révoqué", "success");
|
||||
showToast(t("config.share_revoked"), "success");
|
||||
existingShare = null;
|
||||
renderContent();
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
@@ -1162,7 +1218,7 @@ export async function openShareDialog(vault, path) {
|
||||
});
|
||||
existingShare = share;
|
||||
renderContent();
|
||||
showToast("Lien créé !", "success");
|
||||
showToast(t("config.link_created"), "success");
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
});
|
||||
}
|
||||
@@ -1280,6 +1336,64 @@ function updateRegexPreview() {
|
||||
}
|
||||
|
||||
|
||||
// ── AI Keys management ──
|
||||
async function loadAIKeys() {
|
||||
try {
|
||||
const data = await api("/api/config/ai-keys");
|
||||
["DEEPSEEK_API_KEY","OPENROUTER_API_KEY","GEMINI_API_KEY"].forEach(k => {
|
||||
const id = "cfg-" + k.toLowerCase().replace(/_api_key/g, "") + "-key";
|
||||
const input = document.getElementById(id);
|
||||
if (input && data[k]) input.placeholder = data[k];
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
const map = { "cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-gemini-key": "GEMINI_API_KEY" };
|
||||
for (const [id, name] of Object.entries(map)) {
|
||||
const input = document.getElementById(id);
|
||||
if (input && input.value.trim()) keys[name] = input.value.trim();
|
||||
}
|
||||
if (!Object.keys(keys).length) { showToast(t("config.no_keys"), "warning"); return; }
|
||||
try {
|
||||
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
|
||||
showToast(t("config.api_keys_saved"), "success");
|
||||
// Clear inputs
|
||||
Object.keys(map).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
loadAIKeys();
|
||||
} catch(e) { showToast("Erreur: " + e.message, "error"); }
|
||||
}
|
||||
|
||||
async function testAIKeys() {
|
||||
const status = document.getElementById("cfg-ai-status");
|
||||
if (status) status.textContent = "Test en cours...";
|
||||
try {
|
||||
// Test connectivity
|
||||
const results = await api("/api/config/ai-keys/test", { method: "POST" });
|
||||
var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | ");
|
||||
if (status) status.textContent = msg;
|
||||
// Fetch models for configured providers
|
||||
for (const p of ["deepseek","openrouter","gemini"]) {
|
||||
if (results[p] === "ok") {
|
||||
try {
|
||||
const m = await api("/api/config/ai-models?provider=" + p);
|
||||
var sel = document.getElementById("cfg-" + p + "-model");
|
||||
if (sel && m.models && m.models.length) {
|
||||
sel.innerHTML = '<option value="">-- Modele (' + m.models.length + ') --</option>';
|
||||
m.models.forEach(function(md) {
|
||||
sel.innerHTML += '<option value="' + md + '">' + md + '</option>';
|
||||
});
|
||||
}
|
||||
} catch(e) { console.warn("Failed to fetch models for", p, e); }
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
if (status) status.textContent = "Erreur: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
initSidebarTabs,
|
||||
initConfigModal,
|
||||
@@ -1287,7 +1401,170 @@ export {
|
||||
initHelpModal,
|
||||
closeHelpModal,
|
||||
initRecentTab,
|
||||
initEditorPocBtn,
|
||||
loadAbout as initAboutSection,
|
||||
loadHiddenFilesSettings as initHiddenFilesConfig,
|
||||
initThemePicker,
|
||||
initAboutModal,
|
||||
initProfile,
|
||||
switchSidebarTab,
|
||||
};
|
||||
|
||||
// ── Theme Picker ─────────────────────────────────────────────────────
|
||||
function initThemePicker() {
|
||||
import('./themes.js').then(function(mod) {
|
||||
var THEMES = mod.THEMES;
|
||||
var applyTheme = mod.applyTheme;
|
||||
var getCurrentTheme = mod.getCurrentTheme;
|
||||
var getCurrentMode = mod.getCurrentMode;
|
||||
|
||||
var grid = document.getElementById('theme-grid');
|
||||
if (!grid) return;
|
||||
|
||||
var themeKeys = Object.keys(THEMES);
|
||||
|
||||
function renderCards() {
|
||||
var current = getCurrentTheme();
|
||||
var mode = getCurrentMode();
|
||||
grid.innerHTML = '';
|
||||
themeKeys.forEach(function(key) {
|
||||
var t = THEMES[key];
|
||||
var v = t.modes[mode];
|
||||
if (!v) return;
|
||||
var card = document.createElement('div');
|
||||
card.className = 'theme-card' + (key === current ? ' active' : '');
|
||||
card.innerHTML =
|
||||
'<div class="theme-card-preview">' +
|
||||
'<div class="theme-card-preview-bar" style="background:' + (v['--bg-sidebar'] || v['--bg-primary']) + '"></div>' +
|
||||
'<div class="theme-card-preview-body" style="background:' + (v['--bg-primary'] || '#000') + '">' +
|
||||
'<div class="theme-card-preview-btn" style="background:' + (v['--accent'] || '#58a6ff') + '"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="theme-card-name">' + t.name + '</div>' +
|
||||
'<div class="theme-card-desc">' + t.desc + '</div>';
|
||||
card.addEventListener('click', function() {
|
||||
applyTheme(key, mode);
|
||||
renderCards();
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
// Mode toggle
|
||||
var toggle = document.createElement('div');
|
||||
toggle.className = 'theme-mode-toggle';
|
||||
var dBtn = elBtn(t('theme.dark'), 'dark', mode);
|
||||
var lBtn = elBtn(t('theme.light'), 'light', mode);
|
||||
toggle.appendChild(dBtn);
|
||||
toggle.appendChild(lBtn);
|
||||
grid.appendChild(toggle);
|
||||
}
|
||||
|
||||
function elBtn(label, m, currentMode) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'theme-mode-btn' + (m === currentMode ? ' active' : '');
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', function() {
|
||||
applyTheme(getCurrentTheme(), m);
|
||||
renderCards();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
renderCards();
|
||||
});
|
||||
}
|
||||
|
||||
// ── About Modal ──────────────────────────────────────────────────────
|
||||
function initAboutModal() {
|
||||
var overlay = document.getElementById('about-overlay');
|
||||
var closeBtn = document.getElementById('about-close');
|
||||
if (!overlay) return;
|
||||
|
||||
// Open button
|
||||
var openBtn = document.getElementById('about-open-btn');
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', function() {
|
||||
overlay.classList.add('active');
|
||||
populateAbout();
|
||||
});
|
||||
}
|
||||
|
||||
// Close
|
||||
closeBtn.addEventListener('click', function() { overlay.classList.remove('active'); });
|
||||
overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.classList.remove('active'); });
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
|
||||
|
||||
function populateAbout() {
|
||||
// Live stats from health endpoint
|
||||
fetch('/api/health')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var fEl = document.getElementById('about-stat-files');
|
||||
var vEl = document.getElementById('about-stat-vaults');
|
||||
if (fEl) fEl.textContent = d.total_files || '—';
|
||||
if (vEl) vEl.textContent = d.vaults || '—';
|
||||
})
|
||||
.catch(function() {});
|
||||
|
||||
// Build date (today)
|
||||
var now = new Date();
|
||||
var months = [t('month.january'),t('month.february'),t('month.march'),t('month.april'),t('month.may'),t('month.june'),t('month.july'),t('month.august'),t('month.september'),t('month.october'),t('month.november'),t('month.december')];
|
||||
var dateEl = document.getElementById('about-build-date');
|
||||
if (dateEl) dateEl.textContent = months[now.getMonth()] + ' ' + now.getFullYear();
|
||||
|
||||
// Random joke
|
||||
var jokes = [
|
||||
t('about.joke_0'),
|
||||
t('about.joke_1'),
|
||||
t('about.joke_2'),
|
||||
t('about.joke_3'),
|
||||
t('about.joke_4'),
|
||||
t('about.joke_5'),
|
||||
t('about.joke_6'),
|
||||
t('about.joke_7'),
|
||||
t('about.joke_8'),
|
||||
t('about.joke_9')
|
||||
];
|
||||
var jokeEl = document.getElementById('about-joke');
|
||||
if (jokeEl) jokeEl.textContent = jokes[Math.floor(Math.random() * jokes.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profile ──────────────────────────────────────────────────────────
|
||||
function initProfile() {
|
||||
var langSel = document.getElementById('profile-lang');
|
||||
var nameInp = document.getElementById('profile-name');
|
||||
var saveBtn = document.getElementById('profile-save');
|
||||
var logoutBtn = document.getElementById('profile-logout');
|
||||
var savedEl = document.getElementById('profile-saved');
|
||||
if (!langSel || !saveBtn) return;
|
||||
|
||||
// Load saved profile
|
||||
try {
|
||||
var lang = getLocale();
|
||||
var name = localStorage.getItem('obsigate-name') || '';
|
||||
if (langSel) langSel.value = lang;
|
||||
if (nameInp) nameInp.value = name;
|
||||
} catch(e) {}
|
||||
|
||||
saveBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
var newLang = langSel.value;
|
||||
await setLocale(newLang); // Live-reload all translations
|
||||
if (nameInp) localStorage.setItem('obsigate-name', nameInp.value.trim());
|
||||
|
||||
// Persist language to server (survives clear site data)
|
||||
try {
|
||||
await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ language: newLang }),
|
||||
});
|
||||
} catch (e) { /* non-bloquant */ }
|
||||
|
||||
if (savedEl) { savedEl.style.display = 'inline'; setTimeout(function() { savedEl.style.display = 'none'; }, 2000); }
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
// Logout button — handled inline in index.html (onclick)
|
||||
}
|
||||
|
||||
+50
-31
@@ -4,6 +4,7 @@ import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
|
||||
import { icon, openFile } from './viewer.js';
|
||||
import { TabManager, showToast } from './ui.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files
|
||||
@@ -20,23 +21,23 @@ const DashboardStatsWidget = {
|
||||
async load() {
|
||||
const grid = document.getElementById("dashboard-stats-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '<div class="dashboard-stats-loading">Chargement...</div>';
|
||||
grid.innerHTML = `<div class="dashboard-stats-loading">${t('common.loading')}</div>`;
|
||||
try {
|
||||
const data = await api("/api/dashboard");
|
||||
this.render(data);
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
|
||||
grid.innerHTML = `<div class="dashboard-recent-empty">${t('dashboard.error_prefix')}${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
render(data) {
|
||||
const grid = document.getElementById("dashboard-stats-grid");
|
||||
if (!grid) return;
|
||||
const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`;
|
||||
const fmtSize = (bytes) => bytes < 1024 ? `${bytes}${t('dashboard.size_bytes')}` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)}${t('dashboard.size_kb')}` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)}${t('dashboard.size_mb')}` : `${(bytes/1073741824).toFixed(1)}${t('dashboard.size_gb')}`;
|
||||
const items = [
|
||||
{ icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() },
|
||||
{ icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() },
|
||||
{ icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) },
|
||||
{ icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() },
|
||||
{ icon: "files", label: t('dashboard.stats_files'), value: data.total_files.toLocaleString() },
|
||||
{ icon: "tags", label: t('dashboard.stats_tags'), value: data.total_tags.toLocaleString() },
|
||||
{ icon: "hard-drive", label: t('dashboard.stats_size'), value: fmtSize(data.total_size_bytes) },
|
||||
{ icon: "folder-open", label: t('dashboard.stats_vaults'), value: data.vaults.length.toString() },
|
||||
];
|
||||
grid.innerHTML = items.map(i => `
|
||||
<div class="stat-card">
|
||||
@@ -67,12 +68,12 @@ const DashboardSharedWidget = {
|
||||
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
|
||||
</div>
|
||||
<div class="shared-card-meta">
|
||||
<span>${s.access_count || 0} vue(s)</span>
|
||||
${s.expires_at ? `<span>Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}</span>` : ""}
|
||||
<span>${s.access_count || 0} ${t('dashboard.views', {count: s.access_count || 0})}</span>
|
||||
${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""}
|
||||
</div>
|
||||
<div class="shared-card-actions">
|
||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">📋 Copier</button>
|
||||
<button class="shared-open-btn">📂 Ouvrir</button>
|
||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +83,7 @@ const DashboardSharedWidget = {
|
||||
e.stopPropagation();
|
||||
const url = b.dataset.url;
|
||||
try { await navigator.clipboard.writeText(url); } catch { const ta = document.createElement("textarea"); ta.value=url; ta.style.position="fixed"; ta.style.left="-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); }
|
||||
showToast("Lien copié !", "success");
|
||||
showToast(t("config.link_copied"), "success");
|
||||
}));
|
||||
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -92,7 +93,7 @@ const DashboardSharedWidget = {
|
||||
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api(`/api/share/${b.dataset.id}`, { method: "DELETE" });
|
||||
showToast("Partage révoqué", "success");
|
||||
showToast(t("config.share_revoked"), "success");
|
||||
this.load();
|
||||
}));
|
||||
grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
|
||||
@@ -119,7 +120,7 @@ const DashboardConflictsWidget = {
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-title-row">
|
||||
<i data-lucide="alert-triangle" class="dashboard-icon" style="color:var(--accent-orange)"></i>
|
||||
<h2>Conflits de synchronisation</h2>
|
||||
<h2>${t('dashboard.conflicts_title')}</h2>
|
||||
<span class="dashboard-badge" style="background:var(--accent-orange)">${conflicts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,11 +130,11 @@ const DashboardConflictsWidget = {
|
||||
<div class="conflict-info">
|
||||
<span class="conflict-vault">${escapeHtml(c.vault)}</span>
|
||||
<span class="conflict-name">${escapeHtml(c.conflict_path.split("/").pop())}</span>
|
||||
<span class="conflict-date">Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
|
||||
<span class="conflict-date">${t('dashboard.conflict_date')}${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
|
||||
</div>
|
||||
<div class="conflict-actions">
|
||||
<button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder l'original</button>
|
||||
<button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder le conflit</button>
|
||||
<button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">${t('dashboard.keep_original')}</button>
|
||||
<button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">${t('dashboard.keep_conflict')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
@@ -146,9 +147,9 @@ const DashboardConflictsWidget = {
|
||||
async _resolve(d, action) {
|
||||
try {
|
||||
await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) });
|
||||
showToast("Conflit résolu", "success");
|
||||
showToast(t("dashboard.conflict_resolved"), "success");
|
||||
this.load();
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
} catch (err) { showToast(t('dashboard.error_prefix') + err.message, "error"); }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,7 +204,7 @@ const DashboardRecentWidget = {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle bookmark:", err);
|
||||
showToast("Erreur lors de l'épinglage", "error");
|
||||
showToast(t("dashboard.pin_error"), "error");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -233,7 +234,7 @@ const DashboardRecentWidget = {
|
||||
}
|
||||
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`;
|
||||
if (count) count.textContent = `${this._cache.length} ${this._cache.length > 1 ? t('dashboard.count_files_plural') : t('dashboard.count_files')}`;
|
||||
|
||||
if (!grid) return;
|
||||
grid.innerHTML = "";
|
||||
@@ -274,7 +275,7 @@ const DashboardRecentWidget = {
|
||||
|
||||
const bookmarkBtn = document.createElement("button");
|
||||
bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`;
|
||||
bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
|
||||
bookmarkBtn.title = file.bookmarked ? t('dashboard.bookmark_remove') : t('dashboard.bookmark_add');
|
||||
bookmarkBtn.innerHTML = `<i data-lucide="${file.bookmarked ? "bookmark" : "bookmark-plus"}" style="width:14px;height:14px"></i>`;
|
||||
|
||||
bookmarkBtn.addEventListener("click", (e) => {
|
||||
@@ -329,9 +330,9 @@ const DashboardRecentWidget = {
|
||||
|
||||
card.appendChild(footer);
|
||||
|
||||
// Click handler
|
||||
// Click handler — open in tab system (not raw openFile)
|
||||
card.addEventListener("click", () => {
|
||||
openFile(file.vault, file.path);
|
||||
TabManager.openPreview(file.vault, file.path);
|
||||
});
|
||||
|
||||
return card;
|
||||
@@ -346,7 +347,7 @@ const DashboardRecentWidget = {
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (loading) loading.classList.remove("active");
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
if (count) count.textContent = "0 fichiers";
|
||||
if (count) count.textContent = t('dashboard.zero_files');
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
@@ -355,17 +356,17 @@ const DashboardRecentWidget = {
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
if (empty) {
|
||||
const msg = empty.querySelector("span");
|
||||
if (msg) msg.textContent = "Erreur de chargement";
|
||||
if (msg) msg.textContent = t('dashboard.load_error');
|
||||
}
|
||||
},
|
||||
|
||||
_humanizeDelta(mtime) {
|
||||
const delta = Date.now() / 1000 - mtime;
|
||||
if (delta < 60) return "à l'instant";
|
||||
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
|
||||
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
|
||||
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
|
||||
return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
|
||||
if (delta < 60) return t('dashboard.just_now');
|
||||
if (delta < 3600) return t('dashboard.min_ago', { n: Math.floor(delta / 60) });
|
||||
if (delta < 86400) return t('dashboard.hour_ago', { n: Math.floor(delta / 3600) });
|
||||
if (delta < 604800) return t('dashboard.day_ago', { n: Math.floor(delta / 86400) });
|
||||
return new Date(mtime * 1000).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US', { day: "numeric", month: "short", year: "numeric" });
|
||||
},
|
||||
|
||||
populateVaultFilter() {
|
||||
@@ -395,6 +396,24 @@ const DashboardRecentWidget = {
|
||||
}
|
||||
|
||||
this.populateVaultFilter();
|
||||
|
||||
// Quick Help toggle (with localStorage persistence)
|
||||
var qhToggle = document.getElementById('qh-toggle');
|
||||
var qhBody = document.getElementById('qh-body');
|
||||
if (qhToggle && qhBody) {
|
||||
// Restore saved state
|
||||
if (localStorage.getItem('obsigate-qh-collapsed') === '1') {
|
||||
qhBody.classList.add('collapsed');
|
||||
qhToggle.textContent = '+';
|
||||
qhToggle.title = t('dashboard.show');
|
||||
}
|
||||
qhToggle.addEventListener('click', function() {
|
||||
var collapsed = qhBody.classList.toggle('collapsed');
|
||||
qhToggle.textContent = collapsed ? '+' : '\u2212';
|
||||
qhToggle.title = collapsed ? t('dashboard.show') : t('dashboard.hide');
|
||||
localStorage.setItem('obsigate-qh-collapsed', collapsed ? '1' : '0');
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Vendored
+7
-5
@@ -155,7 +155,8 @@ function onDragStart(e) {
|
||||
|
||||
// Set drag data and visual feedback
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', path);
|
||||
// Prefix with 'tree:' so pane drop zones can distinguish from tab drags
|
||||
e.dataTransfer.setData('text/plain', 'tree:' + JSON.stringify({ vault, path }));
|
||||
|
||||
// Visual: reduce opacity of source
|
||||
treeItem.classList.add(DRAG_SOURCE_CLASS);
|
||||
@@ -195,10 +196,11 @@ function onDragOver(e) {
|
||||
}
|
||||
|
||||
function onDragLeave(e) {
|
||||
// Only clear when actually leaving the container, not entering a child
|
||||
// Only clear when actually leaving the container, not entering a child.
|
||||
// Use relatedTarget: if it's still inside #vault-tree, keep highlights.
|
||||
const container = document.getElementById('vault-tree');
|
||||
if (!container) return;
|
||||
// Use a small delay to avoid flickering when moving between children
|
||||
if (e.relatedTarget && container.contains(e.relatedTarget)) return;
|
||||
clearAllHighlights();
|
||||
}
|
||||
|
||||
@@ -235,7 +237,7 @@ async function onDrop(e) {
|
||||
|
||||
// Don't move if source and target are the same vault
|
||||
if (vault !== targetVault) {
|
||||
showToast('Cross-vault moves are not supported', 'error');
|
||||
showToast(t('dragdrop.cross_vault_unsupported'), 'error');
|
||||
onDragEnd();
|
||||
return;
|
||||
}
|
||||
@@ -262,7 +264,7 @@ async function onDrop(e) {
|
||||
// Refresh the tree preserving expanded state
|
||||
await refreshSidebarTreePreservingState();
|
||||
} else {
|
||||
showToast('Move failed', 'error');
|
||||
showToast(t('dragdrop.move_failed'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Drag & drop move failed:', err);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Excalidraw Viewer — iframe-based diagram editor integration for ObsiGate.
|
||||
*
|
||||
* Renders .excalidraw files in an iframe that loads the standalone
|
||||
* excalidraw-editor.html. Communication between parent and iframe
|
||||
* via postMessage:
|
||||
*
|
||||
* Parent → Iframe: init {data, theme} | theme {theme} | requestSave
|
||||
* Iframe → Parent: ready | save {data} | modified {dirty}
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
// Track active Excalidraw iframes by a unique id
|
||||
const _activeEditors = new Map();
|
||||
let _editorIdCounter = 0;
|
||||
|
||||
/**
|
||||
* Render an Excalidraw editor inside the given container element.
|
||||
*
|
||||
* @param {HTMLElement} container - DOM element to host the iframe (usually content-area)
|
||||
* @param {object} data - File data from /api/file/{vault} (must have is_excalidraw: true)
|
||||
* @param {string} vaultName
|
||||
* @param {string} filePath
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.readOnly=false] - If true, hide save button in iframe
|
||||
*/
|
||||
export function renderExcalidraw(container, data, vaultName, filePath, opts = {}) {
|
||||
const editorId = `excalidraw-${++_editorIdCounter}`;
|
||||
|
||||
// Build the iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = editorId;
|
||||
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
|
||||
iframe.sandbox.add('allow-scripts');
|
||||
iframe.sandbox.add('allow-same-origin');
|
||||
iframe.style.cssText = 'width:100%;height:100%;border:none;';
|
||||
|
||||
// Clean container and insert iframe
|
||||
container.innerHTML = '';
|
||||
container.appendChild(iframe);
|
||||
|
||||
// Track this editor instance
|
||||
const editorState = {
|
||||
id: editorId,
|
||||
iframe,
|
||||
vault: vaultName,
|
||||
path: filePath,
|
||||
isDirty: false,
|
||||
ready: false,
|
||||
saveTimer: null,
|
||||
};
|
||||
_activeEditors.set(editorId, editorState);
|
||||
|
||||
// Get current theme
|
||||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
|
||||
// Wait for iframe to load, then send init
|
||||
iframe.addEventListener('load', () => {
|
||||
// Send init message when iframe sends 'ready'
|
||||
// (iframe may load before React mounts, so we wait for the ready signal)
|
||||
// We set up the message listener before sending anything
|
||||
});
|
||||
|
||||
// Handle messages from iframe
|
||||
window.addEventListener('message', (event) => {
|
||||
// Verify source is our iframe
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg.type !== 'string') return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
// Iframe is mounted — send initial data
|
||||
editorState.ready = true;
|
||||
// Support both pure .excalidraw and compressed .excalidraw.md
|
||||
const initData = data.excalidraw_data || {};
|
||||
if (data.excalidraw_data_compressed) {
|
||||
initData.compressed = data.excalidraw_data_compressed;
|
||||
}
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'init',
|
||||
data: initData,
|
||||
theme: theme,
|
||||
}, '*');
|
||||
break;
|
||||
|
||||
case 'save':
|
||||
if (msg.data) {
|
||||
_saveExcalidrawFile(editorState, msg.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'modified':
|
||||
editorState.isDirty = msg.dirty === true;
|
||||
// If dirty, start auto-save timer (2s debounce)
|
||||
if (editorState.isDirty) {
|
||||
if (editorState.saveTimer) clearTimeout(editorState.saveTimer);
|
||||
editorState.saveTimer = setTimeout(() => {
|
||||
if (editorState.isDirty && editorState.ready) {
|
||||
iframe.contentWindow.postMessage({ type: 'requestSave' }, '*');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Excalidraw data back to the server.
|
||||
*/
|
||||
async function _saveExcalidrawFile(state, excalidrawData) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(excalidrawData, null, 2);
|
||||
await api(`/api/file/${encodeURIComponent(state.vault)}/save?path=${encodeURIComponent(state.path)}&backup=false`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: jsonStr }),
|
||||
});
|
||||
// Toast is handled by the caller or we show a quick one
|
||||
showToast('Excalidraw saved', 'success');
|
||||
} catch (err) {
|
||||
console.error('[Excalidraw] Save failed:', err);
|
||||
showToast('Failed to save Excalidraw: ' + (err.message || 'unknown error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all active Excalidraw editors of a theme change.
|
||||
*/
|
||||
export function notifyExcalidrawThemeChange(theme) {
|
||||
_activeEditors.forEach((state) => {
|
||||
if (state.ready && state.iframe.contentWindow) {
|
||||
state.iframe.contentWindow.postMessage({ type: 'theme', theme }, '*');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an editor instance (e.g., when tab is closed).
|
||||
*/
|
||||
export function destroyExcalidrawEditor(editorId) {
|
||||
const state = _activeEditors.get(editorId);
|
||||
if (state) {
|
||||
if (state.saveTimer) clearTimeout(state.saveTimer);
|
||||
_activeEditors.delete(editorId);
|
||||
}
|
||||
}
|
||||
|
||||
export default { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor };
|
||||
+17
-16
@@ -1,6 +1,7 @@
|
||||
/* ObsiGate — Graph View: interactive file/folder relationship visualization.
|
||||
Phase 2: theme colors, tooltips, depth slider, full-vault, search, backlinks. */
|
||||
import { api } from './auth.js';
|
||||
import { t } from './i18n.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { openFile } from './viewer.js';
|
||||
|
||||
@@ -87,8 +88,8 @@ export const GraphViewManager = {
|
||||
this._depth = depthSlider ? parseInt(depthSlider.value) : 1;
|
||||
this._scope = 'directory';
|
||||
|
||||
title.textContent = `Vue Graphique — ${vault}${path ? '/' + path : ''}`;
|
||||
statusEl.textContent = 'Chargement...';
|
||||
title.textContent = t('graph.title', { vault, path: path ? '/' + path : '' });
|
||||
statusEl.textContent = t('graph.loading');
|
||||
modal.classList.add('active');
|
||||
|
||||
this._canvas = canvas;
|
||||
@@ -119,8 +120,8 @@ export const GraphViewManager = {
|
||||
this._nodes = cached.nodes;
|
||||
this._edges = cached.edges;
|
||||
this._scope = cached.scope;
|
||||
const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier';
|
||||
statusEl.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth} (cache)`;
|
||||
const scopeLabel = this._scope === 'full' ? t('graph.scope_full') : t('graph.scope_directory');
|
||||
statusEl.textContent = `${t('graph.nodes_count', { count: this._nodes.length })}, ${t('graph.edges_count', { count: this._edges.length })} · ${scopeLabel} · ${t('graph.depth_label', { depth: this._depth })} ${t('graph.cache_label')}`;
|
||||
this._initLayout();
|
||||
this._startRender();
|
||||
return;
|
||||
@@ -148,12 +149,12 @@ export const GraphViewManager = {
|
||||
};
|
||||
this._cacheKey = cacheKey;
|
||||
|
||||
const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier';
|
||||
statusEl.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`;
|
||||
const scopeLabel = this._scope === 'full' ? t('graph.scope_full') : t('graph.scope_directory');
|
||||
statusEl.textContent = `${t('graph.nodes_count', { count: this._nodes.length })}, ${t('graph.edges_count', { count: this._edges.length })} · ${scopeLabel} · ${t('graph.depth_label', { depth: this._depth })}`;
|
||||
this._initLayout();
|
||||
this._startRender();
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Erreur de chargement';
|
||||
statusEl.textContent = t('graph.error_loading');
|
||||
console.error('Graph error:', err);
|
||||
}
|
||||
},
|
||||
@@ -172,7 +173,7 @@ export const GraphViewManager = {
|
||||
toggleScope() {
|
||||
this._scope = this._scope === 'full' ? 'directory' : 'full';
|
||||
const btn = document.getElementById('graph-full-vault');
|
||||
if (btn) btn.textContent = this._scope === 'full' ? '📁 Dossier' : '🌐 Tout';
|
||||
if (btn) btn.textContent = this._scope === 'full' ? t('graph.btn_directory') : t('graph.btn_full');
|
||||
this.reload();
|
||||
},
|
||||
|
||||
@@ -624,16 +625,16 @@ export const GraphViewManager = {
|
||||
if (fm.auteur) rows.push(`<div><span style="color:var(--text-muted)">✍️ Auteur</span> <span>${fm.auteur}</span></div>`);
|
||||
if (fm.date || fm.creation_date) rows.push(`<div><span style="color:var(--text-muted)">📅 Date</span> <span>${fm.date || fm.creation_date}</span></div>`);
|
||||
if (fm.statut) rows.push(`<div><span style="color:var(--text-muted)">📌 Statut</span> <span>${fm.statut}</span></div>`);
|
||||
if (fm.catégorie || fm.categorie) rows.push(`<div><span style="color:var(--text-muted)">📂 Catégorie</span> <span>${fm.catégorie || fm.categorie}</span></div>`);
|
||||
if (fm.publish) rows.push('<div><span style="color:var(--text-muted)">✅</span> <span>Publié</span></div>');
|
||||
if (fm.favoris) rows.push('<div><span style="color:var(--text-muted)">⭐</span> <span>Favoris</span></div>');
|
||||
if (fm.catégorie || fm.categorie) rows.push(`<div><span style="color:var(--text-muted)">${t('graph.category')}</span> <span>${fm.catégorie || fm.categorie}</span></div>`);
|
||||
if (fm.publish) rows.push(`<div><span style="color:var(--text-muted)">✅</span> <span>${t('graph.published')}</span></div>`);
|
||||
if (fm.favoris) rows.push('<div><span style="color:var(--text-muted)">⭐</span> <span>' + t('graph.favorites') + '</span></div>');
|
||||
if (rows.length > 0) metaRows = rows.join('');
|
||||
}
|
||||
const tags = (meta?.tags || node.tags || []).slice(0, 6);
|
||||
const inc = node.incoming_count || 0;
|
||||
const out = node.outgoing_count || 0;
|
||||
const typeIcon = node.type === 'directory' ? '📁' : '📄';
|
||||
const typeLabel = node.type === 'directory' ? 'Dossier' : (node.path || '').endsWith('.md') ? 'Markdown' : 'Fichier';
|
||||
const typeLabel = node.type === 'directory' ? t('graph.type_directory') : (node.path || '').endsWith('.md') ? t('graph.type_markdown') : t('graph.type_file');
|
||||
|
||||
this._infoContent.innerHTML = `
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:8px;padding-right:16px">
|
||||
@@ -649,7 +650,7 @@ export const GraphViewManager = {
|
||||
</div>
|
||||
${metaRows ? `<div style="font-size:0.72rem;display:flex;flex-direction:column;gap:2px;padding:6px 0;border-top:1px solid var(--border-color);border-bottom:1px solid var(--border-color);margin-bottom:6px">${metaRows}</div>` : ''}
|
||||
${tags.length > 0 ? `<div style="display:flex;gap:4px;flex-wrap:wrap">${tags.map(t => `<span style="font-size:0.65rem;background:var(--accent-color);color:#fff;padding:1px 6px;border-radius:3px;opacity:0.8">#${t}</span>`).join('')}</div>` : ''}
|
||||
${node.type === 'file' ? '<div style="font-size:0.6rem;color:var(--text-muted);margin-top:6px;opacity:0.6">Ctrl+survol = aperçu complet</div>' : ''}
|
||||
${node.type === 'file' ? '<div style="font-size:0.6rem;color:var(--text-muted);margin-top:6px;opacity:0.6">' + t('graph.ctrl_hover_preview') + '</div>' : ''}
|
||||
`;
|
||||
this._infoPanel.style.display = 'block';
|
||||
},
|
||||
@@ -671,7 +672,7 @@ export const GraphViewManager = {
|
||||
if (fm.auteur) items.push(`✍️ ${fm.auteur}`);
|
||||
if (fm.date || fm.creation_date) items.push(`📅 ${fm.date || fm.creation_date}`);
|
||||
if (fm.statut) items.push(`📌 ${fm.statut}`);
|
||||
if (fm.publish) items.push('✅ publié');
|
||||
if (fm.publish) items.push(t('graph.published_badge'));
|
||||
if (fm.favoris) items.push('⭐ favoris');
|
||||
if (items.length > 0) fmSummary = `<div style="font-size:0.7rem;color:var(--text-muted);margin:4px 0">${items.join(' · ')}</div>`;
|
||||
}
|
||||
@@ -764,7 +765,7 @@ export const GraphViewManager = {
|
||||
if (hit) {
|
||||
this._canvas.style.cursor = 'pointer';
|
||||
this._canvas.title = hit.node.type === 'file'
|
||||
? `📄 ${hit.node.name} (cliquer pour ouvrir${e.ctrlKey ? ', Ctrl+survol = aperçu' : ''})`
|
||||
? `📄 ${hit.node.name} (${t('graph.click_to_open')}${e.ctrlKey ? ', ' + t('graph.ctrl_hover_preview_short') : ''})`
|
||||
: `📁 ${hit.node.name} (cliquer pour explorer)`;
|
||||
|
||||
// Ctrl+hover: fetch content preview for file nodes
|
||||
@@ -795,7 +796,7 @@ export const GraphViewManager = {
|
||||
return;
|
||||
}
|
||||
// Show loading indicator
|
||||
this._showPreviewTooltip(node, '<em>Chargement...</em>', screenX, screenY);
|
||||
this._showPreviewTooltip(node, '<em>' + t('common.loading') + '</em>', screenX, screenY);
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(this._vault)}?path=${encodeURIComponent(node.path)}`);
|
||||
// Strip HTML tags, keep line breaks, limit to 600 chars
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* ObsiGate — i18n (internationalisation).
|
||||
*
|
||||
* Architecture:
|
||||
* Flat key→string JSON locale files in /static/locales/{fr,en,...}.json
|
||||
* Static DOM: data-i18n="key" → textContent
|
||||
* data-i18n-placeholder="key" → placeholder
|
||||
* data-i18n-attr:title="key" → title attribute
|
||||
* data-i18n-html="key" → innerHTML (use sparingly)
|
||||
* Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'})
|
||||
* Live reload: setLocale('en') updates every data-i18n element instantly.
|
||||
*
|
||||
* Key naming: section.what — lowercase, dot-separated, descriptive.
|
||||
* e.g. header.search_placeholder button.save error.network
|
||||
*
|
||||
* Usage:
|
||||
* import { t, setLocale, getLocale, initI18n } from './i18n.js';
|
||||
* await initI18n(); // early, before any rendering
|
||||
* el.textContent = t('button.cancel'); // dynamic string
|
||||
* setLocale('en'); // user switches language
|
||||
*/
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let _currentLocale = 'fr';
|
||||
let _strings = {}; // { key: translated_string }
|
||||
let _fallbackStrings = {}; // FR fallback, loaded once
|
||||
let _loaded = false;
|
||||
|
||||
// ── Public helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Return the currently active locale code ('fr', 'en', ...). */
|
||||
export function getLocale() {
|
||||
return _currentLocale;
|
||||
}
|
||||
|
||||
/** Translate a key. If params provided, replace {name} placeholders.
|
||||
* Falls back: key → French → key itself. Never throws. */
|
||||
export function t(key, params) {
|
||||
let s = _strings[key];
|
||||
if (s === undefined) {
|
||||
s = _fallbackStrings[key];
|
||||
if (s === undefined) {
|
||||
console.warn('obsigate i18n: missing key', key);
|
||||
s = key; // last resort — show the key so the dev notices
|
||||
}
|
||||
}
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), String(v));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Reverse-lookup cache (French text → key) ────────────────────────────
|
||||
let _reverseCache = null;
|
||||
|
||||
function _buildReverseCache() {
|
||||
_reverseCache = {};
|
||||
for (const [k, v] of Object.entries(_fallbackStrings)) {
|
||||
if (k.startsWith('_')) continue;
|
||||
// Prefer shorter keys when multiple keys map to the same French text
|
||||
if (!_reverseCache[v] || k.length < _reverseCache[v].length) {
|
||||
_reverseCache[v] = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate a French string to the current locale by reverse lookup.
|
||||
* "Paramètres client sauvegardés" → fr: same, en: "Client settings saved".
|
||||
* If the string is not found in the FR locale, returns it unchanged. */
|
||||
export function tr(frenchText) {
|
||||
if (!frenchText || _currentLocale === 'fr') return frenchText;
|
||||
if (!_reverseCache) _buildReverseCache();
|
||||
var key = _reverseCache[frenchText];
|
||||
return key ? t(key) : frenchText;
|
||||
}
|
||||
|
||||
/** Change locale and re-translate the entire DOM.
|
||||
* Persists to localStorage. */
|
||||
export async function setLocale(locale) {
|
||||
locale = locale || 'fr';
|
||||
if (!_loaded) {
|
||||
// Too early — store intent and apply after init
|
||||
try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ }
|
||||
return;
|
||||
}
|
||||
await _loadLocaleStrings(locale);
|
||||
_applyDOM();
|
||||
document.documentElement.lang = locale;
|
||||
try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
/** Initialise the i18n system. Call once, early, before any rendering.
|
||||
* Priority: server profile > localStorage > navigator.language > 'fr'. */
|
||||
export async function initI18n() {
|
||||
// 1. Determine locale: server profile > localStorage > navigator.language > 'fr'
|
||||
let locale = null;
|
||||
|
||||
// Try server-side language preference (survives clear site data)
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
if (user.language === 'en' || user.language === 'fr') {
|
||||
locale = user.language;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* not logged in or network error — fall through */ }
|
||||
|
||||
// Fall back to localStorage
|
||||
if (!locale) {
|
||||
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
// Fall back to navigator.language
|
||||
if (!locale) {
|
||||
const nav = (navigator.language || '').toLowerCase();
|
||||
locale = nav.startsWith('fr') ? 'fr'
|
||||
: nav.startsWith('en') ? 'en'
|
||||
: 'fr';
|
||||
}
|
||||
|
||||
// 2. Load fallback (FR) — always needed
|
||||
_fallbackStrings = await _fetchLocale('fr');
|
||||
|
||||
// 3. Load selected locale
|
||||
await _loadLocaleStrings(locale);
|
||||
|
||||
// 4. Apply to DOM
|
||||
_applyDOM();
|
||||
document.documentElement.lang = locale;
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function _fetchLocale(locale) {
|
||||
try {
|
||||
const resp = await fetch('/static/locales/' + locale + '.json');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
console.warn('obsigate i18n: failed to load locale ' + locale, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadLocaleStrings(locale) {
|
||||
_currentLocale = locale;
|
||||
if (locale === 'fr') {
|
||||
_strings = _fallbackStrings;
|
||||
} else {
|
||||
_strings = await _fetchLocale(locale);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global exports for non-module scripts (legacy app.js) ──────────────────
|
||||
window.t = t;
|
||||
window.tr = tr;
|
||||
window.getLocale = getLocale;
|
||||
window.setLocale = setLocale;
|
||||
window.initI18n = initI18n;
|
||||
|
||||
/** Walk the DOM and update every element with a data-i18n* attribute. */
|
||||
function _applyDOM() {
|
||||
// textContent
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// placeholder
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.setAttribute('placeholder', t(key));
|
||||
});
|
||||
|
||||
// innerHTML (use sparingly — XSS risk if keys ever hold user content)
|
||||
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n-html');
|
||||
el.innerHTML = t(key);
|
||||
});
|
||||
|
||||
// data-i18n-attr:TITLE → sets any attribute
|
||||
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
|
||||
const raw = el.getAttribute('data-i18n-attr');
|
||||
const colon = raw.indexOf(':');
|
||||
if (colon === -1) return;
|
||||
const attr = raw.substring(0, colon);
|
||||
const key = raw.substring(colon + 1);
|
||||
el.setAttribute(attr, t(key));
|
||||
});
|
||||
}
|
||||
+55
-55
@@ -25,7 +25,7 @@ import {
|
||||
} from './dashboard.js';
|
||||
|
||||
// --- Re-exports from modules that already have these functions ---
|
||||
export { initSidebarTabs, initConfigModal, initHelpModal, initRecentTab } from './config.js';
|
||||
export { initSidebarTabs, initConfigModal, initHelpModal, initRecentTab, initAboutModal } from './config.js';
|
||||
export { initSyncStatus } from './sync.js';
|
||||
export { DashboardRecentWidget } from './dashboard.js';
|
||||
|
||||
@@ -77,25 +77,29 @@ function _isInputFocused() {
|
||||
function initSearch() {
|
||||
const input = document.getElementById("search-input");
|
||||
if (!input) return;
|
||||
const caseBtn = document.getElementById("search-case-btn");
|
||||
const wordBtn = document.getElementById("search-word-btn");
|
||||
const regexBtn = document.getElementById("search-regex-btn");
|
||||
const caseBtn = document.getElementById("rb-case");
|
||||
const wordBtn = document.getElementById("rb-word");
|
||||
const regexBtn = document.getElementById("rb-regex");
|
||||
const filterBtn = document.getElementById("search-filter-btn");
|
||||
const clearBtn = document.getElementById("search-clear-btn");
|
||||
const filterRow = document.getElementById("search-filter-row");
|
||||
const prevBtn = document.getElementById("search-prev-btn");
|
||||
const nextBtn = document.getElementById("search-next-btn");
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
const prevBtn = document.getElementById("rb-prev");
|
||||
const nextBtn = document.getElementById("rb-next");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
const resultsBar = document.getElementById("results-bar");
|
||||
|
||||
function _showResultsBar() { if (resultsBar) resultsBar.style.display = 'flex'; }
|
||||
function _hideResultsBar() { if (resultsBar) resultsBar.style.display = 'none'; }
|
||||
|
||||
function _updateToggleUI() {
|
||||
caseBtn.classList.toggle("active", state.searchCaseSensitive);
|
||||
wordBtn.classList.toggle("active", state.searchWholeWord);
|
||||
regexBtn.classList.toggle("active", state.searchRegex);
|
||||
filterBtn.classList.toggle("active", state.searchFilterVisible);
|
||||
if (caseBtn) caseBtn.classList.toggle("active", state.searchCaseSensitive);
|
||||
if (wordBtn) wordBtn.classList.toggle("active", state.searchWholeWord);
|
||||
if (regexBtn) regexBtn.classList.toggle("active", state.searchRegex);
|
||||
if (filterBtn) filterBtn.classList.toggle("active", state.searchFilterVisible);
|
||||
}
|
||||
|
||||
// Toggle buttons
|
||||
caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); });
|
||||
if (caseBtn) caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); });
|
||||
if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); });
|
||||
if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); });
|
||||
if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = state.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); });
|
||||
@@ -111,7 +115,7 @@ function initSearch() {
|
||||
if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) {
|
||||
_searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`;
|
||||
}
|
||||
|
||||
@@ -119,6 +123,7 @@ function initSearch() {
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
_searchResultIdx = _searchResultItems.length > 0 ? 0 : -1;
|
||||
_updateResultHighlight();
|
||||
if (_searchResultItems.length > 0) _showResultsBar(); else _hideResultsBar();
|
||||
}
|
||||
|
||||
window.navigateSearchResults = function(delta) {
|
||||
@@ -161,7 +166,7 @@ function initSearch() {
|
||||
// Initially hide clear button
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
|
||||
// Input handler: debounced search + autocomplete dropdown
|
||||
// Input handler: autocomplete dropdown only (search triggered on Enter or selection)
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
@@ -169,23 +174,11 @@ function initSearch() {
|
||||
// Show autocomplete dropdown while typing
|
||||
AutocompleteDropdown.populate(input.value, input.selectionStart);
|
||||
|
||||
// Debounced search execution
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.searchTimeout = setTimeout(
|
||||
() => {
|
||||
const q = input.value.trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
state.advancedSearchOffset = 0;
|
||||
if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) {
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
} else if (q.length === 0) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
},
|
||||
_getEffective("debounce_ms", 300),
|
||||
);
|
||||
// Clear results when input becomes empty
|
||||
if (!hasText) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus handler: show history dropdown
|
||||
@@ -209,7 +202,7 @@ function initSearch() {
|
||||
AutocompleteDropdown.navigateUp();
|
||||
} else if (e.key === "Enter") {
|
||||
// First: check dropdown suggestions (higher priority than search results)
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
if (AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -235,30 +228,28 @@ function initSearch() {
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) {
|
||||
// Navigate search results when dropdown is closed
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Escape") {
|
||||
AutocompleteDropdown.hide();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
} else {
|
||||
// Dropdown not visible: navigate search results or execute search
|
||||
if (e.key === "ArrowDown") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Enter") {
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -269,6 +260,7 @@ function initSearch() {
|
||||
state.searchWholeWord = false;
|
||||
state.searchRegex = false;
|
||||
_updateToggleUI();
|
||||
_hideResultsBar();
|
||||
SearchChips.clear();
|
||||
AutocompleteDropdown.hide();
|
||||
showWelcome();
|
||||
@@ -327,6 +319,11 @@ async function loadSavedSearches() {
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
list.innerHTML = searches.map(s => {
|
||||
var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths;
|
||||
var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query);
|
||||
var typeIcon = isDirSearch
|
||||
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
|
||||
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
|
||||
const badges = [];
|
||||
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||||
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||||
@@ -336,16 +333,19 @@ async function loadSavedSearches() {
|
||||
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">\u{1F4E4} ${escapeHtml(s.exclude_paths)}</span>`);
|
||||
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">\u{1F4C1} ${escapeHtml(s.vault)}</span>` : "";
|
||||
return `
|
||||
<div class="saved-search-item">
|
||||
<div class="saved-search-query">${escapeHtml(s.query)}</div>
|
||||
<div class="saved-search-item${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
|
||||
<div class="saved-search-query">${displayQuery}</div>
|
||||
<div class="saved-search-meta">
|
||||
${badges.join("")}
|
||||
${vaultStr}
|
||||
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
|
||||
${typeIcon}
|
||||
</div>
|
||||
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer"><E2><9C><95></button>
|
||||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
|
||||
</div>
|
||||
`}).join("");
|
||||
// Apply saved filter + init filter bar
|
||||
if (typeof _applySavedFilter === 'function') _applySavedFilter();
|
||||
if (typeof _initSavedFilter === 'function') _initSavedFilter();
|
||||
list.querySelectorAll(".saved-search-item").forEach(item => {
|
||||
item.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("saved-search-delete")) return;
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
/**
|
||||
* Mermaid.js integration for ObsiGate.
|
||||
*
|
||||
* Handles initialization, rendering, templates, zoom/pan,
|
||||
* fullscreen, focus mode, theme sync, and Obsidian compatibility.
|
||||
*
|
||||
* Usage: import { initMermaid, renderMermaidBlocks } from './mermaid-viewer.js';
|
||||
*/
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration (#20)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _mermaidReady = false;
|
||||
var _currentTheme = 'default';
|
||||
|
||||
export function initMermaid() {
|
||||
if (typeof mermaid === 'undefined') {
|
||||
console.warn('[Mermaid] library not loaded');
|
||||
return;
|
||||
}
|
||||
mermaid.initialize(_getMermaidConfig(_currentTheme === 'dark'));
|
||||
_mermaidReady = true;
|
||||
}
|
||||
|
||||
export function updateMermaidTheme(isDark) {
|
||||
_currentTheme = isDark ? 'dark' : 'default';
|
||||
if (_mermaidReady) {
|
||||
mermaid.initialize(_getMermaidConfig(isDark));
|
||||
}
|
||||
|
||||
// Re-render all existing Mermaid containers with the new theme
|
||||
var containers = document.querySelectorAll('.mermaid-container');
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
reRenderContainer(containers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Unified theme config — used by both init and theme toggle
|
||||
function _getMermaidConfig(isDark) {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'base',
|
||||
fontFamily: 'inherit',
|
||||
themeVariables: isDark ? DARK_THEME : LIGHT_THEME,
|
||||
};
|
||||
}
|
||||
|
||||
// Dark theme — optimized for readability on dark backgrounds
|
||||
var DARK_THEME = {
|
||||
// Flowchart / general shapes
|
||||
primaryColor: '#1e3a5f',
|
||||
primaryTextColor: '#c8daf0',
|
||||
primaryBorderColor: '#4a7ab5',
|
||||
lineColor: '#5a7a9a',
|
||||
secondaryColor: '#2a4a6a',
|
||||
tertiaryColor: '#3a6090',
|
||||
// Background & containers
|
||||
background: '#0f1117',
|
||||
mainBkg: '#1a2030',
|
||||
nodeBorder: '#4a7ab5',
|
||||
clusterBkg: '#1a2235',
|
||||
clusterBorder: '#3a5a80',
|
||||
titleColor: '#a0c0e0',
|
||||
edgeLabelBackground: '#1a2d47',
|
||||
nodeTextColor: '#c8daf0',
|
||||
// Sequence diagram
|
||||
actorBkg: '#1e3a5f',
|
||||
actorBorder: '#4a7ab5',
|
||||
actorTextColor: '#c8daf0',
|
||||
actorLineColor: '#4a7ab5',
|
||||
signalColor: '#c8daf0',
|
||||
signalTextColor: '#c8daf0',
|
||||
labelBoxBkgColor: '#1e3a5f',
|
||||
labelBoxBorderColor: '#4a7ab5',
|
||||
labelTextColor: '#c8daf0',
|
||||
loopTextColor: '#c8daf0',
|
||||
noteBkgColor: '#2a3550',
|
||||
noteBorderColor: '#4a7ab5',
|
||||
noteTextColor: '#c8daf0',
|
||||
activationBkgColor: '#3a5a80',
|
||||
// Gantt
|
||||
sectionBkgColor: '#1a2d47',
|
||||
altSectionBkgColor: '#1e3350',
|
||||
sectionBkgColor2: '#1a2d47',
|
||||
taskBkgColor: '#1e3a5f',
|
||||
taskBorderColor: '#4a7ab5',
|
||||
taskTextColor: '#c8daf0',
|
||||
taskTextOutsideColor: '#8aacca',
|
||||
taskTextLightColor: '#e0e8f0',
|
||||
gridColor: '#2a3a55',
|
||||
activeTaskBkgColor: '#2a5a8a',
|
||||
activeTaskBorderColor: '#6a9ad0',
|
||||
doneTaskBkgColor: '#1a3a4f',
|
||||
doneTaskBorderColor: '#3a7a9a',
|
||||
critBkgColor: '#5a2a3a',
|
||||
critBorderColor: '#a04050',
|
||||
todayLineColor: '#4a7ab5',
|
||||
// Git graph
|
||||
git0: '#4a7ab5',
|
||||
git1: '#3a6a9a',
|
||||
git2: '#5a8ac0',
|
||||
git3: '#2a5a8a',
|
||||
git4: '#6a9ad0',
|
||||
git5: '#3a7aaa',
|
||||
git6: '#4a8abf',
|
||||
git7: '#5a9acf',
|
||||
gitInv0: '#c8daf0',
|
||||
gitInv1: '#b0c8e0',
|
||||
gitInv2: '#d0e0f0',
|
||||
gitInv3: '#a0b8d0',
|
||||
gitBranchLabel0: '#c8daf0',
|
||||
gitBranchLabel1: '#b0c8e0',
|
||||
gitBranchLabel2: '#d0e0f0',
|
||||
commitLabelColor: '#c8daf0',
|
||||
commitLabelBackground: '#1e3a5f',
|
||||
commitLabelFontSize: '11px',
|
||||
tagLabelColor: '#a0c0e0',
|
||||
tagLabelBackground: '#1a2d47',
|
||||
tagLabelBorder: '#4a7ab5',
|
||||
// Pie chart
|
||||
pie1: '#1e3a5f',
|
||||
pie2: '#2a5a8a',
|
||||
pie3: '#153050',
|
||||
pie4: '#3a6a9a',
|
||||
pie5: '#204070',
|
||||
pie6: '#4a7ab5',
|
||||
pie7: '#255080',
|
||||
pie8: '#5a8ac0',
|
||||
pieTitleTextColor: '#c8daf0',
|
||||
pieSectionTextColor: '#c8daf0',
|
||||
pieLegendTextColor: '#a0c0e0',
|
||||
pieStrokeColor: '#0f1117',
|
||||
// Class diagram
|
||||
classText: '#c8daf0',
|
||||
// State diagram
|
||||
labelColor: '#c8daf0',
|
||||
// ER diagram — dark alternating rows for readability
|
||||
fillType0: '#111d30',
|
||||
fillType1: '#152238',
|
||||
fillType2: '#111d30',
|
||||
fillType3: '#152238',
|
||||
fillType4: '#111d30',
|
||||
fillType5: '#152238',
|
||||
fillType6: '#111d30',
|
||||
fillType7: '#152238',
|
||||
entityBorder: '#4a7ab5',
|
||||
entityBackground: '#0d1520',
|
||||
attributeEvenColor: '#ffffff',
|
||||
attributeOddColor: '#c8daf0',
|
||||
// Timeline — section colors
|
||||
cScale0: '#1e3a5f',
|
||||
cScale1: '#2a5a8a',
|
||||
cScale2: '#153050',
|
||||
cScale3: '#3a6a9a',
|
||||
cScale4: '#204070',
|
||||
cScale5: '#4a7ab5',
|
||||
cScaleLabel0: '#c8daf0',
|
||||
cScaleLabel1: '#c8daf0',
|
||||
cScaleLabel2: '#c8daf0',
|
||||
cScaleLabel3: '#c8daf0',
|
||||
cScaleLabel4: '#c8daf0',
|
||||
cScaleLabel5: '#c8daf0',
|
||||
// XY Chart — bar and line colors
|
||||
xyChartBackgroundColor: '#0f1117',
|
||||
xyChartTitleColor: '#c8daf0',
|
||||
xyChartXaxisTitleColor: '#8aacca',
|
||||
xyChartYaxisTitleColor: '#8aacca',
|
||||
plotColor1: '#1e3a5f',
|
||||
plotColor2: '#4a7ab5',
|
||||
plotColor3: '#2a5a8a',
|
||||
plotColor4: '#5a8ac0',
|
||||
plotColor5: '#3a6a9a',
|
||||
plotColor6: '#6a9ad0',
|
||||
plotColor7: '#153050',
|
||||
plotColor8: '#204070',
|
||||
// General text
|
||||
textColor: '#c8daf0',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
// Light theme — clean, minimal
|
||||
var LIGHT_THEME = {
|
||||
primaryColor: '#f0f0f5',
|
||||
primaryTextColor: '#1a1a2e',
|
||||
primaryBorderColor: '#ccc',
|
||||
lineColor: '#666',
|
||||
secondaryColor: '#e8e8f0',
|
||||
tertiaryColor: '#fafafa',
|
||||
background: '#ffffff',
|
||||
mainBkg: '#f5f5fa',
|
||||
nodeBorder: '#ccc',
|
||||
clusterBkg: '#f0f0f5',
|
||||
clusterBorder: '#ddd',
|
||||
titleColor: '#333',
|
||||
edgeLabelBackground: '#f5f5fa',
|
||||
nodeTextColor: '#1a1a2e',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Templates for all diagram types (#22)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export var MERMAID_TEMPLATES = {
|
||||
flowchart: 'flowchart TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[OK]\n B -->|No| D[Cancel]',
|
||||
sequenceDiagram: 'sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi!',
|
||||
classDiagram: 'classDiagram\n class Animal {\n +name: string\n +age: int\n +makeSound()\n }\n class Dog {\n +bark()\n }\n Animal <|-- Dog',
|
||||
'stateDiagram-v2': 'stateDiagram-v2\n [*] --> Idle\n Idle --> Running : start\n Running --> Idle : stop\n Running --> [*] : done',
|
||||
erDiagram: 'erDiagram\n CUSTOMER ||--o{ ORDER : places\n ORDER ||--|{ LINE-ITEM : contains',
|
||||
journey: 'journey\n title My day\n section Morning\n Wake up: 5: Me\n Breakfast: 3: Me\n section Work\n Code: 5: Me, Cat',
|
||||
gantt: 'gantt\n title Project Plan\n dateFormat YYYY-MM-DD\n section Design\n Mockups :a1, 2024-01-01, 7d\n section Dev\n Backend :a2, after a1, 14d',
|
||||
pie: 'pie title Languages\n "Python" : 45\n "JavaScript" : 30\n "Go" : 15\n "Rust" : 10',
|
||||
gitGraph: 'gitGraph\n commit\n branch feature\n checkout feature\n commit\n checkout main\n merge feature',
|
||||
requirementDiagram: 'requirementDiagram\n requirement req1 {\n id: 1\n text: System shall do X\n }\n element Component {\n type: package\n }\n Component - satisfies -> req1',
|
||||
mindmap: 'mindmap\n root((Project))\n Frontend\n HTML\n CSS\n JavaScript\n Backend\n API\n Database',
|
||||
timeline: 'timeline\n title History\n 2020 : Started\n 2022 : v1.0\n 2024 : v2.0',
|
||||
quadrantChart: 'quadrantChart\n title Products\n x-axis Low Reach --> High Reach\n y-axis Low Value --> High Value\n quadrant-1 Stars\n quadrant-2 Question Marks\n Product A: [0.8, 0.6]\n Product B: [0.3, 0.8]',
|
||||
C4Context: 'C4Context\n title System Context\n Person(user, "User")\n System(app, "My App")\n System_Ext(db, "Database")\n Rel(user, app, "Uses")\n Rel(app, db, "Reads/Writes")',
|
||||
C4Container: 'C4Container\n title Containers\n Container(web, "Web App", "React")\n Container(api, "API", "FastAPI")\n ContainerDb(db, "DB", "PostgreSQL")\n Rel(web, api, "REST")\n Rel(api, db, "SQL")',
|
||||
C4Component: 'C4Component\n title API Components\n Container_Boundary(api, "API") {\n Component(auth, "Auth Service")\n Component(search, "Search Engine")\n }',
|
||||
C4Dynamic: 'C4Dynamic\n title Login Flow\n Person(user, "User")\n Container(web, "Web App")\n Container(api, "API")\n Rel(user, web, "POST /login")\n Rel(web, api, "authenticate")',
|
||||
C4Deployment: 'C4Deployment\n title Deployment\n Deployment_Node(cloud, "Cloud") {\n Container(web, "Web")\n Container(api, "API")\n }',
|
||||
'xychart-beta': 'xychart-beta\n title "Sales"\n x-axis [Jan, Feb, Mar, Apr]\n y-axis "Revenue" 0 --> 100\n bar [45, 60, 75, 90]\n line [30, 45, 55, 70]',
|
||||
'block-beta': 'block-beta\n columns 3\n A B C\n D E F',
|
||||
'sankey-beta': 'sankey-beta\n A, B, 50\n A, C, 30\n B, D, 20\n C, D, 30',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering engine (#21)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _renderCounter = 0;
|
||||
|
||||
export async function renderMermaidBlocks(container) {
|
||||
if (!_mermaidReady || typeof mermaid === 'undefined') return;
|
||||
|
||||
var blocks = container.querySelectorAll('pre code.language-mermaid, .mermaid-code');
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
for (var i = 0; i < blocks.length; i++) {
|
||||
var codeBlock = blocks[i];
|
||||
var originalCode = codeBlock.textContent || codeBlock.innerText || '';
|
||||
if (!originalCode.trim()) continue;
|
||||
|
||||
var wrapper = codeBlock.closest('pre') || codeBlock.parentElement;
|
||||
if (!wrapper) continue;
|
||||
|
||||
// Check if already rendered
|
||||
if (wrapper.querySelector('.mermaid-rendered')) continue;
|
||||
|
||||
try {
|
||||
// Preprocess Obsidian syntax before Mermaid parsing (#24)
|
||||
var code = preprocessObsidian(originalCode);
|
||||
await mermaid.parse(code);
|
||||
var id = 'mermaid-' + (++_renderCounter) + '-' + Date.now();
|
||||
var result = await mermaid.render(id, code);
|
||||
|
||||
// Extract diagram type from first non-comment, non-empty line
|
||||
var diagramType = extractDiagramType(code);
|
||||
|
||||
// Build container with header + body
|
||||
var containerDiv = document.createElement('div');
|
||||
containerDiv.className = 'mermaid-container';
|
||||
containerDiv.setAttribute('data-mermaid-code', originalCode);
|
||||
|
||||
// Header bar
|
||||
var header = document.createElement('div');
|
||||
header.className = 'mermaid-header';
|
||||
header.innerHTML =
|
||||
'<span class="mermaid-diagram-type">' + escapeHtml(diagramType) + '</span>' +
|
||||
'<div class="mermaid-header-actions">' +
|
||||
'<button class="mermaid-btn mermaid-zoom-in" title="Zoom +"><i data-lucide="zoom-in" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-zoom-out" title="Zoom -"><i data-lucide="zoom-out" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fullscreen" title="Plein ecran"><i data-lucide="expand" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-copy-svg" title="Copier SVG"><i data-lucide="copy" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-copy-code" title="Copier le code Mermaid"><i data-lucide="file-code-2" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-download" title="Telecharger PNG"><i data-lucide="download" style="width:14px;height:14px"></i></button>' +
|
||||
'<div class="mermaid-toggle">' +
|
||||
'<button class="mermaid-toggle-btn active" data-mode="preview">Preview</button>' +
|
||||
'<button class="mermaid-toggle-btn" data-mode="code">Code</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Body
|
||||
var body = document.createElement('div');
|
||||
body.className = 'mermaid-body';
|
||||
body.innerHTML =
|
||||
'<div class="mermaid-rendered">' + result.svg + '</div>' +
|
||||
'<div class="mermaid-source" style="display:none"><pre><code class="language-mermaid">' + escapeHtml(originalCode) + '</code></pre></div>';
|
||||
|
||||
containerDiv.appendChild(header);
|
||||
containerDiv.appendChild(body);
|
||||
|
||||
wrapper.parentNode.replaceChild(containerDiv, wrapper);
|
||||
|
||||
// Wire up header buttons
|
||||
wireHeaderButtons(containerDiv, originalCode);
|
||||
setupZoomPan(containerDiv);
|
||||
setupCodePreviewToggle(containerDiv);
|
||||
// Patch beta diagram colors for dark mode
|
||||
if (_currentTheme === 'dark') patchBetaColors(containerDiv, diagramType);
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
var errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'mermaid-error';
|
||||
errorDiv.innerHTML = '<strong>Erreur Mermaid</strong><br>' + escapeHtml(err.message || String(err));
|
||||
wrapper.parentNode.replaceChild(errorDiv, wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header button wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wireHeaderButtons(container, originalCode) {
|
||||
// Copy SVG
|
||||
var copySvgBtn = container.querySelector('.mermaid-copy-svg');
|
||||
if (copySvgBtn) {
|
||||
copySvgBtn.addEventListener('click', function() {
|
||||
copySvgToClipboard(container);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy code
|
||||
var copyCodeBtn = container.querySelector('.mermaid-copy-code');
|
||||
if (copyCodeBtn) {
|
||||
copyCodeBtn.addEventListener('click', function() {
|
||||
copyToClipboard(originalCode);
|
||||
});
|
||||
}
|
||||
|
||||
// Download PNG
|
||||
var downloadBtn = container.querySelector('.mermaid-download');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', function() {
|
||||
exportMermaidPNG(container);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code / Preview toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupCodePreviewToggle(container) {
|
||||
var toggleBtns = container.querySelectorAll('.mermaid-toggle-btn');
|
||||
var rendered = container.querySelector('.mermaid-rendered');
|
||||
var source = container.querySelector('.mermaid-source');
|
||||
var zoomBtns = container.querySelectorAll('.mermaid-zoom-in, .mermaid-zoom-out');
|
||||
var fullscreenBtn = container.querySelector('.mermaid-fullscreen');
|
||||
|
||||
for (var i = 0; i < toggleBtns.length; i++) {
|
||||
toggleBtns[i].addEventListener('click', function() {
|
||||
var mode = this.getAttribute('data-mode');
|
||||
|
||||
// Update active state
|
||||
for (var j = 0; j < toggleBtns.length; j++) {
|
||||
toggleBtns[j].classList.remove('active');
|
||||
}
|
||||
this.classList.add('active');
|
||||
|
||||
if (mode === 'preview') {
|
||||
rendered.style.display = '';
|
||||
source.style.display = 'none';
|
||||
// Show zoom/fullscreen buttons in preview mode
|
||||
for (var k = 0; k < zoomBtns.length; k++) zoomBtns[k].style.display = '';
|
||||
if (fullscreenBtn) fullscreenBtn.style.display = '';
|
||||
} else {
|
||||
rendered.style.display = 'none';
|
||||
source.style.display = '';
|
||||
// Hide zoom/fullscreen in code mode
|
||||
for (var k = 0; k < zoomBtns.length; k++) zoomBtns[k].style.display = 'none';
|
||||
if (fullscreenBtn) fullscreenBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagram type extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractDiagramType(code) {
|
||||
var lines = code.trim().split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
// Skip comments and empty lines
|
||||
if (!line || line.indexOf('%%') === 0) continue;
|
||||
// Return the first meaningful word(s)
|
||||
var words = line.split(/\s+/);
|
||||
if (words.length >= 2) {
|
||||
return words[0] + ' ' + words[1];
|
||||
}
|
||||
return words[0] || 'mermaid';
|
||||
}
|
||||
return 'mermaid';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-render a container after theme change
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function reRenderContainer(container) {
|
||||
var code = container.getAttribute('data-mermaid-code');
|
||||
if (!code) return;
|
||||
|
||||
var rendered = container.querySelector('.mermaid-rendered');
|
||||
if (!rendered) return;
|
||||
|
||||
try {
|
||||
var id = 'mermaid-' + (++_renderCounter) + '-' + Date.now();
|
||||
var result = await mermaid.render(id, preprocessObsidian(code));
|
||||
rendered.innerHTML = result.svg;
|
||||
|
||||
// Reset zoom/pan state on the SVG
|
||||
var svg = rendered.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.transform = '';
|
||||
svg.style.cursor = 'grab';
|
||||
}
|
||||
// Patch beta diagram colors for dark mode
|
||||
if (_currentTheme === 'dark') {
|
||||
var diagramType = extractDiagramType(code);
|
||||
patchBetaColors(container, diagramType);
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep old rendering on error
|
||||
console.warn('[Mermaid] Re-render failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patch beta diagram colors for dark mode (inline SVG attributes beat CSS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function patchBetaColors(container, diagramType) {
|
||||
var svg = container.querySelector('.mermaid-rendered svg');
|
||||
if (!svg) return;
|
||||
|
||||
// packet-beta — override white block backgrounds
|
||||
if (diagramType.indexOf('packet') !== -1) {
|
||||
var rects = svg.querySelectorAll('rect');
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
var r = rects[i];
|
||||
var fill = r.getAttribute('fill');
|
||||
// Only override if the fill is light/white (preserve dark fills)
|
||||
if (!fill || fill === '#f4f4f4' || fill === '#fff' || fill === '#ffffff' || fill === 'white' || fill === '#e8e8e8') {
|
||||
r.setAttribute('fill', '#1e3a5f');
|
||||
r.setAttribute('stroke', '#4a7ab5');
|
||||
}
|
||||
}
|
||||
// Text color
|
||||
var texts = svg.querySelectorAll('text');
|
||||
for (var j = 0; j < texts.length; j++) {
|
||||
var t = texts[j];
|
||||
var tfill = t.getAttribute('fill');
|
||||
if (!tfill || tfill === '#000' || tfill === '#333' || tfill === 'black') {
|
||||
t.setAttribute('fill', '#c8daf0');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xychart-beta — override cream bars and orange line
|
||||
if (diagramType.indexOf('xychart') !== -1) {
|
||||
var allRects = svg.querySelectorAll('rect');
|
||||
for (var k = 0; k < allRects.length; k++) {
|
||||
var rr = allRects[k];
|
||||
var f = rr.getAttribute('fill');
|
||||
if (!f || f === '#f4f4f4' || f === '#fff' || f === '#ffffff' || f === 'white' || f.indexOf('cream') !== -1 || f === '#ececec') {
|
||||
rr.setAttribute('fill', '#1e3a5f');
|
||||
rr.setAttribute('stroke', '#4a7ab5');
|
||||
}
|
||||
}
|
||||
// Line color
|
||||
var paths = svg.querySelectorAll('path');
|
||||
for (var m = 0; m < paths.length; m++) {
|
||||
var p = paths[m];
|
||||
var ps = p.getAttribute('stroke');
|
||||
if (!ps || ps === '#ffa500' || ps === 'orange' || ps === '#ff8c00') {
|
||||
p.setAttribute('stroke', '#6a9ad0');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zoom & Pan (#27)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupZoomPan(container) {
|
||||
var svgWrapper = container.querySelector('.mermaid-rendered');
|
||||
if (!svgWrapper) return;
|
||||
var svg = svgWrapper.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
var scale = 1;
|
||||
var panX = 0, panY = 0;
|
||||
var dragging = false, startX, startY;
|
||||
|
||||
svg.style.transformOrigin = '0 0';
|
||||
svg.style.cursor = 'grab';
|
||||
|
||||
function update() {
|
||||
svg.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
|
||||
}
|
||||
|
||||
svg.addEventListener('mousedown', function(e) {
|
||||
dragging = true;
|
||||
startX = e.clientX - panX;
|
||||
startY = e.clientY - panY;
|
||||
svg.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!dragging) return;
|
||||
panX = e.clientX - startX;
|
||||
panY = e.clientY - startY;
|
||||
update();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
dragging = false;
|
||||
if (svg) svg.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
svg.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
scale = Math.max(0.2, Math.min(5, scale * delta));
|
||||
update();
|
||||
}, { passive: false });
|
||||
|
||||
var zoomIn = container.querySelector('.mermaid-zoom-in');
|
||||
var zoomOut = container.querySelector('.mermaid-zoom-out');
|
||||
if (zoomIn) zoomIn.addEventListener('click', function() { scale = Math.min(5, scale * 1.3); update(); });
|
||||
if (zoomOut) zoomOut.addEventListener('click', function() { scale = Math.max(0.2, scale * 0.7); update(); });
|
||||
|
||||
// Fullscreen (#28)
|
||||
var fsBtn = container.querySelector('.mermaid-fullscreen');
|
||||
if (fsBtn) {
|
||||
fsBtn.addEventListener('click', function() {
|
||||
openFullscreen(container);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen mode (#28) — with header bar + full-size diagram
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openFullscreen(container) {
|
||||
var svgWrapper = container.querySelector('.mermaid-rendered');
|
||||
var diagramType = container.querySelector('.mermaid-diagram-type');
|
||||
var typeLabel = diagramType ? diagramType.textContent : 'mermaid';
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'mermaid-fullscreen-overlay';
|
||||
|
||||
// Fullscreen header bar
|
||||
overlay.innerHTML =
|
||||
'<div class="mermaid-fullscreen-header">' +
|
||||
'<span class="mermaid-diagram-type">' + escapeHtml(typeLabel) + '</span>' +
|
||||
'<div class="mermaid-header-actions">' +
|
||||
'<button class="mermaid-btn mermaid-fs-zoom-in" title="Zoom +"><i data-lucide="zoom-in" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-zoom-out" title="Zoom -"><i data-lucide="zoom-out" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-copy-svg" title="Copier SVG"><i data-lucide="copy" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-copy-code" title="Copier le code Mermaid"><i data-lucide="file-code-2" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-download" title="Telecharger PNG"><i data-lucide="download" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-close" title="Fermer (Echap)"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mermaid-fullscreen-content"></div>';
|
||||
|
||||
var content = overlay.querySelector('.mermaid-fullscreen-content');
|
||||
content.innerHTML = svgWrapper.innerHTML;
|
||||
var svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.cursor = 'default';
|
||||
}
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Fullscreen zoom
|
||||
var fsScale = 1, fsPanX = 0, fsPanY = 0, fsDragging = false, fsStartX, fsStartY;
|
||||
if (svg) {
|
||||
svg.style.transformOrigin = '0 0';
|
||||
function fsUpdate() {
|
||||
svg.style.transform = 'translate(' + fsPanX + 'px, ' + fsPanY + 'px) scale(' + fsScale + ')';
|
||||
}
|
||||
|
||||
svg.addEventListener('mousedown', function(e) {
|
||||
fsDragging = true;
|
||||
fsStartX = e.clientX - fsPanX;
|
||||
fsStartY = e.clientY - fsPanY;
|
||||
svg.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!fsDragging) return;
|
||||
fsPanX = e.clientX - fsStartX;
|
||||
fsPanY = e.clientY - fsStartY;
|
||||
fsUpdate();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
fsDragging = false;
|
||||
if (svg) svg.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
svg.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
fsScale = Math.max(0.2, Math.min(5, fsScale * delta));
|
||||
fsUpdate();
|
||||
}, { passive: false });
|
||||
|
||||
var fsZoomIn = overlay.querySelector('.mermaid-fs-zoom-in');
|
||||
var fsZoomOut = overlay.querySelector('.mermaid-fs-zoom-out');
|
||||
if (fsZoomIn) fsZoomIn.addEventListener('click', function() { fsScale = Math.min(5, fsScale * 1.3); fsUpdate(); });
|
||||
if (fsZoomOut) fsZoomOut.addEventListener('click', function() { fsScale = Math.max(0.2, fsScale * 0.7); fsUpdate(); });
|
||||
}
|
||||
|
||||
// Copy SVG in fullscreen
|
||||
var fsCopySvg = overlay.querySelector('.mermaid-fs-copy-svg');
|
||||
if (fsCopySvg) {
|
||||
fsCopySvg.addEventListener('click', function() {
|
||||
copySvgFromElement(content);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy code in fullscreen
|
||||
var fsCopyCode = overlay.querySelector('.mermaid-fs-copy-code');
|
||||
if (fsCopyCode) {
|
||||
fsCopyCode.addEventListener('click', function() {
|
||||
var code = container.getAttribute('data-mermaid-code') || '';
|
||||
copyToClipboard(code);
|
||||
});
|
||||
}
|
||||
|
||||
// Download PNG in fullscreen
|
||||
var fsDownload = overlay.querySelector('.mermaid-fs-download');
|
||||
if (fsDownload) {
|
||||
fsDownload.addEventListener('click', function() {
|
||||
exportMermaidPNGFromElement(svg);
|
||||
});
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
var escHandler = function(e) {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
overlay.querySelector('.mermaid-fs-close').addEventListener('click', close);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copy SVG to clipboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function copySvgToClipboard(container) {
|
||||
copySvgFromElement(container.querySelector('.mermaid-rendered'));
|
||||
}
|
||||
|
||||
function copySvgFromElement(el) {
|
||||
if (!el) return;
|
||||
var svg = el.querySelector('svg');
|
||||
if (!svg) return;
|
||||
var data = new XMLSerializer().serializeToString(svg);
|
||||
copyToClipboard(data);
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export SVG/PNG (#24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function exportMermaidSVG(container) {
|
||||
var svg = container.querySelector('.mermaid-rendered svg');
|
||||
if (!svg) return;
|
||||
var data = new XMLSerializer().serializeToString(svg);
|
||||
var blob = new Blob([data], { type: 'image/svg+xml' });
|
||||
downloadBlob(blob, 'diagram.svg');
|
||||
}
|
||||
|
||||
export function exportMermaidPNG(container) {
|
||||
var svg = container.querySelector('.mermaid-rendered svg');
|
||||
exportMermaidPNGFromElement(svg);
|
||||
}
|
||||
|
||||
function exportMermaidPNGFromElement(svg) {
|
||||
if (!svg) return;
|
||||
var data = new XMLSerializer().serializeToString(svg);
|
||||
var canvas = document.createElement('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var img = new Image();
|
||||
var blob = new Blob([data], { type: 'image/svg+xml' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = function() {
|
||||
canvas.width = img.width * 2;
|
||||
canvas.height = img.height * 2;
|
||||
ctx.scale(2, 2);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(function(pngBlob) {
|
||||
downloadBlob(pngBlob, 'diagram.png');
|
||||
}, 'image/png');
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copy text to clipboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(function() {
|
||||
fallbackCopy(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus mode (#29) — click diagram to enlarge in a side panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _focusPanel = null;
|
||||
|
||||
export function setupFocusMode() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var container = e.target.closest('.mermaid-container');
|
||||
if (!container) return;
|
||||
// Don't trigger focus if clicking header or controls
|
||||
if (e.target.closest('.mermaid-header') || e.target.closest('button')) return;
|
||||
|
||||
var svgWrapper = container.querySelector('.mermaid-rendered');
|
||||
if (!svgWrapper) return;
|
||||
|
||||
if (_focusPanel && _focusPanel.style.display !== 'none') {
|
||||
updateFocusPanel(svgWrapper);
|
||||
} else {
|
||||
showFocusPanel(svgWrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showFocusPanel(svgWrapper) {
|
||||
if (!_focusPanel) {
|
||||
_focusPanel = document.createElement('div');
|
||||
_focusPanel.className = 'mermaid-focus-panel';
|
||||
_focusPanel.innerHTML =
|
||||
'<div class="mermaid-focus-header">' +
|
||||
'<span>Diagramme</span>' +
|
||||
'<button class="mermaid-focus-close" title="Fermer"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="mermaid-focus-content"></div>';
|
||||
document.body.appendChild(_focusPanel);
|
||||
|
||||
_focusPanel.querySelector('.mermaid-focus-close').addEventListener('click', function() {
|
||||
_focusPanel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
_focusPanel.style.display = 'block';
|
||||
updateFocusPanel(svgWrapper);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
function updateFocusPanel(svgWrapper) {
|
||||
if (!_focusPanel) return;
|
||||
var content = _focusPanel.querySelector('.mermaid-focus-content');
|
||||
content.innerHTML = svgWrapper.innerHTML;
|
||||
var svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = 'auto';
|
||||
svg.style.maxHeight = '80vh';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Obsidian compatibility pre-processor (#24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function preprocessObsidian(code) {
|
||||
return code
|
||||
.replace(/!\[\[([^\]]+?)\]\]/g, function(_, m) {
|
||||
var parts = m.split('|');
|
||||
var src = parts[0].trim();
|
||||
var width = parts[1] ? parts[1].trim() : null;
|
||||
if (width && /^\d+$/.test(width)) {
|
||||
return '<img src="' + src + '" width="' + width + '" />';
|
||||
}
|
||||
return '<img src="' + src + '" />';
|
||||
})
|
||||
.replace(/\[\[([^\]]+?)\]\]/g, '<a href="$1">$1</a>')
|
||||
.replace(/==([^=]+)==/g, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export function initMobileToolbar() {
|
||||
if (!pill) return;
|
||||
const vaultName = state.selectedContextVault || 'all';
|
||||
if (vaultName === 'all') {
|
||||
pill.textContent = '📂 Toutes les vaults';
|
||||
pill.textContent = t("mobile.all_vaults");
|
||||
} else {
|
||||
pill.textContent = '📂 ' + vaultName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/* ObsiGate — Offline IndexedDB Storage
|
||||
* Stores: file index, file contents, pending changes queue
|
||||
* Schema v1:
|
||||
* files store: { path, vault, title, tags[], snippet, modified, tokens[], weight }
|
||||
* content store: { key: "vault::path", content: string, modified }
|
||||
* pending store: { id, vault, path, content, action, timestamp }
|
||||
*/
|
||||
|
||||
const DB_NAME = 'obsigate-offline';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let _db = null;
|
||||
let _dbPromise = null;
|
||||
|
||||
export const OfflineDB = {
|
||||
|
||||
async open() {
|
||||
if (_db) return _db;
|
||||
if (_dbPromise) return _dbPromise;
|
||||
|
||||
_dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
console.log('[OfflineDB] Upgrading to v' + DB_VERSION);
|
||||
|
||||
// File index store — keyed by composite "vault::path"
|
||||
if (!db.objectStoreNames.contains('files')) {
|
||||
const store = db.createObjectStore('files', { keyPath: 'key' });
|
||||
store.createIndex('vault', 'vault', { unique: false });
|
||||
store.createIndex('modified', 'modified', { unique: false });
|
||||
store.createIndex('title', 'title', { unique: false });
|
||||
}
|
||||
|
||||
// File contents store — keyed by "vault::path"
|
||||
if (!db.objectStoreNames.contains('content')) {
|
||||
db.createObjectStore('content', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// Pending changes queue — auto-increment key
|
||||
if (!db.objectStoreNames.contains('pending')) {
|
||||
const store = db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = (e) => {
|
||||
_db = e.target.result;
|
||||
console.log('[OfflineDB] Database opened');
|
||||
resolve(_db);
|
||||
};
|
||||
|
||||
req.onerror = () => {
|
||||
_dbPromise = null;
|
||||
reject(req.error);
|
||||
};
|
||||
});
|
||||
|
||||
return _dbPromise;
|
||||
},
|
||||
|
||||
/* ── File Index ── */
|
||||
|
||||
async putFileIndex(file) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('files', 'readwrite');
|
||||
const store = tx.objectStore('files');
|
||||
const key = file.vault + '::' + file.path;
|
||||
const doc = { key: key, vault: file.vault, path: file.path,
|
||||
title: file.title, tags: file.tags || [], snippet: file.snippet || '',
|
||||
modified: file.modified || '', tokens: file.tokens || [], weight: file.score || 0 };
|
||||
store.put(doc);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async putFileIndexBatch(files) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('files', 'readwrite');
|
||||
const store = tx.objectStore('files');
|
||||
for (const file of files) {
|
||||
const key = file.vault + '::' + file.path;
|
||||
store.put({
|
||||
key: key, vault: file.vault, path: file.path,
|
||||
title: file.title, tags: file.tags || [], snippet: file.snippet || '',
|
||||
modified: file.modified || '', tokens: file.tokens || [], score: file.score || 0
|
||||
});
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async getFileIndex(vault, path) {
|
||||
const db = await this.open();
|
||||
const key = vault + '::' + path;
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readonly');
|
||||
const store = tx.objectStore('files');
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
},
|
||||
|
||||
/* Search in file index — title matching and prefix */
|
||||
async searchFileIndex(query, vaultFilter) {
|
||||
const db = await this.open();
|
||||
const lowerQ = query.toLowerCase();
|
||||
const words = lowerQ.split(/\s+/).filter(Boolean);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readonly');
|
||||
const store = tx.objectStore('files');
|
||||
const results = [];
|
||||
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) {
|
||||
// Sort by relevance: title match > path match > snippet match
|
||||
results.sort((a, b) => b._score - a._score);
|
||||
resolve(results.slice(0, 50));
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = cursor.value;
|
||||
|
||||
// Vault filter
|
||||
if (vaultFilter && vaultFilter !== 'all' && doc.vault !== vaultFilter) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Score based on match quality
|
||||
let score = 0;
|
||||
const titleLower = doc.title.toLowerCase();
|
||||
const pathLower = doc.path.toLowerCase();
|
||||
const snippetLower = (doc.snippet || '').toLowerCase();
|
||||
|
||||
for (const word of words) {
|
||||
if (titleLower.includes(word)) score += 10;
|
||||
else if (pathLower.includes(word)) score += 5;
|
||||
else if (snippetLower.includes(word)) score += 2;
|
||||
}
|
||||
|
||||
// Full query match bonus
|
||||
if (titleLower.includes(lowerQ)) score += 5;
|
||||
if (pathLower === lowerQ) score += 8;
|
||||
|
||||
if (score > 0) {
|
||||
results.push({
|
||||
vault: doc.vault,
|
||||
path: doc.path,
|
||||
title: doc.title,
|
||||
tags: doc.tags,
|
||||
snippet: doc.snippet,
|
||||
score: score,
|
||||
modified: doc.modified,
|
||||
_score: score
|
||||
});
|
||||
}
|
||||
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async getAllFileIndex(vaultFilter) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readonly');
|
||||
const store = tx.objectStore('files');
|
||||
const results = [];
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { resolve(results); return; }
|
||||
if (!vaultFilter || vaultFilter === 'all' || cursor.value.vault === vaultFilter) {
|
||||
results.push(cursor.value);
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async clearFileIndex(vaultFilter) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readwrite');
|
||||
const store = tx.objectStore('files');
|
||||
if (!vaultFilter || vaultFilter === 'all') {
|
||||
store.clear().onsuccess = () => resolve();
|
||||
} else {
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { resolve(); return; }
|
||||
if (cursor.value.vault === vaultFilter) cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ── File Contents ── */
|
||||
|
||||
async putFileContent(vault, path, content, modified) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('content', 'readwrite');
|
||||
const store = tx.objectStore('content');
|
||||
const key = vault + '::' + path;
|
||||
store.put({ key: key, content: content, modified: modified || '' });
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async getFileContent(vault, path) {
|
||||
const db = await this.open();
|
||||
const key = vault + '::' + path;
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('content', 'readonly');
|
||||
const store = tx.objectStore('content');
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
},
|
||||
|
||||
async removeFileContent(vault, path) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('content', 'readwrite');
|
||||
const store = tx.objectStore('content');
|
||||
const key = vault + '::' + path;
|
||||
store.delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
});
|
||||
},
|
||||
|
||||
/* ── Pending Changes Queue ── */
|
||||
|
||||
async addPending(entry) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('pending', 'readwrite');
|
||||
const store = tx.objectStore('pending');
|
||||
const doc = {
|
||||
vault: entry.vault,
|
||||
path: entry.path,
|
||||
content: entry.content || '',
|
||||
action: entry.action || 'save',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
const req = store.add(doc);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async getPendingCount() {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readonly');
|
||||
const store = tx.objectStore('pending');
|
||||
store.count().onsuccess = (e) => resolve(e.target.result);
|
||||
});
|
||||
},
|
||||
|
||||
async getAllPending() {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readonly');
|
||||
const store = tx.objectStore('pending');
|
||||
const results = [];
|
||||
const index = store.index('timestamp');
|
||||
index.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { resolve(results); return; }
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async clearPending() {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readwrite');
|
||||
tx.objectStore('pending').clear().onsuccess = () => resolve();
|
||||
});
|
||||
},
|
||||
|
||||
async removePendingById(id) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readwrite');
|
||||
tx.objectStore('pending').delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
});
|
||||
},
|
||||
|
||||
/* ── Stats ── */
|
||||
|
||||
async getStats() {
|
||||
const db = await this.open();
|
||||
const fileCount = await new Promise(r => {
|
||||
db.transaction('files','readonly').objectStore('files').count().onsuccess = e => r(e.target.result);
|
||||
});
|
||||
const contentCount = await new Promise(r => {
|
||||
db.transaction('content','readonly').objectStore('content').count().onsuccess = e => r(e.target.result);
|
||||
});
|
||||
const pendingCount = await this.getPendingCount();
|
||||
return { fileCount, contentCount, pendingCount };
|
||||
},
|
||||
|
||||
/* ── Full Sync ── */
|
||||
|
||||
async syncFileIndex(serverFiles) {
|
||||
// serverFiles: array of {vault,path,title,tags,snippet,modified,tokens}
|
||||
await this.clearFileIndex(null);
|
||||
await this.putFileIndexBatch(serverFiles);
|
||||
console.log('[OfflineDB] File index synced:', serverFiles.length, 'files');
|
||||
},
|
||||
|
||||
async pruneContentCache(maxEntries) {
|
||||
maxEntries = maxEntries || 50;
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('content', 'readwrite');
|
||||
const store = tx.objectStore('content');
|
||||
const keys = [];
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) {
|
||||
// Delete oldest if over maxEntries
|
||||
if (keys.length > maxEntries) {
|
||||
const toDelete = keys.slice(0, keys.length - maxEntries);
|
||||
for (const k of toDelete) store.delete(k);
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
keys.push(cursor.value.key);
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Network Status ── */
|
||||
let _online = navigator.onLine;
|
||||
let _listeners = [];
|
||||
|
||||
export const NetworkStatus = {
|
||||
get isOnline() { return _online; },
|
||||
|
||||
init() {
|
||||
window.addEventListener('online', () => {
|
||||
_online = true;
|
||||
_listeners.forEach(fn => fn(true));
|
||||
console.log('[Network] Online');
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
_online = false;
|
||||
_listeners.forEach(fn => fn(false));
|
||||
console.log('[Network] Offline');
|
||||
});
|
||||
},
|
||||
|
||||
onChange(fn) {
|
||||
_listeners.push(fn);
|
||||
return () => { _listeners = _listeners.filter(f => f !== fn); };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
/* ObsiGate — Offline Integration Module
|
||||
* Ties together: IndexedDB, offline search, sync queue, UI indicator
|
||||
*/
|
||||
import { OfflineDB, NetworkStatus } from './offline-db.js';
|
||||
import { state } from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
/* ── Init ── */
|
||||
export async function initOffline() {
|
||||
NetworkStatus.init();
|
||||
|
||||
// Open DB early
|
||||
try {
|
||||
await OfflineDB.open();
|
||||
console.log('[Offline] IndexedDB ready');
|
||||
} catch (e) {
|
||||
console.warn('[Offline] IndexedDB not available:', e);
|
||||
}
|
||||
|
||||
NetworkStatus.onChange((online) => {
|
||||
updateOfflineIndicator();
|
||||
if (online) {
|
||||
syncPendingChanges();
|
||||
showToast(t('offline.reconnected'), 'info');
|
||||
} else {
|
||||
showToast(t('offline.mode_offline'), 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
updateOfflineIndicator();
|
||||
}
|
||||
|
||||
/* ── UI Indicator ── */
|
||||
function updateOfflineIndicator() {
|
||||
const badge = document.getElementById('offline-badge');
|
||||
if (!badge) return;
|
||||
|
||||
if (!NetworkStatus.isOnline) {
|
||||
// Build or show indicator
|
||||
badge.innerHTML = replaceLucideIcon(badge, 'wifi-off',
|
||||
'<i data-lucide="wifi-off" style="width:14px;height:14px"></i>');
|
||||
badge.title = 'Mode hors-ligne';
|
||||
badge.style.display = 'flex';
|
||||
badge.className = 'header-icon offline-indicator offline';
|
||||
|
||||
// Add pending count if any
|
||||
OfflineDB.getPendingCount().then(count => {
|
||||
if (count > 0) {
|
||||
const span = badge.querySelector('.offline-pending') || document.createElement('span');
|
||||
span.className = 'offline-pending';
|
||||
span.textContent = String(count);
|
||||
if (!badge.contains(span)) badge.appendChild(span);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
badge.className = 'header-icon offline-indicator';
|
||||
}
|
||||
}
|
||||
|
||||
function replaceLucideIcon(container, iconName, html) {
|
||||
const existing = container.querySelector('i[data-lucide]');
|
||||
if (existing && existing.getAttribute('data-lucide') === iconName) return container.innerHTML;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Sync File Index from Server ── */
|
||||
export async function syncFileIndexFromServer() {
|
||||
if (!NetworkStatus.isOnline) {
|
||||
console.log('[Offline] Skipping index sync — offline');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Use a wildcard query to get recent files and populate offline index
|
||||
const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200');
|
||||
if (!resp.ok) {
|
||||
console.warn('[Offline] Index sync failed — API returned', resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const files = (data.results || []).map(r => ({
|
||||
vault: r.vault,
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
tags: r.tags || [],
|
||||
snippet: r.snippet || '',
|
||||
modified: r.modified || '',
|
||||
tokens: []
|
||||
}));
|
||||
if (files.length > 0) {
|
||||
await OfflineDB.syncFileIndex(files);
|
||||
console.log('[Offline] Index synced:', files.length, 'files');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Offline] Index sync failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cache File When Viewed ── */
|
||||
export async function cacheViewedFile(vault, path, content, modified) {
|
||||
if (!vault || !path) return;
|
||||
try {
|
||||
await OfflineDB.putFileContent(vault, path, content, modified);
|
||||
await OfflineDB.pruneContentCache(50);
|
||||
} catch (e) {
|
||||
console.warn('[Offline] Cache file failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Offline Search ── */
|
||||
export async function searchOffline(query, vaultFilter) {
|
||||
try {
|
||||
return await OfflineDB.searchFileIndex(query, vaultFilter || state.currentVault);
|
||||
} catch (e) {
|
||||
console.error('[Offline] Search failed:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Get Cached File ── */
|
||||
export async function getCachedFile(vault, path) {
|
||||
try {
|
||||
return await OfflineDB.getFileContent(vault, path);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pending Changes Queue ── */
|
||||
let _syncing = false;
|
||||
|
||||
export async function queueOfflineSave(vault, path, content) {
|
||||
try {
|
||||
const id = await OfflineDB.addPending({ vault, path, content, action: 'save' });
|
||||
updateOfflineIndicator();
|
||||
console.log('[Offline] Queued save:', vault, path, 'id=', id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.error('[Offline] Queue save failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPendingChanges() {
|
||||
if (!NetworkStatus.isOnline || _syncing) return;
|
||||
_syncing = true;
|
||||
|
||||
try {
|
||||
const pending = await OfflineDB.getAllPending();
|
||||
if (pending.length === 0) {
|
||||
_syncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Offline] Syncing', pending.length, 'pending changes...');
|
||||
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
const resp = await fetch('/api/file/' + encodeURIComponent(entry.vault) + '/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: entry.path, content: entry.content })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
await OfflineDB.removePendingById(entry.id);
|
||||
console.log('[Offline] Synced:', entry.vault, entry.path);
|
||||
} else if (resp.status === 409) {
|
||||
// Conflict — keep in queue for manual resolution
|
||||
console.warn('[Offline] Conflict on:', entry.vault, entry.path);
|
||||
} else {
|
||||
console.error('[Offline] Sync failed for:', entry.vault, entry.path, resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Offline] Sync error for:', entry.vault, entry.path, e);
|
||||
break; // Stop on network error
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await OfflineDB.getPendingCount();
|
||||
console.log('[Offline] Sync complete. Remaining:', remaining);
|
||||
updateOfflineIndicator();
|
||||
|
||||
if (remaining === 0) {
|
||||
showToast(t('offline.all_synced'), 'success');
|
||||
} else {
|
||||
showToast(remaining + ' modification(s) en attente de resolution', 'warning');
|
||||
}
|
||||
} finally {
|
||||
_syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Conflict Detection ── */
|
||||
export async function checkConflict(vault, path, localModified) {
|
||||
if (!NetworkStatus.isOnline) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/file/' + encodeURIComponent(vault) + '/info?path=' + encodeURIComponent(path));
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (data.modified && data.modified !== localModified) {
|
||||
return { serverModified: data.modified, localModified };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "module"}
|
||||
+58
-45
@@ -3,6 +3,7 @@ import { api } from './auth.js';
|
||||
import { showToast, toggleTheme, TabManager } from './ui.js';
|
||||
import { openEditor } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
function triggerGoHome() { const e = document.getElementById('header-logo'); if (e) e.click(); }
|
||||
|
||||
@@ -32,22 +33,34 @@ let _browse = { action: null, vault: null, path: '' };
|
||||
let _browseFilterEl = null, _browseFilter = '', _allItems = [];
|
||||
let _browseFilterTimer = null; // debounce for global search
|
||||
|
||||
// ── Commands ──
|
||||
const CMDS = [
|
||||
{ id:'home', label:'🏠 Accueil', desc:"Retour à l'accueil", cat:'Navigation', act:()=>{close();triggerGoHome();} },
|
||||
{ id:'edit-current', label:'✏️ Éditer fichier courant', desc:'Ouvrir le fichier actif dans l\'éditeur', cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f)openEditor(f.vault,f.path);else showToast('Aucun fichier ouvert','error');} },
|
||||
{ id:'popout', label:'🪟 Pop-out', desc:'Ouvrir le document dans une fenêtre', cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f)window.open(`/popout/${encodeURIComponent(f.vault)}/${encodeURIComponent(f.path)}`,'popout','width=1000,height=700');else showToast('Aucun fichier ouvert','error');} },
|
||||
{ id:'create-file', label:'📄 Créer fichier markdown', desc:'Choisir la vault, naviguer, puis nommer le fichier', cat:'Fichiers', act:()=>startBrowse('create-file') },
|
||||
{ id:'delete-file', label:'🗑️ Supprimer fichier', desc:'Rechercher un fichier à supprimer', cat:'Fichiers', act:()=>startBrowse('delete-file') },
|
||||
{ id:'delete-cur', label:'🗑️ Supprimer fichier courant', desc:'Supprimer le fichier de l\'onglet actif', cat:'Fichiers', act:async()=>{close();await delCurFile();} },
|
||||
{ id:'close-cur', label:'✕ Fermer fichier courant', desc:'Fermer l\'onglet actif sans supprimer', cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f){const tab=TabManager._tabs.find(t=>t.vault===f.vault&&t.path===f.path);if(tab)TabManager.close(tab.id);else triggerGoHome();}else{showToast('Aucun fichier ouvert','error');}} },
|
||||
{ id:'create-dir', label:'📁 Créer répertoire', desc:'Choisir la vault, naviguer, puis nommer le dossier', cat:'Répertoires', act:()=>startBrowse('create-dir') },
|
||||
{ id:'delete-dir', label:'🗑️ Supprimer répertoire', desc:'Choisir la vault, naviguer et supprimer', cat:'Répertoires', act:()=>startBrowse('delete-dir') },
|
||||
{ id:'theme', label:'🌓 Changer le thème', desc:'Basculer clair/sombre', cat:'Actions', act:()=>{close();toggleTheme();} },
|
||||
{ id:'reindex', label:'🔄 Réindexer', desc:'Forcer la réindexation complète', cat:'Actions', act:async()=>{close();try{await api('/api/index/reload');showToast('Index rechargé','success');}catch{showToast('Erreur','error');}} },
|
||||
{ id:'help', label:'❓ Aide', desc:'Ouvrir le guide', cat:'Système', act:()=>{close();const b=document.getElementById('help-open-btn');if(b)b.click();} },
|
||||
{ id:'config', label:'⚙️ Configuration', desc:'Ouvrir les paramètres', cat:'Système', act:()=>{close();const b=document.getElementById('config-open-btn');if(b)b.click();} },
|
||||
];
|
||||
// ── Commands ── (lazy: built on first access so i18n is ready)
|
||||
let _cmdsCache = null;
|
||||
function getCMDS() {
|
||||
if (_cmdsCache) return _cmdsCache;
|
||||
_cmdsCache = [
|
||||
{ id:'home', label:t('palette.cmd_back_home'), desc:t('palette.cmd_back_home_desc'), cat:'Navigation', act:()=>{close();triggerGoHome();} },
|
||||
{ id:'edit-current', label:'✏️ '+t('editor.edit'), desc:t('editor.edit_current_desc'), cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f)openEditor(f.vault,f.path);else showToast(t('toast.no_open_file'),'error');} },
|
||||
{ id:'popout', label:'🪟 Pop-out', desc:t('palette.popout_desc'), cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f)window.open(`/popout/${encodeURIComponent(f.vault)}/${encodeURIComponent(f.path)}?theme=${document.documentElement.getAttribute('data-theme')||'dark'}`,'popout','width=1000,height=700');else showToast(t('toast.no_open_file'),'error');} },
|
||||
{ id:'create-file', label:t('palette.cmd_create_file'), desc:t('palette.cmd_create_file_desc'), cat:'Fichiers', act:()=>startBrowse('create-file') },
|
||||
{ id:'delete-file', label:'🗑️ '+t('file.delete'), desc:t('palette.delete_file_desc'), cat:'Fichiers', act:()=>startBrowse('delete-file') },
|
||||
{ id:'delete-cur', label:'🗑️ '+t('palette.delete_current'), desc:t('palette.delete_current_desc'), cat:'Fichiers', act:async()=>{close();await delCurFile();} },
|
||||
{ id:'close-cur', label:'✕ '+t('palette.close_current'), desc:t('palette.close_current_desc'), cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f){const tab=TabManager._tabs.find(t=>t.vault===f.vault&&t.path===f.path);if(tab)TabManager.close(tab.id);else triggerGoHome();}else{showToast(t('toast.no_open_file'),'error');}} },
|
||||
{ id:'create-dir', label:t('palette.cmd_create_dir'), desc:t('palette.cmd_create_dir_desc'), cat:'Répertoires', act:()=>startBrowse('create-dir') },
|
||||
{ id:'delete-dir', label:t('palette.cmd_delete_dir'), desc:t('palette.cmd_delete_dir_desc'), cat:'Répertoires', act:()=>startBrowse('delete-dir') },
|
||||
{ id:'theme', label:t('palette.cmd_theme'), desc:t('palette.cmd_theme_desc'), cat:'Actions', act:()=>{close();toggleTheme();} },
|
||||
{ id:'reindex', label:'🔄 '+t('vault.reindex'), desc:t('palette.reindex_desc'), cat:'Actions', act:async()=>{close();try{await api('/api/index/reload');showToast(t('sync.index_reloaded'),'success');}catch{showToast(t('common.error'),'error');}} },
|
||||
{ id:'help', label:'❓ '+t('help.help_title'), desc:t('palette.help_desc'), cat:'Système', act:()=>{close();const b=document.getElementById('help-open-btn');if(b)b.click();} },
|
||||
{ id:'config', label:t('palette.cmd_config'), desc:t('palette.cmd_config_desc'), cat:'Système', act:()=>{close();const b=document.getElementById('config-open-btn');if(b)b.click();} },
|
||||
// Pane / Split commands
|
||||
{ id:'split-right', label:'⊞→ Diviser à droite', desc:'Crée un panneau à droite avec cet onglet', cat:'Panneaux', act:()=>{close();if(window.PaneManager)window.PaneManager.splitRight();} },
|
||||
{ id:'split-down', label:'⊞↓ Diviser en bas', desc:'Crée un panneau en dessous avec cet onglet', cat:'Panneaux', act:()=>{close();if(window.PaneManager)window.PaneManager.splitDown();} },
|
||||
{ id:'close-pane', label:'✕ Fermer le panneau actif', desc:'Ferme le panneau actif et tous ses onglets', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit())window.PaneManager.closeActivePane();} },
|
||||
{ id:'focus-next', label:'→ Panneau suivant', desc:'Active le panneau suivant (Ctrl+Alt+→)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId+1)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
|
||||
{ id:'focus-prev', label:'← Panneau précédent', desc:'Active le panneau précédent (Ctrl+Alt+←)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId-1+window.PaneManager.panes.length)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
|
||||
{ id:'reset-panes', label:'🔄 Réinitialiser les panneaux', desc:'Ferme tous les panneaux et revient au mode single-pane', cat:'Panneaux', act:()=>{close();if(window.PaneManager){while(window.PaneManager.isSplit()){window.PaneManager.closePane(window.PaneManager.panes.length-1);}localStorage.removeItem('obsigate-panes');}} },
|
||||
];
|
||||
return _cmdsCache;
|
||||
}
|
||||
|
||||
// ── Browse: vault picker ──
|
||||
function startBrowse(action) {
|
||||
@@ -61,13 +74,13 @@ async function renderVaultPicker() {
|
||||
try {
|
||||
const data = await api('/api/vaults');
|
||||
const vaults = Array.isArray(data) ? data : [];
|
||||
_items = vaults.map(v => ({ type:'_vault', vault: v.name, label: (v.type==='DIR'?'📂':'🗂️')+' '+v.name, sublabel: v.file_count+' fichiers' }));
|
||||
_items = vaults.map(v => ({ type:'_vault', vault: v.name, label: (v.type==='DIR'?'📂':'🗂️')+' '+v.name, sublabel: t('palette.vault_count', {count: v.file_count}) }));
|
||||
if (_items.length === 0) {
|
||||
_items = [{ type:'_empty', label: 'Aucune vault trouvée', sublabel: '' }];
|
||||
_items = [{ type:'_empty', label: t('palette.no_vaults'), sublabel: '' }];
|
||||
}
|
||||
_allItems = _items;
|
||||
render();
|
||||
} catch { _items = [{ type:'_error', label: 'Erreur de chargement', sublabel: '' }]; _allItems = _items; render(); }
|
||||
} catch { _items = [{ type:'_error', label: t('palette.error_loading'), sublabel: '' }]; _allItems = _items; render(); }
|
||||
}
|
||||
|
||||
// ── Browse: directory navigation ──
|
||||
@@ -94,11 +107,11 @@ async function renderBrowse() {
|
||||
|
||||
// Action row
|
||||
const actLabel = {
|
||||
'create-file':'📄 Créer fichier ici',
|
||||
'create-dir':'📁 Créer dossier ici',
|
||||
'delete-dir':'🗑️ Supprimer ce dossier',
|
||||
'delete-file':'🗑️ Supprimer un fichier dans ce dossier',
|
||||
'open-file':'📂 Ouvrir un fichier dans ce dossier'
|
||||
'create-file': t('palette.cmd_create_file'),
|
||||
'create-dir': t('palette.cmd_create_dir'),
|
||||
'delete-dir': t('palette.cmd_delete_dir'),
|
||||
'delete-file': t('palette.delete_file_desc'),
|
||||
'open-file': t('palette.browse_open_file')
|
||||
};
|
||||
const isDeleteDir = _browse.action === 'delete-dir' && path;
|
||||
const isDeleteFile = _browse.action === 'delete-file';
|
||||
@@ -106,9 +119,9 @@ async function renderBrowse() {
|
||||
const needsName = _browse.action === 'create-file' || _browse.action === 'create-dir';
|
||||
|
||||
if (isDeleteDir) {
|
||||
_items.push({ type:'_action', label: '🗑️ Supprimer '+ (path.split('/').pop()||path), sublabel: 'Tout le contenu sera supprimé', act:()=>execDelDir(path) });
|
||||
_items.push({ type:'_action', label: '🗑️ '+t('file.delete')+' '+(path.split('/').pop()||path), sublabel: t('palette.dir_delete_confirm'), act:()=>execDelDir(path) });
|
||||
} else if (needsName) {
|
||||
_items.push({ type:'_action', label: actLabel[_browse.action]||'', sublabel: 'Dans '+(path||'racine'), act:()=>askName() });
|
||||
_items.push({ type:'_action', label: actLabel[_browse.action]||'', sublabel: t('palette.browse_in', {path: path||t('palette.browse_root')}), act:()=>askName() });
|
||||
}
|
||||
|
||||
// Parent
|
||||
@@ -134,8 +147,8 @@ async function renderBrowse() {
|
||||
}
|
||||
}
|
||||
|
||||
if (_items.length === 0) _items.push({ type:'_empty', label: '📂 Dossier vide', sublabel: '' });
|
||||
} catch { _items = [{ type:'_error', label: 'Erreur', sublabel: '' }]; }
|
||||
if (_items.length === 0) _items.push({ type:'_empty', label: t('palette.empty_dir'), sublabel: '' });
|
||||
} catch { _items = [{ type:'_error', label: t('common.error'), sublabel: '' }]; }
|
||||
|
||||
_allItems = _items;
|
||||
applyBrowseFilter();
|
||||
@@ -177,7 +190,7 @@ async function searchBrowseGlobal(rawQ) {
|
||||
});
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_empty', label: 'Aucun résultat pour "'+rawQ+'"', sublabel: '' }];
|
||||
_items = [{ type:'_empty', label: t('palette.no_results', {query: rawQ}), sublabel: '' }];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
@@ -219,7 +232,7 @@ async function searchBrowseGlobal(rawQ) {
|
||||
}
|
||||
|
||||
render();
|
||||
} catch { _items = [{ type:'_error', label: 'Erreur de recherche', sublabel: '' }]; render(); }
|
||||
} catch { _items = [{ type:'_error', label: t('palette.search_error'), sublabel: '' }]; render(); }
|
||||
}
|
||||
|
||||
// Navigate to a directory from global search
|
||||
@@ -232,20 +245,20 @@ function execNavGlobal(vault, goPath) {
|
||||
}
|
||||
|
||||
async function execDelFileBrowsed(vault, path) {
|
||||
if (!confirm(`Supprimer le fichier "${vault}/${path}" ?`)) return;
|
||||
if (!confirm(t("confirm.delete_file_named", {path: vault + "/" + path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast('Erreur','error');
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execDelDirBrowsed(vault, path) {
|
||||
if (!confirm(`Supprimer "${path}" et TOUT son contenu ?`)) return;
|
||||
if (!confirm(t("confirm.delete_dir_named", {path: path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast('Erreur','error');
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
@@ -337,7 +350,7 @@ async function execCreateFile(name) {
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(_browse.vault)}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path:fp, content:`# ${name.replace('.md','')}\n\n`}) });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`✅ ${fp} créé`,'success'); close(); openEditor(_browse.vault, fp); }
|
||||
else showToast('Erreur de création','error');
|
||||
else showToast(t('toast.create_error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
@@ -346,37 +359,37 @@ async function execCreateDir(name) {
|
||||
try {
|
||||
const d = await api(`/api/directory/${encodeURIComponent(_browse.vault)}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path:dp}) });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`📁 ${dp} créé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast('Erreur','error');
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execDelDir(path) {
|
||||
if (!confirm(`Supprimer "${path}" et TOUT son contenu ?`)) return;
|
||||
if (!confirm(t("confirm.delete_dir_named", {path: path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/directory/${encodeURIComponent(_browse.vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast('Erreur','error');
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execDelFile(path) {
|
||||
if (!confirm(`Supprimer le fichier "${path}" ?`)) return;
|
||||
if (!confirm(t("confirm.delete_file_named", {path: path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(_browse.vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast('Erreur','error');
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function delCurFile() {
|
||||
const f = getCurrentFile();
|
||||
if (!f) { showToast('Aucun fichier ouvert','error'); return; }
|
||||
if (!confirm(`Supprimer "${f.path}" ?`)) return;
|
||||
if (!f) { showToast(t('toast.no_open_file'),'error'); return; }
|
||||
if (!confirm(t("confirm.delete_file_named", {path: f.path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(f.vault)}?path=${encodeURIComponent(f.path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${f.path} supprimé`,'success');
|
||||
const tab = TabManager._tabs.find(t=>t.vault===f.vault&&t.path===f.path); if(tab) TabManager.close(tab.id); triggerGoHome(); refreshSidebar(); }
|
||||
else showToast('Erreur','error');
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
@@ -500,9 +513,9 @@ async function searchFiles(rawQ) {
|
||||
}
|
||||
|
||||
function searchCmd(q) {
|
||||
if (!q) { _items = CMDS.map(c => ({ type:'cmd', ...c })); render(); return; }
|
||||
if (!q) { _items = getCMDS().map(c => ({ type:'cmd', ...c })); render(); return; }
|
||||
const lq = q.toLowerCase();
|
||||
_items = CMDS.filter(c => c.label.toLowerCase().includes(lq)||c.desc.toLowerCase().includes(lq)||(c.cat&&c.cat.toLowerCase().includes(lq))).map(c => ({ type:'cmd', ...c }));
|
||||
_items = getCMDS().filter(c => c.label.toLowerCase().includes(lq)||c.desc.toLowerCase().includes(lq)||(c.cat&&c.cat.toLowerCase().includes(lq))).map(c => ({ type:'cmd', ...c }));
|
||||
render();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+105
-57
@@ -3,9 +3,11 @@ import { api } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showLoading, el, hideProgressBar, showWelcome, highlightSearchText, loadSavedSearches } from './viewer.js';
|
||||
import { searchOffline } from './offline.js';
|
||||
import { _getEffective } from './config.js';
|
||||
import { TabManager, showToast } from './ui.js';
|
||||
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
|
||||
import { t } from './i18n.js';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search History Service (localStorage, LIFO, max 50, dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -56,7 +58,8 @@ export const SearchHistory = {
|
||||
// ---------------------------------------------------------------------------
|
||||
export const QueryParser = {
|
||||
parse(raw) {
|
||||
const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" };
|
||||
const result = { tags: [], vault: null, title: null, path: null, ext: null,
|
||||
created: null, modified: null, size: null, freeText: "" };
|
||||
if (!raw) return result;
|
||||
const tokens = this._tokenize(raw);
|
||||
const freeTokens = [];
|
||||
@@ -75,6 +78,12 @@ export const QueryParser = {
|
||||
result.path = tok.slice(5).replace(/"/g, "").trim();
|
||||
} else if (lower.startsWith("ext:")) {
|
||||
result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase();
|
||||
} else if (lower.startsWith("created:")) {
|
||||
result.created = tok.slice(8).replace(/"/g, "").trim();
|
||||
} else if (lower.startsWith("modified:")) {
|
||||
result.modified = tok.slice(9).replace(/"/g, "").trim();
|
||||
} else if (lower.startsWith("size:")) {
|
||||
result.size = tok.slice(5).replace(/"/g, "").trim();
|
||||
} else {
|
||||
freeTokens.push(tok);
|
||||
}
|
||||
@@ -170,6 +179,10 @@ export const AutocompleteDropdown = {
|
||||
},
|
||||
|
||||
show() {
|
||||
// Après un hide() (ex: recherche lancée), on ignore le re-show du fetch
|
||||
// de suggestions en attente — sinon le dropdown recouvre l'UI. Le flag
|
||||
// est réarmé par la prochaine frappe (populate) ou le focus.
|
||||
if (this._suppressShow) return;
|
||||
if (this._dropdown) this._dropdown.hidden = false;
|
||||
},
|
||||
|
||||
@@ -177,6 +190,7 @@ export const AutocompleteDropdown = {
|
||||
if (this._dropdown) this._dropdown.hidden = true;
|
||||
state.dropdownActiveIndex = -1;
|
||||
state.dropdownItems = [];
|
||||
this._suppressShow = true;
|
||||
},
|
||||
|
||||
isVisible() {
|
||||
@@ -186,6 +200,8 @@ export const AutocompleteDropdown = {
|
||||
/** Populate and show the dropdown with history, title suggestions, and tag suggestions */
|
||||
async populate(inputValue, cursorPos) {
|
||||
if (this._suppressNext) { this._suppressNext = false; return; }
|
||||
// Nouvelle frappe (ou focus) : réautorise l'affichage du dropdown
|
||||
this._suppressShow = false;
|
||||
// Cancel previous suggestion request
|
||||
if (state.suggestAbortController) {
|
||||
state.suggestAbortController.abort();
|
||||
@@ -279,6 +295,7 @@ export const AutocompleteDropdown = {
|
||||
li.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
input.value = entry;
|
||||
this._suppressNext = true;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.hide();
|
||||
_triggerAdvancedSearch(entry);
|
||||
@@ -347,6 +364,7 @@ export const AutocompleteDropdown = {
|
||||
const base = words.filter(w => w).join(" ");
|
||||
input.value = (base ? base + " " : "") + "tag:" + item.tag + " ";
|
||||
}
|
||||
this._suppressNext = true;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.hide();
|
||||
input.focus();
|
||||
@@ -434,6 +452,18 @@ export const SearchChips = {
|
||||
this._addChip("ext", `ext:${parsed.ext}`, parsed.ext);
|
||||
hasChips = true;
|
||||
}
|
||||
if (parsed.created) {
|
||||
this._addChip("date", `created:${parsed.created}`, `créé ${parsed.created}`);
|
||||
hasChips = true;
|
||||
}
|
||||
if (parsed.modified) {
|
||||
this._addChip("date", `modified:${parsed.modified}`, `modifié ${parsed.modified}`);
|
||||
hasChips = true;
|
||||
}
|
||||
if (parsed.size) {
|
||||
this._addChip("size", `size:${parsed.size}`, `taille ${parsed.size}`);
|
||||
hasChips = true;
|
||||
}
|
||||
this._container.hidden = !hasChips;
|
||||
},
|
||||
clear() {
|
||||
@@ -490,15 +520,19 @@ function _triggerAdvancedSearch(rawQuery) {
|
||||
export function initSearch() {
|
||||
const input = document.getElementById("search-input");
|
||||
if (!input) return;
|
||||
const caseBtn = document.getElementById("search-case-btn");
|
||||
const wordBtn = document.getElementById("search-word-btn");
|
||||
const regexBtn = document.getElementById("search-regex-btn");
|
||||
const caseBtn = document.getElementById("rb-case");
|
||||
const wordBtn = document.getElementById("rb-word");
|
||||
const regexBtn = document.getElementById("rb-regex");
|
||||
const filterBtn = document.getElementById("search-filter-btn");
|
||||
const clearBtn = document.getElementById("search-clear-btn");
|
||||
const filterRow = document.getElementById("search-filter-row");
|
||||
const prevBtn = document.getElementById("search-prev-btn");
|
||||
const nextBtn = document.getElementById("search-next-btn");
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
const prevBtn = document.getElementById("rb-prev");
|
||||
const nextBtn = document.getElementById("rb-next");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
const resultsBar = document.getElementById("results-bar");
|
||||
|
||||
function _showResultsBar() { if (resultsBar) resultsBar.style.display = 'flex'; }
|
||||
function _hideResultsBar() { if (resultsBar) resultsBar.style.display = 'none'; }
|
||||
|
||||
function _updateToggleUI() {
|
||||
caseBtn.classList.toggle("active", state.searchCaseSensitive);
|
||||
@@ -524,7 +558,7 @@ export function initSearch() {
|
||||
if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) {
|
||||
_searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`;
|
||||
}
|
||||
|
||||
@@ -532,6 +566,7 @@ export function initSearch() {
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
_searchResultIdx = _searchResultItems.length > 0 ? 0 : -1;
|
||||
_updateResultHighlight();
|
||||
if (_searchResultItems.length > 0) _showResultsBar(); else _hideResultsBar();
|
||||
}
|
||||
|
||||
window.navigateSearchResults = function(delta) {
|
||||
@@ -574,7 +609,7 @@ export function initSearch() {
|
||||
// Initially hide clear button
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
|
||||
// --- Input handler: debounced search + autocomplete dropdown ---
|
||||
// --- Input handler: autocomplete dropdown only (search triggered on Enter or selection) ---
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
@@ -582,23 +617,11 @@ export function initSearch() {
|
||||
// Show autocomplete dropdown while typing
|
||||
AutocompleteDropdown.populate(input.value, input.selectionStart);
|
||||
|
||||
// Debounced search execution
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.searchTimeout = setTimeout(
|
||||
() => {
|
||||
const q = input.value.trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
state.advancedSearchOffset = 0;
|
||||
if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) {
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
} else if (q.length === 0) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
},
|
||||
_getEffective("debounce_ms", 300),
|
||||
);
|
||||
// Clear results when input becomes empty
|
||||
if (!hasText) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Focus handler: show history dropdown ---
|
||||
@@ -622,7 +645,7 @@ export function initSearch() {
|
||||
AutocompleteDropdown.navigateUp();
|
||||
} else if (e.key === "Enter") {
|
||||
// First: check dropdown suggestions (higher priority than search results)
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
if (AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -648,30 +671,28 @@ export function initSearch() {
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) {
|
||||
// Navigate search results when dropdown is closed
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Escape") {
|
||||
AutocompleteDropdown.hide();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
|
||||
} else {
|
||||
// Dropdown not visible: navigate search results or execute search
|
||||
if (e.key === "ArrowDown") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Enter") {
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -682,6 +703,7 @@ export function initSearch() {
|
||||
state.searchWholeWord = false;
|
||||
state.searchRegex = false;
|
||||
_updateToggleUI();
|
||||
_hideResultsBar();
|
||||
SearchChips.clear();
|
||||
AutocompleteDropdown.hide();
|
||||
showWelcome();
|
||||
@@ -762,6 +784,9 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
|
||||
const excludeEl = document.getElementById("search-exclude-input");
|
||||
if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`;
|
||||
if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`;
|
||||
if (parsed.created) url += `&created=${encodeURIComponent(parsed.created)}`;
|
||||
if (parsed.modified) url += `&modified=${encodeURIComponent(parsed.modified)}`;
|
||||
if (parsed.size) url += `&size=${encodeURIComponent(parsed.size)}`;
|
||||
|
||||
// Search timeout — abort if server takes too long
|
||||
const timeoutId = setTimeout(
|
||||
@@ -782,6 +807,27 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === "AbortError") return;
|
||||
if (searchId !== state.currentSearchId) return;
|
||||
|
||||
// Try offline search fallback
|
||||
console.log('[Search] Network failed, trying offline search');
|
||||
const offlineResults = await searchOffline(query, vaultFilter);
|
||||
if (offlineResults && offlineResults.length > 0) {
|
||||
const data = {
|
||||
query: query,
|
||||
vault_filter: vaultFilter,
|
||||
count: offlineResults.length,
|
||||
total: offlineResults.length,
|
||||
offset: 0,
|
||||
limit: effectiveLimit,
|
||||
results: offlineResults,
|
||||
offline: true
|
||||
};
|
||||
state.advancedSearchTotal = offlineResults.length;
|
||||
state.advancedSearchOffset = 0;
|
||||
renderAdvancedSearchResults(data, query, tagFilter);
|
||||
return;
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
} finally {
|
||||
hideProgressBar();
|
||||
@@ -796,7 +842,7 @@ export function renderSearchResults(data, query, tagFilter) {
|
||||
const header = buildSearchResultsHeader(data, query, tagFilter);
|
||||
area.appendChild(header);
|
||||
if (data.results.length === 0) {
|
||||
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")]));
|
||||
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode(t("search.no_results"))]));
|
||||
return;
|
||||
}
|
||||
const container = el("div", { class: "search-results" });
|
||||
@@ -852,9 +898,11 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
|
||||
// Update match counter
|
||||
const countEl = document.getElementById("search-match-count");
|
||||
// Update match counter + show floating results bar
|
||||
var rb = document.getElementById("results-bar");
|
||||
var countEl = document.getElementById("rb-count");
|
||||
if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`;
|
||||
if (rb) rb.style.display = 'flex';
|
||||
|
||||
// Header with result count and sort controls
|
||||
const header = el("div", { class: "search-results-header" });
|
||||
@@ -863,17 +911,17 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
const freeText = parsed.freeText;
|
||||
|
||||
if (freeText && tagFilter) {
|
||||
summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`;
|
||||
summaryText.textContent = t('search.results_filtered', {count: data.total, query: freeText});
|
||||
} else if (freeText) {
|
||||
summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`;
|
||||
summaryText.textContent = t('search.results_for', {count: data.total, query: freeText});
|
||||
} else if (parsed.tags.length > 0 || tagFilter) {
|
||||
summaryText.textContent = `${data.total} fichier(s) avec filtres`;
|
||||
summaryText.textContent = t('search.files_filtered', {count: data.total});
|
||||
} else {
|
||||
summaryText.textContent = `${data.total} résultat(s)`;
|
||||
summaryText.textContent = t('search.results_count', {count: data.total});
|
||||
}
|
||||
if (data.query_time_ms !== undefined && data.query_time_ms > 0) {
|
||||
const timeBadge = el("span", { class: "search-time-badge" });
|
||||
timeBadge.textContent = `(${data.query_time_ms} ms)`;
|
||||
timeBadge.textContent = t('search.query_time', {time: data.query_time_ms});
|
||||
summaryText.appendChild(timeBadge);
|
||||
}
|
||||
header.appendChild(summaryText);
|
||||
@@ -912,7 +960,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
header.appendChild(sortDiv);
|
||||
|
||||
// Save search button
|
||||
const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" });
|
||||
const saveBtn = el("button", { class: "search-save-btn", type: "button", title: t("search.save_search") });
|
||||
saveBtn.innerHTML = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> Sauver';
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const inclEl = document.getElementById("search-include-input");
|
||||
@@ -931,7 +979,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
exclude_paths: exclEl?.value || "",
|
||||
}),
|
||||
});
|
||||
showToast("Recherche sauvegardée", "success");
|
||||
showToast(t("search.saved"), "success");
|
||||
loadSavedSearches();
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
});
|
||||
@@ -1010,7 +1058,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
|
||||
// Empty state
|
||||
if (data.results.length === 0) {
|
||||
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")]));
|
||||
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode(t("search.no_results"))]));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1075,7 +1123,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
if (data.total > state.ADVANCED_SEARCH_LIMIT) {
|
||||
const paginationDiv = el("div", { class: "search-pagination" });
|
||||
const prevBtn = el("button", { class: "search-pagination__btn", type: "button" });
|
||||
prevBtn.textContent = "← Précédent";
|
||||
prevBtn.textContent = t('search.previous');
|
||||
prevBtn.disabled = state.advancedSearchOffset === 0;
|
||||
prevBtn.addEventListener("click", () => {
|
||||
state.advancedSearchOffset = Math.max(0, state.advancedSearchOffset - state.ADVANCED_SEARCH_LIMIT);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuMana
|
||||
import { _populateRecentVaultFilter, switchSidebarTab } from './config.js';
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault context switching
|
||||
@@ -223,7 +224,7 @@ async function loadVaults() {
|
||||
container.innerHTML = "";
|
||||
|
||||
// Prepare dropdown options
|
||||
const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
|
||||
const dropdownOptions = [{ value: "all", text: t('help.all_vaults') }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
|
||||
|
||||
// Populate custom dropdowns
|
||||
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
|
||||
@@ -787,7 +788,7 @@ function renderFilteredSidebarResults(query, results) {
|
||||
});
|
||||
|
||||
if (grouped.size === 0) {
|
||||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")]));
|
||||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))]));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1120,4 +1121,4 @@ function searchByTag(tag) {
|
||||
}
|
||||
|
||||
|
||||
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter };
|
||||
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter };
|
||||
|
||||
@@ -201,7 +201,7 @@ async function loadVaults() {
|
||||
container.innerHTML = "";
|
||||
|
||||
// Prepare dropdown options
|
||||
const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
|
||||
const dropdownOptions = [{ value: "all", text: t('help.all_vaults') }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
|
||||
|
||||
// Populate custom dropdowns
|
||||
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
|
||||
@@ -757,7 +757,7 @@ function renderFilteredSidebarResults(query, results) {
|
||||
});
|
||||
|
||||
if (grouped.size === 0) {
|
||||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")]));
|
||||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))]));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user