Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa3df74fc1 | ||
|
|
f9d2c0d2d4 | ||
|
|
c066b2c82a | ||
|
|
ab795ec9e0 | ||
|
|
7042307955 | ||
|
|
f37d5fda3c | ||
|
|
c5b92eabe1 | ||
|
|
01453bce5f | ||
|
|
bd86ba36e2 | ||
|
|
88ab8db88d | ||
|
|
11406034c9 | ||
|
|
eef8671912 | ||
|
|
72383b1634 | ||
|
|
2d70be0ae2 | ||
|
|
5350738c22 | ||
|
|
0d29dd00fd | ||
|
|
d441481930 | ||
|
|
7ff9b854b6 | ||
|
|
46be24f6a3 | ||
|
|
3908e1601a | ||
|
|
1ef0a7ed82 | ||
|
|
43f5b4a078 | ||
|
|
83355a25c8 | ||
|
|
ec56bc32f8 | ||
|
|
050c3d2515 | ||
|
|
b649c6b301 | ||
|
|
99c9acccb0 | ||
|
|
524e6da591 | ||
|
|
6292bfd9cb | ||
|
|
83063d3a18 | ||
|
|
7b0dacabdd | ||
|
|
c8b861f0b6 | ||
|
|
09912509cb | ||
|
|
3e0940da8c | ||
|
|
d20e623f0c | ||
|
|
ef7a3ff1ed | ||
|
|
2115cb9085 | ||
|
|
5f981509ac | ||
|
|
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 |
@@ -33,6 +33,15 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# Backup
|
||||
# OBSIGATE_BACKUP_DIR=.obsigate-backup
|
||||
|
||||
# PDF (ROADMAP #74)
|
||||
# OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé
|
||||
# OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction
|
||||
|
||||
# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost
|
||||
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
|
||||
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
|
||||
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
|
||||
|
||||
# ── AI Provider Configuration ──
|
||||
# Définir au moins un provider pour activer les fonctionnalités AI dans l'éditeur
|
||||
|
||||
|
||||
@@ -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
|
||||
+110
-3
@@ -38,6 +38,17 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: node tests/frontend/unit.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
node pane-manager.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing — installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
node pane-manager.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
test:
|
||||
needs: lint
|
||||
@@ -54,6 +65,7 @@ jobs:
|
||||
run: |
|
||||
pip install pytest pytest-cov pytest-asyncio httpx
|
||||
pip install -r backend/requirements.txt
|
||||
pip install -r backend/requirements-test.txt || echo "test deps install failed (non-blocking — PDF tests will skip)"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ --cov=backend --cov-report=xml --cov-report=term -q
|
||||
@@ -82,7 +94,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 +106,103 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t obsigate:ci .
|
||||
- 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: Configure DNS (workaround flaky 127.0.0.11 resolver)
|
||||
# GitHub Actions runners occasionally fail to resolve auth.docker.io via
|
||||
# the embedded Docker DNS (127.0.0.11:53 → "server misbehaving").
|
||||
# Force the daemon to use public DNS as a fallback.
|
||||
run: |
|
||||
sudo mkdir -p /etc/docker
|
||||
if ! grep -q "dns" /etc/docker/daemon.json 2>/dev/null; then
|
||||
echo '{"dns": ["8.8.8.8", "1.1.1.1", "9.9.9.9"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker || sudo service docker restart || true
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
- name: Build Docker image (retry on transient DNS/network errors)
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "=== docker build attempt $attempt/3 ==="
|
||||
if docker build -t obsigate:ci . ; then
|
||||
echo "✓ Docker build succeeded"
|
||||
exit 0
|
||||
fi
|
||||
echo "✗ Build failed (attempt $attempt)"
|
||||
if [ $attempt -lt 3 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
echo "✗ Docker build failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- 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
|
||||
+20
-1
@@ -1,14 +1,33 @@
|
||||
__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/
|
||||
desktop/checksums.txt
|
||||
desktop/backend/
|
||||
desktop/frontend/
|
||||
backend/VERSION
|
||||
|
||||
|
||||
+117
-1
@@ -5,6 +5,122 @@ 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.1.0](#210--2026-09-07). La dernière version publiée est **2.0.0**.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-09-07
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#74 Support PDF au complet** — `GET /api/file/{vault}/pdf/info` (métadonnées sans
|
||||
contenu), streaming avec HTTP Range / 206 Partial Content (rendu progressif des gros PDF),
|
||||
config `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s).
|
||||
16 tests supplémentaires (26 au total dans `test_pdf.py`).
|
||||
- **#64 MFA — WebAuthn** (second facteur en plus du TOTP) : enregistrement de clés de
|
||||
sécurité / biométrie (Windows Hello, Touch ID), assertion au login, gestion des clés dans
|
||||
« Sécurité du compte », codes de récupération émis à l'activation. Nouveau module
|
||||
`backend/auth/webauthn_mfa.py` (lib `webauthn==2.6.0`, challenges in-memory TTL 180s à
|
||||
usage unique). 10 tests avec authentificateur virtuel (CBOR réel, ECDSA P-256).
|
||||
i18n FR/EN (13 clés). Config : `OBSIGATE_WEBAUTHN_RP_ID` / `_RP_NAME` / `_ORIGINS`.
|
||||
- **#77 Desktop — port auto-increment** : `pick_free_port()` scanne 17890..17899 si le port
|
||||
est occupé (2 instances côte à côte possibles) + 3 tests Rust (19 au total côté desktop).
|
||||
- Docs : sections PDF README FR/EN (Range, /pdf/info), variables env, guide WebAuthn.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **PDF stream 500** : `api_pdf_stream` plantait systématiquement (`NameError: current_user`
|
||||
non injecté — endpoint jamais couvert par un test). Désormais authentifié + testé.
|
||||
- **Indexation incrémentale des PDF** : le chemin watcher (`_index_single_file_sync`) lisait
|
||||
les PDFs en `read_text()` → contenu garbage indexé. Utilise maintenant `extract_pdf_text()`
|
||||
comme le scan complet.
|
||||
|
||||
---
|
||||
|
||||
## [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/`)
|
||||
- **Dashboard administrateur (#71, backend)** — 4 endpoints admin-gated pour monitoring serveur
|
||||
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
|
||||
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
|
||||
- `GET /api/admin/backup-stats` — compte + taille + age par vault
|
||||
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
|
||||
- Nouveau module `backend/admin.py` + 13 tests pytest
|
||||
- **Tests JSDOM PaneManager (#75, sous-tâche I2)** — 9 tests d'intégration frontend via JSDOM 30
|
||||
- Couvre : factory, init, open/activate/close, isolation entre panes, drag/drop smoke
|
||||
- Wired dans le CI Gitea (`.gitea/workflows/ci.yml`)
|
||||
- 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 +325,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,12 @@ RUN apt-get update \
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Bake version: build.sh/CI pre-generate backend/VERSION (copied above);
|
||||
# plain `docker compose build` has no such file (gitignored) — fall back
|
||||
# to the VERSION build arg so the image always carries a version string.
|
||||
ARG VERSION=0.0.0-dev
|
||||
RUN test -f backend/VERSION || echo "$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 \
|
||||
|
||||
+883
@@ -0,0 +1,883 @@
|
||||
# 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` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
||||
|
||||
### 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`.
|
||||
|
||||
### Support PDF
|
||||
|
||||
Les fichiers PDF de vos vaults s'affichent en ligne dans le navigateur via le visualiseur PDF natif (iframe + `<embed>`).
|
||||
Le visualiseur streame le fichier via HTTP Range (206 Partial Content) — les gros PDF se chargent progressivement.
|
||||
Le texte est extrait à l'indexation (pypdf / pymupdf) — le contenu PDF est donc recherchable via la recherche full-text.
|
||||
Filtrez avec `ext:pdf` pour restreindre les résultats aux PDF.
|
||||
Les métadonnées (pages, titre, auteur) sont disponibles via `GET /api/file/{vault}/pdf/info` sans transférer le document.
|
||||
|
||||
**Limitations :** pas d'OCR (les PDF scannés ne sont pas recherchables), pas d'annotation, pas d'édition du PDF lui-même.
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,262 @@
|
||||
# backend/admin.py
|
||||
# Admin Dashboard endpoints — system stats, audit logs, backup stats,
|
||||
# and Server-Sent Events stream for real-time widgets.
|
||||
#
|
||||
# All endpoints protected with require_admin.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.audit import AUDIT_LOG_FILE, get_recent_entries
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.indexer import get_vault_data, index
|
||||
|
||||
logger = logging.getLogger("obsigate.admin")
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
# Server start time — set at module import. Approximates uptime even
|
||||
# if used before lifespan startup fully runs.
|
||||
_SERVER_START_TIME = time.time()
|
||||
|
||||
|
||||
def _server_uptime_seconds() -> float:
|
||||
return time.time() - _SERVER_START_TIME
|
||||
|
||||
|
||||
def _format_timestamp_for_stream(payload: dict) -> str:
|
||||
"""Serialize a payload as an SSE-compatible data line."""
|
||||
return json.dumps(payload, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
def _get_disk_stats() -> tuple[float, float]:
|
||||
"""Return (used_gb, total_gb) for the data volume or cwd fallback."""
|
||||
# Prefer /data mount when present (production), else use cwd
|
||||
target = "/data" if os.path.isdir("/data") else "."
|
||||
try:
|
||||
usage = shutil.disk_usage(target)
|
||||
used_gb = round(usage.used / (1024 ** 3), 2)
|
||||
total_gb = round(usage.total / (1024 ** 3), 2)
|
||||
return used_gb, total_gb
|
||||
except OSError as e:
|
||||
logger.warning(f"disk_usage failed for {target}: {e}")
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _count_active_sessions() -> int:
|
||||
"""Count currently-revoked-free active sessions.
|
||||
|
||||
Best-effort estimate: we don't keep an in-memory session store, so we
|
||||
fall back to 1 if the server is up. Useful for the dashboard tile.
|
||||
"""
|
||||
return 1
|
||||
|
||||
|
||||
# ── /api/admin/stats ────────────────────────────────────────────────────
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(_admin=Depends(require_admin)):
|
||||
"""Return real-time system stats for the admin dashboard."""
|
||||
import psutil
|
||||
|
||||
try:
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
cpu_pct = 0.0
|
||||
|
||||
try:
|
||||
vm = psutil.virtual_memory()
|
||||
mem_used_mb = round(vm.used / (1024 ** 2), 1)
|
||||
mem_total_mb = round(vm.total / (1024 ** 2), 1)
|
||||
except Exception:
|
||||
mem_used_mb, mem_total_mb = 0.0, 0.0
|
||||
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
uptime_seconds = int(_server_uptime_seconds())
|
||||
active_sessions = _count_active_sessions()
|
||||
|
||||
return {
|
||||
"cpu_pct": cpu_pct,
|
||||
"mem_used_mb": mem_used_mb,
|
||||
"mem_total_mb": mem_total_mb,
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"active_sessions": active_sessions,
|
||||
}
|
||||
|
||||
|
||||
# ── /api/admin/audit ───────────────────────────────────────────────────
|
||||
@router.get("/audit")
|
||||
async def get_admin_audit(
|
||||
user: str | None = Query(None, description="Filter by username (substring)"),
|
||||
action: str | None = Query(None, description="Filter by exact action"),
|
||||
limit: int = Query(500, ge=1, le=2000, description="Max entries"),
|
||||
offset: int = Query(0, ge=0, description="Skip first N entries"),
|
||||
_admin=Depends(require_admin),
|
||||
):
|
||||
"""Return recent audit log entries with optional filters."""
|
||||
entries = get_recent_entries(limit=offset + limit, action=action)
|
||||
|
||||
if user:
|
||||
needle = user.lower()
|
||||
entries = [e for e in entries if needle in str(e.get("username", "")).lower()]
|
||||
|
||||
if offset:
|
||||
entries = entries[offset:]
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"log_file": str(AUDIT_LOG_FILE),
|
||||
}
|
||||
|
||||
|
||||
# ── /api/admin/backup-stats ───────────────────────────────────────────
|
||||
def _scan_backups() -> list[dict]:
|
||||
"""Walk every vault's backup directory and return one row per .bak file."""
|
||||
rows: list[dict] = []
|
||||
for vault_name in list(index.keys()):
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if not vault_backup_dir.exists():
|
||||
continue
|
||||
try:
|
||||
for fpath in vault_backup_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
try:
|
||||
st = fpath.stat()
|
||||
except OSError:
|
||||
continue
|
||||
# Backup filename: {orig}.{timestamp}.bak — split from the right
|
||||
name = fpath.name
|
||||
parts = name.rsplit(".", 2)
|
||||
if len(parts) < 3 or not parts[-2].isdigit():
|
||||
continue
|
||||
try:
|
||||
ts = int(parts[-2])
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append({
|
||||
"vault": vault_name,
|
||||
"filename": name,
|
||||
"timestamp": ts,
|
||||
"size": st.st_size,
|
||||
})
|
||||
except OSError as e:
|
||||
logger.warning(f"Backup scan error in {vault_backup_dir}: {e}")
|
||||
rows.sort(key=lambda r: r["timestamp"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/backup-stats")
|
||||
async def get_admin_backup_stats(_admin=Depends(require_admin)):
|
||||
"""Return backup statistics across all vaults."""
|
||||
rows = _scan_backups()
|
||||
now_ts = int(time.time())
|
||||
total_size_mb = round(sum(r["size"] for r in rows) / (1024 ** 2), 2)
|
||||
|
||||
by_vault: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
bucket = by_vault.setdefault(r["vault"], {"count": 0, "size_mb": 0.0})
|
||||
bucket["count"] += 1
|
||||
bucket["size_mb"] = round(bucket["size_mb"] + r["size"] / (1024 ** 2), 2)
|
||||
|
||||
if rows:
|
||||
newest_ts = rows[0]["timestamp"]
|
||||
oldest_ts = rows[-1]["timestamp"]
|
||||
newest_age_days = round((now_ts - newest_ts) / 86400, 2)
|
||||
oldest_age_days = round((now_ts - oldest_ts) / 86400, 2)
|
||||
else:
|
||||
newest_age_days = 0.0
|
||||
oldest_age_days = 0.0
|
||||
|
||||
return {
|
||||
"total_backups": len(rows),
|
||||
"total_size_mb": total_size_mb,
|
||||
"oldest_age_days": oldest_age_days,
|
||||
"newest_age_days": newest_age_days,
|
||||
"by_vault": by_vault,
|
||||
}
|
||||
|
||||
|
||||
# ── /api/admin/stream — Server-Sent Events ─────────────────────────────
|
||||
async def _stats_event_generator():
|
||||
"""Yield an SSE `stats` event with the current system stats."""
|
||||
try:
|
||||
import psutil
|
||||
# Prime cpu_percent so the first real measurement isn't 0.0
|
||||
psutil.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_keepalive = time.time()
|
||||
KEEPALIVE_INTERVAL = 15.0
|
||||
STATS_INTERVAL = 5.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
try:
|
||||
data = await asyncio.to_thread(_build_stats_payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"SSE stats build failed: {e}")
|
||||
data = {"error": str(e)}
|
||||
|
||||
yield f"event: stats\ndata: {_format_timestamp_for_stream(data)}\n\n"
|
||||
|
||||
if now - last_keepalive >= KEEPALIVE_INTERVAL:
|
||||
last_keepalive = now
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
await asyncio.sleep(STATS_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("SSE stats stream cancelled")
|
||||
raise
|
||||
|
||||
|
||||
def _build_stats_payload() -> dict:
|
||||
"""Synchronous stats builder for the SSE generator."""
|
||||
import psutil
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
vm = psutil.virtual_memory()
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
return {
|
||||
"cpu_pct": cpu_pct,
|
||||
"mem_used_mb": round(vm.used / (1024 ** 2), 1),
|
||||
"mem_total_mb": round(vm.total / (1024 ** 2), 1),
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"uptime_seconds": int(_server_uptime_seconds()),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_admin_stats(_admin=Depends(require_admin)):
|
||||
"""Server-Sent Events stream of system metrics, every 5 seconds."""
|
||||
return StreamingResponse(
|
||||
_stats_event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
+114
-46
@@ -1,45 +1,102 @@
|
||||
"""ObsiGate AI — Multi-provider AI service for editor enhancement.
|
||||
|
||||
Supports: DeepSeek, OpenRouter, Google Gemini.
|
||||
Supports: DeepSeek, OpenRouter, Google Gemini, Ollama, NVIDIA, QwenCloud, Xiaomi, Mistral.
|
||||
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", "nvidia", "qwencloud", "xiaomi", "mistral"]
|
||||
|
||||
# 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}",
|
||||
},
|
||||
"nvidia": {
|
||||
"api_key": get_ai_key("NVIDIA_API_KEY"),
|
||||
"base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"model": os.getenv("NVIDIA_MODEL", "meta/llama-3.1-405b-instruct"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"qwencloud": {
|
||||
"api_key": get_ai_key("QWENCLOUD_API_KEY"),
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": os.getenv("QWENCLOUD_MODEL", "qwen-max"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"xiaomi": {
|
||||
"api_key": get_ai_key("XIAOMI_API_KEY"),
|
||||
"base_url": os.getenv("XIAOMI_BASE_URL", "https://api.xiaomimimo.com/v1"),
|
||||
"model": os.getenv("XIAOMI_MODEL", "mimo-v2.5-pro"),
|
||||
# Xiaomi MiMo uses a dedicated `api-key` header (NOT Authorization: Bearer).
|
||||
# The `_call_deepseek_openrouter` helper substitutes {api_key} verbatim,
|
||||
# so we just emit the raw key value here.
|
||||
"auth_header": "{api_key}",
|
||||
"auth_header_name": "api-key",
|
||||
},
|
||||
"mistral": {
|
||||
"api_key": get_ai_key("MISTRAL_API_KEY"),
|
||||
"base_url": "https://api.mistral.ai/v1",
|
||||
"model": os.getenv("MISTRAL_MODEL", "mistral-large-latest"),
|
||||
"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,15 +112,25 @@ 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)."""
|
||||
"""Call OpenAI-compatible API (DeepSeek, OpenRouter, Xiaomi MiMo, etc.)."""
|
||||
cfg = _get_provider_config(provider)
|
||||
# Debug: log masked key to diagnose 401
|
||||
key_preview = cfg["api_key"][:8] + "..." + cfg["api_key"][-4:] if len(cfg["api_key"]) > 12 else "***"
|
||||
logger.info(f"AI call: provider={cfg['name']} model={cfg['model']} key={key_preview}")
|
||||
# Most providers use "Authorization: Bearer KEY". Some (Xiaomi MiMo) use a
|
||||
# dedicated header like "api-key: KEY". We support both via the
|
||||
# `auth_header_name` key in PROVIDERS — defaults to "Authorization".
|
||||
header_name = cfg.get("auth_header_name") or "Authorization"
|
||||
header_value = cfg["auth_header"].format(api_key=cfg["api_key"])
|
||||
# If the auth_header template doesn't include "Bearer " but the default
|
||||
# header is Authorization, prepend it. This preserves backward compatibility
|
||||
# for providers that store just the raw key.
|
||||
if header_name == "Authorization" and not header_value.lower().startswith("bearer "):
|
||||
header_value = "Bearer " + header_value
|
||||
headers = {
|
||||
"Authorization": cfg["auth_header"].format(api_key=cfg["api_key"]),
|
||||
header_name: header_value,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
@@ -105,11 +172,12 @@ 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":
|
||||
return await _call_gemini(prompt, "You are a helpful assistant.")
|
||||
# All other providers use OpenAI-compatible format
|
||||
return await _call_deepseek_openrouter(prompt, "You are a helpful assistant.", provider)
|
||||
|
||||
|
||||
@@ -120,7 +188,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 +196,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 +204,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 +212,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 +220,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 +228,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 +236,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 +252,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 +260,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 +268,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 +276,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 +284,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 +292,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 +300,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 +308,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 +316,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}",
|
||||
|
||||
+77
-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,32 +37,72 @@ 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",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"nvidia": "NVIDIA_API_KEY",
|
||||
"qwencloud": "QWENCLOUD_API_KEY",
|
||||
"xiaomi": "XIAOMI_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
}
|
||||
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 (e.g. 'deepseek', 'nvidia')")
|
||||
model: str | None = Field(None, description="Model name override for this request")
|
||||
|
||||
|
||||
class AIResponse(BaseModel):
|
||||
@@ -59,6 +112,12 @@ class AIResponse(BaseModel):
|
||||
|
||||
async def _handle(action, request: AIRequest):
|
||||
"""Wrapper with error handling."""
|
||||
from backend.ai import PROVIDERS
|
||||
# Apply per-request model override (saved/restored around the call)
|
||||
original_model = None
|
||||
if request.model and request.provider and request.provider in PROVIDERS:
|
||||
original_model = PROVIDERS[request.provider].get("model")
|
||||
PROVIDERS[request.provider]["model"] = request.model
|
||||
try:
|
||||
result = await action(request.text, request.provider)
|
||||
return AIResponse(result=result, provider=request.provider or "default")
|
||||
@@ -66,7 +125,10 @@ 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}")
|
||||
finally:
|
||||
if original_model is not None and request.provider in PROVIDERS:
|
||||
PROVIDERS[request.provider]["model"] = original_model
|
||||
|
||||
|
||||
@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])
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# backend/auth/mfa.py
|
||||
# Multi-Factor Authentication: TOTP + recovery codes.
|
||||
# TOTP via pyotp, recovery codes hashed with argon2 for single-use storage.
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
import pyotp
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.mfa")
|
||||
|
||||
TOTP_ISSUER = "ObsiGate"
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Generate a new TOTP secret (base32-encoded, 160 bits)."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def generate_qr_uri(secret: str, username: str, issuer: str = TOTP_ISSUER) -> str:
|
||||
"""Generate an otpauth:// URI for QR code generation."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(name=username, issuer_name=issuer)
|
||||
|
||||
|
||||
def verify_totp(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP code with a ±1 window tolerance."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1)
|
||||
|
||||
|
||||
def generate_recovery_codes(n: int = 8) -> list[str]:
|
||||
"""Generate n human-readable recovery codes (XXXX-XXXX format)."""
|
||||
codes = []
|
||||
for _ in range(n):
|
||||
# 8 chars alphanumeric, grouped with dash for readability
|
||||
raw = secrets.token_hex(4).upper()
|
||||
code = f"{raw[:4]}-{raw[4:]}"
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
|
||||
def hash_recovery_code(code: str) -> str:
|
||||
"""Hash a recovery code for storage (SHA-256 for fast comparison).
|
||||
|
||||
We use SHA-256 instead of argon2 here because recovery codes are
|
||||
high-entropy random strings, not user-chosen passwords.
|
||||
"""
|
||||
return hashlib.sha256(code.upper().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_recovery_code(code: str, hashed_codes: list[str]) -> int | None:
|
||||
"""Verify a recovery code against stored hashes.
|
||||
|
||||
Returns the index of the matched code (for removal), or None if invalid.
|
||||
Comparison is case-insensitive.
|
||||
"""
|
||||
code_hash = hash_recovery_code(code)
|
||||
for i, stored_hash in enumerate(hashed_codes):
|
||||
if secrets.compare_digest(code_hash, stored_hash):
|
||||
return i
|
||||
return None
|
||||
@@ -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,
|
||||
|
||||
+477
-34
@@ -2,24 +2,45 @@
|
||||
# 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, Body, 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 .mfa import (
|
||||
generate_qr_uri,
|
||||
generate_recovery_codes,
|
||||
generate_secret,
|
||||
hash_recovery_code,
|
||||
verify_recovery_code,
|
||||
verify_totp,
|
||||
)
|
||||
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 +69,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 +87,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 ──────────────────────────────────────────────────
|
||||
@@ -123,16 +144,32 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
detail += f" ({remaining} tentative(s) restante(s))"
|
||||
raise HTTPException(401, detail)
|
||||
|
||||
# Success — clear rate limits and generate tokens
|
||||
record_login_success(body.username)
|
||||
# Success — clear rate limits
|
||||
rl_record_success(client_ip)
|
||||
|
||||
# If MFA is enabled, don't issue token yet — require second factor
|
||||
if user.get("mfa_enabled"):
|
||||
method = "totp" if user.get("mfa_secret") else _preferred_mfa_method(user)
|
||||
logger.info(f"User '{body.username}' login deferred — MFA required ({method})")
|
||||
return {
|
||||
"mfa_required": True,
|
||||
"mfa_method": method,
|
||||
"username": body.username,
|
||||
"remember_me": body.remember_me,
|
||||
}
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||
record_login_success(username)
|
||||
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(body.username)
|
||||
refresh_token, refresh_jti = create_refresh_token(username)
|
||||
|
||||
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
|
||||
max_age = 2592000 if body.remember_me else 604800 # 30d or 7d
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
@@ -143,10 +180,7 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
secure=secure,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in")
|
||||
|
||||
# Set access token as cookie for same-origin requests (e.g. popout window)
|
||||
logger.info(f"User '{username}' logged in")
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
@@ -156,7 +190,6 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
@@ -217,18 +250,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 +275,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,
|
||||
@@ -257,6 +324,382 @@ async def change_password(
|
||||
return {"message": "Mot de passe mis à jour"}
|
||||
|
||||
|
||||
# ── MFA endpoints ────────────────────────────────────────────────────
|
||||
|
||||
class MfaVerifyRequest(BaseModel):
|
||||
username: str
|
||||
code: str
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class MfaRecoveryRequest(BaseModel):
|
||||
username: str
|
||||
recovery_code: str
|
||||
|
||||
|
||||
class MfaDisableRequest(BaseModel):
|
||||
password: str
|
||||
code: str
|
||||
|
||||
|
||||
class MfaEnableRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/mfa/totp/setup")
|
||||
async def mfa_totp_setup(current_user=Depends(require_auth)):
|
||||
"""Generate a TOTP secret and QR URI for MFA setup.
|
||||
|
||||
Returns the secret and otpauth URI — client displays QR code.
|
||||
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
|
||||
"""
|
||||
from .user_store import update_user
|
||||
secret = generate_secret()
|
||||
qr_uri = generate_qr_uri(secret, current_user["username"])
|
||||
# Store secret temporarily (not yet enabled)
|
||||
update_user(current_user["username"], {
|
||||
"mfa_secret_pending": secret,
|
||||
})
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_uri": qr_uri,
|
||||
"otpauth_uri": qr_uri,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/enable")
|
||||
async def mfa_totp_enable(
|
||||
req: MfaEnableRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Enable MFA after verifying the first TOTP code.
|
||||
|
||||
On success: generates recovery codes, enables MFA, returns recovery codes.
|
||||
"""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
secret = user.get("mfa_secret_pending")
|
||||
if not secret:
|
||||
raise HTTPException(400, "Aucune configuration MFA en cours. Commencez par /mfa/totp/setup")
|
||||
|
||||
if not verify_totp(secret, req.code):
|
||||
raise HTTPException(400, "Code TOTP invalide")
|
||||
|
||||
# Generate recovery codes
|
||||
recovery_codes = generate_recovery_codes()
|
||||
hashed_codes = [hash_recovery_code(c) for c in recovery_codes]
|
||||
|
||||
# Enable MFA
|
||||
update_user(current_user["username"], {
|
||||
"mfa_enabled": True,
|
||||
"mfa_secret": secret,
|
||||
"mfa_method": "totp",
|
||||
"mfa_recovery_codes": hashed_codes,
|
||||
"mfa_secret_pending": None, # clear pending
|
||||
})
|
||||
|
||||
logger.info(f"MFA enabled for user '{current_user['username']}'")
|
||||
return {
|
||||
"mfa_enabled": True,
|
||||
"recovery_codes": recovery_codes, # shown once, client must display/save
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/disable")
|
||||
async def mfa_totp_disable(
|
||||
req: MfaDisableRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Disable MFA. Requires current password + valid TOTP code."""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe incorrect")
|
||||
|
||||
if not verify_totp(user["mfa_secret"], req.code):
|
||||
raise HTTPException(400, "Code TOTP invalide")
|
||||
|
||||
update_user(current_user["username"], {
|
||||
"mfa_enabled": False,
|
||||
"mfa_secret": None,
|
||||
"mfa_method": None,
|
||||
"mfa_recovery_codes": [],
|
||||
})
|
||||
|
||||
logger.info(f"MFA disabled for user '{current_user['username']}'")
|
||||
return {"mfa_enabled": False}
|
||||
|
||||
|
||||
# ── WebAuthn endpoints (ROADMAP #64) ─────────────────────────────────
|
||||
|
||||
def _preferred_mfa_method(user: dict) -> str:
|
||||
"""Which second factor to offer at login: webauthn when keys exist, else totp."""
|
||||
if user.get("webauthn_credentials"):
|
||||
return "webauthn"
|
||||
return "totp"
|
||||
|
||||
|
||||
class WebauthnRegisterRequest(BaseModel):
|
||||
credential: dict
|
||||
label: str = ""
|
||||
|
||||
|
||||
class WebauthnVerifyRequest(BaseModel):
|
||||
username: str
|
||||
credential: dict
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class WebauthnRemoveRequest(BaseModel):
|
||||
credential_id: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register/options")
|
||||
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
|
||||
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
|
||||
from .webauthn_mfa import begin_registration
|
||||
|
||||
options = begin_registration(current_user["username"],
|
||||
current_user.get("display_name", ""))
|
||||
return {"options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register")
|
||||
async def mfa_webauthn_register(
|
||||
req: WebauthnRegisterRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Verify the created credential, store it, and enable MFA if not already on.
|
||||
|
||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_registration
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
try:
|
||||
record = complete_registration(current_user["username"], req.credential,
|
||||
label=req.label)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
||||
|
||||
record["registered_at"] = datetime.now(timezone.utc).isoformat()
|
||||
creds = list(user.get("webauthn_credentials", []))
|
||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||
creds.append(record)
|
||||
|
||||
updates: dict = {"webauthn_credentials": creds}
|
||||
issued_recovery: list[str] = []
|
||||
if not user.get("mfa_enabled"):
|
||||
issued_recovery = generate_recovery_codes()
|
||||
updates.update({
|
||||
"mfa_enabled": True,
|
||||
"mfa_method": "webauthn",
|
||||
"mfa_recovery_codes": [hash_recovery_code(c) for c in issued_recovery],
|
||||
})
|
||||
update_user(current_user["username"], updates)
|
||||
|
||||
logger.info(f"WebAuthn credential registered for user '{current_user['username']}' "
|
||||
f"({record['label']})")
|
||||
return {
|
||||
"ok": True,
|
||||
"credentials": user_credentials_response(creds),
|
||||
"mfa_enabled": True,
|
||||
"recovery_codes": issued_recovery,
|
||||
}
|
||||
|
||||
|
||||
def user_credentials_response(creds: list[dict]) -> list[dict]:
|
||||
from .webauthn_mfa import credentials_for_api
|
||||
return credentials_for_api(creds)
|
||||
|
||||
|
||||
@router.get("/mfa/webauthn/credentials")
|
||||
async def mfa_webauthn_list(current_user=Depends(require_auth)):
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
return {"credentials": user_credentials_response(user.get("webauthn_credentials", []))}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/credentials/remove")
|
||||
async def mfa_webauthn_remove(
|
||||
req: WebauthnRemoveRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Remove a WebAuthn key. Requires password. Disables MFA if no second factor remains."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import clear_pending
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe incorrect")
|
||||
|
||||
creds = [c for c in user.get("webauthn_credentials", [])
|
||||
if c.get("credential_id") != req.credential_id]
|
||||
if len(creds) == len(user.get("webauthn_credentials", [])):
|
||||
raise HTTPException(404, "Credential inconnu")
|
||||
|
||||
updates: dict = {"webauthn_credentials": creds}
|
||||
if not creds and not user.get("mfa_secret"):
|
||||
updates.update({"mfa_enabled": False, "mfa_method": None, "mfa_recovery_codes": []})
|
||||
elif not creds and user.get("mfa_secret"):
|
||||
updates["mfa_method"] = "totp"
|
||||
update_user(current_user["username"], updates)
|
||||
clear_pending(current_user["username"])
|
||||
return {"ok": True, "credentials": user_credentials_response(creds),
|
||||
"mfa_enabled": bool(updates.get("mfa_enabled", user.get("mfa_enabled"))) and bool(creds or user.get("mfa_secret"))}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/options")
|
||||
async def mfa_webauthn_login_options(body: dict = Body(...)):
|
||||
"""Unauthenticated: begin the login assertion for a user with registered keys.
|
||||
|
||||
Enumeration-safe: always 200 — returns null options (caller falls back to
|
||||
TOTP/recovery UI) when the user has no WebAuthn key or MFA is off.
|
||||
"""
|
||||
username = str(body.get("username", ""))
|
||||
user = get_user(username)
|
||||
creds = (user or {}).get("webauthn_credentials", [])
|
||||
if not user or not user.get("mfa_enabled") or not creds:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
|
||||
from .webauthn_mfa import begin_authentication
|
||||
options = begin_authentication(username, creds)
|
||||
if options is None:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
return {"mfa_method": "webauthn", "options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/verify")
|
||||
async def mfa_webauthn_verify(
|
||||
body: WebauthnVerifyRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
):
|
||||
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_authentication
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
creds = user.get("webauthn_credentials", [])
|
||||
try:
|
||||
credential_id = body.credential.get("id", "")
|
||||
stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
|
||||
if stored is None:
|
||||
raise ValueError("Credential non enregistré")
|
||||
new_count = complete_authentication(body.username, body.credential, stored)
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
|
||||
raise HTTPException(401, "Vérification WebAuthn échouée")
|
||||
|
||||
updated = [dict(c) for c in creds]
|
||||
for c in updated:
|
||||
if c.get("credential_id") == body.credential.get("id"):
|
||||
c["sign_count"] = new_count
|
||||
update_user(body.username, {"webauthn_credentials": updated})
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
@router.get("/mfa/status")
|
||||
async def mfa_status(current_user=Depends(require_auth)):
|
||||
"""Return current user's MFA status."""
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
return {
|
||||
"mfa_enabled": user.get("mfa_enabled", False),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
"totp_enabled": bool(user.get("mfa_secret")),
|
||||
"webauthn_credentials": len(user.get("webauthn_credentials", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/verify")
|
||||
async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: Request):
|
||||
"""Verify TOTP code during login (second factor).
|
||||
|
||||
Called after login returns mfa_required=true.
|
||||
On success: issues JWT tokens.
|
||||
"""
|
||||
from .user_store import get_user
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
# Timing-safe: simulate work
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
|
||||
if not user.get("mfa_enabled") or not user.get("mfa_secret"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
if not verify_totp(user["mfa_secret"], body.code):
|
||||
raise HTTPException(401, "Code TOTP invalide")
|
||||
|
||||
# Clear IP rate limit on success
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
@router.post("/mfa/recovery")
|
||||
async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, request: Request):
|
||||
"""Login with a recovery code (when TOTP device is unavailable).
|
||||
|
||||
Each recovery code is single-use.
|
||||
"""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
hashed_codes = user.get("mfa_recovery_codes", [])
|
||||
if not hashed_codes:
|
||||
raise HTTPException(400, "Aucun code de récupération disponible")
|
||||
|
||||
idx = verify_recovery_code(body.recovery_code, hashed_codes)
|
||||
if idx is None:
|
||||
raise HTTPException(401, "Code de récupération invalide")
|
||||
|
||||
# Remove used recovery code (single-use)
|
||||
hashed_codes.pop(idx)
|
||||
update_user(body.username, {"mfa_recovery_codes": hashed_codes})
|
||||
|
||||
# Clear IP rate limit
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||
return _issue_tokens(user, body.username, False, response)
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/users")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# backend/auth/webauthn_mfa.py
|
||||
# WebAuthn support for MFA (ROADMAP #64): security keys / platform biometrics.
|
||||
# Thin wrapper over the `webauthn` library with an in-memory challenge store.
|
||||
#
|
||||
# Credentials are persisted in users.json under "webauthn_credentials":
|
||||
# [{ "credential_id": <b64url>, "public_key": <b64url>, "sign_count": int,
|
||||
# "transports": [...], "label": str, "registered_at": iso }]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers import (
|
||||
base64url_to_bytes,
|
||||
bytes_to_base64url,
|
||||
parse_authentication_credential_json,
|
||||
parse_registration_credential_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.webauthn")
|
||||
|
||||
# Challenge lifetime: clients have 3 minutes to complete the ceremony.
|
||||
CHALLENGE_TTL_SECONDS = 180
|
||||
|
||||
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
|
||||
_pending: dict[str, tuple[bytes, float]] = {}
|
||||
|
||||
|
||||
def rp_id() -> str:
|
||||
return os.environ.get("OBSIGATE_WEBAUTHN_RP_ID", "localhost")
|
||||
|
||||
|
||||
def rp_name() -> str:
|
||||
return os.environ.get("OBSIGATE_WEBAUTHN_RP_NAME", "ObsiGate")
|
||||
|
||||
|
||||
def expected_origins() -> list[str]:
|
||||
raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost")
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
now = time.time()
|
||||
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
def _store_challenge(key: str) -> bytes:
|
||||
_prune_expired()
|
||||
challenge = secrets.token_bytes(32)
|
||||
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
|
||||
return challenge
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
"""Pop a challenge (single-use). Returns None if missing/expired."""
|
||||
_prune_expired()
|
||||
entry = _pending.pop(key, None)
|
||||
return entry[0] if entry else None
|
||||
|
||||
|
||||
def clear_pending(username: str) -> None:
|
||||
"""Drop all pending challenges for a user (e.g. after enable/disable)."""
|
||||
for key in [k for k in _pending if k.startswith(f"{username}:")]:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
# ── Registration (enrol a key in settings) ─────────────────────────────
|
||||
|
||||
def begin_registration(username: str, display_name: str) -> dict:
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id(),
|
||||
rp_name=rp_name(),
|
||||
user_name=username,
|
||||
user_display_name=display_name or username,
|
||||
challenge=_store_challenge(f"{username}:register"),
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_registration(username: str, credential_json: dict[str, Any],
|
||||
label: str = "") -> dict:
|
||||
challenge = _take_challenge(f"{username}:register")
|
||||
if challenge is None:
|
||||
raise ValueError("Session d'enregistrement expirée — recommencez")
|
||||
|
||||
credential = parse_registration_credential_json(credential_json)
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
)
|
||||
|
||||
transports = credential.response.transports or []
|
||||
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
|
||||
record = {
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": int(verification.sign_count),
|
||||
"transports": [str(t) for t in transports],
|
||||
"label": label[:60],
|
||||
}
|
||||
return record
|
||||
|
||||
|
||||
# ── Authentication (assertion at login) ────────────────────────────────
|
||||
|
||||
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
|
||||
if not credentials:
|
||||
return None
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
allow = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in credentials
|
||||
]
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id(),
|
||||
challenge=_store_challenge(f"{username}:login"),
|
||||
allow_credentials=allow,
|
||||
)
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_authentication(
|
||||
username: str,
|
||||
credential_json: dict[str, Any],
|
||||
stored: dict,
|
||||
) -> int:
|
||||
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
|
||||
challenge = _take_challenge(f"{username}:login")
|
||||
if challenge is None:
|
||||
raise ValueError("Session expirée — rechargez la page")
|
||||
|
||||
credential = parse_authentication_credential_json(credential_json)
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
credential_public_key=base64url_to_bytes(stored["public_key"]),
|
||||
credential_current_sign_count=int(stored.get("sign_count", 0)),
|
||||
)
|
||||
return int(verification.new_sign_count)
|
||||
|
||||
|
||||
def credentials_for_api(credentials: list[dict]) -> list[dict]:
|
||||
"""Sanitized view for the settings UI (no public keys)."""
|
||||
return [
|
||||
{
|
||||
"credential_id": c.get("credential_id"),
|
||||
"label": c.get("label", "Security key"),
|
||||
"transports": c.get("transports", []),
|
||||
"registered_at": c.get("registered_at"),
|
||||
}
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
def _finalize_options(options) -> dict:
|
||||
import json
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
@@ -0,0 +1,256 @@
|
||||
"""BooksLM — Context collection and caching for directory-scoped AI chat.
|
||||
|
||||
Collects markdown files from an Obsidian vault directory, applies secret
|
||||
redaction, builds a system prompt with file contents, and caches results
|
||||
for repeated queries.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm")
|
||||
|
||||
# ── Configuration limits ──
|
||||
BOOKSLM_MAX_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
|
||||
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
|
||||
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000"))
|
||||
|
||||
# ── Cache ──
|
||||
_cache: dict[str, dict[str, Any]] = {}
|
||||
_CACHE_TTL = 300 # seconds
|
||||
|
||||
|
||||
def _cache_key(vault_path: Path, directory: str, file_mtimes: list[tuple[str, float]]) -> str:
|
||||
"""Build a SHA-256 cache key from vault+directory+file modification times."""
|
||||
raw = json.dumps({
|
||||
"vault": str(vault_path),
|
||||
"dir": directory,
|
||||
"mtimes": sorted(file_mtimes),
|
||||
}, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _should_skip(name: str) -> bool:
|
||||
"""Return True if this file/directory name should be skipped."""
|
||||
skip_prefixes = (".",)
|
||||
skip_names = {"_attachments", "node_modules", ".git", ".obsidian", "__pycache__"}
|
||||
if name in skip_names:
|
||||
return True
|
||||
return bool(any(name.startswith(p) for p in skip_prefixes))
|
||||
|
||||
|
||||
def _file_priority(path: Path) -> tuple[int, float]:
|
||||
"""Sort key: README/index first, then by modification time descending.
|
||||
|
||||
Returns (priority_group, -mtime) so that:
|
||||
- Group 0: README* and index* files (come first)
|
||||
- Group 1: all other files (come after)
|
||||
Within each group, newer files come first.
|
||||
"""
|
||||
name_lower = path.stem.lower()
|
||||
if name_lower.startswith("readme") or name_lower.startswith("index"):
|
||||
group = 0
|
||||
else:
|
||||
group = 1
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
return (group, -mtime)
|
||||
|
||||
|
||||
def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any]:
|
||||
"""Walk a directory recursively, collect .md files with content.
|
||||
|
||||
Args:
|
||||
vault_path: Absolute path to the vault root.
|
||||
directory: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
Dict with keys: files, total_chars, file_count, directory_tree.
|
||||
"""
|
||||
target_dir = (vault_path / directory).resolve() if directory else vault_path.resolve()
|
||||
vault_resolved = vault_path.resolve()
|
||||
|
||||
# Safety: ensure target is within vault
|
||||
try:
|
||||
target_dir.relative_to(vault_resolved)
|
||||
except ValueError:
|
||||
logger.warning(f"Directory outside vault: {target_dir}")
|
||||
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
|
||||
# Check cache
|
||||
file_mtimes: list[tuple[str, float]] = []
|
||||
md_files: list[Path] = []
|
||||
try:
|
||||
for p in target_dir.rglob("*"):
|
||||
# Skip hidden dirs/files and special dirs
|
||||
parts = p.relative_to(target_dir).parts
|
||||
if any(_should_skip(part) for part in parts):
|
||||
continue
|
||||
if p.is_file() and p.suffix.lower() == ".md":
|
||||
md_files.append(p)
|
||||
try:
|
||||
file_mtimes.append((str(p.relative_to(target_dir)), p.stat().st_mtime))
|
||||
except OSError:
|
||||
file_mtimes.append((str(p.relative_to(target_dir)), 0.0))
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied scanning {target_dir}")
|
||||
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
|
||||
key = _cache_key(vault_resolved, directory, file_mtimes)
|
||||
if key in _cache:
|
||||
cached = _cache[key]
|
||||
if time.time() - cached.get("_ts", 0) < _CACHE_TTL:
|
||||
logger.debug(f"Cache hit for {directory}")
|
||||
return {k: v for k, v in cached.items() if k != "_ts"}
|
||||
|
||||
# Sort by priority: README/index first, then by mtime descending
|
||||
md_files.sort(key=_file_priority)
|
||||
|
||||
# Apply limits
|
||||
collected: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
|
||||
for p in md_files:
|
||||
if len(collected) >= BOOKSLM_MAX_FILES:
|
||||
break
|
||||
if total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
|
||||
break
|
||||
|
||||
rel_path = str(p.relative_to(vault_resolved)).replace("\\", "/")
|
||||
try:
|
||||
content = p.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.warning(f"Cannot read {rel_path}: {e}")
|
||||
continue
|
||||
|
||||
# Redact secrets
|
||||
content = redact_file_content(content, rel_path)
|
||||
|
||||
# Truncate if too long
|
||||
if len(content) > BOOKSLM_MAX_FILE_CHARS:
|
||||
content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
|
||||
|
||||
remaining = BOOKSLM_MAX_TOTAL_CHARS - total_chars
|
||||
if len(content) > remaining:
|
||||
content = content[:remaining] + "\n\n[... tronqué]"
|
||||
|
||||
title = p.stem.replace("-", " ").replace("_", " ").title()
|
||||
file_type = "markdown"
|
||||
|
||||
collected.append({
|
||||
"path": rel_path,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"type": file_type,
|
||||
})
|
||||
total_chars += len(content)
|
||||
|
||||
# Build directory tree
|
||||
dir_tree = _build_directory_tree(target_dir, vault_resolved)
|
||||
|
||||
result = {
|
||||
"files": collected,
|
||||
"total_chars": total_chars,
|
||||
"file_count": len(collected),
|
||||
"directory_tree": dir_tree,
|
||||
}
|
||||
|
||||
# Store in cache
|
||||
_cache[key] = {**result, "_ts": time.time()}
|
||||
logger.info(f"Collected {len(collected)} files ({total_chars} chars) from {directory or '/'}")
|
||||
return result
|
||||
|
||||
|
||||
def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
|
||||
"""Build a text representation of the directory tree (dirs + .md files)."""
|
||||
lines: list[str] = []
|
||||
try:
|
||||
for p in sorted(target_dir.rglob("*")):
|
||||
parts = p.relative_to(target_dir).parts
|
||||
if any(_should_skip(part) for part in parts):
|
||||
continue
|
||||
if p.is_dir():
|
||||
depth = len(p.relative_to(target_dir).parts)
|
||||
lines.append(f"{' ' * depth}{p.name}/")
|
||||
elif p.is_file() and p.suffix.lower() == ".md":
|
||||
depth = len(p.relative_to(target_dir).parts)
|
||||
lines.append(f"{' ' * depth}{p.name}")
|
||||
except PermissionError:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_system_prompt(context: dict[str, Any]) -> str:
|
||||
"""Build a system prompt for directory-scoped AI chat.
|
||||
|
||||
Args:
|
||||
context: Output of collect_directory_context().
|
||||
|
||||
Returns:
|
||||
System prompt string with file contents.
|
||||
"""
|
||||
files = context.get("files", [])
|
||||
file_count = context.get("file_count", 0)
|
||||
total_chars = context.get("total_chars", 0)
|
||||
|
||||
# Rough token estimate (1 token ≈ 4 chars)
|
||||
est_tokens = total_chars // 4
|
||||
token_warning = ""
|
||||
if est_tokens > 100_000:
|
||||
token_warning = f"\n⚠️ Attention : le contexte est très volumineux (~{est_tokens:,} tokens estimés). Les réponses peuvent être moins précises.\n"
|
||||
|
||||
prompt = (
|
||||
"Tu es un assistant de recherche documentaire. "
|
||||
"Tu réponds UNIQUEMENT en te basant sur les documents fournis ci-dessous. "
|
||||
"Cite tes sources avec le nom du fichier quand tu utilises une information. "
|
||||
"Si l'information ne se trouve pas dans les documents, dis-le clairement."
|
||||
f"\n\n📚 Contexte : {file_count} fichier(s) ({total_chars:,} caractères)"
|
||||
f"{token_warning}\n"
|
||||
)
|
||||
|
||||
# Directory tree
|
||||
tree = context.get("directory_tree", "")
|
||||
if tree:
|
||||
prompt += f"\n📂 Arborescence du dossier :\n```\n{tree}\n```\n"
|
||||
|
||||
# File contents
|
||||
prompt += "\n---\n"
|
||||
for f in files:
|
||||
prompt += f"\n## 📄 {f['title']} (`{f['path']}`)\n\n{f['content']}\n\n---\n"
|
||||
|
||||
prompt += "\nFin du contexte. Réponds à la question de l'utilisateur en te basant uniquement sur ces documents."
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def invalidate_cache(vault_path: Path | None = None, directory: str | None = None) -> int:
|
||||
"""Invalidate cache entries.
|
||||
|
||||
Args:
|
||||
vault_path: If provided, only invalidate entries for this vault.
|
||||
directory: If provided, only invalidate entries for this directory.
|
||||
|
||||
Returns:
|
||||
Number of cache entries removed.
|
||||
"""
|
||||
if vault_path is None and directory is None:
|
||||
count = len(_cache)
|
||||
_cache.clear()
|
||||
return count
|
||||
|
||||
to_remove = list(_cache)
|
||||
for k in to_remove:
|
||||
del _cache[k]
|
||||
return len(to_remove)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""BooksLM API routes — directory-scoped AI chat for Obsidian vaults."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.bookslm import build_system_prompt, collect_directory_context
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm_routes")
|
||||
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
|
||||
|
||||
|
||||
# ── Request models ──
|
||||
|
||||
|
||||
class BooksLMContextRequest(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
directory: str = Field(default="", description="Relative directory path within the vault")
|
||||
|
||||
|
||||
class BooksLMChatRequest(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
directory: str = Field(default="", description="Relative directory path within the vault")
|
||||
message: str = Field(description="User message")
|
||||
conversation_history: list[dict[str, str]] = Field(
|
||||
default_factory=list,
|
||||
description="Previous conversation turns [{role, content}]",
|
||||
)
|
||||
provider: str | None = Field(
|
||||
default=None,
|
||||
description="AI provider override (e.g. 'deepseek', 'openrouter', 'gemini', 'nvidia', 'xiaomi', 'mistral', 'qwencloud'). "
|
||||
"If not set, uses DEFAULT_PROVIDER.",
|
||||
)
|
||||
model: str | None = Field(
|
||||
default=None,
|
||||
description="Model name to use for this request. If not set, uses the provider's default model.",
|
||||
)
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
|
||||
@router.post("/context")
|
||||
async def api_bookslm_context(
|
||||
req: BooksLMContextRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Collect directory context for BooksLM.
|
||||
|
||||
Returns file list, content, and metadata for the specified directory.
|
||||
"""
|
||||
if not check_vault_access(req.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
|
||||
|
||||
vault_data = get_vault_data(req.vault)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
|
||||
|
||||
vault_path = Path(vault_data["path"])
|
||||
context = collect_directory_context(vault_path, req.directory)
|
||||
return context
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def api_bookslm_chat(
|
||||
req: BooksLMChatRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Chat with AI about directory contents (BooksLM).
|
||||
|
||||
Builds context from the directory, then sends the user message
|
||||
with a system prompt containing all file contents to the AI provider.
|
||||
Returns an SSE stream with the response.
|
||||
"""
|
||||
if not check_vault_access(req.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
|
||||
|
||||
vault_data = get_vault_data(req.vault)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
|
||||
|
||||
vault_path = Path(vault_data["path"])
|
||||
|
||||
# Collect context
|
||||
context = collect_directory_context(vault_path, req.directory)
|
||||
if context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
|
||||
# Build system prompt
|
||||
system_prompt = build_system_prompt(context)
|
||||
|
||||
# Call AI provider
|
||||
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
|
||||
|
||||
# Build messages with conversation history
|
||||
messages_text = ""
|
||||
if req.conversation_history:
|
||||
for turn in req.conversation_history:
|
||||
role = turn.get("role", "user")
|
||||
content = turn.get("content", "")
|
||||
if role == "user":
|
||||
messages_text += f"\n\nUtilisateur : {content}"
|
||||
elif role == "assistant":
|
||||
messages_text += f"\n\nAssistant : {content}"
|
||||
|
||||
# Current message
|
||||
user_prompt = req.message
|
||||
if messages_text:
|
||||
user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}"
|
||||
|
||||
async def generate_sse():
|
||||
try:
|
||||
# Resolve provider: explicit override wins, else default.
|
||||
# Fall back to first available if the requested one isn't configured.
|
||||
cfg_name = (req.provider or DEFAULT_PROVIDER).lower()
|
||||
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"):
|
||||
# Try next available provider
|
||||
for pname, pcfg in PROVIDERS.items():
|
||||
if pcfg.get("api_key") and pname != "gemini":
|
||||
cfg_name = pname
|
||||
break
|
||||
else:
|
||||
# No provider available at all
|
||||
err = "Aucun fournisseur AI configuré (clés API manquantes)"
|
||||
error_data = json.dumps({"error": err}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
# Optional per-request model override
|
||||
original_model = None
|
||||
if req.model and cfg_name in PROVIDERS:
|
||||
original_model = PROVIDERS[cfg_name].get("model")
|
||||
PROVIDERS[cfg_name]["model"] = req.model
|
||||
try:
|
||||
if cfg_name == "gemini":
|
||||
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096)
|
||||
else:
|
||||
response = await _call_deepseek_openrouter(
|
||||
user_prompt, system_prompt,
|
||||
provider=cfg_name,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
)
|
||||
finally:
|
||||
# Restore the original model so other calls aren't affected
|
||||
if original_model is not None and cfg_name in PROVIDERS:
|
||||
PROVIDERS[cfg_name]["model"] = original_model
|
||||
|
||||
# Send the full response as a single SSE event
|
||||
data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False)
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"BooksLM chat error: {e}")
|
||||
error_data = json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Export utilities for ObsiGate — standalone HTML, Markdown bundle (.zip) and ePub.
|
||||
|
||||
Each function converts vault content (markdown notes) into a self-contained
|
||||
downloadable artifact. Rendering reuses ``mistune`` (already a core dependency)
|
||||
so no extra markdown engine is required. The ePub generator builds a minimal
|
||||
but valid EPUB 3 container by hand (``zipfile`` only) so there is no hard
|
||||
dependency on ``ebooklib``.
|
||||
|
||||
All functions raise :class:`ExportError` on any failure (missing file, binary
|
||||
content, unreadable source, ...) so callers can translate to HTTP errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import html as html_mod
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
|
||||
logger = logging.getLogger("obsigate.export")
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
"""Raised when an export operation cannot be completed."""
|
||||
|
||||
|
||||
# Cached mistune renderer (singleton) — mirrors backend.main's configuration.
|
||||
_markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
# File extensions considered "safe" text files for markdown-like exports.
|
||||
_MARKDOWN_EXTS = {".md", ".markdown", ".mdown", ".mkd", ".txt"}
|
||||
|
||||
_HTML_CSS = """
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 760px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a2e;
|
||||
font-size: 16px;
|
||||
}
|
||||
h1 { font-size: 28px; border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }
|
||||
h2 { font-size: 22px; margin-top: 28px; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
|
||||
h3 { font-size: 18px; margin-top: 24px; }
|
||||
h4, h5, h6 { font-size: 16px; margin-top: 20px; }
|
||||
p { margin: 10px 0; }
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
code { font-size: 14px; background: #f5f5f5; padding: 2px 5px; border-radius: 3px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
a { color: #4f46e5; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img { max-width: 100%; border-radius: 4px; }
|
||||
blockquote { border-left: 3px solid #ccc; padding-left: 14px; color: #555; margin: 12px 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 14px; }
|
||||
th { background: #f0f0f0; font-weight: 600; }
|
||||
ul, ol { margin: 8px 0; padding-left: 24px; }
|
||||
li { margin: 2px 0; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 20px 0; }
|
||||
nav.export-nav {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin: 16px 0 28px;
|
||||
background: #fafafa;
|
||||
}
|
||||
nav.export-nav h2 { border: none; margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
nav.export-nav ul { margin: 0; padding-left: 20px; }
|
||||
.wikilink-missing { color: #c0392b; }
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""URL-safe slug from arbitrary text (Unicode-aware)."""
|
||||
text = html_mod.unescape(re.sub(r"<[^>]+>", "", text))
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
if unicodedata.category(ch).startswith(("L", "N")) or ch in (" ", "-"):
|
||||
cleaned.append(ch)
|
||||
text = re.sub(r"\s+", "-", "".join(cleaned))
|
||||
text = re.sub(r"-+", "-", text)
|
||||
return text.strip("-") or "document"
|
||||
|
||||
|
||||
def _resolve(vault_path: Path, file_path: Path | str) -> Path:
|
||||
"""Resolve *file_path* against *vault_path*, accepting absolute or relative.
|
||||
|
||||
Returns an absolute :class:`Path`. Does not require the file to exist.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_absolute():
|
||||
return file_path
|
||||
return (Path(vault_path) / file_path).resolve()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
"""Read a text file, raising :class:`ExportError` on missing/binary content."""
|
||||
if not path.exists() or not path.is_file():
|
||||
raise ExportError(f"File not found: {path}")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot read file {path}: {e}") from e
|
||||
if b"\x00" in raw[:4096]:
|
||||
raise ExportError(f"File appears to be binary: {path}")
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> tuple[str, dict]:
|
||||
"""Split frontmatter from a markdown document.
|
||||
|
||||
Returns ``(body, metadata)``. Falls back gracefully when no frontmatter.
|
||||
"""
|
||||
try:
|
||||
post = frontmatter.loads(text)
|
||||
return post.content, dict(post.metadata or {})
|
||||
except Exception:
|
||||
return text, {}
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""ASCII-safe, filename-safe download name."""
|
||||
cleaned = "".join(
|
||||
c for c in name if c.isascii() and (c.isalnum() or c in " _-.")
|
||||
).strip()
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
def _collect_markdown_files(vault_path: Path) -> list[Path]:
|
||||
"""List all markdown files in the vault, sorted by relative path."""
|
||||
vault_path = Path(vault_path)
|
||||
results = []
|
||||
if not vault_path.is_dir():
|
||||
return results
|
||||
for p in sorted(vault_path.rglob("*")):
|
||||
if p.is_file() and p.suffix.lower() in _MARKDOWN_EXTS:
|
||||
results.append(p)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown → HTML (shared by HTML and ePub exporters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _inline_images(md: str, file_dir: Path, vault_path: Path) -> str:
|
||||
"""Replace image references with base64 data URIs.
|
||||
|
||||
Supports standard markdown ```` and Obsidian embeds
|
||||
``![[image.png]]`` / ``![[image.png|300]]``. Sources are resolved relative
|
||||
to the note's directory, then the vault root.
|
||||
"""
|
||||
candidates = [file_dir, vault_path]
|
||||
|
||||
def _to_data_uri(src: str) -> str:
|
||||
src = src.strip().strip("<>")
|
||||
if src.startswith(("http://", "https://", "data:")):
|
||||
return src # leave remote/data URLs untouched
|
||||
src_path = None
|
||||
for base in candidates:
|
||||
candidate = (base / src).resolve()
|
||||
if candidate.is_file():
|
||||
src_path = candidate
|
||||
break
|
||||
if src_path is None:
|
||||
return src # unresolved — leave as-is
|
||||
try:
|
||||
data = src_path.read_bytes()
|
||||
mime = mimetypes.guess_type(str(src_path))[0] or "application/octet-stream"
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except OSError:
|
||||
return src
|
||||
|
||||
# Obsidian embeds: ![[path]] or ![[path|size]]
|
||||
def _embed(match):
|
||||
inner = match.group(1).strip()
|
||||
target = inner.split("|", 1)[0].strip()
|
||||
return f"})"
|
||||
|
||||
md = re.sub(r"!\[\[([^\]]+)\]\]", _embed, md)
|
||||
|
||||
# Standard markdown images
|
||||
def _img(match):
|
||||
alt = match.group(1)
|
||||
src = match.group(2)
|
||||
return f"})"
|
||||
|
||||
md = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", _img, md)
|
||||
return md
|
||||
|
||||
|
||||
def _convert_wikilinks(md: str, vault_path: Path, current: Path) -> str:
|
||||
"""Convert ``[[Note]]`` / ``[[Note|display]]`` to cross-file HTML links."""
|
||||
md_files = {p.stem.lower(): p for p in _collect_markdown_files(vault_path)}
|
||||
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = (match.group(2) or target).strip()
|
||||
|
||||
# Same-document anchor
|
||||
if target.startswith("#"):
|
||||
return f'<a href="#{_slugify(target[1:])}">{html_mod.escape(display)}</a>'
|
||||
|
||||
# Resolve to another file — link to its exported .html sibling
|
||||
key = Path(target).stem.lower()
|
||||
if key in md_files:
|
||||
href = f"{_slugify(md_files[key].stem)}.html"
|
||||
return f'<a href="{href}">{html_mod.escape(display)}</a>'
|
||||
|
||||
return f'<span class="wikilink-missing">{html_mod.escape(display)}</span>'
|
||||
|
||||
return re.sub(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]", _replace, md)
|
||||
|
||||
|
||||
def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> str:
|
||||
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||
md = _inline_images(md, file_dir, vault_path)
|
||||
md = _convert_wikilinks(md, vault_path, current)
|
||||
return _markdown(md)
|
||||
|
||||
|
||||
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||
"""Build a navigation block linking to every other markdown note in the vault."""
|
||||
items = []
|
||||
for p in _collect_markdown_files(vault_path):
|
||||
if p.resolve() == Path(current).resolve():
|
||||
continue
|
||||
stem = p.stem
|
||||
items.append(f'<li><a href="{_slugify(stem)}.html">{html_mod.escape(stem)}</a></li>')
|
||||
if not items:
|
||||
return ""
|
||||
return (
|
||||
'<nav class="export-nav"><h2>Notes</h2><ul>'
|
||||
+ "".join(items)
|
||||
+ "</ul></nav>"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public exporters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def export_html(vault_path: Path, file_path: Path) -> bytes:
|
||||
"""Convert a markdown note into a standalone HTML document.
|
||||
|
||||
Images are inlined as base64 data URIs, wikilinks become cross-file links,
|
||||
and a navigation block lists every other note in the vault.
|
||||
|
||||
Returns:
|
||||
UTF-8 encoded HTML as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, file_path)
|
||||
raw = _read_text(path)
|
||||
body, metadata = _strip_frontmatter(raw)
|
||||
title = str(metadata.get("title") or path.stem)
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
nav = _build_nav(vault_path, path)
|
||||
|
||||
html_doc = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="generator" content="ObsiGate">
|
||||
<title>{html_mod.escape(title)}</title>
|
||||
<style>{_HTML_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>{html_mod.escape(title)}</h1></header>
|
||||
{nav}
|
||||
{rendered}
|
||||
</body>
|
||||
</html>"""
|
||||
return html_doc.encode("utf-8")
|
||||
|
||||
|
||||
def export_md_bundle(vault_path: Path, directory_path: Path) -> bytes:
|
||||
"""Zip a directory (or single file) of markdown, preserving structure.
|
||||
|
||||
Returns:
|
||||
ZIP archive as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, directory_path)
|
||||
|
||||
if not path.exists():
|
||||
raise ExportError(f"Path not found: {path}")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if path.is_dir():
|
||||
base = path
|
||||
for p in sorted(path.rglob("*")):
|
||||
if p.is_file():
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname=arcname)
|
||||
# Avoid emitting an empty (invalid) zip for an empty directory
|
||||
if not any(p.is_file() for p in path.rglob("*")):
|
||||
zf.writestr(".empty", "")
|
||||
else:
|
||||
zf.write(path, arcname=path.name)
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot create bundle: {e}") from e
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
"""Convert a markdown note into a minimal, valid EPUB 3 container.
|
||||
|
||||
Uses only ``zipfile`` + ``mistune`` (no ``ebooklib`` dependency). Images
|
||||
are inlined as base64 so the ePub is fully self-contained.
|
||||
|
||||
Returns:
|
||||
EPUB archive as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, file_path)
|
||||
raw = _read_text(path)
|
||||
body, metadata = _strip_frontmatter(raw)
|
||||
title = str(metadata.get("title") or path.stem)
|
||||
author = str(metadata.get("author") or "ObsiGate")
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
|
||||
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
||||
<head>
|
||||
<title>{html_mod.escape(title)}</title>
|
||||
<style>{_HTML_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{html_mod.escape(title)}</h1>
|
||||
{rendered}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# EPUB requires a valid XHTML-ish body; strip our HTML5-only doctype is not
|
||||
# needed since we emit XML directly above. Escape any bare ampersands in
|
||||
# text nodes outside tags is delegated to mistune's own escaping.
|
||||
content_opf = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="bookid">{html_mod.escape(uid)}</dc:identifier>
|
||||
<dc:title>{html_mod.escape(title)}</dc:title>
|
||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||
<dc:language>fr</dc:language>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="chapter1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="chapter1"/>
|
||||
</spine>
|
||||
</package>"""
|
||||
|
||||
toc_ncx = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="{html_mod.escape(uid)}"/>
|
||||
</head>
|
||||
<docTitle><text>{html_mod.escape(title)}</text></docTitle>
|
||||
<navMap>
|
||||
<navPoint id="navpoint-1" playOrder="1">
|
||||
<navLabel><text>{html_mod.escape(title)}</text></navLabel>
|
||||
<content src="chapter1.xhtml"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>"""
|
||||
|
||||
container_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>"""
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# `mimetype` must be the first entry, stored (uncompressed).
|
||||
zf.writestr(
|
||||
zipfile.ZipInfo("mimetype"),
|
||||
"application/epub+zip",
|
||||
compress_type=zipfile.ZIP_STORED,
|
||||
)
|
||||
zf.writestr("META-INF/container.xml", container_xml)
|
||||
zf.writestr("OEBPS/content.opf", content_opf)
|
||||
zf.writestr("OEBPS/toc.ncx", toc_ncx)
|
||||
zf.writestr("OEBPS/chapter1.xhtml", xhtml)
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot create ePub: {e}") from e
|
||||
|
||||
return buffer.getvalue()
|
||||
+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.
|
||||
|
||||
|
||||
+82
-65
@@ -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 '.').
|
||||
@@ -537,19 +546,27 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
tags: List[str] = []
|
||||
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
||||
tags: list[str] = []
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
if ext == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
|
||||
raw = extract_pdf_text(fpath, max_chars=SEARCH_CONTENT_LIMIT)
|
||||
pdf_meta = extract_pdf_metadata(fpath)
|
||||
title = pdf_meta.get("title") or title
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="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)
|
||||
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()
|
||||
|
||||
return {
|
||||
"path": str(relative).replace("\\", "/"),
|
||||
@@ -569,7 +586,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 +661,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 +706,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 +753,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 +785,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 +834,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 +857,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 +882,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 +954,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 +972,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
|
||||
|
||||
+1175
-142
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,175 @@
|
||||
"""PDF text extraction for ObsiGate — pypdf backend, pymupdf optional fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configurable limits (see ROADMAP #74 G3):
|
||||
# OBSIGATE_PDF_MAX_SIZE_MB — PDFs larger than this are not text-extracted (default 50)
|
||||
# OBSIGATE_PDF_EXTRACT_TIMEOUT — seconds before extraction is abandoned (default 30)
|
||||
PDF_MAX_SIZE_MB: int = int(os.environ.get("OBSIGATE_PDF_MAX_SIZE_MB", "50"))
|
||||
PDF_EXTRACT_TIMEOUT: float = float(os.environ.get("OBSIGATE_PDF_EXTRACT_TIMEOUT", "30"))
|
||||
|
||||
PDF_READER: str = "pypdf"
|
||||
PdfReader = None # type: ignore
|
||||
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:
|
||||
logger.warning("No PDF reader available — install pypdf or pymupdf")
|
||||
|
||||
|
||||
def pdf_exceeds_size_limit(file_path: Path) -> bool:
|
||||
"""True if the PDF is larger than OBSIGATE_PDF_MAX_SIZE_MB (skip text extraction)."""
|
||||
try:
|
||||
return file_path.stat().st_size > PDF_MAX_SIZE_MB * 1024 * 1024
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _run_with_timeout(fn, *args, timeout: float):
|
||||
"""Run a sync function in a worker thread with a hard timeout.
|
||||
|
||||
A timed-out extraction leaves its worker thread running (daemon-style pool
|
||||
is abandoned), but the request itself is freed — acceptable trade-off for
|
||||
pathological PDFs on a self-hosted single-user server.
|
||||
"""
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pdf-extract")
|
||||
try:
|
||||
future = executor.submit(fn, *args)
|
||||
return future.result(timeout=timeout)
|
||||
finally:
|
||||
executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def extract_pdf_text(file_path: Path, max_chars: int = 100000) -> str:
|
||||
"""Extract text from a PDF file. Returns empty string on failure.
|
||||
|
||||
Oversized PDFs (> OBSIGATE_PDF_MAX_SIZE_MB) and extractions exceeding
|
||||
OBSIGATE_PDF_EXTRACT_TIMEOUT seconds return "" instead of blocking.
|
||||
"""
|
||||
if PdfReader is None and PDF_READER == "pypdf":
|
||||
return ""
|
||||
if pdf_exceeds_size_limit(file_path):
|
||||
logger.info("PDF too large to index (> %d MB), skipping text extraction: %s",
|
||||
PDF_MAX_SIZE_MB, file_path)
|
||||
return ""
|
||||
try:
|
||||
if PDF_READER == "pymupdf":
|
||||
return _run_with_timeout(_extract_pymupdf, file_path, max_chars,
|
||||
timeout=PDF_EXTRACT_TIMEOUT)
|
||||
else:
|
||||
return _run_with_timeout(_extract_pypdf, file_path, max_chars,
|
||||
timeout=PDF_EXTRACT_TIMEOUT)
|
||||
except FuturesTimeout:
|
||||
logger.warning("PDF text extraction timed out (%ss): %s", PDF_EXTRACT_TIMEOUT, file_path)
|
||||
return ""
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Additional dev/test-only dependencies for the test suite.
|
||||
# These are NOT required for production runtime.
|
||||
# Install with: pip install -r requirements-test.txt
|
||||
|
||||
reportlab>=4.0 # PDF fixture generation for test_pdf.py
|
||||
@@ -12,3 +12,7 @@ sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=60.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
pyotp>=2.10.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
|
||||
@@ -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,100 @@
|
||||
"""
|
||||
Version management for ObsiGate.
|
||||
|
||||
The canonical version is the numeric SemVer of the LATEST release tag
|
||||
(MAJOR.MINOR.PATCH). get_version() always returns a clean "x.y.z" string so
|
||||
the UI never shows "-dev", "0.0.0-dev" or a "-N-gHASH" suffix — the same
|
||||
number appears in the header badge, the About modal and the API health.
|
||||
|
||||
Examples:
|
||||
tag v2.0.0 -> "2.0.0"
|
||||
HEAD 31 commits after -> "2.0.0" (release version, no dev clutter)
|
||||
no git / no VERSION -> "0.0.0"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root
|
||||
_VERSION_FILE = Path(__file__).resolve().parent / "VERSION"
|
||||
|
||||
|
||||
def _run_git(args: list[str]) -> str:
|
||||
"""Run a git command in the repo root; return stdout (stripped) or ''."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _clean_base(raw: str) -> str:
|
||||
"""Reduce a version string to its numeric MAJOR.MINOR.PATCH base.
|
||||
|
||||
Handles "v2.0.0", "2.0.0-31-gabc1234", "2.0.0-dev", "0.0.0-dev".
|
||||
Returns "" if no numeric triplet can be parsed.
|
||||
"""
|
||||
s = (raw or "").strip().lstrip("vV")
|
||||
base = s.split("-")[0] # strip -N-gHASH / -dev / -dirty suffixes
|
||||
parts = base.split(".")
|
||||
nums = []
|
||||
for p in parts[:3]:
|
||||
if not p.isdigit():
|
||||
break
|
||||
nums.append(str(int(p)))
|
||||
if len(nums) != 3:
|
||||
return ""
|
||||
return ".".join(nums)
|
||||
|
||||
|
||||
def get_git_describe() -> str:
|
||||
"""Full `git describe` string (e.g. "2.0.0-31-gabc1234") or '' if no git."""
|
||||
return _run_git(["describe", "--tags", "--dirty=-dirty"]).lstrip("v")
|
||||
|
||||
|
||||
def get_git_commit() -> str:
|
||||
"""Short HEAD commit hash (e.g. "abc1234") or '' if unavailable."""
|
||||
return _run_git(["rev-parse", "--short", "HEAD"])
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Return the clean release version x.y.z (latest tag) — never a -suffix.
|
||||
|
||||
Priority: latest git tag -> backend/VERSION file -> "0.0.0".
|
||||
"""
|
||||
# 1) Latest tag from git (works even with commits beyond the tag)
|
||||
tag = _run_git(["describe", "--tags", "--abbrev=0"])
|
||||
base = _clean_base(tag)
|
||||
if base:
|
||||
return base
|
||||
|
||||
# 2) backend/VERSION file (baked at build time by build.sh / CI / Docker)
|
||||
if _VERSION_FILE.exists():
|
||||
base = _clean_base(_VERSION_FILE.read_text(encoding="utf-8"))
|
||||
if base:
|
||||
return base
|
||||
|
||||
# 3) Nothing available
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def get_version_tuple() -> tuple[int, int, int]:
|
||||
"""Return (major, minor, patch) tuple for programmatic use."""
|
||||
raw = get_version()
|
||||
base = _clean_base(raw) or "0.0.0"
|
||||
major, minor, patch = (int(p) for p in base.split("."))
|
||||
return (major, minor, patch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(get_version())
|
||||
+69
-23
@@ -2,30 +2,64 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
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'}
|
||||
# Filesystem types where inotify does NOT fire for changes made by other
|
||||
# clients (the edit happens on another machine — e.g. Obsidian on Windows
|
||||
# writing to an NFS/SMB export). For those mounts watchdog's native
|
||||
# Observer silently misses everything and polling is the only option.
|
||||
NETWORK_FSTYPES = {
|
||||
"nfs", "nfs4", "cifs", "smbfs", "smb2", "smb3",
|
||||
"fuse", "fuse.sshfs", "glusterfs", "9p", "virtiofs", "lustre",
|
||||
}
|
||||
|
||||
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()
|
||||
def find_mount_fstype(resolved_path: str, mount_lines: Iterable[str]) -> str:
|
||||
"""Return the fstype of the longest /proc/mounts entry covering the path.
|
||||
|
||||
Pure function (mount_lines are text lines) so it can be unit-tested
|
||||
without /proc. Returns "" when no mount matches.
|
||||
"""
|
||||
best_point = ""
|
||||
best_fstype = ""
|
||||
for line in mount_lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
mount_point, fstype = parts[1], parts[2]
|
||||
# /proc/mounts escapes special chars in octal
|
||||
mount_point = mount_point.replace("\\040", " ").replace("\\011", "\t")
|
||||
root = mount_point.rstrip("/") or "/"
|
||||
if (resolved_path == root or resolved_path.startswith(root + "/")) and len(mount_point) > len(best_point):
|
||||
best_point = mount_point
|
||||
best_fstype = fstype
|
||||
return best_fstype
|
||||
|
||||
|
||||
def is_network_mount(path: str) -> bool:
|
||||
"""True if *path* sits on a network/fuse filesystem (Linux only).
|
||||
|
||||
inotify events are local to the client that made the write, so vaults
|
||||
shared over NFS/SMB never see Obsidian's edits. On those mounts we
|
||||
must fall back to PollingObserver, which detects changes by stat().
|
||||
"""
|
||||
if os.name != "posix":
|
||||
return False
|
||||
try:
|
||||
with open("/proc/mounts", "r", encoding="utf-8") as fh:
|
||||
fstype = find_mount_fstype(os.path.realpath(path), fh)
|
||||
except OSError:
|
||||
return False
|
||||
return fstype in NETWORK_FSTYPES
|
||||
|
||||
|
||||
class VaultEventHandler(FileSystemEventHandler):
|
||||
@@ -105,10 +139,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:
|
||||
@@ -129,7 +163,12 @@ class VaultWatcher:
|
||||
vault_path: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
):
|
||||
"""Créer et démarrer un observer pour une vault."""
|
||||
"""Créer et démarrer un observer pour une vault.
|
||||
|
||||
Le mode (natif inotify vs polling) est choisi par vault :
|
||||
les mounts réseau (NFS/SMB/fuse) sont surveillés en polling car
|
||||
inotify ne voit pas les écritures faites depuis d'autres clients.
|
||||
"""
|
||||
path = Path(vault_path)
|
||||
if not path.exists():
|
||||
logger.warning(f"Vault '{vault_name}' path not found: {vault_path}")
|
||||
@@ -137,16 +176,23 @@ class VaultWatcher:
|
||||
|
||||
handler = VaultEventHandler(vault_name, self.event_queue, loop)
|
||||
|
||||
ObserverClass = PollingObserver if self.use_polling else Observer
|
||||
polling = self.use_polling or is_network_mount(vault_path)
|
||||
if polling and not self.use_polling:
|
||||
logger.info(
|
||||
f"Vault '{vault_name}' is on a network mount — "
|
||||
"using polling watcher (inotify cannot see remote edits)"
|
||||
)
|
||||
|
||||
ObserverClass = PollingObserver if polling else Observer
|
||||
try:
|
||||
observer = ObserverClass(
|
||||
timeout=self.polling_interval if self.use_polling else 1
|
||||
timeout=self.polling_interval if polling else 1
|
||||
)
|
||||
observer.schedule(handler, str(path), recursive=True)
|
||||
observer.daemon = True
|
||||
observer.start()
|
||||
self.observers[vault_name] = observer
|
||||
mode = "polling" if self.use_polling else "native"
|
||||
mode = "polling" if polling else "native"
|
||||
logger.info(f"Watching ({mode}): {vault_name} -> {vault_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start watcher for '{vault_name}': {e}")
|
||||
@@ -186,7 +232,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 +256,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
+6046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
[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"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[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 |
+1058
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+36
-3
@@ -1,7 +1,20 @@
|
||||
# =============================================================================
|
||||
# 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:
|
||||
context: .
|
||||
args:
|
||||
# VERSION est injecte par build.sh/CI via `git describe` ;
|
||||
# sans variable d'env, l'image tombe sur 0.0.0-dev (fallback Dockerfile).
|
||||
VERSION: ${VERSION:-0.0.0-dev}
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
user: "1000:1000"
|
||||
@@ -41,10 +54,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)
|
||||
+865
-109
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -0,0 +1,472 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="admin.page_title">ObsiGate — Administration</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||
|
||||
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
try {
|
||||
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
||||
root.setAttribute("data-theme", mode);
|
||||
var light = mode === "light" || mode === "sepia";
|
||||
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
|
||||
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
|
||||
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
|
||||
<style>
|
||||
/* ── Admin page — local styles (kept inline to limit impact on /static/style.css) ── */
|
||||
:root {
|
||||
--admin-card-bg: var(--bg-secondary, #161b22);
|
||||
--admin-card-border: var(--border, #21262d);
|
||||
--admin-success: var(--success, #3fb950);
|
||||
--admin-warning: var(--warning, #f59e0b);
|
||||
--admin-danger: var(--danger, #ff7b72);
|
||||
}
|
||||
body.admin-page {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
||||
height: auto; /* override SPA style.css: body{height:100vh} */
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Sticky section nav ── */
|
||||
.admin-subnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 8px 24px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--border, #21262d);
|
||||
backdrop-filter: blur(4px);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.admin-subnav::-webkit-scrollbar { height: 6px; }
|
||||
.admin-subnav a {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.admin-subnav a:hover {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-subnav a.active {
|
||||
background: var(--accent-bg, #1f2a3a);
|
||||
color: var(--accent-text, #79c0ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.admin-section { scroll-margin-top: 64px; }
|
||||
@media (max-width: 600px) {
|
||||
.admin-subnav { padding: 8px 12px; }
|
||||
.admin-subnav a { font-size: 0.8rem; padding: 6px 10px; }
|
||||
}
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.admin-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-header .admin-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.admin-header-right { display: flex; align-items: center; gap: 12px; }
|
||||
#admin-role-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-bg, #1f2a3a);
|
||||
color: var(--accent-text, #79c0ff);
|
||||
border: 1px solid var(--accent, #58a6ff);
|
||||
}
|
||||
#admin-role-badge[data-role="admin"] {
|
||||
background: var(--success-bg, #1a3d1f);
|
||||
color: var(--success, #3fb950);
|
||||
border-color: var(--success, #3fb950);
|
||||
}
|
||||
.admin-back-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.admin-back-btn:hover { background: var(--bg-sidebar, #13161d); }
|
||||
|
||||
.admin-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.admin-section {
|
||||
background: var(--admin-card-bg);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-section h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-section h2::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: var(--accent, #58a6ff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Stats grid ── */
|
||||
#admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-stat-card {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-stat-unit {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.admin-stat-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease, background 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-table th, .admin-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-table th {
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
}
|
||||
.admin-table code {
|
||||
background: var(--bg-primary, #0f1117);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-text, #79c0ff);
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
.admin-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-filters input, .admin-filters select {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-filters input:focus, .admin-filters select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.admin-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.admin-btn:hover { opacity: 0.85; }
|
||||
.admin-btn-secondary {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
|
||||
/* ── Backups ── */
|
||||
#admin-backups-summary {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
#admin-backups-summary > div {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-backup-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
}
|
||||
.admin-backup-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent, #58a6ff), var(--accent-text, #79c0ff));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Users management ── */
|
||||
#section-users .admin-users-hint {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.admin-footer {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #484f58);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--admin-card-border);
|
||||
}
|
||||
#admin-stats-timestamp { color: var(--text-secondary, #8b949e); }
|
||||
|
||||
/* ── Forbidden screen ── */
|
||||
#admin-forbidden {
|
||||
display: none;
|
||||
max-width: 500px;
|
||||
margin: 100px auto;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
background: var(--admin-card-bg);
|
||||
border: 1px solid var(--admin-danger);
|
||||
border-radius: 10px;
|
||||
}
|
||||
#admin-forbidden h2 { color: var(--admin-danger); margin-top: 0; }
|
||||
|
||||
/* ── Loading state ── */
|
||||
.admin-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<header class="admin-header">
|
||||
<div class="admin-header-left">
|
||||
<h1>ObsiGate — Admin</h1>
|
||||
<span class="admin-subtitle" data-i18n="admin.header_subtitle">Tableau de bord administrateur</span>
|
||||
</div>
|
||||
<div class="admin-header-right">
|
||||
<span id="admin-role-badge">…</span>
|
||||
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="admin-subnav" id="admin-subnav" aria-label="Sections admin">
|
||||
<a href="#section-stats" class="active" data-section="stats">📊 <span data-i18n="admin.stats_title">Statistiques</span></a>
|
||||
<a href="#section-audit" data-section="audit">🕒 <span data-i18n="admin.audit_title">Logs d'audit</span></a>
|
||||
<a href="#section-backups" data-section="backups">💾 <span data-i18n="admin.backups_title">Backups</span></a>
|
||||
<a href="#section-users" data-section="users">👥 <span data-i18n="admin.users_title">Utilisateurs</span></a>
|
||||
</nav>
|
||||
|
||||
<main id="admin-main" class="admin-main">
|
||||
<!-- Loading state shown until init() resolves -->
|
||||
<div id="admin-loading" class="admin-loading" data-i18n="common.loading">Chargement…</div>
|
||||
|
||||
<!-- Section 1 — Stats temps réel -->
|
||||
<section class="admin-section" id="section-stats">
|
||||
<h2 data-i18n="admin.stats_title">Statistiques temps réel</h2>
|
||||
<div id="admin-stats-grid" class="admin-loading">…</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2 — Logs d'audit -->
|
||||
<section class="admin-section" id="section-audit">
|
||||
<h2 data-i18n="admin.audit_title">Logs d'audit</h2>
|
||||
<div class="admin-filters">
|
||||
<input type="text" id="admin-audit-user" data-i18n-placeholder="admin.audit_filter_user" placeholder="Filtrer par utilisateur" />
|
||||
<select id="admin-audit-action">
|
||||
<option value="" data-i18n="admin.audit_all_actions">Toutes les actions</option>
|
||||
</select>
|
||||
<button id="admin-audit-refresh" class="admin-btn admin-btn-secondary" data-i18n="admin.audit_refresh">Rafraîchir</button>
|
||||
</div>
|
||||
<div id="admin-audit-empty" class="admin-loading" style="display:none;" data-i18n="admin.audit_empty">Aucun log disponible</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Vault</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-audit-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Section 3 — Statistiques backups -->
|
||||
<section class="admin-section" id="section-backups">
|
||||
<h2 data-i18n="admin.backups_title">Statistiques backups</h2>
|
||||
<div id="admin-backups-summary"></div>
|
||||
<div id="admin-backups-empty" class="admin-loading" style="display:none;" data-i18n="admin.backups_vault_empty">Aucun backup</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.backups_per_vault">Par vault</th>
|
||||
<th>Count</th>
|
||||
<th>Size</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Section 4 — Gestion utilisateurs -->
|
||||
<section class="admin-section" id="section-users">
|
||||
<h2 data-i18n="admin.users_title">Gestion utilisateurs</h2>
|
||||
<p class="admin-users-hint" data-i18n="admin.users_manage_hint">Ouvrir la modale de gestion des utilisateurs (CRUD complet)</p>
|
||||
<button id="admin-users-manage-btn" class="admin-btn" data-i18n="admin.users_manage_btn">Gérer les utilisateurs</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="admin-forbidden">
|
||||
<h2 data-i18n="admin.forbidden_title">Accès refusé</h2>
|
||||
<p data-i18n="admin.forbidden_msg">Vous devez être administrateur pour accéder à cette page.</p>
|
||||
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
|
||||
</div>
|
||||
|
||||
<footer class="admin-footer">
|
||||
<span>ObsiGate Admin — ROADMAP #71</span>
|
||||
<span id="admin-stats-timestamp">—</span>
|
||||
</footer>
|
||||
|
||||
<script type="module">
|
||||
// Theme: applique le thème/mode sauvegardé dans l'app principale
|
||||
// (même origine => localStorage partagé). Fait AVANT le rendu pour
|
||||
// éviter que la page admin n'utilise les vars CSS par défaut.
|
||||
import { initThemes } from "/static/js/themes.js";
|
||||
// We import admin.js which provides init() — but the i18n module
|
||||
// is global (window.t), so we can use t() right away to translate
|
||||
// static DOM. admin.js will then call init() after DOMContentLoaded.
|
||||
import { initI18n } from "/static/js/i18n.js";
|
||||
import * as Admin from "/static/js/admin.js";
|
||||
|
||||
initThemes();
|
||||
await initI18n();
|
||||
// Re-apply DOM translations now that locale strings are loaded
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
el.textContent = window.t(el.getAttribute("data-i18n"));
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
el.setAttribute("placeholder", window.t(el.getAttribute("data-i18n-placeholder")));
|
||||
});
|
||||
// Remove loading hint now that i18n is ready
|
||||
const loading = document.getElementById("admin-loading");
|
||||
if (loading) loading.style.display = "none";
|
||||
|
||||
if (typeof Admin.init === "function") {
|
||||
Admin.init();
|
||||
} else {
|
||||
console.error("admin.js did not export init()");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+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>
|
||||
+1808
-1198
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
||||
// frontend/js/admin.js — Admin Dashboard frontend module (ROADMAP #71)
|
||||
//
|
||||
// Standalone ES module loaded by frontend/admin.html.
|
||||
// Public API:
|
||||
// init() — DOMContentLoaded entry point. Verifies auth, fires initial loads.
|
||||
// connectSSE() — Opens EventSource on /api/admin/stream; updates widgets on every "stats" event.
|
||||
// loadStatsOnce() — Initial GET /api/admin/stats (snapshot before SSE delivers first frame).
|
||||
// loadAuditLogs(filters) — GET /api/admin/audit with optional {user, action}.
|
||||
// loadBackupStats() — GET /api/admin/backup-stats.
|
||||
// renderStatsWidget(stats) — Paint CPU/RAM/Disk/Uptime/Sessions cards.
|
||||
// renderAuditTable(entries) — Paint audit log table.
|
||||
// renderBackups(byVault) — Paint per-vault backup bars.
|
||||
// getAuthHeaders() — Read Bearer token from sessionStorage (same source as auth.js).
|
||||
// formatBytes(mb) — Human-readable size formatter.
|
||||
// formatUptime(seconds) — "Xd Yh Zm" formatter.
|
||||
// severityColor(pct) — "success" | "warning" | "danger" based on thresholds.
|
||||
|
||||
import { api, AdminPanel } from "./auth.js";
|
||||
import { state } from "./state.js";
|
||||
import { escapeHtml, safeCreateIcons } from "./utils.js";
|
||||
import { t, getLocale } from "./i18n.js";
|
||||
|
||||
// ── Module state ─────────────────────────────────────────────────────────
|
||||
let _eventSource = null;
|
||||
let _pollTimer = null;
|
||||
let _auditFilters = { user: "", action: "" };
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current Bearer token from sessionStorage (same key auth.js uses).
|
||||
* Returns an `{Authorization: "Bearer ..."}` object or null.
|
||||
*/
|
||||
export function getAuthHeaders() {
|
||||
try {
|
||||
const token = sessionStorage.getItem("obsigate_access_token");
|
||||
if (!token) return null;
|
||||
return { Authorization: "Bearer " + token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a size in MB as a human-readable string. */
|
||||
export function formatBytes(mb) {
|
||||
if (mb == null || isNaN(mb)) return "—";
|
||||
if (mb < 1) return `${Math.round(mb * 1024)} KB`;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Format an uptime in seconds as "Xd Yh Zm" (or "Xh Ym" / "Xm"). */
|
||||
export function formatUptime(seconds) {
|
||||
if (seconds == null || isNaN(seconds) || seconds < 0) return "—";
|
||||
const s = Math.floor(seconds);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a percentage to a CSS severity token.
|
||||
* <70 → success (green)
|
||||
* <90 → warning (orange)
|
||||
* ≥90 → danger (red)
|
||||
*/
|
||||
export function severityColor(pct) {
|
||||
if (pct == null || isNaN(pct)) return "success";
|
||||
if (pct >= 90) return "danger";
|
||||
if (pct >= 70) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp with the user's locale. */
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(
|
||||
getLocale() === "fr" ? "fr-FR" : "en-US"
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Discreet error helper — never crashes the page. */
|
||||
function _showInlineError(containerId, msg) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="admin-inline-error" style="color:var(--danger);padding:8px 12px;font-size:0.85rem;">${escapeHtml(msg)}</div>`;
|
||||
}
|
||||
|
||||
// ── Stats widget ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paint the 5 stats cards.
|
||||
* @param {object} stats {cpu_pct, mem_used_mb, mem_total_mb, disk_used_gb, disk_total_gb, uptime_seconds, active_sessions}
|
||||
*/
|
||||
export function renderStatsWidget(stats) {
|
||||
if (!stats) return;
|
||||
const grid = document.getElementById("admin-stats-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const cpuPct = stats.cpu_pct ?? 0;
|
||||
const memPct = stats.mem_total_mb > 0 ? (stats.mem_used_mb / stats.mem_total_mb) * 100 : 0;
|
||||
const diskPct = stats.disk_total_gb > 0 ? (stats.disk_used_gb / stats.disk_total_gb) * 100 : 0;
|
||||
|
||||
const cpuSev = severityColor(cpuPct);
|
||||
const memSev = severityColor(memPct);
|
||||
const diskSev = severityColor(diskPct);
|
||||
|
||||
const memTotal = stats.mem_total_mb ?? 0;
|
||||
const diskTotal = stats.disk_total_gb ?? 0;
|
||||
|
||||
grid.innerHTML = `
|
||||
<div class="admin-stat-card" data-sev="${cpuSev}">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_cpu"))}</div>
|
||||
<div class="admin-stat-value">${cpuPct.toFixed(1)}<span class="admin-stat-unit">%</span></div>
|
||||
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(cpuPct, 100).toFixed(1)}%;background:var(--${cpuSev});"></div></div>
|
||||
</div>
|
||||
<div class="admin-stat-card" data-sev="${memSev}">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_memory"))}</div>
|
||||
<div class="admin-stat-value">${stats.mem_used_mb?.toFixed(0) ?? "—"}<span class="admin-stat-unit"> / ${memTotal.toFixed(0)} MB</span></div>
|
||||
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(memPct, 100).toFixed(1)}%;background:var(--${memSev});"></div></div>
|
||||
</div>
|
||||
<div class="admin-stat-card" data-sev="${diskSev}">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_disk"))}</div>
|
||||
<div class="admin-stat-value">${stats.disk_used_gb?.toFixed(1) ?? "—"}<span class="admin-stat-unit"> / ${diskTotal.toFixed(1)} GB</span></div>
|
||||
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(diskPct, 100).toFixed(1)}%;background:var(--${diskSev});"></div></div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_uptime"))}</div>
|
||||
<div class="admin-stat-value" style="font-size:1.4rem;">${escapeHtml(formatUptime(stats.uptime_seconds))}</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_sessions"))}</div>
|
||||
<div class="admin-stat-value">${stats.active_sessions ?? 0}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update footer timestamp
|
||||
const ts = stats.timestamp ? new Date(stats.timestamp) : new Date();
|
||||
const stamp = document.getElementById("admin-stats-timestamp");
|
||||
if (stamp) {
|
||||
stamp.textContent = t("admin.stats_refresh", { time: ts.toLocaleTimeString(getLocale() === "fr" ? "fr-FR" : "en-US") });
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch /api/admin/stats and paint. */
|
||||
export async function loadStatsOnce() {
|
||||
try {
|
||||
const data = await api("/api/admin/stats");
|
||||
renderStatsWidget(data);
|
||||
} catch (err) {
|
||||
_showInlineError("admin-stats-grid", err.message || "stats load failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Server-Sent Events connection on /api/admin/stream.
|
||||
* The endpoint emits `event: stats\ndata: {...}` every 5 seconds.
|
||||
*
|
||||
* The browser's EventSource doesn't support custom headers, so we rely on
|
||||
* the httpOnly cookie (set by /api/auth/login with samesite=lax) to authenticate.
|
||||
* `withCredentials: true` ensures the cookie is sent.
|
||||
*
|
||||
* If the connection fails to open (4xx/5xx or network), we fall back to
|
||||
* periodic polling of /api/admin/stats every 5s so the UI keeps updating.
|
||||
*/
|
||||
export function connectSSE() {
|
||||
if (_eventSource) {
|
||||
try { _eventSource.close(); } catch { /* */ }
|
||||
_eventSource = null;
|
||||
}
|
||||
if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
let sseReady = false;
|
||||
try {
|
||||
_eventSource = new EventSource("/api/admin/stream", { withCredentials: true });
|
||||
_eventSource.addEventListener("stats", (ev) => {
|
||||
sseReady = true;
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
renderStatsWidget(data);
|
||||
} catch (err) {
|
||||
console.warn("admin SSE parse error", err);
|
||||
}
|
||||
});
|
||||
_eventSource.onopen = () => {
|
||||
sseReady = true;
|
||||
// Stop the polling fallback once SSE works.
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
};
|
||||
_eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects. Only start polling fallback if we
|
||||
// never received the first event yet (e.g. 401/403/network blocked).
|
||||
if (!sseReady) {
|
||||
_startPollingFallback();
|
||||
}
|
||||
console.warn("admin SSE error (will retry)");
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("admin SSE init failed", err);
|
||||
_startPollingFallback();
|
||||
}
|
||||
// Kick off a single initial fetch right away so the UI doesn't show
|
||||
// zeros for 5 seconds waiting for the first SSE event.
|
||||
loadStatsOnce();
|
||||
}
|
||||
|
||||
function _startPollingFallback() {
|
||||
if (_pollTimer) return;
|
||||
console.warn("admin: falling back to polling /api/admin/stats every 5s");
|
||||
_pollTimer = setInterval(() => {
|
||||
loadStatsOnce().catch(() => { /* ignore — error already shown */ });
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** Close the SSE connection if any (useful before navigation). */
|
||||
export function disconnectSSE() {
|
||||
if (_eventSource) {
|
||||
try { _eventSource.close(); } catch { /* */ }
|
||||
_eventSource = null;
|
||||
}
|
||||
if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the audit log table.
|
||||
* @param {Array<object>} entries Array of audit entries (timestamp, action, username, vault, ip, ...).
|
||||
*/
|
||||
export function renderAuditTable(entries) {
|
||||
const tbody = document.getElementById("admin-audit-tbody");
|
||||
const empty = document.getElementById("admin-audit-empty");
|
||||
if (!tbody) return;
|
||||
|
||||
if (!entries || !entries.length) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
tbody.innerHTML = entries.map((e) => {
|
||||
const ts = formatDate(e.timestamp);
|
||||
const action = escapeHtml(e.action || "—");
|
||||
const user = escapeHtml(e.username || e.user || "—");
|
||||
const vault = escapeHtml(e.vault || "—");
|
||||
const ip = escapeHtml(e.ip || "—");
|
||||
return `<tr>
|
||||
<td>${escapeHtml(ts)}</td>
|
||||
<td><code>${action}</code></td>
|
||||
<td>${user}</td>
|
||||
<td>${vault}</td>
|
||||
<td>${ip}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch /api/admin/audit with optional filters.
|
||||
* @param {{user?: string, action?: string}} filters
|
||||
*/
|
||||
export async function loadAuditLogs(filters = {}) {
|
||||
if (filters.user !== undefined) _auditFilters.user = filters.user;
|
||||
if (filters.action !== undefined) _auditFilters.action = filters.action;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (_auditFilters.user) params.set("user", _auditFilters.user);
|
||||
if (_auditFilters.action) params.set("action", _auditFilters.action);
|
||||
params.set("limit", "200");
|
||||
|
||||
const qs = params.toString();
|
||||
try {
|
||||
const data = await api(`/api/admin/audit?${qs}`);
|
||||
renderAuditTable(data.entries || []);
|
||||
} catch (err) {
|
||||
_showInlineError("admin-audit-tbody", err.message || "audit load failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Populate the action <select> with the values we know about.
|
||||
* The endpoint doesn't list all possible actions, so we hardcode the
|
||||
* common ones and let the user pick "All". */
|
||||
function _populateAuditActions() {
|
||||
const select = document.getElementById("admin-audit-action");
|
||||
if (!select) return;
|
||||
const ACTIONS = [
|
||||
"file_save", "file_delete", "file_view", "file_rename",
|
||||
"share_create", "share_revoke", "auth_login", "auth_logout",
|
||||
"auth_failed", "user_create", "user_update", "user_delete",
|
||||
"backup_create", "config_update",
|
||||
];
|
||||
select.innerHTML =
|
||||
`<option value="">${escapeHtml(t("admin.audit_all_actions"))}</option>` +
|
||||
ACTIONS.map((a) => `<option value="${a}">${escapeHtml(a)}</option>`).join("");
|
||||
}
|
||||
|
||||
// ── Backups ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render per-vault backup bars.
|
||||
* @param {object} byVault { vault: {count, size_mb} }
|
||||
*/
|
||||
export function renderBackups(byVault) {
|
||||
const tbody = document.getElementById("admin-backups-tbody");
|
||||
const empty = document.getElementById("admin-backups-empty");
|
||||
if (!tbody) return;
|
||||
|
||||
const entries = byVault ? Object.entries(byVault) : [];
|
||||
if (!entries.length) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
// Largest vault for proportional bars
|
||||
const maxSize = Math.max(...entries.map(([, v]) => v.size_mb || 0), 1);
|
||||
|
||||
tbody.innerHTML = entries
|
||||
.sort((a, b) => (b[1].size_mb || 0) - (a[1].size_mb || 0))
|
||||
.map(([vault, data]) => {
|
||||
const count = data.count || 0;
|
||||
const size = data.size_mb || 0;
|
||||
const widthPct = Math.min((size / maxSize) * 100, 100).toFixed(1);
|
||||
return `<tr>
|
||||
<td>${escapeHtml(vault)}</td>
|
||||
<td>${count}</td>
|
||||
<td>${escapeHtml(formatBytes(size))}</td>
|
||||
<td>
|
||||
<div class="admin-backup-bar"><div class="admin-backup-bar-fill" style="width:${widthPct}%;"></div></div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Fetch /api/admin/backup-stats and paint. */
|
||||
export async function loadBackupStats() {
|
||||
try {
|
||||
const data = await api("/api/admin/backup-stats");
|
||||
const summary = document.getElementById("admin-backups-summary");
|
||||
if (summary) {
|
||||
summary.innerHTML = `
|
||||
<div>${escapeHtml(t("admin.backups_total", { count: data.total_backups ?? 0, size: formatBytes(data.total_size_mb) }))}</div>
|
||||
<div>${escapeHtml(t("admin.backups_oldest", { days: (data.oldest_age_days ?? 0).toFixed(2) }))}</div>
|
||||
<div>${escapeHtml(t("admin.backups_newest", { days: (data.newest_age_days ?? 0).toFixed(2) }))}</div>
|
||||
`;
|
||||
}
|
||||
renderBackups(data.by_vault || {});
|
||||
} catch (err) {
|
||||
_showInlineError("admin-backups-tbody", err.message || "backups load failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ── User management integration ─────────────────────────────────────────
|
||||
|
||||
/** Wire the "Manage users" button to open the existing AdminPanel modal. */
|
||||
function _wireUserMgmt() {
|
||||
const btn = document.getElementById("admin-users-manage-btn");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
try {
|
||||
AdminPanel.show();
|
||||
} catch (err) {
|
||||
console.error("AdminPanel.show failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filters wiring ──────────────────────────────────────────────────────
|
||||
|
||||
function _wireAuditFilters() {
|
||||
const userInput = document.getElementById("admin-audit-user");
|
||||
const actionSelect = document.getElementById("admin-audit-action");
|
||||
const refreshBtn = document.getElementById("admin-audit-refresh");
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => loadAuditLogs());
|
||||
}
|
||||
if (userInput) {
|
||||
userInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") loadAuditLogs();
|
||||
});
|
||||
}
|
||||
if (actionSelect) {
|
||||
actionSelect.addEventListener("change", () => loadAuditLogs());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
||||
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
||||
* if the observer is unavailable.
|
||||
*/
|
||||
function _wireSectionNav() {
|
||||
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
||||
if (!links.length) return;
|
||||
const targets = links
|
||||
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
||||
.filter(Boolean);
|
||||
|
||||
// Smooth scroll on click
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const href = a.getAttribute("href");
|
||||
if (!href || !href.startsWith("#")) return;
|
||||
const t = document.getElementById(href.slice(1));
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
// Scrollspy: mark the section currently at the top of the viewport
|
||||
let active = null;
|
||||
const setActive = (id) => {
|
||||
if (active === id) return;
|
||||
active = id;
|
||||
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
||||
};
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((en) => {
|
||||
if (en.isIntersecting) setActive(en.target.id);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
targets.forEach((t) => io.observe(t));
|
||||
} else {
|
||||
// Fallback: highlight the first section only (no scroll observation)
|
||||
links[0] && links[0].classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth gate ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify the current session and admin role.
|
||||
* Uses /api/auth/me (which returns the current user) and falls back to
|
||||
* the cached user in sessionStorage if the request fails.
|
||||
* Redirects to / if not authenticated or not admin.
|
||||
* Returns the user object on success, null otherwise.
|
||||
*/
|
||||
async function _gateAdmin() {
|
||||
const container = document.getElementById("admin-main");
|
||||
const forbidden = document.getElementById("admin-forbidden");
|
||||
// First check whether auth is even enabled (public endpoint).
|
||||
let authEnabled = false;
|
||||
try {
|
||||
const statusRes = await fetch("/api/auth/status", { credentials: "include" });
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json();
|
||||
authEnabled = !!status.auth_enabled;
|
||||
}
|
||||
} catch { /* network error — fall through */ }
|
||||
|
||||
if (!authEnabled) {
|
||||
// Server is wide open, treat current visitor as allowed.
|
||||
return { username: "anonymous", role: "admin" };
|
||||
}
|
||||
|
||||
// Auth is enabled — try to load the current user.
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", {
|
||||
credentials: "include",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (meRes.status === 401 || meRes.status === 403) {
|
||||
window.location.href = "/";
|
||||
return null;
|
||||
}
|
||||
if (!meRes.ok) throw new Error("HTTP " + meRes.status);
|
||||
const user = await meRes.json();
|
||||
// Cache for later fallback
|
||||
try {
|
||||
sessionStorage.setItem("obsigate_user", JSON.stringify(user));
|
||||
} catch { /* */ }
|
||||
if (user.role !== "admin") {
|
||||
if (container) container.style.display = "none";
|
||||
if (forbidden) forbidden.style.display = "";
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
} catch (err) {
|
||||
// Network error — try to use cached sessionStorage user as fallback
|
||||
try {
|
||||
const cached = sessionStorage.getItem("obsigate_user");
|
||||
if (cached) {
|
||||
const u = JSON.parse(cached);
|
||||
if (u.role === "admin") return u;
|
||||
}
|
||||
} catch { /* */ }
|
||||
window.location.href = "/";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Page entry point. Called on DOMContentLoaded by admin.html.
|
||||
* Verifies auth, populates the action select, wires events, kicks off loads.
|
||||
*/
|
||||
export async function init() {
|
||||
const user = await _gateAdmin();
|
||||
if (!user) return;
|
||||
|
||||
// Show role badge
|
||||
const badge = document.getElementById("admin-role-badge");
|
||||
if (badge) {
|
||||
badge.textContent = user.role || "user";
|
||||
badge.dataset.role = user.role || "user";
|
||||
}
|
||||
|
||||
_populateAuditActions();
|
||||
_wireAuditFilters();
|
||||
_wireUserMgmt();
|
||||
_wireSectionNav();
|
||||
|
||||
// Initial loads (in parallel — none depends on another)
|
||||
loadStatsOnce();
|
||||
loadAuditLogs();
|
||||
loadBackupStats();
|
||||
|
||||
// Then start the live SSE stream for stats
|
||||
connectSSE();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// ObsiGate — Floating Action Button (FAB) to open/close the BooksLM AI sidebar.
|
||||
// Always visible in the bottom-right corner; hides while BooksLM is open.
|
||||
import booksLM from './bookslm.js';
|
||||
import { state } from './state.js';
|
||||
import { showToast, TabManager } from './ui.js';
|
||||
import { t } from './i18n.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
let _fab = null;
|
||||
|
||||
// Mirror palette.js getCurrentFile(): prefer the active tab, then fall back to state.
|
||||
function getCurrentFile() {
|
||||
const id = TabManager._activeTabId;
|
||||
if (id) {
|
||||
const tab = TabManager._tabs.find((x) => x.id === id);
|
||||
if (tab) return { vault: tab.vault, path: tab.path };
|
||||
}
|
||||
if (state.currentVault && state.currentPath) return { vault: state.currentVault, path: state.currentPath };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The BooksLM panel is considered visible when it has `.open` without `.hidden`
|
||||
// (the header toggle collapses it off-screen via `.hidden`).
|
||||
function isBooksLmVisible() {
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden'));
|
||||
}
|
||||
|
||||
function syncVisibility() {
|
||||
if (!_fab) return;
|
||||
const visible = isBooksLmVisible();
|
||||
_fab.hidden = visible;
|
||||
const label = visible ? t('fab.close') : t('fab.open');
|
||||
_fab.title = label;
|
||||
_fab.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
const f = getCurrentFile();
|
||||
if (!f) {
|
||||
showToast(t('toast.no_open_file'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const sameDir = booksLM._isOpen && booksLM._vault === f.vault && booksLM._directory === f.path;
|
||||
if (sameDir) {
|
||||
// Already open for this directory: re-show a collapsed panel, or close it.
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
if (panel && panel.classList.contains('hidden')) {
|
||||
booksLM._toggleSidebar();
|
||||
} else {
|
||||
booksLM.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await booksLM.open(f.vault, f.path);
|
||||
}
|
||||
|
||||
export function initAIFab() {
|
||||
if (_fab) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.id = 'ai-fab';
|
||||
btn.className = 'ai-fab';
|
||||
btn.innerHTML = '<i data-lucide="bot" style="width:24px;height:24px"></i>';
|
||||
document.body.appendChild(btn);
|
||||
_fab = btn;
|
||||
|
||||
btn.addEventListener('click', onClick);
|
||||
document.addEventListener('bookslm:opened', syncVisibility);
|
||||
document.addEventListener('bookslm:closed', syncVisibility);
|
||||
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
syncVisibility();
|
||||
}
|
||||
|
||||
export default initAIFab;
|
||||
+222
-38
@@ -9,10 +9,18 @@
|
||||
- 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 = {}) {
|
||||
const body = { text, ...extra };
|
||||
// Inject current provider + model from the per-section picker.
|
||||
const pickerState = _readPicker();
|
||||
const body = {
|
||||
text,
|
||||
...extra,
|
||||
...(pickerState.provider ? { provider: pickerState.provider } : {}),
|
||||
...(pickerState.model ? { model: pickerState.model } : {}),
|
||||
};
|
||||
const data = await api(`/api/ai/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -21,6 +29,165 @@ async function aiAction(endpoint, text, extra = {}) {
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// ── Per-section provider/model picker ───────────────────────────────────────
|
||||
// State is stored in localStorage so the user choice persists across sections.
|
||||
const PICKER_STORAGE_KEY = 'obsigate_ai_picker';
|
||||
|
||||
function _readPicker() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function _writePicker(state) {
|
||||
try {
|
||||
localStorage.setItem(PICKER_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provider+model picker that appears in every section's AI toolbar
|
||||
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
|
||||
* available providers, then /api/config/ai-models?provider=X to list models.
|
||||
*/
|
||||
async function _buildPickerUI() {
|
||||
const pickerState = _readPicker();
|
||||
|
||||
// Fetch configured providers
|
||||
let availableProviders = {};
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
availableProviders = status.providers || {};
|
||||
} catch { /* */ }
|
||||
|
||||
const providerNames = Object.keys(availableProviders).filter(
|
||||
(p) => availableProviders[p]?.available
|
||||
);
|
||||
if (!providerNames.length) return null;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ai-picker';
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginLeft: '8px',
|
||||
paddingLeft: '8px',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-muted)',
|
||||
flexShrink: '0',
|
||||
zIndex: '1',
|
||||
});
|
||||
|
||||
// Provider select
|
||||
const providerLabel = document.createElement('span');
|
||||
providerLabel.textContent = t('ai.provider') + ':';
|
||||
wrap.appendChild(providerLabel);
|
||||
|
||||
const providerSelect = document.createElement('select');
|
||||
providerSelect.className = 'ai-picker-select';
|
||||
Object.assign(providerSelect.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = t('ai.provider_default');
|
||||
providerSelect.appendChild(defaultOpt);
|
||||
providerNames.forEach((p) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
if (p === pickerState.provider) opt.selected = true;
|
||||
providerSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Model select (sibling of provider)
|
||||
const modelSelect = document.createElement('select');
|
||||
modelSelect.className = 'ai-picker-model';
|
||||
Object.assign(modelSelect.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
minWidth: '120px',
|
||||
maxWidth: '220px',
|
||||
});
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = '';
|
||||
placeholderOpt.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(placeholderOpt);
|
||||
|
||||
async function _loadModels(provider) {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = t('ai.model_loading');
|
||||
modelSelect.appendChild(ph);
|
||||
try {
|
||||
const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`);
|
||||
modelSelect.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : '');
|
||||
modelSelect.appendChild(def);
|
||||
(data.models || []).forEach((m) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
modelSelect.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = t('ai.model_load_error');
|
||||
modelSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
providerSelect.addEventListener('change', () => {
|
||||
pickerState.provider = providerSelect.value || null;
|
||||
// Reset model when provider changes
|
||||
pickerState.model = null;
|
||||
_writePicker(pickerState);
|
||||
if (pickerState.provider) {
|
||||
_loadModels(pickerState.provider);
|
||||
} else {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(ph);
|
||||
}
|
||||
});
|
||||
|
||||
modelSelect.addEventListener('change', () => {
|
||||
pickerState.model = modelSelect.value || null;
|
||||
_writePicker(pickerState);
|
||||
});
|
||||
|
||||
// Load models for the initial provider selection
|
||||
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
|
||||
_loadModels(pickerState.provider);
|
||||
}
|
||||
|
||||
wrap.appendChild(providerSelect);
|
||||
wrap.appendChild(modelSelect);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── Get selected text from CodeMirror ──
|
||||
function getSelection(editorView) {
|
||||
if (!editorView) return '';
|
||||
@@ -79,12 +246,12 @@ function createMenu(items, parentEl) {
|
||||
const menuLeft = rect.left;
|
||||
|
||||
Object.assign(menu.style, {
|
||||
position: 'fixed',
|
||||
top: menuTop + 'px',
|
||||
left: menuLeft + 'px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
position: 'fixed',
|
||||
top: menuTop + 'px',
|
||||
left: menuLeft + 'px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 0',
|
||||
minWidth: '200px',
|
||||
maxHeight: '60vh',
|
||||
@@ -171,12 +338,12 @@ function createSubMenu(items, parentEl) {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ai-submenu';
|
||||
Object.assign(menu.style, {
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: '0',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: '0',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 0',
|
||||
minWidth: '180px',
|
||||
zIndex: '101',
|
||||
@@ -219,8 +386,8 @@ 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.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border)';
|
||||
hint.textContent = t('ai.not_configured');
|
||||
container.appendChild(hint);
|
||||
return;
|
||||
}
|
||||
@@ -232,7 +399,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-secondary)',
|
||||
flexWrap: 'wrap',
|
||||
position: 'sticky',
|
||||
@@ -256,7 +423,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 +435,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 +445,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 +467,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 +494,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') },
|
||||
@@ -356,6 +523,13 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
toolbar.appendChild(rewriteBtn);
|
||||
toolbar.appendChild(toolboxBtn);
|
||||
|
||||
// Append the per-section provider/model picker on the right.
|
||||
const picker = await _buildPickerUI();
|
||||
if (picker) {
|
||||
toolbar.appendChild(createSeparator());
|
||||
toolbar.appendChild(picker);
|
||||
}
|
||||
|
||||
container.insertBefore(toolbar, container.firstChild);
|
||||
|
||||
// ── Action helper ──
|
||||
@@ -364,10 +538,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 +551,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');
|
||||
}
|
||||
@@ -451,6 +625,16 @@ function createDropdownBtn(text, items, tooltip = '') {
|
||||
|
||||
function createSeparator() {
|
||||
const sep = document.createElement('span');
|
||||
sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px';
|
||||
sep.style.cssText = 'width:1px;height:16px;background:var(--border);margin:0 2px';
|
||||
return sep;
|
||||
}
|
||||
|
||||
// ── Public exports ────────────────────────────────────────────────────────
|
||||
// Other modules (e.g. BooksLM) can import the picker helpers to render the
|
||||
// same per-section provider/model picker in their own toolbar.
|
||||
export {
|
||||
_readPicker,
|
||||
_writePicker,
|
||||
PICKER_STORAGE_KEY,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
};
|
||||
|
||||
+39
-5
@@ -4,6 +4,8 @@
|
||||
|
||||
import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n, t } from './i18n.js';
|
||||
import { initAIFab } from './ai-fab.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
@@ -16,11 +18,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 +49,7 @@ async function init() {
|
||||
Legacy.initSidebarTabs();
|
||||
Legacy.initHelpModal();
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
@@ -47,11 +60,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 +92,37 @@ 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();
|
||||
initAIFab();
|
||||
}
|
||||
|
||||
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 — always visible, fallback before the async fetch
|
||||
const versionBadge = document.getElementById('version-badge');
|
||||
if (versionBadge) {
|
||||
versionBadge.textContent = window.__OBSIGATE_VERSION || '…';
|
||||
}
|
||||
fetch('/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const badge = document.getElementById('version-badge');
|
||||
if (badge && d.version) badge.textContent = d.version;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
+770
-24
@@ -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) {
|
||||
@@ -130,10 +139,122 @@ const AuthManager = {
|
||||
throw new Error(err.detail || "Erreur de connexion");
|
||||
}
|
||||
const data = await response.json();
|
||||
// If MFA is required, return the MFA challenge instead of saving token
|
||||
if (data.mfa_required) {
|
||||
return data;
|
||||
}
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async verifyMfa(username, code, rememberMe) {
|
||||
const response = await fetch("/api/auth/mfa/totp/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, code, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Code invalide");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async verifyRecovery(username, recoveryCode) {
|
||||
const response = await fetch("/api/auth/mfa/recovery", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, recovery_code: recoveryCode }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Code invalide");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
// ── WebAuthn (ROADMAP #64) ─────────────────────────────────────────
|
||||
|
||||
async webauthnLoginOptions(username) {
|
||||
const resp = await fetch("/api/auth/mfa/webauthn/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!resp.ok) throw new Error((await resp.json()).detail || "WebAuthn indisponible");
|
||||
return await resp.json();
|
||||
},
|
||||
|
||||
async verifyWebauthn(username, credential, rememberMe) {
|
||||
const response = await fetch("/api/auth/mfa/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, credential, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Vérification WebAuthn échouée");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async webauthnRegisterOptions() {
|
||||
return await api("/api/auth/mfa/webauthn/register/options", { method: "POST" });
|
||||
},
|
||||
|
||||
async webauthnRegister(credential, label) {
|
||||
return await api("/api/auth/mfa/webauthn/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential, label }),
|
||||
});
|
||||
},
|
||||
|
||||
async webauthnCredentials() {
|
||||
return await api("/api/auth/mfa/webauthn/credentials");
|
||||
},
|
||||
|
||||
async webauthnRemove(credentialId, password) {
|
||||
return await api("/api/auth/mfa/webauthn/credentials/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential_id: credentialId, password }),
|
||||
});
|
||||
},
|
||||
|
||||
// ── MFA Setup API calls ──────────────────────────────────────────
|
||||
|
||||
async getMfaStatus() {
|
||||
const resp = await api("/api/auth/mfa/status");
|
||||
return resp;
|
||||
},
|
||||
|
||||
async mfaSetup() {
|
||||
return await api("/api/auth/mfa/totp/setup", { method: "POST" });
|
||||
},
|
||||
|
||||
async mfaEnable(code) {
|
||||
return await api("/api/auth/mfa/totp/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
},
|
||||
|
||||
async mfaDisable(password, code) {
|
||||
return await api("/api/auth/mfa/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
});
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
@@ -194,7 +315,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");
|
||||
@@ -207,7 +328,7 @@ const AuthManager = {
|
||||
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
|
||||
adminRow.onclick = () => {
|
||||
closeHeaderMenu();
|
||||
AdminPanel.show();
|
||||
window.location.href = "/admin.html";
|
||||
};
|
||||
} else {
|
||||
adminRow.classList.add("hidden");
|
||||
@@ -269,6 +390,343 @@ const AuthManager = {
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-login setup (shared between normal login and MFA login)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function _onLoginSuccess() {
|
||||
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 {
|
||||
const { loadVaults, loadTags } = await import('./sidebar.js');
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
const { IndexUpdateManager } = await import('./sync.js');
|
||||
IndexUpdateManager.connect();
|
||||
const { syncFileIndexFromServer } = await import('./offline.js');
|
||||
syncFileIndexFromServer();
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
console.error("Failed to load data after login:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MFA Challenge UI (TOTP code input during login)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// base64url <-> ArrayBuffer helpers for WebAuthn (ROADMAP #64)
|
||||
function _b64urlToBuf(s) {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4);
|
||||
const b = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
function _bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (const c of bytes) s += String.fromCharCode(c);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function runWebauthnCeremony(optionsJson) {
|
||||
const pkOptions = {
|
||||
challenge: _b64urlToBuf(optionsJson.challenge),
|
||||
timeout: optionsJson.timeout || 60000,
|
||||
rpId: optionsJson.rpId,
|
||||
userVerification: optionsJson.userVerification || "preferred",
|
||||
allowCredentials: (optionsJson.allowCredentials || []).map((c) => ({
|
||||
type: c.type || "public-key",
|
||||
id: _b64urlToBuf(c.id),
|
||||
transports: c.transports,
|
||||
})),
|
||||
};
|
||||
const cred = await navigator.credentials.get({ publicKey: pkOptions });
|
||||
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
|
||||
return {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
|
||||
authenticatorData: _bufToB64url(cred.response.authenticatorData),
|
||||
signature: _bufToB64url(cred.response.signature),
|
||||
userHandle: cred.response.userHandle ? _bufToB64url(cred.response.userHandle) : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebauthnRegistration() {
|
||||
const resp = await AuthManager.webauthnRegisterOptions();
|
||||
const o = resp.options;
|
||||
const pkOptions = {
|
||||
rp: { id: o.rpId || o.rp.id, name: o.rp.name },
|
||||
challenge: _b64urlToBuf(o.challenge),
|
||||
user: {
|
||||
id: _b64urlToBuf(o.user.id),
|
||||
name: o.user.name,
|
||||
displayName: o.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: o.pubKeyCredParams,
|
||||
timeout: o.timeout || 60000,
|
||||
authenticatorSelection: o.authenticatorSelection || undefined,
|
||||
attestation: "none",
|
||||
};
|
||||
const cred = await navigator.credentials.create({ publicKey: pkOptions });
|
||||
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
|
||||
const credential = {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: _bufToB64url(cred.response.attestationObject),
|
||||
},
|
||||
};
|
||||
if (cred.response.getTransports) {
|
||||
credential.response.transports = Array.from(cred.response.getTransports());
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
function _startWebauthnLogin(mfaSection, username, rememberMe) {
|
||||
const btn = mfaSection.querySelector("#mfa-webauthn-btn");
|
||||
const errorEl = mfaSection.querySelector("#mfa-error");
|
||||
if (btn) btn.disabled = true;
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await AuthManager.webauthnLoginOptions(username);
|
||||
if (resp.mfa_method !== "webauthn" || !resp.options) {
|
||||
throw new Error(t("mfa.webauthn_no_key"));
|
||||
}
|
||||
const credential = await runWebauthnCeremony(resp.options);
|
||||
await AuthManager.verifyWebauthn(username, credential, rememberMe);
|
||||
mfaSection.classList.add("hidden");
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = err.message || String(err);
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
|
||||
const loginBox = document.querySelector(".login-box");
|
||||
if (!loginBox) return;
|
||||
|
||||
// Hide the normal login form
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) loginForm.classList.add("hidden");
|
||||
|
||||
// Create MFA challenge UI
|
||||
let mfaSection = document.getElementById("mfa-challenge");
|
||||
if (!mfaSection) {
|
||||
mfaSection = document.createElement("div");
|
||||
mfaSection.id = "mfa-challenge";
|
||||
mfaSection.className = "mfa-challenge";
|
||||
loginBox.appendChild(mfaSection);
|
||||
}
|
||||
|
||||
// WebAuthn second factor: key prompt instead of TOTP code input
|
||||
if (mfaMethod === "webauthn") {
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔑</div>
|
||||
<h3>${t('mfa.title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.webauthn_prompt')}</p>
|
||||
<p class="mfa-error hidden" id="mfa-error"></p>
|
||||
<button type="button" class="btn-login" id="mfa-webauthn-btn">
|
||||
<span class="btn-text">${t('mfa.webauthn_btn')}</span>
|
||||
</button>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
mfaSection.classList.remove("hidden");
|
||||
document.getElementById("mfa-webauthn-btn").addEventListener("click", () => {
|
||||
_startWebauthnLogin(mfaSection, username, rememberMe);
|
||||
});
|
||||
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
|
||||
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
|
||||
});
|
||||
document.getElementById("mfa-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
// Auto-start the ceremony — the browser shows its own dialog
|
||||
_startWebauthnLogin(mfaSection, username, rememberMe);
|
||||
return;
|
||||
}
|
||||
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔐</div>
|
||||
<h3>${t('mfa.title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.subtitle')}</p>
|
||||
<form id="mfa-form">
|
||||
<div class="mfa-code-input-group">
|
||||
<input type="text" id="mfa-code" class="mfa-code-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code"
|
||||
placeholder="000000" autofocus required>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="mfa-error"></p>
|
||||
<button type="submit" class="btn-login" id="mfa-verify-btn">
|
||||
<span class="btn-text">${t('mfa.verify')}</span>
|
||||
<span class="btn-spinner hidden">⏳</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
mfaSection.classList.remove("hidden");
|
||||
|
||||
const codeInput = document.getElementById("mfa-code");
|
||||
codeInput.focus();
|
||||
|
||||
// Auto-submit when 6 digits entered
|
||||
codeInput.addEventListener("input", () => {
|
||||
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
|
||||
if (codeInput.value.length === 6) {
|
||||
document.getElementById("mfa-verify-btn").click();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle MFA form submit
|
||||
document.getElementById("mfa-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
const btn = document.getElementById("mfa-verify-btn");
|
||||
const errorEl = document.getElementById("mfa-error");
|
||||
btn.disabled = true;
|
||||
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verifying');
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.verifyMfa(username, code, rememberMe);
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.querySelector(".btn-spinner").classList.add("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verify');
|
||||
}
|
||||
});
|
||||
|
||||
// Switch to recovery code input
|
||||
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
|
||||
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
|
||||
});
|
||||
|
||||
// Back to login
|
||||
document.getElementById("mfa-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function showRecoveryChallenge(username, rememberMe, loginForm, mfaSection) {
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔑</div>
|
||||
<h3>${t('mfa.recovery_title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.recovery_subtitle')}</p>
|
||||
<form id="recovery-form">
|
||||
<div class="mfa-code-input-group">
|
||||
<input type="text" id="recovery-code" class="mfa-code-input recovery-input" maxlength="9"
|
||||
autocomplete="off" placeholder="XXXX-XXXX" autofocus required>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="recovery-error"></p>
|
||||
<button type="submit" class="btn-login" id="recovery-verify-btn">
|
||||
<span class="btn-text">${t('mfa.verify')}</span>
|
||||
<span class="btn-spinner hidden">⏳</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="recovery-use-totp">${t('mfa.use_totp')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="recovery-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const codeInput = document.getElementById("recovery-code");
|
||||
codeInput.focus();
|
||||
|
||||
// Auto-format: insert dash after 4 chars
|
||||
codeInput.addEventListener("input", () => {
|
||||
let v = codeInput.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
|
||||
if (v.length > 4) v = v.slice(0, 4) + "-" + v.slice(4, 8);
|
||||
codeInput.value = v;
|
||||
});
|
||||
|
||||
document.getElementById("recovery-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const code = codeInput.value.trim();
|
||||
const btn = document.getElementById("recovery-verify-btn");
|
||||
const errorEl = document.getElementById("recovery-error");
|
||||
btn.disabled = true;
|
||||
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verifying');
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.verifyRecovery(username, code);
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.querySelector(".btn-spinner").classList.add("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verify');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("recovery-use-totp").addEventListener("click", () => {
|
||||
showMfaChallenge(username, rememberMe, null, null);
|
||||
});
|
||||
|
||||
document.getElementById("recovery-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login form handler
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,18 +749,14 @@ function initLoginForm() {
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.login(username, password, rememberMe);
|
||||
AuthManager.showApp();
|
||||
// Load app data after successful login
|
||||
try {
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
// Start SSE sync now that auth cookie is set
|
||||
IndexUpdateManager.connect();
|
||||
// Show dashboard
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
console.error("Failed to load data after login:", err);
|
||||
const result = await AuthManager.login(username, password, rememberMe);
|
||||
// Check if MFA is required
|
||||
if (result && result.mfa_required) {
|
||||
showMfaChallenge(result.username, rememberMe, btn, errorEl, result.mfa_method);
|
||||
return;
|
||||
}
|
||||
// Normal login success
|
||||
await _onLoginSuccess();
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
@@ -353,7 +807,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 +816,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 +851,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 +882,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 +988,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");
|
||||
}
|
||||
@@ -547,4 +1003,294 @@ const AdminPanel = {
|
||||
};
|
||||
|
||||
|
||||
export { api, AuthManager, initLoginForm, AdminPanel };
|
||||
// ---------------------------------------------------------------------------
|
||||
// MFA Settings — Setup/Disable UI in the settings panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initMfaSettings() {
|
||||
const area = document.getElementById("mfa-setup-area");
|
||||
const badge = document.getElementById("mfa-status-badge");
|
||||
if (!area || !badge) return;
|
||||
|
||||
// Check current MFA status
|
||||
let mfaEnabled = false;
|
||||
try {
|
||||
const status = await AuthManager.getMfaStatus();
|
||||
mfaEnabled = status.mfa_enabled;
|
||||
} catch (e) {
|
||||
// Not logged in or error
|
||||
return;
|
||||
}
|
||||
|
||||
if (mfaEnabled) {
|
||||
badge.textContent = t("mfa.enabled");
|
||||
badge.className = "mfa-badge mfa-badge-on";
|
||||
area.innerHTML = `
|
||||
<p class="mfa-info-text" data-i18n="mfa.enabled_desc">${t("mfa.enabled_desc")}</p>
|
||||
<button class="config-btn-secondary" id="mfa-disable-btn">${t("mfa.disable_btn")}</button>
|
||||
<div id="mfa-disable-form-area"></div>
|
||||
`;
|
||||
document.getElementById("mfa-disable-btn").addEventListener("click", () => {
|
||||
_showDisableMfaForm();
|
||||
});
|
||||
} else {
|
||||
badge.textContent = t("mfa.disabled");
|
||||
badge.className = "mfa-badge mfa-badge-off";
|
||||
area.innerHTML = `
|
||||
<p class="mfa-info-text" data-i18n="mfa.setup_desc">${t("mfa.setup_desc")}</p>
|
||||
<button class="config-btn-primary" id="mfa-enable-btn">${t("mfa.enable_btn")}</button>
|
||||
<div id="mfa-setup-flow-area"></div>
|
||||
`;
|
||||
document.getElementById("mfa-enable-btn").addEventListener("click", () => {
|
||||
_startMfaSetup();
|
||||
});
|
||||
}
|
||||
|
||||
// WebAuthn security keys section (ROADMAP #64)
|
||||
_renderWebauthnSection(area);
|
||||
}
|
||||
|
||||
|
||||
async function _renderWebauthnSection(container) {
|
||||
if (!container || !window.PublicKeyCredential) return;
|
||||
|
||||
let keys = [];
|
||||
try {
|
||||
const resp = await AuthManager.webauthnCredentials();
|
||||
keys = resp.credentials || [];
|
||||
} catch (e) {
|
||||
return; // auth disabled or endpoint unreachable — hide section
|
||||
}
|
||||
|
||||
let section = document.getElementById("webauthn-settings");
|
||||
if (!section) {
|
||||
section = document.createElement("div");
|
||||
section.id = "webauthn-settings";
|
||||
section.className = "webauthn-settings";
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
const listHtml = keys.length
|
||||
? `<ul class="webauthn-key-list">${keys.map((k) => `
|
||||
<li class="webauthn-key-item">
|
||||
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span>
|
||||
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span>
|
||||
<button class="config-btn-secondary config-btn-sm webauthn-key-remove"
|
||||
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
|
||||
|
||||
section.innerHTML = `
|
||||
<h4 class="webauthn-title">${t("mfa.webauthn_title")}</h4>
|
||||
<p class="mfa-info-text">${t("mfa.webauthn_desc")}</p>
|
||||
${listHtml}
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-primary" id="webauthn-add-btn">${t("mfa.webauthn_add")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="webauthn-error"></p>
|
||||
<div id="webauthn-flow-area"></div>
|
||||
`;
|
||||
|
||||
const errEl = section.querySelector("#webauthn-error");
|
||||
document.getElementById("webauthn-add-btn").addEventListener("click", async () => {
|
||||
errEl.classList.add("hidden");
|
||||
try {
|
||||
const credential = await runWebauthnRegistration();
|
||||
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
|
||||
const result = await AuthManager.webauthnRegister(credential, label || "Security key");
|
||||
if (result.recovery_codes && result.recovery_codes.length) {
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
} else {
|
||||
showToast(t("mfa.webauthn_added"), "success");
|
||||
}
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
section.querySelectorAll(".webauthn-key-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const password = prompt(t("mfa.webauthn_remove_confirm"));
|
||||
if (password === null) return;
|
||||
try {
|
||||
await AuthManager.webauthnRemove(btn.dataset.id, password);
|
||||
showToast(t("mfa.webauthn_removed"), "success");
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _startMfaSetup() {
|
||||
const flowArea = document.getElementById("mfa-setup-flow-area");
|
||||
if (!flowArea) return;
|
||||
|
||||
try {
|
||||
const data = await AuthManager.mfaSetup();
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-setup-card">
|
||||
<h4>${t("mfa.scan_qr")}</h4>
|
||||
<div class="mfa-qr-container">
|
||||
<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code"
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.otpauth_uri)}">
|
||||
</div>
|
||||
<details class="mfa-secret-details">
|
||||
<summary>${t("mfa.manual_entry")}</summary>
|
||||
<code class="mfa-secret-code">${data.secret}</code>
|
||||
</details>
|
||||
<div class="mfa-verify-section">
|
||||
<label>${t("mfa.enter_code")}</label>
|
||||
<input type="text" id="mfa-enable-code" class="mfa-code-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" placeholder="000000" autocomplete="one-time-code">
|
||||
<button class="config-btn-primary" id="mfa-confirm-btn">${t("mfa.confirm_enable")}</button>
|
||||
<p class="mfa-error hidden" id="mfa-enable-error"></p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const codeInput = document.getElementById("mfa-enable-code");
|
||||
codeInput.addEventListener("input", () => {
|
||||
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
|
||||
});
|
||||
|
||||
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
const btn = document.getElementById("mfa-confirm-btn");
|
||||
const errorEl = document.getElementById("mfa-enable-error");
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("mfa.verifying");
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const result = await AuthManager.mfaEnable(code);
|
||||
// Show recovery codes
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("mfa.confirm_enable");
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
flowArea.innerHTML = `<p class="mfa-error">${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function _showRecoveryCodes(codes) {
|
||||
const flowArea = document.getElementById("mfa-setup-flow-area");
|
||||
const area = document.getElementById("mfa-setup-area");
|
||||
if (!flowArea) return;
|
||||
|
||||
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n");
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-recovery-card">
|
||||
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
|
||||
<p class="mfa-warning">${t("mfa.recovery_codes_warning")}</p>
|
||||
<div class="mfa-recovery-list" id="mfa-recovery-list">
|
||||
${codesHtml}
|
||||
</div>
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-secondary" id="mfa-copy-codes">${t("mfa.copy_codes")}</button>
|
||||
<button class="config-btn-secondary" id="mfa-download-codes">${t("mfa.download_codes")}</button>
|
||||
<button class="config-btn-primary" id="mfa-codes-done">${t("mfa.done")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("mfa-copy-codes").addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(codes.join("\n")).then(() => {
|
||||
showToast(t("mfa.codes_copied"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mfa-download-codes").addEventListener("click", () => {
|
||||
const blob = new Blob([codes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "obsigate-recovery-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
document.getElementById("mfa-codes-done").addEventListener("click", () => {
|
||||
// Refresh MFA settings display
|
||||
initMfaSettings();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function _showDisableMfaForm() {
|
||||
const formArea = document.getElementById("mfa-disable-form-area");
|
||||
if (!formArea) return;
|
||||
|
||||
formArea.innerHTML = `
|
||||
<div class="mfa-disable-card">
|
||||
<h4>${t("mfa.disable_confirm_title")}</h4>
|
||||
<p>${t("mfa.disable_confirm_desc")}</p>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.password_label")}</label>
|
||||
<input type="password" id="mfa-disable-password" class="config-input" placeholder="${t('mfa.password_placeholder')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.totp_code_label")}</label>
|
||||
<input type="text" id="mfa-disable-code" class="mfa-code-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" placeholder="000000">
|
||||
</div>
|
||||
<div class="mfa-disable-actions">
|
||||
<button class="config-btn-secondary" id="mfa-disable-cancel">${t("common.cancel")}</button>
|
||||
<button class="config-btn-danger" id="mfa-disable-confirm">${t("mfa.disable_confirm_btn")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="mfa-disable-error"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("mfa-disable-cancel").addEventListener("click", () => {
|
||||
formArea.innerHTML = "";
|
||||
});
|
||||
|
||||
document.getElementById("mfa-disable-confirm").addEventListener("click", async () => {
|
||||
const password = document.getElementById("mfa-disable-password").value;
|
||||
const code = document.getElementById("mfa-disable-code").value.trim();
|
||||
const errorEl = document.getElementById("mfa-disable-error");
|
||||
const btn = document.getElementById("mfa-disable-confirm");
|
||||
|
||||
if (!password || code.length !== 6) {
|
||||
errorEl.textContent = t("mfa.fill_all_fields");
|
||||
errorEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("mfa.verifying");
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.mfaDisable(password, code);
|
||||
showToast(t("mfa.disabled_success"), "success");
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("mfa.disable_confirm_btn");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export { api, AuthManager, initLoginForm, AdminPanel, initMfaSettings };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
// BooksLM — Directory-scoped AI chat panel (style NotebookLM)
|
||||
import { t } from './i18n.js';
|
||||
import { buildAIPickerUI } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
class BooksLM {
|
||||
constructor() {
|
||||
this._isOpen = false;
|
||||
this._vault = null;
|
||||
this._directory = null;
|
||||
this._messages = [];
|
||||
this._contextFiles = [];
|
||||
this._isLoading = false;
|
||||
this._abortCtrl = null;
|
||||
this._panel = null;
|
||||
this._isFullscreen = false;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
async open(vault, directory) {
|
||||
if (this._isOpen && this._vault === vault && this._directory === directory) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this._vault = vault;
|
||||
this._directory = directory;
|
||||
this._messages = [];
|
||||
this._contextFiles = [];
|
||||
this._isLoading = true;
|
||||
|
||||
// Restore history
|
||||
this._loadHistory();
|
||||
|
||||
// Build panel if needed
|
||||
if (!this._panel) {
|
||||
this._panel = this._render();
|
||||
document.body.appendChild(this._panel);
|
||||
}
|
||||
this._updateHeader();
|
||||
this._updateStatus();
|
||||
this._renderMessages();
|
||||
|
||||
// Open with animation
|
||||
requestAnimationFrame(() => {
|
||||
this._panel.classList.add('open');
|
||||
let hidden = false;
|
||||
// Apply persisted hidden state (user toggled off in a previous session)
|
||||
try {
|
||||
const savedHidden = localStorage.getItem('obsigate-bookslm-hidden');
|
||||
if (savedHidden === 'true') {
|
||||
this._panel.classList.add('hidden');
|
||||
this._updateToggleIcon();
|
||||
hidden = true;
|
||||
}
|
||||
} catch { /* */ }
|
||||
document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } }));
|
||||
});
|
||||
this._isOpen = true;
|
||||
|
||||
// Load context
|
||||
try {
|
||||
const resp = await fetch('/api/ai/bookslm/context', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault, directory })
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
this._contextFiles = data.files || [];
|
||||
this._updateStatus(data);
|
||||
this._showSuggestions();
|
||||
} catch (e) {
|
||||
console.warn('BooksLM: context load failed', e);
|
||||
this._contextFiles = [];
|
||||
this._updateStatus({ error: e.message });
|
||||
}
|
||||
this._isLoading = false;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._panel || !this._isOpen) return;
|
||||
this._panel.classList.remove('open');
|
||||
this._isOpen = false;
|
||||
document.dispatchEvent(new CustomEvent('bookslm:closed'));
|
||||
// Abort any in-flight request
|
||||
if (this._abortCtrl) {
|
||||
this._abortCtrl.abort();
|
||||
this._abortCtrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the BooksLM sidebar visibility (collapsed/hidden to the right edge
|
||||
* while the panel stays mounted). The state is persisted in localStorage so
|
||||
* the user choice survives page reloads — same UX as the left sidebar toggle.
|
||||
*
|
||||
* The panel stays in the DOM (no teardown); only a CSS transform is applied,
|
||||
* which is what makes this fast and matches the right-sidebar pattern in
|
||||
* ObsiGate.
|
||||
*/
|
||||
_toggleSidebar() {
|
||||
if (!this._panel) return;
|
||||
const isHidden = this._panel.classList.toggle('hidden');
|
||||
try {
|
||||
localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false');
|
||||
} catch { /* */ }
|
||||
this._updateToggleIcon();
|
||||
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the toggle icon between "panel-right-close" (visible) and
|
||||
* "panel-right-open" (hidden) so the button visually reflects the state.
|
||||
*/
|
||||
_updateToggleIcon() {
|
||||
if (!this._panel) return;
|
||||
const btn = this._panel.querySelector('.bookslm-btn-toggle');
|
||||
if (!btn) return;
|
||||
const isHidden = this._panel.classList.contains('hidden');
|
||||
const icon = btn.querySelector('i[data-lucide]');
|
||||
if (!icon) return;
|
||||
icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close');
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
newConversation() {
|
||||
this._messages = [];
|
||||
this._saveHistory();
|
||||
if (this._panel) {
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
}
|
||||
if (!this._isOpen && this._vault && this._directory) {
|
||||
this.open(this._vault, this._directory);
|
||||
}
|
||||
}
|
||||
|
||||
exportConversation() {
|
||||
if (!this._messages.length) return;
|
||||
let md = `# BooksLM — ${this._directory}\n\n`;
|
||||
for (const msg of this._messages) {
|
||||
const role = msg.role === 'user' ? '**You**' : '**AI**';
|
||||
md += `### ${role}\n\n${msg.content}\n\n---\n\n`;
|
||||
}
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `bookslm-${this._directory.replace(/\//g, '_')}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
openFile(path) {
|
||||
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
|
||||
detail: { vault: this._vault, path }
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'bookslm-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="bookslm-header">
|
||||
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
|
||||
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<span>📚</span>
|
||||
<span class="bookslm-title"></span>
|
||||
<span class="bookslm-picker-host" style="margin-left:auto"></span>
|
||||
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}">✨</button>
|
||||
<button class="bookslm-btn-export" title="${t('bookslm.export')}">📥</button>
|
||||
<button class="bookslm-btn-fullscreen" title="⛶">⛶</button>
|
||||
<button class="bookslm-btn-close" title="✕">✕</button>
|
||||
</div>
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></div>
|
||||
<div class="bookslm-input-area">
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1"></textarea>
|
||||
<button class="bookslm-btn-send">${t('bookslm.send')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Inject the provider/model picker asynchronously (depends on /api/ai/status)
|
||||
const pickerHost = panel.querySelector('.bookslm-picker-host');
|
||||
buildAIPickerUI().then((picker) => {
|
||||
if (picker && pickerHost) pickerHost.replaceWith(picker);
|
||||
}).catch(() => { /* ignore */ });
|
||||
|
||||
// Wire events
|
||||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||||
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
|
||||
panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar());
|
||||
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
|
||||
this._isFullscreen = !this._isFullscreen;
|
||||
panel.classList.toggle('fullscreen', this._isFullscreen);
|
||||
});
|
||||
|
||||
// Send button
|
||||
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
|
||||
|
||||
// Textarea auto-grow + Ctrl+Enter
|
||||
const textarea = panel.querySelector('textarea');
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
});
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this._sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
_updateHeader() {
|
||||
if (!this._panel) return;
|
||||
const title = this._panel.querySelector('.bookslm-title');
|
||||
if (title) {
|
||||
title.textContent = this._directory
|
||||
? `📚 ${this._directory.split('/').pop() || this._vault}`
|
||||
: t('bookslm.title');
|
||||
}
|
||||
}
|
||||
|
||||
_updateStatus(data) {
|
||||
if (!this._panel) return;
|
||||
const status = this._panel.querySelector('.bookslm-status');
|
||||
if (!status) return;
|
||||
|
||||
if (data && data.error) {
|
||||
status.innerHTML = `<span style="color:#f87171">⚠ ${t('bookslm.no_context')}</span>`;
|
||||
return;
|
||||
}
|
||||
if (data && data.files) {
|
||||
const count = data.files.length;
|
||||
const chars = data.total_chars || 0;
|
||||
const pct = Math.min(100, Math.round((chars / 100000) * 100));
|
||||
status.innerHTML = `
|
||||
<span>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
|
||||
<div class="bookslm-context-bar"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
|
||||
`;
|
||||
} else if (this._isLoading) {
|
||||
status.textContent = '⏳ ...';
|
||||
}
|
||||
}
|
||||
|
||||
_showSuggestions() {
|
||||
if (!this._panel) return;
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (!sugEl) return;
|
||||
sugEl.innerHTML = '';
|
||||
|
||||
if (!this._contextFiles.length && !this._isLoading) return;
|
||||
|
||||
const suggestions = [
|
||||
t('bookslm.suggestion_summary'),
|
||||
t('bookslm.suggestion_themes'),
|
||||
t('bookslm.suggestion_contradictions')
|
||||
];
|
||||
for (const s of suggestions) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'bookslm-suggestion';
|
||||
btn.textContent = s;
|
||||
btn.addEventListener('click', () => {
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.value = s;
|
||||
this._sendMessage();
|
||||
}
|
||||
});
|
||||
sugEl.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_renderMessages() {
|
||||
if (!this._panel) return;
|
||||
const container = this._panel.querySelector('.bookslm-messages');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const msg of this._messages) {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `bookslm-bubble ${msg.role}`;
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
bubble.innerHTML = this._renderMarkdown(msg.content || '');
|
||||
// Source badges
|
||||
if (msg.sources && msg.sources.length) {
|
||||
const sourcesDiv = document.createElement('div');
|
||||
sourcesDiv.className = 'bookslm-sources';
|
||||
for (const src of msg.sources) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'bookslm-source-badge';
|
||||
badge.textContent = `📄 ${src.split('/').pop()}`;
|
||||
badge.title = src;
|
||||
badge.addEventListener('click', () => this.openFile(src));
|
||||
sourcesDiv.appendChild(badge);
|
||||
}
|
||||
bubble.appendChild(sourcesDiv);
|
||||
}
|
||||
} else {
|
||||
bubble.textContent = msg.content;
|
||||
}
|
||||
|
||||
container.appendChild(bubble);
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Messaging ───────────────────────────────────────────────────────
|
||||
|
||||
async _sendMessage() {
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
const text = (textarea.value || '').trim();
|
||||
if (!text || this._isLoading) return;
|
||||
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Hide suggestions
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (sugEl) sugEl.innerHTML = '';
|
||||
|
||||
// Add user message
|
||||
this._messages.push({ role: 'user', content: text });
|
||||
this._renderMessages();
|
||||
|
||||
// Add assistant placeholder
|
||||
const assistantMsg = { role: 'assistant', content: '', sources: [] };
|
||||
this._messages.push(assistantMsg);
|
||||
this._renderMessages();
|
||||
this._isLoading = true;
|
||||
|
||||
// Disable send button
|
||||
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
this._abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
// Read provider/model from the picker (localStorage-backed)
|
||||
let provider = null;
|
||||
let model = null;
|
||||
try {
|
||||
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
|
||||
provider = picker.provider || null;
|
||||
model = picker.model || null;
|
||||
} catch { /* */ }
|
||||
|
||||
const resp = await fetch('/api/ai/bookslm/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
// The backend expects `message` + `conversation_history`,
|
||||
// not a generic `messages` array. Convert before sending.
|
||||
message: this._messages.length
|
||||
? this._messages[this._messages.length - 1].content
|
||||
: '',
|
||||
conversation_history: this._messages
|
||||
.slice(0, -1)
|
||||
.filter((m) => m.content && m.content.trim())
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
context_files: this._contextFiles.map((f) => f.path || f),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
signal: this._abortCtrl.signal
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6);
|
||||
if (payload === '[DONE]') continue;
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
if (data.content) {
|
||||
assistantMsg.content += data.content;
|
||||
}
|
||||
if (data.sources) {
|
||||
assistantMsg.sources = data.sources;
|
||||
}
|
||||
} catch {
|
||||
// Raw text token
|
||||
assistantMsg.content += payload;
|
||||
}
|
||||
this._renderMessages();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract sources from context files if mentioned
|
||||
if (!assistantMsg.sources.length) {
|
||||
const contentLower = assistantMsg.content.toLowerCase();
|
||||
assistantMsg.sources = this._contextFiles
|
||||
.filter(f => {
|
||||
const name = (f.path || f || '').split('/').pop().toLowerCase();
|
||||
return name && contentLower.includes(name);
|
||||
})
|
||||
.map(f => f.path || f);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
assistantMsg.content = `⚠ Error: ${e.message}`;
|
||||
console.warn('BooksLM chat error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this._isLoading = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
this._abortCtrl = null;
|
||||
this._renderMessages();
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
// ── Markdown (lightweight) ──────────────────────────────────────────
|
||||
|
||||
_renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
let html = text
|
||||
// Code blocks
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Bold
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
// Unordered lists
|
||||
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
|
||||
// Headers
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
// Paragraphs
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<p>${html}</p>`;
|
||||
}
|
||||
|
||||
// ── History persistence ─────────────────────────────────────────────
|
||||
|
||||
_historyKey() {
|
||||
return `bookslm-history-${this._vault}-${this._directory}`;
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(this._historyKey());
|
||||
if (data) {
|
||||
this._messages = JSON.parse(data);
|
||||
}
|
||||
} catch {
|
||||
this._messages = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const booksLM = new BooksLM();
|
||||
export default booksLM;
|
||||
export { BooksLM };
|
||||
+481
-41
@@ -1,10 +1,11 @@
|
||||
// config.js — extracted from app.js (3872-4865)
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager, initMfaSettings } 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,15 @@ function initConfigModal() {
|
||||
|
||||
// Load saved frontend config on startup
|
||||
applyFrontendConfig();
|
||||
|
||||
// Init theme picker
|
||||
initThemePicker();
|
||||
|
||||
// Init profile
|
||||
initProfile();
|
||||
|
||||
// Init MFA settings (security tab)
|
||||
initMfaSettings();
|
||||
}
|
||||
|
||||
function closeConfigModal() {
|
||||
@@ -665,6 +722,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 +763,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 +774,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 +787,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 +802,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 +847,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 +868,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 +920,33 @@ 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"), 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 +1030,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 +1059,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 +1105,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 +1178,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 +1220,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 +1338,137 @@ function updateRegexPreview() {
|
||||
}
|
||||
|
||||
|
||||
// ── AI Keys management ──
|
||||
const AI_KEY_MAP = {
|
||||
"cfg-deepseek-key": "DEEPSEEK_API_KEY",
|
||||
"cfg-openrouter-key": "OPENROUTER_API_KEY",
|
||||
"cfg-gemini-key": "GEMINI_API_KEY",
|
||||
"cfg-nvidia-key": "NVIDIA_API_KEY",
|
||||
"cfg-qwencloud-key": "QWENCLOUD_API_KEY",
|
||||
"cfg-xiaomi-key": "XIAOMI_API_KEY",
|
||||
"cfg-mistral-key": "MISTRAL_API_KEY",
|
||||
};
|
||||
const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"];
|
||||
|
||||
function _ensureAIKeyUI() {
|
||||
for (const [inputId] of Object.entries(AI_KEY_MAP)) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) continue;
|
||||
const row = input.closest(".config-row");
|
||||
if (!row || row.dataset.enhanced) continue;
|
||||
row.dataset.enhanced = "1";
|
||||
row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
|
||||
const badge = document.createElement("span");
|
||||
badge.id = inputId.replace("-key", "-badge");
|
||||
badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;";
|
||||
row.appendChild(badge);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.id = inputId.replace("-key", "-delete");
|
||||
delBtn.className = "config-btn-secondary";
|
||||
delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;";
|
||||
delBtn.textContent = "\u00d7 Supprimer";
|
||||
delBtn.addEventListener("click", () => deleteAIKey(inputId));
|
||||
row.appendChild(delBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function _setAIKeyBadge(inputId, hasKey) {
|
||||
const badge = document.getElementById(inputId.replace("-key", "-badge"));
|
||||
const delBtn = document.getElementById(inputId.replace("-key", "-delete"));
|
||||
if (badge) {
|
||||
if (hasKey) {
|
||||
badge.textContent = "\u2713 Configur\u00e9";
|
||||
badge.style.background = "var(--success-bg, #27ae6022)";
|
||||
badge.style.color = "var(--success, #27ae60)";
|
||||
badge.style.border = "1px solid var(--success, #27ae60)";
|
||||
} else {
|
||||
badge.textContent = "Non configur\u00e9";
|
||||
badge.style.background = "var(--muted-bg, #ffffff10)";
|
||||
badge.style.color = "var(--text-muted, #888)";
|
||||
badge.style.border = "1px solid var(--border, #444)";
|
||||
}
|
||||
}
|
||||
if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none";
|
||||
}
|
||||
|
||||
async function loadAIKeys() {
|
||||
_ensureAIKeyUI();
|
||||
try {
|
||||
const data = await api("/api/config/ai-keys");
|
||||
for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) {
|
||||
const input = document.getElementById(inputId);
|
||||
const val = data[envName] || "";
|
||||
if (input) {
|
||||
input.placeholder = val || (inputId.includes("gemini") ? "AIza..." : inputId.includes("openrouter") ? "sk-or-..." : "sk-...");
|
||||
}
|
||||
_setAIKeyBadge(inputId, !!val);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
for (const [id, name] of Object.entries(AI_KEY_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");
|
||||
Object.keys(AI_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
loadAIKeys();
|
||||
} catch(e) { showToast("Erreur: " + e.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteAIKey(inputId) {
|
||||
const envName = AI_KEY_MAP[inputId];
|
||||
if (!envName) return;
|
||||
const provider = envName.replace("_API_KEY", "").replace(/_/g, " ");
|
||||
if (!confirm("Supprimer la cl\u00e9 API " + provider + " ?\nLe fournisseur ne sera plus disponible.")) return;
|
||||
try {
|
||||
await api("/api/config/ai-keys/" + envName, { method: "DELETE" });
|
||||
showToast("Cl\u00e9 " + provider + " supprim\u00e9e", "success");
|
||||
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 {
|
||||
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;
|
||||
for (const p of AI_PROVIDER_NAMES) {
|
||||
const inputId = "cfg-" + p + "-key";
|
||||
if (results[p] === "ok") {
|
||||
_setAIKeyBadge(inputId, true);
|
||||
} else if (results[p] && results[p].includes("non")) {
|
||||
_setAIKeyBadge(inputId, false);
|
||||
}
|
||||
}
|
||||
for (const p of AI_PROVIDER_NAMES) {
|
||||
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 +1476,258 @@ 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 AVAILABLE_MODES = mod.AVAILABLE_MODES;
|
||||
var applyTheme = mod.applyTheme;
|
||||
var getCurrentTheme = mod.getCurrentTheme;
|
||||
var getCurrentMode = mod.getCurrentMode;
|
||||
var exportAllThemes = mod.exportAllThemes;
|
||||
var importTheme = mod.importTheme;
|
||||
var deleteCustomTheme = mod.deleteCustomTheme;
|
||||
var loadCustomThemes = mod.loadCustomThemes;
|
||||
|
||||
var grid = document.getElementById('theme-grid');
|
||||
if (!grid) return;
|
||||
|
||||
// Mode label map (uses i18n t() when available, fallback to English)
|
||||
var modeLabels = {
|
||||
'dark': function() { return t('theme.dark'); },
|
||||
'light': function() { return t('theme.light'); },
|
||||
'high-contrast': function() { return t('theme.high_contrast'); },
|
||||
'sepia': function() { return t('theme.sepia'); }
|
||||
};
|
||||
|
||||
function renderCards() {
|
||||
var themeKeys = Object.keys(THEMES);
|
||||
var current = getCurrentTheme();
|
||||
var mode = getCurrentMode();
|
||||
grid.innerHTML = '';
|
||||
|
||||
themeKeys.forEach(function(key) {
|
||||
var th = THEMES[key];
|
||||
// Use dark mode for preview if mode not resolved yet
|
||||
var v = th.modes[mode] || th.modes.dark || th.modes.light;
|
||||
if (!v) return;
|
||||
var card = document.createElement('div');
|
||||
card.className = 'theme-card' + (key === current ? ' active' : '');
|
||||
var isCustom = th.desc === 'Custom theme';
|
||||
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">' + th.name + (isCustom ? ' <span class="theme-custom-badge">✦</span>' : '') + '</div>' +
|
||||
'<div class="theme-card-desc">' + th.desc + '</div>';
|
||||
card.addEventListener('click', function() {
|
||||
applyTheme(key, mode);
|
||||
renderCards();
|
||||
});
|
||||
// Right-click to delete custom themes
|
||||
if (isCustom) {
|
||||
card.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm(t('theme.delete_confirm'))) {
|
||||
deleteCustomTheme(key);
|
||||
renderCards();
|
||||
}
|
||||
});
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
// Mode toggle — 4 modes
|
||||
var toggle = document.createElement('div');
|
||||
toggle.className = 'theme-mode-toggle';
|
||||
AVAILABLE_MODES.forEach(function(m) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'theme-mode-btn' + (m === mode ? ' active' : '');
|
||||
btn.textContent = (modeLabels[m] || function() { return m; })();
|
||||
btn.addEventListener('click', function() {
|
||||
applyTheme(getCurrentTheme(), m);
|
||||
renderCards();
|
||||
});
|
||||
toggle.appendChild(btn);
|
||||
});
|
||||
grid.appendChild(toggle);
|
||||
|
||||
// Import/Export buttons
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'theme-actions';
|
||||
|
||||
var exportBtn = document.createElement('button');
|
||||
exportBtn.className = 'theme-action-btn';
|
||||
exportBtn.textContent = t('theme.export_all');
|
||||
exportBtn.addEventListener('click', function() {
|
||||
var json = exportAllThemes();
|
||||
var blob = new Blob([json], { type: 'application/json' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = 'obsigate-themes.json'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
var importBtn = document.createElement('button');
|
||||
importBtn.className = 'theme-action-btn';
|
||||
importBtn.textContent = t('theme.import');
|
||||
importBtn.addEventListener('click', function() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.addEventListener('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
var content = ev.target.result;
|
||||
// Support single theme or all-themes format
|
||||
try {
|
||||
var data = JSON.parse(content);
|
||||
if (data.themes) {
|
||||
// Multi-theme export: import each
|
||||
var count = 0;
|
||||
Object.keys(data.themes).forEach(function(k) {
|
||||
var res = importTheme(JSON.stringify(data.themes[k]));
|
||||
if (res.ok) count++;
|
||||
});
|
||||
alert(t('theme.import_success', { count: count }));
|
||||
} else {
|
||||
var res = importTheme(content);
|
||||
if (res.ok) {
|
||||
alert(t('theme.import_success', { count: 1 }));
|
||||
} else {
|
||||
alert(t('theme.import_error') + ': ' + res.error);
|
||||
}
|
||||
}
|
||||
} catch(ex) {
|
||||
alert(t('theme.import_error') + ': ' + ex.message);
|
||||
}
|
||||
renderCards();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
|
||||
actions.appendChild(exportBtn);
|
||||
actions.appendChild(importBtn);
|
||||
grid.appendChild(actions);
|
||||
}
|
||||
|
||||
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 + version from health endpoint (single source of truth)
|
||||
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 || '—';
|
||||
// Clean x.y.z version (not hardcoded) + git detail
|
||||
var versionEl = document.getElementById('about-version');
|
||||
if (versionEl && d.version) versionEl.textContent = d.version;
|
||||
var commitEl = document.getElementById('about-commit');
|
||||
if (commitEl) commitEl.textContent = d.git_describe || d.git_commit || '—';
|
||||
})
|
||||
.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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user