Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2153435dc | ||
|
|
f307ecb380 | ||
|
|
50b823e76d | ||
|
|
84bda90065 | ||
|
|
d6fdcef4d4 | ||
|
|
62023acd4d | ||
|
|
75ef597ece | ||
|
|
0cbbbf260a | ||
|
|
4f99d5ce61 | ||
|
|
f96c4ccd64 | ||
|
|
ffa07b7df4 | ||
|
|
e81dd9a7ec | ||
|
|
30f2df3004 | ||
|
|
ac16fc1f0e | ||
|
|
e4aca3b31e | ||
|
|
212753f311 | ||
|
|
ccfcb58ed1 | ||
|
|
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 |
@@ -15,6 +15,7 @@ docker-compose.yml
|
||||
.env.*
|
||||
*.log
|
||||
.obsigate-backup
|
||||
backend/VERSION
|
||||
.pytest_cache
|
||||
htmlcov
|
||||
.coverage
|
||||
|
||||
@@ -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
|
||||
+120
-3
@@ -38,6 +38,23 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: node tests/frontend/unit.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
node pane-manager.test.mjs
|
||||
node excalidraw-viewer.test.mjs
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing — installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
node pane-manager.test.mjs
|
||||
node excalidraw-viewer.test.mjs
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
test:
|
||||
needs: lint
|
||||
@@ -54,6 +71,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 +100,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 +112,107 @@ 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; }
|
||||
# Les fixtures sont copiées via `docker cp` en root → rendre le vault
|
||||
# inscriptible par l'utilisateur non-root de l'app (UID 1000), sinon
|
||||
# toute création/édition de fichier renvoie 403 « Vault is read-only ».
|
||||
docker exec -u 0 obsigate-e2e chmod -R a+rwX /vaults/TestVault /vaults/TestDir || true
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
+246
-1
@@ -5,6 +5,251 @@ 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
|
||||
> [Unreleased](#unreleased). La dernière version publiée est **2.2.1**.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Assistant AI contextuel (sidebar)** — l'assistant s'adapte désormais au contexte d'ouverture :
|
||||
- **Répertoire** (`directory`) : ouvert depuis le menu contextuel d'un dossier — le contexte est
|
||||
le dossier et son contenu (mode historique BooksLM).
|
||||
- **Documents** (`documents`) : ouvert via le bouton flottant quand un ou plusieurs documents
|
||||
sont ouverts — le contexte est la liste des fichiers ouverts (tous onglets/panneaux).
|
||||
- **Général** (`general`) : ouvert via le bouton flottant quand aucun document n'est ouvert —
|
||||
l'assistant répond sur l'application et peut proposer des actions (créer un fichier/dossier)
|
||||
via un bloc `obsigate-action` rendu en carte avec bouton « Appliquer » (aucune exécution
|
||||
automatique).
|
||||
- Le contexte est affiché dans le header de la sidebar (icône, titre, sous-titre) et les
|
||||
suggestions de questions s'adaptent au mode.
|
||||
- Backend : `/api/ai/bookslm/context` et `/chat` acceptent `mode` (`directory`/`documents`/
|
||||
`general`) et `context_files` ; `collect_files_context()`, `empty_context()` et
|
||||
`build_general_system_prompt()` dans `backend/bookslm.py` ; le mode documents dégrade
|
||||
proprement vers le mode général si aucun fichier n'est lisible.
|
||||
- Tests : `tests/test_bookslm.py` (+13 tests modes/documents/général) et
|
||||
`tests/frontend/ai.test.mjs` (+8 tests détection de contexte, clavier, actions, header).
|
||||
|
||||
- **#72 API publique documentée — OpenAPI 3.1 enrichie** — documentation interactive complète
|
||||
de l'API REST, générée automatiquement et enrichie.
|
||||
- Backend `backend/openapi_docs.py` : métadonnées de tags (18 catégories : System, Auth, Files,
|
||||
PDF, Vaults, Search, Bookmarks, Backups, Export, AI, BooksLM, Sharing, Webhooks, Conflicts,
|
||||
Admin, Plugins, Push, Frontend), assignation automatique des tags par préfixe de route
|
||||
(normalisation des tags des routeurs), schémas de sécurité (`bearerAuth`, `cookieAuth`),
|
||||
exemples de requête/réponse sur les endpoints clés, réponses d'erreur documentées
|
||||
(401/403/404/422/500), serveur et `externalDocs`.
|
||||
- `backend/schemas.py` : nouveaux `response_model` Pydantic pour les endpoints qui n'en avaient
|
||||
pas (recent, bookmarks, saved searches, backups, diff/restore/backlinks, pdf/info, replace,
|
||||
vaults, attachments, settings, config, AI keys/models, diagnostics, dashboard, webhooks,
|
||||
shares, conflicts, BooksLM context, AI status).
|
||||
- `backend/main.py` : `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) et
|
||||
override `app.openapi` pour post-traiter le schéma ; page de documentation `/api` (HTML
|
||||
autonome listant les endpoints groupés par catégorie, liens vers `/docs`, `/redoc`,
|
||||
`/openapi.json`).
|
||||
- Frontend : entrée « API » dans le menu d'options (i18n FR/EN) ouvrant Swagger UI.
|
||||
- Tests : `tests/test_openapi.py` (58 tests — version 3.1, tags, sécurité, erreurs, exemples,
|
||||
schémas de réponse, pages `/api`, `/docs`, `/redoc`, `/openapi.json`).
|
||||
|
||||
- **#61 Plugins système au complet** — système de plugins permettant d'étendre ObsiGate
|
||||
(renderers personnalisés, filtres de recherche, actions d'éditeur), sandboxé pour la sécurité.
|
||||
- Backend `backend/plugins.py` : validation du manifest `plugin.json` (name regex lowercase,
|
||||
semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle
|
||||
complet (install/uninstall/enable/disable via marker `.disabled`), validation ZIP à l'upload
|
||||
(anti path-traversal, max 100 fichiers, 500KB/fichier, 5MB upload), 9 endpoints `/api/plugins/*`
|
||||
(list/get/hooks/code/template + install/uninstall/enable/disable admin-gated), template API,
|
||||
scan au démarrage par vault (`get_plugin_registry().scan_vault`).
|
||||
- Frontend `frontend/js/plugins.js` : PluginManager (install/uninstall/enable/disable/view-code),
|
||||
sandbox d'exécution via Web Worker (code chargé par blob URL, protocole `postMessage` structuré,
|
||||
isolation DOM/localStorage/network selon permissions), hooks dispatch
|
||||
(`executeHook`, `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`,
|
||||
`onFileCreate`, `onFileDelete`, `onVaultMount`), UI Settings > Plugins.
|
||||
- Sécurité : manifest de permissions (`read_files`, `write_files`, `network_request`, …
|
||||
restreint), CSP stricte sans `importScripts`, limites de taille.
|
||||
- Tests : `tests/test_plugins.py` (44 tests — validation manifest, lifecycle manager,
|
||||
validation ZIP/directory, démarrage scan) + `tests/frontend/plugins.test.mjs`
|
||||
(21 tests JSDOM — protocole sandbox Worker, isolation DOM, lifecycle mirror).
|
||||
- CI : job lint ajoute `node plugins.test.mjs` aux tests JSDOM.
|
||||
- Docs : `docs/PLUGINS.md` (manifest, hooks, permissions, modèle de sécurité, API, guide).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **#78 Excalidraw — doc utilisateur finalisée** : `.excalidraw` / `.excalidraw.md` ajoutés aux
|
||||
formats supportés (README FR/EN), nouvelle section « Diagrammes Excalidraw » dans le guide
|
||||
intégré (i18n FR/EN) et note de compatibilité avec le plugin Obsidian Excalidraw.
|
||||
- **Roadmap** : #78 marqué terminé (B5 extraction texte, C8 menu contextuel, F3 E2E
|
||||
`tests/e2e/excalidraw.spec.js`, H1-H3 docs) ; F2 non retenu. Points optionnels #61
|
||||
(dépôt communautaire de plugins) et #74 (D2/E4/H2/I2) explicitement marqués non retenus.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Assistant AI — sidebar qui ne s'ouvrait plus & erreur de contexte** :
|
||||
- Le bouton flottant passait le **chemin d'un fichier** comme répertoire de contexte, d'où
|
||||
l'erreur « Aucun fichier markdown trouvé dans ce dossier » à l'ouverture sur un document.
|
||||
Le contexte est maintenant détecté automatiquement (documents ouverts → mode documents,
|
||||
sinon mode général).
|
||||
- Un panneau précédemment masqué (`obsigate-bookslm-hidden=true` persisté en localStorage)
|
||||
restait caché lors d'une ouverture explicite → l'ouverture force désormais l'affichage.
|
||||
- **Ergonomie du header** : le sélecteur fournisseur/modèle était sur la même ligne que les
|
||||
actions et masquait ces dernières ; il est déplacé sur une seconde ligne dédiée (toolbar)
|
||||
pour que « nouvelle conversation / export / plein écran / fermer » restent toujours visibles.
|
||||
- **Clavier de la boîte de chat** : `Entrée` envoie la requête, `Ctrl+Entrée` insère un saut
|
||||
de ligne (au lieu de l'inverse), avec un texte d'aide sous le champ.
|
||||
|
||||
- **Outil AI de l'UI — fiabilisation** :
|
||||
- **BooksLM (chat IA par répertoire)** : les requêtes `context`/`chat` n'envoyaient pas le
|
||||
jeton d'authentification (échec 401 quand l'auth est activée) et la réponse SSE était
|
||||
mal parsée (le backend émet `{token: ...}`, le front lisait `data.content`) → les réponses
|
||||
n'apparaissaient jamais. Le message utilisateur envoyé était le placeholder assistant vide
|
||||
au lieu du texte saisi. Corrigé : requêtes authentifiées via `AuthManager` (retry 401),
|
||||
parsing SSE `event:`/`data:` avec support `token`/`error`, historique construit avant
|
||||
l'ajout du message courant, barre de progression basée sur `max_total_chars`.
|
||||
- **AI Editor** : le raccourci `Ctrl/Cmd+J` (complétion inline) était re-lié à chaque
|
||||
ouverture de l'éditeur (N listeners → N requêtes) ; un seul listener global est désormais
|
||||
posé. Les libellés de la barre d'outils AI étaient codés en dur (FR/EN mélangés) → i18n
|
||||
complet. La réécriture personnalisée utilisait `window.prompt()` → remplacée par une modale
|
||||
accessible (Échap annule, Ctrl/Cmd+Entrée valide).
|
||||
|
||||
- **Recherche cassée (régression plugins)** : `onSearchFilter()` était appelée avec 2 arguments
|
||||
au lieu de 3 (`frontend/js/search.js`), renvoyait `undefined`, faisait échouer le rendu des
|
||||
résultats et basculait silencieusement sur la recherche hors-ligne (0 résultat). Corrigé →
|
||||
la recherche full-text et l'autocomplétion refonctionnent (20 tests E2E).
|
||||
- **`.excalidraw.md` corrompu à l'ouverture** : Excalidraw émet `onChange` au montage, ce qui
|
||||
déclenchait un auto-save 2 s plus tard réécrivant le fichier au format JSON brut et détruisant
|
||||
le format plugin Obsidian (`frontend/excalidraw-editor.html` + `frontend/js/excalidraw-viewer.js`).
|
||||
Les changements initiaux sont désormais ignorés et le format `frontmatter + compressed-json`
|
||||
est préservé à la sauvegarde.
|
||||
- **Splash / tests E2E** : `window.__OBSIGATE_BOOTED` était posé avant la fin de `init()` — le
|
||||
splash disparaissait trop tôt et les tests E2E interagissaient avant que les handlers soient
|
||||
liés. Le flag est maintenant posé après résolution de `init()` (`frontend/js/app.js`), et
|
||||
`goHome()`/`login()` attendent ce signal.
|
||||
- **Tests E2E Excalidraw** : réécriture de `tests/e2e/excalidraw.spec.js` avec les sélecteurs
|
||||
réels (modale `.obsigate-modal` + `#file-ext-select`/`#file-name-input`, menu contextuel,
|
||||
`canvas.first()` pour les deux couches canvas).
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-09-08
|
||||
|
||||
### 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.
|
||||
- **#75 Split View — matrice E2E complète (37 tests)** : 16 tests `split-view.spec.js` (raccourcis,
|
||||
drag & drop, persistance, navigation clavier) + 21 tests `split-view-matrix.spec.js` couvrant
|
||||
100% des cas d'ouverture/fermeture : boutons ⊞→/⊞↓, menu contextuel (diviser bas, fermer, fermer
|
||||
les autres/à droite/tout, fermer le panneau, flyout « Déplacer vers… »), palette (diviser, focus
|
||||
suivant/précédent, reset), fermeture par X/double-clic/molette/Ctrl+W, auto-collapse d'un panneau
|
||||
vidé, plafond 4 panneaux, drag-to-split bas, pas de doublon à la réouverture.
|
||||
- Fix `scripts/run-e2e-local.sh` : chemins Windows via `cygpath` (les env vars MSYS n'étaient pas
|
||||
converties → vaults vides, les 16 tests split-view échouaient en local alors qu'ils passaient
|
||||
en CI).
|
||||
|
||||
### 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 +454,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,13 @@ RUN apt-get update \
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Bake version: build.sh/CI pre-generate backend/VERSION; backend/VERSION est
|
||||
# exclu du .dockerignore pour que le build utilise TOUJOURS l'ARG ci-dessous
|
||||
# (jamais un fichier stale "1.8.0-...-dirty" du contexte build qui afficherait
|
||||
# une vieille version au lieu du tag courant).
|
||||
ARG VERSION=2.2.1
|
||||
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 \
|
||||
|
||||
+891
@@ -0,0 +1,891 @@
|
||||
# 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
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **🎨 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.
|
||||
|
||||
### Diagrammes Excalidraw
|
||||
|
||||
Les fichiers `.excalidraw` et `.excalidraw.md` s'ouvrent dans un éditeur visuel Excalidraw complet, intégré dans une iframe sandboxée — dessinez, modifiez et sauvegardez sans quitter ObsiGate.
|
||||
Les modifications sont sauvegardées automatiquement (2 s) ou avec `Ctrl+S` ; l'éditeur suit le thème clair/sombre.
|
||||
Le texte des éléments du diagramme est extrait à l'indexation : il est donc recherchable via la recherche full-text (`ext:excalidraw`).
|
||||
Les fichiers créés avec le **plugin Obsidian Excalidraw** (y compris le format `.excalidraw.md` compressé) sont compatibles.
|
||||
|
||||
### 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}",
|
||||
|
||||
+79
-16
@@ -1,55 +1,109 @@
|
||||
"""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,
|
||||
)
|
||||
from backend.schemas import AIStatusResponse
|
||||
|
||||
logger = logging.getLogger("obsigate.ai_routes")
|
||||
router = APIRouter(prefix="/api/ai", tags=["AI"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@router.get("/status", response_model=AIStatusResponse)
|
||||
async def api_status():
|
||||
"""Check if AI is configured and which providers are available.
|
||||
|
||||
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 +113,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 +126,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,408 @@
|
||||
"""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,
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": "directory",
|
||||
}
|
||||
|
||||
# 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 _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
|
||||
"""Read, redact and truncate a single file into a context entry."""
|
||||
suffix = target.suffix.lower()
|
||||
try:
|
||||
if suffix == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
content = extract_pdf_text(target)
|
||||
file_type = "pdf"
|
||||
else:
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
file_type = "markdown"
|
||||
except Exception as e:
|
||||
logger.warning(f"Cannot read {rel_path}: {e}")
|
||||
return None
|
||||
|
||||
content = redact_file_content(content, rel_path)
|
||||
if len(content) > BOOKSLM_MAX_FILE_CHARS:
|
||||
content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
|
||||
if len(content) > remaining:
|
||||
content = content[:remaining] + "\n\n[... tronqué]"
|
||||
|
||||
return {
|
||||
"path": rel_path,
|
||||
"title": target.stem.replace("-", " ").replace("_", " ").title(),
|
||||
"content": content,
|
||||
"type": file_type,
|
||||
}
|
||||
|
||||
|
||||
def collect_files_context(
|
||||
vault_path: Path,
|
||||
rel_paths: list[str],
|
||||
scope: str = "documents",
|
||||
) -> dict[str, Any]:
|
||||
"""Collect an explicit list of files (open documents) as AI context.
|
||||
|
||||
Unlike :func:`collect_directory_context`, this reads only the requested
|
||||
relative paths (markdown or PDF), never the whole directory. Paths outside
|
||||
the vault are silently ignored.
|
||||
|
||||
Args:
|
||||
vault_path: Absolute path to the vault root.
|
||||
rel_paths: Relative file paths within the vault (in display order).
|
||||
scope: Context label exposed to the UI ("documents").
|
||||
|
||||
Returns:
|
||||
Same shape as :func:`collect_directory_context`.
|
||||
"""
|
||||
vault_resolved = vault_path.resolve()
|
||||
collected: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
seen: set[str] = set()
|
||||
|
||||
for rel in rel_paths:
|
||||
if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
|
||||
break
|
||||
if not rel or rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
try:
|
||||
target = (vault_resolved / rel).resolve()
|
||||
target.relative_to(vault_resolved)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if not target.is_file():
|
||||
continue
|
||||
entry = _file_entry(target, rel, BOOKSLM_MAX_TOTAL_CHARS - total_chars)
|
||||
if entry is None:
|
||||
continue
|
||||
collected.append(entry)
|
||||
total_chars += len(entry["content"])
|
||||
|
||||
return {
|
||||
"files": collected,
|
||||
"total_chars": total_chars,
|
||||
"file_count": len(collected),
|
||||
"directory_tree": "",
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def empty_context(scope: str = "general") -> dict[str, Any]:
|
||||
"""Return an empty context payload (used by the General assistant)."""
|
||||
return {
|
||||
"files": [],
|
||||
"total_chars": 0,
|
||||
"file_count": 0,
|
||||
"directory_tree": "",
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
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], scope: str = "directory") -> str:
|
||||
"""Build a system prompt for document-scoped AI chat.
|
||||
|
||||
Args:
|
||||
context: Output of collect_directory_context()/collect_files_context().
|
||||
scope: "directory" (whole folder) or "documents" (open files).
|
||||
|
||||
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"
|
||||
|
||||
if scope == "documents":
|
||||
role = (
|
||||
"Tu es un assistant de recherche documentaire intégré à ObsiGate. "
|
||||
"Tu réponds UNIQUEMENT en te basant sur les documents ouverts par l'utilisateur 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."
|
||||
)
|
||||
context_label = "📄 Documents ouverts"
|
||||
else:
|
||||
role = (
|
||||
"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."
|
||||
)
|
||||
context_label = "📚 Contexte"
|
||||
|
||||
prompt = (
|
||||
f"{role}"
|
||||
f"\n\n{context_label} : {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
|
||||
|
||||
|
||||
GENERAL_SYSTEM_PROMPT = """Tu es l'assistant intégré d'ObsiGate, une application web auto-hébergée pour consulter, rechercher et éditer des vaults Obsidian (Markdown).
|
||||
|
||||
Tes deux rôles :
|
||||
1. **Aider sur l'application** : expliquer la navigation, la recherche (full-text, filtres `tag:`, `created:`, `path:`), l'éditeur (CodeMirror, autosave, raccourcis), les onglets et le split view, les sauvegardes et la restauration, le partage public, l'export (HTML/Markdown/ePub/PDF), Mermaid, Excalidraw, les plugins, les thèmes, le mode hors-ligne, le MFA, etc.
|
||||
2. **Proposer des actions concrètes** : créer un fichier ou un dossier dans un vault.
|
||||
|
||||
Quand l'utilisateur demande explicitement de créer un fichier, inclus EXACTEMENT un bloc de ce type dans ta réponse (et rien d'autre à l'intérieur du bloc) :
|
||||
|
||||
```obsigate-action
|
||||
{"action": "create_file", "vault": "<nom du vault>", "path": "<chemin/relatif.md>", "content": "<contenu markdown>"}
|
||||
```
|
||||
|
||||
Pour créer un dossier :
|
||||
|
||||
```obsigate-action
|
||||
{"action": "create_directory", "vault": "<nom du vault>", "path": "<chemin/relatif>"}
|
||||
```
|
||||
|
||||
Règles :
|
||||
- Ne propose une action que si l'utilisateur la demande explicitement.
|
||||
- Explique en une phrase ce que fait l'action avant le bloc.
|
||||
- Utilise un chemin relatif se terminant par `.md` pour un fichier.
|
||||
- N'invente jamais un nom de vault : utilise l'un des vaults disponibles listés ci-dessous.
|
||||
- Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown).
|
||||
"""
|
||||
|
||||
|
||||
def build_general_system_prompt(vaults: list[str] | None = None) -> str:
|
||||
"""System prompt for the General assistant (app help + actions)."""
|
||||
prompt = GENERAL_SYSTEM_PROMPT
|
||||
if vaults:
|
||||
prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n"
|
||||
else:
|
||||
prompt += "\nAucun vault n'est actuellement configuré.\n"
|
||||
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,228 @@
|
||||
"""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_general_system_prompt,
|
||||
build_system_prompt,
|
||||
collect_directory_context,
|
||||
collect_files_context,
|
||||
empty_context,
|
||||
)
|
||||
from backend.indexer import get_vault_data, index
|
||||
from backend.schemas import BooksLMContextResponse
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm_routes")
|
||||
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
|
||||
|
||||
VALID_MODES = {"directory", "documents", "general"}
|
||||
|
||||
|
||||
# ── Request models ──
|
||||
|
||||
|
||||
class BooksLMContextRequest(BaseModel):
|
||||
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
|
||||
directory: str = Field(default="", description="Relative directory path within the vault")
|
||||
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
|
||||
context_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Relative file paths to use as context (documents mode)",
|
||||
)
|
||||
|
||||
|
||||
class BooksLMChatRequest(BaseModel):
|
||||
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
|
||||
directory: str = Field(default="", description="Relative directory path within the vault")
|
||||
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
|
||||
context_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Relative file paths to use as context (documents mode)",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mode(mode: str | None) -> str:
|
||||
mode = (mode or "directory").lower()
|
||||
return mode if mode in VALID_MODES else "directory"
|
||||
|
||||
|
||||
def _resolve_vault_path(vault: str | None, current_user):
|
||||
"""Validate vault access and return (vault_name, vault_path)."""
|
||||
if not vault:
|
||||
raise HTTPException(status_code=400, detail="Champ 'vault' requis pour ce contexte")
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
vault_data = get_vault_data(vault)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault}' not found")
|
||||
return vault, Path(vault_data["path"])
|
||||
|
||||
|
||||
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
|
||||
"""Collect the context payload for the requested mode."""
|
||||
if mode == "general":
|
||||
return empty_context("general")
|
||||
if mode == "documents":
|
||||
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
|
||||
# No open document could be read → degrade gracefully to General.
|
||||
return ctx if ctx["file_count"] else empty_context("general")
|
||||
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
|
||||
@router.post("/context", response_model=BooksLMContextResponse)
|
||||
async def api_bookslm_context(
|
||||
req: BooksLMContextRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Collect context for the AI assistant.
|
||||
|
||||
Three modes are supported:
|
||||
|
||||
* ``directory`` — every supported file under a vault directory;
|
||||
* ``documents`` — only the explicitly listed open documents;
|
||||
* ``general`` — no document context (assistant for the app itself).
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
if mode == "general":
|
||||
return empty_context("general")
|
||||
|
||||
_resolve_vault_path(req.vault, current_user)
|
||||
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
|
||||
return _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chat",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "SSE token stream"}},
|
||||
)
|
||||
async def api_bookslm_chat(
|
||||
req: BooksLMChatRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Chat with the AI assistant.
|
||||
|
||||
Builds a system prompt from the selected context (directory, open
|
||||
documents or general app knowledge), then streams the provider's answer
|
||||
as Server-Sent Events.
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
|
||||
vault_path: Path | None = None
|
||||
if mode != "general":
|
||||
_resolve_vault_path(req.vault, current_user)
|
||||
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
|
||||
|
||||
# Collect context
|
||||
context = _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
effective_mode = context.get("scope", mode)
|
||||
|
||||
# Build system prompt
|
||||
if effective_mode == "general":
|
||||
system_prompt = build_general_system_prompt(list(index.keys()))
|
||||
elif effective_mode == "documents":
|
||||
system_prompt = build_system_prompt(context, scope="documents")
|
||||
else:
|
||||
if context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
system_prompt = build_system_prompt(context, scope="directory")
|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
+273
-65
@@ -1,21 +1,23 @@
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
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()
|
||||
@@ -27,6 +29,9 @@ _async_index_lock: asyncio.Lock | None = None # initialized lazily
|
||||
# (e.g. the inverted index in search.py) can detect staleness.
|
||||
_index_generation: int = 0
|
||||
|
||||
# Timestamp of last full index rebuild (ISO format, empty if never built)
|
||||
_last_full_index_ts: str = ""
|
||||
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
@@ -41,13 +46,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 +62,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 +96,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 +142,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 +165,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
|
||||
@@ -194,6 +201,178 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
|
||||
return str(title)
|
||||
|
||||
|
||||
_EXCALIDRAW_TEXT_FIELDS = ("text", "originalText", "label", "title")
|
||||
|
||||
|
||||
def extract_excalidraw_text_from_elements(elements: list[dict[str, Any]]) -> str:
|
||||
"""Concatenate all user-visible text from an Excalidraw elements list.
|
||||
|
||||
Iterates diagram elements and collects the text-bearing fields
|
||||
(``text`` for text elements, ``label``/``title`` for bound shapes,
|
||||
``originalText`` as the stable source of a text element). Non-text
|
||||
elements and geometry-only shapes contribute nothing. This gives the
|
||||
TF-IDF search engine human-readable content instead of raw JSON.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
for el in elements:
|
||||
if not isinstance(el, dict):
|
||||
continue
|
||||
collected = set()
|
||||
for field in _EXCALIDRAW_TEXT_FIELDS:
|
||||
val = el.get(field)
|
||||
if isinstance(val, str) and val.strip():
|
||||
collected.add(val.strip())
|
||||
if collected:
|
||||
chunks.append(" ".join(sorted(collected)))
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
_EXCALIDRAW_B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
|
||||
|
||||
def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
|
||||
"""Decompress the Obsidian ``compressed-json`` block of a .excalidraw.md file.
|
||||
|
||||
The Obsidian Excalidraw plugin stores scene data as an ``lz-string``
|
||||
``compressToBase64`` payload (LZ-based compression, alphabet = standard
|
||||
base64). We port the reference ``lz-string`` ``_decompress`` algorithm
|
||||
(bitsPerChar=6/base64 key, resetValue=32) in pure Python so indexation
|
||||
can recover the diagram text without the JS client. The port is validated
|
||||
against multiple JS-truth fixtures in ``test_excalidraw.py`` including
|
||||
accented text and edge cases. Returns the parsed scene dict, or None if
|
||||
the payload is not valid base64-lz or does not parse as JSON.
|
||||
|
||||
Only ``decompress`` is needed for indexation (read side); compression
|
||||
stays on the JS client.
|
||||
"""
|
||||
if not compressed:
|
||||
return None
|
||||
|
||||
length = len(compressed)
|
||||
|
||||
def _get_char_value(index: int) -> int:
|
||||
# Mirror JS: input.charAt(index), 0-indexed.
|
||||
ch = compressed[index] if 0 <= index < length else "="
|
||||
pos = _EXCALIDRAW_B64_ALPHABET.find(ch)
|
||||
return 0 if pos == -1 else pos
|
||||
|
||||
# ── Build a bit reader over the base64 characters ────────────────────
|
||||
# JS decompressFromBase64 calls _decompress(length, 32, getNextValue).
|
||||
# state = current 6-bit value + position mask + next char index.
|
||||
value = _get_char_value(0)
|
||||
position = 32 # resetValue
|
||||
index = 1
|
||||
|
||||
def _read_bit() -> int:
|
||||
nonlocal value, position, index
|
||||
resb = value & position
|
||||
position >>= 1
|
||||
if position == 0:
|
||||
position = 32
|
||||
value = _get_char_value(index)
|
||||
index += 1
|
||||
return 1 if resb > 0 else 0
|
||||
|
||||
def _read_bits(n: int) -> int:
|
||||
out = 0
|
||||
for i in range(n):
|
||||
out |= _read_bit() << i
|
||||
return out
|
||||
|
||||
# ── Decompressor state ────────────────────────────────────────────────
|
||||
dictionary: list[Any] = [0, 1, 2] # values 0,1,2 as in JS
|
||||
enlarge_in = 4
|
||||
dict_size = 4
|
||||
num_bits = 3
|
||||
|
||||
first = _read_bits(2)
|
||||
if first == 0:
|
||||
c = chr(_read_bits(8))
|
||||
elif first == 1:
|
||||
c = chr(_read_bits(16))
|
||||
elif first == 2:
|
||||
return None # empty stream
|
||||
else:
|
||||
return None
|
||||
|
||||
dictionary.append(c)
|
||||
w: str = c
|
||||
result = [c]
|
||||
|
||||
while True:
|
||||
if index > length:
|
||||
return None
|
||||
|
||||
c = _read_bits(num_bits)
|
||||
if c == 0:
|
||||
dictionary.append(chr(_read_bits(8)))
|
||||
dict_size += 1
|
||||
c = dict_size - 1
|
||||
enlarge_in -= 1
|
||||
elif c == 1:
|
||||
dictionary.append(chr(_read_bits(16)))
|
||||
dict_size += 1
|
||||
c = dict_size - 1
|
||||
enlarge_in -= 1
|
||||
elif c == 2:
|
||||
break # end of stream
|
||||
|
||||
if enlarge_in == 0:
|
||||
enlarge_in = 1 << num_bits
|
||||
num_bits += 1
|
||||
|
||||
if c < len(dictionary) and dictionary[c]:
|
||||
entry = dictionary[c]
|
||||
elif c == dict_size:
|
||||
entry = w + w[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
result.append(entry)
|
||||
dictionary.append(w + entry[0])
|
||||
dict_size += 1
|
||||
enlarge_in -= 1
|
||||
|
||||
w = entry
|
||||
if enlarge_in == 0:
|
||||
enlarge_in = 1 << num_bits
|
||||
num_bits += 1
|
||||
|
||||
text = "".join(result)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def extract_excalidraw_indexable(raw: str) -> str:
|
||||
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
|
||||
|
||||
Supports both the pure JSON format (``type: "excalidraw"``) and the
|
||||
Obsidian ``compressed-json`` block. Falls back to ``""`` when neither
|
||||
format can be parsed so the file is still indexed (title only).
|
||||
"""
|
||||
# .excalidraw.md — Obsidian plugin embeds a compressed-json block
|
||||
if "excalidraw-plugin:" in raw:
|
||||
m = re.search(r"```compressed-json\n(.*?)\n```", raw, re.DOTALL)
|
||||
if m:
|
||||
parsed = _decompress_excalidraw(m.group(1).strip())
|
||||
if parsed:
|
||||
return extract_excalidraw_text_from_elements(parsed.get("elements") or [])
|
||||
return ""
|
||||
# Pure .excalidraw JSON
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
return ""
|
||||
if not isinstance(data, dict) or data.get("type") != "excalidraw":
|
||||
return ""
|
||||
return extract_excalidraw_text_from_elements(data.get("elements") or [])
|
||||
|
||||
|
||||
def parse_markdown_file(raw: str) -> frontmatter.Post:
|
||||
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid.
|
||||
|
||||
@@ -215,10 +394,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 +415,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 +460,38 @@ 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] = []
|
||||
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
tags: list[str] = []
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
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 +550,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()
|
||||
@@ -402,6 +593,10 @@ async def build_index(progress_callback=None) -> None:
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# Record timestamp of full index rebuild
|
||||
global _last_full_index_ts
|
||||
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Build attachment index
|
||||
from backend.attachment_indexer import build_attachment_index
|
||||
await build_attachment_index(vault_config)
|
||||
@@ -413,7 +608,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 +621,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 +653,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 +684,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 +702,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 +732,32 @@ 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()
|
||||
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
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 +777,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 +852,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 +897,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 +944,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 +976,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 +1025,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 +1048,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 +1073,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 +1145,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 +1163,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
|
||||
|
||||
+1477
-183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
||||
"""OpenAPI 3.1 documentation helpers for ObsiGate (#72).
|
||||
|
||||
This module keeps the API-documentation concerns out of ``backend/main.py``:
|
||||
|
||||
* :data:`TAGS_METADATA` — human descriptions and ordering for every tag.
|
||||
* :func:`enrich_openapi_schema` — post-processes the schema generated by
|
||||
FastAPI to assign tags, add servers/security/examples and document common
|
||||
error responses.
|
||||
* :func:`render_api_landing` — a self-contained ``/api`` landing page that
|
||||
reads ``/openapi.json`` at runtime and groups endpoints by tag, with direct
|
||||
links to the Swagger UI (``/docs``) and ReDoc (``/redoc``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "System", "description": "Health checks, runtime configuration, diagnostics, dashboard stats and the SSE event stream."},
|
||||
{"name": "Auth", "description": "Authentication, sessions, user profile, MFA (TOTP/WebAuthn) and API keys."},
|
||||
{"name": "Files", "description": "Browse, read, create, rename, move, delete and download vault files."},
|
||||
{"name": "PDF", "description": "PDF metadata and byte-range streaming for inline viewing."},
|
||||
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
|
||||
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
||||
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
||||
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
||||
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
||||
{"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."},
|
||||
{"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."},
|
||||
{"name": "Sharing", "description": "Create and manage public read-only share links for documents."},
|
||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||
{"name": "Frontend", "description": "Static assets and SPA fallback routes."},
|
||||
]
|
||||
|
||||
API_DESCRIPTION = """
|
||||
**ObsiGate** exposes a REST API covering the entire application: vaults, files,
|
||||
full-text search, backups, exports, AI actions, sharing and administration.
|
||||
|
||||
### Authentication
|
||||
When authentication is enabled, send a bearer token obtained from
|
||||
`POST /api/auth/login`:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
The same token is also accepted as an HTTP-only cookie, so browser clients can
|
||||
simply use `credentials: "include"`.
|
||||
|
||||
### Interactive documentation
|
||||
* **Swagger UI** — [/docs](/docs): try requests directly from the browser.
|
||||
* **ReDoc** — [/redoc](/redoc): clean, reading-oriented reference.
|
||||
* **OpenAPI JSON** — [/openapi.json](/openapi.json): machine-readable schema.
|
||||
|
||||
### Errors
|
||||
Errors use the standard FastAPI envelope `{"detail": "..."}` with the
|
||||
appropriate HTTP status (`400`, `401`, `403`, `404`, `409`, `422`, `500`).
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automatic tag assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Rules are evaluated in order; the first match wins. Keep the most specific
|
||||
# prefixes first (e.g. BooksLM before AI, config/ai-* before config).
|
||||
_TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/auth"), "Auth"),
|
||||
(re.compile(r"^/api/admin"), "Admin"),
|
||||
(re.compile(r"^/api/plugins"), "Plugins"),
|
||||
(re.compile(r"^/api/push"), "Push"),
|
||||
(re.compile(r"^/api/ai/bookslm"), "BooksLM"),
|
||||
(re.compile(r"^/api/ai"), "AI"),
|
||||
(re.compile(r"^/api/config/ai-"), "AI"),
|
||||
(re.compile(r"^/api/share"), "Sharing"),
|
||||
(re.compile(r"^/api/shares"), "Sharing"),
|
||||
(re.compile(r"^/s/"), "Sharing"),
|
||||
(re.compile(r"^/api/webhooks"), "Webhooks"),
|
||||
(re.compile(r"^/api/conflicts"), "Conflicts"),
|
||||
(re.compile(r"^/api/backups"), "Backups"),
|
||||
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
|
||||
(re.compile(r"^/api/export"), "Export"),
|
||||
(re.compile(r"^/api/file/[^/]+/pdf"), "PDF"),
|
||||
(re.compile(r"^/api/search"), "Search"),
|
||||
(re.compile(r"^/api/tags"), "Search"),
|
||||
(re.compile(r"^/api/tree-search"), "Search"),
|
||||
(re.compile(r"^/api/suggest"), "Search"),
|
||||
(re.compile(r"^/api/graph"), "Search"),
|
||||
(re.compile(r"^/api/recent"), "Bookmarks"),
|
||||
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
||||
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
||||
(re.compile(r"^/api/vaults"), "Vaults"),
|
||||
(re.compile(r"^/api/vault/"), "Vaults"),
|
||||
(re.compile(r"^/api/index/reload"), "Vaults"),
|
||||
(re.compile(r"^/api/attachments"), "Vaults"),
|
||||
(re.compile(r"^/api/browse"), "Files"),
|
||||
(re.compile(r"^/api/file"), "Files"),
|
||||
(re.compile(r"^/api/directory"), "Files"),
|
||||
(re.compile(r"^/api/move"), "Files"),
|
||||
(re.compile(r"^/api/image"), "Files"),
|
||||
(re.compile(r"^/api/health"), "System"),
|
||||
(re.compile(r"^/api/config"), "System"),
|
||||
(re.compile(r"^/api/diagnostics"), "System"),
|
||||
(re.compile(r"^/api/dashboard"), "System"),
|
||||
(re.compile(r"^/api/events"), "System"),
|
||||
]
|
||||
|
||||
|
||||
def tag_for_path(path: str) -> str:
|
||||
"""Return the documentation tag for an API path (``Frontend`` as fallback)."""
|
||||
for pattern, tag in _TAG_RULES:
|
||||
if pattern.match(path):
|
||||
return tag
|
||||
return "Frontend"
|
||||
|
||||
|
||||
# Normalise tags declared by individual routers (``tags=["auth"]``) to the
|
||||
# canonical, documented tag names.
|
||||
_TAG_ALIASES: dict[str, str] = {
|
||||
"auth": "Auth",
|
||||
"admin": "Admin",
|
||||
"plugins": "Plugins",
|
||||
"push": "Push",
|
||||
"files": "Files",
|
||||
"vaults": "Vaults",
|
||||
"search": "Search",
|
||||
"backups": "Backups",
|
||||
"export": "Export",
|
||||
"sharing": "Sharing",
|
||||
"webhooks": "Webhooks",
|
||||
"conflicts": "Conflicts",
|
||||
"system": "System",
|
||||
"frontend": "Frontend",
|
||||
"ai": "AI",
|
||||
"bookslm": "BooksLM",
|
||||
"pdf": "PDF",
|
||||
"bookmarks": "Bookmarks",
|
||||
}
|
||||
|
||||
_CANONICAL_TAGS = {tag["name"] for tag in TAGS_METADATA}
|
||||
|
||||
|
||||
def canonical_tag(name: str) -> str:
|
||||
"""Map a router-declared tag to its documented name (unchanged if unknown)."""
|
||||
mapped = _TAG_ALIASES.get(name.lower(), name)
|
||||
return mapped if mapped in _CANONICAL_TAGS else name
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request/response examples injected into the schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
("post", "/api/file/{vault_name}"): {
|
||||
"request": {"path": "notes/Nouvelle note.md", "content": "# Titre\n\nContenu"},
|
||||
"response": {"success": True, "path": "notes/Nouvelle note.md"},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/save"): {
|
||||
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("post", "/api/search/replace"): {
|
||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
||||
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||
},
|
||||
("post", "/api/ai/improve"): {
|
||||
"request": {"text": "ce texte est bof", "provider": "deepseek"},
|
||||
"response": {"result": "Ce texte mérite d'être amélioré.", "provider": "deepseek"},
|
||||
},
|
||||
("post", "/api/ai/bookslm/chat"): {
|
||||
"request": {"vault": "TestVault", "directory": "projets", "message": "Résume ce dossier", "conversation_history": []},
|
||||
"response": {"token": "Voici un résumé…", "provider": "deepseek", "model": "deepseek-chat"},
|
||||
},
|
||||
("post", "/api/share/{vault_name}"): {
|
||||
"request": {"path": "notes/Accueil.md", "expires_in_hours": 168},
|
||||
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
|
||||
},
|
||||
("post", "/api/webhooks"): {
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
|
||||
},
|
||||
}
|
||||
|
||||
# Common error responses documented on every operation.
|
||||
_COMMON_ERRORS: dict[str, dict[str, Any]] = {
|
||||
"401": {
|
||||
"description": "Authentication required or token expired",
|
||||
"content": {"application/json": {"example": {"detail": "Not authenticated"}}},
|
||||
},
|
||||
"403": {
|
||||
"description": "Access to the vault is denied",
|
||||
"content": {"application/json": {"example": {"detail": "Accès refusé à la vault 'Notes'"}}},
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {"application/json": {"example": {"detail": "File not found: notes/x.md"}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation error",
|
||||
"content": {"application/json": {"example": {"detail": [{"loc": ["query", "path"], "msg": "field required", "type": "value_error.missing"}]}}},
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {"application/json": {"example": {"detail": "Internal server error"}}},
|
||||
},
|
||||
}
|
||||
|
||||
# Paths that return a binary/streaming body — never add a JSON error example
|
||||
# to their 200 response, and skip the 500 JSON example for streams.
|
||||
_BINARY_MEDIA = ("application/pdf", "application/octet-stream", "image/", "text/event-stream")
|
||||
|
||||
|
||||
def _is_binary_operation(operation: dict[str, Any]) -> bool:
|
||||
content = operation.get("responses", {}).get("200", {}).get("content", {})
|
||||
return any(media.startswith(_BINARY_MEDIA) for media in content)
|
||||
|
||||
|
||||
def enrich_openapi_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Enrich a FastAPI-generated OpenAPI schema in place and return it.
|
||||
|
||||
The transformation is idempotent: calling it twice yields the same result.
|
||||
"""
|
||||
schema.setdefault("openapi", "3.1.0")
|
||||
info = schema.setdefault("info", {})
|
||||
info.setdefault("description", API_DESCRIPTION.strip())
|
||||
info.setdefault("contact", {"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"})
|
||||
info.setdefault("license", {"name": "MIT"})
|
||||
|
||||
schema.setdefault("servers", [{"url": "/", "description": "Current ObsiGate instance"}])
|
||||
schema["externalDocs"] = {
|
||||
"description": "ObsiGate source repository",
|
||||
"url": "https://git.dracodev.net/Projets/ObsiGate",
|
||||
}
|
||||
schema["tags"] = TAGS_METADATA
|
||||
|
||||
components = schema.setdefault("components", {})
|
||||
security_schemes = components.setdefault("securitySchemes", {})
|
||||
security_schemes.setdefault("bearerAuth", {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT access token issued by POST /api/auth/login.",
|
||||
})
|
||||
security_schemes.setdefault("cookieAuth", {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "obsigate_token",
|
||||
"description": "HTTP-only session cookie (browser clients).",
|
||||
})
|
||||
components.setdefault("schemas", {}).setdefault("ErrorResponse", {
|
||||
"type": "object",
|
||||
"properties": {"detail": {"title": "Detail", "description": "Error message"}},
|
||||
})
|
||||
|
||||
for path, operations in schema.get("paths", {}).items():
|
||||
default_tag = tag_for_path(path)
|
||||
for method, operation in operations.items():
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
if operation.get("tags"):
|
||||
operation["tags"] = [canonical_tag(t) for t in operation["tags"]]
|
||||
else:
|
||||
operation["tags"] = [default_tag]
|
||||
|
||||
# Document common error responses without overriding explicit ones.
|
||||
responses = operation.setdefault("responses", {})
|
||||
binary = _is_binary_operation(operation)
|
||||
for code, spec in _COMMON_ERRORS.items():
|
||||
if code == "500" and binary:
|
||||
continue
|
||||
responses.setdefault(code, spec)
|
||||
|
||||
# Inject request/response examples for key endpoints.
|
||||
example = _ENDPOINT_EXAMPLES.get((method, path))
|
||||
if example:
|
||||
if "request" in example:
|
||||
body = operation.setdefault("requestBody", {})
|
||||
content = body.setdefault("content", {}).setdefault("application/json", {})
|
||||
content.setdefault("example", example["request"])
|
||||
if "response" in example:
|
||||
ok = responses.setdefault("200", {})
|
||||
content = ok.setdefault("content", {}).setdefault("application/json", {})
|
||||
content.setdefault("example", example["response"])
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api landing page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_METHOD_COLORS = {
|
||||
"get": "#2563eb",
|
||||
"post": "#16a34a",
|
||||
"put": "#d97706",
|
||||
"patch": "#7c3aed",
|
||||
"delete": "#dc2626",
|
||||
"head": "#64748b",
|
||||
"options": "#64748b",
|
||||
}
|
||||
|
||||
|
||||
def render_api_landing(version: str = "") -> str:
|
||||
"""Return the self-contained HTML for the ``/api`` documentation page."""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ObsiGate API{(' — v' + version) if version else ''}</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
||||
--text: #e2e8f0; --muted: #94a3b8; --accent: #60a5fa; --accent-2: #34d399;
|
||||
}}
|
||||
html[data-theme="light"] {{
|
||||
--bg: #f8fafc; --surface: #ffffff; --border: #e2e8f0;
|
||||
--text: #0f172a; --muted: #64748b; --accent: #2563eb; --accent-2: #059669;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.wrap {{ max-width: 1080px; margin: 0 auto; padding: 40px 24px 80px; }}
|
||||
header h1 {{ margin: 0 0 4px; font-size: 1.9rem; }}
|
||||
header p {{ margin: 0; color: var(--muted); }}
|
||||
.actions {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0 32px; }}
|
||||
.actions a {{
|
||||
text-decoration: none; color: var(--text); background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px;
|
||||
font-weight: 600; font-size: .9rem; transition: border-color .15s, transform .15s;
|
||||
}}
|
||||
.actions a:hover {{ border-color: var(--accent); transform: translateY(-1px); }}
|
||||
.actions a.primary {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
|
||||
.meta {{ color: var(--muted); font-size: .85rem; margin-bottom: 24px; }}
|
||||
.tag {{ margin: 28px 0; }}
|
||||
.tag > h2 {{
|
||||
font-size: 1.1rem; margin: 0 0 2px; display: flex; align-items: center; gap: 8px;
|
||||
}}
|
||||
.tag > p {{ margin: 0 0 12px; color: var(--muted); font-size: .85rem; }}
|
||||
.ops {{ display: flex; flex-direction: column; gap: 6px; }}
|
||||
.op {{
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
text-decoration: none; color: var(--text); font-size: .88rem;
|
||||
}}
|
||||
.op:hover {{ border-color: var(--accent); }}
|
||||
.method {{
|
||||
flex: 0 0 62px; text-align: center; font-weight: 700; font-size: .68rem;
|
||||
text-transform: uppercase; letter-spacing: .04em; color: #fff; border-radius: 5px; padding: 3px 0;
|
||||
}}
|
||||
.op code {{ color: var(--accent-2); font-size: .85rem; }}
|
||||
.op .summary {{ color: var(--muted); margin-left: auto; text-align: right; }}
|
||||
.loading {{ color: var(--muted); }}
|
||||
footer {{ margin-top: 48px; color: var(--muted); font-size: .8rem; }}
|
||||
@media (max-width: 640px) {{ .op .summary {{ display: none; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>ObsiGate API</h1>
|
||||
<p>Interactive reference for the ObsiGate REST API (OpenAPI 3.1).</p>
|
||||
</header>
|
||||
<div class="actions">
|
||||
<a class="primary" href="/docs">Swagger UI — try it</a>
|
||||
<a href="/redoc">ReDoc — reference</a>
|
||||
<a href="/openapi.json">OpenAPI JSON</a>
|
||||
<a href="/">← Back to ObsiGate</a>
|
||||
</div>
|
||||
<div class="meta" id="meta">Loading specification…</div>
|
||||
<div id="tags"><p class="loading">Loading endpoints…</p></div>
|
||||
<footer>Generated from the live OpenAPI schema. Authentication: <code>Authorization: Bearer <token></code>.</footer>
|
||||
</div>
|
||||
<script>
|
||||
(function () {{
|
||||
var COLORS = {_js_colors()};
|
||||
function el(tag, cls, text) {{
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}}
|
||||
fetch('/openapi.json', {{ credentials: 'include' }})
|
||||
.then(function (r) {{ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }})
|
||||
.then(function (spec) {{
|
||||
var info = spec.info || {{}};
|
||||
var tagMeta = {{}};
|
||||
(spec.tags || []).forEach(function (t) {{ tagMeta[t.name] = t.description || ''; }});
|
||||
var groups = {{}};
|
||||
var paths = spec.paths || {{}};
|
||||
var opCount = 0;
|
||||
Object.keys(paths).forEach(function (path) {{
|
||||
var ops = paths[path];
|
||||
Object.keys(ops).forEach(function (method) {{
|
||||
if (['get','post','put','patch','delete','head','options'].indexOf(method) === -1) return;
|
||||
var op = ops[method];
|
||||
if (op.deprecated && false) return;
|
||||
opCount++;
|
||||
var tag = (op.tags && op.tags[0]) || 'Other';
|
||||
(groups[tag] = groups[tag] || []).push({{ path: path, method: method, summary: op.summary || '' }});
|
||||
}});
|
||||
}});
|
||||
document.getElementById('meta').textContent =
|
||||
(info.title || 'ObsiGate') + (info.version ? ' v' + info.version : '') +
|
||||
' — OpenAPI ' + (spec.openapi || '3.1') + ' — ' + opCount + ' operations';
|
||||
var host = document.getElementById('tags');
|
||||
host.innerHTML = '';
|
||||
Object.keys(groups).forEach(function (tag) {{
|
||||
var section = el('section', 'tag');
|
||||
section.appendChild(el('h2', null, tag));
|
||||
if (tagMeta[tag]) section.appendChild(el('p', null, tagMeta[tag]));
|
||||
var ops = el('div', 'ops');
|
||||
groups[tag].forEach(function (o) {{
|
||||
var a = el('a', 'op');
|
||||
a.href = '/docs#/' + encodeURIComponent(tag) + '/' + encodeURIComponent(o.path);
|
||||
var m = el('span', 'method', o.method);
|
||||
m.style.background = COLORS[o.method] || '#64748b';
|
||||
a.appendChild(m);
|
||||
a.appendChild(el('code', null, o.path));
|
||||
if (o.summary) a.appendChild(el('span', 'summary', o.summary));
|
||||
ops.appendChild(a);
|
||||
}});
|
||||
section.appendChild(ops);
|
||||
host.appendChild(section);
|
||||
}});
|
||||
}})
|
||||
.catch(function (e) {{
|
||||
document.getElementById('meta').textContent = 'Failed to load OpenAPI schema: ' + e.message;
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _js_colors() -> str:
|
||||
"""Serialise the method→colour map for the landing page script."""
|
||||
import json
|
||||
|
||||
return json.dumps(_METHOD_COLORS)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Plugin system for ObsiGate — backend API and manifest validation.
|
||||
|
||||
Plugins extend ObsiGate with custom renderers, search filters, and editor
|
||||
actions. They run sandboxed in a Web Worker on the frontend; the backend
|
||||
handles manifest validation, storage, and lifecycle (install / uninstall /
|
||||
enable / disable), all scoped to a single vault.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time as _time
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
logger = logging.getLogger("obsigate.plugins")
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────
|
||||
PLUGINS_DIR_NAME = ".obsigate-plugins"
|
||||
MANIFEST_FILENAME = "plugin.json"
|
||||
MAX_PLUGIN_SIZE = 500_000 # 500 KB per plugin file
|
||||
MAX_PLUGINS_PER_VAULT = 50
|
||||
MAX_PLUGIN_FILES = 100 # max files in an installed plugin zip
|
||||
|
||||
ALLOWED_HOOKS = frozenset([
|
||||
"onFileRender",
|
||||
"onSearchFilter",
|
||||
"onEditorAction",
|
||||
"onSidebarItem",
|
||||
"onFileCreate",
|
||||
"onFileDelete",
|
||||
"onVaultMount",
|
||||
])
|
||||
|
||||
ALLOWED_PERMISSIONS = frozenset([
|
||||
"read_files",
|
||||
"write_files",
|
||||
"read_vault_metadata",
|
||||
"network_request",
|
||||
"ui_notify",
|
||||
"access_clipboard",
|
||||
])
|
||||
|
||||
|
||||
# ── Manifest ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginManifest:
|
||||
"""Validated plugin manifest from plugin.json."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
author: str
|
||||
main: str
|
||||
hooks: dict[str, str] = field(default_factory=dict)
|
||||
permissions: list[str] = field(default_factory=list)
|
||||
min_obsigate_version: str = "2.1.0"
|
||||
homepage: str | None = None
|
||||
repository: str | None = None
|
||||
license: str = "MIT"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PluginManifest":
|
||||
"""Validate and build a manifest from a raw dict (JSON object)."""
|
||||
required = ["name", "version", "description", "author", "main"]
|
||||
for field_name in required:
|
||||
if field_name not in data or not data[field_name]:
|
||||
raise ValueError(f"Missing required field: {field_name}")
|
||||
|
||||
name = data["name"]
|
||||
if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", name):
|
||||
raise ValueError(
|
||||
"Plugin name must be lowercase alphanumeric with hyphens (e.g., 'my-plugin')"
|
||||
)
|
||||
|
||||
version = data["version"]
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?", version):
|
||||
raise ValueError("Version must be semantic version (e.g., '1.0.0')")
|
||||
|
||||
hooks = data.get("hooks", {})
|
||||
if not isinstance(hooks, dict):
|
||||
raise TypeError("'hooks' must be an object mapping hook name to handler name")
|
||||
for hook_name, handler in hooks.items():
|
||||
if hook_name not in ALLOWED_HOOKS:
|
||||
raise ValueError(
|
||||
f"Unknown hook: {hook_name}. Allowed: {sorted(ALLOWED_HOOKS)}"
|
||||
)
|
||||
if not isinstance(handler, str) or not handler:
|
||||
raise ValueError(f"Hook handler for '{hook_name}' must be a non-empty string")
|
||||
|
||||
permissions = data.get("permissions", [])
|
||||
if not isinstance(permissions, list):
|
||||
raise TypeError("'permissions' must be a list of permission names")
|
||||
for perm in permissions:
|
||||
if perm not in ALLOWED_PERMISSIONS:
|
||||
raise ValueError(
|
||||
f"Unknown permission: {perm}. Allowed: {sorted(ALLOWED_PERMISSIONS)}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
version=version,
|
||||
description=data["description"],
|
||||
author=data["author"],
|
||||
main=data["main"],
|
||||
hooks=hooks,
|
||||
permissions=permissions,
|
||||
min_obsigate_version=data.get("min_obsigate_version", "2.1.0"),
|
||||
homepage=data.get("homepage"),
|
||||
repository=data.get("repository"),
|
||||
license=data.get("license", "MIT"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"description": self.description,
|
||||
"author": self.author,
|
||||
"main": self.main,
|
||||
"hooks": self.hooks,
|
||||
"permissions": self.permissions,
|
||||
"min_obsigate_version": self.min_obsigate_version,
|
||||
"homepage": self.homepage,
|
||||
"repository": self.repository,
|
||||
"license": self.license,
|
||||
}
|
||||
|
||||
|
||||
# ── Storage path helpers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _vault_plugins_dir(vault_name: str) -> Path:
|
||||
"""Return the plugins directory for a vault (creating it if absent)."""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
base = Path(vault_data["path"])
|
||||
pd = base / PLUGINS_DIR_NAME
|
||||
pd.mkdir(parents=True, exist_ok=True)
|
||||
return pd
|
||||
|
||||
|
||||
# ── Manager ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin lifecycle within a single plugins directory."""
|
||||
|
||||
def __init__(self, plugins_dir):
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _plugin_dir(self, name: str) -> Path:
|
||||
return self.plugins_dir / name
|
||||
|
||||
# ---- install / uninstall ----
|
||||
|
||||
def install(self, manifest: dict, code: str) -> dict[str, Any]:
|
||||
"""Install a plugin from a manifest dict + entry point source string."""
|
||||
m = PluginManifest.from_dict(manifest)
|
||||
|
||||
plugin_dir = self._plugin_dir(m.name)
|
||||
if plugin_dir.exists():
|
||||
raise ValueError(f"Plugin '{m.name}' is already installed")
|
||||
|
||||
installed = list(self.plugins_dir.iterdir()) if self.plugins_dir.exists() else []
|
||||
if installed and len(installed) >= MAX_PLUGINS_PER_VAULT:
|
||||
raise ValueError(f"Maximum of {MAX_PLUGINS_PER_VAULT} plugins per vault reached")
|
||||
|
||||
if len(code.encode("utf-8")) > MAX_PLUGIN_SIZE:
|
||||
raise ValueError(f"Plugin file exceeds {MAX_PLUGIN_SIZE} bytes")
|
||||
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / MANIFEST_FILENAME).write_text(
|
||||
json.dumps(m.to_dict(), indent=2), encoding="utf-8"
|
||||
)
|
||||
(plugin_dir / m.main).write_text(code, encoding="utf-8")
|
||||
logger.info("Installed plugin '%s' v%s", m.name, m.version)
|
||||
return {
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"author": m.author,
|
||||
"main": m.main,
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
def uninstall(self, name: str) -> None:
|
||||
plugin_dir = self._plugin_dir(name)
|
||||
if not plugin_dir.exists():
|
||||
raise ValueError(f"Plugin '{name}' not found")
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(plugin_dir)
|
||||
logger.info("Uninstalled plugin '%s'", name)
|
||||
|
||||
# ---- enable / disable ----
|
||||
|
||||
def enable(self, name: str) -> None:
|
||||
plugin_dir = self._plugin_dir(name)
|
||||
if not plugin_dir.exists():
|
||||
raise ValueError(f"Plugin '{name}' not found")
|
||||
marker = plugin_dir / ".disabled"
|
||||
if marker.exists():
|
||||
marker.unlink()
|
||||
|
||||
def disable(self, name: str) -> None:
|
||||
plugin_dir = self._plugin_dir(name)
|
||||
if not plugin_dir.exists():
|
||||
raise ValueError(f"Plugin '{name}' not found")
|
||||
(plugin_dir / ".disabled").write_text("", encoding="utf-8")
|
||||
|
||||
def is_disabled(self, name: str) -> bool:
|
||||
return (self._plugin_dir(name) / ".disabled").exists()
|
||||
|
||||
# ---- listing / metadata ----
|
||||
|
||||
def list_plugins(self) -> list[dict[str, Any]]:
|
||||
if not self.plugins_dir.exists():
|
||||
return []
|
||||
out = []
|
||||
for d in sorted(self.plugins_dir.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
manifest_file = d / MANIFEST_FILENAME
|
||||
if not manifest_file.exists():
|
||||
continue
|
||||
try:
|
||||
m = PluginManifest.from_dict(json.loads(manifest_file.read_text(encoding="utf-8")))
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid plugin dir '%s': %s", d.name, exc)
|
||||
continue
|
||||
out.append({
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"author": m.author,
|
||||
"main": m.main,
|
||||
"enabled": not self.is_disabled(m.name),
|
||||
"hooks": m.hooks,
|
||||
"permissions": m.permissions,
|
||||
})
|
||||
return out
|
||||
|
||||
def get_plugin(self, name: str) -> dict[str, Any] | None:
|
||||
plugin_dir = self._plugin_dir(name)
|
||||
manifest_file = plugin_dir / MANIFEST_FILENAME
|
||||
if not manifest_file.exists():
|
||||
return None
|
||||
try:
|
||||
m = PluginManifest.from_dict(json.loads(manifest_file.read_text(encoding="utf-8")))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"manifest": m.to_dict(),
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"enabled": not self.is_disabled(m.name),
|
||||
}
|
||||
|
||||
def get_plugin_code(self, name: str, file_name: str) -> str:
|
||||
plugin_dir = self._plugin_dir(name)
|
||||
manifest_file = plugin_dir / MANIFEST_FILENAME
|
||||
if not manifest_file.exists():
|
||||
raise ValueError(f"Plugin '{name}' not found")
|
||||
target = plugin_dir / file_name
|
||||
# Prevent path traversal
|
||||
try:
|
||||
target.resolve().relative_to(plugin_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
raise ValueError(f"Invalid file path: {file_name}")
|
||||
if not target.is_file():
|
||||
raise ValueError(f"File '{file_name}' not found for plugin '{name}'")
|
||||
return target.read_text(encoding="utf-8")
|
||||
|
||||
def get_hooks(self, name: str) -> dict[str, str]:
|
||||
plugin = self.get_plugin(name)
|
||||
if not plugin:
|
||||
raise ValueError(f"Plugin '{name}' not found")
|
||||
return plugin["manifest"].get("hooks", {})
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""Aggregates enabled plugins for a plugins directory."""
|
||||
|
||||
def get_enabled_plugins(self, plugins_dir) -> list[dict[str, Any]]:
|
||||
manager = PluginManager(plugins_dir)
|
||||
return [p for p in manager.list_plugins() if p["enabled"]]
|
||||
|
||||
def get_plugins_by_hook(self, plugins_dir, hook: str) -> list[dict[str, Any]]:
|
||||
manager = PluginManager(plugins_dir)
|
||||
return [
|
||||
p for p in manager.list_plugins()
|
||||
if p["enabled"] and hook in p.get("hooks", {})
|
||||
]
|
||||
|
||||
def scan_vault(self, vault_name: str, vault_path) -> list[dict[str, Any]]:
|
||||
"""Scan a vault directory for installed plugins (startup discovery).
|
||||
|
||||
Returns the list of installed-but-valid plugins so the app can log
|
||||
how many are available per vault. Invalid/zombie plugin dirs are
|
||||
skipped gracefully.
|
||||
"""
|
||||
pd = Path(vault_path) / PLUGINS_DIR_NAME
|
||||
if not pd.is_dir():
|
||||
return []
|
||||
return PluginManager(pd).list_plugins()
|
||||
|
||||
|
||||
# Module-level registry singleton (startup scan + runtime lookups)
|
||||
_registry_singleton: PluginRegistry | None = None
|
||||
|
||||
|
||||
def get_plugin_registry() -> PluginRegistry:
|
||||
"""Return the shared PluginRegistry instance."""
|
||||
global _registry_singleton
|
||||
if _registry_singleton is None:
|
||||
_registry_singleton = PluginRegistry()
|
||||
return _registry_singleton
|
||||
|
||||
|
||||
# ── Validation of uploads (ZIP / directory) ────────────────────────────────
|
||||
|
||||
|
||||
def _plugin_manifest_from_member(zf: zipfile.ZipFile) -> dict[str, Any]:
|
||||
try:
|
||||
raw = zf.read(MANIFEST_FILENAME).decode("utf-8")
|
||||
except KeyError:
|
||||
raise ValueError("Plugin zip is missing plugin.json")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Invalid JSON in plugin.json")
|
||||
m = PluginManifest.from_dict(data)
|
||||
return m.to_dict()
|
||||
|
||||
|
||||
def _validate_plugin_zip(zip_path) -> dict[str, Any]:
|
||||
"""Validate an uploaded plugin zip and return its manifest dict."""
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
members = zf.namelist()
|
||||
|
||||
if len(members) > MAX_PLUGIN_FILES:
|
||||
raise ValueError(f"Plugin contains too many files (max {MAX_PLUGIN_FILES})")
|
||||
|
||||
for member in members:
|
||||
if member.endswith("/"):
|
||||
continue
|
||||
norm = member.replace("\\", "/")
|
||||
clean = norm.lstrip("/")
|
||||
if ".." in clean.split("/"):
|
||||
raise ValueError("Path traversal detected in plugin zip")
|
||||
# Reject absolute paths and any ../../ escapes
|
||||
if norm.startswith("/") or "/../" in f"/{norm}" or norm.endswith("/.."):
|
||||
raise ValueError("Path traversal detected in plugin zip")
|
||||
|
||||
manifest = _plugin_manifest_from_member(zf)
|
||||
|
||||
# Entry point must exist
|
||||
main = manifest["main"]
|
||||
if main not in members and f"{main}/" not in members:
|
||||
# accept with any leading ./ or subdir normalization
|
||||
found = any(m.rstrip("/") == main or m.rstrip("/") == f"./{main}" for m in members)
|
||||
if not found:
|
||||
raise ValueError(f"Missing entry point: {main}")
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_plugin_directory(plugin_dir) -> dict[str, Any]:
|
||||
"""Validate an installed plugin directory and return its manifest dict."""
|
||||
d = Path(plugin_dir)
|
||||
manifest_file = d / MANIFEST_FILENAME
|
||||
if not manifest_file.is_file():
|
||||
raise ValueError(f"Missing {MANIFEST_FILENAME}")
|
||||
try:
|
||||
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"Invalid JSON in {MANIFEST_FILENAME}")
|
||||
m = PluginManifest.from_dict(data)
|
||||
if not (d / m.main).is_file():
|
||||
raise ValueError(f"Missing entry point: {m.main}")
|
||||
return m.to_dict()
|
||||
|
||||
|
||||
# ── API Endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=list[dict])
|
||||
async def api_list_plugins(vault: str, current_user=Depends(require_auth)):
|
||||
"""List installed plugins for a vault."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
return PluginManager(plugins_dir).list_plugins()
|
||||
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
async def api_plugin_template():
|
||||
"""Return a starter plugin template (manifest + sample code)."""
|
||||
template_manifest = {
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Describe what your plugin does",
|
||||
"author": "your-name",
|
||||
"main": "index.js",
|
||||
"hooks": {"onFileRender": "render"},
|
||||
"permissions": ["read_files", "ui_notify"],
|
||||
"license": "MIT",
|
||||
}
|
||||
template_code = (
|
||||
"// ObsiGate plugin template\n"
|
||||
"export function render(ctx) {\n"
|
||||
" // ctx: { path, content, extension, vault }\n"
|
||||
" if (ctx.content && ctx.path.endsWith('.md')) {\n"
|
||||
" ctx.content = `> Plugin: ${self.name}\\n\\n` + ctx.content;\n"
|
||||
" }\n"
|
||||
" return ctx;\n"
|
||||
"}\n"
|
||||
)
|
||||
return {
|
||||
"manifest": template_manifest,
|
||||
"code": template_code,
|
||||
}
|
||||
|
||||
@router.get("/{plugin_name}", response_model=dict)
|
||||
async def api_get_plugin(vault: str, plugin_name: str, current_user=Depends(require_auth)):
|
||||
"""Get a single plugin's metadata."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
plugin = PluginManager(plugins_dir).get_plugin(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
return plugin
|
||||
|
||||
|
||||
@router.get("/{plugin_name}/hooks", response_model=dict)
|
||||
async def api_plugin_hooks(vault: str, plugin_name: str, current_user=Depends(require_auth)):
|
||||
"""Get the hooks a plugin registers."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
try:
|
||||
return PluginManager(plugins_dir).get_hooks(plugin_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/{plugin_name}/code/{file_name:path}", response_model=dict)
|
||||
async def api_plugin_code(
|
||||
vault: str, plugin_name: str, file_name: str, current_user=Depends(require_auth)
|
||||
):
|
||||
"""Get a plugin source file for sandboxed execution."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
try:
|
||||
code = PluginManager(plugins_dir).get_plugin_code(plugin_name, file_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return {"name": plugin_name, "file": file_name, "code": code}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def api_install_plugin(
|
||||
vault: str,
|
||||
file: UploadFile = File(...),
|
||||
current_user=Depends(require_admin),
|
||||
):
|
||||
"""Install a plugin from an uploaded zip (admin only)."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 5_000_000: # 5 MB upload cap
|
||||
raise HTTPException(status_code=413, detail="Plugin zip too large")
|
||||
|
||||
import io
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
zip_path = Path(tmp_dir) / "plugin.zip"
|
||||
try:
|
||||
zip_path.write_bytes(content)
|
||||
manifest = _validate_plugin_zip(zip_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid plugin: {exc}")
|
||||
|
||||
# Extract to a temp dir then validate the entry point exists
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
manager = PluginManager(plugins_dir)
|
||||
|
||||
name = manifest["name"]
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
zf.extractall(td)
|
||||
except (zipfile.BadZipFile, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid plugin zip")
|
||||
extracted = _validate_plugin_directory(Path(td) / name if (Path(td) / name).is_dir() else Path(td))
|
||||
|
||||
code_file = manifest["main"]
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
code = zf.read(code_file).decode("utf-8")
|
||||
|
||||
try:
|
||||
result = manager.install(extracted, code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{plugin_name}")
|
||||
async def api_uninstall_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
try:
|
||||
PluginManager(plugins_dir).uninstall(plugin_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/{plugin_name}/enable")
|
||||
async def api_enable_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
try:
|
||||
PluginManager(plugins_dir).enable(plugin_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/{plugin_name}/disable")
|
||||
async def api_disable_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
plugins_dir = _vault_plugins_dir(vault)
|
||||
try:
|
||||
PluginManager(plugins_dir).disable(plugin_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
# ── Plugin Event Bus ────────────────────────────────────────────────────────
|
||||
# Lightweight pub/sub for backend events that plugins can subscribe to.
|
||||
# Used to dispatch onFileCreate, onFileDelete, onVaultMount to enabled plugins.
|
||||
|
||||
|
||||
class _PluginEventBus:
|
||||
"""In-memory event dispatcher for plugin lifecycle events."""
|
||||
_listeners: ClassVar[dict[str, list]] = {}
|
||||
|
||||
@classmethod
|
||||
def subscribe(cls, event_type: str, callback) -> None:
|
||||
cls._listeners.setdefault(event_type, []).append(callback)
|
||||
|
||||
@classmethod
|
||||
async def emit(cls, event_type: str, data: dict) -> None:
|
||||
for cb in cls._listeners.get(event_type, []):
|
||||
try:
|
||||
result = cb(data)
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.warning(f"Plugin event '{event_type}' handler error: {e}")
|
||||
|
||||
|
||||
plugin_events = _PluginEventBus
|
||||
|
||||
|
||||
def emit_file_created(vault: str, path: str, title: str = "") -> None:
|
||||
"""Called when a file is created via the API."""
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(plugin_events.emit("onFileCreate", {
|
||||
"vault": vault, "path": path, "title": title,
|
||||
"timestamp": _time.time(),
|
||||
}))
|
||||
except RuntimeError:
|
||||
pass # No event loop running
|
||||
|
||||
|
||||
def emit_file_deleted(vault: str, path: str) -> None:
|
||||
"""Called when a file is deleted via the API."""
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(plugin_events.emit("onFileDelete", {
|
||||
"vault": vault, "path": path,
|
||||
"timestamp": _time.time(),
|
||||
}))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def emit_vault_mounted(vault: str, path: str) -> None:
|
||||
"""Called when a vault is loaded into the index at startup."""
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(plugin_events.emit("onVaultMount", {
|
||||
"vault": vault, "path": path,
|
||||
"timestamp": _time.time(),
|
||||
}))
|
||||
except RuntimeError:
|
||||
pass
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
# backend/push.py
|
||||
# Push Notifications — Web Push API with VAPID authentication
|
||||
# ROADMAP #67
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import require_auth
|
||||
|
||||
logger = logging.getLogger("obsigate.push")
|
||||
|
||||
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||
|
||||
# VAPID keys storage
|
||||
VAPID_KEYS_FILE = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "vapid_keys.json"
|
||||
PUSH_SUBSCRIPTIONS_FILE = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "push_subscriptions.json"
|
||||
|
||||
# In-memory cache
|
||||
_vapid_keys: dict[str, str] = {}
|
||||
_push_subscriptions: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def load_vapid_keys() -> dict[str, str]:
|
||||
"""Load VAPID keys from file or generate new ones."""
|
||||
global _vapid_keys
|
||||
if _vapid_keys:
|
||||
return _vapid_keys
|
||||
|
||||
try:
|
||||
if VAPID_KEYS_FILE.exists():
|
||||
with open(VAPID_KEYS_FILE, "r") as f:
|
||||
_vapid_keys = json.load(f)
|
||||
else:
|
||||
# Generate new VAPID keys
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
public_pem = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
).decode('utf-8')
|
||||
|
||||
# Convert to base64url for Web Push
|
||||
public_numbers = public_key.public_numbers()
|
||||
|
||||
def int_to_base64url(n: int) -> str:
|
||||
byte_len = (n.bit_length() + 7) // 8
|
||||
return base64.urlsafe_b64encode(n.to_bytes(byte_len, 'big')).decode('utf-8').rstrip('=')
|
||||
|
||||
_vapid_keys = {
|
||||
"private_key": private_pem,
|
||||
"public_key": public_pem,
|
||||
"public_key_base64url": int_to_base64url(public_numbers.x) + "." + int_to_base64url(public_numbers.y)
|
||||
}
|
||||
|
||||
VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(VAPID_KEYS_FILE, "w") as f:
|
||||
json.dump(_vapid_keys, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load/generate VAPID keys: {e}")
|
||||
# Fallback: return empty (will use demo keys if needed)
|
||||
_vapid_keys = {}
|
||||
|
||||
return _vapid_keys
|
||||
|
||||
|
||||
def get_vapid_public_key() -> str:
|
||||
"""Get VAPID public key in base64url format for client."""
|
||||
keys = load_vapid_keys()
|
||||
return keys.get("public_key_base64url", "")
|
||||
|
||||
|
||||
def load_push_subscriptions() -> list[dict[str, Any]]:
|
||||
"""Load push subscriptions from file."""
|
||||
global _push_subscriptions
|
||||
if _push_subscriptions:
|
||||
return _push_subscriptions
|
||||
|
||||
try:
|
||||
if PUSH_SUBSCRIPTIONS_FILE.exists():
|
||||
with open(PUSH_SUBSCRIPTIONS_FILE, "r") as f:
|
||||
_push_subscriptions = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load push subscriptions: {e}")
|
||||
_push_subscriptions = []
|
||||
return _push_subscriptions
|
||||
|
||||
|
||||
def save_push_subscriptions() -> None:
|
||||
"""Save push subscriptions to file."""
|
||||
try:
|
||||
PUSH_SUBSCRIPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(PUSH_SUBSCRIPTIONS_FILE, "w") as f:
|
||||
json.dump(_push_subscriptions, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscriptions: {e}")
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class PushSubscription(BaseModel):
|
||||
"""Push subscription from client (matches Push API)."""
|
||||
endpoint: str
|
||||
keys: dict[str, str] # { p256dh, auth }
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
"""Request to subscribe to push notifications."""
|
||||
subscription: PushSubscription
|
||||
vault: str = Field(description="Vault name this subscription is for")
|
||||
|
||||
|
||||
class SubscribeResponse(BaseModel):
|
||||
"""Response to subscription request."""
|
||||
success: bool
|
||||
subscription_id: str | None = None
|
||||
|
||||
|
||||
class VapidPublicKeyResponse(BaseModel):
|
||||
"""VAPID public key for client."""
|
||||
public_key: str
|
||||
|
||||
|
||||
class PushPayload(BaseModel):
|
||||
"""Payload for sending a push notification."""
|
||||
vault: str
|
||||
title: str
|
||||
body: str
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
tag: str = "obsigate-notification"
|
||||
|
||||
|
||||
# ── Endpoints ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
||||
async def get_vapid_public_key_endpoint():
|
||||
"""Get VAPID public key for client subscription."""
|
||||
public_key = get_vapid_public_key()
|
||||
if not public_key:
|
||||
# Return a demo key if generation failed (for testing)
|
||||
return {"public_key": "demo-key-for-testing"}
|
||||
return {"public_key": public_key}
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=SubscribeResponse)
|
||||
async def subscribe_push(
|
||||
request: SubscribeRequest,
|
||||
current_user=Depends(require_auth)
|
||||
):
|
||||
"""Subscribe to push notifications for a vault."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
# Check if user has access to this vault
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||
|
||||
# Check if subscription already exists
|
||||
for sub in _push_subscriptions:
|
||||
if sub["endpoint"] == request.subscription.endpoint and sub["username"] == username:
|
||||
return SubscribeResponse(success=True, subscription_id=sub.get("id"))
|
||||
|
||||
# Add new subscription
|
||||
sub_id = base64.urlsafe_b64encode(os.urandom(16)).decode('utf-8').rstrip('=')
|
||||
subscription = {
|
||||
"id": sub_id,
|
||||
"endpoint": request.subscription.endpoint,
|
||||
"keys": request.subscription.keys,
|
||||
"vault": request.vault,
|
||||
"username": username,
|
||||
"created_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
_push_subscriptions.append(subscription)
|
||||
save_push_subscriptions()
|
||||
|
||||
logger.info(f"Push subscription added for user={username}, vault={request.vault}")
|
||||
return SubscribeResponse(success=True, subscription_id=sub_id)
|
||||
|
||||
|
||||
@router.delete("/subscribe")
|
||||
async def unsubscribe_push(
|
||||
endpoint: str,
|
||||
current_user=Depends(require_auth)
|
||||
):
|
||||
"""Unsubscribe from push notifications."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
global _push_subscriptions
|
||||
original_len = len(_push_subscriptions)
|
||||
_push_subscriptions = [
|
||||
s for s in _push_subscriptions
|
||||
if not (s["endpoint"] == endpoint and s["username"] == username)
|
||||
]
|
||||
|
||||
if len(_push_subscriptions) < original_len:
|
||||
save_push_subscriptions()
|
||||
return {"success": True, "message": "Unsubscribed"}
|
||||
|
||||
return {"success": False, "message": "Subscription not found"}
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def list_subscriptions(current_user=Depends(require_auth)):
|
||||
"""List current user's push subscriptions."""
|
||||
username = current_user.get("username", "unknown")
|
||||
user_subs = [
|
||||
{
|
||||
"id": s["id"],
|
||||
"vault": s["vault"],
|
||||
"created_at": s["created_at"],
|
||||
"endpoint": s["endpoint"][:50] + "..." # Truncate for privacy
|
||||
}
|
||||
for s in _push_subscriptions if s["username"] == username
|
||||
]
|
||||
return {"subscriptions": user_subs}
|
||||
|
||||
|
||||
# ── Internal: Send push notification ────────────────────────────────────────
|
||||
|
||||
async def send_push_notification(vault: str, title: str, body: str, data: dict | None = None, tag: str = "obsigate-notification") -> int:
|
||||
"""Send push notification to all subscribers of a vault. Returns count of sent notifications."""
|
||||
subscriptions = [s for s in _push_subscriptions if s["vault"] == vault]
|
||||
|
||||
if not subscriptions:
|
||||
return 0
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"data": data or {},
|
||||
"tag": tag
|
||||
}
|
||||
|
||||
# Import here to avoid circular dependency
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
keys = load_vapid_keys()
|
||||
vapid_private_key = keys.get("private_key")
|
||||
vapid_claims = {
|
||||
"sub": "mailto:[email protected]"
|
||||
}
|
||||
|
||||
sent = 0
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": sub["endpoint"],
|
||||
"keys": sub["keys"]
|
||||
},
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=vapid_claims
|
||||
)
|
||||
sent += 1
|
||||
except WebPushException as e:
|
||||
logger.warning(f"Push failed for subscription {sub['id']}: {e}")
|
||||
# If subscription expired/invalid, remove it
|
||||
if e.response and e.response.status_code in (404, 410):
|
||||
_push_subscriptions.remove(sub)
|
||||
except Exception as e:
|
||||
logger.error(f"Push error for {sub['id']}: {e}")
|
||||
|
||||
if sent < len(subscriptions):
|
||||
save_push_subscriptions()
|
||||
|
||||
return sent
|
||||
@@ -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,8 @@ 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
|
||||
pywebpush>=2.3.0
|
||||
|
||||
@@ -5,11 +5,11 @@ Stored in data/saved_searches.json per user.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.saved_searches")
|
||||
|
||||
@@ -21,7 +21,7 @@ def _get_file(username: str) -> Path:
|
||||
return DATA_DIR / f"{username}_saved_searches.json"
|
||||
|
||||
|
||||
def _read(file: Path) -> List[Dict[str, Any]]:
|
||||
def _read(file: Path) -> list[dict[str, Any]]:
|
||||
if not file.exists():
|
||||
return []
|
||||
try:
|
||||
@@ -30,13 +30,13 @@ def _read(file: Path) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _write(file: Path, data: List[Dict[str, Any]]):
|
||||
def _write(file: Path, data: list[dict[str, Any]]):
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
|
||||
|
||||
def get_saved(username: str) -> List[Dict[str, Any]]:
|
||||
def get_saved(username: str) -> list[dict[str, Any]]:
|
||||
return _read(_get_file(username))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Additional Pydantic response models for the ObsiGate REST API.
|
||||
|
||||
These models enrich the auto-generated OpenAPI 3.1 schema (#72). They are
|
||||
intentionally permissive (``extra="allow"``) so that adding a new field to an
|
||||
existing endpoint never breaks response validation — the model documents the
|
||||
stable, public shape while still accepting internal additions.
|
||||
|
||||
Models whose endpoints are already typed in :mod:`backend.main` (e.g.
|
||||
``FileContentResponse``) live there; this module only holds the ones used to
|
||||
fill the gaps.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""Generic ``{"status": "..."}`` acknowledgement."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
status: str = Field(default="ok", description="Operation status")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard FastAPI error envelope."""
|
||||
|
||||
detail: str | list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Human-readable error message, or a list of validation errors",
|
||||
examples=["Vault 'Notes' not found"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recent files & bookmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecentFileItem(BaseModel):
|
||||
"""A recently opened or recently modified file."""
|
||||
|
||||
path: str = Field(description="Relative path within the vault")
|
||||
title: str = Field(description="File title")
|
||||
vault: str = Field(description="Vault name")
|
||||
mtime: float | int | str | None = Field(default=None, description="Modification timestamp")
|
||||
mtime_human: str | None = Field(default=None, description="Human-readable modification time")
|
||||
mtime_iso: str | None = Field(default=None, description="ISO-8601 modification time")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
|
||||
preview: str | None = Field(default=None, description="Short content preview")
|
||||
bookmarked: bool | None = Field(default=None, description="Whether the file is bookmarked")
|
||||
|
||||
|
||||
class RecentResponse(BaseModel):
|
||||
"""Response for ``GET /api/recent``."""
|
||||
|
||||
files: list[RecentFileItem]
|
||||
total: int = Field(description="Total number of files in the selected mode")
|
||||
limit: int = Field(description="Applied limit")
|
||||
mode: str = Field(description="'opened' or 'modified'")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"files": [
|
||||
{
|
||||
"path": "notes/Accueil.md",
|
||||
"title": "Accueil",
|
||||
"vault": "TestVault",
|
||||
"mtime": 1750000000.0,
|
||||
"mtime_human": "il y a 2 h",
|
||||
"size_bytes": 1024,
|
||||
"tags": ["#accueil"],
|
||||
"preview": "# Bienvenue…",
|
||||
"bookmarked": False,
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 20,
|
||||
"mode": "opened",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BookmarkFileItem(BaseModel):
|
||||
"""A bookmarked file."""
|
||||
|
||||
path: str = Field(description="Relative path within the vault")
|
||||
title: str = Field(description="File title")
|
||||
vault: str = Field(description="Vault name")
|
||||
mtime: float | int | str | None = Field(default=None, description="Bookmark timestamp")
|
||||
mtime_human: str | None = Field(default=None, description="Human-readable bookmark time")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
|
||||
bookmarked: bool | None = Field(default=True, description="Always true for this endpoint")
|
||||
|
||||
|
||||
class BookmarksResponse(BaseModel):
|
||||
"""Response for ``GET /api/bookmarks``."""
|
||||
|
||||
files: list[BookmarkFileItem]
|
||||
total: int = Field(description="Number of bookmarked files")
|
||||
|
||||
|
||||
class BookmarkToggleResponse(BaseModel):
|
||||
"""Response for ``POST /api/bookmarks/toggle``."""
|
||||
|
||||
bookmarked: bool = Field(description="New bookmark state")
|
||||
|
||||
|
||||
class SavedSearch(BaseModel):
|
||||
"""A persisted search definition."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Unique identifier (millisecond timestamp)")
|
||||
query: str = Field(description="Search query text")
|
||||
vault: str = Field(default="all", description="Vault filter")
|
||||
case_sensitive: bool = Field(default=False)
|
||||
whole_word: bool = Field(default=False)
|
||||
regex: bool = Field(default=False)
|
||||
include_paths: str = Field(default="")
|
||||
exclude_paths: str = Field(default="")
|
||||
created_at: float = Field(description="Creation timestamp (epoch seconds)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backups & diffs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BackupsResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/backups``."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
backups: list[dict[str, Any]] = Field(description="Backups, newest first")
|
||||
|
||||
|
||||
class BacklinksResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/backlinks``."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
backlinks: list[dict[str, Any]] = Field(description="Files linking to the target")
|
||||
total: int
|
||||
|
||||
|
||||
class BackupsListResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
backups: list[dict[str, Any]] = Field(description="All backups across vaults")
|
||||
total: int
|
||||
total_size_bytes: int
|
||||
|
||||
|
||||
class BackupsDeletedResponse(BaseModel):
|
||||
"""Response for backup deletion / purge endpoints."""
|
||||
|
||||
deleted: int = Field(description="Number of backup files deleted")
|
||||
|
||||
|
||||
class BackupContentResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups/content``."""
|
||||
|
||||
content: str = Field(description="Backup file content (truncated to 100 KB)")
|
||||
name: str = Field(description="Backup file name")
|
||||
size: int = Field(description="Returned content size in characters")
|
||||
|
||||
|
||||
class BackupsCompressResponse(BaseModel):
|
||||
"""Response for ``POST /api/backups/compress``."""
|
||||
|
||||
compressed: int = Field(description="Number of backups processed")
|
||||
saved_bytes: int = Field(description="Bytes saved by gzip compression")
|
||||
dry_run: bool
|
||||
|
||||
|
||||
class BackupsAutoResponse(BaseModel):
|
||||
"""Response for ``POST /api/backups/auto``."""
|
||||
|
||||
backed_up: int = Field(description="Number of files backed up")
|
||||
since_hours: int | float = Field(description="Look-back window in hours")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PdfInfoResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/pdf/info``."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
pages: int = Field(description="Page count")
|
||||
title: str = Field(description="PDF title (metadata or filename)")
|
||||
author: str = Field(default="", description="PDF author")
|
||||
size_bytes: int = Field(description="File size in bytes")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"vault": "TestVault",
|
||||
"path": "docs/rapport.pdf",
|
||||
"pages": 12,
|
||||
"title": "Rapport annuel",
|
||||
"author": "ObsiGate",
|
||||
"size_bytes": 524288,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search & replace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReplaceMatch(BaseModel):
|
||||
"""A single file affected by a find/replace operation."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str
|
||||
path: str
|
||||
title: str | None = None
|
||||
match_count: int | None = Field(default=None, description="Occurrences found (dry run)")
|
||||
replacements: int | None = Field(default=None, description="Occurrences replaced")
|
||||
preview: list[str] = Field(default_factory=list, description="Context snippets (dry run)")
|
||||
|
||||
|
||||
class ReplaceResponse(BaseModel):
|
||||
"""Response for ``POST /api/search/replace``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
matches: list[ReplaceMatch] = Field(default_factory=list, description="Dry-run matches")
|
||||
replaced: list[ReplaceMatch] = Field(default_factory=list, description="Applied replacements")
|
||||
total_matches: int | None = Field(default=None, description="Total matches (dry run)")
|
||||
total_replacements: int = Field(description="Total replacements performed or previewed")
|
||||
dry_run: bool | None = Field(default=None, description="True when no file was written")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vaults, attachments & settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VaultStatsResponse(BaseModel):
|
||||
"""Response for ``POST /api/vaults/add`` and ``GET /api/index/reload/{vault}``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
status: str = Field(default="ok")
|
||||
vault: str
|
||||
stats: dict[str, Any] = Field(default_factory=dict, description="Index statistics for the vault")
|
||||
|
||||
|
||||
class VaultActionResponse(BaseModel):
|
||||
"""Response for ``DELETE /api/vaults/{vault}``."""
|
||||
|
||||
status: str = Field(default="ok")
|
||||
vault: str
|
||||
|
||||
|
||||
class VaultStatusEntry(BaseModel):
|
||||
"""Per-vault status entry."""
|
||||
|
||||
file_count: int
|
||||
tag_count: int
|
||||
path: str = ""
|
||||
watching: bool = False
|
||||
|
||||
|
||||
class VaultsStatusResponse(BaseModel):
|
||||
"""Response for ``GET /api/vaults/status``."""
|
||||
|
||||
vaults: dict[str, VaultStatusEntry]
|
||||
watcher_active: bool
|
||||
sse_clients: int
|
||||
|
||||
|
||||
class AttachmentRescanResponse(BaseModel):
|
||||
"""Response for ``POST /api/attachments/rescan/{vault}``."""
|
||||
|
||||
status: str = Field(default="ok")
|
||||
vault: str
|
||||
attachment_count: int
|
||||
|
||||
|
||||
class AttachmentStatsResponse(BaseModel):
|
||||
"""Response for ``GET /api/attachments/stats``."""
|
||||
|
||||
vaults: dict[str, Any] = Field(description="Vault name → attachment statistics")
|
||||
|
||||
|
||||
class VaultSettingsResponse(BaseModel):
|
||||
"""Response for the per-vault display settings endpoints."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
hideHiddenFiles: bool = Field(default=False, description="Hide dotfiles in the tree")
|
||||
|
||||
|
||||
class AllVaultSettingsResponse(BaseModel):
|
||||
"""Response for ``GET /api/vaults/settings/all``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class VaultFileEntry(BaseModel):
|
||||
"""A file entry returned by the vault home listing."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
name: str
|
||||
path: str
|
||||
vault: str
|
||||
size: int = 0
|
||||
modified: float = 0
|
||||
modified_iso: str | None = None
|
||||
extension: str = ""
|
||||
rel_dir: str | None = None
|
||||
|
||||
|
||||
class VaultFilesResponse(BaseModel):
|
||||
"""Response for ``GET /api/vault/{vault}/files``."""
|
||||
|
||||
vault: str
|
||||
directory: str = ""
|
||||
recursive: bool = True
|
||||
count: int
|
||||
files: list[VaultFileEntry]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration, AI keys & diagnostics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AppConfigResponse(BaseModel):
|
||||
"""Application configuration (``GET/POST /api/config``)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AIKeysResponse(BaseModel):
|
||||
"""Masked AI provider keys (``GET /api/config/ai-keys``)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AIKeyDeleteResponse(BaseModel):
|
||||
"""Response for ``DELETE /api/config/ai-keys/{provider}``."""
|
||||
|
||||
status: str = Field(default="deleted")
|
||||
key: str = Field(description="Deleted environment variable name")
|
||||
|
||||
|
||||
class AITestResponse(BaseModel):
|
||||
"""Response for ``POST /api/config/ai-keys/test`` (provider → status)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AIModelsResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/ai-models``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
models: list[str] = Field(default_factory=list, description="Available model identifiers")
|
||||
source: str = Field(default="fallback", description="'live', 'fallback' or 'validation'")
|
||||
count: int | None = Field(default=None, description="Number of live models")
|
||||
error: str | None = Field(default=None, description="Provider/network error, if any")
|
||||
note: str | None = Field(default=None, description="Explanatory note when using fallback")
|
||||
|
||||
|
||||
class DiagnosticsResponse(BaseModel):
|
||||
"""Response for ``GET /api/diagnostics``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
index: dict[str, Any] = Field(default_factory=dict)
|
||||
inverted_index: dict[str, Any] = Field(default_factory=dict)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DashboardVaultStat(BaseModel):
|
||||
"""Per-vault dashboard statistics."""
|
||||
|
||||
name: str
|
||||
file_count: int
|
||||
tag_count: int
|
||||
total_size_bytes: int
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
"""Response for ``GET /api/dashboard``."""
|
||||
|
||||
vaults: list[DashboardVaultStat]
|
||||
total_files: int
|
||||
total_tags: int
|
||||
total_size_bytes: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhooks, sharing & conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WebhookModel(BaseModel):
|
||||
"""A configured webhook."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Webhook UUID")
|
||||
name: str
|
||||
url: str = Field(description="Target HTTP(S) URL")
|
||||
events: list[str] = Field(default_factory=list, description="Subscribed event types")
|
||||
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret")
|
||||
enabled: bool = True
|
||||
created_at: str | None = None
|
||||
last_fired_at: str | None = None
|
||||
|
||||
|
||||
class ShareModel(BaseModel):
|
||||
"""A public document share."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str
|
||||
token: str = Field(description="Opaque share token")
|
||||
vault: str
|
||||
path: str
|
||||
url: str | None = Field(default=None, description="Relative public URL (/s/{token})")
|
||||
created_by: str | None = None
|
||||
created_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
access_count: int = 0
|
||||
last_accessed: str | None = None
|
||||
|
||||
|
||||
class ConflictEntry(BaseModel):
|
||||
"""A Syncthing sync-conflict file."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str
|
||||
path: str
|
||||
original_path: str | None = None
|
||||
|
||||
|
||||
class ConflictsResponse(BaseModel):
|
||||
"""Response for ``GET /api/conflicts``."""
|
||||
|
||||
conflicts: list[ConflictEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class ConflictResolveResponse(BaseModel):
|
||||
"""Response for ``POST /api/conflicts/resolve``."""
|
||||
|
||||
status: str = Field(default="resolved")
|
||||
action: str | None = Field(default=None, description="'keep_local' or 'keep_conflict'")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI status & BooksLM context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AIProviderStatus(BaseModel):
|
||||
"""Availability of a single AI provider."""
|
||||
|
||||
available: bool
|
||||
model: str | None = Field(default=None, description="Default model when the provider is configured")
|
||||
|
||||
|
||||
class AIAutocompleteStatus(BaseModel):
|
||||
"""Status of the local Ollama autocomplete backend."""
|
||||
|
||||
available: bool = False
|
||||
server_ok: bool = False
|
||||
model_loaded: bool = False
|
||||
model: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AIStatusResponse(BaseModel):
|
||||
"""Response for ``GET /api/ai/status``."""
|
||||
|
||||
configured: bool = Field(description="True when at least one provider has an API key")
|
||||
default_provider: str
|
||||
providers: dict[str, AIProviderStatus]
|
||||
autocomplete: AIAutocompleteStatus
|
||||
|
||||
|
||||
class BooksLMContextFile(BaseModel):
|
||||
"""A single document included in the BooksLM context."""
|
||||
|
||||
path: str
|
||||
title: str
|
||||
content: str
|
||||
type: str = Field(default="markdown", description="'markdown' (and future types)")
|
||||
|
||||
|
||||
class BooksLMContextResponse(BaseModel):
|
||||
"""Response for ``POST /api/ai/bookslm/context``."""
|
||||
|
||||
files: list[BooksLMContextFile]
|
||||
total_chars: int
|
||||
file_count: int
|
||||
directory_tree: str = ""
|
||||
max_total_chars: int = Field(default=200000, description="Configured context character limit")
|
||||
max_files: int = Field(default=200, description="Configured file-count limit")
|
||||
scope: str = Field(default="directory", description="Context scope: 'directory', 'documents' or 'general'")
|
||||
|
||||
+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,165 @@
|
||||
# =============================================================================
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
|
||||
# ----- Resolve version from git before Docker build -----
|
||||
$Version = (git describe --tags --dirty 2>$null) -replace '^v',''
|
||||
if (-not $Version) { $Version = "0.0.0" }
|
||||
$Version | Out-File -Encoding ascii -NoNewline -FilePath "backend\VERSION"
|
||||
# Export so docker compose substitutes ${VERSION} in the build arg
|
||||
$env:VERSION = $Version
|
||||
Write-Info "Version : $Version"
|
||||
|
||||
# ----- 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,34 @@ 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
|
||||
# Export so docker compose substitutes ${VERSION} in the build arg
|
||||
export 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
+6047
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
[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"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_Shell_Common",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
] }
|
||||
|
||||
[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,18 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for ObsiGate Desktop",
|
||||
"windows": ["main"],
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||
},
|
||||
"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","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# ObsiGate Desktop — Windows code signing (scaffolding).
|
||||
#
|
||||
# Signe le binaire et l'installateur MSI/NSIS après un `cargo tauri build`.
|
||||
# Aucun certificat n'est embarqué : les identifiants sont lus depuis
|
||||
# l'environnement (jamais commités). Le script est un no-op explicite si
|
||||
# l'env n'est pas configuré — le build reste donc fonctionnel sans signature.
|
||||
#
|
||||
# Variables d'environnement :
|
||||
# OBSIGATE_SIGN_CERT_PFX chemin du .pfx (requis)
|
||||
# OBSIGATE_SIGN_CERT_PASSWORD mot de passe du .pfx (optionnel)
|
||||
# OBSIGATE_SIGN_TIMESTAMP_URL URL du serveur d'horodatage RFC3161
|
||||
# (défaut : http://timestamp.digicert.com)
|
||||
# OBSIGATE_SIGN_MODE "signtool" (Windows) ou "osslsigncode" (Linux)
|
||||
#
|
||||
# Usage :
|
||||
# ./scripts/sign-windows.ps1 # après le build, signe les artefacts
|
||||
# OBSIGATE_SIGN_CERT_PFX=cert.pfx ./scripts/sign-windows.ps1
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$pfx = $env:OBSIGATE_SIGN_CERT_PFX
|
||||
if (-not $pfx) {
|
||||
Write-Host "⚠ Aucun certificat fourni (OBSIGATE_SIGN_CERT_PFX). Signature ignorée — build non signé."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$password = $env:OBSIGATE_SIGN_CERT_PASSWORD
|
||||
$tsUrl = if ($env:OBSIGATE_SIGN_TIMESTAMP_URL) { $env:OBSIGATE_SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
|
||||
$mode = if ($env:OBSIGATE_SIGN_MODE) { $env:OBSIGATE_SIGN_MODE } else { 'signtool' }
|
||||
|
||||
$targets = @(
|
||||
'target/release/obsigate-desktop.exe',
|
||||
'target/release/bundle/msi/*.msi',
|
||||
'target/release/bundle/nsis/*-setup.exe'
|
||||
) | ForEach-Object { Get-Item $_ -ErrorAction SilentlyContinue } | Where-Object { $_ }
|
||||
|
||||
if ($targets.Count -eq 0) {
|
||||
Write-Host "⚠ Aucun artefact à signer trouvé (lancer d'abord `cargo tauri build`)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
foreach ($t in $targets) {
|
||||
Write-Host "✍ Signature de $($t.FullName) (mode: $mode)"
|
||||
if ($mode -eq 'osslsigncode') {
|
||||
$args = @('sign', '-pkcs12', $pfx, '-h', 'sha256')
|
||||
if ($password) { $args += @('-pass', $password) }
|
||||
$args += @('-ts', $tsUrl, '-in', $t.FullName, '-out', $t.FullName)
|
||||
& osslsigncode @args
|
||||
if ($LASTEXITCODE -ne 0) { throw "osslsigncode a échoué pour $($t.Name)" }
|
||||
} else {
|
||||
$args = @('sign', '/fd', 'SHA256', '/f', $pfx, '/tr', $tsUrl, '/td', 'SHA256')
|
||||
if ($password) { $args += @('/p', $password) }
|
||||
$args += $t.FullName
|
||||
& signtool @args
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool a échoué pour $($t.Name)" }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "✅ $($targets.Count) artefact(s) signé(s)."
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Windows Taskbar Jump List — recent vaults.
|
||||
//!
|
||||
//! Populates the taskbar jump list with the configured vaults so the user can
|
||||
//! jump straight into a vault from the Windows taskbar / Start menu.
|
||||
//!
|
||||
//! Best-effort: every step is fallible and logged, never panics, and a failure
|
||||
//! simply leaves the jump list at its OS-managed default state.
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::Foundation::PROPERTYKEY;
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
|
||||
use windows::Win32::System::Variant::VT_LPWSTR;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
|
||||
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
|
||||
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
|
||||
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
|
||||
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
|
||||
pid: 2,
|
||||
};
|
||||
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Set the jump-list display title via the shell link's property store.
|
||||
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
|
||||
let store: IPropertyStore = link.cast()?;
|
||||
let mut wide = wide_null_terminated(title);
|
||||
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
let inner = &mut *pv.Anonymous.Anonymous;
|
||||
inner.vt = VT_LPWSTR;
|
||||
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
|
||||
store.SetValue(&PKEY_TITLE, &pv)?;
|
||||
store.Commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build one shell link for a vault. `arg` is the command-line argument the
|
||||
/// app expects to open that vault (see file associations / single-instance).
|
||||
fn build_shell_link(name: &str, arg: &str) -> windows::core::Result<IShellLinkW> {
|
||||
let link: IShellLinkW =
|
||||
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
|
||||
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| windows::core::Error::from(std::io::Error::other(e)))?;
|
||||
let exe_wide = exe.as_os_str().encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
|
||||
|
||||
unsafe {
|
||||
link.SetPath(PCWSTR(exe_wide.as_ptr()))?;
|
||||
if !arg.is_empty() {
|
||||
link.SetArguments(&HSTRING::from(arg))?;
|
||||
}
|
||||
link.SetDescription(&HSTRING::from(name))?;
|
||||
}
|
||||
// Title (display name) is best-effort — a missing title only means the
|
||||
// jump-list entry falls back to the executable name.
|
||||
let _ = set_link_title(&link, name);
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Populate the jump list with the given (name, arg) vault entries.
|
||||
pub fn update_jumplist(vaults: &[(String, String)]) {
|
||||
// COM must be initialised on this thread.
|
||||
let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
|
||||
let result = (|| -> windows::core::Result<()> {
|
||||
unsafe {
|
||||
SetCurrentProcessExplicitAppUserModelID(PCWSTR(wide_null_terminated(APP_ID).as_ptr()))?;
|
||||
}
|
||||
|
||||
let list: ICustomDestinationList =
|
||||
unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)? };
|
||||
|
||||
let mut min_slots = 0u32;
|
||||
unsafe {
|
||||
list.BeginList::<windows::core::IUnknown>(&mut min_slots)?;
|
||||
}
|
||||
|
||||
// "Vaults" category
|
||||
let collection: IObjectCollection = unsafe {
|
||||
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?
|
||||
};
|
||||
for (name, arg) in vaults {
|
||||
match build_shell_link(name, arg) {
|
||||
Ok(link) => unsafe {
|
||||
let _ = collection.AddObject(&link);
|
||||
},
|
||||
Err(e) => warn!("jumplist: failed to build link for {}: {}", name, e),
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
let array: windows::Win32::UI::Shell::Common::IObjectArray = collection.cast()?;
|
||||
list.AppendCategory(&HSTRING::from("Vaults"), &array)?;
|
||||
list.CommitList()?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(()) => info!("jumplist updated with {} vaults", vaults.len()),
|
||||
Err(e) => warn!("jumplist update failed (non-fatal): {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
/// No-op on non-Windows platforms.
|
||||
pub fn update_jumplist(_vaults: &[(String, String)]) {
|
||||
info!("jumplist is Windows-only — skipped");
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry point: update the Windows jump list from the configured vaults.
|
||||
/// Each entry maps a vault name to the CLI argument the app accepts to open it.
|
||||
pub fn update_jumplist(vaults: &[(String, String)]) {
|
||||
imp::update_jumplist(vaults);
|
||||
}
|
||||
|
||||
/// Build the (name, arg) pairs for the jump list from vault paths.
|
||||
/// Pure helper so the mapping is unit-testable on every platform.
|
||||
pub fn build_vault_args(vaults: &[crate::VaultConfig]) -> Vec<(String, String)> {
|
||||
vaults
|
||||
.iter()
|
||||
.map(|v| (v.name.clone(), format!("--vault={}", v.path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_vault_args;
|
||||
use crate::VaultConfig;
|
||||
|
||||
fn v(name: &str, path: &str) -> VaultConfig {
|
||||
VaultConfig { name: name.to_string(), path: path.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_vault_args_empty() {
|
||||
assert!(build_vault_args(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_vault_args_maps_name_and_path() {
|
||||
let vaults = vec![v("Perso", "C:\\vaults\\perso"), v("IT", "/home/bruno/IT")];
|
||||
let args = build_vault_args(&vaults);
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], ("Perso".to_string(), "--vault=C:\\vaults\\perso".to_string()));
|
||||
assert_eq!(args[1], ("IT".to_string(), "--vault=/home/bruno/IT".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_vault_args_preserves_order() {
|
||||
let vaults = vec![v("a", "/a"), v("b", "/b"), v("c", "/c")];
|
||||
let args = build_vault_args(&vaults);
|
||||
assert_eq!(args.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), vec!["a", "b", "c"]);
|
||||
}
|
||||
}
|
||||
+1072
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
|
||||
+38
-3
@@ -1,7 +1,22 @@
|
||||
# =============================================================================
|
||||
# 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`.
|
||||
# En `docker compose build` direct (sans build.sh), on retombe sur le
|
||||
# DERNIER TAG (v2.2.1) et non 0.0.0-dev, pour ne jamais afficher "0.0.0"
|
||||
# dans le header / la boîte À propos. Packagé par backend/VERSION.
|
||||
VERSION: ${VERSION:-2.2.1}
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
user: "1000:1000"
|
||||
@@ -41,10 +56,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 | 🟢 corrigé | P1 | fichier PDF | IA | `backend/main.py` | `GET /api/file/{vault}/pdf/stream?path=…(pdf à nom accentué)` | `backend/main.py` : Content-Disposition encodé RFC 5987 (helper `_content_disposition`) | 500 car nom Unicode brut dans l'en-tête → header invalide. Vérifié: stream 200 / Range 206 + test `tests/test_pdf_stream.py` |
|
||||
| *BUG-002* | l'ouverture d'un fichier .excalidraw ne fonctionne pas et affiche toujours Loading *Excalidraw…* | 🟢 corrigé | P1 | fichier .excalidraw | IA | `frontend/excalidraw-editor.html` | Ouvrir un fichier `.excalidraw` | `frontend/excalidraw-editor.html` : alias esm.sh supprimé (408 jotai) + React 19 cohérent + prop `excalidrawAPI` | 2 causes: 408 esm.sh sur `?alias` + prop legacy `excalidrawRef` inopérante en 0.18. Vérifié navigateur: Loading masqué + cycle save OK |
|
||||
| | | | | | | | | | |
|
||||
|
||||
### 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) |
|
||||
| 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
## 📜 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)
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
# Plugin System — ObsiGate #61
|
||||
|
||||
## Overview
|
||||
|
||||
ObsiGate's plugin system enables extending the application with custom
|
||||
renderers, search filters, and editor actions — all sandboxed for security.
|
||||
|
||||
Plugins run in a Web Worker sandbox and communicate with the main app via
|
||||
structured `postMessage`. No plugin can access the DOM, localStorage, or
|
||||
make network requests without explicit permission.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installing a Plugin
|
||||
|
||||
1. Go to **Settings > Plugins** in the sidebar
|
||||
2. Click **Install Plugin**
|
||||
3. Upload a `.zip` file or a directory containing:
|
||||
- `plugin.json` — the manifest
|
||||
- Your main JS file (entry point specified in `main`)
|
||||
|
||||
### Creating a Plugin
|
||||
|
||||
Use the **View Template** button in Settings > Plugins to get started.
|
||||
|
||||
## Manifest (plugin.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A brief description of what this plugin does",
|
||||
"author": "your-name",
|
||||
"main": "index.js",
|
||||
"hooks": {
|
||||
"onFileRender": "renderFile",
|
||||
"onSearchFilter": "filterResults",
|
||||
"onEditorAction": "handleAction"
|
||||
},
|
||||
"permissions": ["read_files"],
|
||||
"min_obsigate_version": "2.1.0",
|
||||
"license": "MIT",
|
||||
"homepage": "https://example.com",
|
||||
"repository": "https://github.com/user/plugin"
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|--------|-------------------------------------------------|
|
||||
| `name` | string | Lowercase alphanumeric + hyphens (e.g. `my-plugin`) |
|
||||
| `version` | string | Semantic version (e.g. `1.0.0`, `1.0.0-beta.1`) |
|
||||
| `description` | string | Brief description |
|
||||
| `author` | string | Author name |
|
||||
| `main` | string | Entry point file (relative to plugin root) |
|
||||
|
||||
### Optional Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|------------------------|--------|-------------|------------------------------------------|
|
||||
| `hooks` | object | `{}` | Map of hook name → handler function name |
|
||||
| `permissions` | array | `[]` | Required permissions |
|
||||
| `min_obsigate_version` | string | `"2.1.0"` | Minimum ObsiGate version |
|
||||
| `license` | string | `"MIT"` | License identifier |
|
||||
| `homepage` | string | `null` | Plugin homepage URL |
|
||||
| `repository` | string | `null` | Source repository URL |
|
||||
|
||||
## Available Hooks
|
||||
|
||||
| Hook | When it fires | Handler signature |
|
||||
|-------------------|-----------------------------------|--------------------------------|
|
||||
| `onFileRender` | Before a file is rendered | `(ctx) → transformed ctx` |
|
||||
| `onSearchFilter` | During search result filtering | `(results) → filtered results` |
|
||||
| `onEditorAction` | When editor action is triggered | `(action, state) → result` |
|
||||
| `onSidebarItem` | Sidebar item is rendered | `(item) → enhanced item` |
|
||||
| `onFileCreate` | After a file is created | `(file) → void` |
|
||||
| `onFileDelete` | After a file is deleted | `(file) → void` |
|
||||
| `onVaultMount` | When a vault is mounted | `(vault) → void` |
|
||||
|
||||
## Permissions
|
||||
|
||||
Plugins must declare the permissions they need. The sandbox enforces these
|
||||
restrictions at runtime.
|
||||
|
||||
| Permission | Description |
|
||||
|-----------------------|--------------------------------------------------|
|
||||
| `read_files` | Read file contents from the vault |
|
||||
| `write_files` | Write/create files in the vault |
|
||||
| `read_vault_metadata` | Access vault configuration and metadata |
|
||||
| `network_request` | Make HTTP requests to external services |
|
||||
| `ui_notify` | Show toast notifications in the UI |
|
||||
| `access_clipboard` | Read/write to the system clipboard |
|
||||
|
||||
## Security Model
|
||||
|
||||
### Sandbox Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Main App (browser context) │
|
||||
│ │
|
||||
│ PluginManager │
|
||||
│ ├── install/uninstall │
|
||||
│ ├── enable/disable │
|
||||
│ └── hook dispatch │
|
||||
│ │ postMessage (C3) │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ Web Worker Sandbox │ │
|
||||
│ │ (no DOM, no localStorage│ │
|
||||
│ │ no network by default) │ │
|
||||
│ │ │ │
|
||||
│ │ Plugin code executes │ │
|
||||
│ │ here with structured │ │
|
||||
│ │ message passing only │ │
|
||||
│ └─────────────────────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Security Guarantees
|
||||
|
||||
- **C1**: Manifest validation (name format, semver, allowed hooks/permissions)
|
||||
- **C2**: Vault isolation — plugins are scoped to a single vault
|
||||
- **C3**: Web Worker sandbox — no DOM, no `localStorage`, no `importScripts`
|
||||
- **C4**: ZIP validation — path traversal, file count, size limits
|
||||
- **C5**: Directory validation — manifest + entry point presence
|
||||
- **C6**: Permission enforcement at sandbox boundary
|
||||
|
||||
### Limits
|
||||
|
||||
- Max 50 plugins per vault
|
||||
- Max 500 KB per plugin file
|
||||
- Max 100 files per plugin ZIP
|
||||
- No dynamic `import()` or `eval()`
|
||||
|
||||
## Plugin Storage
|
||||
|
||||
Plugins are installed under `<vault>/.obsigate-plugins/<plugin-name>/`:
|
||||
```
|
||||
.obsigate-plugins/
|
||||
my-plugin/
|
||||
plugin.json # manifest
|
||||
index.js # entry point
|
||||
.disabled # marker file (created when disabled)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Backend Endpoints
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|---------------------------------|---------|--------------------------|
|
||||
| GET | `/api/plugins` | require_auth | List installed plugins |
|
||||
| POST | `/api/plugins/install` | require_admin | Install from ZIP |
|
||||
| DELETE | `/api/plugins/{name}` | require_admin | Uninstall plugin |
|
||||
| POST | `/api/plugins/{name}/enable` | require_admin | Enable plugin |
|
||||
| POST | `/api/plugins/{name}/disable` | require_admin | Disable plugin |
|
||||
| GET | `/api/plugins/{name}/code/{file}` | require_auth | Get plugin code |
|
||||
| GET | `/api/plugins/{name}/hooks` | require_auth | Get plugin hooks |
|
||||
| GET | `/api/plugins/template` | require_auth | Get plugin template |
|
||||
|
||||
### Frontend Module (`plugins.js`)
|
||||
|
||||
```javascript
|
||||
import { PluginManager } from './plugins.js';
|
||||
|
||||
// Install
|
||||
await PluginManager.installPlugin(manifest, code);
|
||||
|
||||
// Enable/Disable
|
||||
await PluginManager.enablePlugin('my-plugin');
|
||||
await PluginManager.disablePlugin('my-plugin');
|
||||
|
||||
// Uninstall
|
||||
await PluginManager.uninstallPlugin('my-plugin');
|
||||
|
||||
// Get code for sandbox
|
||||
const code = await PluginManager.getPluginCode('my-plugin', 'index.js');
|
||||
|
||||
// List
|
||||
const plugins = await PluginManager.getCachedPlugins();
|
||||
```
|
||||
|
||||
## Creating Your First Plugin
|
||||
|
||||
1. Create a directory with `plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hello-world",
|
||||
"version": "1.0.0",
|
||||
"description": "My first ObsiGate plugin",
|
||||
"author": "you",
|
||||
"main": "index.js",
|
||||
"hooks": {
|
||||
"onFileRender": "render"
|
||||
},
|
||||
"permissions": ["read_files", "ui_notify"]
|
||||
}
|
||||
```
|
||||
|
||||
2. Create `index.js`:
|
||||
|
||||
```javascript
|
||||
export function render(ctx) {
|
||||
// Add a custom header to rendered markdown
|
||||
if (ctx.content && ctx.path.endsWith('.md')) {
|
||||
ctx.content = `> 📝 Plugin: hello-world\n\n${ctx.content}`;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
3. Zip the directory and install via Settings > Plugins > Install Plugin.
|
||||
+904
-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,345 @@
|
||||
<!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>
|
||||
|
||||
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
|
||||
The alias gets propagated by esm.sh to every transitive dependency; for
|
||||
range-version deps (e.g. jotai@>=2.9.2) esm.sh then tries to rebuild the
|
||||
range + alias variant and times out (HTTP 408) → the whole import fails →
|
||||
the diagram stays stuck on "Loading Excalidraw…". Dropping the alias lets
|
||||
esm.sh resolve a consistent React 19 (Excalidraw 0.18's default), which
|
||||
loads reliably. -->
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/react@^19.2.0?target=es2022";
|
||||
import { createRoot } from "https://esm.sh/react-dom@^19.2.0/client?target=es2022";
|
||||
import { decompressFromBase64, compressToBase64 } from "https://esm.sh/[email protected]";
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
|
||||
|
||||
window.React = React;
|
||||
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;
|
||||
// Excalidraw fires onChange on initial mount; ignore those so simply
|
||||
// opening a diagram does not trigger a spurious autosave (which would
|
||||
// rewrite a .excalidraw.md file as raw JSON and destroy its format).
|
||||
let ignoreChanges = true;
|
||||
// Source format: "md" for the Obsidian .excalidraw.md (compressed-json).
|
||||
let sourceIsMd = false;
|
||||
|
||||
// --- 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();
|
||||
const data = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements: elements,
|
||||
appState: appState,
|
||||
files: files,
|
||||
};
|
||||
// Preserve the Obsidian .excalidraw.md format: ship the compressed
|
||||
// scene so the parent can rewrite the frontmatter + compressed-json block.
|
||||
if (sourceIsMd) {
|
||||
try {
|
||||
data.compressed = compressToBase64(JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.error("Failed to compress Excalidraw scene:", e);
|
||||
}
|
||||
}
|
||||
sendToParent({ type: "save", data });
|
||||
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);
|
||||
|
||||
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
|
||||
// prop, called with the API object once mounted (NOT the legacy
|
||||
// `excalidrawRef` name). Without this the loading overlay never hides
|
||||
// and save/export stay dead.
|
||||
const onReady = React.useCallback((api) => {
|
||||
if (api) {
|
||||
excalidrawAPI = api;
|
||||
loadingEl.classList.add("hidden");
|
||||
if (theme === "dark") {
|
||||
api.updateScene({ appState: { theme: "dark" } });
|
||||
}
|
||||
// Stop ignoring changes once the initial mount settles.
|
||||
setTimeout(() => { ignoreChanges = false; }, 800);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const onChange = React.useCallback((elements, state, files) => {
|
||||
if (ignoreChanges) return;
|
||||
markDirty();
|
||||
}, []);
|
||||
|
||||
return React.createElement(Excalidraw, {
|
||||
excalidrawAPI: onReady,
|
||||
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) {
|
||||
sourceIsMd = true;
|
||||
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 createRoot !== "function") {
|
||||
throw new Error("createRoot is not available — check react-dom/client import");
|
||||
}
|
||||
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>
|
||||
+2002
-1201
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,63 @@
|
||||
// ObsiGate — Floating Action Button (FAB) for the AI Assistant sidebar.
|
||||
// Always visible in the bottom-right corner; hides while the assistant panel
|
||||
// is expanded. Clicking it opens the assistant in the context that matches
|
||||
// the current application state (open documents, or General when none).
|
||||
import booksLM from './bookslm.js';
|
||||
import { t } from './i18n.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
let _fab = null;
|
||||
|
||||
// The panel is visible when it has `.open` without `.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);
|
||||
}
|
||||
|
||||
// Shared entry point used by the floating FAB and the mobile toolbar button.
|
||||
// Reopens a collapsed panel, otherwise opens (or re-focuses) the assistant
|
||||
// for the current application context.
|
||||
export async function openAssistant() {
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
// Collapsed panel: reveal it instead of reopening.
|
||||
if (panel && panel.classList.contains('open') && panel.classList.contains('hidden')) {
|
||||
booksLM._toggleSidebar();
|
||||
return;
|
||||
}
|
||||
// Otherwise open (or re-focus) the assistant for the current context.
|
||||
await booksLM.openForCurrentContext();
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
await openAssistant();
|
||||
}
|
||||
|
||||
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;
|
||||
+325
-64
@@ -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',
|
||||
@@ -209,6 +376,76 @@ function createSubMenu(items, parentEl) {
|
||||
}
|
||||
|
||||
// ── Main AI Toolbar ──
|
||||
let _activeAiBtn = null;
|
||||
let _aiShortcutBound = false;
|
||||
|
||||
/**
|
||||
* Bind the Ctrl/Cmd+J inline-completion shortcut exactly once, regardless of
|
||||
* how many times the editor (and therefore the toolbar) is re-created.
|
||||
* Without this guard every editor open added a new listener, so pressing the
|
||||
* shortcut fired the AI request N times.
|
||||
*/
|
||||
function _bindAiShortcut() {
|
||||
if (_aiShortcutBound) return;
|
||||
_aiShortcutBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
if (!_activeAiBtn) return;
|
||||
e.preventDefault();
|
||||
_activeAiBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Professional replacement for `window.prompt()` for the custom-rewrite
|
||||
* instruction. Returns a Promise resolving to the entered instruction or
|
||||
* `null` when cancelled. Escape cancels, Ctrl/Cmd+Enter submits.
|
||||
*/
|
||||
function _promptRewriteInstruction() {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ai-modal-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', t('ai.rewrite_title'));
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'ai-modal';
|
||||
modal.innerHTML = `
|
||||
<h3 class="ai-modal-title">${t('ai.custom_rewrite')}</h3>
|
||||
<p class="ai-modal-hint">${t('ai.rewrite_prompt')}</p>
|
||||
<textarea class="ai-modal-input" rows="3" placeholder="${t('ai.rewrite_placeholder')}"></textarea>
|
||||
<div class="ai-modal-actions">
|
||||
<button type="button" class="ai-modal-btn ai-modal-cancel">${t('button.cancel')}</button>
|
||||
<button type="button" class="ai-modal-btn primary ai-modal-ok">${t('ai.rewrite')}</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = modal.querySelector('.ai-modal-input');
|
||||
const close = (value) => {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
resolve(value);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(null); }
|
||||
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
close(input.value.trim() || null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
modal.querySelector('.ai-modal-cancel').addEventListener('click', () => close(null));
|
||||
modal.querySelector('.ai-modal-ok').addEventListener('click', () => close(input.value.trim() || null));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); });
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAIToolbar(container, getEditorView) {
|
||||
// Check if AI is configured
|
||||
let aiConfigured = false;
|
||||
@@ -219,8 +456,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 +469,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 +493,8 @@ 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.setAttribute('aria-label', t('ai.inline_completion_title'));
|
||||
aiBtn.addEventListener('click', async () => {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
@@ -268,9 +506,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,42 +516,43 @@ 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' },
|
||||
{ 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: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
|
||||
], 'Modifie le texte sélectionné');
|
||||
const editBtn = createDropdownBtn(t('ai.toolbar_edit'), [
|
||||
{ label: '🪄 ' + t('ai.improve'), action: () => action('improve'), hint: t('ai.hint_improve') },
|
||||
{ label: '🔤 ' + t('ai.fix_spelling'), action: () => action('fix-spelling'), hint: t('ai.fix_spelling_hint') },
|
||||
{ label: '📏 ' + t('ai.shorter'), action: () => action('make-shorter'), hint: t('ai.shorter_hint') },
|
||||
{ label: '📐 ' + t('ai.longer'), action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||||
{ label: '📋 ' + t('ai.simplify'), action: () => action('simplify'), hint: t('ai.simplify_hint') },
|
||||
], t('ai.hint_edit_selection'));
|
||||
|
||||
// ── Tone menu ──
|
||||
const toneBtn = createDropdownBtn('Ton', [
|
||||
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
|
||||
], 'Change le ton du texte');
|
||||
const toneBtn = createDropdownBtn(t('ai.toolbar_tone'), [
|
||||
{ label: '💼 ' + t('ai.professional'), action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 ' + t('ai.casual'), action: () => action('tone', { tone: 'casual' }) },
|
||||
], t('ai.tone_hint'));
|
||||
|
||||
// ── Translate menu ──
|
||||
const translateBtn = createDropdownBtn('Traduire', [
|
||||
const translateBtn = createDropdownBtn(t('ai.toolbar_translate'), [
|
||||
{ label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) },
|
||||
{ label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) },
|
||||
{ label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) },
|
||||
{ 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', [
|
||||
{ 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');
|
||||
const genBtn = createDropdownBtn(t('ai.toolbar_generate'), [
|
||||
{ label: 'ℹ️ ' + t('ai.explain'), action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 ' + t('ai.summarize'), action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ ' + t('ai.continue'), action: () => action('continue', {}, 'append') },
|
||||
], 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.toolbar_rewrite');
|
||||
rewriteBtn.title = t('ai.rewrite_title');
|
||||
rewriteBtn.setAttribute('aria-label', t('ai.rewrite_title'));
|
||||
Object.assign(rewriteBtn.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'transparent',
|
||||
@@ -327,24 +566,31 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
const instruction = prompt('Instruction de réécriture :', '');
|
||||
if (!text.trim()) {
|
||||
showToast(t('ai.select_text'), 'warning');
|
||||
return;
|
||||
}
|
||||
const instruction = await _promptRewriteInstruction();
|
||||
if (!instruction) return;
|
||||
rewriteBtn.disabled = true;
|
||||
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');
|
||||
} finally {
|
||||
rewriteBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Toolbox menu ──
|
||||
const toolboxBtn = createDropdownBtn('🧰 Boîte', [
|
||||
{ label: '📋 Convert to list', action: () => action('to-list') },
|
||||
{ label: '📊 Convert to table', action: () => action('to-table') },
|
||||
{ label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') },
|
||||
{ label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') },
|
||||
], 'Outils de conversion');
|
||||
const toolboxBtn = createDropdownBtn(t('ai.toolbar_toolbox'), [
|
||||
{ label: '📋 ' + t('ai.to_list'), action: () => action('to-list') },
|
||||
{ label: '📊 ' + t('ai.to_table'), action: () => action('to-table') },
|
||||
{ label: '⚙️ ' + t('ai.frontmatter'), action: () => action('frontmatter', {}, 'before') },
|
||||
{ label: '🔷 ' + t('ai.to_canvas'), action: () => action('to-canvas', {}, 'append') },
|
||||
], t('ai.toolbox_hint'));
|
||||
|
||||
toolbar.appendChild(aiBtn);
|
||||
toolbar.appendChild(createSeparator());
|
||||
@@ -356,6 +602,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 +617,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 +630,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');
|
||||
}
|
||||
@@ -403,9 +656,10 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
el.id = 'ai-processing-indicator';
|
||||
el.className = 'ai-processing-indicator';
|
||||
el.innerHTML = '<span class="ai-spinner"></span> AI...';
|
||||
el.title = 'Traitement AI en cours';
|
||||
el.title = t('ai.processing');
|
||||
el.setAttribute('role', 'status');
|
||||
const header = document.querySelector('#editor-modal .editor-header');
|
||||
if (header) header.appendChild(el);
|
||||
(header || toolbar).appendChild(el);
|
||||
}
|
||||
el.style.display = 'inline-flex';
|
||||
} else if (el) {
|
||||
@@ -413,14 +667,8 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
}
|
||||
}
|
||||
// ── Keyboard shortcut Ctrl+J for inline completion ──
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'j') {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
e.preventDefault();
|
||||
aiBtn.click();
|
||||
}
|
||||
});
|
||||
_activeAiBtn = aiBtn;
|
||||
_bindAiShortcut();
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
@@ -430,6 +678,8 @@ function createDropdownBtn(text, items, tooltip = '') {
|
||||
btn.className = 'ai-toolbar-btn';
|
||||
btn.innerHTML = text + ' ▾';
|
||||
btn.title = tooltip || text;
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
Object.assign(btn.style, {
|
||||
position: 'relative',
|
||||
fontSize: '0.7rem',
|
||||
@@ -451,6 +701,17 @@ 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,
|
||||
_promptRewriteInstruction,
|
||||
};
|
||||
|
||||
+48
-7
@@ -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,29 @@ 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';
|
||||
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.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();
|
||||
|
||||
// 1b. Desktop bridge — crash banner + OS theme sync (before theme engine).
|
||||
// No-op on web (Docker); on desktop it also readies the vault picker.
|
||||
await initDesktopIntegration();
|
||||
|
||||
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
UI.initTheme();
|
||||
UI.initHeaderMenu();
|
||||
UI.initCustomDropdowns();
|
||||
@@ -37,6 +57,7 @@ async function init() {
|
||||
Legacy.initSidebarTabs();
|
||||
Legacy.initHelpModal();
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
@@ -47,10 +68,11 @@ async function init() {
|
||||
UI.FindInPageManager.init();
|
||||
UI.ContextMenuManager.init();
|
||||
UI.TabManager.init();
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
initMobileToolbar();
|
||||
initPush();
|
||||
initPlugins();
|
||||
|
||||
if (authOk) {
|
||||
Legacy.initSyncStatus();
|
||||
@@ -80,15 +102,34 @@ 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();
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
try {
|
||||
await 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é.
|
||||
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
|
||||
window.__OBSIGATE_BOOTED = true;
|
||||
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
|
||||
}
|
||||
// Show version in header — always visible, fallback before the async fetch
|
||||
populateVersions();
|
||||
fetch('/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d && d.version) populateVersions(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 };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user