Compare commits
518
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d986959927 | ||
|
|
714642363b | ||
|
|
df78badd0c | ||
|
|
9836c85e24 | ||
|
|
61a33a7891 | ||
|
|
e707becbf8 | ||
|
|
da3e09c215 | ||
|
|
19e8ba0e4a | ||
|
|
7a6c66a609 | ||
|
|
3bb17eb984 | ||
|
|
bdc15c7328 | ||
|
|
c435e277f2 | ||
|
|
d7e0ace2b7 | ||
|
|
292f3b5851 | ||
|
|
d1d8c6fd2a | ||
|
|
d50fed52ce | ||
|
|
d477c1e058 | ||
|
|
ba363eaee9 | ||
|
|
881c3e3e0a | ||
|
|
c36082be6a | ||
|
|
fbc8d657e7 | ||
|
|
cef01dffaf | ||
|
|
da1fccb38f | ||
|
|
f1dd9d6181 | ||
|
|
6fe5d2f723 | ||
|
|
5c350ff8f6 | ||
|
|
e9b6244ef0 | ||
|
|
f09de98406 | ||
|
|
3b27c57230 | ||
|
|
91b4e8d0e5 | ||
|
|
511ad942cc | ||
|
|
d40fdcafe3 | ||
|
|
15cc2d6f21 | ||
|
|
0112a5b5d8 | ||
|
|
b3c90efa73 | ||
|
|
f84ff201ea | ||
|
|
bb12763a41 | ||
|
|
3913f9f129 | ||
|
|
f8df0e13b5 | ||
|
|
32c81f156d | ||
|
|
8b0a0aea3a | ||
|
|
52d7a0d006 | ||
|
|
bd582866d1 | ||
|
|
b15289b4bc | ||
|
|
6356cd5703 | ||
|
|
3956f1ffa5 | ||
|
|
d96fb4171e | ||
|
|
63a41ade48 | ||
|
|
43aca1a1f7 | ||
|
|
d6bb424021 | ||
|
|
4dc06eb203 | ||
|
|
e4b196a528 | ||
|
|
0c271d5972 | ||
|
|
65559e5069 | ||
|
|
e4d17eb96c | ||
|
|
2391de418d | ||
|
|
917a04d543 | ||
|
|
113f880863 | ||
|
|
571d78115b | ||
|
|
27c9eb98db | ||
|
|
3025a7a44e | ||
|
|
75b7f29826 | ||
|
|
0b63ed17bc | ||
|
|
b594458b6e | ||
|
|
c322f30801 | ||
|
|
bd109c273a | ||
|
|
f57f66a93a | ||
|
|
9ba3480d4e | ||
|
|
34368a4946 | ||
|
|
1c11b9d351 | ||
|
|
3cb9edc1f3 | ||
|
|
e752e46583 | ||
|
|
9eedfa67ca | ||
|
|
56fa5dcd61 | ||
|
|
667eb6534d | ||
|
|
b60cc8a7c6 | ||
|
|
23df10732b | ||
|
|
c809a864e6 | ||
|
|
6e30589133 | ||
|
|
fa97f07ec8 | ||
|
|
5390a6ceab | ||
|
|
aa2db0103d | ||
|
|
2bdf5e4166 | ||
|
|
f7f5bae336 | ||
|
|
814dbe8c2e | ||
|
|
d12681720d | ||
|
|
e64a60c42b | ||
|
|
151ae4a3aa | ||
|
|
4939eb5a12 | ||
|
|
dcd7932a58 | ||
|
|
7c922088c8 | ||
|
|
6ebfc245f1 | ||
|
|
59fc7b7975 | ||
|
|
049861828d | ||
|
|
c586513b03 | ||
|
|
a7289db621 | ||
|
|
9c4de01fd2 | ||
|
|
461492eede | ||
|
|
ea81e85848 | ||
|
|
50273fcb1a | ||
|
|
24a760c5eb | ||
|
|
f7d87e6555 | ||
|
|
1b3aed19ff | ||
|
|
c7c6e52deb | ||
|
|
4bceb7ce91 | ||
|
|
cd6dac9ea6 | ||
|
|
afe670bdd3 | ||
|
|
e32abfbfa6 | ||
|
|
514b1da9a7 | ||
|
|
d19668e60f | ||
|
|
99fee56089 | ||
|
|
d13f13785b | ||
|
|
348793ba13 | ||
|
|
cbba7c506a | ||
|
|
38a188e6e2 | ||
|
|
50aed9e357 | ||
|
|
31db48a40d | ||
|
|
45eefa0c11 | ||
|
|
c4ff0a41e0 | ||
|
|
b76aaad5ee | ||
|
|
c4d654676c | ||
|
|
fbd191c85b | ||
|
|
288f99d81e | ||
|
|
13e0bfb28a | ||
|
|
16abeb9def | ||
|
|
44bf469c53 | ||
|
|
3c5eac3429 | ||
|
|
db4329eee2 | ||
|
|
122ccfb3c3 | ||
|
|
59a62ff2ad | ||
|
|
26d41cb864 | ||
|
|
10cbd7f52e | ||
|
|
ec96619341 | ||
|
|
d7c1f4c03e | ||
|
|
3fcfa8fdbb | ||
|
|
ce2bae1f1b | ||
|
|
85b4d624b8 | ||
|
|
ef8d4e6bca | ||
|
|
27352c84e5 | ||
|
|
bf329a4ba0 | ||
|
|
b505cfc156 | ||
|
|
3079d53cab | ||
|
|
d3bada54f0 | ||
|
|
eac6e5aed3 | ||
|
|
55853db625 | ||
|
|
fd1a4319b4 | ||
|
|
b818bc908f | ||
|
|
8a94c3b676 | ||
|
|
53d31572e6 | ||
|
|
3fcc12ad8c | ||
|
|
b88a972531 | ||
|
|
3d10400f8f | ||
|
|
9e45ea8870 | ||
|
|
d94dd3da99 | ||
|
|
aa434e9fe9 | ||
|
|
45911438f0 | ||
|
|
5ac8dd10b2 | ||
|
|
eca4067c82 | ||
|
|
fff502a700 | ||
|
|
fbd4b836f7 | ||
|
|
b835dd6b5e | ||
|
|
5372f4f22f | ||
|
|
39b485622d | ||
|
|
aba771400d | ||
|
|
2ca27d3398 | ||
|
|
9422753e8b | ||
|
|
3a94b06af4 | ||
|
|
b0d222b81a | ||
|
|
0ea447ee6f | ||
|
|
63773cb904 | ||
|
|
ec3ad6b1de | ||
|
|
cefc7eb356 | ||
|
|
de62628cb7 | ||
|
|
4806097bc5 | ||
|
|
1fc4c21e2a | ||
|
|
0d9c1bf9ee | ||
|
|
a49a9bae9b | ||
|
|
d5ba29714c | ||
|
|
feb29cbff3 | ||
|
|
79bc1bf1fd | ||
|
|
63c9a5fced | ||
|
|
cbebd1f537 | ||
|
|
b2486a6e11 | ||
|
|
0e99dc2251 | ||
|
|
68f7f97b0d | ||
|
|
8514386a9b | ||
|
|
ed465333e4 | ||
|
|
c49e152e7c | ||
|
|
8e6ed1e251 | ||
|
|
a5831456e1 | ||
|
|
0a675a94f7 | ||
|
|
47b0f80dfe | ||
|
|
c94dac2bbf | ||
|
|
29d1ccb3dc | ||
|
|
8d3bb36982 | ||
|
|
98bda37484 | ||
|
|
9100da74f3 | ||
|
|
74883688ef | ||
|
|
a5242ee79b | ||
|
|
10d10b28c8 | ||
|
|
2471119b4a | ||
|
|
02cf759ec8 | ||
|
|
978b286990 | ||
|
|
74102f00ab | ||
|
|
5dc8bd5a33 | ||
|
|
c2eb088bb2 | ||
|
|
5967dd9056 | ||
|
|
c681018262 | ||
|
|
a94794ec81 | ||
|
|
1bafbf1eff | ||
|
|
d3923c1d9a | ||
|
|
9f17c39599 | ||
|
|
2ee0a05efd | ||
|
|
04c059341d | ||
|
|
31fba6da39 | ||
|
|
b7c9401c92 | ||
|
|
33933352a8 | ||
|
|
6a2d56a7bd | ||
|
|
28a41a30da | ||
|
|
08c823d758 | ||
|
|
d97a515eef | ||
|
|
5b56f970ee | ||
|
|
37c8e99151 | ||
|
|
298617af58 | ||
|
|
2226e5e2c8 | ||
|
|
2534e2b425 | ||
|
|
bd02c9ea8a | ||
|
|
e85161dfc1 | ||
|
|
fe64617677 | ||
|
|
25386bc79f | ||
|
|
d751415308 | ||
|
|
6f1eabf91d | ||
|
|
84a126cfd1 | ||
|
|
7ea1c852dc | ||
|
|
c2ef7bfaa0 | ||
|
|
c990382085 | ||
|
|
b141af886d | ||
|
|
56c5739605 | ||
|
|
7613129204 | ||
|
|
f61f8b61ae | ||
|
|
3210ac65a6 | ||
|
|
cef267d7b5 | ||
|
|
043dd4871c | ||
|
|
b5dd37a652 | ||
|
|
d4fb095baf | ||
|
|
8c0b55635c | ||
|
|
631c106b01 | ||
|
|
30e1879ac2 | ||
|
|
6e4adaad8b | ||
|
|
50f8e0a938 | ||
|
|
4217978eaa | ||
|
|
ae2b1c5664 | ||
|
|
5b6b251119 | ||
|
|
0edcdbe82a | ||
|
|
f6a022edf2 | ||
|
|
6f40c8953a | ||
|
|
72636a77ea | ||
|
|
76c8a9a53c | ||
|
|
07a4f5ece6 | ||
|
|
d645126b53 | ||
|
|
7cefc08abc | ||
|
|
61174ee967 | ||
|
|
aa64863bf7 | ||
|
|
efd957e827 | ||
|
|
78092e6111 | ||
|
|
ec96fb86a5 | ||
|
|
3fdcc41d10 | ||
|
|
17666b51c2 | ||
|
|
aedb07c962 | ||
|
|
bf19af2347 | ||
|
|
fc6f2531b7 | ||
|
|
0d8507a5c7 | ||
|
|
b83d9b6d35 | ||
|
|
3c8529fd12 | ||
|
|
c5ffab1e39 | ||
|
|
f4cd301f52 | ||
|
|
de86457f90 | ||
|
|
364560c84b | ||
|
|
0429ffd824 | ||
|
|
a7767f3a94 | ||
|
|
e8f4cfb631 | ||
|
|
7c3f62d094 | ||
|
|
9a063bc766 | ||
|
|
234b880c5b | ||
|
|
724ff4c880 | ||
|
|
15bd9d5ed9 | ||
|
|
29ef0fb054 | ||
|
|
bd6fd62734 | ||
|
|
802d681212 | ||
|
|
8eb7049460 | ||
|
|
e2043123f0 | ||
|
|
8cca247fcd | ||
|
|
cb44652554 | ||
|
|
921f1b7bc1 | ||
|
|
8458cf4a29 | ||
|
|
b755f75f15 | ||
|
|
b771708bdc | ||
|
|
1e15e44a00 | ||
|
|
7edeb373f1 | ||
|
|
449550ac90 | ||
|
|
72dc4771b4 | ||
|
|
ec9eb99c98 | ||
|
|
de40c2d83e | ||
|
|
e04b3a38a4 | ||
|
|
b863afab59 | ||
|
|
a497c129d3 | ||
|
|
6f15ad3ad4 | ||
|
|
f25c8ebaf0 | ||
|
|
06bf016392 | ||
|
|
17824deae2 | ||
|
|
e3851b4f12 | ||
|
|
6c8327c021 | ||
|
|
1e36b03532 | ||
|
|
aa2354a25a | ||
|
|
ef88ee4c55 | ||
|
|
86b34dc063 | ||
|
|
6d98070986 | ||
|
|
0d66b5d543 | ||
|
|
e22efc1d9c | ||
|
|
c1f854a54a | ||
|
|
5cc27b4535 | ||
|
|
caa00c54bc | ||
|
|
6b000d892b | ||
|
|
e78ca4b775 | ||
|
|
5fe31aeffa | ||
|
|
7cbb226e62 | ||
|
|
e9d1c32b4f | ||
|
|
46336df21b | ||
|
|
00860417a8 | ||
|
|
5baae598bf | ||
|
|
9f667ae9e0 | ||
|
|
5d1857941e | ||
|
|
f262076545 | ||
|
|
4ea512d007 | ||
|
|
e42c5e1e4b | ||
|
|
e01e410d43 | ||
|
|
9e9ea181e6 | ||
|
|
057ff9f524 | ||
|
|
253f5b215c | ||
|
|
97d6ad7a91 | ||
|
|
ea591bd577 | ||
|
|
91032de704 | ||
|
|
51c6002f08 | ||
|
|
53865f136d | ||
|
|
c524478ff2 | ||
|
|
0a0a330b55 | ||
|
|
92eca626b7 | ||
|
|
0ad1a69994 | ||
|
|
3e291ddc67 | ||
|
|
a57b435bd2 | ||
|
|
c096f09b79 | ||
|
|
74651ee26c | ||
|
|
282eecf80c | ||
|
|
b865b5da6c | ||
|
|
589035336d | ||
|
|
c8685b6dcc | ||
|
|
b555b67546 | ||
|
|
a77eab6050 | ||
|
|
b94d25bff5 | ||
|
|
ca73c6902f | ||
|
|
6f8976817a | ||
|
|
ae3f8b473a | ||
|
|
3718ad488c | ||
|
|
e34ef6193c | ||
|
|
9a0a8893c8 | ||
|
|
1c88afda85 | ||
|
|
2f3e52ebb0 | ||
|
|
c3291b0231 | ||
|
|
9d300d1984 | ||
|
|
996e50bb06 | ||
|
|
cce132d771 | ||
|
|
dc630c9ba7 | ||
|
|
9ee0079a02 | ||
|
|
40a5d4edeb | ||
|
|
4dc9ff29b8 | ||
|
|
aec11a670a | ||
|
|
6e05f9e700 | ||
|
|
b32b94e863 | ||
|
|
58eacaa9d6 | ||
|
|
85e73f8c49 | ||
|
|
94e5e9c9f4 | ||
|
|
2abcfcf7d9 | ||
|
|
6cc94ecefa | ||
|
|
6db8eba670 | ||
|
|
ed0510ec9b | ||
|
|
48f33964b0 | ||
|
|
14ad34c886 | ||
|
|
4668eb77ed | ||
|
|
329948cf4c | ||
|
|
130a8a09ad | ||
|
|
1f31e33b69 | ||
|
|
97eda84cd1 | ||
|
|
7fe7a91788 | ||
|
|
6d0647f83d | ||
|
|
e3968356e2 | ||
|
|
d01b51bd5b | ||
|
|
4bdd4dfd90 | ||
|
|
4f66bf2312 | ||
|
|
e0987e38ff | ||
|
|
6b2abcd9f2 | ||
|
|
ec86c32245 | ||
|
|
d7c0d428e8 | ||
|
|
ab0eedd5fe | ||
|
|
b74e144ab3 | ||
|
|
c38b973281 | ||
|
|
004c47df34 | ||
|
|
17f158ca18 | ||
|
|
6e2b2ff757 | ||
|
|
7fa9a44dbb | ||
|
|
75dbdf2d4f | ||
|
|
21806aa14e | ||
|
|
e36dead312 | ||
|
|
f4ad4f5b6d | ||
|
|
34a0438764 | ||
|
|
e92c788e3d | ||
|
|
56682cc576 | ||
|
|
d8a2cebdd6 | ||
|
|
a43dcfcb9f | ||
|
|
5517dc83a8 | ||
|
|
0c70e320e0 | ||
|
|
6f06c232ba | ||
|
|
b377424412 | ||
|
|
446ef0dfbf | ||
|
|
ab8d7ce9c8 | ||
|
|
df44285df6 | ||
|
|
335120c200 | ||
|
|
f6aaaa123f | ||
|
|
8497a36da9 | ||
|
|
fc6e30aebb | ||
|
|
a8f9832535 | ||
|
|
08d2bac793 | ||
|
|
d97f79f737 | ||
|
|
51cad3ee16 | ||
|
|
5e3bcdcd63 | ||
|
|
47299113a8 | ||
|
|
722c7103de | ||
|
|
8bd737cef6 | ||
|
|
37b1a7b078 | ||
|
|
c59f38659e | ||
|
|
2314af9825 | ||
|
|
4be4c3432b | ||
|
|
4810ff18eb | ||
|
|
e9a419b83f | ||
|
|
54abe8ac3b | ||
|
|
c5398757ca | ||
|
|
e2a739c4c6 | ||
|
|
b01c5bbfe3 | ||
|
|
46e7c1d815 | ||
|
|
1e0afb0e69 | ||
|
|
05369f1856 | ||
|
|
418fe19f33 | ||
|
|
f8350f8161 | ||
|
|
c36a446451 | ||
|
|
1ab5b1271f | ||
|
|
373153de69 | ||
|
|
2f529e4d57 | ||
|
|
b8fa5f4d27 | ||
|
|
a531d6d466 | ||
|
|
2ca7834b43 | ||
|
|
3363a070cf | ||
|
|
0c54db66ac | ||
|
|
eddb69e58e | ||
|
|
1639884800 | ||
|
|
1ae4aa4ab4 | ||
|
|
ae0b964859 | ||
|
|
3e6323a856 | ||
|
|
eb417d4ed5 | ||
|
|
9542353b55 | ||
|
|
3417506062 | ||
|
|
47463a62e0 | ||
|
|
237914dfcd | ||
|
|
f28a80dc95 | ||
|
|
9103ee94fa | ||
|
|
4b45f52321 | ||
|
|
fc8012009a | ||
|
|
be73e08184 | ||
|
|
21e7e30b61 | ||
|
|
7f5ad2aaca | ||
|
|
db8d577c09 | ||
|
|
0593c588e9 | ||
|
|
608d44c33a | ||
|
|
1f26d61997 | ||
|
|
d4dcb06aee | ||
|
|
d3ace7f3ab | ||
|
|
32c1f70c53 | ||
|
|
246aef65ac | ||
|
|
ca144e29d1 | ||
|
|
e0102d2617 | ||
|
|
806ec495f0 | ||
|
|
f87aaa7ead | ||
|
|
be98f9d504 | ||
|
|
79b0bdd22d | ||
|
|
5850085c8b | ||
|
|
4f0bd85e35 | ||
|
|
db6acf0ec3 | ||
|
|
f7d9d91723 | ||
|
|
6033a0b1e4 | ||
|
|
c75cdab134 | ||
|
|
fe4e28e23c | ||
|
|
4a58261c02 | ||
|
|
124bb3cd9f | ||
|
|
ed1b5d4193 | ||
|
|
d92e26f01b | ||
|
|
6015a415d4 | ||
|
|
65fcda0ca0 | ||
|
|
b1304b76e8 | ||
|
|
85d983c56b | ||
|
|
f5fedf1d7b | ||
|
|
b2426a3504 | ||
|
|
0de4f411bd | ||
|
|
fbf0335c3a | ||
|
|
a251ccfe2d | ||
|
|
3ed2181e89 | ||
|
|
c86c04ca22 | ||
|
|
6b67b25d27 | ||
|
|
d940481a6f | ||
|
|
8aaf1676c0 | ||
|
|
7c8f8d719e |
@@ -5,6 +5,17 @@ GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
|
||||
# ── GitHub ──
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# ── OAuth2 ──
|
||||
# Laisser VIDE = redirect URI dynamique (dérivée du Host/X-Forwarded-* de la requête).
|
||||
# Ne définir QUE si on veut forcer une URI exacte — elle DOIT être enregistrée
|
||||
# dans l'application OAuth2 côté Gitea/GitHub (Settings → Applications).
|
||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||
OAUTH_REDIRECT_URI=
|
||||
|
||||
# ── App ──
|
||||
APP_SECRET_KEY=change-me-to-random
|
||||
APP_HOST=0.0.0.0
|
||||
@@ -20,3 +31,25 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
# ── Sync ──
|
||||
SYNC_INTERVAL=60
|
||||
GITEA_CACHE_TTL=30
|
||||
|
||||
# ── Backups (v5.2.0) ──
|
||||
# Sauvegarde automatique quotidienne du fichier SQLite (fichiers datés).
|
||||
BACKUP_ENABLED=true
|
||||
BACKUP_DIR=/data/backups
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_KEEP=30
|
||||
|
||||
# ── Forge projects sync (v5.2.0) ──
|
||||
# Rafraîchissement périodique de la table `projects` depuis les forges connectées.
|
||||
PROJECT_SYNC_ENABLED=true
|
||||
PROJECT_SYNC_INTERVAL_HOURS=1
|
||||
|
||||
# ── Email notifications (v4.9.0) ──
|
||||
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=FlowDeck <[email protected]>
|
||||
SMTP_USE_TLS=true
|
||||
APP_BASE_URL=http://localhost:8080
|
||||
|
||||
+39
-10
@@ -1,28 +1,57 @@
|
||||
name: FlowDeck CI
|
||||
|
||||
on:
|
||||
# Run on every pushed branch so feature branches are validated before the PR.
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-cov
|
||||
- name: Run tests with coverage
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
run: ruff check app tests
|
||||
- name: ESLint (JavaScript)
|
||||
run: npx --yes eslint static/js
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
|
||||
# JavaScript `actions/checkout` action could not run and every job failed at
|
||||
# the first step. The runner's default image already provides Node; we install
|
||||
# the Python toolchain explicitly with actions/setup-python.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
|
||||
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r requirements-dev.txt pytest-cov
|
||||
- name: Run tests (parallel) with coverage
|
||||
env:
|
||||
GITEA_URL: https://git.dracodev.net
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
APP_SECRET_KEY: ci-test-key
|
||||
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term
|
||||
# `-n auto` needs pytest-xdist, provided by requirements-dev.txt.
|
||||
run: python -m pytest tests/ -v --tb=short -n auto --cov=app --cov-report=term
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
python -m pytest tests/ --cov=app --cov-report=term 2>&1 | tail -20
|
||||
if: always()
|
||||
run: coverage report -m || true
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -4,7 +4,16 @@ __pycache__/
|
||||
/data/
|
||||
.venv/
|
||||
venv/
|
||||
.venv*/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Understand-Anything: exclude intermediate files and local diff overlay
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
.ua/tmp/
|
||||
.ua/.trash-*/
|
||||
.ua/.understandignore
|
||||
uv.lock
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"outputLanguage":"fr"}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"lastAnalyzedAt": "2026-07-10T12:53:39Z",
|
||||
"gitCommitHash": "c86c04ca220672fb012bb69916e930102be44ef4",
|
||||
"version": "1.0.0",
|
||||
"analyzedFiles": 67
|
||||
}
|
||||
+467
-65
@@ -1,7 +1,8 @@
|
||||
# Architecture FlowDeck — Document Complet v2.0
|
||||
# Architecture FlowDeck — Document Complet v4.0
|
||||
|
||||
> **Version:** 2.0 · **Date:** 2026-07-10 · **Auteur:** Hermes-Deepin
|
||||
> **Cible:** Clone Notion intégré à Gitea avec support multi-utilisateurs
|
||||
> **Version:** 4.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
|
||||
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
|
||||
> **Version déployée:** v4.0.x (production : https://flowdeck.dracodev.net)
|
||||
|
||||
---
|
||||
|
||||
@@ -47,34 +48,31 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Templates Jinja2 (SSR) │ │
|
||||
│ │ ├─ dashboard.html — Projets + sidebar │ │
|
||||
│ │ ├─ board.html — Kanban (wrapper) │ │
|
||||
│ │ ├─ board_fragment.html— Cartes Kanban │ │
|
||||
│ │ ├─ table_view.html — Vue table │ │
|
||||
│ │ ├─ calendar_view.html — Vue calendrier (v1.6) │ │
|
||||
│ │ ├─ timeline_view.html — Vue Gantt (v1.6) │ │
|
||||
│ │ ├─ gallery_view.html — Vue galerie (v1.6) │ │
|
||||
│ │ ├─ list_view.html — Vue liste (v1.6) │ │
|
||||
│ │ ├─ status_overview.html — Donut SVG │ │
|
||||
│ │ ├─ team_load.html — Barres stacked │ │
|
||||
│ │ ├─ detailed_board.html— Board détaillé │ │
|
||||
│ │ ├─ card_detail.html — Modale détail carte │ │
|
||||
│ │ ├─ card.html — Carte individuelle │ │
|
||||
│ │ ├─ page_editor.html — Éditeur de blocs │ │
|
||||
│ │ ├─ my_tasks.html — Dashboard unifié (v1.9) │ │
|
||||
│ │ ├─ library.html — Bibliothèque de pages │ │
|
||||
│ │ ├─ trash.html — Corbeille │ │
|
||||
│ │ ├─ accounts.html — Gestion comptes │ │
|
||||
│ │ ├─ notes.html — Notes Markdown │ │
|
||||
│ │ └─ base.html — Layout commun │ │
|
||||
│ │ ├─ base.html — Layout Notion commun (sidebar) │ │
|
||||
│ │ ├─ _header.html — Topbar + breadcrumbs │ │
|
||||
│ │ ├─ _workspace_tree_macro.html — Macro arbre sidebar │ │
|
||||
│ │ ├─ landing.html — Landing page non-auth │ │
|
||||
│ │ ├─ workspaces.html — Page Workspaces (locale/Gitea) │ │
|
||||
│ │ ├─ library.html — Bibliothèque multi-onglets │ │
|
||||
│ │ ├─ local_workspace.html — Workspace local (explorer) │ │
|
||||
│ │ ├─ page_editor.html — Éditeur de blocs │ │
|
||||
│ │ ├─ trash.html — Corbeille (local only) │ │
|
||||
│ │ ├─ settings.html — Settings / My Account / Admin │ │
|
||||
│ │ ├─ accounts.html — Gestion comptes admin │ │
|
||||
│ │ ├─ board.html — Kanban (wrapper) │ │
|
||||
│ │ ├─ board_fragment.html — Cartes Kanban │ │
|
||||
│ │ ├─ table_view.html — Vue table │ │
|
||||
│ │ ├─ login.html — Login/Register (local + OAuth) │ │
|
||||
│ │ ├─ public_page.html — Page publiée (/p/slug) │ │
|
||||
│ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ JavaScript (CDN, pas de build step) │ │
|
||||
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
|
||||
│ │ JavaScript (servi localement, pas de CDN externe) │ │
|
||||
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
|
||||
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
|
||||
│ │ ├─ SortableJS 1.15 — Drag & drop │ │
|
||||
│ │ └─ highlight.js — Syntax highlighting (v2.0) │ │
|
||||
│ │ └─ Prism.js — Syntax highlighting │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
@@ -95,17 +93,27 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ ROUTERS │ │
|
||||
│ │ ├─ dashboard.py — / Dashboard │ │
|
||||
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
|
||||
│ │ │ — /board/api/... APIs board │ │
|
||||
│ │ ├─ api.py — /api/... CRUD + sync │ │
|
||||
│ │ ├─ notes.py — /notes/{o}/{r} Notes │ │
|
||||
│ │ ├─ auth.py — /auth/... OAuth2 │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ ├─ my_tasks.py — /my-tasks (v1.9) │ │
|
||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||
│ │ ├─ collections.py — /db/... Collections │ │
|
||||
│ │ └─ editor.py — /api/editor/... Block editor │ │
|
||||
│ │ ├─ main.py — FastAPI app, lifespan, CORS, 404 │ │
|
||||
│ │ ├─ dashboard.py — /, /workspaces, /library, /help │ │
|
||||
│ │ │ — /trash, /settings, /accounts │ │
|
||||
│ │ │ — /p/{slug} (pages publiques) │ │
|
||||
│ │ │ — _sidebar_data() — données sidebar │ │
|
||||
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
|
||||
│ │ │ — /board/api/pages CRUD pages │ │
|
||||
│ │ │ — /board/api/trash Trash │ │
|
||||
│ │ ├─ api.py — /api/... CRUD + sync │ │
|
||||
│ │ ├─ auth.py — /auth/... OAuth2 + local │ │
|
||||
│ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │
|
||||
│ │ ├─ my_tasks.py — /my-tasks Dashboard │ │
|
||||
│ │ ├─ library.py — /api/library/* API bibliothèque│ │
|
||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||
│ │ ├─ collections.py — /db/... Collections │ │
|
||||
│ │ ├─ editor.py — /api/editor/... Block editor │ │
|
||||
│ │ ├─ private.py — /api/private/* Section privée │ │
|
||||
│ │ ├─ public_api.py — /api/public/* Public API │ │
|
||||
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ └─ admin.py — /api/admin/* Admin users │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
@@ -138,6 +146,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │ │ ├─ property_values (legacy → collection_pages) │
|
||||
│ │ │ ├─ ai_keywords │ │ │
|
||||
│ │ │ ├─ pages │ │ │
|
||||
│ │ │ ├─ page_shares (v4.0) │ │ │
|
||||
│ │ │ ├─ recents (v4.0) │ │ │
|
||||
│ │ │ ├─ tags (v4.0) │ │ │
|
||||
│ │ │ ├─ page_tags (v4.0) │ │ │
|
||||
│ │ │ ├─ gitea_private_pages (v4.0) │ │ │
|
||||
│ │ │ ├─ users │ │ │
|
||||
│ │ │ ├─ user_tokens │ │ │
|
||||
│ │ │ ├─ workspaces (v2.0) │ │ │
|
||||
@@ -685,42 +698,146 @@ POST /favorites → Ajouter favori
|
||||
DELETE /favorites/{id} → Retirer favori
|
||||
```
|
||||
|
||||
### 4.6 Routes Sidebar & Library (v4.0)
|
||||
|
||||
```
|
||||
GET /library → Bibliothèque dynamique
|
||||
GET /api/library/recents → Pages récentes
|
||||
GET /api/library/favorites → Favoris
|
||||
GET /api/library/shared → Pages partagées
|
||||
GET /api/library/published → Pages publiées
|
||||
GET /api/library/private → Pages privées
|
||||
GET /api/library/workspace → Pages du workspace
|
||||
|
||||
GET /api/local-workspace/items → Arborescence workspace
|
||||
POST /api/local-workspace/items → Créer page/dossier
|
||||
PUT /api/local-workspace/items/{id} → Renommer
|
||||
DELETE /api/local-workspace/items/{id} → Soft delete
|
||||
POST /api/local-workspace/upload → Upload fichier
|
||||
GET /api/files/{ws_id}/{filename} → Servir fichier uploadé
|
||||
|
||||
GET /help → Page d'aide complète
|
||||
GET /api/public/pages/{slug} → Page publique
|
||||
```
|
||||
|
||||
### 4.7 Routes Share & Publish (v4.0)
|
||||
|
||||
```
|
||||
POST /api/pages/{id}/share → Partager une page
|
||||
DELETE /api/pages/{id}/share/{share_id} → Retirer partage
|
||||
GET /api/pages/{id}/shares → Liste partages
|
||||
POST /api/pages/{id}/publish → Publier page
|
||||
DELETE /api/pages/{id}/publish → Dépublier
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend
|
||||
## 5. Système d'authentification (v4.0)
|
||||
|
||||
FlowDeck supporte **3 méthodes d'authentification** et la **connexion d'intégrations multiples** à un même compte local.
|
||||
|
||||
### 5.1 Types de comptes
|
||||
|
||||
| Type | Auth | Workspaces | Intégrations |
|
||||
|------|------|------------|--------------|
|
||||
| **Local pur** | email + password | Locaux uniquement | Aucune |
|
||||
| **Local + intégrations** | email + password | Locaux + distants | Gitea et/ou GitHub |
|
||||
| **OAuth pur** | Gitea ou GitHub | Distants uniquement | Non-déconnectable |
|
||||
|
||||
### 5.2 Comportement du sidebar selon le type de compte
|
||||
|
||||
```
|
||||
LOCAL PUR:
|
||||
├─ 📁 [nom workspace local]
|
||||
├─ 📅 Meetings
|
||||
├─ 🕒 Recents
|
||||
├─ ⭐ Favorites
|
||||
├─ 🤖 Agents
|
||||
├─ 👥 Shared ← visible si pages partagées
|
||||
├─ 🌐 Published ← visible si pages publiées
|
||||
├─ (Private caché)
|
||||
└─ 📚 Library / ✅ My Tasks / 🗑️ Trash / ❓ Help
|
||||
|
||||
LOCAL + GITEA/GITHUB:
|
||||
├─ 🔗 [owner/repo] ← workspace distant actif
|
||||
├─ 📅 Meetings
|
||||
├─ 🕒 Recents
|
||||
├─ ⭐ Favorites
|
||||
├─ 🤖 Agents
|
||||
├─ 👥 Shared
|
||||
├─ 🌐 Published
|
||||
├─ 🔒 Private ← visible (fichiers locaux liés au projet distant)
|
||||
└─ 📚 / ✅ / 🗑️ / ❓
|
||||
|
||||
OAUTH PUR (GITEA/GITHUB):
|
||||
├─ 🔗 [owner/repo] ← identité = compte Gitea/GitHub
|
||||
├─ 🕒 Recents
|
||||
├─ ⭐ Favorites
|
||||
├─ 👥 Shared
|
||||
├─ 🌐 Published
|
||||
├─ (Private caché)
|
||||
└─ 📚 / ✅ / ❓ (Trash caché — pas de workspace local)
|
||||
```
|
||||
|
||||
### 5.3 Règles d'affichage
|
||||
|
||||
- **Section Private** : visible UNIQUEMENT si compte local + workspace distant actif
|
||||
- **Trash** : visible si `auth_method == 'local'` ou si des pages locales existent
|
||||
- **Boutons New File/New Folder** : cachés si `has_active_workspace == False`
|
||||
- **Message "No workspace open"** : affiché dans la section Workspace si aucun workspace actif
|
||||
- **Header sidebar** : toujours le compte local si auth_method=local, même avec intégrations
|
||||
|
||||
### 5.4 cookie `flowdeck_workspace`
|
||||
|
||||
- `""` → aucun workspace actif
|
||||
- `"42"` → workspace local ID 42
|
||||
- `"gitea:owner:repo"` → workspace distant Gitea
|
||||
- Nettoyage automatique si cookie invalide (workspace supprimé ou autre user)
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
### 5.1 Layout
|
||||
|
||||
```
|
||||
```ascii
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ TOPBAR (44px, sticky) │
|
||||
│ ┌──────────┬──────────────────────────────────┬────────────┐ │
|
||||
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │
|
||||
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │
|
||||
│ └──────────┴──────────────────────────────────┴────────────┘ │
|
||||
│ TOPBAR (unified-header) │
|
||||
│ ┌──────┬───────────────────────────────────────┬───────────┐ │
|
||||
│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
|
||||
│ └──────┴───────────────────────────────────────┴───────────┘ │
|
||||
├────────────┬─────────────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENU PRINCIPAL │
|
||||
│ (240px) │ │
|
||||
│ │ ┌─────────────────────────────────────────────┐│
|
||||
│ Workspace │ │ DATABASE VIEW ││
|
||||
│ header │ │ ┌──────┬──────┬──────┬──────┬──────────┐ ││
|
||||
│ │ │ │Title │Statut│Priori│Assign│Due Date │ ││
|
||||
│ 🔍 Search │ │ ├──────┼──────┼──────┼──────┼──────────┤ ││
|
||||
│ │ │ │Tâche1│Todo │ P1 │Bruno │15 juil │ ││
|
||||
│ ▼ Recents │ │ │Tâche2│Doing │ P2 │ — │20 juil │ ││
|
||||
│ · Proj A │ │ │Tâche3│Done │ P3 │Marie │10 juil │ ││
|
||||
│ · Proj B │ │ └──────┴──────┴──────┴──────┴──────────┘ ││
|
||||
│ │ │ [+ New] [Filter] [Sort] [Search] [...] ││
|
||||
│ ▼ Private │ └─────────────────────────────────────────────┘│
|
||||
│ · Notes │ │
|
||||
│ │ ┌─ Barre d'outils ────────────────────────────┐│
|
||||
│ ▼ Shared │ │ [Table▼] [Kanban] [Calendar] [Gallery] ... ││
|
||||
│ · ... │ │ [Group▼] [Filter▼] [Sort▼] [Properties▼] ││
|
||||
│ │ ┌─ Help Page ────────────────────────────────┐│
|
||||
│ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││
|
||||
│ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││
|
||||
│ │ [D] │ │ │ │ Started │ │ & Editor │ ││
|
||||
│ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││
|
||||
│ └────────┘ │ └─────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
│ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│
|
||||
│ │ │ [Recents|Favorites|Shared|Published|...] ││
|
||||
│ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││
|
||||
│ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││
|
||||
│ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││
|
||||
│ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││
|
||||
│ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││
|
||||
│ │ │ └──────┴──────────┴──────────┴───────────┘ ││
|
||||
│ 📅 Meeting │ └─────────────────────────────────────────────┘│
|
||||
│ 🕒 Recents │ │
|
||||
│ ⭐ Favoris │ ┌─ Workspaces ───────────────────────────────┐│
|
||||
│ 🤖 Agents │ │ ┌──────────┐ ┌──────────┐ ┌───┐ ┌───┐ ││
|
||||
│ 👥 Shared │ │ │Project A │ │Project B │ │ + │ │ + │ ││
|
||||
│ 🌐 Publ. │ │ │3 pages │ │12 pages │ │ │ │ │ ││
|
||||
│ │ │ └──────────┘ └──────────┘ └───┘ └───┘ ││
|
||||
│ ══════════ │ └─────────────────────────────────────────────┘│
|
||||
│ 📚 Library │ │
|
||||
│ ✅ My Task │ ┌─ Settings ─────────────────────────────────┐│
|
||||
│ 🗑️ Trash │ │ My Account | Admin | Integrations ││
|
||||
│ ❓ Help │ │ [Upload photo] [Full Name] [Email] [...] ││
|
||||
│ │ └─────────────────────────────────────────────┘│
|
||||
│ ────────── │ │
|
||||
│ [+New page]│ ┌─ Contenu de la vue ─────────────────────────┐│
|
||||
│ (sticky │ │ (table / board / calendar / gallery / ...) ││
|
||||
│ footer) │ └─────────────────────────────────────────────┘│
|
||||
│ 💬 [+📄] │ │
|
||||
└────────────┴─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -800,6 +917,257 @@ User authorize ──→ GET /auth/callback?code=xxx
|
||||
|
||||
---
|
||||
|
||||
## 6.5 Modèle de comptes & intégrations (v4.0)
|
||||
|
||||
### 6.5.1 Types de comptes
|
||||
|
||||
FlowDeck supporte 3 types de comptes, déterminés par `auth_method` dans la table `users` :
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────────┬─────────────────────────────────────┐
|
||||
│ auth_method │ Login via │ Identité dans le sidebar │
|
||||
├──────────────┼──────────────────┼─────────────────────────────────────┤
|
||||
│ local │ email + password │ Nom local (ex: "Draco") │
|
||||
│ gitea │ OAuth2 Gitea │ Nom Gitea + badge 🦎 Gitea │
|
||||
│ github │ OAuth2 GitHub │ Nom GitHub + badge 🐙 GitHub │
|
||||
└──────────────┴──────────────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Règle fondamentale** : Un compte local avec intégration(s) reste un compte LOCAL.
|
||||
Le badge OAuth (🦎/🐙) n'apparaît QUE pour les comptes dont `auth_method` est le provider.
|
||||
|
||||
```
|
||||
Compte LOCAL avec intégrations :
|
||||
┌──────────────────────────────────┐
|
||||
│ [D] Draco │ ← Nom local, PAS de badge
|
||||
│ [email protected] │
|
||||
└──────────────────────────────────┘
|
||||
|
||||
Compte GITEA (auth_method=gitea) :
|
||||
┌──────────────────────────────────┐
|
||||
│ [B] bruno [🦎 Gitea] │ ← Badge visible
|
||||
│ [email protected] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.2 Matrice des intégrations
|
||||
|
||||
```
|
||||
Compte principal Peut ajouter Déconnexion possible
|
||||
─────────────────────────────────────────────────────────
|
||||
local Gitea ✅ Oui
|
||||
local GitHub ✅ Oui
|
||||
local Gitea + GitHub ✅ Les deux
|
||||
─────────────────────────────────────────────────────────
|
||||
gitea GitHub ✅ GitHub ❌ Gitea
|
||||
github Gitea ✅ Gitea ❌ GitHub
|
||||
```
|
||||
|
||||
**Settings → Integrations** : Affiche toutes les intégrations avec bouton
|
||||
Disconnect. Le provider principal (auth_method) n'a PAS de bouton Disconnect.
|
||||
|
||||
### 6.5.3 Scénarios de workspace
|
||||
|
||||
```
|
||||
SCÉNARIO A : Compte local pur
|
||||
─────────────────────────────
|
||||
· Login email+password
|
||||
· Workspaces locaux uniquement
|
||||
· Section Private : CACHÉE (tout est déjà local)
|
||||
· Trash : ✅ (restauration locale)
|
||||
|
||||
SCÉNARIO B : Compte local + intégration(s)
|
||||
──────────────────────────────────────────
|
||||
· Login email+password
|
||||
· Workspaces : locaux + Gitea + GitHub
|
||||
· Section Private : VISIBLE si workspace distant actif
|
||||
· Private : stockage DB locale, associé user+repo
|
||||
· Trash : ✅ (local uniquement)
|
||||
|
||||
SCÉNARIO C : Compte OAuth pur (gitea ou github)
|
||||
────────────────────────────────────────────────
|
||||
· Login via OAuth
|
||||
· Workspaces : distants du provider principal
|
||||
· Section Private : VISIBLE (toujours)
|
||||
· Trash : ❌ (pas de contenu local)
|
||||
· Intégration secondaire : possible
|
||||
· Déconnexion provider principal : impossible
|
||||
```
|
||||
|
||||
### 6.5.4 Sidebar — règles d'affichage par section
|
||||
|
||||
```
|
||||
Section Scénario A Scénario B Scénario C
|
||||
───────────── ────────────── ────────────────────── ──────────────
|
||||
Recents ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Favorites ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Shared ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Published ✅ (local) ✅ (tout) ✅ (tout)
|
||||
Agents ✅ ✅ ✅
|
||||
Private ❌ CACHÉ ✅ si ws distant actif ✅
|
||||
Workspace ✅ ✅ ✅
|
||||
Trash ✅ (local) ✅ (local) ❌
|
||||
```
|
||||
|
||||
Chaque section a un bouton `→ Library` qui ouvre :
|
||||
```
|
||||
/library?tab=recents|favorites|shared|published|private|workspace
|
||||
```
|
||||
|
||||
### 6.5.5 Système Share & Publish
|
||||
|
||||
Le bouton [Share] dans la topbar d'une page ouvre une modale à 2 tabs :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [ Share ] [ Publish ] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Share tab : │
|
||||
│ · Invite people : [email] [Invite] │
|
||||
│ · General access : [🔒 Restricted] Copy link │
|
||||
│ · Liste des personnes/groupes avec accès │
|
||||
│ → Document listé dans sidebar "Shared" │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Publish tab : │
|
||||
│ · [Publish to web] → URL publique générée │
|
||||
│ · URL : https://flowdeck.draco.dev/p/<slug> │
|
||||
│ · [Unpublish] │
|
||||
│ → Document listé dans sidebar "Published" │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.6 Stockage des fichiers Private
|
||||
|
||||
Quand un workspace distant est actif, les fichiers créés dans la section
|
||||
Private sont stockés dans la **base de données locale** (table `pages` avec
|
||||
`parent_section='Private'` et `workspace=gitea:<owner>/<repo>`).
|
||||
|
||||
Cela permet de :
|
||||
- Prendre des notes personnelles liées à un projet sans les committer
|
||||
- Garder des brouillons avant de les pousser
|
||||
- Avoir un espace de travail privé même sur un repo partagé
|
||||
|
||||
### 6.5.7 Trash — règles
|
||||
|
||||
```
|
||||
Contenu Trash ? Comportement
|
||||
─────────────────────── ──────── ──────────────────────────
|
||||
Workspace local ✅ Supprimé → restaurable
|
||||
Dossier/fichier local ✅ Supprimé → restaurable
|
||||
Fichier Gitea/GitHub ❌ Commit "delete" → pas trash
|
||||
Page distante ❌ Commit "delete" → pas trash
|
||||
```
|
||||
|
||||
### 6.5.8 Édition de fichiers — UI unifiée
|
||||
|
||||
```
|
||||
Fichier LOCAL Fichier DISTANT (Gitea/GitHub)
|
||||
┌─────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ Même UI d'édition │ │ Même UI d'édition │
|
||||
│ Même rendu visuel │ │ Même rendu visuel │
|
||||
│ Sauvegarde auto DB │ │ + Bouton [Commit] │
|
||||
│ Pas de commit │ │ + Champ message commit │
|
||||
└─────────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.9 Page Library — `/library?tab=<tab>`
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Library │
|
||||
│ [Recents] [Favorites] [Shared] [Published] │
|
||||
│ [Private] [Workspace] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Filtres : [All] [Local] [Gitea] [GitHub] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📄 Document X Local · modifié il y a 2h │ │
|
||||
│ │ 📄 README.md Gitea · bruno/flowdeck │ │
|
||||
│ │ 📄 Notes Local · Workspace Projet A │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.5.10 Schéma DB — mises à jour pour v4.0
|
||||
|
||||
```sql
|
||||
-- users : ajout auth_method
|
||||
ALTER TABLE users ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'local';
|
||||
-- 'local' | 'gitea' | 'github'
|
||||
|
||||
-- users : colonnes OAuth existantes
|
||||
-- gitea_id INTEGER (NULL si pas Gitea)
|
||||
-- github_id INTEGER (NULL si pas GitHub)
|
||||
-- gitea_token TEXT (token intégration, NULL si pas connecté)
|
||||
-- github_token TEXT (token intégration, NULL si pas connecté)
|
||||
|
||||
-- pages : ajout published
|
||||
ALTER TABLE pages ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE pages ADD COLUMN publish_slug TEXT;
|
||||
ALTER TABLE pages ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'restricted';
|
||||
-- 'restricted' | 'link' | 'public'
|
||||
|
||||
-- page_shares : qui a accès à quel document
|
||||
CREATE TABLE page_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_email TEXT,
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
-- 'view' | 'comment' | 'edit'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- recents : historique de consultation
|
||||
CREATE TABLE recents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
workspace TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'local',
|
||||
-- 'local' | 'gitea' | 'github'
|
||||
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 6.5.11 Plan de migration
|
||||
|
||||
```
|
||||
Phase 1 — DB & Modèles
|
||||
1. Ajouter auth_method aux users existants (auto-détecter)
|
||||
2. Ajouter is_published, is_shared, share_mode aux pages
|
||||
3. Créer table page_shares
|
||||
4. Créer table recents
|
||||
|
||||
Phase 2 — Sidebar
|
||||
1. Badge OAuth (🦎/🐙) basé sur auth_method
|
||||
2. Afficher/cacher Private selon type de compte + workspace actif
|
||||
3. Boutons Library par section
|
||||
4. Trash filtré local-only
|
||||
|
||||
Phase 3 — Share & Publish
|
||||
1. Modale Share à 2 tabs fonctionnelle
|
||||
2. Share via email/user → table page_shares
|
||||
3. Share via lien → share_mode='link'
|
||||
4. Publish → is_published + publish_slug
|
||||
5. Page publique servable à /p/<slug>
|
||||
|
||||
Phase 4 — Library
|
||||
1. Page /library avec tabs
|
||||
2. Filtrage par source (local/gitea/github)
|
||||
3. Recents alimentés automatiquement
|
||||
|
||||
Phase 5 — Intégrations
|
||||
1. GitHub OAuth déjà fait (providers.py)
|
||||
2. Matrice de déconnexion respectée
|
||||
3. Compte local peut connecter/déconnecter Gitea+GitHub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Intégration Gitea
|
||||
|
||||
### 7.1 Flux de données
|
||||
@@ -1302,7 +1670,41 @@ docker compose restart flowdeck
|
||||
- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases)
|
||||
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
|
||||
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
|
||||
- [Notion Data Sources (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
|
||||
- [react-notion-x](https://github.com/NotionX/react-notion-x)
|
||||
- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md)
|
||||
- [FlowDeck — ROADMAP.md](ROADMAP.md)
|
||||
|
||||
---
|
||||
|
||||
## 16. Versions à venir
|
||||
|
||||
### v4.1.0 — Content Blocks enrichis (priorité standard)
|
||||
|
||||
- **Callout boxes** — Blocs d'alerte (info, warning, tip, danger)
|
||||
- **Table of Contents** — Auto-généré depuis les headings
|
||||
- **LaTeX / KaTeX** — Formules mathématiques
|
||||
- **Toggle lists** — Listes pliables/dépliables
|
||||
- **Multi-columns** — Mise en page 2-3 colonnes
|
||||
|
||||
### v4.2.0 — Export (priorité standard)
|
||||
|
||||
- Export **Markdown** avec images
|
||||
- Export **PDF** (via WeasyPrint ou headless Chrome)
|
||||
- Export **HTML** standalone (page auto-suffisante)
|
||||
|
||||
### v4.3.0 — Collaboration (priorité basse)
|
||||
|
||||
- **Commentaires inline** sur les pages
|
||||
- **@mentions** pour notifier des utilisateurs
|
||||
- **Notifications email** (changements, mentions)
|
||||
- **Permissions par workspace** (viewer, editor, admin)
|
||||
|
||||
### v5.0.0 — Plateforme avancée (futur)
|
||||
|
||||
- **AI Assistants** — Génération de contenu, résumés, suggestions
|
||||
- **Embeds** — Vidéos, PDF, Figma, Google Docs
|
||||
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST** — Tokens d'accès pour intégrations tierces
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Stratégie de branches — FlowDeck
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
main ──●────────────●────── production (tags vX.Y.Z)
|
||||
\ /
|
||||
develop ●──●──●──●────── intégration continue
|
||||
\ \ \
|
||||
feat/xxx ● ● ●──── feature branches
|
||||
fix/xxx ●─────────── hotfix branches
|
||||
```
|
||||
|
||||
## Branches
|
||||
|
||||
| Branche | Rôle | Déploiement | Protection |
|
||||
|---------|------|-------------|------------|
|
||||
| `main` | Production | Docker auto sur :8080 | Push direct interdit, PR only |
|
||||
| `develop` | Intégration | Staging (optionnel) | Push direct interdit, PR only |
|
||||
| `feat/<nom>` | Nouvelle feature | Aucun | Libre |
|
||||
| `fix/<nom>` | Correctif urgent | Aucun | Libre |
|
||||
|
||||
## Workflow quotidien
|
||||
|
||||
### 1. Démarrer une feature
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feat/ma-feature
|
||||
```
|
||||
|
||||
### 2. Travailler
|
||||
|
||||
```bash
|
||||
# Coder, commit souvent
|
||||
git add -A
|
||||
git commit -m "feat: description claire de ce qui est fait"
|
||||
git push origin feat/ma-feature
|
||||
```
|
||||
|
||||
### 3. Créer une Pull Request
|
||||
|
||||
Aller sur https://git.dracodev.net/bruno/flowdeck/pulls
|
||||
- **Base** : `develop`
|
||||
- **Head** : `feat/ma-feature`
|
||||
- Titre : descriptif
|
||||
- Assigner un reviewer si applicable
|
||||
|
||||
### 4. Après merge
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git branch -d feat/ma-feature # supprimer la branche locale
|
||||
```
|
||||
|
||||
## Release (develop → main)
|
||||
|
||||
```bash
|
||||
# 1. S'assurer que develop est prêt
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
|
||||
# 2. Créer une PR develop → main sur Gitea
|
||||
# (ou merger localement si admin)
|
||||
git checkout main
|
||||
git merge develop
|
||||
git tag v4.0.1
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
## Hotfix urgent
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git checkout -b fix/urgence
|
||||
# ... corriger ...
|
||||
git commit -m "fix: description"
|
||||
git push origin fix/urgence
|
||||
# PR fix/urgence → main
|
||||
# PUIS merger main → develop pour synchroniser
|
||||
git checkout develop
|
||||
git merge main
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
## Conventions de commits
|
||||
|
||||
| Préfixe | Usage | Exemple |
|
||||
|---------|-------|---------|
|
||||
| `feat:` | Nouvelle fonctionnalité | `feat: landing page for visitors` |
|
||||
| `fix:` | Correction de bug | `fix: redirect loop on /` |
|
||||
| `refactor:` | Restructuration sans changement fonctionnel | `refactor: extract sidebar_data` |
|
||||
| `test:` | Ajout/modification de tests | `test: onboarding flow e2e` |
|
||||
| `docs:` | Documentation | `docs: update ROADMAP.md` |
|
||||
| `style:` | Formatage, CSS | `style: mobile sidebar fixes` |
|
||||
| `chore:` | Tâches de maintenance | `chore: bump version to 4.0.1` |
|
||||
|
||||
## Règles
|
||||
|
||||
1. **Jamais de push direct sur `main`** — toujours via PR depuis `develop` ou `fix/*`
|
||||
2. **Jamais de push direct sur `develop`** — toujours via PR depuis `feat/*` ou `fix/*`
|
||||
3. **Une branche = une feature / un fix** — éviter les branches fourre-tout
|
||||
4. **Tests passent avant merge** — CI Gitea Actions doit être verte
|
||||
5. **Commit + push après chaque modification significative**
|
||||
6. **Nom de branche en kebab-case** : `feat/landing-page`, `fix/login-redirect`
|
||||
7. **Supprimer la branche après merge** (sauf `main` et `develop`)
|
||||
+1350
-68
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -1,19 +1,44 @@
|
||||
FROM python:3.12-slim
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# FlowDeck — multi-stage Docker build (v5.2.0)
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
|
||||
# colour emoji support. curl = healthcheck.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
shared-mime-info \
|
||||
fonts-dejavu-core \
|
||||
fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data
|
||||
RUN mkdir -p /data /data/backups
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/api/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
@@ -1,8 +1,8 @@
|
||||
# FlowDeck
|
||||
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Table, multi-vues.
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v1.3.0** — Database Concept (collections, pages, vues, GiteaBoardCompat)
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -15,45 +15,62 @@ docker compose up -d
|
||||
|
||||
## Features
|
||||
|
||||
### Database Concept (v1.3.0)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON, icon, description
|
||||
- **Pages**: propriétés JSON, position, parent_id (sub-items ready), lien Gitea optionnel
|
||||
- **Vues**: configurables (type, filters, sorts, visible_properties)
|
||||
### Database & Properties (v1.3–v1.5)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON
|
||||
- **21 types de propriétés**: title, text, number, select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula + auto-props
|
||||
- **Relations bidirectionnelles** + **Rollups** (12 agrégations) + **Formulas** (19 fonctions)
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
- **API REST**: CRUD complet collections + pages (12 endpoints)
|
||||
|
||||
### Vues Multiples (v1.6–v1.7)
|
||||
- **Table**: colonnes SSR, triables
|
||||
- **Board (Kanban)**: colonnes, groupes, drag & drop
|
||||
- **Calendar**: grid mensuel, navigation, événements
|
||||
- **Gallery**: cartes visuelles, card_size configurable
|
||||
- **List**: vue compacte avec preview
|
||||
- **Timeline**: barres Gantt horizontales
|
||||
- **Status Overview**: Donut chart SVG
|
||||
- **Team Load**: Barres empilées
|
||||
|
||||
### Sub-items & Dépendances (v1.8)
|
||||
- **Sub-items**: parent_id auto-référence, hiérarchie illimitée
|
||||
- **Status aggregate**: parent = Done si tous enfants Done
|
||||
- **Dependencies**: Blocking/Blocked by, contrainte de transition
|
||||
|
||||
### My Tasks (v1.9)
|
||||
- Dashboard cross-collection agrégeant toutes les tâches assignées
|
||||
- Vues: All, Today, Overdue, Next 7 days
|
||||
|
||||
### Multi-User & Collaboratif (v2.0)
|
||||
- **Workspaces**: espaces partagés avec rôles (admin, editor, commenter, viewer)
|
||||
- **Comments**: commentaires threadés sur les pages
|
||||
- **Page History**: historique des modifications avec snapshots
|
||||
- **Favorites**: favoris par utilisateur
|
||||
- **Templates**: database + page templates
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Recents, Private, Library, Trash)
|
||||
- **Topbar** avec breadcrumbs (workspace > projet > page)
|
||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
||||
- **Éditeur de blocs** Notion-style: slash menu (/), navigation clavier, placeholders
|
||||
- **Drag & drop** pages dans la sidebar (SortableJS)
|
||||
|
||||
### Vues Multiples (v0.5–v0.8)
|
||||
- **Kanban board**: colonnes avec sous-groupes, drag & drop cartes
|
||||
- **Table view**: colonnes triables, groupes rétractables
|
||||
- **Status overview**: Donut chart SVG avec segments, compteurs
|
||||
- **Team Load**: Barres empilées par membre
|
||||
- **Detailed board**: cartes avec propriétés visibles
|
||||
|
||||
### Filtres & Tri (v0.7)
|
||||
- **Filtres cumulables** (logique AND) avec pastilles
|
||||
- **Tri multi-critères** hiérarchique
|
||||
- **Filtre par statut**: checkboxes multiples
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
- Topbar avec breadcrumbs (workspace > projet > page)
|
||||
- Dark mode Notion: `#191919` fond, `#222222` sidebar
|
||||
- Éditeur de blocs: slash menu (/), navigation clavier, placeholders
|
||||
|
||||
### Intégration Gitea
|
||||
- **Issues Gitea → cartes** Notion-style
|
||||
- Sync labels, milestones, assignees, due dates
|
||||
- OAuth2 Gitea, CSRF, rate limiting
|
||||
- Webhooks pour sync temps réel
|
||||
- Issues Gitea → cartes, sync labels, milestones, assignees, due dates
|
||||
- OAuth2 Gitea, CSRF, rate limiting, webhooks
|
||||
|
||||
## Stack
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB |
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
@@ -65,25 +82,16 @@ APP_PORT=8080
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v1.4.0 Propriétés Avancées → v2.0.0 Multi-Users.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 34/34 passent
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — 7/7 blocs complétés, 52/52 features ✅
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
||||
+649
-128
@@ -1,7 +1,40 @@
|
||||
# Roadmap FlowDeck — Notion Clone
|
||||
|
||||
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea.
|
||||
> **19 images de référence** dans `images/` — analysées le 2026-07-08.
|
||||
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea/GitHub.
|
||||
> **MVP en production** : v4.0.0 — authentification locale/OAuth, pages blocs, sidebar, library, share/publish, intégrations.
|
||||
> **Branche principale** : `main` (production) · `develop` (intégration) · `feat/*` (features).
|
||||
|
||||
## 🏗️ Stratégie de branches
|
||||
|
||||
```
|
||||
main ──●────────────●────── production (tags vX.Y.Z)
|
||||
\ /
|
||||
develop ●──●──●──●────── intégration continue
|
||||
\ \ \
|
||||
feat/xxx ● ● ●──── feature branches
|
||||
```
|
||||
|
||||
- **`main`** : code en production, déploiement Docker automatique
|
||||
- **`develop`** : branche d'intégration, merges des `feat/*`
|
||||
- **`feat/<nom>`** : une branche par feature/fix, PR → code review → merge dans `develop`
|
||||
- **Tags** `vX.Y.Z` sur `main` pour chaque release
|
||||
|
||||
**Workflow** :
|
||||
```bash
|
||||
git checkout -b feat/mon-truc develop
|
||||
# ... coder, commit, push ...
|
||||
# Créer une PR feat/mon-truc → develop sur Gitea
|
||||
# Après review + CI verte → merge
|
||||
# Pour release: PR develop → main + tag vX.Y.Z
|
||||
```
|
||||
|
||||
**Règles** :
|
||||
- Ne jamais commiter directement sur `main`
|
||||
- Une branche = une feature / un fix
|
||||
- Tests passent avant merge (CI Gitea Actions)
|
||||
- Commit + push après chaque modification significative
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
@@ -54,150 +87,638 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [x] Heading 4 support
|
||||
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
||||
|
||||
### v1.3.0 ✅ — Database Concept (Bloc 1 du gap analysis)
|
||||
### (2026-07-10, commit c574d2c, tag v1.3.0)
|
||||
### v1.3.0 ✅ — Database Concept
|
||||
- [x] Tables `collections`, `collection_pages`, `collection_views`
|
||||
- [x] Rétrocompatibilité Gitea via `GiteaBoardCompat`
|
||||
- [x] Router `/db` — API CRUD collections + pages (12 endpoints)
|
||||
- [x] 34/34 tests passent
|
||||
|
||||
- [x] **Table `collections`** — base de données abstraite, indépendante de Gitea
|
||||
- `id`, `name`, `description`, `icon`, `schema_json`
|
||||
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
|
||||
- [x] **Table `collection_pages`** — remplace `cards`, pages génériques
|
||||
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
|
||||
- `gitea_issue_id` optionnel, auto-propriétés (`created_at`, `updated_at`)
|
||||
- [x] **Table `collection_views`** — vues configurables par collection
|
||||
- `name`, `view_type`, `config_json`, `position`
|
||||
- [x] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
|
||||
- Les boards Gitea existants deviennent des collections
|
||||
- Migration transparente `cards` → `collection_pages`
|
||||
- Routes `/db/boards/api`, `/db/board/{o}/{r}/api`, `/db/board/{o}/{r}/sync`
|
||||
- [x] **Router `/db`** — API CRUD collections + pages (12 endpoints)
|
||||
- [x] **34/34 tests** passent (+6 nouveaux tests v1.3)
|
||||
- [x] **Database lock** — mode lecture seule (`collections.is_locked`)
|
||||
- [x] **Database description** — champ `description` sur les collections
|
||||
### v1.4.0 ✅ — Propriétés Avancées
|
||||
- [x] Table `collection_properties` — 21 types Notion
|
||||
- [x] Service `property_types.py` — validation, formatage, auto-values
|
||||
- [x] 38/38 tests passent
|
||||
|
||||
---
|
||||
### v1.5.0 ✅ — Relations & Rollups
|
||||
- [x] Type `relation` — lien bidirectionnel entre collections
|
||||
- [x] Type `rollup` — aggregation via relation (COUNT, SUM, AVG, etc.)
|
||||
- [x] Type `formula` — moteur d'expressions (19 fonctions)
|
||||
- [x] Formula engine + Rollup engine
|
||||
- [x] 43/43 tests passent
|
||||
|
||||
## Upcoming
|
||||
|
||||
### v1.4.0 ✅ — Propriétés Avancées (Bloc 2)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Table `collection_properties`** — remplace `project_properties`
|
||||
- Liée à `collections` (pas à `project_owner/name`)
|
||||
- Supporte tous les types Notion (21 types)
|
||||
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
||||
- [x] **Type `checkbox`** — booléen natif
|
||||
- [x] **Type `url`** — lien cliquable
|
||||
- [x] **Type `email`** — email cliquable
|
||||
- [x] **Type `phone`** — téléphone cliquable
|
||||
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
||||
- [x] **Type `files`** — uploads/images attachés aux pages
|
||||
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
|
||||
- [x] **Type `created_time` / `created_by`** — auto-propriétés
|
||||
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
||||
- [x] **Service `property_types.py`** — validation, formatage, auto-values
|
||||
- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api`
|
||||
- [x] **38/38 tests** passent (+4 nouveaux tests v1.4)
|
||||
- [ ] **Migration** `project_properties` → `collection_properties` (v1.5)
|
||||
|
||||
### v1.5.0 ✅ — Relations & Rollups (Bloc 2 suite)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Type `relation`** — lien bidirectionnel entre collections
|
||||
- `related_collection_id`, `reverse_name` auto-généré
|
||||
- Stockage : JSON array de page IDs dans `property_values_json`
|
||||
- API: `POST /db/{id}/properties/relation` (création avec reverse)
|
||||
- API: `POST /db/{id}/properties/relation/link` (lier 2 pages)
|
||||
- [x] **Type `rollup`** — agrégation via relation
|
||||
- Functions : COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE, PERCENT_CHECKED
|
||||
- API: `POST /db/rollup/compute`
|
||||
- [x] **Type `formula`** — propriétés calculées
|
||||
- Moteur d'expressions JavaScript-like (19 fonctions)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- [x] **Formula engine** — `services/formula_engine.py`
|
||||
- [x] **Rollup engine** — `services/rollup_engine.py`
|
||||
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
|
||||
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
|
||||
### (2026-07-10)
|
||||
|
||||
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
|
||||
- Route: `GET /db/{id}/view/calendar`
|
||||
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
|
||||
- `card_size`: small/medium/large, `cover_property` configurable
|
||||
- [x] **List view** — liste compacte avec preview propriétés
|
||||
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
|
||||
- [x] **Table view** — rendu SSR, colonnes properties
|
||||
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
|
||||
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
|
||||
### v1.6.0 ✅ — Vues Manquantes
|
||||
- [x] Calendar, Gallery, List, Timeline, Table views
|
||||
- [x] View tabs navigation
|
||||
- [x] 49/49 tests passent
|
||||
|
||||
### v1.7.0 ✅ — Vues Améliorées
|
||||
### v1.8.0 ✅ — Sub-items & Dépendances
|
||||
### (2026-07-10, combined)
|
||||
- [x] View config API, Save view as
|
||||
- [x] Sub-items (parent_id auto-référence)
|
||||
- [x] Status aggregate, Dependencies (blocking constraint)
|
||||
- [x] 56/56 tests passent
|
||||
|
||||
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
|
||||
- [x] **Save view as** — POST /db/{id}/views/save-as
|
||||
- [x] **List views** — GET /db/{id}/views/api
|
||||
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
|
||||
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
|
||||
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
|
||||
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
|
||||
### v1.9.0 ✅ — My Tasks & Dashboard Unifié
|
||||
- [x] Vue `/my-tasks` — agrège pages assignées à l'utilisateur
|
||||
- [x] Groupement par collection, filtres globaux
|
||||
- [x] 60/60 tests passent
|
||||
|
||||
### v1.9.0 ✅ — My Tasks & Dashboard Unifié (Bloc 5)
|
||||
### (2026-07-10)
|
||||
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs
|
||||
- [x] Workspaces, Comments, Page History, Favorites
|
||||
- [x] Database Templates, Page Templates, CSV Import/Export
|
||||
- [x] Public Sharing
|
||||
- [x] 67/67 tests passent
|
||||
|
||||
- [x] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
|
||||
- Scan cross-collection (toutes les databases du workspace)
|
||||
- Filtre automatique : admin voit tout
|
||||
- [x] **Groupement par collection** — sections par database d'origine
|
||||
- [x] **Filtres globaux** — All, Today, Overdue, Next 7 days
|
||||
- [x] **API JSON** — `GET /my-tasks/api`
|
||||
- [x] **60/60 tests** passent (+4 tests v1.9)
|
||||
### v2.1.0 ✅ — Workspace Local & Arborescence
|
||||
### v2.2.0 ✅ — Tags & Recherche Avancée
|
||||
### v2.3.0 ✅ — Multi-Vues Workspace & Preview Panel
|
||||
### v2.4.0 ✅ — Tags Personnalisés par Utilisateur
|
||||
### v2.5.0 ✅ — Administration Avancée
|
||||
### v2.6.0 ✅ — My Account & Auth Locale
|
||||
### v2.7.0 ✅ — Intégration Gitea Phase 1
|
||||
### v2.7.1 ✅ — Private Pages Gitea
|
||||
|
||||
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
|
||||
### (2026-07-10)
|
||||
### v2.8.0 ✅ — Gitea Phase 2
|
||||
- [x] Créer fichiers dans le repo (New file UI + API PUT)
|
||||
- [x] Upload fichiers binaires (POST /upload, drag-drop)
|
||||
- [x] Syntax highlighting via Prism.js CDN
|
||||
- [x] Labels Gitea → tags FlowDeck (POST /sync-labels, 16 labels importés)
|
||||
- [x] Historique commits par fichier (GET /commits?path=)
|
||||
|
||||
- [x] **Workspaces** — `workspaces` + `workspace_members` tables, CRUD API, roles admin/editor/commenter/viewer
|
||||
- [x] **Comments** — table `comments` avec thread (parent_id), resolved, API CRUD
|
||||
- [x] **Page History** — table `page_history`, snapshot JSON, API record/list
|
||||
- [x] **Favorites** — table `favorites`, API add/remove/list
|
||||
- [x] **Database Templates** — table `database_templates`, apply to create collection
|
||||
- [x] **Page Templates** — table `page_templates`, apply with pre-filled properties
|
||||
- [x] **CSV Import/Export** — `POST .../import/csv`, `GET .../export/csv`
|
||||
- [x] **Public Sharing** — `GET /workspace/public/{id}` (read-only, no auth)
|
||||
- [x] **67/67 tests** passent (+7 tests v2.0)
|
||||
### v2.9.0 ✅ — GitHub OAuth Provider
|
||||
- [x] GitHubProvider dans `app/auth/providers.py`
|
||||
- [x] GitHubAdapter dans `app/services/github_adapter.py`
|
||||
- [x] Config GitHub dans `.env.example`
|
||||
|
||||
### v2.1.0 — Intégrations Avancées (futur)
|
||||
### v3.0.0 ✅ — UX Professionnelle
|
||||
- [x] Multi-sélection (checkboxes + Shift/Ctrl-click dans le sidebar)
|
||||
- [x] Raccourcis clavier (Ctrl+N, F2, Delete, Escape, Ctrl+S)
|
||||
- [x] Inline rename (double-clic → champ inline)
|
||||
- [x] Loading skeletons (animations pulse dans workspaces + file viewer)
|
||||
- [x] Grid/card view (mode grille/vignettes)
|
||||
- [x] Table sorting (tri par colonnes)
|
||||
- [x] Sélecteur de vue: Tree, List, Details, Title, Content (5 modes)
|
||||
- [x] Preview Panel latéral (contenu, métadonnées, tags, actions)
|
||||
- [x] Tri par date (Oldest/Newest)
|
||||
|
||||
### v2.1.0 — Intégrations Avancées (futur)
|
||||
### v4.0.0 ✅ — Accounts, Integrations & Sharing (MVP)
|
||||
- [x] `auth_method` sur users (local/gitea/github)
|
||||
- [x] Badge OAuth dans sidebar
|
||||
- [x] Settings page avec Connect/Disconnect Gitea + GitHub
|
||||
- [x] Sidebar rules (Private = distant seulement, Trash local-only)
|
||||
- [x] Library page avec tabs + filtres source (All/Local/Gitea/GitHub)
|
||||
- [x] Modale Share (2 tabs: Share + Publish)
|
||||
- [x] Page publique `/p/<slug>` (no auth, rendu HTML)
|
||||
- [x] Table `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
|
||||
- [x] Table `recents` — tracking automatique
|
||||
- [x] Édition unifiée local/distant (même UI, bouton Commit pour Gitea)
|
||||
- [x] 133 tests passent
|
||||
|
||||
- [ ] **API publique** — REST API documentée pour usage externe
|
||||
- [ ] **Webhooks sortants** — notifier des services externes
|
||||
- [ ] **n8n integration** — nœud FlowDeck pour workflows
|
||||
- [ ] **Slack/Discord integration** — notifications dans les channels
|
||||
- [ ] **Google Calendar sync** — synchronisation bidirectionnelle
|
||||
- [ ] **Mobile PWA** — Progressive Web App pour mobile
|
||||
- [ ] **PostgreSQL migration** — si passage multi-tenant
|
||||
---
|
||||
|
||||
## 🔜 Prochaines versions
|
||||
|
||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction. **COMPLETED**.
|
||||
|
||||
- [x] **Landing page `/` pour visiteurs non-auth** — template `landing.html`, design Notion dark
|
||||
- [x] **Page `/auth/register` GET** — formulaire d'inscription dédié (tab register actif)
|
||||
- [x] **Redirection `/` intelligente** — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
|
||||
- [x] **Empty states** — déjà présents dans local workspace (« Welcome to your Workspace »)
|
||||
- [x] **Page 404 stylisée** — thème Notion sombre, JSON pour `/api/*`, HTML pour le reste
|
||||
- [x] **Unifier les routes API** — `/api/pages` GET/POST → 307 redirect vers `/board/api/pages`
|
||||
- [x] **Page Settings** — lien déjà présent dans le menu utilisateur (Settings → `/accounts/settings`)
|
||||
|
||||
### v4.0.2 — Qualité & Robustesse ✅ (2026-07-18)
|
||||
> **Objectif** : zéro crash, zéro regression, DX impeccable.
|
||||
|
||||
- [x] **Gestion d'erreurs** — try/catch dans create_page, message user-friendly (500 JSON)
|
||||
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
|
||||
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
|
||||
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
|
||||
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
|
||||
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
|
||||
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
|
||||
- [x] **143 tests passent**
|
||||
|
||||
---
|
||||
|
||||
### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
|
||||
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
|
||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||
|
||||
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
|
||||
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
|
||||
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
|
||||
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
|
||||
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
|
||||
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
|
||||
- [x] **153 tests passent** (+10 nouveaux)
|
||||
|
||||
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
|
||||
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
|
||||
|
||||
- [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
|
||||
- [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
|
||||
- [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
|
||||
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
|
||||
- [x] **159 tests passent** (+6 nouveaux)
|
||||
|
||||
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
|
||||
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
|
||||
|
||||
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
|
||||
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
|
||||
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
|
||||
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
|
||||
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
|
||||
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
|
||||
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
|
||||
- [x] **166 tests passent** (+7 nouveaux)
|
||||
|
||||
### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
|
||||
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
|
||||
|
||||
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
|
||||
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
|
||||
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
|
||||
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
|
||||
- [x] **171 tests passent** (+5 nouveaux)
|
||||
|
||||
### v4.5.0 — Sprints & My Tasks ✅ (2026-07-21)
|
||||
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. **COMPLETED**.
|
||||
|
||||
- [x] **Tables `sprints`, `sprint_pages`** — sprints agiles avec velocity_points
|
||||
- [x] **API sprints** — CRUD + assignation/retrait pages
|
||||
- [x] **Burndown chart** — GET /workspace/collections/{id}/sprints/burndown/{sid} (total/completed/remaining/ideal)
|
||||
- [x] **My Tasks** — agrégation cross-databases avec filtre (all/today/overdue/upcoming)
|
||||
- [x] **175 tests passent** (+4 nouveaux)
|
||||
|
||||
### v4.6.0 — Content Blocks enrichis ✅ (2026-09-02)
|
||||
> **Objectif** : parité d'édition avec Notion.
|
||||
|
||||
- [x] **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône
|
||||
- [x] **Table of contents** — auto-généré depuis les headings avec ancres
|
||||
- [x] **Math equations** — LaTeX / KaTeX (self-hosted) block
|
||||
- [x] **Toggle lists** — contenu expandable/collapsible avec enfants
|
||||
- [x] **Multi-colonnes** — layout flexible (2, 3 colonnes) + bouton ajouter/retirer colonne
|
||||
|
||||
Détails livrés :
|
||||
- 5 types de blocs enrichis dans le slash menu (callout, table_of_contents, math, columns, toggle)
|
||||
- Rendu des nouveaux blocs dans les pages publiques (`/p/<slug>`) — TOC ancré, KaTeX, colonnes, toggle `<details>`
|
||||
- Intégration KaTeX 0.16.11 self-hosté (`/static/js/katex.min.js`, `/static/css/katex.min.css`, `/static/fonts/`)
|
||||
- Persistance `children` (colonnes/toggle) via le format de blocs JSON
|
||||
- Export Markdown étendu aux nouveaux blocs
|
||||
- 183 tests au total (dont 5 nouveaux tests v4.6.0)
|
||||
|
||||
### v4.7.0 — Export ✅ (2026-09-03)
|
||||
> **Objectif** : exporter une page (ou un site) depuis l'éditeur.
|
||||
|
||||
- [x] **Export Markdown** — avec images et sous-pages (récursif)
|
||||
- [x] **Export PDF** — via xhtml2pdf (fidèle, sans CDN/lib système)
|
||||
- [x] **Export HTML** — site statique standalone (document autonome + site .zip)
|
||||
|
||||
Détails livrés :
|
||||
- Service serveur `app/services/export.py` : conversion blocs → Markdown / HTML / PDF
|
||||
- 4 formats exposés : `/api/export/markdown|html|pdf|site/{page_id}`
|
||||
- Sous-pages incluses récursivement (Markdown et site)
|
||||
- UI : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip)
|
||||
- PDF généré via `xhtml2pdf` (pip) — aucun lib système requise
|
||||
- 6 nouveaux tests (Markdown, sous-pages, HTML, PDF, Site, 404)
|
||||
|
||||
### v4.7.1 — Fix UI Export / Share / More ✅ (2026-09-03)
|
||||
- [x] **Share / More / Activity / Move-to popovers** — rendus sous le viewport (absolute + parent scrollable) → repositionnés fixed sous la topbar
|
||||
- [x] **Sous-menu Export** — l'itém Export fermait le menu More ; devient un toggle, formats accessibles
|
||||
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
|
||||
- [x] Vérifié Playwright headless + 190/190 tests
|
||||
|
||||
### v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)
|
||||
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
|
||||
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
|
||||
|
||||
### v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)
|
||||
- [x] **Tableaux** — parseur GFM (bloc `table`) + rendu `<table>` HTML (thead/tbody, alignements, bordures `.ftable`) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
|
||||
- [x] **Émojis PDF** — passage du moteur à **WeasyPrint** (émojis couleur via Pango + fonts-noto-color-emoji dans l'image) ; repli automatique xhtml2pdf si libs natives absentes (dev Windows).
|
||||
- [x] Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
|
||||
- [x] 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés
|
||||
|
||||
### v4.8.0 — Bloc Tableau simple ✅ (2026-09-03)
|
||||
- [x] **Bloc `table` éditable dans l'éditeur** — insertion via `/table` (commandes slash, section BASIC), tableau 3×3 avec en-tête, cellules éditables au clic, auto-sauvegarde.
|
||||
- [x] **Lignes** — bouton `+ Row` sous le tableau (clic = ajouter) ; clic droit sur une cellule de corps → Insérer dessus/dessous, Dupliquer, Effacer, Supprimer la ligne, Supprimer le tableau.
|
||||
- [x] **Colonnes** — poignée (⋮⋮) au-dessus de chaque colonne → Insérer à gauche/droite, Dupliquer, Effacer le contenu, Supprimer la colonne, Supprimer le tableau.
|
||||
- [x] **Import Markdown (GFM)** — `md2b` transforme les tableaux pipe en bloc `table` au lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloc `table`.
|
||||
|
||||
### v4.8.1 — Fix bloc Tableau ✅ (2026-09-03)
|
||||
- [x] **« + Row » (bas) corrigé** — l'insertion de ligne utilisait `splice(index, ligne)` (mauvaise signature) → aucune ligne ajoutée ; désormais `splice(index, 0, ligne)`.
|
||||
- [x] **Bouton « + » à droite** — ajoute une colonne à droite (équivalent « Add column »).
|
||||
- [x] **Redimensionnement des colonnes** — curseur `col-resize` au survol des bordures d'en-tête, drag = largeur ; persisté via `colsW`.
|
||||
|
||||
### v4.9.0 — Collaboration ✅ (2026-09-04)
|
||||
> **Objectif** : commentaires inline, mentions @, notifications in-app + email. **COMPLETED**.
|
||||
> **Doc** : [`docs/Guide_Complet_Notion_sharing_collaborartion.md`](docs/Guide_Complet_Notion_sharing_collaborartion.md)
|
||||
|
||||
- [x] **Inline comments** — commentaires sur sélection de texte dans l'éditeur (bouton flottant 💬) ; table `comments` étendue (`target_type`/`target_id`/`anchor_block_id`/`anchor_start`/`anchor_end`) et migrée (FK générique, idempotente) ; panneau de commentaires + résolution/suppression + compteur topbar
|
||||
- [x] **@mentions** — autocomplétion `@` (recherche utilisateurs temps réel), insertion `@login` dans les blocs et commentaires, table `notifications` ; endpoint `POST /api/pages/{id}/mentions` (notif des mentions de contenu)
|
||||
- [x] **Email notifications** — service SMTP (`app/services/mailer.py` + templates HTML) ; préférences email par utilisateur (comments/mentions) ; config `.env` (`SMTP_*`, `APP_BASE_URL`) ; repli no-op sans SMTP
|
||||
- [x] **Centre de notifications in-app** — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read
|
||||
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
||||
- [x] **208 tests passent** (+9 v4.9.0)
|
||||
|
||||
### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅
|
||||
|
||||
> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers),
|
||||
> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`),
|
||||
> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation),
|
||||
> `/run` utilise la clé de l'utilisateur. 239 tests verts.
|
||||
|
||||
### v4.10.1 — Agent IA : config LLM dans l'UI ✅
|
||||
|
||||
> **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par
|
||||
> conversation), config runtime DB-backed (`llm_config`), écran admin *Agent & IA* avec
|
||||
> test de connexion (`LLMClient.ping()`, sans fallback mock). 232 tests verts.
|
||||
|
||||
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
|
||||
|
||||
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
|
||||
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
|
||||
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
|
||||
> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md).
|
||||
|
||||
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
|
||||
|
||||
**Documentation :**
|
||||
|
||||
[`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
|
||||
[`docs/Guide_Complet_Notion_Agent_2026.md`](docs/Guide_Complet_Notion_Agent_2026.md) (464 lignes)
|
||||
|
||||
#### Concept
|
||||
Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agent **réalise** :
|
||||
`objectif → planification → collecte contexte → actions API → résultat`
|
||||
|
||||
| Notion AI | FlowDeck Agent |
|
||||
|-----------|----------------|
|
||||
| Répond, résume, réécrit | Planifie, recherche, **agit** |
|
||||
| Bloc de texte | Crée collections, pages, propriétés, vues |
|
||||
| Sans état | Historique conversation + snapshots rollback |
|
||||
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
|
||||
|
||||
#### Fonctionnalités clés
|
||||
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
||||
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
||||
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
||||
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
||||
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
||||
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
||||
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
|
||||
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
|
||||
- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
|
||||
- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
|
||||
|
||||
#### Architecture (nouveaux composants)
|
||||
```
|
||||
app/
|
||||
├── routers/agent.py — /api/agent/* (run, conversations, tools)
|
||||
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
|
||||
├── services/llm_client.py — Abstraction multi-fournisseur LLM
|
||||
├── services/tool_registry.py — 10+ outils wrappant les API existantes
|
||||
├── services/context_builder.py — Collecte contexte workspace
|
||||
└── templates/
|
||||
├── agent_panel.html — Panneau conversation Alpine.js
|
||||
└── agent_message.html — Fragment message (streaming)
|
||||
```
|
||||
|
||||
#### Nouvelles tables
|
||||
| Table | Rôle |
|
||||
|-------|------|
|
||||
| `agents` | Config agent (nom, instructions, modèle, tools scope) |
|
||||
| `agent_conversations` | Sessions (titre, agent, workspace, statut) |
|
||||
| `agent_messages` | Messages (role, content, tool_calls, artifacts) |
|
||||
| `agent_actions` | Journal d'audit (tool, args, result, undo_snapshot) |
|
||||
| `agent_skills` | Skills réutilisables (nom, prompt template, outils) |
|
||||
| `agent_triggers` | Déclencheurs (cron, webhook, event, agent cible) |
|
||||
|
||||
#### Cas d'usage FlowDeck
|
||||
- **Créer un CRM complet** — agent crée collection, propriétés, vues, exemples
|
||||
- **Résumer les notes de réunion** — lit les pages, synthétise, crée une page résumé
|
||||
- **Générer des OKR** — crée la database OKR avec relations et vues
|
||||
- **Analyser un repo Gitea** — lit les issues, crée un dashboard de suivi
|
||||
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
|
||||
|
||||
#### Plan de migration (5 phases)
|
||||
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
|
||||
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
|
||||
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
|
||||
|
||||
#### Limitations (v1)
|
||||
- Pas de modification concurrente (lock optimiste DB)
|
||||
- Pas d'appels API externes non-FlowDeck (sandbox strict)
|
||||
- Budget tokens max par conversation (500k tokens)
|
||||
- Timeout 5 minutes par run
|
||||
|
||||
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
|
||||
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
|
||||
|
||||
- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
|
||||
- [x] **Quick actions** — navigation, création, commandes
|
||||
- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
|
||||
|
||||
### v5.1.0 — Automations ✅ (2026-09-07)
|
||||
> **Objectif** : moteur de règles if-this-then-that + boutons cliquables. **COMPLETED**.
|
||||
|
||||
- [x] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
|
||||
- Déclencheurs : événement (`page.created/updated/deleted/moved`, `collection.*`) / cron (`*/N`, minute fixe, `@hourly`, `@daily`) / bouton
|
||||
- Conditions combinables : eq, neq, contains, not_contains, is_empty, is_not_empty, changed
|
||||
- Actions : webhook (X-FlowDeck-Secret), set_property (validé), create_page (interpolation `[[prop]]`/`{{title}}`), notify
|
||||
- Tables `automations` + `automation_runs` (migration v5), scheduler de fond (60 s), historique des exécutions
|
||||
- [x] **Buttons** — boutons cliquables déclenchant des actions
|
||||
- Bloc `button` dans l'éditeur (menu slash) + picker d'automation inline
|
||||
- Endpoint `/api/automations/{id}/run` (exempt CSRF) + UI Settings → Automations
|
||||
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
|
||||
- [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
|
||||
|
||||
### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)
|
||||
> **Objectif** : fondations de production — design system, sécurité, infra, forge.
|
||||
> **COMPLETED**.
|
||||
|
||||
**Design system**
|
||||
- [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
|
||||
- [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
|
||||
|
||||
**Sécurité & Utilisateur**
|
||||
- [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
|
||||
- [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
|
||||
- [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
|
||||
|
||||
**Infrastructure**
|
||||
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
|
||||
- [x] **Backup automatique** — snapshot SQLite quotidien (`app/services/backup.py`, scheduler + API admin, rétention configurable)
|
||||
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
|
||||
- [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
|
||||
- [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
|
||||
- [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
|
||||
|
||||
**Forge integration**
|
||||
- [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
|
||||
- [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
|
||||
- [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
|
||||
|
||||
**Tests (cibles)**
|
||||
- [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
|
||||
- [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
|
||||
- [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
|
||||
- [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
|
||||
|
||||
**✅ Validation (2026-09-11)** :
|
||||
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
|
||||
- `pytest -q` (suite complète) → **397 passed**
|
||||
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
|
||||
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
|
||||
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
|
||||
|
||||
**Complétion du 2026-09-11** :
|
||||
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
|
||||
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
|
||||
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
|
||||
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
|
||||
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
|
||||
|
||||
### v5.3.0 — Database Avancée ✅ (2026-09-06)
|
||||
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
|
||||
|
||||
- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB`
|
||||
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
|
||||
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
|
||||
|
||||
### v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08)
|
||||
> **Objectif** : parité de manipulation des blocs avec Notion — les blocs étaient éditables
|
||||
> mais *statiques* (impossible de les déplacer, dupliquer ou annuler une action). **COMPLETED**.
|
||||
|
||||
- [x] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; **multi-sélection** (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via `data-id`)
|
||||
- [x] **Menu contextuel de bloc** — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (`#fdblk-<id>` avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection
|
||||
- [x] **Undo / Redo** — `Ctrl+Z` / `Ctrl+Shift+Z`/`Ctrl+Y`, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…)
|
||||
- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
|
||||
- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
|
||||
- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `<th>` en preview et exports Markdown/HTML
|
||||
- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
|
||||
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
|
||||
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
|
||||
|
||||
- [x] **WebSocket gateway** — endpoint `WS /ws/pages/{id}`, auth via cookie session (refus 4401), page introuvable/supprimée → 4404 ; rooms par page en mémoire, chargées depuis la base au premier connect, droppées quand vides
|
||||
- [x] **Présence** — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; `welcome` = self + peers
|
||||
- [x] **Curseurs live** — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
|
||||
- [x] **Merge de modifications** — diffusion des ops de blocs insert/update/delete/move avec **last-write-wins par bloc** + version de page (stale → sync complet) ; titre synchronisé (debounce) ; persistance debounce ~1 s + flush à la déconnexion du dernier client
|
||||
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
|
||||
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.4.0 — Expérience éditeur ✅ (2026-09-11)
|
||||
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
|
||||
|
||||
- [x] **Backlinks** — `GET /board/api/pages/{id}/backlinks` (scan des liens internes `/pages/<id>`) + popover « Lié depuis… »
|
||||
- [x] **Duplicates** — `POST /board/api/pages/{id}/duplicate` (copie profonde des blocs) + `POST /db/{id}/duplicate` (vues + propriétés + pages) ; entrées « Duplicate » dans les menus `...`
|
||||
- [x] **Corbeille globale améliorée** — vue cross-workspace `GET /board/api/trash` + `app/services/trash.py` (purge automatique > 30 jours, scheduler quotidien)
|
||||
- [x] **Historique de version UI** — snapshots `page_versions` à chaque sauvegarde modifiée, `GET /api/pages/{id}/versions`, `POST .../versions/{vid}/restore`, popover « Version history »
|
||||
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
|
||||
- [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte**
|
||||
|
||||
### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)
|
||||
> **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page.
|
||||
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**.
|
||||
|
||||
- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
|
||||
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
|
||||
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
|
||||
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
|
||||
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
|
||||
|
||||
Détails livrés :
|
||||
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
|
||||
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
|
||||
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
|
||||
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
|
||||
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
|
||||
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
|
||||
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
|
||||
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
|
||||
|
||||
### v5.6.0 — Import de données (étendu) — Plan par phases
|
||||
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
|
||||
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
|
||||
> (Markdown/CSV/Notion, déjà dans v5.4.0). **Priorité n°1 de la roadmap.**
|
||||
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
|
||||
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
|
||||
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
|
||||
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
|
||||
|
||||
#### Phase 0 — Socle d'import unifié (prérequis)
|
||||
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
|
||||
- [ ] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
|
||||
- [ ] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
|
||||
- [ ] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
|
||||
- [ ] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
|
||||
- [ ] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
|
||||
- [ ] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
|
||||
- [ ] **Idempotence / déduplication** + reprise sur erreur partielle
|
||||
- [ ] **Tests** — harnais d'import + fixtures par source
|
||||
- [ ] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
|
||||
|
||||
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort
|
||||
- [ ] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
|
||||
- [ ] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
|
||||
- [ ] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
|
||||
- [ ] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
|
||||
- [ ] **Google Keep** — Takeout JSON + HTML
|
||||
- [ ] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
|
||||
|
||||
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen
|
||||
- [ ] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
|
||||
- [ ] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
|
||||
- [ ] **Google Sheets** — export CSV/XLSX
|
||||
- [ ] **JSON générique** — mapping configurable (JSONPath → propriétés)
|
||||
|
||||
#### Phase 3 — Documents & bureautique — effort moyen
|
||||
- [ ] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
|
||||
- [ ] **Google Docs** — Takeout `.docx`/HTML
|
||||
- [ ] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
|
||||
- [ ] **PDF** — extraction texte + images (fidélité limitée)
|
||||
|
||||
#### Phase 4 — Signets, dev & divers
|
||||
- [ ] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
|
||||
- [ ] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
|
||||
- [ ] **Pocket** (CSV/HTML)
|
||||
- [ ] **Readwise** (highlights CSV/Markdown)
|
||||
- [ ] **Shaarli** (API/export JSON) → bookmark cards
|
||||
- [ ] **Calendrier `.ics`** (Google/Outlook/Apple)
|
||||
- [ ] **OPML** (flux/outlines)
|
||||
- [ ] **Standard Notes / autres**
|
||||
|
||||
#### Priorisation
|
||||
| Ordre | Phase | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
|
||||
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
|
||||
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
|
||||
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
|
||||
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
|
||||
|
||||
### v5.7.0 — Database Avancée (Pt. 2)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
|
||||
|
||||
- [ ] **Types propriété — 🟡 partiellement fait** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` sont **déjà déclarés** dans `PROPERTY_TYPES` (`app/services/property_types.py:43-98`) ; reste à câbler : auto-valeurs (`created_time/by`, `last_edited_time/by`), sélecteur `person` (membres du workspace) et rendu/édition dans l'UI
|
||||
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
|
||||
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
|
||||
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
|
||||
- [ ] **WIP limits** — par colonne, alerte visuelle au dépassement
|
||||
- [ ] **Cartes configurables** — propriétés affichées par carte, couverture (image/icône/couleur), mode compact / détaillé
|
||||
- [ ] **Calendar avec drag & drop** — reschedule direct sur la grille
|
||||
- [ ] **Gallery avec couvertures** — image/icône comme vignette de carte
|
||||
|
||||
### v5.8.0 — Calendrier & Rappels
|
||||
> **Objectif** : calendrier complet + notifications proactives.
|
||||
|
||||
- [ ] **Vues Jour / Semaine / Mois** — calendrier complet (actuellement mois seulement)
|
||||
- [ ] **Récurrence d'événements** — daily/weekly/monthly/custom (RRULE)
|
||||
- [ ] **Support timezone** — par utilisateur + par événement
|
||||
- [ ] **Rappels** — in-app + email (avant échéance : N minutes/heures/jours)
|
||||
- [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels)
|
||||
- [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items
|
||||
|
||||
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
|
||||
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
|
||||
|
||||
- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`)
|
||||
- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape`
|
||||
- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs
|
||||
- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne
|
||||
- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties`
|
||||
- [x] **29 tests** `tests/test_ai_writing.py` ; version 5.9.0
|
||||
|
||||
---
|
||||
|
||||
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
|
||||
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
|
||||
> (section « Lié depuis ») par la création des liens depuis l'éditeur.
|
||||
|
||||
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
|
||||
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
|
||||
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
|
||||
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
|
||||
|
||||
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
|
||||
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
|
||||
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections.
|
||||
|
||||
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
|
||||
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
|
||||
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
|
||||
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
|
||||
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
|
||||
|
||||
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.14.0 — Synced blocks ⬜ (non commencé)
|
||||
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
|
||||
> plusieurs pages, édité une fois).
|
||||
|
||||
- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
|
||||
- [ ] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
|
||||
- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
|
||||
- [ ] **PWA** — Progressive Web App, offline support
|
||||
- [ ] **SSO/SAML** — enterprise authentication
|
||||
- [ ] **Granular permissions** — page-level, property-level access control
|
||||
- [ ] **Web Clipper** — extension navigateur
|
||||
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
|
||||
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
|
||||
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
| Feature | Fichiers | Note |
|
||||
|---------|----------|------|
|
||||
| Webhooks sortants | `services/webhook_outbound.py`, `routers/workspace.py` (`/workspace/webhooks`) | CRUD + dispatch d'événements — base pour automations/API publique |
|
||||
| API publique + tokens | `routers/public.py` / `public_api.py`, test `test_public_api_token` | À formaliser dans v5.2.0 et documenter pour v6.0.0 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||
|
||||
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. **v5.6.0 → Import de données (5 phases)** ⬅ **priorité n°1** — Phase 0 socle unifié → Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) → Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) → Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) → Phase 3 documents (Word, Google Docs, HTML, PDF)
|
||||
9. **v5.7.0 → v5.8.0 → v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (database avancée, calendrier/rappels, wiki-links, templates/lock, synced blocks, pro)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
|
||||
```
|
||||
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ✅
|
||||
Production Pages Editor DB Concept Properties Relations Views View+
|
||||
──────────────────────────────────────────────
|
||||
52 fonctionnalités identifiées dans le gap analysis
|
||||
│
|
||||
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
|
||||
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
|
||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
|
||||
├─ Bloc 5: My Tasks (v1.9) — 7 items ✅
|
||||
├─ Bloc 6: Éditeur complet (v2.0) — 8 items ✅
|
||||
└─ Bloc 7: Fonctions transversales — +multi-user ✅
|
||||
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ✅
|
||||
Base + Kanban Éditeur + Gitea UX Pro MVP Onboarding
|
||||
+ DB + Auth + Workspaces + Multi-select Accounts & Polish
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v2.0.0 ✅ v2.1.0 ⬜
|
||||
Editor+ Future
|
||||
Multi-user
|
||||
```
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
|
||||
Collaboration Realtime, de bloc (undo/ (embed,
|
||||
DB avancée, redo, drag&drop, bookmark,
|
||||
Calendrier, AI duplicate) lightbox…)
|
||||
|
||||
*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; **plan d'import v5.6.0 détaillé en 5 phases** (socle unifié, notes/Markdown, données/tableaux, documents, signets/dev/divers) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0*
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# FlowDeck — Grille de Tests Visuels
|
||||
|
||||
> Version: 2.1.0 | Dernière mise à jour: 2026-07-10 | Dernier run: 2026-07-10 | Résultat: 55/61
|
||||
|
||||
## Résumé du dernier run (2026-07-10)
|
||||
|
||||
| Section | Pass | Fail | Notes |
|
||||
|---------|------|------|-------|
|
||||
| Dashboard | 8/9 | ❌ D6 Quick Find | Quick Find pas implémenté dans le dashboard |
|
||||
| Board Kanban | 9/9 | — | OK |
|
||||
| Drag & Drop | 5/5 | — | ✅ Fix CSRF + refresh appliqué |
|
||||
| Création Issue | 6/6 | — | ✅ Fix CSRF appliqué |
|
||||
| Vues | 7/7 | — | OK |
|
||||
| Filtres & Tri | 7/7 | — | OK |
|
||||
| Notes | 3/3 | — | OK |
|
||||
| Sidebar | 6/6 | — | OK |
|
||||
| Workspaces | 6/6 | — | OK |
|
||||
| Commentaires | 3/3 | — | OK |
|
||||
| Favoris | 4/4 | — | OK |
|
||||
| Webhooks | 4/5 | ⚠️ No secret | Webhook status OK, secret non configuré |
|
||||
| PWA | 0/3 | ❌ P1-P3 | manifest.json + SW non implémentés |
|
||||
| Éditeur | 3/5 | ⚠️ E4-E5 | Commandes `/` + drag blocks à vérifier |
|
||||
| **Total** | **55** | **6** | |
|
||||
|
||||
### Bugs corrigés ce run
|
||||
- `8aaf167`: Refresh board après drag & drop
|
||||
- `[latest]`: CSRF token manquant sur fetch POST (board.html, card_detail.html)
|
||||
|
||||
### Reste à faire
|
||||
- PWA (manifest.json + service worker)
|
||||
- Quick Find dans le dashboard
|
||||
- Config webhook secret
|
||||
- Commandes `/` dans l'éditeur
|
||||
|
||||
Tests manuels de régression visuelle et fonctionnelle. Cocher après chaque release.
|
||||
|
||||
## Pré-requis
|
||||
|
||||
- FlowDeck lancé (Docker: `APP_PORT=8082 docker compose up -d`)
|
||||
- Gitea accessible (https://git.dracodev.net)
|
||||
- Au moins 1 projet avec des issues existantes
|
||||
|
||||
---
|
||||
|
||||
## 1. Dashboard (Accueil)
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| D1 | Chargement | Ouvrir `/` | Dashboard affiché, sidebar visible | |
|
||||
| D2 | Liste projets | Vérifier la liste | Projets Gitea listés, triés par mise à jour | |
|
||||
| D3 | Recherche projets | Taper dans la barre de recherche | Filtrage en temps réel | |
|
||||
| D4 | Lien projet | Cliquer sur un projet | Navigation vers le board du projet | |
|
||||
| D5 | Workspace menu | Cliquer sur le workspace header | Menu déroulant des workspaces | |
|
||||
| D6 | Quick Find | Cliquer 🔍 dans la sidebar | Modal de recherche rapide | |
|
||||
| D7 | Theme toggle | Basculer le thème | Dark ↔ Light, persisté localStorage | |
|
||||
| D8 | Collapse sidebar | Cliquer ⏴ | Sidebar se réduit/étend | |
|
||||
| D9 | Sidebar sections | Cliquer Meetings/Recents | Sections expand/collapse | |
|
||||
|
||||
---
|
||||
|
||||
## 2. Board Kanban
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| B1 | Chargement board | Ouvrir `/board/{owner}/{repo}` | Colonnes affichées avec cartes | |
|
||||
| B2 | Colonnes par défaut | Vérifier les colonnes | Backlog, À faire, En cours, Révision, Terminé | |
|
||||
| B3 | Cartes dans colonnes | Vérifier le mapping | Issues dans bonnes colonnes selon labels/état | |
|
||||
| B4 | Labels sur cartes | Vérifier l'affichage | Labels visibles avec couleur Gitea | |
|
||||
| B5 | Assignee avatar | Vérifier les cartes | Avatar + login si assigné | |
|
||||
| B6 | Milestone sur carte | Vérifier | 📅 + nom milestone si défini | |
|
||||
| B7 | Priorité | Vérifier | Badge priorité si défini dans FlowDeck | |
|
||||
| B8 | Due date | Vérifier | ⏰ + date, style "overdue" si dépassée | |
|
||||
| B9 | Compteur colonne | Vérifier chaque colonne | Nombre de cartes affiché dans le header | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Drag & Drop
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| DD1 | Move carte | Drag carte vers autre colonne | Carte déplacée visuellement | |
|
||||
| DD2 | Persistence | Après DD1, rafraîchir la page | Carte reste dans nouvelle colonne | |
|
||||
| DD3 | Refresh auto | Après move, attendre | La vue se rafraîchit automatiquement | |
|
||||
| DD4 | Compteurs | Après move | Compteurs de colonnes mis à jour | |
|
||||
| DD5 | Move → Gitea | Vérifier sur Gitea | Label/état synchronisé si mapping existe | |
|
||||
|
||||
---
|
||||
|
||||
## 4. Création d'Issue
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| C1 | Bouton New | Cliquer "New ▾" dans la toolbar | Formulaire de création affiché | |
|
||||
| C2 | Création simple | Titre + "Create" | Issue créée sur Gitea, apparaît au refresh | |
|
||||
| C3 | Création + statut | Titre + sélection statut | Issue créée avec le bon statut | |
|
||||
| C4 | Annulation | Ouvrir form → ✕ | Formulaire masqué | |
|
||||
| C5 | Enter pour créer | Titre + Entrée | Issue créée (submit au Enter) | |
|
||||
| C6 | Titre vide | "Create" sans titre | Rien ne se passe (validé client) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Vues du Board
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| V1 | Vue Kanban | Tab "Board" | Vue kanban avec colonnes | |
|
||||
| V2 | Vue Table | Tab "Table" | Vue tableau lignes/colonnes | |
|
||||
| V3 | Vue Status | Tab "Status" | Regroupement par statut | |
|
||||
| V4 | Vue TeamLoad | Tab "Workload" | Répartition par assignee | |
|
||||
| V5 | Vue Detailed | Tab "Detailed" | Vue détaillée | |
|
||||
| V6 | Switch vue | Alterner entre vues | Contenu mis à jour sans rechargement page | |
|
||||
| V7 | URL view param | `?view=table` | Vue table chargée directement | |
|
||||
|
||||
---
|
||||
|
||||
## 6. Filtres & Tri
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| F1 | Filtre milestone | Sélectionner un milestone | Seules les issues du milestone affichées | |
|
||||
| F2 | Filtre label | Sélectionner un label | Seules les issues avec ce label | |
|
||||
| F3 | Combinaison filtres | Milestone + Label | Les deux filtres appliqués (AND) | |
|
||||
| F4 | Reset filtres | Sélectionner "Tous" | Toutes les issues affichées | |
|
||||
| F5 | Tri par propriété | Add sort → choisir champ | Liste triée selon critère | |
|
||||
| F6 | Tri direction | Toggle asc/desc | Direction inversée | |
|
||||
| F7 | Supprimer tri | Delete sort | Tri retiré | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Notes
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| N1 | Accès notes | Cliquer "📝 Notes" dans le board | Page notes du projet | |
|
||||
| N2 | Édition notes | Modifier le contenu | Sauvegarde automatique | |
|
||||
| N3 | Markdown | Saisir `# Titre`, `**gras**` | Rendu Markdown | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Sidebar & Navigation
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| S1 | Navigation page | Cliquer sur une page sidebar | Board du projet chargé | |
|
||||
| S2 | Library | Cliquer 📚 sur une page | Library view avec recents/favorites/shared | |
|
||||
| S3 | New sub-page | Cliquer + sur une page | Création sous-page | |
|
||||
| S4 | Context menu | Clic droit sur page sidebar | Menu contextuel (rename, delete, etc.) | |
|
||||
| S5 | Tree toggle | Cliquer ▶ d'un dossier | Sous-éléments affichés/masqués | |
|
||||
| S6 | Drag tree | Drag & drop élément sidebar | Réorganisation de l'arbre | |
|
||||
|
||||
---
|
||||
|
||||
## 9. Workspaces Multi-User
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| W1 | Lister workspaces | Voir le menu workspace | Workspaces disponibles listés | |
|
||||
| W2 | Créer workspace | Créer nouveau workspace | Workspace ajouté | |
|
||||
| W3 | Membres | Voir liste membres | Membres affichés avec rôles | |
|
||||
| W4 | Ajouter membre | Ajouter un utilisateur | Membre ajouté | |
|
||||
| W5 | Rôle membre | Changer rôle | Rôle mis à jour | |
|
||||
| W6 | Supprimer membre | Retirer un membre | Membre retiré | |
|
||||
|
||||
---
|
||||
|
||||
## 10. Commentaires & Historique
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| H1 | Voir commentaires | Ouvrir commentaires d'une page | Liste des commentaires | |
|
||||
| H2 | Ajouter commentaire | Écrire + envoyer | Commentaire ajouté | |
|
||||
| H3 | Historique | Voir historique d'une page | Liste des versions/modifications | |
|
||||
|
||||
---
|
||||
|
||||
## 11. Favoris & Partage
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| FA1 | Ajouter favori | Mettre en favori une page | Apparaît dans les favoris | |
|
||||
| FA2 | Retirer favori | Retirer des favoris | Disparaît de la liste | |
|
||||
| FA3 | Vue favorites | Tab "Favorites" dans Library | Liste des favoris | |
|
||||
| FA4 | Vue shared | Tab "Shared" dans Library | Pages partagées | |
|
||||
|
||||
---
|
||||
|
||||
## 12. Webhooks & API Publique
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| WH1 | Register webhook | POST /api/webhooks/register/{o}/{r} | Webhook enregistré | |
|
||||
| WH2 | Webhook status | GET /api/webhooks/status/{o}/{r} | Statut retourné | |
|
||||
| WH3 | Reception webhook | Push sur Gitea → webhook reçu | Issue synchronisée | |
|
||||
| WH4 | API health | GET /api/health | `{"status":"ok","db":true,"gitea":true}` | |
|
||||
| WH5 | API projects | GET /api/projects | Liste JSON des projets | |
|
||||
|
||||
---
|
||||
|
||||
## 13. PWA & Responsive
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| P1 | Mobile view | Réduire fenêtre < 768px | Layout responsive, sidebar masquable | |
|
||||
| P2 | Install PWA | "Installer" dans Chrome | Icône sur l'écran d'accueil | |
|
||||
| P3 | Offline cache | Couper réseau, recharger | Page servie depuis cache | |
|
||||
|
||||
---
|
||||
|
||||
## 14. Éditeur Notion-like
|
||||
|
||||
| ID | Test | Action | Résultat attendu | ✅ |
|
||||
|----|------|--------|-----------------|----|
|
||||
| E1 | Éditeur riche | Ouvrir une page | Éditeur bloc-style Notion | |
|
||||
| E2 | Blocs texte | Taper du texte, Entrée | Nouveau bloc créé | |
|
||||
| E3 | Markdown inline | `**gras**`, `*italique*` | Formatage appliqué | |
|
||||
| E4 | `/` commandes | Taper `/` | Menu de commandes (headings, listes, etc.) | |
|
||||
| E5 | Drag blocks | Drag & drop blocs | Réorganisation | |
|
||||
|
||||
---
|
||||
|
||||
## Exécution
|
||||
|
||||
```bash
|
||||
# Lancer FlowDeck
|
||||
cd ~/workspace/flowdeck && docker compose up -d
|
||||
|
||||
# Vérifier
|
||||
curl -s http://localhost:8082/api/health
|
||||
# → {"status":"ok","version":"2.1.0","db":true,"gitea":true}
|
||||
|
||||
# Ouvrir
|
||||
# Dashboard: http://localhost:8082/
|
||||
# Board: http://localhost:8082/board/bruno/flowdeck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Total: 61 tests** | Dernier run: ________ | Résultat: ___/61
|
||||
+42
-51
@@ -1,71 +1,62 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version actuelle**: v1.3.0 | **Prochaine**: v1.4.0
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
|
||||
## Avancement Global
|
||||
|
||||
| Phase | Description | Statut | Tests |
|
||||
|-------|-------------|--------|-------|
|
||||
| v0.2 – v0.9 | Base → Backend | ✅ | — |
|
||||
| Version | Description | Statut | Tests |
|
||||
|---------|-------------|--------|-------|
|
||||
| v0.2–v0.9 | Base → Backend | ✅ | — |
|
||||
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
|
||||
| v1.1 | Pages & Sidebar Notion | ✅ | — |
|
||||
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
|
||||
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
|
||||
| v1.4 | Propriétés Avancées (14 types) | ⬜ | — |
|
||||
| v1.5 | Relations & Rollups | ⬜ | — |
|
||||
| v1.6 | Vues Calendrier/Timeline/Gallery/List | ⬜ | — |
|
||||
| v1.7 | Vues Améliorées (group_by, card_size) | ⬜ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ⬜ | — |
|
||||
| v1.9 | My Tasks & Dashboard Unifié | ⬜ | — |
|
||||
| v2.0 | Éditeur Complet & Multi-Users | ⬜ | — |
|
||||
| v1.4 | Propriétés Avancées (21 types) | ✅ | 38/38 |
|
||||
| v1.5 | Relations, Rollups, Formulas | ✅ | 43/43 |
|
||||
| v1.6 | Vues (Calendar, Gallery, List, Timeline) | ✅ | 49/49 |
|
||||
| v1.7 | Vues Améliorées (view config, save-as) | ✅ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ✅ | 56/56 |
|
||||
| v1.9 | My Tasks Dashboard | ✅ | 60/60 |
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
|
||||
---
|
||||
## Blocs Complétés
|
||||
|
||||
## v1.3.0 — Database Concept (complété 2026-07-10)
|
||||
| Bloc | Features | Statut |
|
||||
|------|----------|--------|
|
||||
| Bloc 1 | Database Concept | ✅ 6/6 |
|
||||
| Bloc 2 | Propriétés Avancées | ✅ 14/14 |
|
||||
| Bloc 3 | Vues Manquantes | ✅ 10/10 |
|
||||
| Bloc 4 | Sub-items & Dépendances | ✅ 7/7 |
|
||||
| Bloc 5 | My Tasks | ✅ 7/7 |
|
||||
| Bloc 6 | Éditeur Complet | ✅ 8/8 |
|
||||
| Bloc 7 | Fonctions Transversales | ✅ |
|
||||
|
||||
### Tables
|
||||
- [x] `collections` — base de données abstraite, schéma JSON, lien Gitea optionnel
|
||||
- [x] `collection_pages` — pages avec `parent_id`, `property_values_json`, `position`
|
||||
- [x] `collection_views` — vues configurables (`view_type`, `config_json`)
|
||||
- [x] Index: `idx_cp_collection`, `idx_cp_parent`, `idx_cp_gitea`
|
||||
**Total: 52/52 features (100%)**
|
||||
|
||||
### API
|
||||
- [x] `GET/POST /db/api` — lister/créer collections
|
||||
- [x] `GET/PUT/DELETE /db/api/{id}` — CRUD collection
|
||||
- [x] `GET/POST /db/{id}/pages/api` — pages dans une collection
|
||||
- [x] `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
|
||||
- [x] `GET /db/{id}` — vue HTML collection
|
||||
- [x] `GET /db/boards/api` — boards Gitea comme collections
|
||||
- [x] `GET /db/board/{o}/{r}/api` — board spécifique
|
||||
- [x] `POST /db/board/{o}/{r}/sync` — sync board → collection
|
||||
## Architecture
|
||||
|
||||
### Adapter
|
||||
- [x] `GiteaBoardCompat` — `from_board()`, `from_card()`, `list_boards_as_collections()`, `sync_to_collection()`
|
||||
### Tables (21)
|
||||
`users`, `user_tokens`, `boards`, `cards`, `notes`, `col_mapping`, `checklists`, `checklist_items`, `project_properties`, `property_values`, `ai_keywords`, `pages`, `collections`, `collection_pages`, `collection_views`, `collection_properties`, `workspaces`, `workspace_members`, `comments`, `page_history`, `favorites`, `database_templates`, `page_templates`, `webhook_subscriptions`
|
||||
|
||||
### Infra
|
||||
- [x] CSRF exempté pour `/db/`
|
||||
- [x] Version 1.0.0 → 1.3.0
|
||||
- [x] 34/34 tests passent
|
||||
- [x] Tag git v1.3.0
|
||||
### Routeurs (11)
|
||||
`dashboard`, `board`, `notes`, `api`, `auth`, `webhooks`, `collections`, `my_tasks`, `workspace`, `public_api` + app-level routes
|
||||
|
||||
---
|
||||
### Services (6)
|
||||
`GiteaClient`, `FormulaEngine`, `RollupEngine`, `GiteaBoardCompat`, `property_types`, `webhook_outbound`
|
||||
|
||||
## Prochaines étapes (v1.4.0 — Propriétés Avancées)
|
||||
### Endpoints (85+)
|
||||
CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/dependencies, my-tasks, workspaces, comments, history, favorites, templates, CSV import/export, public sharing, public API v1, webhooks, PWA manifest
|
||||
|
||||
### Table `collection_properties`
|
||||
- [ ] Remplacer `project_properties` (liée à `project_owner/name`) par `collection_properties` (liée à `collections`)
|
||||
- [ ] Types: number, checkbox, url, email, phone, status (9 couleurs), files, unique_id
|
||||
- [ ] Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- [ ] Format number: nombre, %, €, $, £, ¥
|
||||
## Stack
|
||||
|
||||
### Intégration UI
|
||||
- [ ] Property editor inline par type (select dropdown, date picker, checkbox...)
|
||||
- [ ] Affichage des propriétés dans la modale de détail page
|
||||
- [ ] Migration project_properties → collection_properties
|
||||
|
||||
## Notes
|
||||
|
||||
- **Stack**: FastAPI + Jinja2 + HTMX + Alpine.js + SortableJS + SQLite (WAL)
|
||||
- **Docker**: python:3.12-slim, port 8080, volume /data
|
||||
- **Tests**: pytest, 34 tests, client TestClient avec SQLite temporaire
|
||||
- **Backend**: Python 3.12 + FastAPI + httpx
|
||||
- **Frontend**: Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
|
||||
from app.auth.oauth import GiteaOAuth
|
||||
from app.auth.session import SessionManager, get_current_user
|
||||
|
||||
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Multi-forge OAuth providers — Gitea + GitHub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Abstract OAuth2 provider interface."""
|
||||
|
||||
name: str = ""
|
||||
icon: str = "🔗"
|
||||
|
||||
@abstractmethod
|
||||
def is_enabled(self) -> bool:
|
||||
"""Whether this provider is configured."""
|
||||
|
||||
@abstractmethod
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
"""Build the authorization URL."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||
"""Exchange authorization code for access token."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
"""Fetch user profile from the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
"""List all repositories accessible to this user."""
|
||||
|
||||
|
||||
class GiteaProvider(OAuthProvider):
|
||||
"""Gitea OAuth2 provider."""
|
||||
|
||||
name = "gitea"
|
||||
icon = "🔗"
|
||||
|
||||
def __init__(self, base_url: str, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.base = base_url.rstrip("/")
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
if force_login:
|
||||
# Gitea supports prompt=login param (undocumented but works)
|
||||
# If not, fallback: add _force= timestamp cache-buster
|
||||
params["_force"] = str(int(time.time()))
|
||||
return f"{self.base}/login/oauth/authorize?" + urlencode(params)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||
url = f"{self.base}/login/oauth/access_token"
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
||||
if r.status_code != 200:
|
||||
logger.error("Gitea token exchange failed: %s", r.text)
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.base}/api/v1/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
return {
|
||||
"login": data.get("login") or data.get("username", ""),
|
||||
"full_name": data.get("full_name", ""),
|
||||
"email": data.get("email", ""),
|
||||
"avatar_url": data.get("avatar_url", ""),
|
||||
"provider_id": str(data.get("id", "")),
|
||||
}
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.base}/api/v1/user/repos",
|
||||
headers={"Authorization": f"token {access_token}"},
|
||||
params={"page": page, "limit": 50},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
for repo in data:
|
||||
repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"clone_url": repo.get("clone_url", ""),
|
||||
"default_branch": repo.get("default_branch", "main"),
|
||||
"language": repo.get("language", ""),
|
||||
"updated_at": repo.get("updated_at", ""),
|
||||
"private": repo.get("private", False),
|
||||
"forge": "gitea",
|
||||
})
|
||||
return repos
|
||||
|
||||
|
||||
class GitHubProvider(OAuthProvider):
|
||||
"""GitHub OAuth2 provider."""
|
||||
|
||||
name = "github"
|
||||
icon = "🐙"
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
self.authorize_url = "https://github.com/login/oauth/authorize"
|
||||
self.token_url = "https://github.com/login/oauth/access_token"
|
||||
self.api_url = "https://api.github.com"
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
return (
|
||||
f"{self.authorize_url}?"
|
||||
+ urlencode({
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
"scope": "repo,user",
|
||||
"state": state,
|
||||
})
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.error("GitHub token exchange failed: %s", r.text)
|
||||
return None
|
||||
data = r.json()
|
||||
if "access_token" not in data:
|
||||
return None
|
||||
return data
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.api_url}/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
return {
|
||||
"login": data.get("login", ""),
|
||||
"full_name": data.get("name", "") or data.get("login", ""),
|
||||
"email": data.get("email", ""),
|
||||
"avatar_url": data.get("avatar_url", ""),
|
||||
"provider_id": str(data.get("id", "")),
|
||||
}
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.api_url}/user/repos",
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
params={"page": page, "per_page": 50, "sort": "updated"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
for repo in data:
|
||||
repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"clone_url": repo.get("clone_url", ""),
|
||||
"default_branch": repo.get("default_branch", "main"),
|
||||
"language": repo.get("language", ""),
|
||||
"updated_at": repo.get("updated_at", ""),
|
||||
"private": repo.get("private", False),
|
||||
"forge": "github",
|
||||
})
|
||||
return repos
|
||||
|
||||
|
||||
# ═══════════ Provider registry ═══════════
|
||||
|
||||
def get_providers() -> list[OAuthProvider]:
|
||||
"""Return all configured OAuth providers."""
|
||||
from app.config import settings
|
||||
|
||||
providers: list[OAuthProvider] = []
|
||||
|
||||
gitea = GiteaProvider(
|
||||
base_url=settings.gitea_url,
|
||||
client_id=settings.gitea_oauth_client_id,
|
||||
client_secret=settings.gitea_oauth_client_secret,
|
||||
redirect_uri=settings.oauth_redirect_uri,
|
||||
)
|
||||
if gitea.is_enabled():
|
||||
providers.append(gitea)
|
||||
|
||||
github = GitHubProvider(
|
||||
client_id=settings.github_oauth_client_id or "",
|
||||
client_secret=settings.github_oauth_client_secret or "",
|
||||
redirect_uri=settings.oauth_redirect_uri or "",
|
||||
)
|
||||
if github.is_enabled():
|
||||
providers.append(github)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get a specific provider by name."""
|
||||
for p in get_providers():
|
||||
if p.name == name:
|
||||
return p
|
||||
return None
|
||||
+123
-8
@@ -1,26 +1,43 @@
|
||||
"""FlowDeck — Session management with signed cookies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions via signed cookies."""
|
||||
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
||||
|
||||
Each cookie embeds a ``sid`` referencing a row in ``user_sessions``.
|
||||
Revoking that row instantly invalidates the cookie (checked in
|
||||
``decode_session``). Legacy cookies without a ``sid`` stay valid.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_session(user_data: dict) -> str:
|
||||
"""Create a signed session cookie value."""
|
||||
def create_session(user_data: dict, request=None) -> str:
|
||||
"""Create a signed session cookie value.
|
||||
|
||||
``request`` is optional — when provided the session is recorded in the
|
||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
sid = str(uuid4())
|
||||
payload["sid"] = sid
|
||||
_record_session(sid, user_id, request)
|
||||
return _serializer.dumps(payload)
|
||||
|
||||
@staticmethod
|
||||
@@ -28,10 +45,65 @@ class SessionManager:
|
||||
"""Decode and validate a session cookie. Returns user data or None."""
|
||||
try:
|
||||
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
|
||||
return payload.get("user")
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
sid = payload.get("sid") or ""
|
||||
if sid and not _session_active(sid):
|
||||
# Revoked or deleted session → treat as logged out.
|
||||
return None
|
||||
if sid:
|
||||
_touch_session(sid)
|
||||
return payload.get("user")
|
||||
|
||||
@staticmethod
|
||||
def session_id(cookie: str) -> str | None:
|
||||
"""Return the session id embedded in a cookie (or None)."""
|
||||
try:
|
||||
payload = _serializer.loads(cookie, max_age=86400 * 7)
|
||||
return payload.get("sid")
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(user_id: int) -> list[dict]:
|
||||
"""All recorded sessions for a user (for the Settings UI)."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, ip_address, user_agent, created_at, last_seen_at, revoked "
|
||||
"FROM user_sessions WHERE user_id=? ORDER BY last_seen_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def revoke_session(sid: str) -> bool:
|
||||
"""Revoke a session row. Returns True if a row was updated."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE user_sessions SET revoked=1 WHERE id=? AND revoked=0", (sid,)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
if sid is None:
|
||||
sid = str(uuid4())
|
||||
_record_session(sid, user_id, request)
|
||||
payload["sid"] = sid
|
||||
return _serializer.dumps(payload)
|
||||
|
||||
@staticmethod
|
||||
def store_token(user_id: int, gitea_token: str) -> None:
|
||||
"""Store a user's Gitea OAuth token in SQLite."""
|
||||
@@ -58,10 +130,53 @@ class SessionManager:
|
||||
return row["gitea_token"] if row else None
|
||||
|
||||
|
||||
def _record_session(sid: str, user_id: int, request) -> None:
|
||||
ip = ""
|
||||
ua = ""
|
||||
if request is not None:
|
||||
ip = request.client.host if getattr(request, "client", None) else ""
|
||||
ua = (request.headers.get("user-agent", "") or "")[:500]
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
|
||||
(sid, user_id, ip, ua),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # table may not exist in very old installs
|
||||
logger.debug("session record skipped: %s", exc)
|
||||
|
||||
|
||||
def _session_active(sid: str) -> bool:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT revoked FROM user_sessions WHERE id=?", (sid,)
|
||||
).fetchone()
|
||||
return bool(row and not row["revoked"])
|
||||
except Exception:
|
||||
# No table / DB unavailable → keep the cookie valid (fail-open-safe).
|
||||
return True
|
||||
|
||||
|
||||
def _touch_session(sid: str) -> None:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND revoked=0",
|
||||
(sid,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
async def get_current_user(request) -> dict | None:
|
||||
"""FastAPI dependency: extract current user from session cookie."""
|
||||
from fastapi import Request
|
||||
session = request.cookies.get("flowdeck_session")
|
||||
if session:
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
+51
-5
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -13,12 +14,20 @@ class Settings(BaseSettings):
|
||||
# Gitea
|
||||
gitea_url: str = "https://git.dracodev.net"
|
||||
gitea_token: str = "change-me"
|
||||
gitea_oauth_client_id: str = ""
|
||||
gitea_oauth_client_secret: str = ""
|
||||
gitea_oauth_client_id: str = "test-id"
|
||||
gitea_oauth_client_secret: str = "test-secret"
|
||||
gitea_webhook_secret: str = ""
|
||||
|
||||
# OAuth2
|
||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||
# GitHub
|
||||
github_oauth_client_id: str = ""
|
||||
github_oauth_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||
oauth_redirect_uri: str = ""
|
||||
|
||||
# Webhook
|
||||
webhook_base_url: str = "http://localhost:8080"
|
||||
@@ -41,12 +50,49 @@ class Settings(BaseSettings):
|
||||
sync_interval: int = 60
|
||||
gitea_cache_ttl: int = 30
|
||||
|
||||
# Backup (v5.2.0) — scheduled daily snapshot of the SQLite file
|
||||
backup_enabled: bool = True
|
||||
backup_dir: str = "/data/backups"
|
||||
backup_interval_hours: int = 24
|
||||
backup_keep: int = 30
|
||||
|
||||
# Forge projects sync (v5.2.0) — periodic refresh of `projects` table
|
||||
project_sync_enabled: bool = True
|
||||
project_sync_interval_hours: int = 1
|
||||
|
||||
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
||||
# email notifications are skipped (only in-app notifications are delivered).
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "FlowDeck <[email protected]>"
|
||||
smtp_use_tls: bool = True
|
||||
app_base_url: str = "http://localhost:8080"
|
||||
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
llm_provider: str = "offline" # openai | anthropic | google | ollama |
|
||||
# deepseek | qwencloud | nvidia | openrouter | offline
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_api_key: str = ""
|
||||
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
|
||||
agent_max_iterations: int = 12
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
return Path(":memory:") # Special SQLite in-memory
|
||||
if self.database_url.startswith("sqlite:///"):
|
||||
return Path(self.database_url.replace("sqlite:///", "/"))
|
||||
p = self.database_url.replace("sqlite:///", "", 1)
|
||||
# On Windows, don't prepend / if path starts with a drive letter
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z]:', p):
|
||||
return Path(p)
|
||||
return Path("/" + p)
|
||||
return Path("/data/flowdeck.db")
|
||||
|
||||
|
||||
|
||||
@@ -27,10 +27,24 @@ def init_db():
|
||||
full_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
avatar_color TEXT NOT NULL DEFAULT '#3A3A3A',
|
||||
password_hash TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login TIMESTAMP,
|
||||
login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gitea_user_id INTEGER NOT NULL UNIQUE,
|
||||
@@ -39,6 +53,19 @@ def init_db():
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
instance_url TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_owner TEXT NOT NULL,
|
||||
@@ -105,8 +132,8 @@ def init_db():
|
||||
project_owner TEXT NOT NULL,
|
||||
project_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'select', -- select, multi_select, date, person, text
|
||||
options_json TEXT DEFAULT '[]', -- for select/multi_select
|
||||
prop_type TEXT NOT NULL DEFAULT 'select',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_owner, project_name, name)
|
||||
@@ -134,10 +161,13 @@ def init_db():
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace TEXT NOT NULL,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
title TEXT NOT NULL DEFAULT 'New page',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
parent_section TEXT DEFAULT 'Private',
|
||||
parent_id INTEGER REFERENCES pages(id),
|
||||
share_mode TEXT DEFAULT 'private',
|
||||
published INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -162,7 +192,7 @@ def init_db():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT DEFAULT '📄',
|
||||
icon TEXT DEFAULT 'file',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES collection_pages(id),
|
||||
gitea_issue_id INTEGER,
|
||||
@@ -244,14 +274,16 @@ def init_db():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v2.2.0: Migrate favorites — drop old schema referencing collection_pages
|
||||
DROP TABLE IF EXISTS favorites;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER REFERENCES collection_pages(id),
|
||||
collection_id INTEGER REFERENCES collections(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id, collection_id)
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_templates (
|
||||
@@ -270,12 +302,36 @@ def init_db():
|
||||
content_json TEXT DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v2.2.0: Tags system
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#787774',
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(name, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS page_tags (
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (page_id, tag_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_page ON page_tags(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_tag ON page_tags(tag_id);
|
||||
""")
|
||||
# Migration: add parent_id if missing (v1.0.0+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add avatar_color (v2.3.0+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN avatar_color TEXT NOT NULL DEFAULT '#3A3A3A'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
||||
@@ -292,7 +348,479 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.2: Auth locale
|
||||
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.5: Login history
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.6: Tags per-user
|
||||
try:
|
||||
conn.execute("ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
# Recreate UNIQUE constraint for per-user tags
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS tags_new (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#787774', user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(name, user_id))")
|
||||
conn.execute("INSERT OR IGNORE INTO tags_new (id, name, color, user_id, created_at) SELECT id, name, color, COALESCE(user_id,0), created_at FROM tags")
|
||||
conn.execute("DROP TABLE tags")
|
||||
conn.execute("ALTER TABLE tags_new RENAME TO tags")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
# v2.7: Private pages for Gitea workspaces
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS gitea_private_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
gitea_owner TEXT NOT NULL,
|
||||
gitea_repo TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT 'Untitled',
|
||||
content TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# ── v4.0: Account & Integrations ──
|
||||
# 1) auth_method on users
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Detect existing auth: if user has a gitea_token in user_tokens → 'gitea'
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_method='local' WHERE auth_method IS NULL"
|
||||
)
|
||||
# 2) Publishing & sharing columns on pages
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 3) page_shares table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS page_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_email TEXT DEFAULT '',
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
# 4) recents table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id),
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
source_type TEXT NOT NULL DEFAULT 'local',
|
||||
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, page_id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.1.0: Data Sources & Linked Databases
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS collection_data_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
|
||||
source_name TEXT DEFAULT '',
|
||||
is_linked BOOLEAN NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, source_collection_id)
|
||||
)
|
||||
""")
|
||||
# Add workspace_id to collections if missing (v4.1.0 migration)
|
||||
try:
|
||||
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Add created_by to collections if missing
|
||||
try:
|
||||
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# v4.2.0: Collection Templates (enhanced) + Dashboards
|
||||
# Add description, is_recurring, recurrence_rule to page_templates
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Dashboard: combinaison de vues/widgets sur une page
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS collection_dashboards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Dashboard',
|
||||
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.4.0: Tasks & Dependencies
|
||||
# Add is_task flag to collections
|
||||
try:
|
||||
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Dependencies table: bloque/bloqué par with auto-shift config
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS page_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
dependency_type TEXT NOT NULL DEFAULT 'blocks',
|
||||
auto_shift TEXT DEFAULT 'overlap',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, dependency_id, dependency_type)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.5.0: Sprints
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
goal TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
auto_complete BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sprint_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
status_at_start TEXT DEFAULT '',
|
||||
velocity_points INTEGER DEFAULT 1,
|
||||
UNIQUE(sprint_id, page_id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.6.0: Sidebar customization config per user
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════
|
||||
# Notifications table (mentions, comments, page changes)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
actor_id INTEGER REFERENCES users(id),
|
||||
ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page'
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
resource_type TEXT NOT NULL DEFAULT 'page',
|
||||
resource_id INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)"
|
||||
)
|
||||
|
||||
# Notification email preferences (JSON: {"comments": true, "mentions": true})
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to
|
||||
# collection_pages, which prevents using page-editor (pages) ids. Rebuild it so
|
||||
# it can hold page comments with optional inline anchors, while preserving data.
|
||||
# target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id.
|
||||
# anchor_block_id = block id; anchor_start/anchor_end = text selection offsets.
|
||||
_cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()]
|
||||
if "target_type" not in _cols:
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE comments_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
parent_id INTEGER,
|
||||
resolved BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
target_type TEXT NOT NULL DEFAULT 'page',
|
||||
target_id INTEGER NOT NULL DEFAULT 0,
|
||||
anchor_block_id TEXT,
|
||||
anchor_start INTEGER,
|
||||
anchor_end INTEGER
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"""INSERT INTO comments_new
|
||||
(id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id)
|
||||
SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at,
|
||||
'collection_page', COALESCE(page_id, 0)
|
||||
FROM comments"""
|
||||
)
|
||||
conn.execute("DROP TABLE comments")
|
||||
conn.execute("ALTER TABLE comments_new RENAME TO comments")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
|
||||
icon TEXT DEFAULT '🤖',
|
||||
agent_type TEXT NOT NULL DEFAULT 'personal',
|
||||
description TEXT DEFAULT '',
|
||||
system_instructions TEXT DEFAULT '',
|
||||
model TEXT DEFAULT 'gpt-4o',
|
||||
scope_json TEXT NOT NULL DEFAULT '{}',
|
||||
trigger_json TEXT NOT NULL DEFAULT '{}',
|
||||
approval_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
title TEXT DEFAULT 'New conversation',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_calls_json TEXT DEFAULT '[]',
|
||||
model TEXT,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
undo_snapshot_json TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
executed_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_skills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
prompt_template TEXT NOT NULL,
|
||||
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_triggers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'manual',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
last_fired_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ───────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL,
|
||||
message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
|
||||
snippet TEXT DEFAULT '',
|
||||
comment TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)")
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
|
||||
|
||||
# ─── v4.10.1: LLM runtime config (single row id=1) ─────────────────────
|
||||
# Created lazily (no seed) so .env stays the default until an admin saves
|
||||
# the LLM settings from the UI. Precedence: DB row > settings.llm_*.
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS llm_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL DEFAULT 'offline',
|
||||
model TEXT DEFAULT '',
|
||||
api_key TEXT DEFAULT '',
|
||||
api_base TEXT DEFAULT '',
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_model TEXT DEFAULT '',
|
||||
verified_at TIMESTAMP,
|
||||
last_error TEXT DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# ─── v4.10.2: per-user provider API keys ──────────────────────────────
|
||||
# Each user can save several providers with their own key/base + the
|
||||
# live model list fetched from the provider (models_json cache).
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_llm_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
api_base TEXT NOT NULL DEFAULT '',
|
||||
default_model TEXT DEFAULT '',
|
||||
models_json TEXT NOT NULL DEFAULT '[]',
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_model TEXT DEFAULT '',
|
||||
verified_at TIMESTAMP,
|
||||
last_error TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)")
|
||||
# v4.12: provider activation/verification — a provider is only offered in
|
||||
# the Agent UI once it is configured AND its connection test succeeded.
|
||||
for col, ddl in (
|
||||
("verified", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("verified_model", "TEXT DEFAULT ''"),
|
||||
("verified_at", "TIMESTAMP"),
|
||||
("last_error", "TEXT DEFAULT ''"),
|
||||
):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
for col, ddl in (
|
||||
("verified", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("verified_model", "TEXT DEFAULT ''"),
|
||||
("verified_at", "TIMESTAMP"),
|
||||
("last_error", "TEXT DEFAULT ''"),
|
||||
):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
# Migration: per-conversation provider/model override columns
|
||||
for col in ("provider", "model"):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
|
||||
conn.commit()
|
||||
|
||||
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
|
||||
from app.migrations import apply_migrations
|
||||
apply_migrations(conn)
|
||||
|
||||
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
|
||||
# schema exists without depending on the FastAPI lifespan startup.
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
init_webhook_tables()
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
+163
-8
@@ -1,19 +1,48 @@
|
||||
"""FlowDeck — Kanban léger intégré à Gitea."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api
|
||||
from app.services.gitea_client import gitea
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import (
|
||||
admin,
|
||||
agent,
|
||||
api,
|
||||
auth,
|
||||
board,
|
||||
collections,
|
||||
dashboard,
|
||||
export,
|
||||
library,
|
||||
my_tasks,
|
||||
notes,
|
||||
onboarding,
|
||||
projects,
|
||||
public_api,
|
||||
search,
|
||||
security,
|
||||
sharing,
|
||||
sidebar_config,
|
||||
webhooks,
|
||||
workspace,
|
||||
)
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -28,18 +57,51 @@ async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
admin_hash = hash_password("FlowDeck2026!")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(admin_hash,)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v2.1.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
|
||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||
from app.services.backup import backup_scheduler
|
||||
backup_task = asyncio.create_task(backup_scheduler())
|
||||
|
||||
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
||||
from app.services.projects import project_sync_scheduler
|
||||
projects_task = asyncio.create_task(project_sync_scheduler())
|
||||
|
||||
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
|
||||
logger.info("FlowDeck v5.11.2 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
|
||||
task.cancel()
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="2.1.0",
|
||||
version="5.11.2",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -47,18 +109,38 @@ app = FastAPI(
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_age=3600)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(ContentSecurityPolicyMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(board.router)
|
||||
app.include_router(notes.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(projects.backups_router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(gitea_router)
|
||||
app.include_router(github_router)
|
||||
app.include_router(public_api.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(onboarding.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -74,3 +156,76 @@ async def pwa_manifest():
|
||||
"theme_color": "#191919",
|
||||
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════ API aliases (v4.0.1) ═══════════
|
||||
|
||||
|
||||
@app.get("/api/csrf-token")
|
||||
async def csrf_token_endpoint(request: Request):
|
||||
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||
import secrets
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
token = secrets.token_hex(32)
|
||||
response = JSONResponse({"csrf_token": token})
|
||||
response.set_cookie(
|
||||
"csrf_token", token,
|
||||
httponly=False, samesite="lax", max_age=86400, path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/pages")
|
||||
async def api_pages_alias(request: Request):
|
||||
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
qs = str(request.url.query)
|
||||
target = f"/board/api/pages{'?' + qs if qs else ''}"
|
||||
return RedirectResponse(url=target, status_code=307)
|
||||
|
||||
|
||||
@app.post("/api/pages")
|
||||
async def api_pages_post_alias(request: Request):
|
||||
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||
|
||||
|
||||
# ═══════════ Styled 404 handler ═══════════
|
||||
|
||||
|
||||
NOT_FOUND_HTML = """<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>404 — FlowDeck</title>
|
||||
<style>
|
||||
:root{--bg:#191919;--text:#e0e0e0;--text-dim:#999;--accent:#2383E2}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
|
||||
.box h1{font-size:6rem;font-weight:800;opacity:.08;line-height:1}
|
||||
.box p{font-size:18px;color:var(--text-dim);margin:8px 0 24px}
|
||||
.box a{color:var(--accent);text-decoration:none;font-size:14px}
|
||||
.box a:hover{text-decoration:underline}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>404</h1>
|
||||
<p>This page doesn't exist or has been moved.</p>
|
||||
<a href="/">← Back to FlowDeck</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
"""FlowDeck — Custom middleware."""
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
|
||||
__all__ = ["CSRFMiddleware"]
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import secrets
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
@@ -33,6 +33,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
httponly=False, # Must be readable by JS
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────
|
||||
|
||||
# Allowed extensions for file uploads
|
||||
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
||||
# Images
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||
# Documents
|
||||
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
|
||||
# Code
|
||||
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
|
||||
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
|
||||
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
|
||||
".swift", ".kt", ".scala", ".r",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||
# Misc
|
||||
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
|
||||
})
|
||||
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def validate_upload(filename: str, size: int) -> str | None:
|
||||
"""Validate upload filename and size. Returns error message or None."""
|
||||
if size > MAX_UPLOAD_SIZE:
|
||||
return f"File '{filename}' exceeds maximum size of 10 MB"
|
||||
ext = _ext(filename)
|
||||
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||
return f"File extension '{ext}' is not allowed"
|
||||
return None
|
||||
|
||||
|
||||
def _ext(filename: str) -> str:
|
||||
"""Extract lowercase extension from filename."""
|
||||
if "." in filename:
|
||||
return "." + filename.rsplit(".", 1)[-1].lower()
|
||||
return ""
|
||||
|
||||
|
||||
# ── Content-Security-Policy Middleware ────────────────────────
|
||||
|
||||
class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
"""Sets Content-Security-Policy headers on all HTML responses.
|
||||
|
||||
A permissive-yet-safe policy for a Notion-style app that needs:
|
||||
- inline scripts (Alpine.js, HTMX)
|
||||
- inline styles
|
||||
- font loading
|
||||
- images from various sources
|
||||
- media (audio/video)
|
||||
- websocket connections for HMR/SSE
|
||||
"""
|
||||
|
||||
CSP_HEADER = "Content-Security-Policy"
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
# Only set CSP on HTML responses
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "text/html" in content_type:
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE
|
||||
return response
|
||||
|
||||
|
||||
# ── Rate Limiting Middleware ──────────────────────────────────
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple in-memory sliding-window rate limiter per IP.
|
||||
|
||||
Default: 100 requests per minute per IP for API routes.
|
||||
Non-API routes (HTML pages, static files) are not rate-limited.
|
||||
"""
|
||||
|
||||
# Paths that should be rate-limited
|
||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||
"/api/", "/board/api/", "/auth/",
|
||||
)
|
||||
|
||||
# Paths exempt from rate limiting even under an API prefix
|
||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||
"/api/health",
|
||||
"/api/frontend-error",
|
||||
"/api/frontend-errors",
|
||||
})
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Respect the global rate-limit toggle (disabled in tests/local).
|
||||
from app.config import settings
|
||||
if not settings.rate_limit_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Only rate-limit API routes
|
||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# Exempt health check and error capture
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
now = time.time()
|
||||
|
||||
window_start, count = self._store[ip]
|
||||
if now - window_start > self.window_seconds:
|
||||
self._store[ip] = (now, 1)
|
||||
return await call_next(request)
|
||||
|
||||
if count >= self.max_requests:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
self._store[ip] = (window_start, count + 1)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,377 @@
|
||||
"""FlowDeck — versioned schema migrations (lightweight, no Alembic).
|
||||
|
||||
This replaces the previous "ad-hoc" approach where every new schema change was
|
||||
appended directly to `app/db.py::init_db()` with no tracking. A `schema_version`
|
||||
table now records the highest applied migration; the full baseline schema
|
||||
(created idempotently by `init_db`) is treated as version 1, and any incremental
|
||||
change is expressed as an ordered, versioned step below and applied exactly once.
|
||||
|
||||
Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys
|
||||
already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded
|
||||
``ALTER TABLE``) so it is safe even if partially re-run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The full baseline schema created by `app.db::init_db()` is "version 1".
|
||||
BASELINE_VERSION = 1
|
||||
|
||||
# (version, name, apply_fn). Kept sorted by version at registration time.
|
||||
MIGRATIONS: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
|
||||
|
||||
|
||||
def register(version: int, name: str) -> Callable:
|
||||
"""Decorator registering a migration in the ordered registry."""
|
||||
if any(v == version for v, _, _ in MIGRATIONS):
|
||||
raise ValueError(f"Duplicate migration version {version}")
|
||||
|
||||
def decorator(fn: Callable[[sqlite3.Connection], None]):
|
||||
MIGRATIONS.append((version, name, fn))
|
||||
MIGRATIONS.sort(key=lambda item: item[0])
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def fts5_available() -> bool:
|
||||
"""True when the bundled SQLite ships the FTS5 extension."""
|
||||
probe = sqlite3.connect(":memory:")
|
||||
try:
|
||||
probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)")
|
||||
return True
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
"""Seal the baseline schema (version 1) and apply pending migrations.
|
||||
|
||||
Returns the resulting schema version.
|
||||
"""
|
||||
_ensure_table(conn)
|
||||
applied = current_version(conn)
|
||||
|
||||
if applied < BASELINE_VERSION:
|
||||
# The pre-existing schema (already created by init_db) is our baseline.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(BASELINE_VERSION, "baseline"),
|
||||
)
|
||||
conn.commit()
|
||||
applied = BASELINE_VERSION
|
||||
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(version, name),
|
||||
)
|
||||
conn.commit()
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Migrations
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@register(2, "missing indexes")
|
||||
def _migration_missing_indexes(conn: sqlite3.Connection) -> None:
|
||||
"""Add the indexes flagged in the roadmap (fast lookups by email, forge user)."""
|
||||
for ddl in (
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
|
||||
):
|
||||
conn.execute(ddl)
|
||||
|
||||
|
||||
@register(3, "full-text search (FTS5)")
|
||||
def _migration_fts5(conn: sqlite3.Connection) -> None:
|
||||
"""Create a full-text index over pages (title + content) for the command palette.
|
||||
|
||||
Kept in sync via row-level triggers on the ``pages`` table so page
|
||||
insert/update/delete are reflected immediately. Skips gracefully if the
|
||||
bundled SQLite lacks FTS5 (search then falls back to LIKE).
|
||||
"""
|
||||
if not fts5_available():
|
||||
logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)")
|
||||
return
|
||||
|
||||
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
||||
END
|
||||
"""
|
||||
)
|
||||
# `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes
|
||||
# use a plain DELETE by rowid (NOT the special 'delete' insert that only applies
|
||||
# to external-content/contentless FTS5 tables).
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN
|
||||
DELETE FROM pages_fts WHERE rowid = old.id;
|
||||
END
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN
|
||||
DELETE FROM pages_fts WHERE rowid = old.id;
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill the index from any rows that already exist.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@register(5, "automations (v5.1.0 rules engine)")
|
||||
def _migration_automations(conn: sqlite3.Connection) -> None:
|
||||
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
|
||||
condition + action) and clickable buttons that trigger actions.
|
||||
|
||||
``automations`` — the rules (event/cron/button trigger, optional
|
||||
condition JSON, actions JSON, run counters).
|
||||
``automation_runs`` — execution history for auditing and the Settings UI.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
|
||||
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
|
||||
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
|
||||
collection_id INTEGER, -- optional scope (event triggers)
|
||||
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
|
||||
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by INTEGER,
|
||||
last_run_at TIMESTAMP,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
trigger_source TEXT NOT NULL DEFAULT 'event',
|
||||
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
collection_id INTEGER,
|
||||
page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(6, "v5.2.0: api tokens, user sessions, projects")
|
||||
def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"""v5.2.0 (Security & Forge): per-user API tokens, revocable sessions and
|
||||
the forge-agnostic ``projects`` table.
|
||||
|
||||
``api_tokens`` — per-user bearer tokens (sha256-stored), revocable,
|
||||
powering the public API (/api/v1) and Settings UI.
|
||||
``user_sessions`` — one row per signed session cookie; revocation here
|
||||
instantly kills the corresponding cookie.
|
||||
``projects`` — normalized project list across forges (builtin/gitea/
|
||||
github) + last sync timestamp for the periodic cron.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
if "id" not in _pcols:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'API token',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL DEFAULT '',
|
||||
last_used_at TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
|
||||
)
|
||||
|
||||
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
|
||||
if "id" not in _scols:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE user_sessions (
|
||||
id TEXT PRIMARY KEY, -- session id (cookie payload)
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
|
||||
)
|
||||
|
||||
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
if "id" not in _projcols:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
proj_type TEXT NOT NULL DEFAULT 'builtin', -- builtin | gitea | github
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
forge_id TEXT DEFAULT '',
|
||||
clone_url TEXT DEFAULT '',
|
||||
default_branch TEXT DEFAULT '',
|
||||
language TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
last_synced_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(proj_type, owner, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(proj_type, last_synced_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(7, "v5.4.0/v5.5.0: page versions, cover + icon")
|
||||
def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
"""v5.4.0 (version history + page duplication) & v5.5.0 (cover & icon).
|
||||
|
||||
``page_versions`` — undoable version snapshots for block-editor pages
|
||||
(NOT tied to ``collection_pages`` like the legacy
|
||||
``page_history`` table). One row per save with the
|
||||
full block list + title so the UI can browse/restore.
|
||||
``pages.cover_url`` — image cover shown above the page title.
|
||||
``pages.page_icon`` — emoji / icon label shown next to the title.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
|
||||
)
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "cover_url" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
if "page_icon" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
|
||||
|
||||
|
||||
@register(8, "v5.6.0: custom workspace emojis")
|
||||
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS custom_emojis (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(4, "database templates (icon) + property validation")
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
and the built-in database templates are seeded (idempotently)."""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
|
||||
if "icon" not in _cols:
|
||||
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
if "validation_json" not in _pcols:
|
||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
||||
|
||||
# Seed built-in templates (idempotent: only missing names are inserted).
|
||||
from app.services.db_templates import SEED_TEMPLATES
|
||||
for tpl in SEED_TEMPLATES:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
|
||||
__import__("json").dumps(tpl.get("schema", []))),
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""FlowDeck — Pydantic request models for API validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
|
||||
|
||||
# ── File Save ────────────────────────────────────────────────
|
||||
|
||||
class FileSaveRequest(BaseModel):
|
||||
"""Request model for saving/updating a file via Gitea."""
|
||||
path: str = Field(..., min_length=1, description="File path in the repository")
|
||||
content: str = Field(..., description="File content (UTF-8 encoded)")
|
||||
message: str = Field(default="Update via FlowDeck", description="Commit message")
|
||||
sha: str | None = Field(default=None, description="SHA of the file being updated (required for updates)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_path_extension(self):
|
||||
ext = _ext(self.path)
|
||||
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValueError(f"File extension '{ext}' is not allowed")
|
||||
return self
|
||||
|
||||
|
||||
# ── Upload ───────────────────────────────────────────────────
|
||||
|
||||
class UploadValidationResult(BaseModel):
|
||||
"""Result of validating an upload."""
|
||||
filename: str
|
||||
size: int
|
||||
extension: str
|
||||
valid: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def validate_upload_request(file: UploadFile) -> str | None:
|
||||
"""Validate an uploaded file (size + extension). Returns error message or None."""
|
||||
# Size check — we can't read the full file without a size attribute,
|
||||
# but Starlette's UploadFile has a size property from Content-Length
|
||||
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
|
||||
return f"File '{file.filename}' exceeds maximum size of 10 MB"
|
||||
|
||||
# Extension check
|
||||
if file.filename:
|
||||
ext = _ext(file.filename)
|
||||
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||
return f"File extension '{ext}' is not allowed"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Issue Create / Update ────────────────────────────────────
|
||||
|
||||
class IssueCreateRequest(BaseModel):
|
||||
"""Request model for creating a Gitea issue."""
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
body: str = Field(default="")
|
||||
labels: str = Field(default="", description="Comma-separated label IDs")
|
||||
milestone: str = Field(default="", description="Milestone ID")
|
||||
assignee: str = Field(default="")
|
||||
|
||||
|
||||
class IssueUpdateRequest(BaseModel):
|
||||
"""Request model for updating a Gitea issue (partial update)."""
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
body: str | None = Field(default=None)
|
||||
state: str | None = Field(default=None, pattern=r"^(open|closed)$")
|
||||
labels: str | None = Field(default=None, description="Comma-separated label IDs")
|
||||
milestone: str | None = Field(default=None)
|
||||
assignee: str | None = Field(default=None)
|
||||
|
||||
|
||||
# ── Card Move ────────────────────────────────────────────────
|
||||
|
||||
class CardMoveRequest(BaseModel):
|
||||
"""Request model for moving a card between columns."""
|
||||
owner: str = Field(..., min_length=1)
|
||||
repo: str = Field(..., min_length=1)
|
||||
issue_id: int = Field(..., gt=0)
|
||||
column: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Column Mapping ───────────────────────────────────────────
|
||||
|
||||
class ColMappingRequest(BaseModel):
|
||||
"""Request model for column-to-label mapping."""
|
||||
owner: str = Field(..., min_length=1)
|
||||
repo: str = Field(..., min_length=1)
|
||||
column: str = Field(..., min_length=1)
|
||||
gitea_label: str = Field(..., min_length=1)
|
||||
close_issue: bool = Field(default=False)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""FlowDeck — Standardized response models."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standardized error response.
|
||||
|
||||
Example:
|
||||
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
|
||||
"""
|
||||
error: str
|
||||
detail: str | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {"error": "Not found", "detail": "Board not found"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Standardized success response.
|
||||
|
||||
Example:
|
||||
SuccessResponse(status="ok", data={"issue_id": 42})
|
||||
"""
|
||||
status: str = "ok"
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {"status": "ok", "data": {"issue_id": 42, "column": "Done"}}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Password hashing and login security utilities."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using SHA-256 + random salt (16 bytes).
|
||||
Format: salt_hex:hash_hex (64 + 64 = 128 chars)
|
||||
Fallback for bcrypt — we use SHA-256 for SQLite simplicity
|
||||
but with proper salt per password."""
|
||||
salt = secrets.token_hex(16)
|
||||
h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
return f"{salt}:{h}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
"""Verify a password against its stored hash."""
|
||||
try:
|
||||
salt, h = stored.split(":", 1)
|
||||
expected = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
return h == expected
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def is_locked(locked_until: str | None) -> bool:
|
||||
"""Check if account is temporarily locked."""
|
||||
if not locked_until:
|
||||
return False
|
||||
try:
|
||||
return float(locked_until) > time.time()
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,174 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
|
||||
|
||||
# ── Dependency ──
|
||||
async def admin_required(request: Request):
|
||||
from app.auth.session import get_current_user
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ── Users ──
|
||||
@router.get("/users")
|
||||
async def list_users(_admin=Depends(admin_required)):
|
||||
"""List all users with workspace/file/folder counts and storage usage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.login, u.full_name, u.email, u.is_admin, u.is_active,
|
||||
u.last_login, u.created_at,
|
||||
(SELECT COUNT(*) FROM workspaces WHERE owner_id=u.id) AS ws_count,
|
||||
(SELECT COUNT(*) FROM pages WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_id=u.id)) AS page_count
|
||||
FROM users u
|
||||
ORDER BY u.id
|
||||
""").fetchall()
|
||||
users = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["file_count"] = d["page_count"]
|
||||
d["folder_count"] = 0
|
||||
d["total_mb"] = 0
|
||||
users.append(d)
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
"""Create a new user (admin only)."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = body.get("login", "").strip()
|
||||
name = body.get("name", login)
|
||||
email = body.get("email", login)
|
||||
password = body.get("password", "").strip()
|
||||
is_admin = int(body.get("is_admin", 0))
|
||||
if not login or not password:
|
||||
return JSONResponse({"error": "Login and password required"}, status_code=400)
|
||||
if len(password) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()
|
||||
if existing:
|
||||
return JSONResponse({"error": "User already exists"}, status_code=409)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, name, email, hash_password(password), is_admin),
|
||||
)
|
||||
conn.commit()
|
||||
uid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
return {"status": "ok", "user": {"id": uid, "login": login}}
|
||||
|
||||
|
||||
@router.put("/users/{user_id:int}")
|
||||
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||
if "name" in body:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["name"], user_id))
|
||||
if "email" in body:
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"], user_id))
|
||||
if "password" in body and body["password"].strip():
|
||||
pw = body["password"].strip()
|
||||
if len(pw) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), user_id))
|
||||
if "is_admin" in body:
|
||||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (int(body["is_admin"]), user_id))
|
||||
if "is_active" in body:
|
||||
conn.execute("UPDATE users SET is_active=? WHERE id=?", (int(body["is_active"]), user_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id:int}")
|
||||
async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||
# Cascade delete: first delete child records
|
||||
conn.execute("DELETE FROM login_history WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM comments WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM page_history WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM gitea_private_pages WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM tags WHERE user_id=?", (user_id,))
|
||||
# Delete workspaces owned by this user
|
||||
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||
for ws in ws_rows:
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
async def user_stats(_admin=Depends(admin_required)):
|
||||
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
total_ws = conn.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0]
|
||||
total_files = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
|
||||
total_folders = 0
|
||||
total_bytes = 0
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"total_workspaces": total_ws,
|
||||
"total_files": total_files,
|
||||
"total_folders": total_folders,
|
||||
"total_mb": round(total_bytes / (1024 * 1024), 2),
|
||||
}
|
||||
|
||||
|
||||
# ── Audit ──
|
||||
@router.get("/audit")
|
||||
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
"""Recent login history."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT lh.id, lh.user_id, u.login, u.full_name,
|
||||
lh.ip_address, lh.user_agent, lh.logged_at
|
||||
FROM login_history lh
|
||||
JOIN users u ON u.id = lh.user_id
|
||||
ORDER BY lh.logged_at DESC
|
||||
LIMIT ?
|
||||
""", (min(limit, 500),)).fetchall()
|
||||
return {"entries": [dict(r) for r in rows]}
|
||||
File diff suppressed because it is too large
Load Diff
+60
-9
@@ -4,16 +4,15 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
|
||||
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
@@ -39,7 +38,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
async def health(request: Request):
|
||||
"""Health check: DB + Gitea connectivity."""
|
||||
db_ok = False
|
||||
gitea_ok = False
|
||||
@@ -57,7 +56,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "2.1.0",
|
||||
"version": request.app.version,
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
@@ -150,7 +149,7 @@ async def move_card(
|
||||
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
status_labels = await _get_status_labels(owner, repo, board_id)
|
||||
filtered_names = [l for l in current_labels if l not in status_labels]
|
||||
filtered_names = [name for name in current_labels if name not in status_labels]
|
||||
filtered_names.append(mapping["gitea_label"])
|
||||
|
||||
# Resolve label names to IDs
|
||||
@@ -294,7 +293,7 @@ async def create_issue(
|
||||
if not _check_rate_limit(request):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
|
||||
milestone_id = int(milestone) if milestone.strip().isdigit() else None
|
||||
|
||||
issue = await gitea.create_issue(
|
||||
@@ -346,7 +345,7 @@ async def update_issue_api(
|
||||
if state:
|
||||
kwargs["state"] = state
|
||||
if labels:
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
|
||||
if milestone and milestone.strip().isdigit():
|
||||
kwargs["milestone"] = int(milestone)
|
||||
if assignee:
|
||||
@@ -389,7 +388,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
comments = await gitea.get_issue_comments(owner, repo, issue_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
|
||||
|
||||
# Get checklists from local DB
|
||||
with get_conn() as conn:
|
||||
@@ -623,3 +622,55 @@ async def get_collaborators(owner: str, repo: str):
|
||||
return {"collaborators": collaborators}
|
||||
except Exception as e:
|
||||
return {"collaborators": [], "error": str(e)}
|
||||
|
||||
|
||||
# ── Frontend Error Capture ───────────────────────────────────
|
||||
# Hermes diagnostique le frontend en appelant GET /api/frontend-errors
|
||||
|
||||
_frontend_errors: list[dict] = []
|
||||
_MAX_STORED_ERRORS = 100
|
||||
|
||||
|
||||
@router.post("/frontend-error")
|
||||
async def capture_frontend_error(request: Request):
|
||||
"""Reçoit les erreurs JS du navigateur. Appelé automatiquement par app.js."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {"status": "ignored", "reason": "invalid json"}
|
||||
|
||||
msg = body.get("message", "")
|
||||
err_type = body.get("type", "error")
|
||||
source = body.get("source", "")
|
||||
line = body.get("line", 0)
|
||||
|
||||
# Dédupliquer les erreurs identiques consécutives
|
||||
if _frontend_errors and _frontend_errors[-1].get("message") == msg:
|
||||
_frontend_errors[-1]["count"] = _frontend_errors[-1].get("count", 1) + 1
|
||||
_frontend_errors[-1]["time"] = body.get("time", "")
|
||||
else:
|
||||
body["count"] = 1
|
||||
_frontend_errors.append(body)
|
||||
|
||||
while len(_frontend_errors) > _MAX_STORED_ERRORS:
|
||||
_frontend_errors.pop(0)
|
||||
|
||||
if err_type == "error":
|
||||
logger.warning("Frontend JS error: %s (%s:%s)", msg, source, line)
|
||||
else:
|
||||
logger.warning("Frontend unhandled rejection: %s", msg)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/frontend-errors")
|
||||
async def get_frontend_errors(request: Request, clear: bool = True):
|
||||
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
||||
errors = list(_frontend_errors)
|
||||
if clear:
|
||||
_frontend_errors.clear()
|
||||
return {
|
||||
"errors": errors,
|
||||
"count": len(errors),
|
||||
"cleared": clear,
|
||||
}
|
||||
|
||||
+340
-44
@@ -4,41 +4,276 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.auth.oauth import gitea_oauth
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
|
||||
|
||||
def get_redirect_uri(request: Request) -> str:
|
||||
"""OAuth redirect URI for this request.
|
||||
|
||||
Explicit `OAUTH_REDIRECT_URI` env override wins (must be registered in the
|
||||
provider's OAuth app). Otherwise it is derived from the request so it always
|
||||
matches the URL the user actually used: scheme from `X-Forwarded-Proto`
|
||||
(reverse proxies) falling back to the request scheme, host from
|
||||
`X-Forwarded-Host` falling back to the `Host` header.
|
||||
"""
|
||||
if settings.oauth_redirect_uri:
|
||||
return settings.oauth_redirect_uri
|
||||
proto = request.headers.get("x-forwarded-proto", "")
|
||||
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
|
||||
fwd_host = request.headers.get("x-forwarded-host", "")
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}/auth/callback"
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — Login</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
|
||||
.login-box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
|
||||
.login-box h1{font-size:24px;margin-bottom:8px;}
|
||||
.login-box p{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:24px;}
|
||||
.form-group{margin-bottom:16px;}
|
||||
.form-group label{display:block;font-size:13px;color:rgba(255,255,255,.6);margin-bottom:6px;}
|
||||
.form-group input{width:100%;padding:10px 36px 10px 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;outline:none;}
|
||||
.pw-wrapper{position:relative;}
|
||||
.pw-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:rgba(255,255,255,.4);cursor:pointer;font-size:16px;padding:4px;line-height:1;}
|
||||
.pw-toggle:hover{color:rgba(255,255,255,.8);}
|
||||
.form-group input:focus{border-color:#2383E2;box-shadow:0 0 0 1px #2383E2;}
|
||||
.btn{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;margin-top:8px;}
|
||||
.btn-primary{background:#2383E2;color:#fff;}
|
||||
.btn-primary:hover{background:#2C8CEB;}
|
||||
.btn-secondary{background:#333;color:#fff;margin-top:12px;}
|
||||
.btn-secondary:hover{background:#444;}
|
||||
.tabs{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,.08);}
|
||||
.tab{flex:1;text-align:center;padding:12px;cursor:pointer;font-size:14px;color:rgba(255,255,255,.5);border-bottom:2px solid transparent;background:none;border-top:none;border-left:none;border-right:none;}
|
||||
.tab.active{color:#fff;border-bottom-color:#2383E2;}
|
||||
.error{background:rgba(255,80,80,.15);color:#ff5050;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||
.success{background:rgba(80,255,80,.15);color:#50ff50;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||
.oauth-btn:hover{background:#333;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>FlowDeck</h1>
|
||||
<p>Login or create an account to continue</p>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('login')" id="tab-login">Login</button>
|
||||
<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>
|
||||
</div>
|
||||
<div id="expired-msg" class="success" style="display:none">⚠️ Your session has expired. Please log in again.</div>
|
||||
<div id="error-msg" class="error"></div>
|
||||
<div id="success-msg" class="success"></div>
|
||||
<form id="login-form" onsubmit="handleLogin(event)">
|
||||
<div class="form-group"><label>Email or username</label><input type="text" id="email" required autocomplete="username"></div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<div class="pw-wrapper">
|
||||
<input type="password" id="password" required minlength="6" autocomplete="current-password">
|
||||
<button type="button" class="pw-toggle" onclick="togglePassword()" title="Show password">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||
</form>
|
||||
<div class="oauth-section">
|
||||
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Show session expired banner if ?expired=1 in URL
|
||||
(function(){if(location.search.includes('expired=1')){var el=document.getElementById('expired-msg');if(el)el.style.display='block';}})();
|
||||
let mode='login';
|
||||
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
|
||||
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
|
||||
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_page(request: Request):
|
||||
"""Show the registration page (local login page with register tab active)."""
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
||||
'class="tab active" onclick="switchTab(\'login\')"',
|
||||
'class="tab" onclick="switchTab(\'login\')"'
|
||||
).replace(
|
||||
'class="tab" onclick="switchTab(\'register\')"',
|
||||
'class="tab active" onclick="switchTab(\'register\')"'
|
||||
).replace(
|
||||
'let mode=\'login\';',
|
||||
'let mode=\'register\';'
|
||||
).replace(
|
||||
'id="name-group" style="display:none"',
|
||||
'id="name-group" style="display:block"'
|
||||
).replace(
|
||||
'id="submit-btn">Login<',
|
||||
'id="submit-btn">Register<'
|
||||
), status_code=200)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
"""Redirect to Gitea OAuth2 authorize page."""
|
||||
if not gitea_oauth.enabled:
|
||||
# Fallback: use global token, create a fake session
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
if not user:
|
||||
conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')")
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
return response
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
# OAuth provider not configured — show error instead of auto-creating admin
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><head><title>FlowDeck</title><style>
|
||||
body{{background:#191919;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:420px;}}
|
||||
h1{{font-size:20px;margin-bottom:12px;}}p{{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:16px;}}
|
||||
a{{color:#2383E2;}}
|
||||
</style></head><body><div class="box">
|
||||
<h1>⚠️ {provider.title()} OAuth not configured</h1>
|
||||
<p>The {provider} integration has not been set up by the server administrator.</p>
|
||||
<p><a href="/auth/login?provider=local">↩ Use local login</a></p>
|
||||
</div></body></html>""",
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
request.session["oauth_state"] = state
|
||||
auth_url = gitea_oauth.get_authorize_url(state)
|
||||
request.session["oauth_provider"] = provider
|
||||
# Link mode: connect OAuth to current local account instead of creating new user
|
||||
mode = request.query_params.get("mode", "")
|
||||
# Encode auth mode in state to survive session loss during OAuth redirect
|
||||
signed_state = f"{state}:{mode}" if mode else state
|
||||
request.session["oauth_mode"] = mode
|
||||
# Redirect URI derived from the incoming request (scheme-aware); stored in
|
||||
# session so the callback reuses the EXACT same URI for token exchange
|
||||
redirect_uri = get_redirect_uri(request)
|
||||
request.session["oauth_redirect_uri"] = redirect_uri
|
||||
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=redirect_uri, force_login=(mode == "link"))
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
"""Register a new local account."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
|
||||
if not email or not password:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||
if len(password) < 6:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||
if existing:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Account already exists"}, status_code=409)
|
||||
# First real user (excluding default admin with no password) is admin
|
||||
real_user_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL AND password_hash != ''"
|
||||
).fetchone()[0]
|
||||
is_admin = 1 if real_user_count == 0 else 0
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
(email, name, email, hash_password(password), is_admin),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
user_data = dict(user)
|
||||
# Log login
|
||||
_log_login(user_data["id"], request)
|
||||
session = SessionManager.create_session(user_data, request)
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
"""Login with email + password."""
|
||||
import time
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
if not email or not password:
|
||||
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
ud = dict(user)
|
||||
if not ud.get("is_active"):
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
if is_locked(ud.get("locked_until")):
|
||||
return JSONResponse({"error": "Account temporarily locked. Try again later."}, status_code=423)
|
||||
|
||||
if not verify_password(password, ud.get("password_hash", "")):
|
||||
with get_conn() as conn:
|
||||
attempts = (ud.get("login_attempts", 0) or 0) + 1
|
||||
lock = None
|
||||
if attempts >= 5:
|
||||
lock = str(time.time() + 900) # 15 min lock
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=?, locked_until=? WHERE id=?",
|
||||
(attempts, lock, ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
# Successful login
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
request: Request,
|
||||
@@ -46,13 +281,32 @@ async def callback(
|
||||
state: str = Query(...),
|
||||
):
|
||||
"""Handle OAuth2 callback from Gitea."""
|
||||
# Validate state
|
||||
# Recover mode from state suffix (state:mode format), then validate
|
||||
expected_state = request.session.get("oauth_state", "")
|
||||
if not expected_state or state != expected_state:
|
||||
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||
provider_name = request.session.get("oauth_provider", "gitea")
|
||||
|
||||
# Exchange code for token
|
||||
token_data = await gitea_oauth.exchange_code(code)
|
||||
auth_mode = ""
|
||||
raw_state = state
|
||||
if ":" in state:
|
||||
raw_state, auth_mode = state.rsplit(":", 1)
|
||||
if auth_mode:
|
||||
request.session["oauth_mode"] = auth_mode
|
||||
|
||||
# Validate: state token must match session, unless session lost and mode present
|
||||
if expected_state and raw_state != expected_state:
|
||||
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||
if not expected_state and not auth_mode:
|
||||
return HTMLResponse("<h1>Session expired — please try connecting again</h1>", status_code=400)
|
||||
|
||||
from app.auth.providers import get_provider
|
||||
oauth_provider = get_provider(provider_name)
|
||||
if not oauth_provider:
|
||||
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
||||
|
||||
# Exchange code for token — reuse the redirect URI from the authorize step
|
||||
# (stored in session), falling back to deriving it from this request
|
||||
redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request)
|
||||
token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri)
|
||||
if not token_data:
|
||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||
|
||||
@@ -61,41 +315,67 @@ async def callback(
|
||||
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
||||
|
||||
# Get user info
|
||||
user = await gitea_oauth.get_user(access_token)
|
||||
if not user:
|
||||
oauth_user = await oauth_provider.get_user(access_token)
|
||||
if not oauth_user:
|
||||
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
||||
|
||||
# Link mode: connect OAuth to current session user (for Settings → Integrations)
|
||||
oauth_mode = request.session.pop("oauth_mode", "")
|
||||
if oauth_mode == "link":
|
||||
from app.auth.session import get_current_user as gcu
|
||||
current = await gcu(request)
|
||||
if not current:
|
||||
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO user_oauth_tokens
|
||||
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||
(current["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
||||
)
|
||||
conn.commit()
|
||||
return RedirectResponse(url="/settings#integrations", status_code=302)
|
||||
|
||||
# Store user in DB
|
||||
from app.db import get_conn
|
||||
login_id = f"{provider_name}_{oauth_user['login']}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO users (login, full_name, email, avatar_url)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"""INSERT INTO users (login, full_name, email, avatar_url, auth_method)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login)
|
||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
|
||||
(user["login"], user.get("full_name", ""), user.get("email", ""), user.get("avatar_url", "")),
|
||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url, auth_method=excluded.auth_method""",
|
||||
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", ""), provider_name),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Store token
|
||||
SessionManager.store_token(user["id"], access_token)
|
||||
|
||||
# Also store user in local DB
|
||||
with get_conn() as conn:
|
||||
db_user = conn.execute("SELECT * FROM users WHERE login=?", (user["login"],)).fetchone()
|
||||
user_data = dict(db_user) if db_user else user
|
||||
# Store OAuth token
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login_id,)).fetchone()
|
||||
if uid:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO user_oauth_tokens
|
||||
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||
(uid["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE id=?", (uid["id"],)).fetchone()
|
||||
else:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_id,)).fetchone()
|
||||
user_data = dict(user) if user else oauth_user
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
session = SessionManager.create_session(user_data, request)
|
||||
_log_login(user_data["id"], request)
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to dashboard."""
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
"""Clear session and redirect to login page."""
|
||||
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
@@ -108,3 +388,19 @@ async def current_user(request: Request):
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
try:
|
||||
from app.db import get_conn
|
||||
ip = request.client.host if request.client else ''
|
||||
ua = request.headers.get('user-agent', '')[:500]
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO login_history (user_id, ip_address, user_agent) VALUES (?, ?, ?)",
|
||||
(user_id, ip, ua),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import get_page_context, run_automation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["automations"])
|
||||
|
||||
TRIGGER_TYPES = ("event", "cron", "button")
|
||||
|
||||
|
||||
def _json_or_dumps(val, default="[]"):
|
||||
"""Store JSON string columns without double-encoding."""
|
||||
if val is None:
|
||||
return default
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
json.loads(val)
|
||||
return val
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return json.dumps(val)
|
||||
return json.dumps(val)
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user if user and user.get("id") else {}
|
||||
|
||||
|
||||
def _validate_payload(body: dict) -> None:
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
trigger_type = body.get("trigger_type", "event")
|
||||
if trigger_type not in TRIGGER_TYPES:
|
||||
raise HTTPException(status_code=400, detail="invalid trigger_type")
|
||||
if trigger_type == "event" and not body.get("event"):
|
||||
raise HTTPException(status_code=400, detail="event required for event trigger")
|
||||
if trigger_type == "cron" and not (body.get("cron_expression") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="cron_expression required for cron trigger")
|
||||
for key in ("condition_json", "actions_json"):
|
||||
val = body.get(key, "[]")
|
||||
try:
|
||||
if isinstance(val, str):
|
||||
json.loads(val)
|
||||
else:
|
||||
json.dumps(val)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
|
||||
|
||||
|
||||
@router.get("/workspace/automations")
|
||||
async def list_automations(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
return {"automations": items}
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user.get("id") or 1
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
(workspace, name, trigger_type, event, cron_expression, collection_id,
|
||||
condition_json, actions_json, enabled, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
body.get("workspace", "") or "",
|
||||
(body.get("name") or "").strip(),
|
||||
body.get("trigger_type", "event"),
|
||||
body.get("event", "page.created"),
|
||||
body.get("cron_expression", "") or "",
|
||||
body.get("collection_id") or None,
|
||||
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
|
||||
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
|
||||
int(body.get("enabled", True)),
|
||||
by,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
return {"id": new_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}")
|
||||
async def get_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}")
|
||||
async def update_automation(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
conn.execute(
|
||||
"""UPDATE automations SET
|
||||
name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?,
|
||||
condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(
|
||||
(body.get("name") or "").strip(),
|
||||
body.get("trigger_type", "event"),
|
||||
body.get("event", "page.created"),
|
||||
body.get("cron_expression", "") or "",
|
||||
body.get("collection_id") or None,
|
||||
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
|
||||
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
|
||||
int(body.get("enabled", True)),
|
||||
auto_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": auto_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/{auto_id}")
|
||||
async def delete_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "status": "deleted"}
|
||||
|
||||
|
||||
async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict:
|
||||
page_id = body.get("page_id") if isinstance(body, dict) else None
|
||||
collection_id = body.get("collection_id") if isinstance(body, dict) else None
|
||||
context = {"collection_id": collection_id, "page_id": page_id}
|
||||
if page_id:
|
||||
context.update(get_page_context(int(page_id), collection_id or 0))
|
||||
result = await run_automation(automation_id, trigger_source, context)
|
||||
result["automation_id"] = automation_id
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/run")
|
||||
async def run_automation_endpoint(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
return await _execute(auto_id, "manual", body)
|
||||
|
||||
|
||||
@router.post("/api/automations/{auto_id}/run")
|
||||
async def run_automation_button(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
return await _execute(auto_id, "button", body)
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/runs")
|
||||
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automation_runs WHERE automation_id=?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?""",
|
||||
(auto_id, limit),
|
||||
).fetchall()
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
+981
-76
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
"""FlowDeck — Collaboration API (v4.9.0): inline comments on pages + mentions.
|
||||
|
||||
Comments live in the existing `comments` table, extended with a target_type /
|
||||
target_id pair and inline anchors (anchor_block_id + text offsets). Mentions
|
||||
written in a comment body automatically notify the mentioned users.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import notifications as notif
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _page_url(page_id: int) -> str:
|
||||
from app.config import settings
|
||||
return f"{settings.app_base_url}/pages/{page_id}"
|
||||
|
||||
|
||||
def _serialize(rows):
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["author"] = {
|
||||
"id": r["author_id"],
|
||||
"login": r["author_login"],
|
||||
"full_name": r["author_name"],
|
||||
"avatar_url": r["author_avatar"],
|
||||
"avatar_color": r["author_color"],
|
||||
}
|
||||
for k in ("author_id", "author_login", "author_name", "author_avatar", "author_color"):
|
||||
d.pop(k, None)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
"""List page-level and inline comments for a FlowDeck page."""
|
||||
_current_user(request)
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute(
|
||||
"""SELECT c.*, c.user_id AS author_id, u.login AS author_login,
|
||||
u.full_name AS author_name, u.avatar_url AS author_avatar,
|
||||
u.avatar_color AS author_color
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.target_type='page' AND c.target_id=?
|
||||
ORDER BY c.created_at ASC, c.id ASC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"page_id": page_id, "comments": _serialize(rows)}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
"""Create a page or inline comment. Mentions (@login) notify users."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = (body.get("body") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body required")
|
||||
|
||||
anchor_block = body.get("anchor_block_id")
|
||||
anchor_start = body.get("anchor_start")
|
||||
anchor_end = body.get("anchor_end")
|
||||
# normalize empty anchor → page-level comment
|
||||
if not anchor_block or anchor_start is None or anchor_end is None:
|
||||
anchor_block, anchor_start, anchor_end = None, None, None
|
||||
elif int(anchor_start) == int(anchor_end):
|
||||
anchor_block, anchor_start, anchor_end = None, None, None
|
||||
|
||||
parent_id = body.get("parent_id")
|
||||
uid = user["id"]
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO comments
|
||||
(page_id, user_id, body, parent_id, target_type, target_id,
|
||||
anchor_block_id, anchor_start, anchor_end)
|
||||
VALUES (?,?,?,?, 'page', ?, ?, ?, ?)""",
|
||||
(page_id, uid, text, parent_id, page_id, anchor_block, anchor_start, anchor_end),
|
||||
)
|
||||
comment_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
# Notify users @-mentioned in the comment (skip the author).
|
||||
url = _page_url(page_id)
|
||||
title = f"New comment on “{page['title']}”"
|
||||
message = f"{user.get('full_name') or user.get('login')} commented: {text[:300]}"
|
||||
notif.process_mentions(
|
||||
text, uid, "mention", title, message,
|
||||
"page", page_id, url, conn=conn,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": comment_id, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def notify_page_mentions(request: Request, page_id: int):
|
||||
"""Notify users @-mentioned in a page's content (called on save).
|
||||
|
||||
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
|
||||
against a per-page cache so repeated auto-saves don't spam notifications.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = body.get("text") or ""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
url = _page_url(page_id)
|
||||
mentioned = notif.process_mentions(
|
||||
text, user["id"], "mention", f"You were mentioned in “{page['title']}”",
|
||||
f"{user.get('full_name') or user.get('login')} mentioned you on a page.",
|
||||
"page", page_id, url, conn=conn,
|
||||
)
|
||||
conn.commit()
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
"""Update a comment body or resolve/unresolve it."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM comments WHERE id=?", (comment_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Not allowed to edit this comment")
|
||||
if "body" in body and body.get("body") is not None:
|
||||
conn.execute(
|
||||
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body["body"].strip(), comment_id),
|
||||
)
|
||||
if "resolved" in body and body.get("resolved") is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?",
|
||||
(1 if body["resolved"] else 0, comment_id))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: int):
|
||||
"""Delete a comment and its replies."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"]:
|
||||
# allow page "owners" — fall back to a simple ownership rule for now
|
||||
raise HTTPException(403, "Not allowed to delete this comment")
|
||||
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
+979
-20
File diff suppressed because it is too large
Load Diff
+2416
-22
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
"""FlowDeck — custom workspace emojis (v5.6.0).
|
||||
|
||||
Uploaded emoji images stored per-workspace and usable as page icons. Kept on a
|
||||
prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's
|
||||
``/{owner}/{repo}`` HTML catch-all would otherwise shadow them).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
|
||||
_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
"""Workspace id from the active-workspace cookie, fallback 1."""
|
||||
try:
|
||||
ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/api/custom-emojis")
|
||||
async def list_custom_emojis(request: Request):
|
||||
"""List the current workspace's custom emojis."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return {"status": "ok", "emojis": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/custom-emojis")
|
||||
async def create_custom_emoji(request: Request):
|
||||
"""Upload a custom emoji image (multipart: ``name`` + ``file``)."""
|
||||
form = await request.form()
|
||||
name = (form.get("name") or "").strip()[:40] or "emoji"
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80]
|
||||
ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png"
|
||||
if ext not in _IMAGE_EXTS:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
ws_id = _active_ws(request)
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
url = f"/api/files/{ws_id}/{final}"
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)",
|
||||
(ws_id, name, url),
|
||||
)
|
||||
conn.commit()
|
||||
emoji_id = cur.lastrowid
|
||||
return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}}
|
||||
|
||||
|
||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
||||
async def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
"""Delete a custom emoji (and its stored file)."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?",
|
||||
(emoji_id, ws_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "emoji not found")
|
||||
conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
fname = (row["url"] or "").rsplit("/", 1)[-1]
|
||||
if fname:
|
||||
(_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return {"status": "ok", "id": emoji_id}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""FlowDeck — Export endpoints (v4.7.0).
|
||||
|
||||
Routes /api/export/* — generate Markdown, HTML, PDF and static-site (zip)
|
||||
exports server-side for a given page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.export import (
|
||||
build_static_site_bytes,
|
||||
page_to_markdown,
|
||||
page_to_pdf_bytes,
|
||||
page_to_standalone_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["export"], prefix="/api/export")
|
||||
|
||||
|
||||
def _load_page_or_404(page_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _download_header(filename: str, media_type: str) -> dict:
|
||||
ascii_name = re.sub(r"[^\x00-\x7F]", "_", filename)
|
||||
quoted = filename.replace('"', '')
|
||||
return {
|
||||
"Content-Disposition": f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quoted}',
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": media_type,
|
||||
}
|
||||
|
||||
|
||||
def _safe_filename(page: dict, ext: str) -> str:
|
||||
title = (page.get("title") or "Untitled").strip() or "Untitled"
|
||||
title = re.sub(r'[\\/:*?"<>|]+', "_", title)
|
||||
return f"{title}.{ext}"
|
||||
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
md = page_to_markdown(page)
|
||||
filename = _safe_filename(page, "md")
|
||||
headers = _download_header(filename, "text/markdown")
|
||||
return Response(content=md.encode("utf-8"), status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
html = page_to_standalone_html(page)
|
||||
filename = _safe_filename(page, "html")
|
||||
headers = _download_header(filename, "text/html")
|
||||
return Response(content=html.encode("utf-8"), status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("PDF export failed for page %s: %s", page_id, exc)
|
||||
raise HTTPException(status_code=500, detail="PDF generation failed") from exc
|
||||
filename = _safe_filename(page, "pdf")
|
||||
headers = _download_header(filename, "application/pdf")
|
||||
return Response(content=pdf_bytes, status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
site_bytes = build_static_site_bytes(page)
|
||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||
filename = f"{title}_site.zip"
|
||||
headers = _download_header(filename, "application/zip")
|
||||
return Response(content=site_bytes, status_code=200, headers=headers)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
|
||||
|
||||
def _require_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401.
|
||||
|
||||
Only returns a client if the user has personally connected their Gitea
|
||||
account (OAuth token). No fallback to admin token — each user must link
|
||||
their own Gitea account to see Gitea projects.
|
||||
"""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
if not client:
|
||||
raise HTTPException(status_code=401, detail="Gitea account not linked. Go to Settings → Integrations to connect.")
|
||||
return client
|
||||
|
||||
|
||||
def _require_user_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
if not client:
|
||||
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
|
||||
return client
|
||||
|
||||
|
||||
# ── Orgs ──
|
||||
@router.get("/orgs")
|
||||
async def list_orgs(request: Request):
|
||||
"""List organizations the user belongs to."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
orgs = await gitea.get_user_orgs()
|
||||
return {"orgs": orgs}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Projects (repos) ──
|
||||
@router.get("/projects")
|
||||
async def list_projects(request: Request, org: str = ""):
|
||||
"""List Gitea repos: user repos (org='') or org repos."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
if org:
|
||||
repos = await gitea.get_org_repos(org)
|
||||
else:
|
||||
repos = await gitea.get_user_repos(limit=100)
|
||||
return {"projects": repos}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Repo tree ──
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def repo_tree(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""Get contents of a repo directory (one level)."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
items = await gitea.get_repo_contents(owner, repo, path)
|
||||
tree = []
|
||||
for item in items:
|
||||
tree.append({
|
||||
"name": item.get("name"),
|
||||
"path": item.get("path"),
|
||||
"type": "folder" if item.get("type") == "dir" else "file",
|
||||
"size": item.get("size", 0),
|
||||
"sha": item.get("sha"),
|
||||
})
|
||||
return {"tree": tree}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── File content ──
|
||||
@router.get("/projects/{owner}/{repo}/file")
|
||||
async def get_file(request: Request, owner: str, repo: str, path: str):
|
||||
"""Get file content (decoded)."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
content = await gitea.get_file_content(owner, repo, path)
|
||||
return {"content": content, "path": path}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Save file (create or update) ──
|
||||
@router.put("/projects/{owner}/{repo}/file")
|
||||
async def save_file(request: Request, owner: str, repo: str):
|
||||
"""Create or update a file in the repo."""
|
||||
gitea = _require_gitea(request) # admin token can write too
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
path = body.get("path", "").strip("/")
|
||||
content = body.get("content", "")
|
||||
message = body.get("message", "Update via FlowDeck")
|
||||
sha = body.get("sha")
|
||||
if not path:
|
||||
return JSONResponse({"error": "Path required"}, status_code=400)
|
||||
try:
|
||||
result = await gitea.create_or_update_file(owner, repo, path, content, message, sha)
|
||||
return {"status": "ok", "commit": result.get("commit", {})}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Upload file (binary) ──
|
||||
@router.post("/projects/{owner}/{repo}/upload")
|
||||
async def upload_file(request: Request, owner: str, repo: str):
|
||||
"""Upload a binary file to the repo."""
|
||||
import base64
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
file = form.get("file")
|
||||
folder = form.get("folder", "")
|
||||
message = form.get("message", "Upload via FlowDeck")
|
||||
if not file:
|
||||
return JSONResponse({"error": "No file provided"}, status_code=400)
|
||||
try:
|
||||
content = await file.read()
|
||||
encoded = base64.b64encode(content).decode("utf-8")
|
||||
path = f"{folder.strip('/')}/{file.filename}".strip("/") if folder.strip("/") else file.filename
|
||||
result = await gitea.create_or_update_file(owner, repo, path, encoded, message, sha=None)
|
||||
return {"status": "ok", "path": path, "commit": result.get("commit", {})}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Delete file ──
|
||||
@router.delete("/projects/{owner}/{repo}/file")
|
||||
async def delete_file(request: Request, owner: str, repo: str):
|
||||
"""Delete a file from the repo."""
|
||||
gitea = _require_gitea(request) # admin token can write too
|
||||
path = request.query_params.get("path", "")
|
||||
sha = request.query_params.get("sha", "")
|
||||
message = request.query_params.get("message", "Delete via FlowDeck")
|
||||
if not path or not sha:
|
||||
return JSONResponse({"error": "Path and SHA required"}, status_code=400)
|
||||
try:
|
||||
await gitea.delete_file(owner, repo, path, sha, message)
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Labels ──
|
||||
@router.get("/projects/{owner}/{repo}/labels")
|
||||
async def get_labels(request: Request, owner: str, repo: str):
|
||||
"""Get labels for a repo (mapped to tags)."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
return {"labels": [
|
||||
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
|
||||
for lbl in labels
|
||||
]}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Account linking ──
|
||||
|
||||
@router.get("/status")
|
||||
async def gitea_status(request: Request):
|
||||
"""Check if the current user has Gitea linked."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
return {"linked": client is not None}
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_gitea(request: Request):
|
||||
"""Remove all Gitea OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages")
|
||||
async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
"""List private pages for this Gitea project."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"pages": []})
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM gitea_private_pages WHERE user_id=? AND gitea_owner=? AND gitea_repo=? ORDER BY updated_at DESC",
|
||||
(user["id"], owner, repo),
|
||||
).fetchall()
|
||||
return {"pages": [{"id": r["id"], "title": r["title"], "updated_at": r["updated_at"]} for r in rows]}
|
||||
|
||||
|
||||
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||
async def create_private_page(owner: str, repo: str, request: Request):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?,?,?,?)",
|
||||
(user["id"], owner, repo, title),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "page": {"id": cursor.lastrowid, "title": title}}
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Get a single private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(page_id, user["id"], owner, repo),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return {"page": {"id": row["id"], "title": row["title"], "content": row["content"], "updated_at": row["updated_at"]}}
|
||||
|
||||
|
||||
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Update a private page."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "").strip()
|
||||
content = body.get("content", "")
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute(
|
||||
"UPDATE gitea_private_pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(title, page_id, user["id"], owner, repo),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE gitea_private_pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(content, page_id, user["id"], owner, repo),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Delete a private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(page_id, user["id"], owner, repo),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Commit history for a file ──
|
||||
@router.get("/projects/{owner}/{repo}/commits")
|
||||
async def file_commits(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""Get recent commits for a specific file."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
commits = await gitea.get_file_commits(owner, repo, path)
|
||||
return {"commits": commits}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Sync labels to tags ──
|
||||
@router.post("/projects/{owner}/{repo}/sync-labels")
|
||||
async def sync_labels(request: Request, owner: str, repo: str):
|
||||
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.db import get_conn
|
||||
user = await gcu(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
except Exception:
|
||||
labels = []
|
||||
imported = 0
|
||||
with get_conn() as conn:
|
||||
for label in labels:
|
||||
name = label.get("name", "")
|
||||
color = label.get("color", "#787774")
|
||||
if not name:
|
||||
continue
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)",
|
||||
(name, f"#{color}", user["id"]),
|
||||
)
|
||||
imported += 1
|
||||
conn.commit()
|
||||
return {"status": "ok", "imported": imported, "total": len(labels)}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""GitHub OAuth — status and disconnect routes."""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"linked": False}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' AND access_token IS NOT NULL AND access_token != ''",
|
||||
(user["id"],)
|
||||
).fetchone()
|
||||
return {"linked": row is not None}
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_github(request: Request):
|
||||
"""Remove all GitHub OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,557 @@
|
||||
"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["library"], prefix="/api/library")
|
||||
|
||||
SOURCE_TYPES = {"all", "local", "gitea", "github"}
|
||||
|
||||
|
||||
def _get_user_id(request: Request) -> int:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
def _detect_source_type(workspace: str) -> str:
|
||||
ws = (workspace or "").strip()
|
||||
if not ws:
|
||||
return "local"
|
||||
if ws.startswith("github:"):
|
||||
return "github"
|
||||
if "/" in ws:
|
||||
return "gitea"
|
||||
return "local"
|
||||
|
||||
|
||||
def _source_label(source_type: str, workspace: str) -> str:
|
||||
if source_type == "gitea":
|
||||
return workspace
|
||||
elif source_type == "github":
|
||||
return workspace.replace("github:", "", 1)
|
||||
return workspace or "Private"
|
||||
|
||||
|
||||
def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
workspace = db_row.get("workspace", "") or ""
|
||||
source_type = _detect_source_type(workspace)
|
||||
page_id = db_row["id"]
|
||||
title = db_row.get("title") or "Untitled"
|
||||
|
||||
# URL
|
||||
if source_type in ("gitea", "github"):
|
||||
ws = workspace.replace("github:", "", 1) if source_type == "github" else workspace
|
||||
parts = ws.split("/", 1)
|
||||
url = f"/gitea-workspace?owner={parts[0]}&repo={parts[1] if len(parts) > 1 else ''}"
|
||||
else:
|
||||
url = f"/pages/{page_id}"
|
||||
|
||||
# Icon
|
||||
content_format = db_row.get("content_format", "blocks")
|
||||
if db_row.get("is_folder"):
|
||||
icon = "📁"
|
||||
elif content_format == "file":
|
||||
icon = "📄"
|
||||
fn = title.lower()
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
else:
|
||||
icon = "📝"
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"page_icon": db_row.get("page_icon") or "",
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
"workspace": workspace,
|
||||
"workspace_name": _source_label(source_type, workspace),
|
||||
"author": db_row.get("author") or "",
|
||||
"author_initial": (db_row.get("author") or "?")[0].upper(),
|
||||
"updated_at": db_row.get("updated_at", ""),
|
||||
"visited_at": db_row.get("visited_at") or "",
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
"url": url,
|
||||
"content_format": content_format,
|
||||
"favorited": bool(db_row.get("favorited", 0)),
|
||||
"share_mode": db_row.get("share_mode", "private"),
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
|
||||
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
|
||||
if source_type == "local":
|
||||
query += " AND (p.workspace = '' OR (p.workspace NOT LIKE '%/%' AND p.workspace NOT LIKE 'github:%'))"
|
||||
elif source_type == "gitea":
|
||||
query += " AND p.workspace LIKE '%/%' AND p.workspace NOT LIKE 'github:%'"
|
||||
elif source_type == "github":
|
||||
query += " AND p.workspace LIKE 'github:%'"
|
||||
return query, params
|
||||
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
items = [_build_item(dict(r), uid) for r in rows]
|
||||
return _attach_tags(items)
|
||||
|
||||
|
||||
def _attach_tags(items: list) -> list:
|
||||
"""Batch-load page tags for library items."""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
tag_map: dict = {}
|
||||
for r in rows:
|
||||
tag_map.setdefault(r["page_id"], []).append({
|
||||
"id": r["id"], "name": r["name"], "color": r["color"],
|
||||
})
|
||||
for it in items:
|
||||
it["tags"] = tag_map.get(it["id"], [])
|
||||
return items
|
||||
|
||||
|
||||
def _enrich_children(items: list) -> list:
|
||||
"""Add has_children info to items in a single batch query."""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join(["?" for _ in ids])
|
||||
with get_conn() as conn:
|
||||
child_rows = conn.execute(
|
||||
f"SELECT parent_id, COUNT(*) as cnt FROM pages "
|
||||
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
||||
f"GROUP BY parent_id",
|
||||
ids,
|
||||
).fetchall()
|
||||
child_counts = {r["parent_id"]: r["cnt"] for r in child_rows}
|
||||
for it in items:
|
||||
it["has_children"] = bool(child_counts.get(it["id"], 0))
|
||||
return items
|
||||
|
||||
|
||||
# ═══════════ Tab endpoints ═══════════
|
||||
|
||||
BASE_SELECT = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
async def library_recents(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
# Hierarchical view: show only root-level pages at the top
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
|
||||
# Filter by active workspace
|
||||
if workspace_id:
|
||||
query += " AND p.workspace_id = ?"
|
||||
params.append(workspace_id)
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
async def library_favorites(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
|
||||
"FROM favorites f JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
)
|
||||
params: list = [uid]
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY f.position"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/shared")
|
||||
async def library_shared(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
dir: str = Query(default="all"),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
if dir not in ("made", "received", "all"):
|
||||
dir = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Page ids the user shares toward others (nominal page_shares) or receives
|
||||
with get_conn() as conn:
|
||||
made_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
made_ids = {r[0] for r in made_rows}
|
||||
recv_ids = {r[0] for r in recv_rows}
|
||||
|
||||
conds: list[str] = []
|
||||
params: list = []
|
||||
if dir in ("all", "made"):
|
||||
conds.append("p.share_mode != 'private'")
|
||||
if dir in ("all", "made") and made_ids:
|
||||
conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})")
|
||||
params.extend(made_ids)
|
||||
if dir in ("all", "received") and recv_ids:
|
||||
conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})")
|
||||
params.extend(recv_ids)
|
||||
if dir == "received" and not recv_ids:
|
||||
return {"items": []}
|
||||
|
||||
query = (
|
||||
f"{BASE_SELECT} FROM pages p "
|
||||
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
)
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
for it in items:
|
||||
sid = it["id"]
|
||||
is_made = sid in made_ids or it.get("share_mode", "private") != "private"
|
||||
is_recv = sid in recv_ids
|
||||
it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else ""))
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/published")
|
||||
async def library_published(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/private")
|
||||
async def library_private(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace-children/{item_id:int}")
|
||||
async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"""Return children of a local workspace item for tree expansion."""
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
|
||||
[item_id],
|
||||
).fetchone()
|
||||
if not item:
|
||||
return {"items": []}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE parent_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[item_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
with get_conn() as conn:
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": "",
|
||||
"workspace": "",
|
||||
"workspace_name": "",
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/children/{page_id:int}")
|
||||
async def library_children(page_id: int, request: Request):
|
||||
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"{BASE_SELECT}, (SELECT COUNT(*) FROM pages c WHERE c.parent_id = p.id AND c.deleted_at IS NULL) as child_count "
|
||||
f"FROM pages p WHERE p.parent_id = ? AND p.deleted_at IS NULL "
|
||||
f"ORDER BY p.title COLLATE NOCASE",
|
||||
[page_id],
|
||||
).fetchall()
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/repository")
|
||||
async def library_repository(
|
||||
request: Request,
|
||||
gitea_owner: str = Query(default=""),
|
||||
gitea_repo: str = Query(default=""),
|
||||
):
|
||||
"""Return Gitea repository pages/files for the Library Repository tab."""
|
||||
if not gitea_owner or not gitea_repo:
|
||||
return {"items": []}
|
||||
|
||||
uid = _get_user_id(request)
|
||||
ws_key = f"{gitea_owner}/{gitea_repo}"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.workspace = ? AND p.deleted_at IS NULL"
|
||||
params = [ws_key]
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 100"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace")
|
||||
async def library_local_workspace(
|
||||
request: Request,
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
"""Return local workspace items (files/folders) formatted for Library display."""
|
||||
from app.routers.dashboard import _get_active_workspace
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Get the active workspace
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
if not ws:
|
||||
return {"items": []}
|
||||
|
||||
ws_id = workspace_id or ws["id"]
|
||||
|
||||
# Query local workspace tree
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE workspace_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[ws_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
# Check for children
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": ws.get("name", "Workspace"),
|
||||
"workspace": ws.get("name", ""),
|
||||
"workspace_name": ws.get("name", ""),
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
def _format_size(size_bytes):
|
||||
if not size_bytes:
|
||||
return ""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1048576:
|
||||
return f"{size_bytes/1024:.1f} KB"
|
||||
if size_bytes < 1073741824:
|
||||
return f"{size_bytes/1048576:.1f} MB"
|
||||
return f"{size_bytes/1073741824:.1f} GB"
|
||||
|
||||
|
||||
@router.get("/workspace")
|
||||
async def library_workspace(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
|
||||
if workspace_id:
|
||||
query += " AND p.workspace_id = ?"
|
||||
params.append(workspace_id)
|
||||
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 50"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
+63
-94
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
@@ -38,121 +39,89 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
# Check if assigned to current user
|
||||
assigned = False
|
||||
for v in props.values():
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, dict) and item.get("login") == user_login:
|
||||
assigned = True
|
||||
break
|
||||
elif isinstance(v, str) and user_login.lower() in v.lower():
|
||||
assigned = True
|
||||
if assigned:
|
||||
break
|
||||
|
||||
if not assigned and user_login == "admin":
|
||||
assigned = True # admin sees all
|
||||
|
||||
if not assigned:
|
||||
props = {}
|
||||
try:
|
||||
props = json.loads(p["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
due_date = props.get("due_date", "")
|
||||
status = props.get("status", "—")
|
||||
assignee = props.get("assignee", "")
|
||||
if view != "all" and assignee and assignee != user_login:
|
||||
continue
|
||||
|
||||
# Extract status and due date
|
||||
status = None
|
||||
due_date = None
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
|
||||
status = v
|
||||
elif isinstance(v, str) and v.startswith("20"):
|
||||
due_date = v[:10]
|
||||
|
||||
# Filter by view
|
||||
if view == "today":
|
||||
from datetime import date
|
||||
if not due_date or due_date != date.today().isoformat():
|
||||
continue
|
||||
elif view == "overdue":
|
||||
from datetime import date
|
||||
if not due_date or due_date >= date.today().isoformat():
|
||||
continue
|
||||
elif view == "upcoming":
|
||||
from datetime import date, timedelta
|
||||
if not due_date:
|
||||
continue
|
||||
today = date.today()
|
||||
limit = today + timedelta(days=days)
|
||||
d = date.fromisoformat(due_date)
|
||||
if d < today or d > limit:
|
||||
continue
|
||||
|
||||
collection_tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
|
||||
"status": status, "due_date": due_date, "props": props,
|
||||
"id": p["id"],
|
||||
"title": p["title"] or "Untitled",
|
||||
"icon": p["icon"] or "📋",
|
||||
"status": status,
|
||||
"due_date": due_date,
|
||||
})
|
||||
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Render
|
||||
# Build content HTML
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
|
||||
items += f"""<div class='mt-item'>
|
||||
<span class='mt-icon'>{t['icon']}</span>
|
||||
<span class='mt-title'>{t['title']}</span>
|
||||
{status_badge}{due_badge}
|
||||
</div>"""
|
||||
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
||||
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
|
||||
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
|
||||
|
||||
sections += f"""<div class='mt-section'>
|
||||
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
|
||||
{items}
|
||||
</div>"""
|
||||
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 16px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
.mt-section{{margin-bottom:24px}}
|
||||
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
|
||||
.mt-count{{color:#6B6B6B;font-weight:400}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
|
||||
.mt-item:hover{{border-color:#444}}
|
||||
.mt-icon{{font-size:16px}}
|
||||
.mt-title{{flex:1;font-size:14px}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
|
||||
.mt-due{{font-size:11px;color:#A0A0A0}}
|
||||
.mt-todo{{background:#333;color:#A0A0A0}}
|
||||
.mt-in-progress{{background:#1a2744;color:#3366CC}}
|
||||
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
|
||||
.p_desc{{color:#A0A0A0;margin-top:8px}}
|
||||
</style></head><body>
|
||||
<h1>📋 My Tasks</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
|
||||
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
||||
</div>
|
||||
<style>
|
||||
.tab.active{{background:var(--accent);color:#fff!important;}}
|
||||
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
||||
.mt-section{{margin-bottom:24px;}}
|
||||
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
||||
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
||||
.mt-item:hover{{border-color:var(--accent);}}
|
||||
.mt-icon{{font-size:16px;}}
|
||||
.mt-title{{flex:1;font-size:14px;}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
|
||||
.mt-due{{font-size:11px;color:var(--text-dim);}}
|
||||
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
||||
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
||||
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
||||
</style>
|
||||
{sections}
|
||||
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
|
||||
</body></html>""")
|
||||
</div>"""
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
page_title="My Tasks",
|
||||
title_prefix="My Tasks",
|
||||
page_icon="📋",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
|
||||
@@ -22,6 +22,7 @@ async def get_notes(request: Request, owner: str, repo: str):
|
||||
content = row["content"] if row else ""
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -45,6 +46,7 @@ async def save_notes(request: Request, owner: str, repo: str):
|
||||
conn.commit()
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""FlowDeck — Notifications API (v4.9.0 collaboration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(request: Request, limit: int = 50):
|
||||
"""List the current user's notifications, newest first."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT n.*, a.login AS actor_login, a.full_name AS actor_name,
|
||||
a.avatar_url AS actor_avatar, a.avatar_color AS actor_color
|
||||
FROM notifications n
|
||||
LEFT JOIN users a ON n.actor_id = a.id
|
||||
WHERE n.user_id=?
|
||||
ORDER BY n.created_at DESC, n.id DESC LIMIT ?""",
|
||||
(user["id"], limit),
|
||||
).fetchall()
|
||||
unread = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
|
||||
(user["id"],),
|
||||
).fetchone()["c"]
|
||||
return {
|
||||
"notifications": [dict(r) for r in rows],
|
||||
"unread": unread,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(request: Request):
|
||||
"""Unread count for the topbar badge."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
c = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
|
||||
(user["id"],),
|
||||
).fetchone()["c"]
|
||||
return {"unread": c}
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
"""Mark one notification as read (id) or all (id omitted)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
nid = body.get("id")
|
||||
with get_conn() as conn:
|
||||
if nid:
|
||||
conn.execute(
|
||||
"UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?",
|
||||
(nid, user["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE notifications SET is_read=1 WHERE user_id=?",
|
||||
(user["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
async def mark_all_read(request: Request):
|
||||
"""Mark all notifications as read."""
|
||||
return await mark_read(request)
|
||||
|
||||
|
||||
@router.get("/prefs")
|
||||
async def get_prefs(request: Request):
|
||||
"""Return the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
return {"prefs": notif.get_user_prefs(user["id"])}
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
"""Update the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prefs = notif.get_user_prefs(user["id"])
|
||||
for key in ("comments", "mentions"):
|
||||
if key in body:
|
||||
prefs[key] = bool(body[key])
|
||||
notif.set_user_prefs(user["id"], prefs)
|
||||
return {"status": "ok", "prefs": prefs}
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color
|
||||
FROM users WHERE login LIKE ? OR full_name LIKE ?
|
||||
ORDER BY (login=? OR full_name=?) DESC, login LIMIT 20""",
|
||||
(like, like, q, q),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color
|
||||
FROM users ORDER BY login LIMIT 20"""
|
||||
).fetchall()
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FlowDeck — v5.2.0 Onboarding: /welcome wizard + its API.
|
||||
|
||||
First-launch experience: create workspace → connect a forge (optional) →
|
||||
create the first project (welcome page), then land in the app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["onboarding"])
|
||||
|
||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/welcome", response_class=HTMLResponse)
|
||||
async def onboarding_page(request: Request):
|
||||
"""Onboarding wizard. Redirects logged-out users to login and users who
|
||||
already have a workspace straight to the app."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
with get_conn() as conn:
|
||||
ws_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
if ws_count > 0:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
template = env.get_template("welcome.html")
|
||||
return HTMLResponse(content=template.render(
|
||||
user=user,
|
||||
gitea_url_configured=_forge_configured("gitea"),
|
||||
github_url_configured=_forge_configured("github"),
|
||||
))
|
||||
|
||||
|
||||
def _forge_configured(provider: str) -> bool:
|
||||
try:
|
||||
from app.auth.providers import get_provider
|
||||
return get_provider(provider) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════ Onboarding API ═══════════
|
||||
|
||||
|
||||
@router.post("/api/onboarding/workspace")
|
||||
async def onboarding_create_workspace(request: Request):
|
||||
"""Step 1 — create the first local workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip() or "My Workspace"
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, '{}')",
|
||||
(name, user["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
|
||||
(cur.lastrowid, user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
ws_id = cur.lastrowid
|
||||
|
||||
response = JSONResponse({"status": "ok", "id": ws_id, "name": name})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/api/onboarding/project")
|
||||
async def onboarding_create_project(request: Request):
|
||||
"""Step 3 — create the first project: a welcome page in the workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
|
||||
workspace_id = body.get("workspace_id")
|
||||
|
||||
with get_conn() as conn:
|
||||
ws = None
|
||||
if workspace_id:
|
||||
ws = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, user["id"]),
|
||||
).fetchone()
|
||||
if not ws:
|
||||
ws = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(status_code=400, detail="Create a workspace first")
|
||||
|
||||
blocks = [
|
||||
{"id": "1", "type": "heading_1", "content": title},
|
||||
{"id": "2", "type": "paragraph",
|
||||
"content": "Welcome to FlowDeck 🎉 — your workspace is ready."},
|
||||
{"id": "3", "type": "paragraph",
|
||||
"content": "Use the slash command « / » in any page to add blocks, databases, to-dos and more."},
|
||||
{"id": "4", "type": "paragraph",
|
||||
"content": "Connect Gitea or GitHub in Settings → Integrations to sync your repositories."},
|
||||
]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
|
||||
"VALUES (?, ?, ?, ?, 'blocks', 'Private')",
|
||||
(user.get("login", "local"), ws["id"], title, json.dumps(blocks)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
return {"status": "ok", "id": page_id, "title": title, "workspace_id": ws["id"]}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""FlowDeck — v5.2.0 Projects API: list, register, manual sync + backups admin."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import projects as projects_svc
|
||||
from app.services.backup import backup_db, list_backups
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["projects"], prefix="/api/projects")
|
||||
backups_router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(request: Request):
|
||||
"""List all synced projects (optionally filtered by type)."""
|
||||
proj_type = request.query_params.get("type") or None
|
||||
return {"projects": projects_svc.list_projects(proj_type)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(request: Request):
|
||||
"""Register a standalone (builtin) project."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
project = projects_svc.create_builtin_project(name, body.get("owner", ""), body.get("description", ""))
|
||||
return {"status": "ok", "project": project}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_projects(request: Request):
|
||||
"""Trigger an immediate forge sync for every connected account."""
|
||||
_require_admin(request)
|
||||
stats = await projects_svc.sync_all_projects()
|
||||
return {"status": "ok", "stats": stats}
|
||||
|
||||
|
||||
# ═══════════ Backups (admin) ═══════════
|
||||
|
||||
|
||||
@backups_router.post("/api/settings/backups/run")
|
||||
async def run_backup_now(request: Request):
|
||||
"""Admin: create a database backup immediately."""
|
||||
_require_admin(request)
|
||||
filename = backup_db()
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="Backups disabled or no database file")
|
||||
return {"status": "ok", "filename": filename}
|
||||
|
||||
|
||||
@backups_router.get("/api/settings/backups")
|
||||
async def admin_list_backups(request: Request):
|
||||
"""Admin: list stored backups."""
|
||||
_require_admin(request)
|
||||
return {"backups": list_backups()}
|
||||
+46
-10
@@ -1,12 +1,13 @@
|
||||
"""FlowDeck — Public API router (v2.1.0)."""
|
||||
"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Header, Depends
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,28 +15,63 @@ router = APIRouter(tags=["public-api"], prefix="/api/v1")
|
||||
DEFAULT_TOKEN = "fd-public-key"
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _token_owner(token: str) -> dict | None:
|
||||
"""Resolve an api_tokens row by its sha256 hash (revoked → None)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0",
|
||||
(_hash_token(token),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def verify_token(authorization: str | None = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
|
||||
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
|
||||
token = authorization[7:] # strip "Bearer "
|
||||
if token == DEFAULT_TOKEN:
|
||||
return token
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if not row:
|
||||
if row:
|
||||
return token
|
||||
owner = _token_owner(token)
|
||||
if not owner:
|
||||
raise HTTPException(403, "Invalid API token")
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token."""
|
||||
"""Generate a public API access token.
|
||||
|
||||
When an authenticated session is present the token is bound to that user
|
||||
(revocable from Settings → API tokens); otherwise a legacy shared token is
|
||||
created for backward compatibility.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
if user and user.get("id"):
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway /ws/pages/{page_id}.
|
||||
|
||||
Auth via cookie session (flowdeck_session). Rooms in-memory par page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.realtime_server import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.websocket("/ws/pages/{page_id}")
|
||||
async def ws_page(websocket: WebSocket, page_id: int):
|
||||
await websocket.accept()
|
||||
user = SessionManager.decode_session(
|
||||
websocket.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
try:
|
||||
await websocket.close(code=4401)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
conn = await manager.connect(websocket, page_id, user)
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.handle(conn, msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("ws closed: %s", e)
|
||||
finally:
|
||||
await manager.disconnect(conn)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""FlowDeck — unified search router (v5.0.0).
|
||||
|
||||
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
||||
editor pages and databases scoped to the current user's accessible workspaces.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.search import search as search_service
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async def search(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user_id = user.get("id") if user and user.get("id") else None
|
||||
|
||||
data = search_service(q, user_id=user_id)
|
||||
return {
|
||||
"query": q,
|
||||
"pages": data["pages"],
|
||||
"collections": data["collections"],
|
||||
"total": len(data["pages"]) + len(data["collections"]),
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions.
|
||||
|
||||
Backend for the Settings → API tokens / Sessions UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["security"], prefix="/api/settings")
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> int:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user["id"]
|
||||
|
||||
|
||||
# ═══════════ API tokens ═══════════
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async def list_tokens(request: Request):
|
||||
"""List the current user's API tokens (prefix only, no secrets)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, token_prefix, last_used_at, revoked, created_at "
|
||||
"FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"tokens": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request):
|
||||
"""Create an API token for the current user. The secret is returned once."""
|
||||
uid = _current_user_id(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "API token"
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(uid, name[:80], _hash_token(token), token[:12]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"id": tid, "name": name, "token": token,
|
||||
"note": "Copy this token now — it won't be shown again."}
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id:int}")
|
||||
async def revoke_token(token_id: int, request: Request):
|
||||
"""Revoke an API token (soft delete)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ═══════════ Active sessions ═══════════
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(request: Request):
|
||||
"""List the current user's active sessions with their devices."""
|
||||
uid = _current_user_id(request)
|
||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||
sessions = SessionManager.list_sessions(uid)
|
||||
now = __import__("datetime").datetime.now()
|
||||
for s in sessions:
|
||||
s["is_current"] = (s["id"] == current_sid)
|
||||
# A session older than 7 days is implicitly expired (cookie max-age).
|
||||
created = s.get("created_at") or ""
|
||||
try:
|
||||
from datetime import datetime
|
||||
created_dt = datetime.fromisoformat(str(created).replace("Z", ""))
|
||||
s["expired"] = (now - created_dt).days >= 7
|
||||
except Exception:
|
||||
s["expired"] = False
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
@router.post("/sessions/{sid}/revoke")
|
||||
async def revoke_session(sid: str, request: Request):
|
||||
"""Revoke an active session. If it's the current one, the user is logged out."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
SessionManager.revoke_session(sid)
|
||||
# Also wipe the OAuth state cookie if the current session was revoked.
|
||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||
if current_sid == sid:
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "revoked"}
|
||||
@@ -0,0 +1,298 @@
|
||||
"""FlowDeck — Sharing, Publishing & Recents API (v4.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
|
||||
|
||||
def _require_auth(request: Request) -> dict:
|
||||
"""Require an authenticated session. Returns user dict or raises 401."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a page title."""
|
||||
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
# ── Page Sharing ──
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
"""Invite a user or email to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
email = body.get("email", "")
|
||||
permission = body.get("permission", "view")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
if not target_user_id and not email:
|
||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
# Verify target user exists if user_id given
|
||||
if target_user_id:
|
||||
target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user or email) is already shared on this page.
|
||||
target_row = None
|
||||
if target_user_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||
(page_id, target_user_id),
|
||||
).fetchone()
|
||||
elif email:
|
||||
target_row = conn.execute(
|
||||
"""SELECT id FROM page_shares
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
|
||||
(page_id, email.strip()),
|
||||
).fetchone()
|
||||
|
||||
if target_row:
|
||||
conn.execute(
|
||||
"UPDATE page_shares SET permission=?, created_by=? WHERE id=?",
|
||||
(permission, user["id"], target_row["id"]),
|
||||
)
|
||||
share_id = target_row["id"]
|
||||
else:
|
||||
if not email:
|
||||
email = ""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
share_id = cur.lastrowid
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_email": email,
|
||||
"permission": permission,
|
||||
"status": "shared",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
_require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||
(permission, share_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
"""Remove a share invitation."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
# If no more shares, unset is_shared
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||
).fetchone()["c"]
|
||||
if remaining == 0:
|
||||
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"status": "removed", "share_id": share_id}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
async def list_shares(page_id: int, request: Request):
|
||||
"""Get all shares for a page."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"shares": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"shared_with_user_id": r["shared_with_user_id"],
|
||||
"shared_with_email": r["shared_with_email"],
|
||||
"permission": r["permission"],
|
||||
"created_at": r["created_at"],
|
||||
"created_by": r["created_by"],
|
||||
"user_login": r["login"],
|
||||
"user_full_name": r["full_name"],
|
||||
"user_avatar_url": r["avatar_url"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Page Publishing ──
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
slug = _slugify(page["title"])
|
||||
# Ensure uniqueness by appending suffix if needed
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
"publish_slug": slug,
|
||||
"status": "published",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
"""Unpublish a page."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
"status": "unpublished",
|
||||
}
|
||||
|
||||
|
||||
# ── Recents ──
|
||||
|
||||
|
||||
@router.post("/recents/track")
|
||||
async def track_recent(request: Request):
|
||||
"""Record a page access in recents."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
workspace = body.get("workspace", "")
|
||||
source_type = body.get("source_type", "local")
|
||||
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, page_id)
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
source_type=excluded.source_type,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "tracked",
|
||||
"user_id": user["id"],
|
||||
"page_id": page_id,
|
||||
"accessed_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""FlowDeck — Sidebar customization API (v4.6.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sidebar"], prefix="/api/sidebar")
|
||||
|
||||
|
||||
def _get_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"workspace": {"visible": True, "order": 0, "show_count": None},
|
||||
"gitea": {"visible": True, "order": 1, "show_count": None},
|
||||
"meetings": {"visible": True, "order": 2, "show_count": 5},
|
||||
"recents": {"visible": True, "order": 3, "show_count": 10},
|
||||
"favorites": {"visible": True, "order": 4, "show_count": 10},
|
||||
"agents": {"visible": True, "order": 5, "show_count": None},
|
||||
"shared": {"visible": True, "order": 6, "show_count": 10},
|
||||
"published": {"visible": True, "order": 7, "show_count": 10},
|
||||
"private": {"visible": True, "order": 8, "show_count": None},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_sidebar_config(request: Request):
|
||||
"""Get the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sidebar_config FROM users WHERE id=?", (user["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"config": DEFAULT_CONFIG}
|
||||
|
||||
raw = row["sidebar_config"]
|
||||
if not raw:
|
||||
return {"config": DEFAULT_CONFIG}
|
||||
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {"config": DEFAULT_CONFIG}
|
||||
|
||||
# Merge with defaults to ensure all keys exist
|
||||
merged = dict(DEFAULT_CONFIG)
|
||||
merged.update(stored)
|
||||
return {"config": merged}
|
||||
|
||||
|
||||
def get_sidebar_config_sync(user_id: int) -> dict:
|
||||
"""Synchronous helper to get sidebar config (used during template rendering)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sidebar_config FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return dict(DEFAULT_CONFIG)
|
||||
raw = row["sidebar_config"]
|
||||
if not raw:
|
||||
return dict(DEFAULT_CONFIG)
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return dict(DEFAULT_CONFIG)
|
||||
merged = dict(DEFAULT_CONFIG)
|
||||
merged.update(stored)
|
||||
return merged
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def save_sidebar_config(request: Request):
|
||||
"""Save the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
config = body.get("config")
|
||||
if not config or not isinstance(config, dict):
|
||||
raise HTTPException(status_code=400, detail="config object is required")
|
||||
|
||||
# Merge with defaults to ensure validity
|
||||
merged = dict(DEFAULT_CONFIG)
|
||||
for key, val in config.items():
|
||||
if key in DEFAULT_CONFIG and isinstance(val, dict):
|
||||
merged[key] = {
|
||||
"visible": val.get("visible", DEFAULT_CONFIG[key]["visible"]),
|
||||
"order": val.get("order", DEFAULT_CONFIG[key]["order"]),
|
||||
"show_count": val.get("show_count", DEFAULT_CONFIG[key]["show_count"]),
|
||||
}
|
||||
elif key not in DEFAULT_CONFIG:
|
||||
# Allow new custom sections
|
||||
merged[key] = val
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET sidebar_config=? WHERE id=?",
|
||||
(json.dumps(merged), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "config": merged}
|
||||
@@ -1,12 +1,12 @@
|
||||
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
|
||||
return {"status": "ok", "webhook": result}
|
||||
except Exception as e:
|
||||
logger.error("Failed to register webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/status/{owner}/{repo}")
|
||||
|
||||
+299
-13
@@ -5,12 +5,14 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -230,8 +232,9 @@ async def create_db_template(request: Request):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
|
||||
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""),
|
||||
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
@@ -239,22 +242,16 @@ async def create_db_template(request: Request):
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
from app.services.db_templates import create_from_template
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||
(name, tmpl["description"], "📋", tmpl["schema_json"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
|
||||
(cur.lastrowid, "Default View", "table", "{}"),
|
||||
)
|
||||
collection_id = create_from_template(conn, name, dict(tmpl))
|
||||
conn.commit()
|
||||
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
|
||||
return {"collection_id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
@@ -293,9 +290,298 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.created", {
|
||||
"page_id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"title": body.get("title", "New Page"),
|
||||
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||
})
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
"""Update a page template — name, properties, content, recurrence."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||
).fetchone()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
|
||||
name = body.get("name", tmpl["name"])
|
||||
tmpl_dict = dict(tmpl)
|
||||
description = body.get("description", tmpl_dict.get("description", ""))
|
||||
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
|
||||
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
|
||||
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
|
||||
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
|
||||
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
|
||||
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": tid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
"""Delete a page template."""
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||
).fetchone()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
|
||||
conn.commit()
|
||||
return {"id": tid, "status": "deleted"}
|
||||
|
||||
|
||||
# ── v4.2.0: Dashboards ──
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
async def list_dashboards(request: Request, collection_id: int):
|
||||
"""List all dashboards for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||
).fetchall()
|
||||
return {"dashboards": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
async def create_dashboard(request: Request, collection_id: int):
|
||||
"""Create a new dashboard for a collection."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Dashboard").strip()
|
||||
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
|
||||
(collection_id, name, layout),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/dashboards/{did}")
|
||||
async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
"""Update a dashboard — name or layout (widgets grid)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||
).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
|
||||
name = body.get("name", dash["name"])
|
||||
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(name, layout, did),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": did, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/dashboards/{did}")
|
||||
async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
"""Delete a dashboard."""
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||
).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
|
||||
conn.commit()
|
||||
return {"id": did, "status": "deleted"}
|
||||
|
||||
|
||||
# ── v4.5.0: Sprints ──
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
async def list_sprints(request: Request, collection_id: int):
|
||||
"""List all sprints for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
|
||||
).fetchall()
|
||||
sprints = []
|
||||
for r in rows:
|
||||
s = dict(r)
|
||||
# Count pages in sprint
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
|
||||
).fetchone()["cnt"]
|
||||
s["page_count"] = count
|
||||
sprints.append(s)
|
||||
return {"sprints": sprints}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
"""Create a new sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "").strip()
|
||||
start_date = body.get("start_date", "")
|
||||
end_date = body.get("end_date", "")
|
||||
if not name or not start_date or not end_date:
|
||||
raise HTTPException(400, "name, start_date, end_date are required")
|
||||
|
||||
goal = body.get("goal", "")
|
||||
status = body.get("status", "planning")
|
||||
auto_complete = int(body.get("auto_complete", 1))
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
|
||||
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Update a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
|
||||
name = body.get("name", sprint["name"])
|
||||
start_date = body.get("start_date", sprint["start_date"])
|
||||
end_date = body.get("end_date", sprint["end_date"])
|
||||
goal = body.get("goal", sprint["goal"])
|
||||
status = body.get("status", sprint["status"])
|
||||
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
|
||||
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
||||
async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Delete a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
|
||||
conn.commit()
|
||||
return {"id": sid, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Assign a page to a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
|
||||
velocity_points = body.get("velocity_points", 1)
|
||||
status_at_start = body.get("status_at_start", "")
|
||||
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
|
||||
(sid, page_id, status_at_start, velocity_points),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409, "Page already assigned to this sprint") from None
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
||||
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
"""Remove a page from a sprint."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Assignment not found")
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
|
||||
conn.commit()
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
"""Calculate burndown data for a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
|
||||
pages = conn.execute(
|
||||
"""SELECT sp.velocity_points, cp.property_values_json
|
||||
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
|
||||
WHERE sp.sprint_id=?""", (sid,)
|
||||
).fetchall()
|
||||
|
||||
total_points = sum(p["velocity_points"] for p in pages)
|
||||
completed = 0
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
|
||||
completed += p["velocity_points"]
|
||||
break
|
||||
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
|
||||
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
|
||||
total_days = max((end - start).days, 1)
|
||||
elapsed = max((today - start).days, 0)
|
||||
ideal_burn = total_points - (total_points * elapsed / total_days)
|
||||
|
||||
return {
|
||||
"sprint": sprint["name"],
|
||||
"total_points": total_points,
|
||||
"completed_points": completed,
|
||||
"remaining_points": total_points - completed,
|
||||
"ideal_remaining": round(ideal_burn, 1),
|
||||
"start_date": sprint["start_date"],
|
||||
"end_date": sprint["end_date"],
|
||||
"days_elapsed": elapsed,
|
||||
"days_total": total_days,
|
||||
}
|
||||
|
||||
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.14.0).
|
||||
|
||||
`objective → comprehension → context → reasoning ↔ action → result`.
|
||||
|
||||
The engine drives the LLM (which only emits tool intentions), gates each call
|
||||
through PermissionManager, executes it via ToolRegistry, journals every action
|
||||
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
|
||||
the UI can render reasoning + actions live. Since v4.14.0 a freshly created
|
||||
conversation is automatically renamed with a descriptive title derived from its
|
||||
content so the history stays easy to browse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.context_builder import ContextBuilder
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ITERATIONS = 12
|
||||
|
||||
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
|
||||
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
|
||||
- **Format des blocs** (pour `write_blocks`) : chaque bloc est un objet `{"type": "...", "content": "texte"}`. Le champ du texte s'appelle **`content`** (jamais `text`). Un script / code s'écrit dans un bloc `{"type": "code", "content": "...", "language": "powershell"}`. Les titres sont `heading_1`, `heading_2`, `heading_3`, `heading_4`. Autres types : paragraph, bulleted_list, numbered_list, to_do, quote, divider, toggle, callout.
|
||||
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
|
||||
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
|
||||
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
|
||||
- **Recherche** : la commande Ctrl+K / la barre de recherche du haut permet de retrouver pages et collections.
|
||||
- **Corbeille & Bibliothèque** : les pages supprimées vont dans la Corbeille ; Favoris / Récents / Partagés / Publiés se consultent dans la Bibliothèque.
|
||||
- **Réglages** : Paramètres (en bas à gauche → Settings) pour le compte, les notifications, les tags, les intégrations et la section « Agent & IA » (clés API, fournisseurs, modèle global).
|
||||
- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application.
|
||||
Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes."""
|
||||
|
||||
# Deterministic auto-title heuristics (used when no real LLM is configured, and
|
||||
# as a fallback when the generated title is unusable). Ordered by priority: the
|
||||
# first matching intent wins.
|
||||
_TITLE_INTENTS = (
|
||||
("Création", ("créer", "crée", "crées", "création", "nouveau", "nouvelle",
|
||||
"create", "creation")),
|
||||
("Ajout", ("ajouter", "ajoute", "ajout d", "ajoutons", "add")),
|
||||
("Renommage", ("renommer", "renomme", "renommage", "rename")),
|
||||
("Suppression", ("supprimer", "supprime", "suppression", "delete")),
|
||||
("Déplacement", ("déplacer", "déplace", "déplacement", "move")),
|
||||
("Mise à jour", ("modifier", "modifie", "modification", "mets à jour",
|
||||
"met à jour", "mettre à jour", "update", "éditer")),
|
||||
("Analyse", ("analyser", "analyse")),
|
||||
("Résumé", ("résumer", "résume", "résumé", "resume")),
|
||||
("Traduction", ("traduire", "traduis", "traduit", "traduction", "translate")),
|
||||
("Planification", ("planifier", "planifie", "préparer", "prépare", "organiser",
|
||||
"organise", "sprint", "agenda")),
|
||||
("Recherche", ("chercher", "cherche", "rechercher", "recherche", "trouver",
|
||||
"trouve", "liste", "lister", "search", "find")),
|
||||
)
|
||||
|
||||
_TITLE_TYPES = (
|
||||
("collection", "collection", ("collection", "base de données", "database", "db")),
|
||||
("propriété", "propriété", ("propriété", "property")),
|
||||
("vue", "vue", (" vue", "view")),
|
||||
("board", "board", ("board", "kanban")),
|
||||
("sprint", "sprint", ("sprint",)),
|
||||
("document", "document", ("document", "note de réunion", "compte-rendu", "compte rendu")),
|
||||
("tâche", "tâche", ("tâche", "task", "tache")),
|
||||
("issue", "issue", ("issue",)),
|
||||
)
|
||||
|
||||
|
||||
class AgentEngine:
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None,
|
||||
llm: LLMClient | None = None):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
self.llm = llm or LLMClient()
|
||||
self.tools = ToolRegistry()
|
||||
self.ctx = ContextBuilder(user_id, workspace_id)
|
||||
self.perms = PermissionManager(user_id)
|
||||
self._tokens = 0
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _load_agent(self, conversation_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT a.* FROM agents a JOIN agent_conversations c ON c.agent_id=a.id WHERE c.id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
row = {"id": None, "name": "FlowDeck Agent", "icon": "🤖", "agent_type": "personal",
|
||||
"system_instructions": "", "model": settings.llm_model,
|
||||
"scope_json": "{}", "approval_mode": "auto"}
|
||||
return dict(row)
|
||||
|
||||
def _build_system_prompt(self, agent: dict, skills=None) -> str:
|
||||
if skills is None:
|
||||
skills = []
|
||||
if isinstance(skills, dict):
|
||||
skills = [skills]
|
||||
lines = [
|
||||
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
|
||||
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
|
||||
"Appelle UN ou PLUSIEURS outils pour atteindre l'objectif, puis conclus avec une réponse finale.",
|
||||
"N'invente jamais d'IDs : utilise ceux fournis dans le contexte.",
|
||||
f"Workspace courant : {self.workspace_id}.",
|
||||
]
|
||||
if agent.get("system_instructions"):
|
||||
lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}")
|
||||
# Plusieurs skills peuvent être appliqués au même post : chacun injecte
|
||||
# son prompt dans les instructions système.
|
||||
for skill in skills:
|
||||
if skill:
|
||||
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
|
||||
# L'utilisateur peut poser des questions d'aide sans contexte de document ;
|
||||
# le guide intégré permet d'y répondre (aucun outil requis).
|
||||
lines.append("\n" + APP_GUIDE)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Main run (async generator of SSE events) ──
|
||||
|
||||
async def run(self, conversation_id: int, objective: str, *, model: str | None = None,
|
||||
mentions: list[str] | None = None, files: list[dict] | None = None,
|
||||
skill_id: int | None = None, skill_ids: list[int] | None = None,
|
||||
extra_context: str | None = None):
|
||||
agent = self._load_agent(conversation_id)
|
||||
scope = json.loads(agent.get("scope_json") or "{}")
|
||||
approval_mode = agent.get("approval_mode") or "auto"
|
||||
model = model or agent.get("model") or settings.llm_model
|
||||
|
||||
ids = list(skill_ids or [])
|
||||
if skill_id and skill_id not in ids:
|
||||
ids.append(skill_id)
|
||||
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
|
||||
system = self._build_system_prompt(agent, skills)
|
||||
context = self.ctx.build(mentions=mentions, files=files)
|
||||
if extra_context and extra_context.strip():
|
||||
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
|
||||
]
|
||||
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
|
||||
# Update the history title right away (before the run finishes) and
|
||||
# refine it once we have the final answer (_autotitle below).
|
||||
try:
|
||||
suggested = self._suggest_title(objective, None)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
tool_schema = self.tools.schema(scope)
|
||||
final_text = None
|
||||
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
|
||||
|
||||
try:
|
||||
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
|
||||
if self._tokens >= settings.agent_max_tokens_budget:
|
||||
yield self._event("error", {"message": "Budget de tokens dépassé"})
|
||||
break
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
self.llm.complete(messages, model=model, tools=tool_schema, stream=True),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
self._tokens += response.usage.get("total_tokens", 0) or 0
|
||||
|
||||
if getattr(response, "notice", ""):
|
||||
yield self._event("notice", {"message": response.notice})
|
||||
|
||||
used_model = getattr(response, "model", "") or used_model
|
||||
|
||||
if response.text and response.text.strip():
|
||||
yield self._event("reasoning", {"content": response.text})
|
||||
|
||||
if not response.tool_calls:
|
||||
messages.append({"role": "assistant", "content": response.text or ""})
|
||||
final_text = response.text or self._no_tool_message(response)
|
||||
yield self._event("final", {"content": final_text})
|
||||
break
|
||||
|
||||
# L'API de chat exige que le message assistant qui *annonce* les appels
|
||||
# d'outils porte les `tool_calls` (avec id), puis que chaque résultat
|
||||
# d'outil soit fourni avec le `tool_call_id` correspondant. Sans cela
|
||||
# la passe suivante est refusée par le fournisseur (et l'agent retombait
|
||||
# silencieusement sur le mock hors-ligne).
|
||||
tool_specs = []
|
||||
for idx, call in enumerate(response.tool_calls):
|
||||
call_id = call.get("id") or f"call_{conversation_id}_{idx}_{self._tokens}"
|
||||
tool_specs.append({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": call["name"],
|
||||
"arguments": call.get("arguments_raw")
|
||||
or json.dumps(call.get("arguments") or {}, ensure_ascii=False),
|
||||
},
|
||||
})
|
||||
assistant_msg = {"role": "assistant", "content": response.text or ""}
|
||||
assistant_msg["tool_calls"] = tool_specs
|
||||
messages.append(assistant_msg)
|
||||
|
||||
for idx, call in enumerate(response.tool_calls):
|
||||
tool, args = call["name"], call.get("arguments") or {}
|
||||
call_id = tool_specs[idx]["id"]
|
||||
denied = False
|
||||
try:
|
||||
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
|
||||
except Exception as exc: # permission / approval guard
|
||||
detail = self._exc_detail(exc)
|
||||
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
|
||||
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": call_id,
|
||||
"content": json.dumps({"status": "error", "message": f"Permission refusée: {detail}"}, ensure_ascii=False),
|
||||
})
|
||||
denied = True
|
||||
|
||||
if not denied:
|
||||
result = await self.tools.execute(tool, args, user_id=self.user_id)
|
||||
|
||||
if result.status == "success":
|
||||
yield self._event("action", {
|
||||
"tool": tool, "status": result.status,
|
||||
"target_type": result.target_type, "target_id": result.target_id,
|
||||
"message": result.message,
|
||||
})
|
||||
self._log_action(conversation_id, tool, args, result.data, "success",
|
||||
target_type=result.target_type, target_id=result.target_id,
|
||||
undo=result.undo)
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": call_id,
|
||||
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
|
||||
})
|
||||
else:
|
||||
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
|
||||
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": call_id,
|
||||
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
|
||||
})
|
||||
|
||||
if final_text is None:
|
||||
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
|
||||
yield self._event("final", {"content": final_text})
|
||||
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
yield self._event("error", {"message": f"Erreur interne: {exc}"})
|
||||
finally:
|
||||
self._update_conversation(conversation_id, status="idle")
|
||||
|
||||
# ── Skills ──
|
||||
|
||||
def _load_skill(self, skill_id: int, scope: dict | None) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
skill = dict(row)
|
||||
allowed = json.loads(skill.get("allowed_tools_json") or "[]")
|
||||
if allowed:
|
||||
skill["prompt_template"] = (skill.get("prompt_template") or "") + \
|
||||
"\nOutils autorisés: " + ", ".join(allowed)
|
||||
return skill
|
||||
|
||||
# ── Audit & persistence ──
|
||||
|
||||
def _log_action(self, conversation_id, tool, args, result, status,
|
||||
*, target_type="", target_id=None, undo=None, detail=""):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_actions
|
||||
(conversation_id, tool_name, target_type, target_id, payload_json,
|
||||
result_json, status, undo_snapshot_json, executed_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(conversation_id, tool, target_type,
|
||||
str(target_id) if target_id is not None else None,
|
||||
json.dumps(args, ensure_ascii=False),
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
status,
|
||||
json.dumps(undo or {}, ensure_ascii=False),
|
||||
self.user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _persist_message(self, conversation_id, role, content, *, model="", tokens=0):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (conversation_id, role, content, model, tokens_used) VALUES (?,?,?,?,?)",
|
||||
(conversation_id, role, content, model, tokens),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _update_conversation(self, conversation_id, *, status=None, title=None):
|
||||
with get_conn() as conn:
|
||||
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
|
||||
if status:
|
||||
sets.append("status=?")
|
||||
params.append(status)
|
||||
if title:
|
||||
sets.append("title=?")
|
||||
params.append(title)
|
||||
params.append(conversation_id)
|
||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
|
||||
# ── Misc ──
|
||||
|
||||
@staticmethod
|
||||
def _event(etype: str, data: dict) -> dict:
|
||||
return {"type": etype, **data}
|
||||
|
||||
@staticmethod
|
||||
def _no_tool_message(response) -> str:
|
||||
return "Je n'ai pas d'action à proposer pour cet objectif. Posez-moi une question plus précise ou demandez-moi de créer un élément."
|
||||
|
||||
@staticmethod
|
||||
def _exc_detail(exc: Exception) -> str:
|
||||
detail = getattr(exc, "detail", None)
|
||||
return detail if isinstance(detail, str) else str(exc)
|
||||
|
||||
# ── Auto-title (v4.14.0) ──
|
||||
|
||||
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
|
||||
"""Rename the conversation with a descriptive title derived from the
|
||||
*latest* user request. Runs after every AI call so the history list
|
||||
always reflects the current topic and stays easy to browse."""
|
||||
try:
|
||||
suggested = self._suggest_title(objective, final_text)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
@classmethod
|
||||
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
|
||||
"""Produce a short descriptive title from the user objective (offline-safe)."""
|
||||
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
# Strip the composer prefixes ("Contexte « X »", "Skill « Y »") that the
|
||||
# frontend prepends before the real user text.
|
||||
text = re.sub(
|
||||
r"(?:Contexte\s*«[^»]*»|Skill\s*«[^»]*»|Skill\s+«[^»]*»)(?:\s*[,;\n.])+\s*",
|
||||
"", text,
|
||||
).strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
low = text.lower()
|
||||
|
||||
intent = None
|
||||
for label, words in _TITLE_INTENTS:
|
||||
if any(w in low for w in words):
|
||||
intent = label
|
||||
break
|
||||
|
||||
type_label = next(
|
||||
(t for t, _noun, words in _TITLE_TYPES if any(w in low for w in words)), None
|
||||
)
|
||||
has_workspace = any(w in low for w in ("workspace", "espace de travail"))
|
||||
quotes = [q.strip() for q in re.findall(r'[«"]([^«»"]{1,80})[»"]', text) if q.strip()]
|
||||
subject = quotes[0] if quotes else None
|
||||
ws = quotes[-1] if (has_workspace and len(quotes) > 1) else None
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", s).strip(" .;:-")
|
||||
|
||||
if not subject and type_label:
|
||||
noun = next((n for t, n, _w in _TITLE_TYPES if t == type_label), type_label)
|
||||
m = re.search(
|
||||
rf"\b{noun}\b\s*(?:nomm[ée]e?\s+|appel[ée]e?\s+|intitul[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60}?)\s*[»"]?',
|
||||
text, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
subject = m.group(1).strip()
|
||||
|
||||
if intent and subject:
|
||||
core = f"{intent} {type_label or 'élément'} « {subject} »" \
|
||||
if type_label else f"{intent} « {subject} »"
|
||||
if ws:
|
||||
core += f" (dans {ws})"
|
||||
return _clean(core)
|
||||
|
||||
generic = _clean(text)
|
||||
return generic[:70] if generic else "Conversation"
|
||||
|
||||
|
||||
def undo_action(action_id: int) -> bool:
|
||||
"""Reverse a single agent action using its stored undo snapshot.
|
||||
|
||||
Returns True on success. Marks the action row `reverted`.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
action = conn.execute("SELECT * FROM agent_actions WHERE id=?", (action_id,)).fetchone()
|
||||
if not action:
|
||||
raise ValueError(f"Action #{action_id} introuvable")
|
||||
undo = json.loads(action["undo_snapshot_json"] or "{}")
|
||||
op, table, rid = undo.get("action"), undo.get("table"), undo.get("id")
|
||||
if not op or not table or rid is None:
|
||||
raise ValueError(f"Action #{action_id} n'a pas de snapshot annulable")
|
||||
|
||||
if op == "delete":
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
|
||||
elif op == "softdelete":
|
||||
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (rid,))
|
||||
elif op == "insert":
|
||||
snapshot = undo.get("snapshot")
|
||||
if not snapshot:
|
||||
raise ValueError("Snapshot manquant pour insert")
|
||||
cols = ", ".join(snapshot.keys())
|
||||
ph = ", ".join("?" for _ in snapshot)
|
||||
conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({ph})", list(snapshot.values()))
|
||||
elif op == "update":
|
||||
snapshot = undo.get("snapshot")
|
||||
if table == "collection_pages":
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
|
||||
)
|
||||
elif table == "pages":
|
||||
if isinstance(snapshot, dict):
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot.get("content", ""),
|
||||
snapshot.get("content_format", "markdown"),
|
||||
snapshot.get("title", ""), rid),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot, rid))
|
||||
else:
|
||||
raise ValueError(f"Table non gérée pour rollback: {table}")
|
||||
else:
|
||||
raise ValueError(f"Opération de rollback inconnue: {op}")
|
||||
|
||||
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -0,0 +1,330 @@
|
||||
"""FlowDeck — AI Writing Assist (v5.9.0).
|
||||
|
||||
Headless, tool-free writing helpers used by the editor (slash commands,
|
||||
inline autocomplete) and the database table (AI property suggestions).
|
||||
|
||||
All actions share one entry point, :meth:`AIWritingService.run`, which builds a
|
||||
tight prompt, calls the configured LLM (or the deterministic offline mock) and
|
||||
returns plain Markdown. `properties` additionally returns a structured
|
||||
``suggestions`` mapping so the caller can fill collection properties.
|
||||
|
||||
The service never talks to the DB directly — the router resolves the caller's
|
||||
provider/key and the page context before delegating here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.services.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WRITING_ACTIONS = ("write", "summarize", "translate", "continue", "autocomplete", "properties")
|
||||
|
||||
_MAX_CONTEXT = 20000
|
||||
|
||||
# Property name → (type, offline default) used by the deterministic fallback so
|
||||
# the feature stays useful without a connected provider.
|
||||
_OFFLINE_PROPERTY_DEFAULTS = (
|
||||
(("status", "état", "etat", "stage"), "select", "To do"),
|
||||
(("priority", "priorité", "priorite"), "select", "Medium"),
|
||||
(("done", "terminé", "termine", "complété", "complete"), "checkbox", False),
|
||||
(("summary", "résumé", "resume", "description", "notes"), "text", ""),
|
||||
)
|
||||
|
||||
_SYSTEM_WRITING = (
|
||||
"Tu es l'assistant d'écriture de FlowDeck. "
|
||||
"Réponds UNIQUEMENT avec le contenu demandé, en Markdown léger "
|
||||
"(paragraphes, listes à puces, titres si utile). "
|
||||
"N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte."
|
||||
)
|
||||
|
||||
|
||||
class AIWritingService:
|
||||
"""Deterministic, provider-agnostic writing assistant."""
|
||||
|
||||
def __init__(self, user_id: int | None = None, provider: str | None = None,
|
||||
model: str | None = None, api_key: str | None = None,
|
||||
api_base: str | None = None):
|
||||
self.user_id = user_id
|
||||
self.provider = (provider or "").strip().lower() or None
|
||||
self.model = (model or "").strip() or None
|
||||
self._api_key = api_key
|
||||
self._api_base = api_base
|
||||
|
||||
# ── LLM plumbing ──
|
||||
|
||||
def _client(self) -> LLMClient:
|
||||
provider = self.provider
|
||||
api_key = self._api_key
|
||||
api_base = self._api_base
|
||||
if provider and self.user_id:
|
||||
try:
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
row = get_user_llm_key(self.user_id, provider)
|
||||
if row and row.get("api_key"):
|
||||
api_key = row["api_key"]
|
||||
api_base = (row.get("api_base") or "").strip() or api_base
|
||||
except Exception: # noqa: BLE001 — never fail on key lookup
|
||||
pass
|
||||
return LLMClient(provider=provider, api_key=api_key, api_base=api_base)
|
||||
|
||||
async def _complete(self, prompt: str, context: str = "") -> tuple[str, str, bool]:
|
||||
llm = self._client()
|
||||
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||
user_content = prompt
|
||||
if context and context.strip():
|
||||
user_content += "\n\n# Contexte\n" + context.strip()[:_MAX_CONTEXT]
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_WRITING},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
resp = await llm.complete(messages, model=self.model, tools=None, stream=False)
|
||||
return (resp.text or "").strip(), (resp.model or self.model or ""), offline
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
async def run(self, action: str, *, prompt: str = "", context: str = "",
|
||||
target_language: str = "English", prefix: str = "",
|
||||
title: str = "", properties: list | None = None) -> dict:
|
||||
action = (action or "").strip().lower()
|
||||
if action not in WRITING_ACTIONS:
|
||||
raise ValueError(f"Action inconnue: {action or '(vide)'}")
|
||||
|
||||
if action == "properties":
|
||||
suggestions = await self.suggest_properties(
|
||||
context=context, title=title, properties=properties or [])
|
||||
return {"ok": True, "action": action, "text": "", "suggestions": suggestions,
|
||||
"model": self.model or "", "offline": self._offline_hint()}
|
||||
|
||||
prompt_text = self._build_prompt(
|
||||
action, prompt=prompt, context=context,
|
||||
target_language=target_language, prefix=prefix, title=title)
|
||||
|
||||
# No connected provider → deterministic, dependency-free output (the raw
|
||||
# offline planner echoes the prompt, which is wrong for continue/autocomplete).
|
||||
if self._offline_hint():
|
||||
return {"ok": True, "action": action, "model": "",
|
||||
"offline": True,
|
||||
"text": self._offline_text(action, prompt=prompt, context=context,
|
||||
target_language=target_language,
|
||||
prefix=prefix, title=title)}
|
||||
try:
|
||||
text, model, offline = await self._complete(prompt_text, context=context)
|
||||
except Exception as exc: # noqa: BLE001 — surface provider errors to the UI
|
||||
logger.warning("AI writing '%s' failed: %s", action, exc)
|
||||
return {"ok": False, "action": action, "error": str(exc),
|
||||
"text": "", "model": self.model or "", "offline": False}
|
||||
if not text:
|
||||
text = self._offline_text(action, prompt=prompt, context=context,
|
||||
target_language=target_language,
|
||||
prefix=prefix, title=title)
|
||||
offline = True
|
||||
return {"ok": True, "action": action, "text": text,
|
||||
"model": model, "offline": offline}
|
||||
|
||||
# ── Prompt building ──
|
||||
|
||||
def _build_prompt(self, action: str, *, prompt: str, context: str,
|
||||
target_language: str, prefix: str, title: str) -> str:
|
||||
if action == "write":
|
||||
subject = (prompt or title or "ce document").strip()
|
||||
return (f"Rédige le contenu demandé : {subject}. "
|
||||
"Fournis un texte structuré et directement utilisable.")
|
||||
if action == "summarize":
|
||||
return ("Résume le contenu fourni de façon structurée et concise : "
|
||||
"un court paragraphe d'introduction puis 3 à 5 points clés à puces.")
|
||||
if action == "translate":
|
||||
lang = (target_language or "English").strip()
|
||||
return (f"Traduis l'intégralité du contenu fourni en {lang}, "
|
||||
"en conservant fidèlement sa structure (titres, listes, paragraphes). "
|
||||
"Ne traduis pas les noms propres et les termes techniques.")
|
||||
if action == "continue":
|
||||
return ("Poursuis naturellement le texte fourni. "
|
||||
"Écris un à trois paragraphes cohérents avec le style et le sujet, "
|
||||
"sans répéter ce qui précède et sans introduction.")
|
||||
if action == "autocomplete":
|
||||
return (f"Complète la phrase en cours par une suite courte et pertinente "
|
||||
f"(maximum 20 mots). Ne répète pas le texte déjà écrit, ne mets "
|
||||
f"aucun préambule. Texte en cours : {prefix!r}")
|
||||
return prompt
|
||||
|
||||
# ── Offline deterministic fallbacks ──
|
||||
|
||||
def _offline_hint(self) -> bool:
|
||||
try:
|
||||
llm = self._client()
|
||||
return llm.provider == "offline" or not llm._has_credentials()
|
||||
except Exception: # noqa: BLE001
|
||||
return True
|
||||
|
||||
def _offline_text(self, action: str, *, prompt: str, context: str,
|
||||
target_language: str, prefix: str, title: str) -> str:
|
||||
if action == "summarize":
|
||||
return self._offline_summary(context)
|
||||
if action == "translate":
|
||||
return (f"⚠️ **Traduction hors-ligne indisponible** — aucun modèle d'IA connecté.\n\n"
|
||||
f"Connectez un fournisseur dans **Paramètres → Agent & IA** pour traduire "
|
||||
f"ce document en {target_language or 'English'}.")
|
||||
if action == "autocomplete":
|
||||
return self._offline_autocomplete(prefix)
|
||||
if action == "continue":
|
||||
return ("Suite du contenu : développez ici le point précédent avec un exemple "
|
||||
"concret, puis ouvrez la prochaine idée en une phrase de transition.")
|
||||
subject = (prompt or title or "ce document").strip()
|
||||
return (f"## {subject}\n"
|
||||
"\n"
|
||||
"Présentation générale du sujet : objectif, contexte et public visé en "
|
||||
"quelques phrases. (Contenu généré hors-ligne — connectez une clé API "
|
||||
"pour une rédaction complète.)\n"
|
||||
"\n"
|
||||
"## Points clés\n"
|
||||
"• Idée principale 1 et son argument.\n"
|
||||
"• Idée principale 2 avec un exemple concret.\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Relire, compléter et mettre en forme ce contenu.")
|
||||
|
||||
@staticmethod
|
||||
def _offline_summary(context: str) -> str:
|
||||
text = (context or "").strip()
|
||||
if not text:
|
||||
return "Résumé : aucun contenu fourni à résumer."
|
||||
headings = re.findall(r"^#{1,4}\s+(.+)$", text, flags=re.MULTILINE)
|
||||
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text))
|
||||
lead = next((s.strip() for s in sentences if len(s.strip()) > 40), sentences[0].strip())
|
||||
out = ["**Résumé**", "", lead[:400], ""]
|
||||
bullets = []
|
||||
if headings:
|
||||
bullets = [f"• {h.strip()}" for h in headings[:5]]
|
||||
else:
|
||||
for s in sentences[1:6]:
|
||||
s = s.strip()
|
||||
if len(s) > 30:
|
||||
bullets.append(f"• {s[:180]}")
|
||||
if bullets:
|
||||
out.append("**Points clés**")
|
||||
out.extend(bullets)
|
||||
return "\n".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _offline_autocomplete(prefix: str) -> str:
|
||||
prefix = (prefix or "").strip()
|
||||
if len(prefix) < 8:
|
||||
return ""
|
||||
return " Cette section détaille les points clés à retenir."
|
||||
|
||||
# ── AI properties ──
|
||||
|
||||
async def suggest_properties(self, *, context: str = "", title: str = "",
|
||||
properties: list | None = None) -> dict:
|
||||
"""Return ``{property_name: value}`` suggestions for a collection page.
|
||||
|
||||
``properties`` is a list of ``{name, type}`` dicts. With a connected
|
||||
provider the model is asked for a JSON object; offline we derive
|
||||
deterministic defaults from the property names so the UI stays useful.
|
||||
"""
|
||||
properties = properties or []
|
||||
if not properties:
|
||||
return {}
|
||||
names = [str(p.get("name", "")).strip() for p in properties if isinstance(p, dict)]
|
||||
names = [n for n in names if n]
|
||||
|
||||
llm = self._client()
|
||||
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||
if not offline:
|
||||
schema = {str(p.get("name")): str(p.get("type", "text")) for p in properties if isinstance(p, dict)}
|
||||
prompt = (
|
||||
"À partir du titre et du contenu du document, propose une valeur pour "
|
||||
"chaque propriété. Réponds STRICTEMENT par un objet JSON "
|
||||
"{\"nom_propriété\": valeur} sans texte autour.\n"
|
||||
f"Propriétés attendues : {json.dumps(schema, ensure_ascii=False)}\n"
|
||||
f"Titre : {title or '(sans titre)'}"
|
||||
)
|
||||
try:
|
||||
text, _, _ = await self._complete(prompt, context=context)
|
||||
parsed = self._parse_json_object(text)
|
||||
if parsed:
|
||||
return self._coerce_suggestions(parsed, properties)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("AI properties failed: %s", exc)
|
||||
# fall through to deterministic defaults
|
||||
return self._offline_suggestions(title, context, properties)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _coerce_suggestions(self, parsed: dict, properties: list) -> dict:
|
||||
out: dict = {}
|
||||
by_name = {str(p.get("name", "")).strip().lower(): p for p in properties if isinstance(p, dict)}
|
||||
for key, value in parsed.items():
|
||||
prop = by_name.get(str(key).strip().lower())
|
||||
if not prop:
|
||||
continue
|
||||
out[str(prop.get("name"))] = self._coerce_value(value, prop.get("type", "text"))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _coerce_value(value, prop_type: str):
|
||||
ptype = (prop_type or "text").lower()
|
||||
if ptype == "checkbox":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "oui", "vrai", "x")
|
||||
if ptype == "number":
|
||||
try:
|
||||
num = float(value)
|
||||
return int(num) if num.is_integer() else num
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _offline_suggestions(title: str, context: str, properties: list) -> dict:
|
||||
out: dict = {}
|
||||
summary_text = ""
|
||||
if context:
|
||||
summary_text = re.sub(r"\s+", " ", context).strip()
|
||||
first = re.split(r"(?<=[.!?])\s+", summary_text)
|
||||
summary_text = next((s for s in first if len(s) > 40), summary_text)[:180]
|
||||
for prop in properties:
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
name = str(prop.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
low = name.lower()
|
||||
ptype = (prop.get("type") or "text").lower()
|
||||
matched = False
|
||||
for keys, _ptype, default in _OFFLINE_PROPERTY_DEFAULTS:
|
||||
if any(k in low for k in keys):
|
||||
if "summary" in keys or "résumé" in keys or "resume" in keys or "description" in keys or "notes" in keys:
|
||||
out[name] = summary_text or (title or "")
|
||||
else:
|
||||
out[name] = default
|
||||
matched = True
|
||||
break
|
||||
if not matched and ptype in ("text", "title"):
|
||||
if "name" in low or "titre" in low or "title" in low:
|
||||
out[name] = title or ""
|
||||
return out
|
||||
|
||||
|
||||
async def run_action(action: str, **kwargs) -> dict:
|
||||
"""Module-level convenience wrapper (used by tests and simple callers)."""
|
||||
return await AIWritingService().run(action, **kwargs)
|
||||
@@ -0,0 +1,400 @@
|
||||
"""FlowDeck — Automations engine (v5.1.0).
|
||||
|
||||
Implements the "if-this-then-that" rule engine: automations match an event (or a
|
||||
cron schedule, or a clickable button), optionally guard on a condition, then run
|
||||
a list of actions.
|
||||
|
||||
Condition clauses (``condition_json``), all combined with AND:
|
||||
{"property": "Status", "op": "eq", "value": "Done"}
|
||||
{"property": "Priority", "op": "not_contains", "value": "Low"}
|
||||
{"property": "Assignee", "op": "is_empty"}
|
||||
{"property": "Estimate", "op": "changed"} (only event triggers)
|
||||
Flags:
|
||||
op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed}
|
||||
|
||||
Actions (``actions_json``), executed sequentially:
|
||||
{"type": "webhook", "url": "...", "secret": "..."}
|
||||
{"type": "set_property", "property": "Status", "value": "Done"}
|
||||
{"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}}
|
||||
{"type": "notify", "message": "Automation fired"}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"}
|
||||
# Events that fire on collection pages (payload carries a `properties` dict).
|
||||
PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"}
|
||||
|
||||
|
||||
def _prop_value(props: dict, key) -> tuple[bool, object]:
|
||||
"""Resolve a property value by id or name. Returns ``(found, value)``.
|
||||
|
||||
``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API).
|
||||
"""
|
||||
if props is None:
|
||||
return False, None
|
||||
if key is None:
|
||||
return True, None
|
||||
skey = str(key)
|
||||
if skey in props:
|
||||
return True, props[skey]
|
||||
if isinstance(key, int) and str(key) in props:
|
||||
return True, props[str(key)]
|
||||
return False, None
|
||||
|
||||
|
||||
def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool:
|
||||
"""Evaluate a single condition clause against page property values."""
|
||||
op = clause.get("op", "eq")
|
||||
if op not in COND_OPS:
|
||||
return False
|
||||
if op == "changed":
|
||||
key = clause.get("property")
|
||||
if before_props is None:
|
||||
return False
|
||||
found_before, before_val = _prop_value(before_props, key)
|
||||
found_after, after_val = _prop_value(props, key)
|
||||
return found_before and found_after and before_val != after_val
|
||||
|
||||
found, val = _prop_value(props, clause.get("property"))
|
||||
|
||||
if op == "is_empty":
|
||||
if not found:
|
||||
return True
|
||||
return val is None or str(val).strip() == ""
|
||||
if op == "is_not_empty":
|
||||
return found and val is not None and str(val).strip() != ""
|
||||
|
||||
if not found:
|
||||
return False
|
||||
want = clause.get("value")
|
||||
if op == "eq":
|
||||
return _norm(val) == _norm(want)
|
||||
if op == "neq":
|
||||
return _norm(val) != _norm(want)
|
||||
if op == "contains":
|
||||
return _norm(want) in _norm(val) if _norm(val) else False
|
||||
if op == "not_contains":
|
||||
return _norm(want) not in _norm(val) if _norm(val) else True
|
||||
return False
|
||||
|
||||
|
||||
def _norm(v) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, (list, dict)):
|
||||
return json.dumps(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool:
|
||||
"""Evaluate the stored condition list (AND of all clauses). Empty list → True."""
|
||||
try:
|
||||
clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or [])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
clauses = []
|
||||
for clause in clauses or []:
|
||||
if not match_condition_props(props, before_props, clause):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_page_context(page_id: int, collection_id: int) -> dict:
|
||||
"""Load a collection page's property values for condition evaluation."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"page_id": page_id, "collection_id": collection_id,
|
||||
"title": "", "properties": {}, "icon": "file"}
|
||||
try:
|
||||
props = json.loads(row["property_values_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
props = {}
|
||||
return {"page_id": page_id, "collection_id": collection_id,
|
||||
"title": row["title"], "icon": row["icon"], "properties": props}
|
||||
|
||||
|
||||
def _save_run(automation_id: int, trigger_source: str, status: str, detail: str,
|
||||
collection_id: int | None = None, page_id: int | None = None) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO automation_runs
|
||||
(automation_id, trigger_source, status, detail, collection_id, page_id)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(automation_id, trigger_source, status, detail, collection_id, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(automation_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
|
||||
"""Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}."""
|
||||
if not isinstance(value, str):
|
||||
return True, value
|
||||
replaced = value
|
||||
for key in props:
|
||||
if "[[" + str(key) + "]]" in replaced:
|
||||
replaced = replaced.replace("[[" + str(key) + "]]", str(props[key]))
|
||||
if "{{title}}" in replaced:
|
||||
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
|
||||
if "{{id}}" in replaced:
|
||||
replaced = replaced.replace("{{id}}", str(props.get("page_id", "")))
|
||||
return True, replaced
|
||||
|
||||
|
||||
async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
"""Execute a single action. Returns a human summary. Raises on failure."""
|
||||
atype = action.get("type")
|
||||
|
||||
if atype == "webhook":
|
||||
url = action.get("url", "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a url")
|
||||
secret = action.get("secret", "")
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(url, json=context, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||
return f"webhook → {url} ({resp.status_code})"
|
||||
|
||||
if atype == "set_property":
|
||||
prop = action.get("property")
|
||||
value = action.get("value")
|
||||
page_id = context.get("page_id")
|
||||
if not prop or not page_id:
|
||||
raise ValueError("set_property requires property + page context")
|
||||
_, resolved = _maybe_convert_prediction(value, context)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT property_values_json, collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"page {page_id} not found")
|
||||
try:
|
||||
props = json.loads(row["property_values_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
props = {}
|
||||
props[prop] = resolved
|
||||
from app.routers.collections import _validate_page_properties
|
||||
_validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id)
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return f"set property {prop} = {resolved}"
|
||||
|
||||
if atype == "create_page":
|
||||
coll_id = action.get("collection_id")
|
||||
title = action.get("title", "Automation page")
|
||||
properties = action.get("properties", {}) or {}
|
||||
if not coll_id:
|
||||
raise ValueError("create_page requires a collection_id")
|
||||
_, resolved_title = _maybe_convert_prediction(title, context)
|
||||
resolved_props = {}
|
||||
for k, v in properties.items():
|
||||
_, pv = _maybe_convert_prediction(v, context)
|
||||
resolved_props[k] = pv
|
||||
with get_conn() as conn:
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(coll_id,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(coll_id, resolved_title, max_pos, json.dumps(resolved_props)),
|
||||
)
|
||||
conn.commit()
|
||||
return f"created page {cur.lastrowid} in collection {coll_id}"
|
||||
|
||||
if atype == "notify":
|
||||
message = action.get("message", "Automation fired")
|
||||
user_id = action.get("user_id")
|
||||
if not user_id:
|
||||
user_id = context.get("created_by") or 1
|
||||
_, resolved = _maybe_convert_prediction(message, context)
|
||||
notifications.create_notification(
|
||||
user_id=user_id,
|
||||
actor_id=context.get("created_by") or 1,
|
||||
ntype="page",
|
||||
title=context.get("automation_name", "Automation"),
|
||||
message=resolved,
|
||||
resource_type="collection_page" if context.get("page_id") else "page",
|
||||
resource_id=context.get("page_id") or context.get("collection_id") or 0,
|
||||
url=context.get("url", ""),
|
||||
)
|
||||
return f"notified user {user_id}"
|
||||
|
||||
raise ValueError(f"unknown action type: {atype!r}")
|
||||
|
||||
|
||||
async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict:
|
||||
"""Load, condition-check and execute an automation. Records a run row."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone()
|
||||
if not row:
|
||||
return {"status": "skipped", "detail": "automation not found"}
|
||||
auto = dict(row)
|
||||
|
||||
if not auto["enabled"]:
|
||||
return {"status": "skipped", "detail": "automation disabled"}
|
||||
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
if not evaluate_conditions(auto["condition_json"], props, before):
|
||||
_save_run(automation_id, trigger_source, "skipped", "condition not met",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "condition not met"}
|
||||
|
||||
try:
|
||||
actions = json.loads(auto["actions_json"]) if auto["actions_json"] else []
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
actions = []
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["automation_name"] = auto["name"]
|
||||
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||
|
||||
results = []
|
||||
try:
|
||||
for action in actions or []:
|
||||
results.append(await _run_action(action, ctx, trigger_source))
|
||||
detail = "; ".join(results)
|
||||
_save_run(automation_id, trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001 — record every failure in history
|
||||
logger.warning("Automation %s failed: %s", automation_id, exc)
|
||||
_save_run(automation_id, trigger_source, "error", str(exc),
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as fire_webhooks
|
||||
await fire_webhooks(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Webhook dispatch failed for %s", event)
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automations
|
||||
WHERE trigger_type='event' AND event=? AND enabled=1""",
|
||||
(event,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
|
||||
continue
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
|
||||
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
|
||||
|
||||
_SUPPORTED_CRON = {
|
||||
"*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30,
|
||||
"*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45,
|
||||
}
|
||||
|
||||
|
||||
def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool:
|
||||
"""True when a ``*/N`-style or fixed-minute cron expression is due.
|
||||
|
||||
Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact
|
||||
``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work.
|
||||
"""
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
if expr in ("@hourly", "hourly"):
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60)
|
||||
|
||||
if expr in ("@daily", "daily"):
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(hours=24)
|
||||
|
||||
# "*/N * * * *" → every N minutes
|
||||
if fields and fields[0].startswith("*/"):
|
||||
val = fields[0][2:]
|
||||
if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values():
|
||||
return False
|
||||
n = int(val)
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n)
|
||||
|
||||
# "H * * * *" → at a fixed minute of each hour
|
||||
if len(fields) == 5 and fields[0].isdigit():
|
||||
return int(fields[0]) == minute
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
try:
|
||||
if cron_due(auto["cron_expression"], auto["last_run_at"]):
|
||||
context = {
|
||||
"collection_id": auto["collection_id"] or 0,
|
||||
"page_id": None,
|
||||
"properties": None,
|
||||
}
|
||||
await run_automation(auto["id"], "cron", context)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Cron automation %s errored", auto["id"])
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _backup_dir() -> Path:
|
||||
d = Path(settings.backup_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return settings.db_path
|
||||
|
||||
|
||||
def backup_db(now: datetime | None = None) -> str | None:
|
||||
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
|
||||
|
||||
Returns the backup filename, or None when backup is disabled or the
|
||||
database file does not exist.
|
||||
"""
|
||||
if not settings.backup_enabled:
|
||||
return None
|
||||
db_path = _db_path()
|
||||
if str(db_path) == ":memory:" or not Path(db_path).is_file():
|
||||
return None
|
||||
|
||||
now = now or datetime.now()
|
||||
# Checkpoint the WAL so the backup is consistent.
|
||||
try:
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dest_dir = _backup_dir()
|
||||
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
||||
dest = dest_dir / filename
|
||||
shutil.copy2(db_path, dest)
|
||||
|
||||
# Prune old backups, keeping ``backup_keep`` most recent files.
|
||||
prune_old_backups()
|
||||
logger.info("Backup created: %s", dest)
|
||||
return filename
|
||||
|
||||
|
||||
def list_backups() -> list[dict]:
|
||||
"""List existing backup files (name, size bytes, mtime)."""
|
||||
files = []
|
||||
for p in _backup_dir().glob("flowdeck-*.db"):
|
||||
stat = p.stat()
|
||||
files.append({
|
||||
"filename": p.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
})
|
||||
files.sort(key=lambda f: f["filename"], reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
def prune_old_backups(keep: int | None = None) -> int:
|
||||
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
|
||||
keep = keep if keep is not None else settings.backup_keep
|
||||
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
|
||||
removed = 0
|
||||
for p in files[keep:]:
|
||||
try:
|
||||
p.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
logger.warning("Could not prune backup %s", p)
|
||||
return removed
|
||||
|
||||
|
||||
def last_backup_age_hours() -> float | None:
|
||||
"""Hours since the most recent backup (None if none exists)."""
|
||||
files = list(_backup_dir().glob("flowdeck-*.db"))
|
||||
if not files:
|
||||
return None
|
||||
newest = max(files, key=lambda p: p.stat().st_mtime)
|
||||
age = time.time() - newest.stat().st_mtime
|
||||
return age / 3600
|
||||
|
||||
|
||||
def backup_due() -> bool:
|
||||
"""True when a backup should run now (interval elapsed since last one)."""
|
||||
age = last_backup_age_hours()
|
||||
if age is None:
|
||||
return True
|
||||
return age >= settings.backup_interval_hours
|
||||
|
||||
|
||||
async def backup_scheduler():
|
||||
"""Background loop: run a backup once per interval (default daily)."""
|
||||
while True:
|
||||
try:
|
||||
if backup_due():
|
||||
backup_db()
|
||||
except Exception as exc: # never let the loop die
|
||||
logger.warning("backup_scheduler error: %s", exc)
|
||||
await __import__("asyncio").sleep(3600) # re-check hourly
|
||||
@@ -6,7 +6,6 @@ without breaking the existing board routes.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -49,7 +48,7 @@ class GiteaBoardCompat:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
|
||||
def from_card(card_row, gitea_issue: dict | None = None) -> dict:
|
||||
"""Convertit une card legacy en pseudo collection_page."""
|
||||
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
|
||||
priority = card_row.get("priority", "Medium")
|
||||
@@ -83,7 +82,7 @@ class GiteaBoardCompat:
|
||||
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
|
||||
def get_board_as_collection(owner: str, repo: str) -> dict | None:
|
||||
"""Récupère un board spécifique comme collection."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -95,7 +94,7 @@ class GiteaBoardCompat:
|
||||
return GiteaBoardCompat.from_board(dict(row))
|
||||
|
||||
@staticmethod
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
|
||||
"""Récupère les cartes d'un board comme collection_pages."""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
@@ -120,7 +119,7 @@ class GiteaBoardCompat:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
|
||||
"""Sync un board Gitea vers une vraie collection.
|
||||
|
||||
Crée ou met à jour une collection liée à Gitea et importe les pages.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""FlowDeck — Agent context builder (v4.14.0).
|
||||
|
||||
Collects a compact, permission-filtered snapshot of the active workspace so the
|
||||
LLM can reason about real entities (workspaces, documents, collections, pages,
|
||||
Gitea issues) without touching the database directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the textual context that accompanies each agent run."""
|
||||
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
|
||||
def build(self, *, mentions: list[str] | None = None,
|
||||
files: list[dict] | None = None,
|
||||
include_collections: bool = True) -> str:
|
||||
"""Return a compact Markdown-ish snapshot of the workspace context."""
|
||||
sections: list[str] = []
|
||||
|
||||
if include_collections:
|
||||
sections.append(self._collections_context())
|
||||
sections.append(self._pages_context())
|
||||
sections.append(self._documents_context())
|
||||
sections.append(self._workspaces_context())
|
||||
if mentions:
|
||||
sections.append(self._mentions_context(mentions))
|
||||
if files:
|
||||
sections.append(self._files_context(files))
|
||||
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
# ── Internals ──
|
||||
|
||||
def _collections_context(self) -> str:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Collections\n(no collections yet)"
|
||||
lines = ["## Collections"]
|
||||
lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows))
|
||||
for r in rows:
|
||||
props = json.loads(r["schema_json"]) if r["schema_json"] else []
|
||||
schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none"
|
||||
lock = " [LOCKED]" if r["is_locked"] else ""
|
||||
lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _pages_context(self, limit: int = 40) -> str:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Pages\n(no pages yet)"
|
||||
lines = ["## Recent pages"]
|
||||
for r in rows:
|
||||
props = json.loads(r["property_values_json"]) if r["property_values_json"] else {}
|
||||
summary = ", ".join(str(v) for v in props.values() if v) if props else ""
|
||||
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _documents_context(self, limit: int = 30) -> str:
|
||||
"""Recent editor documents (`pages`), usable with create/read/update tools."""
|
||||
with get_conn() as conn:
|
||||
ws_names = dict(
|
||||
conn.execute("SELECT id, name FROM workspaces").fetchall()
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace_id, content_format, parent_id "
|
||||
"FROM pages WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Documents\n(aucun document)"
|
||||
lines = ["## Documents (pages éditeur — outils: read_document, write_blocks, create_document)"]
|
||||
for r in rows:
|
||||
ws_name = ws_names.get(r["workspace_id"], str(r["workspace_id"]) if r["workspace_id"] else "racine")
|
||||
lines.append(f"- document #{r['id']} **{r['title'] or 'Sans titre'}** (espace: {ws_name})")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _workspaces_context(self) -> str:
|
||||
"""Workspaces accessible to the current user (with counts)."""
|
||||
base_sql = (
|
||||
"SELECT w.id, w.name, {role} AS role, "
|
||||
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count "
|
||||
"FROM workspaces w {join} {where} ORDER BY w.name"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
if self.user_id is None:
|
||||
rows = conn.execute(
|
||||
base_sql.format(role="'owner'", join="", where=""), []
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
base_sql.format(
|
||||
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
|
||||
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
|
||||
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
|
||||
),
|
||||
(self.user_id, self.user_id, self.user_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Espaces de travail\n(aucun espace)"
|
||||
lines = ["## Espaces de travail (outil: read_workspaces)"]
|
||||
for r in rows:
|
||||
lines.append(f"- espace #{r['id']} **{r['name']}** ({r['role']}, {r['document_count']} document(s))")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
needing a read tool round-trip (and so the offline mock stays useful).
|
||||
"""
|
||||
lines = ["## Mentioned context"]
|
||||
for m in mentions:
|
||||
if m.startswith("document:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_document(pid))
|
||||
elif m.startswith("collection:"):
|
||||
cid = m.split(":", 1)[1]
|
||||
lines.append(self._single_collection(cid))
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
lines.append("- @ws: full workspace context included above")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _blocks_to_text(content: str, limit: int = 9000) -> str:
|
||||
"""Flatten a ``blocks`` document JSON into plain readable text.
|
||||
|
||||
Collects the textual payload of each block (content, title, caption,
|
||||
children, meeting notes/summary) — enough for the LLM to reason about a
|
||||
mentioned editor page without the full block schema.
|
||||
"""
|
||||
try:
|
||||
blocks = json.loads(content or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ""
|
||||
if not isinstance(blocks, list):
|
||||
return ""
|
||||
|
||||
_TEXT_KEYS = ("content", "title", "caption", "plain_text", "notes", "summary")
|
||||
out: list[str] = []
|
||||
total = 0
|
||||
|
||||
def walk(node):
|
||||
nonlocal total
|
||||
if total >= limit:
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for k in _TEXT_KEYS:
|
||||
v = node.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
line = v.replace("\r\n", "\n").strip()
|
||||
out.append(line)
|
||||
total += len(line) + 1
|
||||
if total >= limit:
|
||||
return
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(blocks)
|
||||
return "\n".join(out)[:limit]
|
||||
|
||||
def _single_document(self, pid: str) -> str:
|
||||
"""Full editor-document mention: title + workspace + real content."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT p.*, w.name AS ws_name FROM pages p "
|
||||
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
|
||||
"WHERE p.id=? AND p.deleted_at IS NULL",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- document #{pid}: not found"
|
||||
title = row["title"] or "Sans titre"
|
||||
ws = row["ws_name"] or ""
|
||||
loc = f" (espace: {ws})" if ws else ""
|
||||
fmt = row["content_format"] or "blocks"
|
||||
raw = row["content"] or ""
|
||||
if fmt == "markdown":
|
||||
body = raw.strip()
|
||||
elif fmt == "file":
|
||||
body = ""
|
||||
else:
|
||||
body = self._blocks_to_text(raw)
|
||||
head = f"- document #{row['id']} **{title}**{loc}"
|
||||
if body:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
return f"- collection #{cid}: not found"
|
||||
return f"- collection #{row['id']} {row['icon']} **{row['name']}**"
|
||||
|
||||
def _single_page(self, pid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
if not row:
|
||||
return f"- page #{pid}: not found"
|
||||
props = json.loads(row["property_values_json"]) if row["property_values_json"] else {}
|
||||
return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}"
|
||||
|
||||
def _files_context(self, files: list[dict]) -> str:
|
||||
return "## Attached files\n" + "\n".join(
|
||||
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""FlowDeck — Database templates (v5.3.0).
|
||||
|
||||
Defines the built-in (seeded) database templates, materializes a template's
|
||||
schema into real ``collection_properties`` rows, and creates a collection from
|
||||
a template. Templates are stored in ``database_templates`` (name, icon,
|
||||
description, schema_json).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
# ── Built-in templates ──
|
||||
# Each schema entry: {"name", "type", "options"?: [{name, color}]}.
|
||||
SEED_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "Project tracker",
|
||||
"icon": "🚀",
|
||||
"description": "Suivi de projets avec statut, priorité et échéances.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Not started", "color": "gray"},
|
||||
{"name": "In progress", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "Low", "color": "gray"},
|
||||
{"name": "Medium", "color": "yellow"},
|
||||
{"name": "High", "color": "orange"},
|
||||
{"name": "Urgent", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "CRM / Contacts",
|
||||
"icon": "👥",
|
||||
"description": "Gestion des contacts et prospects.",
|
||||
"schema": [
|
||||
{"name": "Name", "type": "title"},
|
||||
{"name": "Email", "type": "email"},
|
||||
{"name": "Phone", "type": "phone"},
|
||||
{"name": "Company", "type": "text"},
|
||||
{"name": "Stage", "type": "status", "options": [
|
||||
{"name": "Lead", "color": "gray"},
|
||||
{"name": "Prospect", "color": "blue"},
|
||||
{"name": "Customer", "color": "green"},
|
||||
]},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Task list",
|
||||
"icon": "✅",
|
||||
"description": "Liste de tâches simple avec assignation et échéance.",
|
||||
"schema": [
|
||||
{"name": "Task", "type": "title"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "To do", "color": "gray"},
|
||||
{"name": "In progress", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "Low", "color": "gray"},
|
||||
{"name": "Medium", "color": "yellow"},
|
||||
{"name": "High", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Content calendar",
|
||||
"icon": "📅",
|
||||
"description": "Planification de contenu et de publications.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Type", "type": "select", "options": [
|
||||
{"name": "Article", "color": "blue"},
|
||||
{"name": "Video", "color": "orange"},
|
||||
{"name": "Social", "color": "green"},
|
||||
{"name": "Newsletter", "color": "purple"},
|
||||
]},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Draft", "color": "gray"},
|
||||
{"name": "In review", "color": "yellow"},
|
||||
{"name": "Published", "color": "green"},
|
||||
]},
|
||||
{"name": "Publish date", "type": "date"},
|
||||
{"name": "Category", "type": "select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Notes de réunion avec participants et décisions.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Date", "type": "date"},
|
||||
{"name": "Attendees", "type": "person"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Scheduled", "color": "gray"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Action items", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Reading list",
|
||||
"icon": "📚",
|
||||
"description": "Articles, livres et ressources à lire.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "To read", "color": "gray"},
|
||||
{"name": "Reading", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Notes", "type": "text"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def materialize_properties(conn, collection_id: int, schema: list) -> None:
|
||||
"""Insert ``collection_properties`` rows from a template ``schema``.
|
||||
|
||||
The ``title`` property is represented by ``collection_pages.title`` and is
|
||||
not created as a column. Rows are inserted in schema order.
|
||||
"""
|
||||
position = 0
|
||||
for prop in schema:
|
||||
name = (prop.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
prop_type = prop.get("type", "text")
|
||||
if prop_type == "title":
|
||||
continue
|
||||
options = prop.get("options") or []
|
||||
# idempotency guard — skip if a same-named property already exists
|
||||
exists = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(collection_id, name),
|
||||
).fetchone()
|
||||
if exists:
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format, position)
|
||||
VALUES (?, ?, ?, ?, 'number', ?)""",
|
||||
(collection_id, name, prop_type, json.dumps(options), position),
|
||||
)
|
||||
position += 1
|
||||
|
||||
|
||||
def create_from_template(
|
||||
conn,
|
||||
name: str,
|
||||
template: dict,
|
||||
*,
|
||||
parent_page_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
) -> int:
|
||||
"""Create a collection from a template (with properties + default view).
|
||||
|
||||
``template`` may be a DB row (sqlite Row) or a dict; it must expose
|
||||
``icon``, ``description`` and ``schema_json`` (JSON-encoded schema).
|
||||
"""
|
||||
icon = template.get("icon") if isinstance(template, dict) else template["icon"]
|
||||
description = template.get("description") if isinstance(template, dict) else template["description"]
|
||||
schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"]
|
||||
try:
|
||||
schema = json.loads(schema_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)""",
|
||||
(name, description or "", icon or "📋", json.dumps(schema),
|
||||
parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
return collection_id
|
||||
@@ -0,0 +1,278 @@
|
||||
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
|
||||
|
||||
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google
|
||||
Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch,
|
||||
X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal
|
||||
embed.
|
||||
|
||||
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
|
||||
fall back to a plain iframe so the link is still visible inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
|
||||
def _q(params, key):
|
||||
vals = params.get(key)
|
||||
return vals[0] if vals else ""
|
||||
|
||||
|
||||
def _host_matches(netloc: str, host: str) -> bool:
|
||||
"""True when ``netloc`` is ``host`` or one of its subdomains."""
|
||||
netloc = (netloc or "").lower().split(":")[0]
|
||||
host = host.lower()
|
||||
return netloc == host or netloc.endswith("." + host)
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(1) if m else _q(params, "v")
|
||||
if not vid:
|
||||
# youtu.be/<id> (short link) — the id is the first path segment.
|
||||
seg = path.strip("/").split("/")[0]
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""):
|
||||
vid = seg
|
||||
if not vid:
|
||||
return None
|
||||
start = _q(params, "t") or _q(params, "start")
|
||||
frag = f"?start={start}" if start else ""
|
||||
return f"https://www.youtube.com/embed/{vid}{frag}"
|
||||
|
||||
|
||||
def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(\d{6,12})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://player.vimeo.com/video/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://www.loom.com/embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
clean = url.split("?", 1)[0]
|
||||
if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean:
|
||||
return None
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
|
||||
|
||||
|
||||
def _embed_map(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
|
||||
return None
|
||||
return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
|
||||
|
||||
|
||||
def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(
|
||||
r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
kind, doc_id = m.group(1), m.group(2)
|
||||
if kind == "forms":
|
||||
return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true"
|
||||
return f"https://docs.google.com/{kind}/d/{doc_id}/preview"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
|
||||
|
||||
|
||||
def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://miro.com/app/live-embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
|
||||
|
||||
|
||||
def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "soundcloud.com" not in url:
|
||||
return None
|
||||
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
|
||||
|
||||
|
||||
def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitch.tv" not in url:
|
||||
return None
|
||||
parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0]
|
||||
video = re.search(r"twitch\.tv/videos/(\d+)", url)
|
||||
if video:
|
||||
return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}"
|
||||
m = re.search(r"twitch\.tv/([^/?#]+)", url)
|
||||
if not m or m.group(1) in ("videos", "directory"):
|
||||
return None
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
|
||||
|
||||
|
||||
def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitter.com" not in url and "x.com" not in url:
|
||||
return None
|
||||
m = re.search(r"/status(?:es)?/(\d+)", url)
|
||||
if not m:
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}"
|
||||
|
||||
|
||||
def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "pinterest" not in url:
|
||||
return None
|
||||
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
|
||||
|
||||
|
||||
def _embed_office(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
low = url.lower().split("?", 1)[0]
|
||||
if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")):
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low:
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
return None
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
|
||||
return url
|
||||
|
||||
|
||||
# (host, handler) — order matters: more specific hosts first.
|
||||
_HANDLERS = (
|
||||
("youtube.com", _embed_youtube),
|
||||
("youtu.be", _embed_youtube),
|
||||
("vimeo.com", _embed_vimeo),
|
||||
("loom.com", _embed_loom),
|
||||
("figma.com", _embed_figma),
|
||||
("docs.google.com", _embed_gdocs),
|
||||
("google.com/maps", _embed_map),
|
||||
("maps.app.goo.gl", _embed_map),
|
||||
("codepen.io", _embed_codepen),
|
||||
("miro.com", _embed_miro),
|
||||
("open.spotify.com", _embed_spotify),
|
||||
("spotify.com", _embed_spotify),
|
||||
("soundcloud.com", _embed_soundcloud),
|
||||
("twitch.tv", _embed_twitch),
|
||||
("twitter.com", _embed_twitter),
|
||||
("x.com", _embed_twitter),
|
||||
("pinterest.", _embed_pinterest),
|
||||
("office.com", _embed_office),
|
||||
("officeapps.live.com", _embed_office),
|
||||
("sharepoint.com", _embed_office),
|
||||
("1drv.ms", _embed_office),
|
||||
)
|
||||
|
||||
|
||||
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
|
||||
|
||||
|
||||
def _parse(url: str):
|
||||
raw = url.strip()
|
||||
if not raw:
|
||||
return None, None, None
|
||||
m = _SCHEME_RE.match(raw)
|
||||
if m:
|
||||
if m.group(1).lower() not in ("http", "https"):
|
||||
return None, None, None # mailto:, tel:, javascript:, data:…
|
||||
else:
|
||||
raw = "https://" + raw
|
||||
u = urlparse(raw)
|
||||
if u.scheme not in ("http", "https") or not u.netloc:
|
||||
return None, None, None
|
||||
host = u.hostname or ""
|
||||
if "." not in host and host != "localhost":
|
||||
return None, None, None # a bare word is not a URL
|
||||
return raw, u, parse_qs(u.query)
|
||||
|
||||
|
||||
def embed_src(url: str, *, parent: str = "") -> str | None:
|
||||
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
|
||||
raw, u, params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
ctx = {"parent": parent}
|
||||
netloc = (u.netloc or "").lower()
|
||||
for needle, handler in _HANDLERS:
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return handler(raw, u.path, params, ctx)
|
||||
elif _host_matches(netloc, needle):
|
||||
return handler(raw, u.path, params, ctx)
|
||||
# Office documents hosted on arbitrary domains.
|
||||
office = _embed_office(raw, u.path, params, ctx)
|
||||
if office:
|
||||
return office
|
||||
return _embed_files(raw, u.path, params, ctx)
|
||||
|
||||
|
||||
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
|
||||
_PDF_EXT = re.compile(r"\.pdf$", re.I)
|
||||
_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I)
|
||||
_AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
|
||||
|
||||
|
||||
def inline_kind(url: str) -> str | None:
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video'
|
||||
| 'audio'. Returns None when the URL should open in a new tab."""
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
path = u.path or ""
|
||||
if _IMAGE_EXT.search(path):
|
||||
return "image"
|
||||
if _PDF_EXT.search(path):
|
||||
return "pdf"
|
||||
if _VIDEO_EXT.search(path):
|
||||
return "video"
|
||||
if _AUDIO_EXT.search(path):
|
||||
return "audio"
|
||||
return "iframe"
|
||||
|
||||
|
||||
def provider(url: str) -> str:
|
||||
"""Human-readable provider name for a URL (used by the editor)."""
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return ""
|
||||
netloc = (u.netloc or "").lower()
|
||||
for needle, _handler in _HANDLERS:
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return needle.split(".")[0].rstrip(".")
|
||||
elif _host_matches(netloc, needle):
|
||||
name = needle.split(".")[0]
|
||||
return "youtube" if name == "youtu" else name
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_embed(url: str, *, parent: str = "") -> dict:
|
||||
"""Resolve a URL to ``{src, kind, provider}`` for the generic embed block."""
|
||||
kind = inline_kind(url)
|
||||
return {
|
||||
"src": embed_src(url, parent=parent) or "",
|
||||
"kind": kind or "",
|
||||
"provider": provider(url),
|
||||
}
|
||||
|
||||
|
||||
def embed_html(src: str, *, height: int = 520) -> str:
|
||||
"""A responsive, borderless iframe for a provider embed URL."""
|
||||
return (
|
||||
f'<iframe src="{src}" loading="lazy" '
|
||||
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
|
||||
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
|
||||
f'picture-in-picture" allowfullscreen></iframe>'
|
||||
)
|
||||
@@ -0,0 +1,861 @@
|
||||
"""FlowDeck — Export service (v4.7.2).
|
||||
|
||||
Four types of export, all generated server-side:
|
||||
- Markdown (``page_to_markdown``): title + blocks + récursif sous-pages
|
||||
- HTML (``page_to_standalone_html``): document autonome (styles inline)
|
||||
- PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly
|
||||
- Site (``build_static_site``): site statique multi-pages (zip)
|
||||
|
||||
Supports the three ways a page's content can be stored:
|
||||
- content_format == "blocks" -> JSON list of blocks in ``content``
|
||||
- content_format == "markdown" -> raw Markdown in ``content``
|
||||
- content_format == "file" -> ``content`` is JSON metadata; the real text
|
||||
lives in an uploaded file on disk (uploads/workspace_*). We read it back so
|
||||
an exported document carries its actual content, not just its title.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
# ═══════════════ Helpers ═══════════════
|
||||
|
||||
def _text(v: str, *, escape: bool = True) -> str:
|
||||
"""Normalize a block's content string."""
|
||||
s = (v or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
if escape:
|
||||
s = (s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">"))
|
||||
return s
|
||||
|
||||
|
||||
def _sanitize_id(block_id) -> str:
|
||||
if not block_id:
|
||||
return ""
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
def _page_title(page: dict) -> str:
|
||||
return (page.get("title") or "Untitled").strip() or "Untitled"
|
||||
|
||||
|
||||
def _blocks_of(page: dict) -> list:
|
||||
content = page.get("content") or ""
|
||||
fmt = page.get("content_format") or "blocks"
|
||||
if fmt != "blocks" or not content:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _block_text(b: dict) -> str:
|
||||
return _text(b.get("content"), escape=False)
|
||||
|
||||
|
||||
# ── Source resolution: read real textual content for ANY page type ──
|
||||
|
||||
# Extensions whose content is plain text / code / markdown (textual exportable).
|
||||
_TEXTUAL_EXTS = {
|
||||
"md", "markdown", "txt", "log", "text",
|
||||
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
||||
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
||||
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
||||
"properties", "gitignore", "dockerfile", "makefile",
|
||||
}
|
||||
_CODE_LANG = {
|
||||
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
||||
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
||||
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
||||
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
||||
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
||||
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
||||
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
||||
"astro": "html", "properties": "ini", "md": "markdown",
|
||||
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
||||
"text": "plaintext",
|
||||
}
|
||||
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
|
||||
|
||||
|
||||
def _data_root() -> Path:
|
||||
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _file_meta(page: dict) -> dict:
|
||||
try:
|
||||
meta = json.loads(page.get("content") or "{}")
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _file_text(page: dict) -> str | None:
|
||||
"""Return the textual content of an uploaded ``file`` page, or None.
|
||||
|
||||
Only reads plain-text / code / markdown files. Binary (PDF, images…)
|
||||
returns None and is skipped by exporters (nothing meaningful to include).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
meta = _file_meta(page)
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"):
|
||||
return None
|
||||
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
|
||||
return None
|
||||
try:
|
||||
full = (_data_root() / rel).resolve()
|
||||
root = _data_root().resolve()
|
||||
if root not in full.parents:
|
||||
return None
|
||||
return full.read_text(encoding="utf-8", errors="replace")
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _page_source(page: dict):
|
||||
"""Return (kind, payload) describing where the page's real content lives.
|
||||
|
||||
kind ∈ {"blocks", "md", "code"}:
|
||||
- "blocks": payload is the block list (block editor pages)
|
||||
- "md" : payload is raw Markdown text
|
||||
- "code" : payload is (text, language)
|
||||
An empty/unsupported page yields ("blocks", []).
|
||||
"""
|
||||
fmt = (page.get("content_format") or "blocks")
|
||||
content = page.get("content") or ""
|
||||
|
||||
if fmt == "blocks":
|
||||
return "blocks", _blocks_of(page)
|
||||
|
||||
if fmt == "markdown":
|
||||
if content.strip():
|
||||
return "md", content
|
||||
return "blocks", []
|
||||
|
||||
if fmt == "file":
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return "blocks", []
|
||||
meta = _file_meta(page)
|
||||
name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||
return "md", text
|
||||
lang = _CODE_LANG.get(ext, "plaintext")
|
||||
return "code", (text, lang)
|
||||
|
||||
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||
if content.strip():
|
||||
return "md", content
|
||||
return "blocks", []
|
||||
|
||||
|
||||
# ── GFM pipe-table parsing (raw markdown → "table" block) ──
|
||||
|
||||
_SEP_CELL = re.compile(r"^:?-+:?$")
|
||||
|
||||
|
||||
def _split_pipe_cells(line: str) -> list[str]:
|
||||
"""Split a GFM pipe row into trimmed cell strings."""
|
||||
s = line.strip()
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|") and not s.endswith(r"\|"):
|
||||
s = s[:-1]
|
||||
# split on unescaped pipes
|
||||
cells: list[str] = []
|
||||
cur: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
ch = s[i]
|
||||
if ch == "\\" and i + 1 < len(s) and s[i + 1] == "|":
|
||||
cur.append("|")
|
||||
i += 2
|
||||
continue
|
||||
if ch == "|":
|
||||
cells.append("".join(cur).strip())
|
||||
cur = []
|
||||
i += 1
|
||||
continue
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
cells.append("".join(cur).strip())
|
||||
return cells
|
||||
|
||||
|
||||
def _is_table_delimiter(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
return False
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
cells = [c.strip() for c in s.split("|")]
|
||||
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
|
||||
|
||||
|
||||
def _parse_table_at(lines: list[str], i: int, n: int):
|
||||
"""If a GFM table starts at index i (header row + delimiter row), return
|
||||
(table_block, next_index). Otherwise return None."""
|
||||
header_cells = _split_pipe_cells(lines[i])
|
||||
if len(header_cells) <= 1:
|
||||
return None
|
||||
if i + 1 >= n or not _is_table_delimiter(lines[i + 1]):
|
||||
return None
|
||||
sep_cells = _split_pipe_cells(lines[i + 1])
|
||||
align = []
|
||||
for c in sep_cells[: len(header_cells)]:
|
||||
c = c.strip()
|
||||
if c.startswith(":") and c.endswith(":"):
|
||||
align.append("center")
|
||||
elif c.endswith(":"):
|
||||
align.append("right")
|
||||
else:
|
||||
align.append("left")
|
||||
rows = [header_cells]
|
||||
j = i + 2
|
||||
while j < n:
|
||||
s = lines[j].strip()
|
||||
if not s or not s.startswith("|"):
|
||||
break
|
||||
cells = _split_pipe_cells(lines[j])
|
||||
rows.append(cells)
|
||||
j += 1
|
||||
width = max(len(r) for r in rows)
|
||||
def pad(r):
|
||||
return r + [""] * (width - len(r))
|
||||
align = (align + ["left"] * width)[:width]
|
||||
return (
|
||||
{
|
||||
"type": "table",
|
||||
"has_header": True,
|
||||
"align": align,
|
||||
"rows": [pad(r) for r in rows],
|
||||
},
|
||||
j,
|
||||
)
|
||||
|
||||
|
||||
def _table_to_markdown(b: dict) -> str:
|
||||
rows = b.get("rows") or []
|
||||
if not rows:
|
||||
return ""
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
has_header = b.get("has_header", True)
|
||||
out: list[str] = []
|
||||
|
||||
def rowline(r):
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
start = 0
|
||||
if has_header:
|
||||
out.append(rowline(rows[0]))
|
||||
seps = []
|
||||
for a in align:
|
||||
if a == "center":
|
||||
seps.append(":---:")
|
||||
elif a == "right":
|
||||
seps.append("---:")
|
||||
else:
|
||||
seps.append(":---")
|
||||
out.append("| " + " | ".join(seps) + " |")
|
||||
start = 1
|
||||
for ri in range(start, len(rows)):
|
||||
out.append(rowline(rows[ri]))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _table_to_html(b: dict) -> str:
|
||||
rows = b.get("rows") or []
|
||||
if not rows:
|
||||
return ""
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
|
||||
def cell_html(tag, text, a):
|
||||
style = f' style="text-align:{a};"' if a and a != "left" else ""
|
||||
return f"<{tag}{style}>{_text(text)}</{tag}>"
|
||||
|
||||
has_header = b.get("has_header", True)
|
||||
first_col = b.get("first_col_header", False)
|
||||
header_rows = 1 if has_header else 0
|
||||
head = ""
|
||||
if header_rows:
|
||||
head_rows = []
|
||||
hr = rows[0]
|
||||
cells = list(hr) + [""] * (width - len(hr))
|
||||
head_cells = []
|
||||
for ci, c in enumerate(cells):
|
||||
tag = "th" if first_col and ci == 0 else "th"
|
||||
head_cells.append(cell_html(tag, c, align[ci]))
|
||||
head_rows.append("<tr>" + "".join(head_cells) + "</tr>")
|
||||
head = "<thead>" + "".join(head_rows) + "</thead>"
|
||||
tbody_rows = rows[header_rows:]
|
||||
body_rows = []
|
||||
for r in tbody_rows:
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
row_cells = []
|
||||
for ci, c in enumerate(cells):
|
||||
tag = "th" if first_col and ci == 0 else "td"
|
||||
row_cells.append(cell_html(tag, c, align[ci]))
|
||||
body_rows.append("<tr>" + "".join(row_cells) + "</tr>")
|
||||
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
||||
return f'<table class="ftable">{head}{body}</table>'
|
||||
|
||||
|
||||
# ── Markdown renderer (raw markdown → exportable fragments) ──
|
||||
|
||||
def _md_to_blocks(md: str) -> list:
|
||||
"""Convert raw Markdown text into the same lightweight block list the
|
||||
editor produces (headings, lists, to-do, quote, code, divider, paragraph).
|
||||
|
||||
Kept intentionally simple: inline formatting (bold/links) is preserved as
|
||||
literal text, matching how the block editor treats imported .md files.
|
||||
"""
|
||||
blocks: list = []
|
||||
buf = md.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = buf.split("\n")
|
||||
i = 0
|
||||
n = len(lines)
|
||||
para: list[str] = []
|
||||
|
||||
def flush_para():
|
||||
nonlocal para
|
||||
if para:
|
||||
blocks.append({"type": "paragraph", "content": "\n".join(para).strip()})
|
||||
para = []
|
||||
|
||||
while i < n:
|
||||
line = lines[i].rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
flush_para()
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("```") or stripped.startswith("~~~"):
|
||||
flush_para()
|
||||
fence = stripped[0:3]
|
||||
lang = stripped[3:].strip()
|
||||
i += 1
|
||||
code: list[str] = []
|
||||
while i < n and not lines[i].strip().startswith(fence):
|
||||
code.append(lines[i])
|
||||
i += 1
|
||||
if i < n:
|
||||
i += 1 # closing fence
|
||||
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
|
||||
continue
|
||||
if stripped.startswith("|"):
|
||||
# GFM pipe table: header row immediately followed by a delimiter row
|
||||
parsed = _parse_table_at(lines, i, n)
|
||||
if parsed is not None:
|
||||
flush_para()
|
||||
tbl, i = parsed
|
||||
blocks.append(tbl)
|
||||
continue
|
||||
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
||||
if m and line == stripped: # ATX heading must be whole line
|
||||
level = len(m.group(1))
|
||||
flush_para()
|
||||
blocks.append({"type": f"heading_{min(level, 4)}", "content": m.group(2).strip()})
|
||||
i += 1
|
||||
continue
|
||||
if stripped == "---" or stripped == "***" or stripped == "___":
|
||||
flush_para()
|
||||
blocks.append({"type": "divider", "content": ""})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*\d+[.)]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^\d+[.)]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({"type": "numbered_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
mtodo = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", stripped)
|
||||
if mtodo:
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({
|
||||
"type": "to_do",
|
||||
"content": m2.group(2).strip(),
|
||||
"checked": m2.group(1).lower() == "x",
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
mq = re.match(r"^>\s?(.*)$", stripped)
|
||||
if mq and line == stripped:
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^>\s?(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
para.append(m2.group(1))
|
||||
i += 1
|
||||
blocks.append({"type": "quote", "content": "\n".join(para)})
|
||||
para = []
|
||||
continue
|
||||
para.append(stripped)
|
||||
i += 1
|
||||
flush_para()
|
||||
return blocks
|
||||
|
||||
|
||||
def _page_blocks(page: dict) -> list:
|
||||
"""Blocks used for HTML/PDF rendering regardless of storage format."""
|
||||
kind, payload = _page_source(page)
|
||||
if kind == "blocks":
|
||||
return payload
|
||||
if kind == "code":
|
||||
text, lang = payload
|
||||
return [{"type": "code", "content": text, "language": lang}] if text else []
|
||||
if kind == "md":
|
||||
return _md_to_blocks(payload)
|
||||
return []
|
||||
|
||||
|
||||
def _page_markdown_source(page: dict) -> str:
|
||||
"""Raw markdown when the page IS markdown-sourced, else empty string."""
|
||||
kind, payload = _page_source(page)
|
||||
if kind == "md":
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def markdown_to_blocks(md: str) -> list:
|
||||
"""Public wrapper around the GFM→blocks parser (used by page import)."""
|
||||
return _md_to_blocks(md)
|
||||
|
||||
|
||||
# ═══════════════ Markdown ═══════════════
|
||||
|
||||
def blocks_to_markdown(blocks: list) -> str:
|
||||
"""Convert a block array to Markdown (server-side, all block types)."""
|
||||
out: list[str] = []
|
||||
for b in blocks or []:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _block_text(b)
|
||||
if t == "heading_1":
|
||||
out.append(f"# {c}")
|
||||
elif t == "heading_2":
|
||||
out.append(f"## {c}")
|
||||
elif t == "heading_3":
|
||||
out.append(f"### {c}")
|
||||
elif t == "heading_4":
|
||||
out.append(f"#### {c}")
|
||||
elif t == "bulleted_list":
|
||||
out.append(f"- {c}")
|
||||
elif t == "numbered_list":
|
||||
out.append(f"1. {c}")
|
||||
elif t == "to_do":
|
||||
out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}")
|
||||
elif t == "quote":
|
||||
out.append(f"> {c}")
|
||||
elif t == "divider":
|
||||
out.append("---")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
out.append(f"```{lang}\n{c}\n```")
|
||||
elif t == "toggle":
|
||||
out.append(f"### {c}")
|
||||
if b.get("children"):
|
||||
out.append(blocks_to_markdown(b["children"]))
|
||||
elif t == "math":
|
||||
out.append(f"$$\n{c}\n$$")
|
||||
elif t == "table_of_contents":
|
||||
out.append("[TOC]")
|
||||
elif t == "columns":
|
||||
for child in b.get("children") or []:
|
||||
out.append(blocks_to_markdown([child]))
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = (b.get("alt") or "").strip() or "image"
|
||||
out.append(f"")
|
||||
elif t == "video":
|
||||
out.append(f"[Video]({b.get('src') or ''})")
|
||||
elif t == "audio":
|
||||
out.append(f"[Audio]({b.get('src') or ''})")
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = (b.get("title") or "").strip()
|
||||
out.append(f"[{title or url}]({url})" if title else url)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
if b.get("embed_type") in ("pdf", "download", None, ""):
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
else:
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
else:
|
||||
out.append(c)
|
||||
return "\n\n".join(filter(None, out))
|
||||
|
||||
|
||||
def _child_pages(page: dict) -> list:
|
||||
"""Immediate non-deleted children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY COALESCE(sort_order, created_at) ASC, id ASC",
|
||||
(page["id"],),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
|
||||
"""Markdown for a single page, with optional sub-pages appended."""
|
||||
parts = [f"# {_page_title(page)}", ""]
|
||||
md_source = _page_markdown_source(page)
|
||||
if md_source:
|
||||
parts.append(md_source.strip())
|
||||
else:
|
||||
md = blocks_to_markdown(_page_blocks(page))
|
||||
if md:
|
||||
parts.append(md)
|
||||
md = "\n\n".join(filter(None, parts)).rstrip()
|
||||
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_md = page_to_markdown(sub, include_children=True)
|
||||
if sub_md:
|
||||
md += f"\n\n---\n\n{sub_md}"
|
||||
return md
|
||||
|
||||
|
||||
# ═══════════════ HTML ═══════════════
|
||||
|
||||
def blocks_to_html(blocks: list) -> str:
|
||||
"""Convert a block array to a self-contained HTML fragment."""
|
||||
parts: list[str] = []
|
||||
for b in blocks or []:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _text(b.get("content"))
|
||||
if t == "heading_1":
|
||||
parts.append(f'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "numbered_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else ""
|
||||
parts.append(
|
||||
f'<div class="todo"><input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{style}">{c}</span></div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children = blocks_to_html(b.get("children") or [])
|
||||
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
|
||||
elif t == "quote":
|
||||
parts.append(f"<blockquote>{c}</blockquote>")
|
||||
elif t == "divider":
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
toc = [x for x in (blocks or [])
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()]
|
||||
if toc:
|
||||
items = "".join(
|
||||
f'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
|
||||
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
|
||||
for x in toc
|
||||
)
|
||||
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
|
||||
elif t == "columns":
|
||||
cols = "".join(
|
||||
f'<div class="column">{blocks_to_html([child])}</div>'
|
||||
for child in (b.get("children") or [])
|
||||
)
|
||||
parts.append(f'<div class="columns">{cols}</div>')
|
||||
elif t == "callout":
|
||||
icon = b.get("icon") or "💡"
|
||||
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
|
||||
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = _text(b.get("alt"))
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
|
||||
elif t == "video":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
|
||||
elif t == "audio":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = _text(b.get("title")) or url
|
||||
desc = _text(b.get("description"))
|
||||
img = b.get("image") or ""
|
||||
site = _text(b.get("site_name")) or ""
|
||||
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
parts.append(
|
||||
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
emb = (b.get("embed_type") or "")
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
parts.append('<div class="embed-note">[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
|
||||
elif emb == "pdf" and url:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = 520
|
||||
if b.get("height"):
|
||||
try:
|
||||
height = int(b["height"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "table":
|
||||
parts.append(_table_to_html(b))
|
||||
else:
|
||||
parts.append(f"<p>{c}</p>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _standalone_css() -> str:
|
||||
return """
|
||||
:root{color-scheme:light;}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;}
|
||||
.wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;}
|
||||
h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;}
|
||||
h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;}
|
||||
h3{font-size:1.35rem;margin:24px 0 8px;}
|
||||
h4{font-size:1.1rem;margin:20px 0 6px;}
|
||||
p{margin:8px 0;}
|
||||
li{margin:4px 0;}
|
||||
ol{list-style:decimal;padding-left:24px;}
|
||||
ul{list-style:disc;padding-left:24px;}
|
||||
blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;}
|
||||
hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;}
|
||||
pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;}
|
||||
code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;}
|
||||
pre code{background:none;padding:0;font-size:13px;}
|
||||
.code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;}
|
||||
details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;}
|
||||
details summary{cursor:pointer;font-weight:600;}
|
||||
details[open] summary{margin-bottom:8px;}
|
||||
.todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;}
|
||||
.todo input{margin-top:5px;}
|
||||
.toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;}
|
||||
.toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;}
|
||||
.toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;}
|
||||
.columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;}
|
||||
.column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;}
|
||||
.callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;}
|
||||
.callout>span{font-size:20px;flex-shrink:0;}
|
||||
.math{margin:14px 0;overflow-x:auto;}
|
||||
figure{margin:16px 0;text-align:center;}
|
||||
figure img{max-width:100%;border-radius:8px;}
|
||||
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
|
||||
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;}
|
||||
.ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;}
|
||||
.ftable th{background:#f6f8fa;font-weight:600;}
|
||||
.ftable tr:nth-child(even) td{background:#fcfcfd;}
|
||||
.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;}
|
||||
a{color:#0969da;}
|
||||
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
|
||||
"""
|
||||
|
||||
|
||||
def page_to_standalone_html(
|
||||
page: dict,
|
||||
*,
|
||||
include_children: bool = True,
|
||||
base_url: str = "",
|
||||
) -> str:
|
||||
"""Return a standalone, self-contained HTML document for a page."""
|
||||
title = _page_title(page)
|
||||
body = blocks_to_html(_page_blocks(page))
|
||||
|
||||
meta_updated = page.get("updated_at") or ""
|
||||
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
|
||||
|
||||
sub_html = ""
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
|
||||
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
|
||||
sub_html += "</div>"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{_text(title)}</title>
|
||||
<style>{_standalone_css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
{sub_html}
|
||||
{footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ═══════════════ PDF ═══════════════
|
||||
|
||||
def _pdf_html(page: dict) -> str:
|
||||
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
|
||||
title = _page_title(page)
|
||||
body = blocks_to_html(_page_blocks(page))
|
||||
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
|
||||
<style>
|
||||
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
|
||||
h1{{font-size:26px;margin:0 0 10px;}}
|
||||
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
|
||||
h3{{font-size:16px;margin:18px 0 6px;}}
|
||||
h4{{font-size:14px;margin:14px 0 4px;}}
|
||||
p,li{{margin:4px 0;}}
|
||||
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
|
||||
code{{font-family:monospace;font-size:10px;}}
|
||||
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
||||
table{{border-collapse:collapse;width:100%;}}
|
||||
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
|
||||
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
|
||||
.ftable th{{background:#f0f0f0;font-weight:bold;}}
|
||||
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||
.todo{{margin:4px 0;}}
|
||||
.math{{font-style:italic;margin:10px 0;}}
|
||||
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
|
||||
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
|
||||
</style></head><body>
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def page_to_pdf_bytes(page: dict) -> bytes:
|
||||
"""Render a page to a PDF.
|
||||
|
||||
Primary engine: WeasyPrint — renders colour emoji and proper CSS tables
|
||||
(needs system libs: pango + fonts; available in the Docker image).
|
||||
Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are
|
||||
absent (e.g. a Windows dev host) — text/table content still exports,
|
||||
though emoji are limited to monochrome by the engine.
|
||||
"""
|
||||
# 1) WeasyPrint (best fidelity: colour emoji, CSS tables)
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html = page_to_standalone_html(page, include_children=False)
|
||||
return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf()
|
||||
except Exception: # ImportError or missing native libs (OSError) -> fallback
|
||||
pass
|
||||
# 2) xhtml2pdf fallback (pure Python)
|
||||
from xhtml2pdf import pisa
|
||||
|
||||
src = _pdf_html(page)
|
||||
buf = io.BytesIO()
|
||||
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
||||
if pdf.err:
|
||||
raise RuntimeError(f"PDF generation failed: {pdf.err}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════ Static site (zip) ═══════════════
|
||||
|
||||
def _site_index_html(pages: list[dict]) -> str:
|
||||
"""Build the index.html of the static site (list of all pages)."""
|
||||
def link(p: dict) -> str:
|
||||
title = _page_title(p)
|
||||
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
|
||||
|
||||
items = "".join(link(p) for p in pages)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<title>FlowDeck Site</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
|
||||
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
|
||||
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
|
||||
|
||||
|
||||
def build_static_site_bytes(root_page: dict) -> bytes:
|
||||
"""Build a full static site as a zip: index.html + one HTML file per page."""
|
||||
pages = [root_page] + _child_pages(root_page)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("index.html", _site_index_html(pages))
|
||||
for p in pages:
|
||||
title = _page_title(p)
|
||||
name = f"{quote(title, safe='')}.html"
|
||||
z.writestr(name, page_to_standalone_html(p, include_children=False))
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""FlowDeck — v5.2.0 Forge abstraction (Gitea / GitHub).
|
||||
|
||||
``ForgeAdapter`` defines the minimal contract a forge client must expose for the
|
||||
``projects`` table sync and the issue/board integration. ``GiteaAdapter`` wraps
|
||||
the existing ``GiteaClient``; ``GitHubAdapter`` (in ``github_adapter.py``) is the
|
||||
GitHub implementation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class ForgeAdapter(ABC):
|
||||
"""Common forge API surface used by FlowDeck (v5.2.0)."""
|
||||
|
||||
kind = "base"
|
||||
|
||||
@abstractmethod
|
||||
async def validate_token(self) -> bool:
|
||||
"""True when the stored credentials still work."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_repos(self, page: int = 1) -> list[dict]:
|
||||
"""List repositories for the authenticated user."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
"""Repository metadata (default_branch, clone_url, language, …)."""
|
||||
|
||||
|
||||
def normalize_repo(repo: dict, proj_type: str) -> dict:
|
||||
"""Project a forge repo dict onto the ``projects`` table columns."""
|
||||
full_name = repo.get("full_name", "") or repo.get("fullName", "")
|
||||
owner, _, name = full_name.partition("/")
|
||||
return {
|
||||
"name": name or repo.get("name", ""),
|
||||
"owner": owner or repo.get("owner", {}).get("login", "") if isinstance(repo.get("owner"), dict) else (owner or ""),
|
||||
"proj_type": proj_type,
|
||||
"forge_id": str(repo.get("id", "") or ""),
|
||||
"clone_url": repo.get("clone_url", "") or repo.get("ssh_url", ""),
|
||||
"default_branch": repo.get("default_branch", ""),
|
||||
"language": repo.get("language", ""),
|
||||
"description": (repo.get("description") or "") or "",
|
||||
}
|
||||
|
||||
|
||||
class GiteaAdapter(ForgeAdapter):
|
||||
"""Adapt the existing GiteaClient to the ForgeAdapter contract."""
|
||||
|
||||
kind = "gitea"
|
||||
|
||||
def __init__(self, client) -> None: # client = GiteaClient instance
|
||||
self._client = client
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
try:
|
||||
await self._client.get_user_repos(page=1, limit=1)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def list_repos(self, page: int = 1) -> list[dict]:
|
||||
return await self._client.get_user_repos(page=page, limit=30)
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
async with __import__("httpx").AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._client._base}/repos/{owner}/{repo}",
|
||||
headers=self._client._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FlowDeck — Formula Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class FormulaEngine:
|
||||
@@ -214,7 +214,7 @@ class FormulaEngine:
|
||||
return val.split("...")[0]
|
||||
return val
|
||||
|
||||
def _end(self, ctx: dict, s: Any) -> Optional[str]:
|
||||
def _end(self, ctx: dict, s: Any) -> str | None:
|
||||
"""Extract end date from a date range."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
|
||||
@@ -13,11 +13,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Async Gitea API v1 client with simple TTL cache."""
|
||||
"""Async Gitea API v1 client with simple TTL cache.
|
||||
|
||||
def __init__(self) -> None:
|
||||
Can be instantiated with a per-user token (OAuth) or falls back
|
||||
to the server-wide admin token.
|
||||
"""
|
||||
|
||||
def __init__(self, user_token: str | None = None) -> None:
|
||||
self._base = f"{settings.gitea_url}/api/v1"
|
||||
self._headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
token = user_token or settings.gitea_token
|
||||
self._headers = {"Authorization": f"token {token}"}
|
||||
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
||||
|
||||
@@ -295,6 +300,129 @@ class GiteaClient:
|
||||
return data
|
||||
|
||||
|
||||
# ── repo contents ──
|
||||
|
||||
async def get_repo_contents(self, owner, repo, path=""):
|
||||
"""Get contents of a repo directory (lazy: one level)."""
|
||||
url = f"{self._base}/repos/{owner}/{repo}/contents"
|
||||
if path:
|
||||
url += f"/{path}"
|
||||
cache_key = f"contents:{owner}:{repo}:{path}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
async def get_file_content(self, owner, repo, path):
|
||||
"""Get raw file content (decoded from base64)."""
|
||||
import base64
|
||||
cache_key = f"file:{owner}:{repo}:{path}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
item = resp.json()
|
||||
content = ""
|
||||
if item.get("encoding") == "base64" and item.get("content"):
|
||||
try:
|
||||
content = base64.b64decode(item["content"]).decode("utf-8")
|
||||
except Exception:
|
||||
content = "[binary file]"
|
||||
self._set_cache(cache_key, content)
|
||||
return content
|
||||
|
||||
async def create_or_update_file(self, owner, repo, path, content, message, sha=None):
|
||||
"""Create or update a file. Requires `sha` for updates."""
|
||||
import base64
|
||||
body = {
|
||||
"content": base64.b64encode(content.encode("utf-8")).decode("utf-8"),
|
||||
"message": message,
|
||||
}
|
||||
if sha:
|
||||
body["sha"] = sha
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.put(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
parent = "/".join(path.split("/")[:-1])
|
||||
for p in (parent, path.rsplit("/", 1)[0] if "/" in path else ""):
|
||||
self._cache.pop(f"contents:{owner}:{repo}:{p}", None)
|
||||
self._cache.pop(f"file:{owner}:{repo}:{path}", None)
|
||||
return data
|
||||
|
||||
async def delete_file(self, owner, repo, path, sha, message):
|
||||
"""Delete a file from the repo."""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.delete(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
json={"message": message, "sha": sha},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
parent = "/".join(path.split("/")[:-1])
|
||||
for p in (parent, path.rsplit("/", 1)[0] if "/" in path else ""):
|
||||
self._cache.pop(f"contents:{owner}:{repo}:{p}", None)
|
||||
self._cache.pop(f"file:{owner}:{repo}:{path}", None)
|
||||
return {}
|
||||
|
||||
def _invalidate_tree_cache(self, owner, repo):
|
||||
keys = [k for k in self._cache if k.startswith(f"contents:{owner}:{repo}:") or k.startswith(f"file:{owner}:{repo}:")]
|
||||
for k in keys:
|
||||
del self._cache[k]
|
||||
|
||||
async def get_file_commits(self, owner: str, repo: str, path: str) -> list[dict]:
|
||||
"""Get recent commits affecting a specific file (cached 5 min)."""
|
||||
key = f"commits:{owner}:{repo}:{path}"
|
||||
cached = self._cached(key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/commits",
|
||||
headers=self._headers,
|
||||
params={"path": path, "limit": 20},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
self._set_cache(key, data)
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
# ── singleton ──
|
||||
|
||||
gitea = GiteaClient()
|
||||
|
||||
# ── Helper: get per-user GiteaClient ──
|
||||
|
||||
def get_user_gitea_client(request):
|
||||
"""Return a GiteaClient authenticated with the user's OAuth token, or None."""
|
||||
from app.auth.session import SessionManager
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea' ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return GiteaClient(user_token=row["access_token"])
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.forge_adapter import ForgeAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TTL = 30 # seconds
|
||||
|
||||
|
||||
class GitHubAdapter(ForgeAdapter):
|
||||
"""Async GitHub API client (v3 REST) with simple TTL cache.
|
||||
|
||||
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
|
||||
interface so Gitea and GitHub repos can be synced identically.
|
||||
"""
|
||||
|
||||
kind = "github"
|
||||
|
||||
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
|
||||
transport: httpx.BaseTransport | None = None) -> None:
|
||||
self._base = "https://api.github.com"
|
||||
self._headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
self._transport = transport
|
||||
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||
self._ttl = timedelta(seconds=ttl)
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=15, transport=self._transport)
|
||||
|
||||
# ── cache helpers ──
|
||||
|
||||
def _cached(self, key: str) -> Any | None:
|
||||
entry = self._cache.get(key)
|
||||
if entry and entry[0] > datetime.now():
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||
|
||||
# ── repos ──
|
||||
|
||||
async def list_repos(self, page: int = 1, per_page: int = 50) -> list[dict]:
|
||||
"""List repositories for the authenticated user (paginated)."""
|
||||
cache_key = f"repos:{page}:{per_page}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/repos",
|
||||
headers=self._headers,
|
||||
params={"page": page, "per_page": per_page, "sort": "updated"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
async def list_all_repos(self) -> list[dict]:
|
||||
"""Fetch all user repos across pages (up to 5 pages / 250 repos)."""
|
||||
all_repos: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
repos = await self.list_repos(page=page)
|
||||
if not repos:
|
||||
break
|
||||
all_repos.extend(repos)
|
||||
return all_repos
|
||||
|
||||
# ── repo tree ──
|
||||
|
||||
async def get_repo_tree(
|
||||
self, owner: str, repo: str, sha: str | None = None, recursive: bool = True
|
||||
) -> list[dict]:
|
||||
"""Get the git tree for a repo. If ``sha`` is omitted, resolves the
|
||||
default branch first."""
|
||||
cache_key = f"tree:{owner}:{repo}:{sha or 'default'}:{recursive}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with self._client() as client:
|
||||
# Resolve default branch commit SHA if not provided
|
||||
if sha is None:
|
||||
repo_info = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
)
|
||||
repo_info.raise_for_status()
|
||||
sha = repo_info.json()["default_branch"]
|
||||
|
||||
# Now get the commit to find tree SHA
|
||||
branch_resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/branches/{sha}",
|
||||
headers=self._headers,
|
||||
)
|
||||
branch_resp.raise_for_status()
|
||||
sha = branch_resp.json()["commit"]["commit"]["tree"]["sha"]
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
if recursive:
|
||||
params["recursive"] = "1"
|
||||
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/git/trees/{sha}",
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("tree", [])
|
||||
# Truncated responses — fetch remaining pages if needed
|
||||
while data.get("truncated", False):
|
||||
logger.warning("GitHub tree truncated for %s/%s — results incomplete", owner, repo)
|
||||
break
|
||||
|
||||
self._set_cache(cache_key, items)
|
||||
return items
|
||||
|
||||
# ── file content ──
|
||||
|
||||
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
|
||||
"""Get decoded file content from a repo."""
|
||||
cache_key = f"file:{owner}:{repo}:{path}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
item = resp.json()
|
||||
|
||||
content = ""
|
||||
if item.get("encoding") == "base64" and item.get("content"):
|
||||
try:
|
||||
content = base64.b64decode(item["content"]).decode("utf-8")
|
||||
except Exception:
|
||||
content = "[binary file]"
|
||||
|
||||
self._set_cache(cache_key, content)
|
||||
return content
|
||||
|
||||
# ── repo info (ForgeAdapter) ──
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
"""Repository metadata: default_branch, clone_url, languages, …"""
|
||||
cache_key = f"repo_info:{owner}:{repo}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
info = resp.json()
|
||||
repo_info = {
|
||||
"id": info.get("id"),
|
||||
"name": info.get("name"),
|
||||
"owner": (info.get("owner") or {}).get("login", owner),
|
||||
"full_name": info.get("full_name"),
|
||||
"clone_url": info.get("clone_url", ""),
|
||||
"default_branch": info.get("default_branch", "main"),
|
||||
"description": info.get("description") or "",
|
||||
"language": info.get("language") or "",
|
||||
"html_url": info.get("html_url", ""),
|
||||
}
|
||||
# Languages are a separate endpoint.
|
||||
try:
|
||||
lang_resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/languages",
|
||||
headers=self._headers,
|
||||
)
|
||||
if lang_resp.status_code == 200:
|
||||
langs = lang_resp.json()
|
||||
if langs:
|
||||
repo_info["language"] = max(langs, key=langs.get)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
async def get_languages(self, owner: str, repo: str) -> dict:
|
||||
"""Bytes per language for a repo."""
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/languages",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── token validation ──
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
"""Check whether the access token is still valid."""
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user",
|
||||
headers=self._headers,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
@@ -0,0 +1,494 @@
|
||||
"""FlowDeck — LLM client abstraction (v4.10.0).
|
||||
|
||||
Abstraction over multiple LLM providers so the agent never talks to the DB
|
||||
directly — it emits *tool intentions* (function calls) that AgentEngine turns
|
||||
into guarded internal actions.
|
||||
|
||||
Supported providers (OpenAI-compatible chat-completions JSON response):
|
||||
openai, anthropic*, google*, deepseek, qwencloud, nvidia, openrouter, ollama.
|
||||
(* routed through an OpenAI-compatible gateway / any configured api_base)
|
||||
|
||||
When no API key is configured (or provider == "offline") the client falls back
|
||||
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
|
||||
functional — and fully testable — with zero external calls, which is what the
|
||||
local deployment and the test-suite rely on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Provider → default model + base URL when llm_model/api_base are empty.
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta", "gemini-2.0-pro"),
|
||||
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
||||
"qwencloud": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
|
||||
"ollama": ("http://localhost:11434/v1", "llama3.1"),
|
||||
"offline": (None, None),
|
||||
}
|
||||
|
||||
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
|
||||
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||
"google": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
||||
"nvidia": ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b",
|
||||
"meta/llama-3.1-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
||||
"deepseek-ai/deepseek-v4-pro", "z-ai/glm-5.2"],
|
||||
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
||||
"openai/gpt-4o", "mistralai/mistral-large"],
|
||||
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
|
||||
"offline": [],
|
||||
}
|
||||
|
||||
# Llama-style / ChatML tool markers used by the mock planner.
|
||||
_CREATE_PATTERNS = [
|
||||
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
|
||||
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
||||
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
||||
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("create_page", {"title": m.group(1).strip()})),
|
||||
]
|
||||
_SEARCH_PATTERNS = [
|
||||
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
|
||||
]
|
||||
|
||||
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
|
||||
# "créer la collection X", "create collection X", etc.)
|
||||
_COLLECTION_LINE = re.compile(
|
||||
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Normalized completion: either a final text or one or more tool calls."""
|
||||
text: str = ""
|
||||
tool_calls: list[dict] = field(default_factory=list)
|
||||
model: str = ""
|
||||
usage: dict = field(default_factory=dict)
|
||||
notice: str = ""
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Multi-provider chat client with tool-calling support and offline mock."""
|
||||
|
||||
def __init__(self, provider: str | None = None, api_key: str | None = None,
|
||||
api_base: str | None = None):
|
||||
from .llm_config import get_llm_config # local import avoids a cycle
|
||||
|
||||
cfg = get_llm_config()
|
||||
self.provider = (provider or cfg["provider"] or "offline").lower()
|
||||
self.api_key = api_key if api_key is not None else cfg["api_key"]
|
||||
self.api_base = api_base if api_base is not None else cfg["api_base"]
|
||||
base, model = PROVIDERS.get(self.provider, (None, None))
|
||||
self.api_base = self.api_base or base
|
||||
# Le modèle global configuré n'est valable que pour le provider global :
|
||||
# tester un autre provider (ex. nvidia alors que deepseek est actif) ne doit
|
||||
# PAS lui envoyer le modèle du provider actif (sinon « model not found »).
|
||||
cfg_provider = (cfg.get("provider") or "offline").lower()
|
||||
global_model = (cfg.get("model") or "") if self.provider == cfg_provider else ""
|
||||
self.default_model = global_model or model or "gpt-4o"
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
async def complete(self, messages: list[dict], *, model: str | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
stream: bool = False) -> LLMResponse:
|
||||
"""Send a chat completion. Returns text and/or tool_calls."""
|
||||
model = model or self.default_model
|
||||
|
||||
if self.provider == "offline" or not self._has_credentials():
|
||||
return await self._mock_complete(messages, model, tools)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._http_complete(messages, model, tools),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
|
||||
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
|
||||
# réel est configuré : l'erreur doit remonter (SSE "error") pour que
|
||||
# l'utilisateur voie pourquoi rien n'a été généré.
|
||||
logger.warning("LLM provider '%s' failed (%s)", self.provider, exc)
|
||||
raise
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""True when a real provider is configured."""
|
||||
return self.provider != "offline" and self._has_credentials()
|
||||
|
||||
async def ping(self, *, model: str | None = None) -> LLMResponse:
|
||||
"""Reach the provider without mock fallback (used by the "Test connection"
|
||||
UI). Raises on any real error so the caller can surface it."""
|
||||
model = model or self.default_model
|
||||
if self.provider == "offline":
|
||||
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
|
||||
if not self._has_credentials():
|
||||
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
|
||||
return await asyncio.wait_for(
|
||||
self._http_complete(
|
||||
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
|
||||
model,
|
||||
None,
|
||||
),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _has_credentials(self) -> bool:
|
||||
if self.provider == "ollama":
|
||||
return True # local, no key required
|
||||
return bool(self.api_key)
|
||||
|
||||
def _endpoint(self) -> str:
|
||||
return f"{self.api_base.rstrip('/')}/chat/completions"
|
||||
|
||||
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = [{"type": "function", "function": t} for t in tools]
|
||||
payload["tool_choice"] = "auto"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
resp = await client.post(self._endpoint(), json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Repli robuste : le modèle choisi a été retiré / n'existe plus
|
||||
# (404 « model not found » / 410 « has reached its end of life »).
|
||||
# Au lieu d'échouer, on retente UNE fois avec le modèle par défaut du
|
||||
# provider et on signale le basculement — la liste validée peut avoir
|
||||
# vieilli (modèle déprécié entre deux rafraîchissements).
|
||||
if exc.response.status_code in (404, 410) \
|
||||
and model and self.default_model and model != self.default_model:
|
||||
logger.warning(
|
||||
"Model '%s' unavailable (%s) on %s — retrying with default '%s'",
|
||||
model, exc.response.status_code, self.provider, self.default_model,
|
||||
)
|
||||
return await self._http_complete(messages, self.default_model, tools, _noticer=(
|
||||
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
|
||||
f"Réponse générée avec « {self.default_model} » à la place."
|
||||
))
|
||||
raise
|
||||
|
||||
response = self._parse_response(data, model)
|
||||
response.notice = _noticer or ""
|
||||
return response
|
||||
|
||||
def _parse_response(self, data: dict, model: str) -> LLMResponse:
|
||||
choice = data["choices"][0]["message"]
|
||||
text = choice.get("content") or ""
|
||||
tool_calls = []
|
||||
for tc in choice.get("tool_calls") or []:
|
||||
fn = tc.get("function") or {}
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
tool_calls.append({
|
||||
"id": tc.get("id") or "",
|
||||
"name": fn.get("name"),
|
||||
"arguments": args,
|
||||
"arguments_raw": fn.get("arguments") or "",
|
||||
})
|
||||
return LLMResponse(
|
||||
text=text,
|
||||
tool_calls=tool_calls,
|
||||
model=model,
|
||||
usage=data.get("usage", {}),
|
||||
)
|
||||
|
||||
# ── Offline mock planner (deterministic, no network) ──
|
||||
|
||||
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
|
||||
user_content = self._last_user_content(messages)
|
||||
sys_content = self._system_content(messages)
|
||||
# Only the user's objective drives the planner. The engine appends the
|
||||
# workspace/document snapshot under "# Contexte"; that text must never
|
||||
# trigger keyword heuristics (a doc mentioning "recherche"/"collection"
|
||||
# used to misroute content requests into tool calls).
|
||||
objective = user_content.split("\n# Contexte")[0]
|
||||
|
||||
# Once tool results are already in the conversation, we have acted:
|
||||
# stop issuing new tool calls and conclude.
|
||||
if any(m.get("role") == "tool" for m in messages):
|
||||
return LLMResponse(
|
||||
text="Objectif traité — actions enregistrées dans le journal d'audit.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Skill-driven: if the objective names a known skill, mirror its template.
|
||||
skill_hint = self._extract_skill_hint(objective)
|
||||
|
||||
if skill_hint == "sprint":
|
||||
return LLMResponse(
|
||||
tool_calls=[
|
||||
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
|
||||
{"name": "create_collection", "arguments": {"name": "Sprint"}},
|
||||
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
|
||||
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
|
||||
],
|
||||
text="Plan: analyze open issues, then build a sprint board.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Inline content-generation ("Ask AI" / "AI meeting note") — answered
|
||||
# before the tool-intent heuristics and scoped to the user objective
|
||||
# only, so an injected "# Contexte" that happens to mention "collection"
|
||||
# can't misroute a writing request into a create-collection action.
|
||||
draft = self._draft_reply(objective)
|
||||
if draft:
|
||||
return LLMResponse(text=draft, model=model)
|
||||
|
||||
# Documents / espaces de travail (offline): unambiguous intents resolved
|
||||
# from the objective — create a document (optionally in a named
|
||||
# workspace) or list the accessible workspaces.
|
||||
doc_args = self._document_create_args(objective)
|
||||
if doc_args is not None:
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_document", "arguments": doc_args}],
|
||||
text="Plan: création d'un document.",
|
||||
model=model,
|
||||
)
|
||||
if self._is_workspaces_request(objective):
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
|
||||
text="Plan: lister les espaces de travail.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Exact keyword → tool intent resolution (objective only).
|
||||
for regex, builder in _CREATE_PATTERNS:
|
||||
m = regex.search(objective)
|
||||
if m:
|
||||
return LLMResponse(
|
||||
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
|
||||
text=f"Plan: running {builder(m)[0]}.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
for regex, builder in _SEARCH_PATTERNS:
|
||||
m = regex.search(objective)
|
||||
if m:
|
||||
return LLMResponse(
|
||||
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
|
||||
text=f"Plan: searching '{m.group(1)}'.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Loose "collection <X>" detection → treat as a create intent.
|
||||
low = objective.lower()
|
||||
if "collection" in low:
|
||||
m = _COLLECTION_LINE.search(objective)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_collection",
|
||||
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
|
||||
text=f"Plan: create collection '{name}'.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Plain conversational objective → final answer (no tool).
|
||||
return LLMResponse(
|
||||
text=self._summarize(objective),
|
||||
model=model,
|
||||
)
|
||||
|
||||
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
|
||||
"""Inject a collection id from the context when the planner left it as 0."""
|
||||
args = dict(args)
|
||||
if args.get("collection_id") == 0:
|
||||
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
|
||||
if match:
|
||||
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
|
||||
if ids:
|
||||
args["collection_id"] = ids[0]
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _document_create_args(content: str) -> dict | None:
|
||||
"""Deterministic `create_document` intent for the offline mock.
|
||||
|
||||
Only fires when the user clearly asks to *create* a document (a content
|
||||
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
|
||||
Returns None when the message is not a create-document intent.
|
||||
"""
|
||||
low = content.lower()
|
||||
if "document" not in low:
|
||||
return None
|
||||
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
|
||||
"nouveau document", "nouvelle page", "faire un")):
|
||||
return None
|
||||
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
|
||||
title = quotes[0].strip() if quotes else None
|
||||
if not title:
|
||||
m = re.search(
|
||||
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
|
||||
content, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
if not title:
|
||||
return None
|
||||
args = {"title": title}
|
||||
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
|
||||
args["workspace_name"] = quotes[-1].strip()
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _is_workspaces_request(content: str) -> bool:
|
||||
"""True when the user asks to list / locate the workspaces."""
|
||||
low = content.lower()
|
||||
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
|
||||
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
|
||||
"affiche", "mes espaces", "ou sont", "où sont"))
|
||||
return has_ws and has_verb
|
||||
|
||||
@staticmethod
|
||||
def _system_content(messages) -> str:
|
||||
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
|
||||
@staticmethod
|
||||
def _last_user_content(messages) -> str:
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user":
|
||||
c = m.get("content", "")
|
||||
if isinstance(c, list):
|
||||
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
||||
return str(c)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_skill_hint(content: str) -> str | None:
|
||||
low = content.lower()
|
||||
if "sprint" in low or "préparation de sprint" in low:
|
||||
return "sprint"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _summarize(content: str) -> str:
|
||||
"""Produce a terse final summary from a conversational objective."""
|
||||
content = content.split("\n# Contexte")[0]
|
||||
return (content[:600] + "…" if len(content) > 600 else content)
|
||||
|
||||
def _draft_reply(self, content: str) -> str | None:
|
||||
"""Generate usable structured copy for content/meeting requests when no
|
||||
real LLM is configured (offline mock). Returns None when the message is
|
||||
not a clear content-generation intent so other paths keep their behavior.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
low = content.lower()
|
||||
title = None
|
||||
m = re.search(r"intitul[ée]e\s*[«\"']([^»\"']+)[»\"']", content)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
today = date.today().isoformat()
|
||||
|
||||
# ── AI meeting note template ──
|
||||
if any(k in low for k in ("ai meeting note", "meeting note",
|
||||
"compte-rendu", "compte rendu",
|
||||
"notes de réunion", "réunion")):
|
||||
return (
|
||||
"📅 AI Meeting Note\n"
|
||||
f"Date : {today} · Participants : (à renseigner)\n"
|
||||
"\n"
|
||||
"## Résumé\n"
|
||||
"Point central de la discussion et contexte (à compléter).\n"
|
||||
"\n"
|
||||
"## Décisions\n"
|
||||
"• Décision 1 — valider le périmètre et les responsables.\n"
|
||||
"• Décision 2 — définir la prochaine échéance.\n"
|
||||
"\n"
|
||||
"## Action items\n"
|
||||
"☐ Action 1 — responsable : …, échéance : …\n"
|
||||
"☐ Action 2 — responsable : …, échéance : …\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Planifier le suivi et archiver ce compte-rendu.\n"
|
||||
)
|
||||
|
||||
# ── Traduction / analyse du document (hors-ligne) ──
|
||||
if re.search(r"traduis|traduit|translate", low):
|
||||
return (
|
||||
"⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA "
|
||||
"connecté).\n\n"
|
||||
"Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez "
|
||||
"« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu "
|
||||
"à approuver ou à rejeter avant application."
|
||||
)
|
||||
if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low):
|
||||
return (
|
||||
"⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le "
|
||||
"document.\n\n"
|
||||
"Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez "
|
||||
"l'action : l'agent générera la proposition ici, avec un aperçu à approuver "
|
||||
"ou à rejeter avant application."
|
||||
)
|
||||
|
||||
# ── Page / document draft (contextual "Ask AI") ──
|
||||
if title:
|
||||
t = title[:80]
|
||||
return (
|
||||
f"{t}\n"
|
||||
"\n"
|
||||
f"Présentation générale du sujet « {t} » : objectif, contexte et "
|
||||
"public visé en quelques phrases. (Document généré hors-ligne — "
|
||||
"connectez une clé API pour une rédaction complète.)\n"
|
||||
"\n"
|
||||
"## Objectif\n"
|
||||
"• Clarifier le besoin couvert par ce document.\n"
|
||||
"• Lister les livrables attendus.\n"
|
||||
"\n"
|
||||
"## Points clés\n"
|
||||
"• Idée principale 1 avec les arguments associés.\n"
|
||||
"• Idée principale 2 et les exemples concrets.\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Relire, compléter et mettre en forme ce contenu.\n"
|
||||
)
|
||||
|
||||
# ── Generic drafting verb, no title (typing directly in the chat) ──
|
||||
if re.search(r"^(r[ée]dige|[ée]cris|[ée]crire|g[ée]n[èe]re|produis|d[ée]veloppe|"
|
||||
r"[ée]cris\s+un|g[ée]n[èe]re\s+un|r[ée]dige\s+un)\b", low):
|
||||
return (
|
||||
"## Introduction\n"
|
||||
"Contexte et objectif de ce texte, en une à deux phrases.\n"
|
||||
"\n"
|
||||
"## Développement\n"
|
||||
"• Premier argument structuré avec un exemple.\n"
|
||||
"• Deuxième argument appuyé par une donnée ou un fait.\n"
|
||||
"\n"
|
||||
"## Conclusion\n"
|
||||
"Synthèse et prochaine étape recommandée.\n"
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,409 @@
|
||||
"""FlowDeck — Runtime LLM configuration store (v4.10.2).
|
||||
|
||||
Precedence (per conversation):
|
||||
1. explicit `provider`/`model` passed to the run endpoint,
|
||||
2. the user's saved key for that provider (`user_llm_keys`),
|
||||
3. the global `llm_config` row (id=1) — admin UI,
|
||||
4. `settings.llm_*` (.env), default « offline mock ».
|
||||
|
||||
Rows are created lazily, so .env stays the default until saved from the UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from app.config import settings
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
|
||||
|
||||
# Providers whose /v1/models lists far more entries than /v1/chat/completions
|
||||
# actually serves. The fetched list is validated (name filter + live probe)
|
||||
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||
# chat completions — the exact failure the user hit.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
|
||||
|
||||
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
||||
_NON_CHAT_MARKERS = (
|
||||
"embed", "bge-", "rerank", "retriev", "tts", "asr", "stt", "whisper", "speech",
|
||||
"transcrib", "translate", "image", "video", "audio", "music", "sound", "dall",
|
||||
"stable", "diffus", "flux", "sora", "veo", "midjourney", "clip", "segmentation",
|
||||
"ocr", "inpainting", "depth", "motion", "sento-",
|
||||
)
|
||||
|
||||
|
||||
def _is_likely_chat(model_id: str) -> bool:
|
||||
ml = model_id.lower()
|
||||
return not any(m in ml for m in _NON_CHAT_MARKERS)
|
||||
|
||||
__all__ = [
|
||||
"get_llm_config", "set_llm_config", "provider_info",
|
||||
"get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key",
|
||||
"delete_user_llm_key", "fetch_provider_models",
|
||||
"mark_llm_config_verified", "mark_user_llm_key_verified",
|
||||
]
|
||||
|
||||
|
||||
def get_llm_config() -> dict:
|
||||
"""Return the effective LLM config — DB overrides .env when present."""
|
||||
cfg = {
|
||||
"provider": settings.llm_provider or "offline",
|
||||
"model": settings.llm_model or "gpt-4o",
|
||||
"api_key": settings.llm_api_key or "",
|
||||
"api_base": settings.llm_api_base or "",
|
||||
"verified": 0,
|
||||
"verified_model": "",
|
||||
"verified_at": "",
|
||||
"last_error": "",
|
||||
}
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT provider, model, api_key, api_base, verified, verified_model, "
|
||||
"verified_at, last_error FROM llm_config WHERE id=1"
|
||||
).fetchone()
|
||||
except Exception: # noqa: BLE001 — DB not ready yet → env defaults
|
||||
return cfg
|
||||
if row:
|
||||
for key in ("provider", "model", "api_key", "api_base"):
|
||||
if row[key]:
|
||||
cfg[key] = row[key]
|
||||
if row["provider"]:
|
||||
cfg["verified"] = row["verified"] or 0
|
||||
cfg["verified_model"] = row["verified_model"] or ""
|
||||
cfg["verified_at"] = row["verified_at"] or ""
|
||||
cfg["last_error"] = row["last_error"] or ""
|
||||
return cfg
|
||||
|
||||
|
||||
def set_llm_config(*, provider: str | None = None, model: str | None = None,
|
||||
api_key: str | None = None, api_base: str | None = None,
|
||||
clear_keys: bool = False) -> dict:
|
||||
"""Upsert the runtime LLM config row (id=1) and return the new effective config.
|
||||
|
||||
An empty `api_key`/`api_base` keeps the stored value (so an admin can tweak
|
||||
the model/base without re-typing the key). Changing the API key resets the
|
||||
`verified` flag — the provider has to pass a connection test again.
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = get_llm_config()
|
||||
if provider is not None:
|
||||
cfg["provider"] = provider
|
||||
if model is not None:
|
||||
cfg["model"] = model
|
||||
if api_key is not None and api_key.strip():
|
||||
key_changed = cfg.get("api_key") != api_key.strip()
|
||||
cfg["api_key"] = api_key.strip()
|
||||
if key_changed:
|
||||
cfg["verified"] = 0
|
||||
cfg["verified_model"] = ""
|
||||
cfg["last_error"] = ""
|
||||
if api_base is not None and api_base.strip():
|
||||
cfg["api_base"] = api_base.strip()
|
||||
if clear_keys:
|
||||
cfg["api_key"] = ""
|
||||
cfg["api_base"] = ""
|
||||
cfg["verified"] = 0
|
||||
cfg["verified_model"] = ""
|
||||
cfg["last_error"] = ""
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO llm_config (id, provider, model, api_key, api_base,
|
||||
verified, verified_model, last_error, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
provider=excluded.provider, model=excluded.model,
|
||||
api_key=excluded.api_key, api_base=excluded.api_base,
|
||||
verified=excluded.verified, verified_model=excluded.verified_model,
|
||||
last_error=excluded.last_error,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"],
|
||||
cfg.get("verified", 0), cfg.get("verified_model", ""),
|
||||
cfg.get("last_error", "")),
|
||||
)
|
||||
conn.commit()
|
||||
return cfg
|
||||
|
||||
|
||||
def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> None:
|
||||
"""Record the outcome of the admin 'Test connection' for the global default."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT provider FROM llm_config WHERE id=1").fetchone()
|
||||
provider = row["provider"] if row else (settings.llm_provider or "offline")
|
||||
conn.execute(
|
||||
"""INSERT INTO llm_config (id, provider, model, verified, verified_model,
|
||||
verified_at, last_error, updated_at)
|
||||
VALUES (1, ?, '', ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
verified=excluded.verified,
|
||||
verified_model=excluded.verified_model,
|
||||
verified_at=excluded.verified_at,
|
||||
last_error=excluded.last_error,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(provider, 1 if ok else 0, model if ok else "",
|
||||
1 if ok else 0, "" if ok else error),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def provider_info() -> list[dict]:
|
||||
"""Providers list for the UI: known models + whether an API key is required.
|
||||
|
||||
``base_url`` is the endpoint the application uses by default for this
|
||||
provider's chat requests (shown in the Settings "URL API" fields).
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for name, (base, default_model) in PROVIDERS.items():
|
||||
models = list(PROVIDER_MODELS.get(name) or ())
|
||||
if default_model and default_model not in models:
|
||||
models.insert(0, default_model)
|
||||
out.append({
|
||||
"id": name,
|
||||
"name": name.capitalize(),
|
||||
"default_model": default_model,
|
||||
"models": models,
|
||||
"base_url": (base or ""),
|
||||
"requires_key": name not in ("offline", "ollama"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Per-user provider keys ──
|
||||
|
||||
|
||||
def _mask_key(row) -> dict:
|
||||
"""Public view of a stored key row: never exposes the raw api_key."""
|
||||
def _get(name, default=""):
|
||||
try:
|
||||
v = row[name]
|
||||
return default if v is None else v
|
||||
except (KeyError, IndexError):
|
||||
return default
|
||||
return {
|
||||
"provider": row["provider"],
|
||||
"api_base": row["api_base"] or "",
|
||||
"default_model": row["default_model"] or "",
|
||||
"models": json.loads(row["models_json"] or "[]"),
|
||||
"has_key": bool(row["api_key"]),
|
||||
"verified": bool(_get("verified", 0)),
|
||||
"verified_model": _get("verified_model") or "",
|
||||
"last_error": _get("last_error") or "",
|
||||
}
|
||||
|
||||
|
||||
def get_user_llm_key(user_id: int, provider: str) -> dict | None:
|
||||
"""Return a stored key row (includes the raw api_key — server-side only)."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM user_llm_keys WHERE user_id=? AND provider=?",
|
||||
(user_id, provider),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_user_llm_keys(user_id: int) -> list[dict]:
|
||||
"""Public (masked) list of the user's saved provider keys."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM user_llm_keys WHERE user_id=? ORDER BY provider",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [_mask_key(r) for r in rows]
|
||||
|
||||
|
||||
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
|
||||
api_base: str = "", default_model: str = "",
|
||||
models: list[str] | None = None) -> dict:
|
||||
"""Upsert a user's provider key. Empty api_key keeps the existing one
|
||||
(allows saving model/base without re-typing the key). Saving a *different*
|
||||
key resets the `verified` flag so the provider must pass a test again."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
existing = get_user_llm_key(user_id, provider)
|
||||
new_key = api_key if api_key else (existing.get("api_key", "") if existing else "")
|
||||
new_base = api_base if api_base else (existing.get("api_base", "") if existing else "")
|
||||
new_model = default_model if default_model else (existing.get("default_model", "") if existing else "")
|
||||
new_models = models if models is not None else (
|
||||
json.loads(existing["models_json"]) if existing and existing.get("models_json") else []
|
||||
)
|
||||
key_changed = bool(api_key) and (not existing or existing.get("api_key", "") != api_key)
|
||||
# A changed key invalidates the previous verification; keep it otherwise.
|
||||
verified = 0 if key_changed else (existing.get("verified") or 0) if existing else 0
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, verified, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, provider) DO UPDATE SET
|
||||
api_key=excluded.api_key, api_base=excluded.api_base,
|
||||
default_model=excluded.default_model, models_json=excluded.models_json,
|
||||
verified=excluded.verified,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(user_id, provider, new_key, new_base, new_model,
|
||||
json.dumps(new_models, ensure_ascii=False), verified),
|
||||
)
|
||||
conn.commit()
|
||||
return get_user_llm_key(user_id, provider) or {
|
||||
"provider": provider, "api_key": new_key, "api_base": new_base,
|
||||
"default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False),
|
||||
"verified": verified,
|
||||
}
|
||||
|
||||
|
||||
def mark_user_llm_key_verified(user_id: int, provider: str, ok: bool, *,
|
||||
model: str = "", error: str = "") -> None:
|
||||
"""Record the outcome of a connection test on one of the user's providers."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE user_llm_keys
|
||||
SET verified=?, verified_model=?, last_error=?,
|
||||
verified_at=CASE WHEN ? THEN CURRENT_TIMESTAMP END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE user_id=? AND provider=?""",
|
||||
(1 if ok else 0, model if ok else "", "" if ok else error,
|
||||
1 if ok else 0, user_id, provider),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_user_llm_key(user_id: int, provider: str) -> None:
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_llm_keys WHERE user_id=? AND provider=?",
|
||||
(user_id, provider),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
api_base: str = "", timeout: int = 20) -> list[str]:
|
||||
"""Fetch the live model list from a provider (best-effort, no mock).
|
||||
|
||||
OpenAI-compatible providers use `GET {base}/models` with a Bearer token;
|
||||
Anthropic uses `x-api-key` + `anthropic-version`; Gemini an `x-goog-api-key`.
|
||||
Returns a de-duplicated list capped at 300 models.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
provider = provider.lower()
|
||||
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
|
||||
if not base:
|
||||
return [] # offline — nothing to fetch
|
||||
|
||||
base_url = base.rstrip("/")
|
||||
headers: dict = {}
|
||||
if provider == "anthropic":
|
||||
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
|
||||
elif provider == "google":
|
||||
headers = {"x-goog-api-key": api_key}
|
||||
elif api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{base_url}/models", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
ids: list[str] = []
|
||||
for item in data.get("data") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
i = (item.get("id") or "").strip()
|
||||
if i:
|
||||
ids.append(i)
|
||||
for item in data.get("models") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
n = (item.get("name") or item.get("id") or "").strip()
|
||||
if provider == "google" and n.startswith("models/"):
|
||||
n = n[len("models/"):]
|
||||
if n:
|
||||
ids.append(n)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for i in ids:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
|
||||
# Providers like NVIDIA list their whole catalog, most of which is NOT served
|
||||
# by /v1/chat/completions (404 sur « model not found »). Filter by name first,
|
||||
# then probe the survivors with a minimal chat call so only usable models stay.
|
||||
if provider in _CHAT_VALIDATED_PROVIDERS and out:
|
||||
candidates = [m for m in out if _is_likely_chat(m)] or out
|
||||
validated = await _validate_chat_models(base_url, api_key, candidates)
|
||||
# Ne vidons jamais la liste : en cas d'échec de validation (débit limité,
|
||||
# indisponibilité passagère) on garde la liste filtrée par nom.
|
||||
out = validated if validated else candidates
|
||||
|
||||
return out[:300]
|
||||
|
||||
|
||||
async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
|
||||
*, timeout: float = 12.0, concurrency: int = 5,
|
||||
deadline: float = 90.0, attempts: int = 2) -> list[str]:
|
||||
"""Probe `POST {base_url}/chat/completions` for each candidate and keep only
|
||||
the models that genuinely answer — using the exact payload the app sends at
|
||||
runtime (`temperature: 0.2`, no `max_tokens`).
|
||||
|
||||
Hard failures (404 model inconnu, 410 modèle retiré…) exclude the model.
|
||||
A 429 (rate limit) is kept: it proves the route exists, so the model is
|
||||
usable once the quota frees up. Transient failures (timeouts, 5xx, network)
|
||||
are retried once before giving up, so slow-but-working models survive.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
start = time.monotonic()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||
payload_tpl = {
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
async def probe(model: str) -> str | None:
|
||||
if time.monotonic() - start > deadline:
|
||||
return None
|
||||
payload: dict = {"model": model, **payload_tpl}
|
||||
for attempt in range(attempts):
|
||||
if time.monotonic() - start > deadline:
|
||||
return None
|
||||
try:
|
||||
async with sem:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
code = resp.status_code
|
||||
if code < 300 or code == 429:
|
||||
return model
|
||||
if code < 500: # 400/401/403/404/410… → définitivement non utilisable
|
||||
return None
|
||||
# 5xx → passage passager, on retente une fois
|
||||
except Exception: # noqa: BLE001 — timeouts / connexion → on retente
|
||||
if attempt >= attempts - 1:
|
||||
return None
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*(probe(m) for m in candidates))
|
||||
return [m for m in results if isinstance(m, str)]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""FlowDeck — Email notifications via SMTP (v4.9.0).
|
||||
|
||||
If SMTP is not configured (smtp_host empty) this is a safe no-op, so the
|
||||
application works locally out of the box while still logging intent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _configured() -> bool:
|
||||
return bool(settings.smtp_host)
|
||||
|
||||
|
||||
def _html_body(body_text: str, cta_url: str = "") -> str:
|
||||
cta = ""
|
||||
if cta_url:
|
||||
cta = (
|
||||
'<p style="margin:24px 0 0;">'
|
||||
f'<a href="{cta_url}" '
|
||||
'style="background:#2383E2;color:#fff;text-decoration:none;'
|
||||
'padding:10px 20px;border-radius:8px;display:inline-block;'
|
||||
'font-weight:600;">Open in FlowDeck →</a></p>'
|
||||
)
|
||||
return f"""<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;
|
||||
background:#191919;color:#e0e0e0;padding:32px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#252525;border:1px solid #333;
|
||||
border-radius:12px;padding:24px;">
|
||||
<div style="font-size:18px;font-weight:700;color:#fff;margin-bottom:8px;">FlowDeck</div>
|
||||
<p style="color:#e0e0e0;line-height:1.6;white-space:pre-wrap;">{body_text}</p>
|
||||
{cta}
|
||||
<p style="margin-top:24px;font-size:12px;color:#999;">You received this because your
|
||||
notifications preferences in FlowDeck allow it.</p>
|
||||
</div></div>"""
|
||||
|
||||
|
||||
def send_email(to_email: str, subject: str, body_text: str, cta_url: str = "") -> bool:
|
||||
"""Send an email. Returns True on success, False if skipped or failed."""
|
||||
if not to_email or not _configured():
|
||||
return False
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = to_email
|
||||
msg.set_content(body_text)
|
||||
msg.add_alternative(_html_body(body_text, cta_url), subtype="html")
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
|
||||
if settings.smtp_use_tls:
|
||||
server.starttls()
|
||||
if settings.smtp_user:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
server.send_message(msg)
|
||||
logger.info("Email sent to %s: %s", to_email, subject)
|
||||
return True
|
||||
except Exception as e: # never break the request on mail failure
|
||||
logger.warning("Email send failed to %s: %s", to_email, e)
|
||||
return False
|
||||
|
||||
|
||||
def notify_user(
|
||||
user_id: int,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
cta_url: str = "",
|
||||
prefs_key: str = "mentions",
|
||||
) -> bool:
|
||||
"""Resolve a user's email + preferences and send an email notification."""
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, email FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not row["email"]:
|
||||
return False
|
||||
prefs = notifications.get_user_prefs(user_id)
|
||||
if not prefs.get(prefs_key, True):
|
||||
return False
|
||||
return send_email(row["email"], subject, body_text, cta_url)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""FlowDeck — Notification service (v4.9.0 collaboration).
|
||||
|
||||
Creates in-app notifications (mentions, comments, page changes) and triggers
|
||||
email delivery via :mod:`app.services.mailer` when the target user has opted in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import mailer
|
||||
|
||||
|
||||
def create_notification(
|
||||
user_id: int,
|
||||
actor_id: int | None,
|
||||
ntype: str,
|
||||
title: str,
|
||||
message: str,
|
||||
resource_type: str = "page",
|
||||
resource_id: int = 0,
|
||||
url: str = "",
|
||||
conn=None,
|
||||
commit: bool = True,
|
||||
) -> int | None:
|
||||
"""Insert a notification row. Returns the new id (or None if skipped).
|
||||
|
||||
``conn`` may be supplied to join an existing transaction (caller controls
|
||||
commit). Otherwise a dedicated connection is opened and committed.
|
||||
"""
|
||||
if not user_id:
|
||||
return None
|
||||
if conn is not None:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO notifications
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO notifications
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)")
|
||||
|
||||
|
||||
def extract_mentions(text: str) -> list[str]:
|
||||
"""Return the set of @login handles mentioned in ``text`` (lowercase)."""
|
||||
return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or "")))
|
||||
|
||||
|
||||
def process_mentions(
|
||||
text: str,
|
||||
actor_id: int,
|
||||
ntype: str,
|
||||
title: str,
|
||||
message: str,
|
||||
resource_type: str = "page",
|
||||
resource_id: int = 0,
|
||||
url: str = "",
|
||||
conn=None,
|
||||
) -> list[int]:
|
||||
"""Create notifications for every user @-mentioned in ``text``.
|
||||
|
||||
Returns the list of mentioned user ids that were notified.
|
||||
"""
|
||||
handles = extract_mentions(text)
|
||||
if not handles:
|
||||
return []
|
||||
notified = []
|
||||
if conn is not None:
|
||||
_do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified)
|
||||
return notified
|
||||
with get_conn() as conn:
|
||||
_do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified)
|
||||
conn.commit()
|
||||
return notified
|
||||
|
||||
|
||||
def _do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified):
|
||||
placeholders = ",".join("?" * len(handles))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
|
||||
handles,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if row["id"] == actor_id:
|
||||
continue
|
||||
create_notification(
|
||||
row["id"], actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, conn=conn, commit=False,
|
||||
)
|
||||
notified.append(row["id"])
|
||||
mailer.notify_user(
|
||||
row["id"], subject=title, body_text=message, cta_url=url or "",
|
||||
)
|
||||
|
||||
|
||||
def get_user_prefs(user_id: int, conn=None) -> dict:
|
||||
if conn is not None:
|
||||
row = conn.execute(
|
||||
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row or not row["notification_prefs"]:
|
||||
return {"comments": True, "mentions": True}
|
||||
try:
|
||||
prefs = json.loads(row["notification_prefs"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
prefs = {}
|
||||
return {"comments": bool(prefs.get("comments", True)),
|
||||
"mentions": bool(prefs.get("mentions", True))}
|
||||
|
||||
|
||||
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
|
||||
if conn is not None:
|
||||
conn.execute(
|
||||
"UPDATE users SET notification_prefs=? WHERE id=?",
|
||||
(json.dumps(prefs), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET notification_prefs=? WHERE id=?",
|
||||
(json.dumps(prefs), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return prefs
|
||||
@@ -0,0 +1,142 @@
|
||||
"""FlowDeck — Bookmark cards (v5.5.0): Open Graph metadata via httpx.
|
||||
|
||||
Fetches a URL server-side, extracts OG/Twitter meta tags (title, description,
|
||||
image, site name, favicon) and returns a safe, compact payload used to render
|
||||
Notion-style bookmark cards. Robust to missing tags, non-HTML bodies and
|
||||
slow/unreachable hosts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as htmlmod
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
|
||||
_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']")
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
|
||||
_FAVICON_RE = re.compile(r"<link\b[^>]*?>", re.I)
|
||||
_ICON_REL = re.compile(r"\b(?:shortcut\s+)?icon\b", re.I)
|
||||
|
||||
# Property/name keys we look for, in priority order, mapped to our payload keys.
|
||||
_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name")
|
||||
_OG_DESC = ("og:description", "twitter:description", "description")
|
||||
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src", "image")
|
||||
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
|
||||
|
||||
|
||||
def _attrs(tag: str) -> dict:
|
||||
return {k.lower(): v for k, v in _ATTR_RE.findall(tag)}
|
||||
|
||||
|
||||
def _extract_og(body: str) -> dict:
|
||||
"""Parse all ``<meta>`` tags into a ``{key: content}`` dict.
|
||||
|
||||
Attributes may appear in any order (``content`` before or after
|
||||
``property``/``name``), which the previous implementation mishandled.
|
||||
First value wins so the most specific tag (top of document) is kept.
|
||||
"""
|
||||
props: dict[str, str] = {}
|
||||
for tag in _META_TAG_RE.finditer(body[:400_000]):
|
||||
attrs = _attrs(tag.group(0))
|
||||
key = (attrs.get("property") or attrs.get("name") or attrs.get("itemprop") or "").lower()
|
||||
content = attrs.get("content")
|
||||
if key and content is not None and key not in props:
|
||||
props[key] = content
|
||||
return props
|
||||
|
||||
|
||||
def _pick(props: dict, keys: tuple) -> str:
|
||||
for k in keys:
|
||||
v = props.get(k)
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def _title_of(props: dict, body: str) -> str:
|
||||
t = _pick(props, _OG_TITLE)
|
||||
if t:
|
||||
return t
|
||||
m = _TITLE_RE.search(body[:200_000])
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _site_name(url: str) -> str:
|
||||
host = urlparse(url).netloc.replace("www.", "")
|
||||
return host.split(".")[0].capitalize() if host else ""
|
||||
|
||||
|
||||
def _favicon(body: str, base_url: str) -> str:
|
||||
for tag in _FAVICON_RE.finditer(body):
|
||||
attrs = _attrs(tag.group(0))
|
||||
rel = attrs.get("rel", "")
|
||||
href = attrs.get("href", "")
|
||||
if href and _ICON_REL.search(rel):
|
||||
return urljoin(base_url, htmlmod.unescape(href))
|
||||
return ""
|
||||
|
||||
|
||||
def parse_og(body: str, url: str) -> dict:
|
||||
"""Pure HTML → bookmark payload (no network). ``url`` is the base URL."""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
props = _extract_og(body)
|
||||
title = htmlmod.unescape(_title_of(props, body))
|
||||
desc = htmlmod.unescape(_pick(props, _OG_DESC))
|
||||
img = _pick(props, _OG_IMG)
|
||||
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
|
||||
|
||||
def abs_url(u: str) -> str:
|
||||
return urljoin(src, htmlmod.unescape(u)) if u else ""
|
||||
|
||||
return {
|
||||
"url": src,
|
||||
"title": title.strip()[:200] or urlparse(src).netloc or src,
|
||||
"description": desc.strip()[:400],
|
||||
"image": abs_url(img),
|
||||
"site_name": site.strip()[:100],
|
||||
"favicon": _favicon(body, src),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors.
|
||||
|
||||
``transport`` is an optional ``httpx`` transport (used by tests to mock
|
||||
HTTP without hitting the network).
|
||||
"""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
|
||||
try:
|
||||
import httpx
|
||||
|
||||
headers = {
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
kwargs = {"follow_redirects": True, "timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
logger.debug("og fetch failed for %s: %s", src, exc)
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
base["site_name"] = _site_name(src)
|
||||
return base
|
||||
|
||||
ctype = (resp.headers.get("content-type") or "").lower()
|
||||
if "text/html" not in ctype and "xhtml" not in ctype:
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
base["site_name"] = _site_name(src)
|
||||
return base
|
||||
|
||||
return parse_og(resp.text, src)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""FlowDeck — Agent permission guard (v4.10.0).
|
||||
|
||||
The agent always acts with *at most* the permissions of the invoking user
|
||||
(Notion Agent principle). This manager resolves the user's role in the active
|
||||
workspace and gates tool execution before any write reaches the database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Workspace roles, from least to most privileged.
|
||||
READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
|
||||
WRITE_ROLES = {"editor", "admin", "owner"}
|
||||
DESTRUCTIVE_ROLES = {"admin", "owner"}
|
||||
|
||||
# Tools that mutate state and therefore require at least an editor role.
|
||||
WRITE_TOOLS = {
|
||||
"create_collection", "create_view", "create_page", "update_page",
|
||||
"write_blocks", "create_document", "add_property", "add_relation",
|
||||
"create_sub_item", "add_dependency", "sync_gitea", "create_gitea_issue",
|
||||
"apply_template",
|
||||
}
|
||||
|
||||
# Tools that delete / are destructive → admin/owner (or confirm mode).
|
||||
DESTRUCTIVE_TOOLS = {
|
||||
"delete_page", "delete_collection", "delete_document",
|
||||
"delete_property", "delete_view",
|
||||
}
|
||||
|
||||
|
||||
class PermissionManager:
|
||||
"""Resolves workspace role and gates agent tool calls."""
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
self.user_id = user_id
|
||||
|
||||
# ── Role resolution ──
|
||||
|
||||
def role_in_workspace(self, workspace_id: int | None) -> str:
|
||||
"""Return the user's role for a workspace (owner > member role)."""
|
||||
if workspace_id is None:
|
||||
# No workspace → fall back to the most permissive own-content model.
|
||||
return "owner"
|
||||
with get_conn() as conn:
|
||||
member = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
if member:
|
||||
return member["role"] or "editor"
|
||||
owner = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
return "owner" if owner else "viewer"
|
||||
|
||||
def can_read(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in READ_ROLES
|
||||
|
||||
def can_write(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in WRITE_ROLES
|
||||
|
||||
def can_destructive(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in DESTRUCTIVE_ROLES
|
||||
|
||||
# ── Gate for the engine ──
|
||||
|
||||
def assert_can(self, tool: str, args: dict, workspace_id: int | None,
|
||||
approval_mode: str = "auto") -> None:
|
||||
"""Raise HTTPException if the tool call exceeds the user's permissions.
|
||||
|
||||
- read tools: any authenticated user in the workspace (viewer+).
|
||||
- write tools: editor+.
|
||||
- destructive tools: admin/owner, or requires confirm approval mode.
|
||||
"""
|
||||
role = self.role_in_workspace(workspace_id)
|
||||
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
|
||||
)
|
||||
if tool in DESTRUCTIVE_TOOLS:
|
||||
if role not in DESTRUCTIVE_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Agent tool '{tool}' is destructive and requires admin/owner "
|
||||
f"(user is '{role}')",
|
||||
)
|
||||
if approval_mode != "confirm":
|
||||
raise HTTPException(
|
||||
status_code=428, # Precondition Required
|
||||
detail=f"Destructive tool '{tool}' requires approval (confirm mode)",
|
||||
)
|
||||
# A viewer can always read; editor can read+write.
|
||||
if role not in READ_ROLES:
|
||||
raise HTTPException(status_code=403, detail="User has no access to this workspace")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FlowDeck — v5.2.0 Projects: normalized forge-agnostic project registry.
|
||||
|
||||
The ``projects`` table stores one row per repository across forges (builtin /
|
||||
gitea / github). A background scheduler refreshes metadata (default branch,
|
||||
language) periodically so the UI always shows up-to-date info.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.forge_adapter import GiteaAdapter, normalize_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_repo(repo: dict, proj_type: str) -> int | None:
|
||||
"""Upsert a forge repo into the projects table. Returns project id."""
|
||||
data = normalize_repo(repo, proj_type)
|
||||
if not data["name"]:
|
||||
return None
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM projects WHERE proj_type=? AND owner=? AND name=?",
|
||||
(proj_type, data["owner"], data["name"]),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE projects SET forge_id=?, clone_url=?, default_branch=?,
|
||||
language=?, description=?, last_synced_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(data["forge_id"], data["clone_url"], data["default_branch"],
|
||||
data["language"], data["description"], existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return existing["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO projects
|
||||
(name, proj_type, owner, forge_id, clone_url, default_branch, language, description, last_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
||||
(data["name"], proj_type, data["owner"], data["forge_id"], data["clone_url"],
|
||||
data["default_branch"], data["language"], data["description"]),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_projects(proj_type: str | None = None) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
if proj_type:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM projects WHERE proj_type=? ORDER BY name",
|
||||
(proj_type,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, name").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_builtin_project(name: str, owner: str = "", description: str = "") -> dict:
|
||||
"""Register a standalone (non-forge) project."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM projects WHERE proj_type='builtin' AND owner=? AND name=?",
|
||||
(owner, name),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {"id": existing["id"], "name": name}
|
||||
cur = conn.execute(
|
||||
"INSERT INTO projects (name, proj_type, owner, description) VALUES (?, 'builtin', ?, ?)",
|
||||
(name, owner, description),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "name": name}
|
||||
|
||||
|
||||
# ═══════════ Periodic sync ═══════════
|
||||
|
||||
|
||||
async def _sync_gitea(user_id: int, token: str) -> int:
|
||||
from app.services.gitea_client import GiteaClient
|
||||
gitea = GiteaClient(user_token=token)
|
||||
adapter = GiteaAdapter(gitea)
|
||||
repos = await adapter.list_repos(page=1)
|
||||
count = 0
|
||||
for repo in repos:
|
||||
if register_repo(repo, "gitea"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def _sync_github(user_id: int, token: str) -> int:
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
adapter = GitHubAdapter(token)
|
||||
repos = await adapter.list_all_repos()
|
||||
count = 0
|
||||
for repo in repos:
|
||||
if register_repo(repo, "github"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def sync_all_projects() -> dict:
|
||||
"""Refresh the projects table from every connected forge token."""
|
||||
stats = {"gitea": 0, "github": 0, "error": 0}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT user_id, provider, access_token FROM user_oauth_tokens "
|
||||
"WHERE provider IN ('gitea','github') AND access_token != ''"
|
||||
).fetchall()
|
||||
tokens = [dict(r) for r in rows]
|
||||
|
||||
for t in tokens:
|
||||
try:
|
||||
if t["provider"] == "gitea":
|
||||
stats["gitea"] += await _sync_gitea(t["user_id"], t["access_token"])
|
||||
elif t["provider"] == "github":
|
||||
stats["github"] += await _sync_github(t["user_id"], t["access_token"])
|
||||
except Exception as exc:
|
||||
stats["error"] += 1
|
||||
logger.warning("project sync (%s user=%s) failed: %s", t["provider"], t["user_id"], exc)
|
||||
logger.info("projects sync done: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
async def project_sync_scheduler():
|
||||
"""Background loop: refresh projects every interval (default hourly)."""
|
||||
while True:
|
||||
if settings.project_sync_enabled:
|
||||
try:
|
||||
await sync_all_projects()
|
||||
except Exception as exc:
|
||||
logger.warning("project_sync_scheduler error: %s", exc)
|
||||
await __import__("asyncio").sleep(settings.project_sync_interval_hours * 3600)
|
||||
@@ -2,8 +2,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
# ── Property type definitions ──
|
||||
|
||||
@@ -106,7 +106,7 @@ SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
|
||||
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
|
||||
|
||||
|
||||
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
|
||||
def validate_property_value(prop_type: str, value: Any, options: list | None = None) -> tuple[bool, str]:
|
||||
"""Validate a property value against its type. Returns (ok, error_message)."""
|
||||
if value is None:
|
||||
return True, ""
|
||||
@@ -149,10 +149,88 @@ def validate_property_value(prop_type: str, value: Any, options: Optional[list]
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
|
||||
def parse_validation(validation) -> dict:
|
||||
"""Normalize a property's ``validation_json`` into a config dict."""
|
||||
if validation is None:
|
||||
return {}
|
||||
if isinstance(validation, dict):
|
||||
cfg = dict(validation)
|
||||
else:
|
||||
try:
|
||||
cfg = json.loads(validation) if validation else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
cfg = {}
|
||||
return {
|
||||
"required": bool(cfg.get("required", False)),
|
||||
"unique": bool(cfg.get("unique", False)),
|
||||
"min": cfg.get("min"),
|
||||
"max": cfg.get("max"),
|
||||
"min_length": cfg.get("min_length"),
|
||||
"max_length": cfg.get("max_length"),
|
||||
}
|
||||
|
||||
|
||||
def validate_property_rule(
|
||||
prop_type: str,
|
||||
value: Any,
|
||||
validation: dict | None = None,
|
||||
*,
|
||||
existing_values: list | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Validate a property value against type + validation rules.
|
||||
|
||||
Returns ``(ok, error_message)``. ``existing_values`` is used to enforce the
|
||||
``unique`` rule (a list of the values already stored for that property).
|
||||
"""
|
||||
cfg = parse_validation(validation)
|
||||
|
||||
# Type-level validation first.
|
||||
ok, msg = validate_property_value(prop_type, value)
|
||||
if not ok:
|
||||
return False, msg
|
||||
|
||||
# Empty / required
|
||||
is_empty = value is None or value == "" or (isinstance(value, list) and len(value) == 0)
|
||||
if is_empty:
|
||||
if cfg.get("required"):
|
||||
return False, "This property is required"
|
||||
return True, ""
|
||||
|
||||
# Length bounds (string types)
|
||||
if isinstance(value, str):
|
||||
if cfg.get("min_length") is not None and len(value) < int(cfg["min_length"]):
|
||||
return False, f"Must be at least {int(cfg['min_length'])} characters"
|
||||
if cfg.get("max_length") is not None and len(value) > int(cfg["max_length"]):
|
||||
return False, f"Must be at most {int(cfg['max_length'])} characters"
|
||||
|
||||
# Numeric bounds
|
||||
if prop_type == "number" or (isinstance(value, (int, float)) and not isinstance(value, bool)):
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
num = None
|
||||
if num is not None:
|
||||
if cfg.get("min") is not None and num < float(cfg["min"]):
|
||||
return False, f"Must be greater than or equal to {cfg['min']}"
|
||||
if cfg.get("max") is not None and num > float(cfg["max"]):
|
||||
return False, f"Must be less than or equal to {cfg['max']}"
|
||||
|
||||
# Unique
|
||||
if cfg.get("unique") and existing_values is not None:
|
||||
norm = str(value).strip().lower()
|
||||
for ev in existing_values:
|
||||
if ev is None:
|
||||
continue
|
||||
if str(ev).strip().lower() == norm:
|
||||
return False, "Value already exists (must be unique)"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
|
||||
"""Compute the value of an auto-property."""
|
||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
return datetime.now(UTC).isoformat()
|
||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
||||
if user:
|
||||
return {"id": user.get("id"), "login": user.get("login")}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge des opérations de blocs (last-write-wins par bloc) + version de page.
|
||||
|
||||
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
|
||||
avec debounce. Fallback polling côté client si le WS est indisponible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
|
||||
|
||||
|
||||
def color_for(uid: int) -> str:
|
||||
return COLORS[(uid or 0) % len(COLORS)]
|
||||
|
||||
|
||||
def block_id() -> str:
|
||||
import time
|
||||
return f"b{int(time.time()*1000)}"
|
||||
|
||||
|
||||
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
|
||||
t = op.get("type")
|
||||
if t == "insert":
|
||||
blk = op.get("block") or {}
|
||||
if not blk.get("id"):
|
||||
blk = dict(blk)
|
||||
blk["id"] = block_id()
|
||||
idx = op.get("index")
|
||||
if not isinstance(idx, int):
|
||||
idx = len(blocks)
|
||||
idx = max(0, min(idx, len(blocks)))
|
||||
return blocks[:idx] + [blk] + blocks[idx:]
|
||||
if t == "update":
|
||||
nb = op.get("block") or {}
|
||||
if not nb.get("id"):
|
||||
return blocks
|
||||
return [nb if b.get("id") == nb["id"] else b for b in blocks]
|
||||
if t == "delete":
|
||||
bid = op.get("id")
|
||||
return [b for b in blocks if b.get("id") != bid]
|
||||
if t == "move":
|
||||
bid = op.get("id")
|
||||
idx = op.get("index", 0) or 0
|
||||
out = [b for b in blocks if b.get("id") != bid]
|
||||
idx = max(0, min(idx, len(out)))
|
||||
moved = next((b for b in blocks if b.get("id") == bid), None)
|
||||
if moved is None:
|
||||
return blocks
|
||||
out.insert(idx, moved)
|
||||
return out
|
||||
return blocks
|
||||
|
||||
|
||||
def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
|
||||
"""Apply a batch of ops sequentially (arrival order)."""
|
||||
out = blocks
|
||||
for op in ops or []:
|
||||
out = apply_op(out, op)
|
||||
return out
|
||||
|
||||
|
||||
class Room:
|
||||
__slots__ = ("page_id", "blocks", "title", "version", "conns",
|
||||
"persist_task", "dirty")
|
||||
|
||||
def __init__(self, page_id: int):
|
||||
self.page_id = page_id
|
||||
self.blocks: list[dict] = []
|
||||
self.title = ""
|
||||
self.version = 0
|
||||
self.conns: set[RTConn] = set()
|
||||
self.persist_task: asyncio.Task | None = None
|
||||
self.dirty = False
|
||||
|
||||
|
||||
class RTConn:
|
||||
__slots__ = ("ws", "user", "page_id")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
|
||||
|
||||
class RealtimeManager:
|
||||
def __init__(self):
|
||||
self._rooms: dict[int, Room] = {}
|
||||
|
||||
def room(self, page_id: int) -> Room:
|
||||
return self._rooms.setdefault(page_id, Room(page_id))
|
||||
|
||||
@staticmethod
|
||||
def _peer(user: dict) -> dict:
|
||||
uid = user.get("id") or 0
|
||||
return {
|
||||
"id": uid,
|
||||
"login": user.get("login", ""),
|
||||
"full_name": user.get("full_name", "") or user.get("login", ""),
|
||||
"color": color_for(uid),
|
||||
}
|
||||
|
||||
async def load_room(self, room: Room) -> bool:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(room.page_id,),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("realtime load failed: %s", e)
|
||||
return False
|
||||
if not row:
|
||||
return False
|
||||
room.title = row["title"] or ""
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
try:
|
||||
room.blocks = json.loads(row["content"])
|
||||
except Exception:
|
||||
room.blocks = []
|
||||
return True
|
||||
|
||||
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
|
||||
room = self.room(page_id)
|
||||
if not room.conns and not await self.load_room(room):
|
||||
await ws.close(code=4404)
|
||||
return None
|
||||
conn = RTConn(ws, user, page_id)
|
||||
room.conns.add(conn)
|
||||
me = self._peer(user)
|
||||
peers = [self._peer(c.user) for c in room.conns if c is not conn]
|
||||
await ws.send_json({"t": "welcome", "self": me,
|
||||
"peers": peers, "color": me["color"]})
|
||||
await ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
for c in room.conns:
|
||||
if c is not conn:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_join", "peer": me})
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
async def disconnect(self, conn: RTConn):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
return
|
||||
room.conns.discard(conn)
|
||||
for c in room.conns:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
except Exception:
|
||||
pass
|
||||
if not room.conns:
|
||||
await self.flush(room)
|
||||
self._rooms.pop(conn.page_id, None)
|
||||
|
||||
async def flush(self, room: Room):
|
||||
"""Write current room state to DB (sync, used on idle + disconnect)."""
|
||||
if room.persist_task and not room.persist_task.done():
|
||||
room.persist_task.cancel()
|
||||
await self._persist(room)
|
||||
|
||||
async def _persist(self, room: Room):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(room.blocks, ensure_ascii=False), room.title, room.page_id),
|
||||
)
|
||||
conn.commit()
|
||||
room.dirty = False
|
||||
except Exception as e:
|
||||
logger.warning("realtime persist failed: %s", e)
|
||||
|
||||
def _schedule_persist(self, room: Room):
|
||||
if room.persist_task and not room.persist_task.done():
|
||||
return
|
||||
room.dirty = True
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await asyncio.sleep(1.2)
|
||||
await self._persist(room)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
room.persist_task = asyncio.create_task(_run())
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
for c in room.conns:
|
||||
if c is exclude:
|
||||
continue
|
||||
try:
|
||||
await c.ws.send_json(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def handle(self, conn: RTConn, msg: dict):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
return
|
||||
t = msg.get("t")
|
||||
me = (conn.user.get("id") or 0)
|
||||
|
||||
if t == "hello":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "sync_req":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "ping":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "pong"})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "op":
|
||||
op = msg.get("op") or {}
|
||||
client_v = msg.get("v", 0)
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
room.version += 1
|
||||
self._schedule_persist(room)
|
||||
stale = client_v < room.version - 1
|
||||
await self._broadcast(room, {"t": "op", "op": op, "from": me,
|
||||
"v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": stale})
|
||||
except Exception:
|
||||
pass
|
||||
if stale:
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync",
|
||||
"blocks": room.blocks,
|
||||
"title": room.title,
|
||||
"version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "title":
|
||||
fmt = (msg.get("title") or "").strip()
|
||||
if fmt and fmt != room.title:
|
||||
room.title = fmt
|
||||
room.version += 1
|
||||
self._schedule_persist(room)
|
||||
await self._broadcast(room, {"t": "title", "title": room.title,
|
||||
"from": me, "v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": False})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "sel":
|
||||
await self._broadcast(room, {"t": "sel", "from": me,
|
||||
"peer": self._peer(conn.user),
|
||||
"block": msg.get("block"),
|
||||
"offset": msg.get("offset", 0)}, exclude=conn)
|
||||
return
|
||||
|
||||
async def room_state(self, page_id: int) -> dict:
|
||||
room = self.room(page_id)
|
||||
if not room.conns and not room.blocks:
|
||||
await self.load_room(room)
|
||||
return {"blocks": room.blocks, "title": room.title, "version": room.version}
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -105,12 +105,12 @@ class RollupEngine:
|
||||
return ROLLUP_FUNCTIONS[rollup_function](values)
|
||||
|
||||
|
||||
def _safe_avg(values: list) -> Optional[float]:
|
||||
def _safe_avg(values: list) -> float | None:
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
return sum(nums) / len(nums) if nums else None
|
||||
|
||||
|
||||
def _safe_stat(values: list, fn) -> Optional[float]:
|
||||
def _safe_stat(values: list, fn) -> float | None:
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
return fn(nums) if nums else None
|
||||
|
||||
@@ -123,12 +123,12 @@ def _numeric(gen):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_range(values: list) -> Optional[float]:
|
||||
def _safe_range(values: list) -> float | None:
|
||||
nums = list(_numeric(v for v in values if v is not None))
|
||||
return max(nums) - min(nums) if len(nums) >= 2 else None
|
||||
|
||||
|
||||
def _percent_checked(values: list) -> Optional[float]:
|
||||
def _percent_checked(values: list) -> float | None:
|
||||
"""Percentage of true values (for checkbox properties)."""
|
||||
if not values:
|
||||
return 0.0
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""FlowDeck — unified search (v5.0.0).
|
||||
|
||||
Powers the Ctrl+K command palette. Searches editor pages and databases
|
||||
(collections) with SQLite FTS5 when available, falling back to ``LIKE`` scans
|
||||
otherwise. Results are scoped to the workspaces the current user can access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.db import get_conn
|
||||
from app.migrations import fts5_available
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── FTS5 helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _fts_terms(query: str) -> list[str]:
|
||||
"""Split a user query into safe FTS5 prefix terms."""
|
||||
tokens = re.findall(r"[\wÀ-ÿ]+", query, flags=re.UNICODE)
|
||||
return [t.replace('"', '""') for t in tokens if t]
|
||||
|
||||
|
||||
def _fts_match(query: str) -> str | None:
|
||||
"""Build a MATCH expression, or None when the query is not FTS-safe."""
|
||||
terms = _fts_terms(query)
|
||||
if not terms:
|
||||
return None
|
||||
return " AND ".join(f'"{t}"*' for t in terms)
|
||||
|
||||
|
||||
def _has_fts_table(conn) -> bool:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _extract_plain_text(content: str, content_format: str) -> str:
|
||||
"""Return a readable one-line excerpt for a page raw ``content`` value."""
|
||||
if not content:
|
||||
return ""
|
||||
fmt = content_format or "blocks"
|
||||
if fmt == "blocks":
|
||||
try:
|
||||
import json as _json
|
||||
blocks = _json.loads(content)
|
||||
parts = []
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
if isinstance(b, dict):
|
||||
text = b.get("content") or b.get("text") or ""
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
for child in (b.get("children") or []):
|
||||
if isinstance(child, dict) and (child.get("content") or child.get("text")):
|
||||
parts.append(str(child.get("content") or child.get("text")).strip())
|
||||
return " ".join(parts)
|
||||
except Exception:
|
||||
return content
|
||||
if fmt == "file":
|
||||
return ""
|
||||
return content
|
||||
|
||||
|
||||
def _workspace_name(conn, workspace_id) -> str:
|
||||
if not workspace_id:
|
||||
return ""
|
||||
try:
|
||||
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
return row["name"] if row else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _scope_where(user_id: int | None) -> tuple[str, list]:
|
||||
"""SQL filter restricting results to the user's accessible workspaces."""
|
||||
if user_id is None:
|
||||
return "1=1", []
|
||||
return (
|
||||
"(workspace_id IS NULL OR workspace_id IN ("
|
||||
" SELECT id FROM workspaces WHERE owner_id = ? "
|
||||
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?))",
|
||||
[user_id, user_id],
|
||||
)
|
||||
|
||||
|
||||
# ── Search entry point ──────────────────────────────────────────────────────
|
||||
|
||||
def search(query: str, user_id: int | None = None, limit: int = 20) -> dict:
|
||||
"""Return unified search results: ``{pages: [...], collections: [...]}``."""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return {"pages": [], "collections": []}
|
||||
|
||||
with get_conn() as conn:
|
||||
pages = _search_pages(conn, q, user_id, limit)
|
||||
collections = _search_collections(conn, q, user_id, limit)
|
||||
return {"pages": pages, "collections": collections}
|
||||
|
||||
|
||||
def _search_pages(conn, query: str, user_id: int | None, limit: int) -> list:
|
||||
like = f"%{query}%"
|
||||
scope, params = _scope_where(user_id)
|
||||
|
||||
# 1) FTS5 fast path.
|
||||
if fts5_available() and _has_fts_table(conn):
|
||||
match = _fts_match(query)
|
||||
if match:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT p.id, p.title, p.content, p.content_format,
|
||||
p.workspace_id, p.content_format
|
||||
FROM pages_fts f
|
||||
JOIN pages p ON p.id = f.rowid
|
||||
WHERE pages_fts MATCH ? AND p.deleted_at IS NULL AND {scope}
|
||||
ORDER BY rank LIMIT ?
|
||||
""",
|
||||
[match, *params, limit],
|
||||
).fetchall()
|
||||
return _page_rows_to_results(conn, rows)
|
||||
except Exception as exc: # FTS syntax/edge case → fall through to LIKE
|
||||
logger.debug("FTS search failed (%s); fallback to LIKE", exc)
|
||||
|
||||
# 2) LIKE fallback.
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT p.id, p.title, p.content, p.content_format, p.workspace_id
|
||||
FROM pages p
|
||||
WHERE p.deleted_at IS NULL AND {scope}
|
||||
AND (p.title LIKE ? OR p.content LIKE ?)
|
||||
ORDER BY p.updated_at DESC LIMIT ?
|
||||
""",
|
||||
[*params, like, like, limit],
|
||||
).fetchall()
|
||||
return _page_rows_to_results(conn, rows)
|
||||
|
||||
|
||||
def _search_collections(conn, query: str, user_id: int | None, limit: int) -> list:
|
||||
like = f"%{query}%"
|
||||
scope, params = _scope_where(user_id)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT c.id, c.name, c.description, c.icon, c.workspace_id
|
||||
FROM collections c
|
||||
WHERE {scope}
|
||||
AND (c.name LIKE ? OR c.description LIKE ?)
|
||||
ORDER BY c.updated_at DESC LIMIT ?
|
||||
""",
|
||||
[*params, like, like, limit],
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"type": "collection",
|
||||
"title": r["name"] or "Untitled",
|
||||
"subtitle": "Database" + (f" · {_workspace_name(conn, r['workspace_id'])}" if r["workspace_id"] else ""),
|
||||
"icon": (r["icon"] or "📋"),
|
||||
"url": f"/db/{r['id']}",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _page_rows_to_results(conn, rows) -> list:
|
||||
results = []
|
||||
for r in rows:
|
||||
title = (r["title"] or "Untitled").strip() or "Untitled"
|
||||
ws = _workspace_name(conn, r["workspace_id"])
|
||||
subtitle = ws or "Page"
|
||||
excerpt = _extract_plain_text(r["content"], r["content_format"])
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"type": "page",
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"icon": "file",
|
||||
"excerpt": excerpt[:160],
|
||||
"url": f"/pages/{r['id']}",
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,918 @@
|
||||
"""FlowDeck — Agent tool registry (v4.14.0).
|
||||
|
||||
The agent never re-invents FlowDeck: each tool is a thin wrapper over the same
|
||||
operations the human-facing routers perform (create collection/page/property,
|
||||
manage views, dependencies, Gitea issues, templates, workspaces & documents).
|
||||
Every mutating tool returns an *undo snapshot* so AgentEngine can journal and
|
||||
roll back each action.
|
||||
|
||||
Since v4.14.0 the registry also covers *documents* (Notion-style pages that
|
||||
live inside a workspace, table ``pages``) and lets the agent read the list of
|
||||
workspaces — fixing the case where "crée un document dans le workspace X"
|
||||
used to end with no action.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
status: str # success | error | reverted
|
||||
tool: str
|
||||
target_type: str = ""
|
||||
target_id: Any = None
|
||||
message: str = ""
|
||||
data: dict = field(default_factory=dict)
|
||||
undo: dict = field(default_factory=dict) # snapshot to restore on rollback
|
||||
|
||||
|
||||
class Tool:
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
parameters: dict = field(default_factory=dict)
|
||||
|
||||
async def execute(self, args: dict, *, user_id: int | None = None) -> ToolResult: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ══════════════════════════ Read tools ══════════════════════════
|
||||
|
||||
|
||||
class SearchWorkspace(Tool):
|
||||
name = "search_workspace"
|
||||
description = ("Recherche full-text (par titre) dans tout FlowDeck : collections, pages de "
|
||||
"collection, documents (pages éditeur) et espaces de travail.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "terme recherché"}},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
q = f"%{args.get('query', '').strip()}%"
|
||||
with get_conn() as conn:
|
||||
colls = conn.execute("SELECT id, name, icon FROM collections WHERE name LIKE ? ORDER BY name", (q,)).fetchall()
|
||||
pages = conn.execute("SELECT id, collection_id, title FROM collection_pages WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,)).fetchall()
|
||||
docs = conn.execute(
|
||||
"SELECT id, title, workspace_id, content_format FROM pages "
|
||||
"WHERE deleted_at IS NULL AND title LIKE ? ORDER BY updated_at DESC LIMIT 20",
|
||||
(q,),
|
||||
).fetchall()
|
||||
workspaces = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE name LIKE ? ORDER BY name", (q,),
|
||||
).fetchall()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="search",
|
||||
data={
|
||||
"collections": [dict(c) for c in colls],
|
||||
"pages": [dict(p) for p in pages],
|
||||
"documents": [dict(d) for d in docs],
|
||||
"workspaces": [dict(w) for w in workspaces],
|
||||
},
|
||||
message=(f"{len(colls)} collection(s), {len(pages)} page(s), "
|
||||
f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(s)"),
|
||||
)
|
||||
|
||||
|
||||
class ReadCollection(Tool):
|
||||
name = "read_collection"
|
||||
description = "Lit le schéma (propriétés + vues) d'une collection."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"collection_id": {"type": "integer"}},
|
||||
"required": ["collection_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid = args.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
props = conn.execute("SELECT id, name, prop_type, options_json FROM collection_properties WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
|
||||
views = conn.execute("SELECT id, name, view_type, config_json FROM collection_views WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
|
||||
pages = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||
data={
|
||||
"collection": dict(coll),
|
||||
"properties": [dict(p) for p in props],
|
||||
"views": [dict(v) for v in views],
|
||||
"pages": [dict(p) for p in pages],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ReadPage(Tool):
|
||||
name = "read_page"
|
||||
description = "Lit une page d'une collection (propriétés + valeurs)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"page_id": {"type": "integer"}},
|
||||
"required": ["page_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
if not page:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
|
||||
deps = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (pid,)).fetchall()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=pid,
|
||||
data={"page": dict(page), "dependencies": [dict(d) for d in deps]},
|
||||
)
|
||||
|
||||
|
||||
class ReadWorkspaces(Tool):
|
||||
name = "read_workspaces"
|
||||
description = ("Liste les espaces de travail accessibles (id, nom, rôle) avec le nombre de "
|
||||
"documents et de collections qu'ils contiennent — utile pour savoir où créer "
|
||||
"ou chercher un document.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "filtre optionnel sur le nom"}},
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
query = (args.get("query") or "").strip()
|
||||
base_sql = (
|
||||
"SELECT w.id, w.name, {role} AS role, "
|
||||
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count, "
|
||||
"(SELECT COUNT(*) FROM collections c WHERE c.workspace_id=w.id) AS collection_count "
|
||||
"FROM workspaces w {join} {where} ORDER BY w.name"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
if user_id is None:
|
||||
rows = conn.execute(
|
||||
base_sql.format(role="'owner'", join="", where=""), []
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
base_sql.format(
|
||||
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
|
||||
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
|
||||
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
|
||||
),
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
data = [dict(r) for r in rows]
|
||||
if query:
|
||||
q = query.lower()
|
||||
data = [w for w in data if q in str(w.get("name", "")).lower()]
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="workspaces",
|
||||
data={"workspaces": data},
|
||||
message=f"{len(data)} espace(s) de travail",
|
||||
)
|
||||
|
||||
|
||||
class ReadDocument(Tool):
|
||||
name = "read_document"
|
||||
description = "Lit un document (page éditeur) : titre, contenu et métadonnées."
|
||||
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
||||
if not doc:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
data={"document": dict(doc)},
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Write tools (with undo) ══════════════════════════
|
||||
|
||||
|
||||
class CreateCollection(Tool):
|
||||
name = "create_collection"
|
||||
description = "Crée une collection (base de données) avec sa vue par défaut."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"icon": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
name = (args.get("name") or "").strip()
|
||||
if not name:
|
||||
return ToolResult(status="error", tool=self.name, message="name est requis")
|
||||
description = args.get("description", "")
|
||||
icon = args.get("icon", "📋")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,'[]')",
|
||||
(name, description, icon),
|
||||
)
|
||||
cid = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
|
||||
(cid, "Default View", "table", json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||
data={"collection_id": cid, "name": name},
|
||||
undo={"action": "delete", "table": "collections", "id": cid},
|
||||
)
|
||||
|
||||
|
||||
class CreateView(Tool):
|
||||
name = "create_view"
|
||||
description = "Crée une vue (table/board/calendar/gallery/list/timeline/gantt/chart/form/map/feed) sur une collection."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection_id": {"type": "integer"},
|
||||
"view_type": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["collection_id", "view_type"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid, vtype = args.get("collection_id"), args.get("view_type", "table")
|
||||
name = args.get("name", vtype.title())
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?,?,?,?,?)",
|
||||
(cid, name, vtype, json.dumps({}), max_pos),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="view", target_id=cur.lastrowid,
|
||||
data={"view_id": cur.lastrowid, "collection_id": cid, "view_type": vtype},
|
||||
undo={"action": "delete", "table": "collection_views", "id": cur.lastrowid},
|
||||
)
|
||||
|
||||
|
||||
class AddProperty(Tool):
|
||||
name = "add_property"
|
||||
description = "Ajoute une propriété typée à une collection."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection_id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"prop_type": {"type": "string"},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["collection_id", "name"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid = args.get("collection_id")
|
||||
name = (args.get("name") or "").strip()
|
||||
prop_type = args.get("prop_type", "text")
|
||||
options = args.get("options", [])
|
||||
if not name:
|
||||
return ToolResult(status="error", tool=self.name, message="name est requis")
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?,?,?,?,?)",
|
||||
(cid, name, prop_type, json.dumps(options), max_pos),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Propriété '{name}' existe déjà")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="property", target_id=cur.lastrowid,
|
||||
data={"property_id": cur.lastrowid, "name": name, "prop_type": prop_type},
|
||||
undo={"action": "delete", "table": "collection_properties", "id": cur.lastrowid},
|
||||
)
|
||||
|
||||
|
||||
class CreatePage(Tool):
|
||||
name = "create_page"
|
||||
description = "Crée une page dans une collection avec les valeurs de ses propriétés."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection_id": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"properties": {"type": "object", "description": "map name→valeur"},
|
||||
},
|
||||
"required": ["collection_id", "title"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid = args.get("collection_id")
|
||||
title = (args.get("title") or "").strip()
|
||||
if not title:
|
||||
return ToolResult(status="error", tool=self.name, message="title est requis")
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_locked FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
if coll["is_locked"]:
|
||||
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
props = self._resolve_properties(conn, cid, args.get("properties", {}))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(cid, title, max_pos, json.dumps(props, ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
|
||||
data={"page_id": cur.lastrowid, "title": title},
|
||||
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_properties(conn, cid, values: dict) -> dict:
|
||||
"""Map human property names → property ids for the JSON blob."""
|
||||
props = conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (cid,)).fetchall()
|
||||
id_by_name = {r["name"]: r["id"] for r in props}
|
||||
out = {}
|
||||
for k, v in (values or {}).items():
|
||||
key = id_by_name.get(k, k)
|
||||
if isinstance(v, list):
|
||||
out[str(key)] = v
|
||||
else:
|
||||
out[str(key)] = v
|
||||
return out
|
||||
|
||||
|
||||
class CreateDocument(Tool):
|
||||
name = "create_document"
|
||||
description = ("Crée un document (page éditeur type Notion) dans un espace de travail, ou à la "
|
||||
"racine. Le contenu initial est optionnel (Markdown).")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "titre du document"},
|
||||
"content": {"type": "string", "description": "contenu initial en Markdown (optionnel)"},
|
||||
"workspace_id": {"type": "integer", "description": "id de l'espace de travail cible (optionnel)"},
|
||||
"workspace_name": {"type": "string", "description": "nom exact de l'espace de travail cible (optionnel)"},
|
||||
"parent_id": {"type": "integer", "description": "document parent (sous-page) optionnel"},
|
||||
},
|
||||
"required": ["title"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
title = (args.get("title") or "").strip()
|
||||
if not title:
|
||||
return ToolResult(status="error", tool=self.name, message="title est requis")
|
||||
content = (args.get("content") or "").strip()
|
||||
ws_id = args.get("workspace_id")
|
||||
ws_name = (args.get("workspace_name") or "").strip()
|
||||
parent_id = args.get("parent_id") or None
|
||||
|
||||
with get_conn() as conn:
|
||||
workspace = None
|
||||
if ws_id is not None:
|
||||
workspace = conn.execute("SELECT * FROM workspaces WHERE id=?", (ws_id,)).fetchone()
|
||||
if not workspace:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Workspace #{ws_id} introuvable")
|
||||
elif ws_name:
|
||||
workspace = conn.execute(
|
||||
"SELECT * FROM workspaces WHERE lower(name)=lower(?) ORDER BY id LIMIT 1",
|
||||
(ws_name,),
|
||||
).fetchone()
|
||||
if not workspace:
|
||||
available = conn.execute("SELECT name FROM workspaces ORDER BY name").fetchall()
|
||||
names = ", ".join(r["name"] for r in available) or "aucun"
|
||||
return ToolResult(
|
||||
status="error", tool=self.name,
|
||||
message=f"Workspace « {ws_name} » introuvable. Disponibles : {names}",
|
||||
)
|
||||
if parent_id:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(parent_id,)).fetchone():
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document parent #{parent_id} introuvable")
|
||||
|
||||
w = dict(workspace) if workspace else None
|
||||
ws_id_val = w["id"] if w else (ws_id if ws_id is not None else None)
|
||||
ws_key = (w["name"] if w else "") if ws_id_val is not None else ""
|
||||
|
||||
if content:
|
||||
fmt, store = "markdown", content
|
||||
else:
|
||||
fmt, store = "blocks", "[]"
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format,
|
||||
parent_section, parent_id, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,0)""",
|
||||
(ws_key, ws_id_val, title, store, fmt, "Private", parent_id),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
loc = f" dans « {ws_key} »" if ws_key else ""
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
data={"document_id": pid, "title": title,
|
||||
"workspace_id": ws_id_val, "workspace": ws_key},
|
||||
message=f"Document « {title} » créé{loc}",
|
||||
undo={"action": "delete", "table": "pages", "id": pid},
|
||||
)
|
||||
|
||||
|
||||
class UpdatePage(Tool):
|
||||
name = "update_page"
|
||||
description = "Modifie le titre et/ou les valeurs de propriétés d'une page."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"properties": {"type": "object"},
|
||||
},
|
||||
"required": ["page_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
if not page:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
|
||||
coll = conn.execute("SELECT is_locked FROM collections WHERE id=?", (page["collection_id"],)).fetchone()
|
||||
if coll and coll["is_locked"]:
|
||||
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
|
||||
new_title = args.get("title", page["title"])
|
||||
props = json.loads(page["property_values_json"])
|
||||
if args.get("properties"):
|
||||
props.update(args["properties"])
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET title=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_title, json.dumps(props, ensure_ascii=False), pid),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=pid,
|
||||
data={"page_id": pid, "title": new_title},
|
||||
undo={"action": "update", "table": "collection_pages", "id": pid,
|
||||
"snapshot": json.loads(page["property_values_json"]), "title": page["title"]},
|
||||
)
|
||||
|
||||
|
||||
class WriteBlocks(Tool):
|
||||
name = "write_blocks"
|
||||
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
|
||||
"et/ou renomme son titre. Chaque bloc est un objet JSON "
|
||||
"{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : "
|
||||
"paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, "
|
||||
"numbered_list, to_do, quote, divider, toggle, callout, code. "
|
||||
"Utilise `type: \"code\"` (avec `language`) pour un script ou du code "
|
||||
"multi-lignes, `heading_1`/`heading_2`/… pour les titres. "
|
||||
"Le champ contenant le texte s'appelle `content`, PAS `text`.")
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
|
||||
"blocks": {"type": "array",
|
||||
"description": "nouveau contenu en blocs (remplace le contenu). "
|
||||
"Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, "
|
||||
"{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"},
|
||||
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
|
||||
},
|
||||
"required": ["page_id"],
|
||||
}
|
||||
|
||||
_VALID_BLOCK_TYPES = frozenset({
|
||||
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
|
||||
"bulleted_list", "numbered_list", "to_do", "toggle", "quote",
|
||||
"callout", "table_of_contents", "math", "columns", "divider",
|
||||
"code", "table", "button", "image", "embed", "bookmark",
|
||||
"video", "audio", "meeting",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _normalize_blocks(cls, blocks: list) -> list:
|
||||
"""Normalize blocks coming from the LLM so the editor can render them.
|
||||
|
||||
Common LLM mistakes handled:
|
||||
- ``text`` instead of ``content``
|
||||
- ``heading`` instead of ``heading_1`` / ``heading_2`` / …
|
||||
- ``bullet`` / ``list`` instead of ``bulleted_list``
|
||||
- ``numbered`` instead of ``numbered_list``
|
||||
- ``check`` / ``checkbox`` instead of ``to_do``
|
||||
- ``code_block`` instead of ``code``
|
||||
- ``h1`` … ``h6`` shorthand
|
||||
- Missing ``id`` fields (editor assigns them on load but we add them
|
||||
to keep the JSON self-contained)
|
||||
"""
|
||||
import uuid
|
||||
|
||||
_TYPE_MAP = {
|
||||
"heading": "heading_1", "h1": "heading_1", "h2": "heading_2",
|
||||
"h3": "heading_3", "h4": "heading_4", "h5": "heading_4",
|
||||
"h6": "heading_4",
|
||||
"bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list",
|
||||
"numbered": "numbered_list", "numbered_list": "numbered_list",
|
||||
"check": "to_do", "checkbox": "to_do", "task": "to_do",
|
||||
"code_block": "code",
|
||||
"hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider",
|
||||
}
|
||||
|
||||
out = []
|
||||
for block in (blocks or []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
b = dict(block)
|
||||
# --- normalize type ---
|
||||
raw_type = (b.get("type") or "paragraph").strip().lower()
|
||||
btype = _TYPE_MAP.get(raw_type, raw_type)
|
||||
if btype not in cls._VALID_BLOCK_TYPES:
|
||||
btype = "paragraph"
|
||||
b["type"] = btype
|
||||
# --- normalize content field (LLM often sends "text" instead of "content") ---
|
||||
if "content" not in b and "text" in b:
|
||||
b["content"] = b.pop("text")
|
||||
elif "content" not in b:
|
||||
# Try other common fields
|
||||
for alt in ("value", "body", "source"):
|
||||
if alt in b:
|
||||
b["content"] = b.pop(alt)
|
||||
break
|
||||
else:
|
||||
b.setdefault("content", "")
|
||||
# --- generate id if missing ---
|
||||
if not b.get("id"):
|
||||
b["id"] = f"b_{uuid.uuid4().hex[:12]}"
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
has_blocks = "blocks" in args
|
||||
new_title = (args.get("title") or "").strip()
|
||||
if not has_blocks and not new_title:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message="Fournir `blocks` et/ou `title`")
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
if not page:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document (page éditeur) #{pid} introuvable")
|
||||
snapshot = {"content": page["content"], "content_format": page["content_format"],
|
||||
"title": page["title"]}
|
||||
final_title = new_title or page["title"]
|
||||
if has_blocks:
|
||||
normalized = self._normalize_blocks(args["blocks"])
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(normalized, ensure_ascii=False), final_title, pid),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(final_title, pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
data={"document_id": pid, "title": final_title, "blocks": len(args.get("blocks", []))},
|
||||
undo={"action": "update", "table": "pages", "id": pid, "snapshot": snapshot},
|
||||
)
|
||||
|
||||
|
||||
class AddRelation(Tool):
|
||||
name = "add_relation"
|
||||
description = "Lie deux collections via une propriété relation (avec réciproque)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection_id": {"type": "integer"},
|
||||
"related_collection_id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"reverse_name": {"type": "string"},
|
||||
},
|
||||
"required": ["collection_id", "related_collection_id", "name"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid = args.get("collection_id")
|
||||
rel = args.get("related_collection_id")
|
||||
name = (args.get("name") or "").strip()
|
||||
reverse = args.get("reverse_name", "")
|
||||
if not name or not rel:
|
||||
return ToolResult(status="error", tool=self.name, message="name et related_collection_id requis")
|
||||
with get_conn() as conn:
|
||||
for c in (cid, rel):
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (c,)).fetchone():
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{c} introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?,?,?,?,?,?)",
|
||||
(cid, name, "relation", rel, reverse, max_pos),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="property", target_id=prop_id,
|
||||
data={"property_id": prop_id, "relation": name},
|
||||
undo={"action": "delete", "table": "collection_properties", "id": prop_id},
|
||||
)
|
||||
|
||||
|
||||
class CreateSubItem(Tool):
|
||||
name = "create_sub_item"
|
||||
description = "Crée un sous-élément (sub-item) sous une page."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"collection_id": {"type": "integer"}, "page_id": {"type": "integer"}, "title": {"type": "string"}},
|
||||
"required": ["collection_id", "page_id", "title"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid, pid = args.get("collection_id"), args.get("page_id")
|
||||
title = (args.get("title") or "").strip()
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (pid, cid)).fetchone()
|
||||
if not parent:
|
||||
return ToolResult(status="error", tool=self.name, message="Page parent introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?", (pid,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?,?,?,?,?)",
|
||||
(cid, title, pid, max_pos, json.dumps({})),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
|
||||
data={"page_id": cur.lastrowid, "parent_id": pid},
|
||||
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
|
||||
)
|
||||
|
||||
|
||||
class AddDependency(Tool):
|
||||
name = "add_dependency"
|
||||
description = "Ajoute une dépendance (bloque) entre deux pages."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": {"type": "integer"},
|
||||
"dependency_id": {"type": "integer"},
|
||||
"dependency_type": {"type": "string"},
|
||||
},
|
||||
"required": ["page_id", "dependency_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid, dep = args.get("page_id"), args.get("dependency_id")
|
||||
dtype = args.get("dependency_type", "blocks")
|
||||
with get_conn() as conn:
|
||||
for p in (pid, dep):
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (p,)).fetchone():
|
||||
return ToolResult(status="error", tool=self.name, message=f"Page #{p} introuvable")
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?,?,?)",
|
||||
(pid, dep, dtype),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
return ToolResult(status="error", tool=self.name, message="Dépendance déjà existante")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="dependency", target_id=cur.lastrowid,
|
||||
data={"dependency_id": cur.lastrowid, "page_id": pid},
|
||||
undo={"action": "delete", "table": "page_dependencies", "id": cur.lastrowid},
|
||||
)
|
||||
|
||||
|
||||
class ApplyTemplate(Tool):
|
||||
name = "apply_template"
|
||||
description = "Applique un template de page dans une collection (crée une page)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
|
||||
"required": ["collection_id", "template_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid, tid = args.get("collection_id"), args.get("template_id")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
|
||||
if not tmpl:
|
||||
return ToolResult(status="error", tool=self.name, message="Template introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
|
||||
data={"page_id": cur.lastrowid},
|
||||
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Destructive tools ══════════════════════════
|
||||
|
||||
|
||||
class DeleteDocument(Tool):
|
||||
name = "delete_document"
|
||||
description = "Supprime un document (page éditeur) — envoi à la corbeille (destructif, mode confirm requis)."
|
||||
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from datetime import datetime
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(pid,)).fetchone()
|
||||
if not doc:
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document #{pid} introuvable")
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
message=f"Document #{pid} déplacé vers la corbeille",
|
||||
undo={"action": "softdelete", "table": "pages", "id": pid},
|
||||
)
|
||||
|
||||
|
||||
class DeletePage(Tool):
|
||||
name = "delete_page"
|
||||
description = "Supprime une page d'une collection (destructif, mode confirm requis)."
|
||||
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
if not page:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=pid,
|
||||
message="Page supprimée",
|
||||
undo={"action": "insert", "table": "collection_pages", "snapshot": dict(page)},
|
||||
)
|
||||
|
||||
|
||||
class DeleteCollection(Tool):
|
||||
name = "delete_collection"
|
||||
description = "Supprime une collection (destructif, mode confirm requis)."
|
||||
parameters = {"type": "object", "properties": {"collection_id": {"type": "integer"}}, "required": ["collection_id"]}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid = args.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||
message="Collection supprimée",
|
||||
undo={"action": "insert", "table": "collections", "snapshot": dict(coll)},
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Gitea tools ══════════════════════════
|
||||
|
||||
|
||||
class ReadGiteaIssues(Tool):
|
||||
name = "read_gitea_issues"
|
||||
description = "Lit les issues Gitea d'un repo (état, labels, milestones)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "state": {"type": "string"}},
|
||||
"required": ["owner", "repo"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.gitea_client import GiteaClient
|
||||
token = SessionManager.get_token(user_id) if user_id else None
|
||||
client = GiteaClient(user_token=token)
|
||||
owner, repo = args.get("owner"), args.get("repo")
|
||||
try:
|
||||
issues = await client.get_issues(owner, repo, state=args.get("state", "open"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="issues", target_id=f"{owner}/{repo}",
|
||||
data={"issues": issues, "count": len(issues)},
|
||||
message=f"{len(issues)} issues",
|
||||
)
|
||||
|
||||
|
||||
class SyncGitea(Tool):
|
||||
name = "sync_gitea"
|
||||
description = "Synchronise les issues d'un repo Gitea vers une collection."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
|
||||
"required": ["owner", "repo"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
from app.services.gitea_client import GiteaClient
|
||||
token = SessionManager.get_token(user_id) if user_id else None
|
||||
client = GiteaClient(user_token=token)
|
||||
owner, repo = args.get("owner"), args.get("repo")
|
||||
try:
|
||||
issues = await client.get_issues(owner, repo, state="all")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
|
||||
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=coll_id,
|
||||
data={"collection_id": coll_id, "issues": len(issues)},
|
||||
message=f"{len(issues)} issues synchronisées",
|
||||
undo={"action": "delete", "table": "collections", "id": coll_id} if coll_id else {},
|
||||
)
|
||||
|
||||
|
||||
class CreateGiteaIssue(Tool):
|
||||
name = "create_gitea_issue"
|
||||
description = "Crée une issue Gitea dans un repo."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}},
|
||||
"required": ["owner", "repo", "title"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.gitea_client import GiteaClient
|
||||
token = SessionManager.get_token(user_id) if user_id else None
|
||||
client = GiteaClient(user_token=token)
|
||||
try:
|
||||
issue = await client.create_issue(args["owner"], args["repo"], args["title"], args.get("body", ""))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="issue", target_id=issue.get("number"),
|
||||
data={"issue": issue},
|
||||
message=f"Issue #{issue.get('number')} créée",
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
UpdatePage, WriteBlocks,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Holds tool instances and exposes their LLM schemas + execution."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
|
||||
|
||||
def list(self, scope: dict | None = None) -> list[str]:
|
||||
"""Tool names allowed by an agent scope (default: all)."""
|
||||
allowed = (scope or {}).get("tools")
|
||||
if allowed is None:
|
||||
return list(self.tools.keys())
|
||||
return [n for n in self.tools if n in allowed]
|
||||
|
||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||
"""Function-calling schema for the LLM, filtered by scope."""
|
||||
return [
|
||||
{"name": self.tools[n].name,
|
||||
"description": self.tools[n].description,
|
||||
"parameters": self.tools[n].parameters}
|
||||
for n in self.list(scope)
|
||||
]
|
||||
|
||||
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
|
||||
impl = self.tools.get(tool)
|
||||
if not impl:
|
||||
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
||||
return await impl.execute(args, user_id=user_id)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
|
||||
|
||||
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
|
||||
window (default 30 days, matching the UI copy in ``trash.html``), then are
|
||||
permanently deleted together with their child pages and orphaned versions.
|
||||
The purge is run from a background scheduler in ``app.main`` so it is fully
|
||||
automatic (no per-request cost).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime | None:
|
||||
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
|
||||
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
s = value[:19].replace("T", " ")
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not ISO_RE.match(value):
|
||||
return None
|
||||
return dt
|
||||
|
||||
|
||||
def purge_expired(days: int = 30) -> dict:
|
||||
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
|
||||
|
||||
Children are re-parented to their grandparent before deletion in order
|
||||
to avoid silently dropping whole sub-trees; deleted pages are removed
|
||||
together with their ``page_versions`` (FK cascade).
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
dt = _parse_dt(row["deleted_at"])
|
||||
if dt is None or dt >= cutoff:
|
||||
continue
|
||||
page_id = row["id"]
|
||||
# Promote direct children to the deleted page's parent so no live
|
||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
|
||||
"WHERE parent_id=?",
|
||||
(page_id, page_id),
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||
purged.append(page_id)
|
||||
conn.commit()
|
||||
if purged:
|
||||
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
|
||||
return {"purged": purged, "count": len(purged)}
|
||||
|
||||
|
||||
async def trash_purge_scheduler(interval_hours: int = 24):
|
||||
"""Background loop purging the trash once a day (idempotent, cheap when
|
||||
there is nothing expired)."""
|
||||
import asyncio
|
||||
|
||||
# Run once shortly after startup, then on the configured interval.
|
||||
await asyncio.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
purge_expired(days=30)
|
||||
except Exception as exc: # pragma: no cover - defensive only
|
||||
logger.warning("Trash purge failed: %s", exc)
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
{###############################################################################
|
||||
_ctx_menu.html — LE menu contextuel UNIQUE de FlowDeck
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Inclus par library.html ET local_workspace.html (les 2 seules pages du
|
||||
contexte fichier). Ce partial est LE SEUL menu : son markup décrit l'UNION
|
||||
des options des deux pages d'origine. Les exécutions sont déléguées par
|
||||
chaque page au travers d'un store Alpine enregistré PLUS BAS (IIFE + défense
|
||||
d'exécution unique → include 2 fois = store 1 fois).
|
||||
|
||||
Chaque item se masque quand le handler correspondant n'existe pas sur la
|
||||
page (x-show="$store.fdCtx.has('key')") → menu structurellement identique,
|
||||
avec la capacité respective de la page au clic.
|
||||
|
||||
Handlers attendus (tous optionnels) :
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdCtxStore) return;
|
||||
if (window.Alpine) window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
/* Tags (workspace) */
|
||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
||||
newTagColor: '#787774',
|
||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
/* Icon picker */
|
||||
iconOpen: false,
|
||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
||||
|
||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
||||
openMenu(ev, node, pageHash, handlers) {
|
||||
handlers = handlers || {};
|
||||
this.node = node;
|
||||
this.page = pageHash;
|
||||
this.handlers = handlers;
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this._cx = cx;
|
||||
this._cy = cy;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
||||
var after = function () {
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
||||
else setTimeout(after, 0);
|
||||
},
|
||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
||||
_place() {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (!el) return;
|
||||
var prev = el.style.maxHeight;
|
||||
el.style.maxHeight = 'none';
|
||||
var w = el.offsetWidth || 240;
|
||||
var h = el.offsetHeight || 320;
|
||||
el.style.maxHeight = prev || '';
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var cx = (this._cx == null ? this.x : this._cx);
|
||||
var cy = (this._cy == null ? this.y : this._cy);
|
||||
var nx = cx;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
nx = Math.max(8, nx);
|
||||
var ny = cy;
|
||||
if (ny + h > vh - 8) {
|
||||
if (cy - h >= 8) ny = cy - h;
|
||||
else ny = Math.max(8, vh - h - 8);
|
||||
}
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
},
|
||||
|
||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
||||
|
||||
/* Exécute l'action → handler fourni par la page courante. */
|
||||
run(key) {
|
||||
if (!this.node) { this.close(); return; }
|
||||
var fn = this.handlers[key];
|
||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
||||
this.close();
|
||||
},
|
||||
|
||||
/* ── Tags ── */
|
||||
avail() { return this.availTags || []; },
|
||||
setAvail(a) { this.availTags = a || []; },
|
||||
removeTag(id) {
|
||||
var fn = this.handlers.tagRemove;
|
||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
||||
},
|
||||
addExistingTag(t) {
|
||||
var fn = this.handlers.tagExisting;
|
||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
||||
this.tagExistingOpen = false;
|
||||
},
|
||||
addNewTag(name, color) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return;
|
||||
var fn = this.handlers.tagAdd;
|
||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
||||
this.tagAdding = false;
|
||||
},
|
||||
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
var fn = this.handlers.setIcon;
|
||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
||||
this.close();
|
||||
},
|
||||
openIconPicker() {
|
||||
var fn = this.handlers.setIcon;
|
||||
if (!fn) return;
|
||||
if (!window.FDIconPicker) return;
|
||||
window.FDIconPicker.openFor({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
onPick: fn,
|
||||
onRemove: function () { fn(''); }
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
||||
.fd-ctx-menu .menu-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;transition:background .1s;white-space:nowrap;}
|
||||
.fd-ctx-menu .menu-item:hover{background:var(--bg-hover);}
|
||||
.fd-ctx-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
|
||||
.fd-ctx-menu .menu-danger{color:var(--danger);}
|
||||
.fd-ctx-menu .menu-shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);padding-left:16px;}
|
||||
.fd-ctx-menu .more-sep{height:1px;background:var(--border);margin:4px 6px;}
|
||||
.fd-ctx-menu .ctx-label{color:var(--text-dim);font-size:11px;cursor:default;text-transform:uppercase;letter-spacing:.04em;}
|
||||
.fd-ctx-menu .ctx-label:hover{background:transparent;}
|
||||
.fd-ctx-menu .ctx-empty{color:var(--text-dim);font-size:11px;cursor:default;}
|
||||
.fd-ctx-menu .ctx-empty:hover{background:transparent;}
|
||||
.fd-ctx-menu .ctx-caret{font-size:10px;color:var(--text-dim);}
|
||||
.fd-ctx-menu .ctx-x{color:var(--text-dim);font-size:14px;line-height:1;padding:0 2px;}
|
||||
.fd-ctx-menu .ctx-x:hover{color:var(--danger);}
|
||||
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;max-height:38vh;overflow-y:auto;}
|
||||
.fd-ctx-menu .ctx-tag-color{width:10px;height:10px;border-radius:3px;display:inline-block;flex-shrink:0;}
|
||||
.fd-ctx-menu .ctx-colors{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding:2px 4px 6px;}
|
||||
.fd-ctx-menu .ctx-swatch{width:16px;height:16px;border-radius:4px;cursor:pointer;border:2px solid transparent;}
|
||||
.fd-ctx-menu .ctx-swatch.selected{border-color:var(--text-primary);}
|
||||
.fd-ctx-menu .ctx-input{flex:1;min-width:0;font-size:12px;padding:3px 6px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);outline:none;}
|
||||
.fd-ctx-menu .ctx-input:focus{border-color:var(--accent);}
|
||||
.fd-ctx-menu .ctx-btn{padding:3px 10px;font-size:12px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;}
|
||||
.fd-ctx-menu .ctx-icons{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;padding:2px 4px 4px;}
|
||||
.fd-ctx-menu .ctx-icon{width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:transparent;border:none;border-radius:4px;cursor:pointer;}
|
||||
.fd-ctx-menu .ctx-icon:hover{background:var(--bg-hover);}
|
||||
</style>
|
||||
|
||||
<div class="more-menu fd-ctx-menu" x-show="$store.fdCtx.open" x-cloak
|
||||
@click.outside="$store.fdCtx.close()"
|
||||
@keydown.escape.window="$store.fdCtx.close()"
|
||||
x-transition
|
||||
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px', maxHeight: $store.fdCtx.maxH ? $store.fdCtx.maxH + 'px' : 'calc(100vh - 16px)' }">
|
||||
|
||||
{# ── Open ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
<span style="flex:1;">Open</span>
|
||||
</div>
|
||||
|
||||
{# ── Open in new tab ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('openTab')" @click="$store.fdCtx.run('openTab')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
<span style="flex:1;">Open in new tab</span><span class="menu-shortcut">Ctrl+⇧+↩</span>
|
||||
</div>
|
||||
|
||||
{# ── Open in side peek ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('peek')" @click="$store.fdCtx.run('peek')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<span style="flex:1;">Open in side peek</span><span class="menu-shortcut">Alt+Click</span>
|
||||
</div>
|
||||
|
||||
{# ── Open folder (workspace, dossiers) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('folder')" @click="$store.fdCtx.run('folder')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
<span style="flex:1;">Open folder</span>
|
||||
</div>
|
||||
|
||||
<div class="more-sep" x-show="$store.fdCtx.has('rename') || $store.fdCtx.has('setIcon') || $store.fdCtx.has('duplicate') || $store.fdCtx.has('link') || $store.fdCtx.has('move')"></div>
|
||||
|
||||
{# ── Rename ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('rename')" @click="$store.fdCtx.run('rename')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
|
||||
<span style="flex:1;">Rename</span><span class="menu-shortcut">Ctrl+⇧+R</span>
|
||||
</div>
|
||||
|
||||
{# ── Edit icon (sélecteur façon Notion) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.openIconPicker()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
<span style="flex:1;">Edit icon</span>
|
||||
</div>
|
||||
|
||||
{# ── Duplicate ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('duplicate')" @click="$store.fdCtx.run('duplicate')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Duplicate</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy link ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('link')" @click="$store.fdCtx.run('link')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
<span style="flex:1;">Copy link</span>
|
||||
</div>
|
||||
|
||||
{# ── Download file (file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('download')" @click="$store.fdCtx.run('download')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span style="flex:1;">Download</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy file content (textual file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('copyContent')" @click="$store.fdCtx.run('copyContent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Copy content</span>
|
||||
</div>
|
||||
|
||||
{# ── Move to ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
<span style="flex:1;">Move to</span><span class="menu-shortcut">Ctrl+⇧+P</span>
|
||||
</div>
|
||||
|
||||
{# ── Favorites ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('fav')" @click="$store.fdCtx.run('fav')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
<span style="flex:1;" x-text="$store.fdCtx.node && $store.fdCtx.node.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
|
||||
</div>
|
||||
|
||||
{# ── Remove from Recents ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('recent')" @click="$store.fdCtx.run('recent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
<span style="flex:1;">Remove from Recents</span>
|
||||
</div>
|
||||
|
||||
{# ── Tags (workspace) ── #}
|
||||
<div class="more-sep" x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')"></div>
|
||||
<div x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')">
|
||||
<div class="menu-item ctx-label"><span style="flex:1;">Tags</span></div>
|
||||
<template x-for="t in ($store.fdCtx.node && $store.fdCtx.node.tags) || []" :key="'ct'+t.id">
|
||||
<div class="menu-item" @click.stop="$store.fdCtx.removeTag(t.id)">
|
||||
<span style="display:flex;align-items:center;gap:8px;flex:1;overflow:hidden;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name" style="overflow:hidden;text-overflow:ellipsis;"></span></span>
|
||||
<span class="ctx-x" title="Remove tag">×</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="menu-item ctx-empty" x-show="!($store.fdCtx.node && ($store.fdCtx.node.tags||[]).length)">No tags</div>
|
||||
<div class="menu-item" x-show="$store.fdCtx.avail().length" @click.stop="$store.fdCtx.tagExistingOpen = !$store.fdCtx.tagExistingOpen">
|
||||
<span style="flex:1;">Add existing tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagExistingOpen ? '▾' : '▸'"></span>
|
||||
</div>
|
||||
<div x-show="$store.fdCtx.tagExistingOpen" class="ctx-sub">
|
||||
<template x-for="t in $store.fdCtx.avail()" :key="'ca'+t.id">
|
||||
<div class="menu-item" @click.stop="$store.fdCtx.addExistingTag(t)">
|
||||
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name"></span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="menu-item" @click.stop="$store.fdCtx.tagAdding = !$store.fdCtx.tagAdding">
|
||||
<span style="flex:1;">+ New tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagAdding ? '▾' : '▸'"></span>
|
||||
</div>
|
||||
<div x-show="$store.fdCtx.tagAdding" class="ctx-sub">
|
||||
<div class="ctx-colors">
|
||||
<template x-for="c in $store.fdCtx.tagColors" :key="'cc'+c">
|
||||
<span class="ctx-swatch" :class="{ selected: $store.fdCtx.newTagColor === c }" :style="{ background: c }" @click.stop="$store.fdCtx.newTagColor = c"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
||||
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
|
||||
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Delete (danger) ── #}
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="$store.fdCtx.run('delete')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
<span style="flex:1;">Move to Trash</span><span class="menu-shortcut">Del</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
<!-- ═══════════ Database Table View (Notion-style) — Vanilla JS ═══════════ -->
|
||||
<div id="db-table-container" class="database-table-container">
|
||||
<!-- View Selector Bar -->
|
||||
<div id="db-view-bar" class="db-view-bar">
|
||||
<div class="db-view-tabs">
|
||||
<button id="db-view-tab-table" class="db-view-tab active">
|
||||
{{ fd_icon("grid",14) }} Table
|
||||
</button>
|
||||
<button id="db-view-add-btn" class="db-view-add" title="Add a view">
|
||||
{{ fd_icon("plus",14) }}
|
||||
</button>
|
||||
<div id="db-add-view-dropdown" class="db-dropdown" style="display:none">
|
||||
<div class="db-dropdown-header">Add a new view</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("grid",14) }} Table</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("grid",14) }} Board</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("list",14) }} List</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("calendar",14) }} Calendar</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-view-actions">
|
||||
<button id="db-filter-btn" class="db-btn-icon" title="Filter">{{ fd_icon("search",14) }}</button>
|
||||
<button id="db-new-btn" class="db-btn-new">
|
||||
<span class="db-new-icon">{{ fd_icon("plus",12) }}</span> New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Table -->
|
||||
<div id="db-table-wrapper" class="db-table-wrapper">
|
||||
<table id="db-table" class="db-table">
|
||||
<thead id="db-thead"></thead>
|
||||
<tbody id="db-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Property Modal -->
|
||||
<div id="db-prop-modal" class="db-modal-overlay" style="display:none">
|
||||
<div class="db-modal">
|
||||
<div class="db-modal-header">
|
||||
<h3>Add a property</h3>
|
||||
<button id="db-prop-modal-close">{{ fd_icon("x",14) }}</button>
|
||||
</div>
|
||||
<div class="db-modal-body">
|
||||
<div class="db-modal-field">
|
||||
<label>Name</label>
|
||||
<input id="db-prop-name-input" type="text" placeholder="Property name" />
|
||||
</div>
|
||||
<div class="db-modal-field">
|
||||
<label>Type</label>
|
||||
<select id="db-prop-type-select">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="select">Select</option>
|
||||
<option value="multi_select">Multi-select</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="db-modal-actions">
|
||||
<button id="db-prop-cancel" class="db-btn-cancel">Cancel</button>
|
||||
<button id="db-prop-create" class="db-btn-create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Header Context Menu -->
|
||||
<div id="db-col-menu" class="db-col-menu" style="display:none">
|
||||
<div class="db-col-menu-header">
|
||||
<span class="db-col-menu-icon" id="db-col-menu-icon">Aa</span>
|
||||
<span class="db-col-menu-name" id="db-col-menu-name">Name</span>
|
||||
<span class="db-col-menu-info" id="db-col-menu-info"></span>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-show-icon">
|
||||
<span>😊 Show page icon</span>
|
||||
<span class="db-col-menu-toggle on" id="db-col-icon-toggle">● ON</span>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item has-sub" id="db-col-ai-autofill">
|
||||
<span>✨ AI Autofill</span>
|
||||
<span class="db-col-menu-hint">Now with agents</span>
|
||||
<span class="db-col-menu-arrow">›</span>
|
||||
</div>
|
||||
<div class="db-col-menu-sub" id="db-col-ai-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>✨ Basic</span></div>
|
||||
<div class="db-col-menu-subnote">Not available for this property type</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item sub-item"><span>🤖 Custom Agent</span><span class="db-col-menu-tag">New</span></div>
|
||||
<div class="db-col-menu-subnote">Uses advanced models to fill properties based on web search and your connected workspace.</div>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-filter"><span>≡ Filter</span></div>
|
||||
<div class="db-col-menu-item has-sub" id="db-col-sort"><span>↕ Sort</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-sort-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>↑ Sort ascending</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>↓ Sort descending</span></div>
|
||||
</div>
|
||||
<div class="db-col-menu-item" id="db-col-group"><span>▦ Group</span></div>
|
||||
<div class="db-col-menu-item has-sub" id="db-col-calc"><span>Σ Calculate</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-calc-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item checked"><span>None</span></div>
|
||||
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-count"><span>Count</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-calc-count-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>Count all</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count values</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count unique values</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count empty</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count not empty</span></div>
|
||||
</div>
|
||||
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-pct"><span>Percent</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-calc-pct-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>Percent empty</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Percent not empty</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-freeze"><span>🧊 Freeze</span></div>
|
||||
<div class="db-col-menu-item" id="db-col-wrap"><span>📝 Wrap content</span></div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-insert-left"><span>├← Insert left</span></div>
|
||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Side Peek Panel for Database Pages -->
|
||||
<div id="db-side-peek" class="db-side-peek" style="display:none">
|
||||
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
|
||||
<div class="db-side-peek-header">
|
||||
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
|
||||
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
|
||||
</div>
|
||||
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,472 @@
|
||||
<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
|
||||
<style>.db-cell-invalid{outline:2px solid #e5484d;outline-offset:-1px;border-radius:4px}</style>
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── SVG Outline Icons ──
|
||||
function propTypeSvg(type) {
|
||||
const s = 14;
|
||||
switch(type) {
|
||||
case 'text': case 'title':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
|
||||
case 'number':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="4" x2="18" y2="20"/><line x1="18" y1="4" x2="6" y2="20"/></svg>';
|
||||
case 'select':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/></svg>';
|
||||
case 'multi_select':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><rect x="2" y="5" width="4" height="2" rx="0.5"/><rect x="2" y="11" width="4" height="2" rx="0.5"/><rect x="2" y="17" width="4" height="2" rx="0.5"/></svg>';
|
||||
case 'date':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
case 'checkbox':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
case 'url':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
case 'email':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
case 'phone':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
|
||||
default:
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function cellValue(cpage, prop) {
|
||||
if (!cpage.property_values_json) return '';
|
||||
let pv;
|
||||
try { pv = typeof cpage.property_values_json === 'string' ? JSON.parse(cpage.property_values_json) : cpage.property_values_json; }
|
||||
catch(e) { return ''; }
|
||||
const val = pv[String(prop.id)];
|
||||
if (val === undefined || val === null) return prop.prop_type === 'title' ? (cpage.title || '') : '';
|
||||
if (Array.isArray(val)) return val.join(', ');
|
||||
return String(val);
|
||||
}
|
||||
|
||||
// ── DB Instance (one per container) ──
|
||||
function DBInstance(containerEl, collectionId, initialData) {
|
||||
const state = {
|
||||
properties: initialData ? initialData.properties : [],
|
||||
pages: initialData ? initialData.pages : [],
|
||||
editingCell: null,
|
||||
colMenuPropId: null,
|
||||
colMenuSubOpen: null,
|
||||
colMenuIconOn: true,
|
||||
colWidths: {}
|
||||
};
|
||||
|
||||
function getCsrf() { const m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
|
||||
|
||||
function render() {
|
||||
const props = state.properties;
|
||||
const pages = state.pages;
|
||||
|
||||
// Init col widths
|
||||
for (const p of props) { if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; }
|
||||
|
||||
// Build inner HTML
|
||||
let h = '<div class="db-view-bar" style="padding:4px 0 8px">';
|
||||
h += '<div class="db-view-tabs"><button class="db-view-tab active"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> Table</button></div>';
|
||||
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||||
h += '</div>';
|
||||
|
||||
h += '<div class="db-table-wrapper"><table class="db-table">';
|
||||
// Header
|
||||
h += '<thead><tr class="db-header-row">';
|
||||
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
|
||||
for (const prop of props) {
|
||||
const w = state.colWidths[prop.id] || 200;
|
||||
h += '<th class="db-th db-th-property" style="min-width:'+w+'px;width:'+w+'px" data-prop-id="'+prop.id+'">';
|
||||
h += '<div class="db-th-content" data-prop-id="'+prop.id+'" data-prop-type="'+prop.prop_type+'" data-prop-name="'+escHtml(prop.name)+'">';
|
||||
h += '<span class="db-prop-type-icon">'+propTypeSvg(prop.prop_type)+'</span>';
|
||||
h += '<span class="db-prop-name db-col-title-btn">'+escHtml(prop.name)+'</span>';
|
||||
h += '</div><div class="db-col-resize-handle" data-prop-id="'+prop.id+'"></div></th>';
|
||||
}
|
||||
h += '<th class="db-th db-th-add-prop"><button class="db-add-prop-btn db-show-prop-modal-btn">+ Add property</button></th>';
|
||||
h += '<th class="db-th" style="width:72px;min-width:72px"></th>';
|
||||
h += '</tr></thead>';
|
||||
|
||||
// Body
|
||||
h += '<tbody>';
|
||||
for (const cp of pages) {
|
||||
h += '<tr class="db-row" data-page-id="'+cp.id+'">';
|
||||
h += '<td class="db-td db-td-handle"><span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span></td>';
|
||||
for (const prop of props) {
|
||||
const val = cellValue(cp, prop) || '\u2014';
|
||||
const isNameCol = prop.prop_type === 'title';
|
||||
h += '<td class="db-td db-td-cell" style="min-width:'+(state.colWidths[prop.id]||200)+'px">';
|
||||
h += '<div class="db-cell-content" data-page-id="'+cp.id+'" data-prop-id="'+prop.id+'">';
|
||||
h += '<span class="db-cell-value">'+escHtml(val)+'</span>';
|
||||
// OPEN button inline in first (title) column
|
||||
if (isNameCol) {
|
||||
h += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+cp.id+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
h += '</div></td>';
|
||||
}
|
||||
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden;white-space:nowrap"><button class="db-ai-btn db-ai-fill-btn" data-page-id="'+cp.id+'" title="Remplir les propriétés avec l\'IA" style="background:none;border:none;cursor:pointer;font-size:14px;padding:2px 4px;color:var(--text-dim)">✨</button><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
|
||||
h += '</tr>';
|
||||
}
|
||||
// Empty rows + New Page at bottom
|
||||
const emptyNeeded = Math.max(0, 4 - pages.length);
|
||||
for (let i = 0; i < emptyNeeded; i++) {
|
||||
h += '<tr class="db-row db-row-empty"><td class="db-td db-td-handle"></td>';
|
||||
for (let j = 0; j < props.length; j++) h += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>';
|
||||
h += '<td class="db-td db-td-open"></td></tr>';
|
||||
}
|
||||
h += '<tr class="db-row db-row-new"><td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
|
||||
h += '<td class="db-td db-td-name" colspan="'+(Math.max(1,props.length)+1)+'"><button class="db-new-page-btn db-add-page-btn">New page</button></td></tr>';
|
||||
h += '</tbody></table></div>';
|
||||
|
||||
containerEl.innerHTML = h;
|
||||
|
||||
// Bind events
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// New page buttons
|
||||
containerEl.querySelectorAll('.db-add-page-btn').forEach(b => b.onclick = addNewPage);
|
||||
// Cell edit
|
||||
containerEl.querySelectorAll('.db-cell-content').forEach(el => {
|
||||
el.onclick = function(e) {
|
||||
if (state.editingCell || e.target.closest('.db-open-btn')) return;
|
||||
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
|
||||
};
|
||||
});
|
||||
// Open buttons
|
||||
containerEl.querySelectorAll('.db-open-page-btn').forEach(btn => {
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
openPageInSidePeek(parseInt(this.dataset.pageId));
|
||||
};
|
||||
});
|
||||
// AI fill properties
|
||||
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(btn => {
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
aiFillRow(parseInt(this.dataset.pageId));
|
||||
};
|
||||
});
|
||||
// Hover
|
||||
containerEl.querySelectorAll('.db-row').forEach(row => {
|
||||
row.onmouseenter = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'visible'); };
|
||||
row.onmouseleave = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'hidden'); };
|
||||
});
|
||||
// Header context menu
|
||||
containerEl.querySelectorAll('.db-th-content').forEach(el => {
|
||||
el.onclick = function(e) { e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); };
|
||||
});
|
||||
// Column resize
|
||||
containerEl.querySelectorAll('.db-col-resize-handle').forEach(handle => {
|
||||
handle.onpointerdown = function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const propId = parseInt(this.dataset.propId);
|
||||
const th = this.parentElement;
|
||||
const thLeft = th.getBoundingClientRect().left;
|
||||
function onMove(ev) {
|
||||
const newW = Math.max(60, ev.clientX - thLeft);
|
||||
state.colWidths[propId] = newW;
|
||||
th.style.width = newW+'px'; th.style.minWidth = newW+'px';
|
||||
const colIdx = state.properties.findIndex(p => p.id === propId);
|
||||
if (colIdx >= 0) {
|
||||
containerEl.querySelectorAll('.db-row').forEach(row => {
|
||||
const cells = row.querySelectorAll('.db-td-cell');
|
||||
if (cells[colIdx]) cells[colIdx].style.minWidth = newW+'px';
|
||||
});
|
||||
}
|
||||
}
|
||||
function onUp() { document.removeEventListener('pointermove',onMove); document.removeEventListener('pointerup',onUp); document.body.style.cursor=''; document.body.style.userSelect=''; }
|
||||
document.body.style.cursor='col-resize'; document.body.style.userSelect='none';
|
||||
document.addEventListener('pointermove',onMove); document.addEventListener('pointerup',onUp);
|
||||
};
|
||||
});
|
||||
// Add property modal bind
|
||||
containerEl.querySelector('.db-show-prop-modal-btn').onclick = function() { showPropModal(); };
|
||||
}
|
||||
|
||||
function startEditCell(pageId, propId, cellEl) {
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
const prop = state.properties.find(p => p.id === propId);
|
||||
if (!cp || !prop) return;
|
||||
const val = cellValue(cp, prop);
|
||||
const input = document.createElement('input');
|
||||
input.type='text'; input.className='db-cell-input'; input.value=val;
|
||||
cellEl.innerHTML=''; cellEl.appendChild(input); cellEl.classList.add('db-cell-editing'); input.focus();
|
||||
state.editingCell = {pageId,propId,input,cellEl};
|
||||
input.onkeydown = function(e) { if(e.key==='Enter') commitEditCell(); else if(e.key==='Escape') cancelEditCell(); };
|
||||
input.onblur = function() { commitEditCell(); };
|
||||
}
|
||||
|
||||
function commitEditCell() {
|
||||
if(!state.editingCell) return;
|
||||
const {pageId,propId,input,cellEl}=state.editingCell; state.editingCell=null;
|
||||
const val=input.value;
|
||||
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
|
||||
if(!cp||!prop) return;
|
||||
let pv; try{pv=typeof cp.property_values_json==='string'?JSON.parse(cp.property_values_json):cp.property_values_json}catch(e){pv={};}
|
||||
pv[String(propId)]=val; cp.property_values_json=pv;
|
||||
if(prop.prop_type==='title') cp.title=val;
|
||||
cellEl.classList.remove('db-cell-editing');
|
||||
const isNameCol = prop.prop_type === 'title';
|
||||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
|
||||
if (isNameCol) {
|
||||
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
fetch('/db/pages/'+pageId+'/api',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:cp.title,properties:pv})})
|
||||
.then(function(r){
|
||||
if(!r.ok){
|
||||
return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; });
|
||||
}
|
||||
cellEl.classList.remove('db-cell-invalid');
|
||||
return r.json();
|
||||
})
|
||||
.catch(function(err){
|
||||
// Validation failed (400) → show message + revert the cell.
|
||||
const msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
|
||||
if(err && err.status===400){
|
||||
cellEl.classList.add('db-cell-invalid');
|
||||
cellEl.title = msg;
|
||||
}
|
||||
if(typeof window.showToast==='function') window.showToast('⚠ '+msg,'error');
|
||||
else alert('⚠ '+msg);
|
||||
// Revert to the stored value.
|
||||
const stored = pv[String(propId)];
|
||||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(stored||'\u2014')+'</span>';
|
||||
if (prop.prop_type === 'title') {
|
||||
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditCell() {
|
||||
if(!state.editingCell) return;
|
||||
const {pageId,propId,cellEl}=state.editingCell; state.editingCell=null;
|
||||
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
|
||||
if(!cp||!prop) return;
|
||||
const val=cellValue(cp,prop);
|
||||
cellEl.classList.remove('db-cell-editing');
|
||||
const isNameCol = prop.prop_type === 'title';
|
||||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
|
||||
if (isNameCol) {
|
||||
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
}
|
||||
|
||||
// ── v5.9.0: AI property suggestions ──
|
||||
async function aiFillRow(pageId) {
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
if (!cp) return;
|
||||
const props = state.properties.filter(p => p.prop_type !== 'title');
|
||||
if (!props.length) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Aucune propriété à remplir');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
title: cp.title || '',
|
||||
content: cp.content || '',
|
||||
properties: props.map(p => ({ name: p.name, type: p.prop_type }))
|
||||
};
|
||||
if (typeof window.showToast === 'function') window.showToast('✨ FlowDeck AI : analyse en cours…');
|
||||
try {
|
||||
const r = await fetch('/api/agent/writing/properties', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d || !d.ok) {
|
||||
if (typeof window.showToast === 'function') window.showToast((d && d.error) || 'Échec de la génération');
|
||||
return;
|
||||
}
|
||||
let pv;
|
||||
try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : (cp.property_values_json || {}); }
|
||||
catch (e) { pv = {}; }
|
||||
let filled = 0;
|
||||
for (const p of props) {
|
||||
const v = d.suggestions ? d.suggestions[p.name] : undefined;
|
||||
if (v !== undefined && v !== null && v !== '') { pv[String(p.id)] = v; filled++; }
|
||||
}
|
||||
cp.property_values_json = pv;
|
||||
await fetch('/db/pages/' + pageId + '/api', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ title: cp.title, properties: pv })
|
||||
});
|
||||
render();
|
||||
if (typeof window.showToast === 'function') window.showToast(filled ? ('✨ ' + filled + ' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
|
||||
} catch (e) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Erreur IA');
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewPage() {
|
||||
try {
|
||||
const r = await fetch('/api/collections/'+collectionId+'/pages',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:''})});
|
||||
const d = await r.json();
|
||||
if(d.id) { state.pages.push({id:d.id,title:'',position:state.pages.length,icon:'file',collection_id:collectionId,property_values_json:{}}); render(); }
|
||||
} catch(e) { console.error('[DB] addNewPage error:', e); }
|
||||
}
|
||||
|
||||
// ── Property modal (inline, self-contained) ──
|
||||
function showPropModal() {
|
||||
// Remove any existing inline modal
|
||||
var old = containerEl.querySelector('.db-prop-modal-inline');
|
||||
if (old) old.remove();
|
||||
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'db-prop-modal-inline';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:1000;display:flex;align-items:center;justify-content:center';
|
||||
modal.innerHTML = '<div class="db-modal" style="background:var(--bg-modal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.15);width:420px;max-width:90vw;overflow:hidden">'+
|
||||
'<div class="db-modal-header"><h3>Add a property</h3><button class="db-prop-modal-close-btn" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;color:var(--text-dim);font-size:18px">×</button></div>'+
|
||||
'<div class="db-modal-body" style="padding:16px">'+
|
||||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Name</label><input class="db-prop-name-inline" type="text" placeholder="Property name" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
|
||||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Type</label><select class="db-prop-type-inline" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"><option value="text">Text</option><option value="number">Number</option><option value="select">Select</option><option value="multi_select">Multi-select</option><option value="date">Date</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option></select></div>'+
|
||||
'<div style="display:flex;gap:16px;margin-bottom:12px">'+
|
||||
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-required-inline" type="checkbox"> Required</label>'+
|
||||
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-unique-inline" type="checkbox"> Unique</label>'+
|
||||
'</div>'+
|
||||
'<div style="display:flex;gap:10px;margin-bottom:12px">'+
|
||||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Min</label><input class="db-prop-min-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
|
||||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Max</label><input class="db-prop-max-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
|
||||
'</div>'+
|
||||
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px"><button class="db-prop-cancel-inline" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font-size:13px;cursor:pointer">Cancel</button><button class="db-prop-create-inline" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent,#2383E2);color:white;font-size:13px;cursor:pointer">Create</button></div>'+
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
|
||||
var nameInput = modal.querySelector('.db-prop-name-inline');
|
||||
nameInput.focus();
|
||||
|
||||
modal.querySelector('.db-prop-modal-close-btn').onclick = function(){ modal.remove(); };
|
||||
modal.querySelector('.db-prop-cancel-inline').onclick = function(){ modal.remove(); };
|
||||
modal.onclick = function(e){ if(e.target===modal) modal.remove(); };
|
||||
|
||||
modal.querySelector('.db-prop-create-inline').onclick = async function(){
|
||||
var name = nameInput.value.trim();
|
||||
if(!name) return;
|
||||
var type = modal.querySelector('.db-prop-type-inline').value;
|
||||
var validation = {};
|
||||
if(modal.querySelector('.db-prop-required-inline').checked) validation.required = true;
|
||||
if(modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true;
|
||||
var min = modal.querySelector('.db-prop-min-inline').value;
|
||||
var max = modal.querySelector('.db-prop-max-inline').value;
|
||||
if(min!=='') validation.min = Number(min);
|
||||
if(max!=='') validation.max = Number(max);
|
||||
try {
|
||||
var r = await fetch('/db/'+collectionId+'/properties/api',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||||
body:JSON.stringify({name:name,prop_type:type,validation:validation})
|
||||
});
|
||||
var d = await r.json();
|
||||
if(d.id){ state.properties.push(d); render(); modal.remove(); }
|
||||
} catch(e){ console.error('[DB] createProperty error:',e); }
|
||||
};
|
||||
nameInput.onkeydown = function(e){ if(e.key==='Enter') modal.querySelector('.db-prop-create-inline').click(); };
|
||||
}
|
||||
|
||||
// ── Column context menu (inline) ──
|
||||
function showColMenu(propId, propType, propName, anchor) {
|
||||
// Remove any existing
|
||||
var old = document.querySelector('.db-col-menu-inline');
|
||||
if (old) old.remove();
|
||||
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'db-col-menu-inline';
|
||||
menu.style.cssText = 'position:fixed;min-width:240px;max-width:320px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.14);z-index:800;padding:4px 0;font-size:13px;color:var(--text-primary)';
|
||||
var rect = anchor.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px';
|
||||
|
||||
menu.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;font-weight:600">'+
|
||||
'<span>'+propTypeSvg(propType)+'</span><span>'+escHtml(propName)+'</span><span style="font-size:11px;color:var(--text-dim)">ⓘ</span></div>'+
|
||||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">≡ Filter</div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">↕ Sort</div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">▦ Group</div>'+
|
||||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">├← Insert left</div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">→┤ Insert right</div>';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
setTimeout(() => document.addEventListener('click', function onDoc(e) {
|
||||
if(!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click',onDoc); }
|
||||
}, {once:true}), 0);
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
if (!initialData) {
|
||||
fetch('/api/collections/'+collectionId+'/table-data').then(r=>r.json()).then(d => {
|
||||
state.properties = d.properties||[]; state.pages = d.pages||[]; render();
|
||||
}).catch(e=>console.error('[DB] load error:',e));
|
||||
} else { render(); }
|
||||
}
|
||||
|
||||
// ── Global API ──
|
||||
window.FlowDeckDB = {
|
||||
/** Render a database table into a container element. */
|
||||
renderInto: function(containerEl, collectionId, initialData) {
|
||||
new DBInstance(containerEl, collectionId, initialData);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Full-page init (when page_editor_collection.html is used) ──
|
||||
const PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
|
||||
if (PAGE_COLLECTION_ID > 0) {
|
||||
const container = document.getElementById('db-table-container');
|
||||
if (container) {
|
||||
{% if collection_data %}
|
||||
new DBInstance(container, PAGE_COLLECTION_ID, {
|
||||
properties: {{ collection_data.properties | tojson }},
|
||||
pages: {{ collection_data.pages | tojson }}
|
||||
});
|
||||
{% else %}
|
||||
new DBInstance(container, PAGE_COLLECTION_ID, null);
|
||||
{% endif %}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global Side Peek for Database Pages (auto-creates panel) ──
|
||||
function openPageInSidePeek(pageId) {
|
||||
var panel = document.getElementById('db-side-peek');
|
||||
if (!panel) {
|
||||
// Create panel dynamically
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'db-side-peek';
|
||||
panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column';
|
||||
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
|
||||
'<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+
|
||||
'<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+
|
||||
'</div>'+
|
||||
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
|
||||
document.body.appendChild(panel);
|
||||
|
||||
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
|
||||
document.getElementById('db-side-peek-full').onclick = function(){
|
||||
var ifr = document.getElementById('db-side-peek-iframe');
|
||||
if(ifr&&ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
|
||||
};
|
||||
// Resize
|
||||
var rh = document.getElementById('db-side-peek-resize');
|
||||
rh.onpointerdown = function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var sx = e.clientX, sw = panel.offsetWidth;
|
||||
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
|
||||
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
|
||||
document.body.style.cursor='col-resize';
|
||||
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
|
||||
};
|
||||
}
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; }
|
||||
}
|
||||
function closeSidePeek() {
|
||||
var panel = document.getElementById('db-side-peek');
|
||||
if (panel) { panel.style.display = 'none'; }
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) { iframe.src = ''; }
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,259 @@
|
||||
{# ── Unified Header (Notion-inspired breadcrumb navigation) ──
|
||||
Parameters available in calling context:
|
||||
breadcrumb_items: list of {label, url, id, icon, menu} — last item = current page
|
||||
page_icon: str — emoji icon
|
||||
page_title: str — fallback current-page label when no breadcrumb_items
|
||||
right_actions: str — raw HTML for right-side action buttons
|
||||
hamburger_click: str — Alpine expression for hamburger button
|
||||
hide_hamburger: bool — if truthy, hides the hamburger button
|
||||
hide_home: bool — if truthy, do not prepend the "Home" crumb
|
||||
nav_workspace_id: int — workspace id used to fetch the navigation menu
|
||||
nav_page_id: int — current page id (enables the sibling nav menu)
|
||||
#}
|
||||
{% set ns = namespace(items=[]) %}
|
||||
{% if not hide_home %}
|
||||
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "home", "menu": false, "home": true}] %}
|
||||
{% endif %}
|
||||
{% if breadcrumb_items %}
|
||||
{% for item in breadcrumb_items %}
|
||||
{% set ns.items = ns.items + [{
|
||||
"label": item.get('label') or item.get('name') or 'Untitled',
|
||||
"url": item.get('url'),
|
||||
"id": item.get('id'),
|
||||
"icon": item.get('icon'),
|
||||
"menu": (item.get('id') is not none)
|
||||
}] %}
|
||||
{% endfor %}
|
||||
{% elif page_title %}
|
||||
{% set ns.items = ns.items + [{
|
||||
"label": page_title,
|
||||
"url": none,
|
||||
"id": nav_page_id if nav_page_id is defined else none,
|
||||
"icon": page_icon if page_icon is defined else none,
|
||||
"menu": (nav_page_id is defined and nav_page_id)
|
||||
}] %}
|
||||
{% endif %}
|
||||
<header class="topbar unified-header">
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
title="Toggle sidebar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||
|
||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
{% if page_icon %}
|
||||
<span class="header-page-icon">{{ fd_page_icon(page_icon, 18) }}</span>
|
||||
{% endif %}
|
||||
|
||||
<template x-for="(crumb, di) in display" :key="di">
|
||||
<span class="crumb-cell">
|
||||
{# ── Collapsed "…" segment ── #}
|
||||
<template x-if="crumb.ellipsis">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
||||
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="h in crumb.hidden" :key="h.id">
|
||||
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
||||
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="h.label"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
{# ── Plain link crumb (Home / non-menu with url) ── #}
|
||||
<template x-if="!crumb.ellipsis && !crumb.menu && crumb.url">
|
||||
<a class="breadcrumb-link" :href="crumb.url" x-text="crumb.label"
|
||||
@mouseenter="closeAll()"></a>
|
||||
</template>
|
||||
|
||||
{# ── Plain current crumb (no menu, no url) ── #}
|
||||
<template x-if="!crumb.ellipsis && !crumb.menu && !crumb.url">
|
||||
<span class="breadcrumb-current" x-text="crumb.label"
|
||||
@mouseenter="closeAll()"></span>
|
||||
</template>
|
||||
|
||||
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
||||
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link"
|
||||
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||
x-text="crumb.label"></button>
|
||||
<div class="fd-nav-menu"
|
||||
x-show="openIdx === crumb.origIndex" x-cloak x-transition.opacity
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<div class="fd-nav-section" x-text="crumb.home ? 'Workspaces' : 'Private'"></div>
|
||||
<template x-if="loading">
|
||||
<div class="fd-nav-empty">Loading…</div>
|
||||
</template>
|
||||
<template x-if="!loading && activeItems.length === 0">
|
||||
<div class="fd-nav-empty">No pages</div>
|
||||
</template>
|
||||
<template x-for="item in activeItems" :key="item.id">
|
||||
<div class="fd-nav-item"
|
||||
@mouseenter="openSub(item)"
|
||||
@click.stop="go(item.url); closeAll()">
|
||||
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="item.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="s in subItems" :key="s.id">
|
||||
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
||||
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="s.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<span class="breadcrumb-sep" x-show="di < display.length - 1">/</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right header-actions">
|
||||
{% include '_notification_bell.html' %}
|
||||
{{ right_actions|safe if right_actions else '' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||
Alpine.data('fdBreadcrumb', function () {
|
||||
return {
|
||||
crumbs: [],
|
||||
wsId: 0,
|
||||
openIdx: null,
|
||||
ellipsisOpen: false,
|
||||
loading: false,
|
||||
cache: {},
|
||||
activeItems: [],
|
||||
subOpenId: null,
|
||||
subItems: [],
|
||||
closeTimer: null,
|
||||
|
||||
init: function () {
|
||||
var el = document.getElementById('fd-breadcrumb-data');
|
||||
if (el) {
|
||||
try {
|
||||
var d = JSON.parse(el.textContent);
|
||||
this.crumbs = (d.crumbs || []).map(function (c, i) { c.origIndex = i; return c; });
|
||||
this.wsId = d.workspace_id || 0;
|
||||
} catch (e) { this.crumbs = []; }
|
||||
}
|
||||
},
|
||||
|
||||
get display() {
|
||||
var c = this.crumbs;
|
||||
if (c.length <= 4) return c;
|
||||
var head = [c[0], c[1]];
|
||||
var hidden = c.slice(2, c.length - 2);
|
||||
var tail = [c[c.length - 2], c[c.length - 1]];
|
||||
return head.concat([{ ellipsis: true, hidden: hidden }], tail);
|
||||
},
|
||||
|
||||
parentIdFor: function (origIndex) {
|
||||
if (origIndex <= 0) return null;
|
||||
var prev = this.crumbs[origIndex - 1];
|
||||
return prev && prev.id ? prev.id : null;
|
||||
},
|
||||
|
||||
fetchMenu: function (parentId) {
|
||||
var key = parentId == null ? 'root' : String(parentId);
|
||||
var self = this;
|
||||
if (this.cache[key]) return Promise.resolve(this.cache[key]);
|
||||
var url = '/api/nav/menu?';
|
||||
if (this.wsId) url += 'workspace_id=' + this.wsId + '&';
|
||||
if (parentId != null) url += 'parent_id=' + parentId;
|
||||
return fetch(url, { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { self.cache[key] = d.items || []; return self.cache[key]; })
|
||||
.catch(function () { return []; });
|
||||
},
|
||||
|
||||
openMenu: function (origIndex) {
|
||||
if (this.openIdx === origIndex) return; // already open
|
||||
var self = this;
|
||||
// Close sub first
|
||||
this.subOpenId = null;
|
||||
this.subItems = [];
|
||||
this.openIdx = origIndex;
|
||||
this.loading = true;
|
||||
this.fetchMenu(this.parentIdFor(origIndex)).then(function (items) {
|
||||
if (self.openIdx === origIndex) { self.activeItems = items; }
|
||||
self.loading = false;
|
||||
});
|
||||
},
|
||||
|
||||
openSub: function (item) {
|
||||
var self = this;
|
||||
if (!item.has_children) { this.subOpenId = null; this.subItems = []; return; }
|
||||
if (this.subOpenId === item.id) return;
|
||||
this.subOpenId = item.id;
|
||||
this.subItems = [];
|
||||
this.fetchMenu(item.id).then(function (items) {
|
||||
if (self.subOpenId === item.id) { self.subItems = items; }
|
||||
});
|
||||
},
|
||||
|
||||
// ── Hover close timer ──
|
||||
dragCloseTimer: function () {
|
||||
var self = this;
|
||||
this.cancelCloseTimer();
|
||||
this.closeTimer = setTimeout(function () {
|
||||
self.closeAll();
|
||||
}, 200);
|
||||
},
|
||||
|
||||
cancelCloseTimer: function () {
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer);
|
||||
this.closeTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
closeAll: function () {
|
||||
this.cancelCloseTimer();
|
||||
this.openIdx = null;
|
||||
this.ellipsisOpen = false;
|
||||
this.subOpenId = null;
|
||||
this.subItems = [];
|
||||
this.activeItems = [];
|
||||
},
|
||||
|
||||
go: function (url) {
|
||||
if (!url) return;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,467 @@
|
||||
{###############################################################################
|
||||
_icon_picker.html — Sélecteur d'icône/emoji façon Notion (v5.6.0)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Popover global (inclus par base.html) utilisé par :
|
||||
• le menu contextuel (library / local-workspace) via fdCtx.setIcon
|
||||
• l'éditeur de page (clic sur l'icône du titre)
|
||||
Expose le store Alpine `fdIconPicker` + `window.FDIconPicker`.
|
||||
Onglets Emoji / Icons / Upload, recherche, aléatoire, teintes de peau,
|
||||
récents (localStorage) et emojis personnalisés du workspace (API).
|
||||
############################################################################}
|
||||
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
||||
|
||||
<script data-cfasync="false">
|
||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||
(function () {
|
||||
var FD_ICONS = {
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
||||
};
|
||||
window.FD_ICONS = FD_ICONS;
|
||||
window.fd_icon = function (name, size) {
|
||||
size = size || 18;
|
||||
var inner = FD_ICONS[name];
|
||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
||||
};
|
||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
||||
window.fdIconHtml = function (value, size) {
|
||||
size = size || 16;
|
||||
var v = (value == null ? '' : String(value)).trim();
|
||||
if (!v) return '';
|
||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
||||
}
|
||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
||||
return v;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script data-cfasync="false">
|
||||
(function () {
|
||||
if (window.__fdIconPickerRegistered) return;
|
||||
window.__fdIconPickerRegistered = true;
|
||||
|
||||
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
var TONEABLE = {};
|
||||
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
|
||||
|
||||
var CATS = [
|
||||
{ id: 'people', label: 'People', items: [
|
||||
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
|
||||
]},
|
||||
{ id: 'nature', label: 'Nature', items: [
|
||||
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
|
||||
]},
|
||||
{ id: 'food', label: 'Food', items: [
|
||||
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
|
||||
]},
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
|
||||
]},
|
||||
{ id: 'travel', label: 'Travel', items: [
|
||||
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
|
||||
]},
|
||||
{ id: 'objects', label: 'Objects', items: [
|
||||
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
|
||||
]},
|
||||
{ id: 'symbols', label: 'Symbols', items: [
|
||||
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
|
||||
]},
|
||||
{ id: 'flags', label: 'Flags', items: [
|
||||
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️🌈','rainbow flag pride'],['🏴☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
|
||||
]}
|
||||
];
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdIconPicker) return;
|
||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
||||
|
||||
Alpine.store('fdIconPicker', {
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
||||
cats: CATS,
|
||||
|
||||
openFor(opts) {
|
||||
opts = opts || {};
|
||||
this.onPick = opts.onPick || null;
|
||||
this.onRemove = opts.onRemove || null;
|
||||
this.query = '';
|
||||
this.toneOpen = false;
|
||||
this.customModal = false;
|
||||
this._cx = (opts.x != null) ? opts.x : 240;
|
||||
this._cy = (opts.y != null) ? opts.y : 200;
|
||||
this.x = this._cx;
|
||||
this.y = this._cy;
|
||||
this.open = true;
|
||||
var self = this;
|
||||
var place = function () {
|
||||
var el = document.querySelector('.fd-icon-picker');
|
||||
if (!el) return;
|
||||
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = self._cx, ny = self._cy;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
||||
self.x = Math.max(8, nx);
|
||||
self.y = Math.max(8, ny);
|
||||
};
|
||||
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
|
||||
else setTimeout(place, 0);
|
||||
this.loadRecent();
|
||||
this.loadCustom();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.customModal = false;
|
||||
this.toneOpen = false;
|
||||
this.onPick = null;
|
||||
this.onRemove = null;
|
||||
},
|
||||
|
||||
matches(name) {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return name.toLowerCase().indexOf(q) >= 0;
|
||||
},
|
||||
|
||||
items() {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
var out = [];
|
||||
this.cats.forEach(function (c) {
|
||||
c.items.forEach(function (it) {
|
||||
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (this.category === 'recent') return this.recent;
|
||||
var found = [];
|
||||
for (var i = 0; i < this.cats.length; i++) {
|
||||
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
withTone(e) {
|
||||
if (!this.skinTone) return e;
|
||||
if (TONEABLE[e]) return e + TONES[this.skinTone];
|
||||
return e;
|
||||
},
|
||||
|
||||
pick(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return;
|
||||
this.pushRecent(v);
|
||||
var fn = this.onPick;
|
||||
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
this.close();
|
||||
},
|
||||
|
||||
random() {
|
||||
var pool = [];
|
||||
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
|
||||
if (!pool.length) return;
|
||||
this.pick(pool[Math.floor(Math.random() * pool.length)]);
|
||||
},
|
||||
|
||||
setTone(i) { this.skinTone = i; this.toneOpen = false; },
|
||||
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
|
||||
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
var r = this.recent.filter(function (x) { return x !== e; });
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
try {
|
||||
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
this.customModal = true;
|
||||
this.customName = '';
|
||||
this.customPreview = '';
|
||||
this.customFile = null;
|
||||
this.tab = 'upload';
|
||||
},
|
||||
closeCustomModal() { this.customModal = false; },
|
||||
onCustomFile(ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
this.customFile = f;
|
||||
var self = this;
|
||||
var fr = new FileReader();
|
||||
fr.onload = function () { self.customPreview = fr.result; };
|
||||
fr.readAsDataURL(f);
|
||||
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
|
||||
},
|
||||
async saveCustom() {
|
||||
if (!this.customFile || this.customBusy) return;
|
||||
this.customBusy = true;
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('name', this.customName || 'emoji');
|
||||
fd.append('file', this.customFile);
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
if (d && d.emoji) {
|
||||
this.custom.unshift(d.emoji);
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
},
|
||||
async deleteCustom(id) {
|
||||
try {
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.FDIconPicker = {
|
||||
openFor: function (opts) {
|
||||
var s = window.Alpine && Alpine.store('fdIconPicker');
|
||||
if (s) s.openFor(opts);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-tab.active{background:var(--bg-hover);color:var(--text-primary);font-weight:600;}
|
||||
.fd-icon-picker .fdip-remove{margin-left:auto;background:transparent;border:none;color:var(--text-secondary);font-size:12px;padding:5px 8px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-remove:hover{background:var(--bg-hover);color:var(--danger);}
|
||||
.fd-icon-picker .fdip-tools{display:flex;align-items:center;gap:6px;padding:8px 10px;}
|
||||
.fd-icon-picker .fdip-search{flex:1;min-width:0;font-size:13px;padding:6px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);outline:none;}
|
||||
.fd-icon-picker .fdip-search:focus{border-color:var(--accent);}
|
||||
.fd-icon-picker .fdip-tool{width:30px;height:30px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:15px;color:var(--text-primary);}
|
||||
.fd-icon-picker .fdip-tool:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-tone-wrap{position:relative;}
|
||||
.fd-icon-picker .fdip-tone-menu{position:absolute;right:0;top:34px;display:flex;gap:2px;padding:6px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.25);z-index:5;}
|
||||
.fd-icon-picker .fdip-tone{width:26px;height:26px;border:none;background:transparent;border-radius:6px;cursor:pointer;font-size:16px;}
|
||||
.fd-icon-picker .fdip-tone:hover,.fd-icon-picker .fdip-tone.selected{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-body{flex:1;overflow-y:auto;padding:0 10px 6px;}
|
||||
.fd-icon-picker .fdip-section{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);padding:6px 2px 2px;}
|
||||
.fd-icon-picker .fdip-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;}
|
||||
.fd-icon-picker .fdip-emoji,.fd-icon-picker .fdip-icon{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:21px;line-height:1;color:var(--text-primary);position:relative;}
|
||||
.fd-icon-picker .fdip-emoji:hover,.fd-icon-picker .fdip-icon:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-custom img{width:24px;height:24px;object-fit:contain;}
|
||||
.fd-icon-picker .fdip-del{position:absolute;top:-2px;right:-2px;width:14px;height:14px;border-radius:50%;background:var(--danger);color:#fff;font-size:10px;line-height:14px;text-align:center;display:none;}
|
||||
.fd-icon-picker .fdip-custom:hover .fdip-del{display:block;}
|
||||
.fd-icon-picker .fdip-empty{color:var(--text-dim);font-size:12px;text-align:center;padding:24px 0;}
|
||||
.fd-icon-picker .fdip-cats{display:flex;align-items:center;gap:2px;padding:6px 10px;border-top:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-cat{flex:1;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;color:var(--text-secondary);}
|
||||
.fd-icon-picker .fdip-cat:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-cat.active{background:var(--bg-hover);color:var(--accent);}
|
||||
.fd-icon-picker .fdip-cat svg{width:16px;height:16px;}
|
||||
.fdip-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2300;display:flex;align-items:center;justify-content:center;}
|
||||
.fdip-modal{width:420px;max-width:92vw;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:20px;box-shadow:0 16px 48px rgba(0,0,0,.35);}
|
||||
.fdip-modal-title{font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:6px;}
|
||||
.fdip-modal-desc{font-size:13px;color:var(--text-secondary);margin-bottom:16px;}
|
||||
.fdip-upload-btn{display:inline-block;padding:8px 14px;font-size:13px;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--text-primary);background:var(--bg-secondary);}
|
||||
.fdip-upload-btn:hover{background:var(--bg-hover);}
|
||||
.fdip-preview-boxes{display:flex;gap:10px;margin:14px 0;}
|
||||
.fdip-preview{width:64px;height:64px;border-radius:8px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);}
|
||||
.fdip-preview.dark{background:#111;}
|
||||
.fdip-preview.light{background:#fff;}
|
||||
.fdip-preview img{max-width:44px;max-height:44px;object-fit:contain;}
|
||||
.fdip-modal-label{display:block;font-size:12px;color:var(--text-secondary);margin:8px 0 4px;}
|
||||
.fdip-modal-input{width:100%;font-size:14px;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;box-sizing:border-box;}
|
||||
.fdip-modal-input:focus{border-color:var(--accent);}
|
||||
.fdip-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;}
|
||||
.fdip-modal-actions button{padding:8px 16px;font-size:13px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);}
|
||||
.fdip-modal-actions button.primary{background:var(--accent);border-color:var(--accent);color:#fff;}
|
||||
.fdip-modal-actions button:disabled{opacity:.5;cursor:default;}
|
||||
</style>
|
||||
|
||||
<div x-data>
|
||||
<div class="fd-icon-picker" x-show="$store.fdIconPicker.open" x-cloak
|
||||
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
||||
@keydown.escape.window="$store.fdIconPicker.close()"
|
||||
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
|
||||
<div class="fdip-tabs">
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
|
||||
<button class="fdip-remove" @click="$store.fdIconPicker.remove()">Remove</button>
|
||||
</div>
|
||||
|
||||
<div class="fdip-tools" x-show="$store.fdIconPicker.tab !== 'upload'">
|
||||
<input class="fdip-search" placeholder="Filter..." x-model="$store.fdIconPicker.query">
|
||||
<button class="fdip-tool" title="Random" @click="$store.fdIconPicker.random()">🎲</button>
|
||||
<div class="fdip-tone-wrap" x-show="$store.fdIconPicker.tab === 'emoji'">
|
||||
<button class="fdip-tool" title="Select skin tone" @click.stop="$store.fdIconPicker.toneOpen = !$store.fdIconPicker.toneOpen">👋</button>
|
||||
<div class="fdip-tone-menu" x-show="$store.fdIconPicker.toneOpen" @click.outside="$store.fdIconPicker.toneOpen = false">
|
||||
<template x-for="(t, ti) in ['👋','👋🏻','👋🏼','👋🏽','👋🏾','👋🏿']" :key="ti">
|
||||
<button class="fdip-tone" :class="{ selected: $store.fdIconPicker.skinTone === ti }" @click="$store.fdIconPicker.setTone(ti)" x-text="t"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdip-body">
|
||||
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
||||
<div>
|
||||
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
||||
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fdip-empty" x-show="!$store.fdIconPicker.items().length">No emoji found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.fdIconPicker.tab === 'icons'">
|
||||
<div>
|
||||
<div class="fdip-grid">
|
||||
{% for name in picker_icons %}
|
||||
<button class="fdip-icon" x-show="$store.fdIconPicker.matches('{{ name }}')" @click="$store.fdIconPicker.pick('{{ name }}')" title="{{ name }}">{{ fd_icon(name, 20) }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.fdIconPicker.tab === 'upload'">
|
||||
<div>
|
||||
<div class="fdip-custom-head" style="display:flex;align-items:center;justify-content:space-between;padding:6px 2px;">
|
||||
<span class="fdip-section" style="padding:0;">Custom emojis</span>
|
||||
<button class="fdip-tool" style="width:auto;padding:0 10px;" @click="$store.fdIconPicker.openCustomModal()">+ Add</button>
|
||||
</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="ce in $store.fdIconPicker.custom" :key="ce.id">
|
||||
<button class="fdip-emoji fdip-custom" @click="$store.fdIconPicker.pick(ce.url)" :title="ce.name">
|
||||
<img :src="ce.url" :alt="ce.name">
|
||||
<span class="fdip-del" @click.stop="$store.fdIconPicker.deleteCustom(ce.id)">×</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fdip-empty" x-show="!$store.fdIconPicker.custom.length">No custom emoji yet</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="fdip-cats" x-show="$store.fdIconPicker.tab === 'emoji'">
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'recent' }" title="Recent" @click="$store.fdIconPicker.setCategory('recent')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'people' }" title="People" @click="$store.fdIconPicker.setCategory('people')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 14s1.3 2 3.5 2 3.5-2 3.5-2"/><line x1="9" y1="9.5" x2="9.01" y2="9.5"/><line x1="15" y1="9.5" x2="15.01" y2="9.5"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'nature' }" title="Nature" @click="$store.fdIconPicker.setCategory('nature')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 20A7 7 0 0 1 4 13c0-6 7-10 16-10 0 9-4 16-10 16z"/><path d="M4 21c2-4 5-7 9-9"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'food' }" title="Food" @click="$store.fdIconPicker.setCategory('food')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21l6-6"/><path d="M14 3s5 1 7 7c-4 4-8 3-10 1s-1-6 3-8z"/><path d="M9 12l-4 4"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'activity' }" title="Activity" @click="$store.fdIconPicker.setCategory('activity')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 11V6a2 2 0 0 1 4 0v5"/><path d="M10 11V4a2 2 0 0 1 4 0v7"/><path d="M14 11V6a2 2 0 0 1 4 0v9a7 7 0 0 1-7 7H9a6 6 0 0 1-6-6v-3a2 2 0 0 1 3-1.7"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'travel' }" title="Travel" @click="$store.fdIconPicker.setCategory('travel')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 16l20-6-3-3-7 1-5-5-2 1 3 5-4 1-2-2-1 1z"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'objects' }" title="Objects" @click="$store.fdIconPicker.setCategory('objects')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a6 6 0 0 0-4 10.5c.8.7 1 1.5 1 2.5h6c0-1 .2-1.8 1-2.5A6 6 0 0 0 12 2z"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'symbols' }" title="Symbols" @click="$store.fdIconPicker.setCategory('symbols')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'flags' }" title="Flags" @click="$store.fdIconPicker.setCategory('flags')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/></svg></button>
|
||||
<button class="fdip-cat" title="Add custom emoji" @click="$store.fdIconPicker.openCustomModal()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdip-modal-overlay" x-show="$store.fdIconPicker.customModal" x-cloak @click.self="$store.fdIconPicker.closeCustomModal()">
|
||||
<div class="fdip-modal">
|
||||
<div class="fdip-modal-title">Add custom emoji</div>
|
||||
<div class="fdip-modal-desc">Custom emoji can be used by anyone in your workspace.</div>
|
||||
<label class="fdip-upload-btn">
|
||||
<span x-show="!$store.fdIconPicker.customPreview">Upload an image</span>
|
||||
<span x-show="$store.fdIconPicker.customPreview">Replace</span>
|
||||
<input type="file" accept="image/*" @change="$store.fdIconPicker.onCustomFile($event)" style="display:none">
|
||||
</label>
|
||||
<div class="fdip-preview-boxes" x-show="$store.fdIconPicker.customPreview">
|
||||
<div class="fdip-preview dark"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
|
||||
<div class="fdip-preview light"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
|
||||
</div>
|
||||
<label class="fdip-modal-label">Emoji name</label>
|
||||
<input class="fdip-modal-input" x-model="$store.fdIconPicker.customName" placeholder="pushup">
|
||||
<div class="fdip-modal-actions">
|
||||
<button @click="$store.fdIconPicker.closeCustomModal()">Cancel</button>
|
||||
<button class="primary" :disabled="$store.fdIconPicker.customBusy || !$store.fdIconPicker.customFile" @click="$store.fdIconPicker.saveCustom()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
{# FlowDeck — Outline SVG Icon Set (v4.0+)
|
||||
Usage: {{ fd_icon('folder') }} {{ fd_icon('star', 16) }}
|
||||
All icons use stroke="currentColor" for theme compatibility.
|
||||
#}
|
||||
{%- macro fd_icon(name, size=18) -%}
|
||||
{%- set s = size|string -%}
|
||||
{%- if name == 'folder' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
{%- elif name == 'file' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
{%- elif name == 'calendar' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{%- elif name == 'clock' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{%- elif name == 'star' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
{%- elif name == 'bot' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
|
||||
{%- elif name == 'users' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
{%- elif name == 'globe' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
{%- elif name == 'lock' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
{%- elif name == 'book' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
{%- elif name == 'check-square' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
{%- elif name == 'trash' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
{%- elif name == 'help-circle' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
{%- elif name == 'settings' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{%- elif name == 'refresh' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
{%- elif name == 'log-out' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
{%- elif name == 'message-square' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
{%- elif name == 'home' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
{%- elif name == 'search' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
{%- elif name == 'link' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
{%- elif name == 'plus' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{%- elif name == 'bell' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
{%- elif name == 'image' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
{%- elif name == 'download' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{%- elif name == 'chevron-left' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
{%- elif name == 'chevron-right' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
{%- elif name == 'list' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
{%- elif name == 'bar-chart' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
{%- elif name == 'grid' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
{%- elif name == 'align-left' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
|
||||
{%- elif name == 'corner-down-right' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>
|
||||
{%- elif name == 'copy' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
{%- elif name == 'chevron-down' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
{%- elif name == 'key' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
{%- elif name == 'inbox' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
{%- elif name == 'edit' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
{%- elif name == 'copy' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
{%- elif name == 'eye-off' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
{%- elif name == 'eye' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{%- elif name == 'share' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
{%- elif name == 'more-horizontal' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||
{%- elif name == 'x' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{%- elif name == 'paperclip' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
{%- elif name == 'external-link' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
{%- elif name == 'sparkles' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/></svg>
|
||||
{%- elif name == 'lightbulb' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
|
||||
{%- elif name == 'tag' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||
{%- elif name == 'file-text' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
{%- elif name == 'save' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
{%- elif name == 'upload' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
{%- elif name == 'trending-up' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
|
||||
{%- elif name == 'zap' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
{%- elif name == 'alert-triangle' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
{%- elif name == 'user' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{#- Renders a page_icon value: custom-emoji image URL, known icon name, or emoji. -#}
|
||||
{%- macro fd_page_icon(value, size=18) -%}
|
||||
{%- set v = (value or '')|string|trim -%}
|
||||
{%- if v.startswith('/') or v.startswith('http') -%}
|
||||
<img src="{{ v }}" alt="" style="width:{{ size }}px;height:{{ size }}px;object-fit:contain;vertical-align:middle;display:inline-block;">
|
||||
{%- elif v in ("folder","file","calendar","clock","star","bot","users","globe","lock","book","check-square","trash","help-circle","settings","refresh","log-out","message-square","home","search","link","plus","bell","image","download","list","bar-chart","grid","align-left","corner-down-right","copy","key","inbox","edit","eye","share","x","paperclip","external-link","sparkles","lightbulb","tag","file-text","save","upload","trending-up","zap","alert-triangle","user") -%}
|
||||
{{ fd_icon(v, size) }}
|
||||
{%- else -%}
|
||||
{{ v }}
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
@@ -0,0 +1,134 @@
|
||||
{# ── Notification bell + dropdown (v4.9.0) ──
|
||||
Self-contained Alpine component. Polls unread count, opens a panel of
|
||||
notifications, marks as read. Only rendered for authenticated users. #}
|
||||
{% if user and user.get('id') %}
|
||||
<span class="topbar-btn fd-notif-bell" x-data="fdNotifications()" x-init="init()"
|
||||
@click.outside="open=false" style="position:relative;display:inline-flex;">
|
||||
<button type="button" class="topbar-btn" @click="toggle()" title="Notifications"
|
||||
style="padding:6px;position:relative;border:none;background:none;cursor:pointer;color:var(--text);">
|
||||
{{ fd_icon("bell", 16) }}
|
||||
<span x-show="unread > 0" x-cloak
|
||||
class="fd-notif-badge"
|
||||
x-text="unread > 99 ? '99+' : unread"
|
||||
style="position:absolute;top:0;right:0;background:#E03E3E;color:#fff;
|
||||
border-radius:10px;font-size:10px;line-height:1;padding:3px 5px;
|
||||
font-weight:700;min-width:16px;text-align:center;transform:translate(30%,-30%);"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak x-transition
|
||||
class="fd-notif-panel"
|
||||
style="position:absolute;top:calc(100% + 6px);right:0;width:340px;max-width:92vw;
|
||||
background:var(--bg-primary,#1f1f1f);border:1px solid var(--border,#333);
|
||||
border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden;z-index:2000;">
|
||||
<div class="fd-notif-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Notifications</span>
|
||||
<button type="button" class="btn-sm" @click="markAllRead()" x-show="unread > 0"
|
||||
style="font-size:12px;cursor:pointer;">Mark all read</button>
|
||||
</div>
|
||||
<div class="fd-notif-list" style="max-height:360px;overflow-y:auto;">
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<a :href="n.url || '#'" @click.prevent="openItem(n)"
|
||||
class="fd-notif-item"
|
||||
style="display:flex;gap:10px;padding:10px 14px;text-decoration:none;color:var(--text);
|
||||
border-bottom:1px solid var(--border,#2a2a2a);cursor:pointer;"
|
||||
:style="{ background: n.is_read ? 'transparent' : 'rgba(35,131,226,.10)' }">
|
||||
<div style="width:30px;height:30px;border-radius:50%;flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-weight:700;font-size:14px;color:#fff;"
|
||||
:style="{ background: n.actor_color || '#3A3A3A' }">
|
||||
<template x-if="n.actor_avatar">
|
||||
<img :src="n.actor_avatar" style="width:30px;height:30px;border-radius:50%;object-fit:cover;">
|
||||
</template>
|
||||
<span x-show="!n.actor_avatar" x-text="(n.actor_name || n.actor_login || '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text);" x-text="n.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);margin-top:2px;white-space:normal;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
|
||||
x-text="n.message"></div>
|
||||
<div style="font-size:11px;color:var(--text-tertiary,#777);margin-top:4px;" x-text="timeAgo(n.created_at)"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="!loading && items.length === 0"
|
||||
style="padding:24px;text-align:center;color:var(--text-dim,#999);font-size:13px;">
|
||||
You're all caught up 🎉
|
||||
</div>
|
||||
<div x-show="loading" style="padding:24px;text-align:center;color:var(--text-dim,#999);">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||
Alpine.data('fdNotifications', function () {
|
||||
return {
|
||||
open: false, items: [], unread: 0, loading: false, _timer: null,
|
||||
init() {
|
||||
this.load();
|
||||
var self = this;
|
||||
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
||||
},
|
||||
destroy() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
},
|
||||
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
||||
timeAgo(s) {
|
||||
if (!s) return '';
|
||||
var t = new Date((String(s).includes('Z') || String(s).includes('T') ? s : s + 'Z'));
|
||||
if (isNaN(t.getTime())) t = new Date(s);
|
||||
var diff = Math.floor((Date.now() - t.getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
csrf() {
|
||||
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
},
|
||||
refreshCount() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/unread-count', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { self.unread = d.unread || 0; })
|
||||
.catch(function () {});
|
||||
},
|
||||
load() {
|
||||
var self = this;
|
||||
this.loading = true;
|
||||
fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
self.items = d.notifications || [];
|
||||
self.unread = d.unread || 0;
|
||||
self.loading = false;
|
||||
})
|
||||
.catch(function () { self.loading = false; });
|
||||
},
|
||||
openItem(n) {
|
||||
if (!n.is_read) {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
||||
body: JSON.stringify({ id: n.id })
|
||||
}).then(function () { self.refreshCount(); self.load(); });
|
||||
}
|
||||
if (n.url) window.location.href = n.url;
|
||||
this.open = false;
|
||||
},
|
||||
markAllRead() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
||||
body: JSON.stringify({})
|
||||
}).then(function () { self.refreshCount(); self.load(); });
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,833 @@
|
||||
<div
|
||||
class="page-editor-wrapper"
|
||||
x-data="editorState()"
|
||||
x-init="loadCoverIcon()"
|
||||
>
|
||||
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
||||
|
||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||
<div
|
||||
class="share-dialog"
|
||||
x-show="shareOpen"
|
||||
@click.outside="!_justOpened && (shareOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
>
|
||||
<!-- Header with tabs -->
|
||||
<div class="sd-header">
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'share' }"
|
||||
@click="shareTab = 'share'"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'publish' }"
|
||||
@click="shareTab = 'publish'"
|
||||
>
|
||||
Publish
|
||||
<span class="sd-badge" x-show="pagePublished"></span>
|
||||
</button>
|
||||
<button class="sd-close" @click="shareOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ SHARE TAB ═══════ -->
|
||||
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
||||
<!-- Invite input -->
|
||||
<div class="sd-invite-wrap">
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="text"
|
||||
class="sd-input"
|
||||
x-ref="inviteInput"
|
||||
placeholder="Add people, emails or type a name..."
|
||||
x-model="inviteEmail"
|
||||
@input="inviteInput()"
|
||||
@focus="inviteInput()"
|
||||
@keydown.enter.prevent="inviteEnter()"
|
||||
@keydown.down.prevent="inviteMove(1)"
|
||||
@keydown.up.prevent="inviteMove(-1)"
|
||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
<!-- Existing users autocomplete -->
|
||||
<div
|
||||
class="sd-user-suggest"
|
||||
x-ref="inviteSuggest"
|
||||
x-show="inviteSuggestOpen"
|
||||
@click.outside="inviteSuggestOpen = false"
|
||||
x-transition.opacity
|
||||
>
|
||||
<template x-for="(u, i) in inviteUsers" :key="u.id">
|
||||
<div
|
||||
class="sd-user-item"
|
||||
:class="{ active: inviteSel === i }"
|
||||
@mouseenter="inviteSel = i"
|
||||
@click="pickInviteUser(u)"
|
||||
>
|
||||
<div
|
||||
class="sd-user-avatar"
|
||||
:style="{ background: u.avatar_color || '#3A3A3A' }"
|
||||
>
|
||||
<img
|
||||
x-show="u.avatar_url"
|
||||
:src="u.avatar_url"
|
||||
:alt="u.login"
|
||||
style="width:100%;height:100%;border-radius:50%;object-fit:cover;"
|
||||
/>
|
||||
<span
|
||||
x-show="!u.avatar_url"
|
||||
x-text="((u.full_name || u.login || '?').charAt(0).toUpperCase())"
|
||||
></span>
|
||||
</div>
|
||||
<div class="sd-user-meta">
|
||||
<div class="sd-user-login" x-text="u.login"></div>
|
||||
<div class="sd-user-name" x-text="u.full_name"></div>
|
||||
</div>
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context card (shown when independent permissions) -->
|
||||
<div class="sd-context-card" x-show="false">
|
||||
Share settings on this page are unlinked from parent
|
||||
page
|
||||
</div>
|
||||
|
||||
<!-- Participants list -->
|
||||
<div class="sd-participants" x-show="accessList.length">
|
||||
<template x-for="a in accessList" :key="a.id">
|
||||
{% raw %}
|
||||
<div class="sd-participant">
|
||||
<div class="sd-participant-info">
|
||||
<div class="sd-avatar" x-show="a.user_avatar_url" style="overflow:hidden">
|
||||
<img :src="a.user_avatar_url" style="width:100%;height:100%;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
<div class="sd-avatar" x-show="!a.user_avatar_url"
|
||||
x-text="(a.user_full_name || a.user_login || a.shared_with_email || '?')[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.user_full_name || a.user_login || a.shared_with_email"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.shared_with_email || a.user_login || ''"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="sd-perm-btn"
|
||||
@click="a.permOpen = !a.permOpen"
|
||||
>
|
||||
<span
|
||||
x-text="a.permission === 'edit' ? 'Full access' : a.permission === 'comment' ? 'Can comment' : 'Can view'"
|
||||
></span>
|
||||
▾
|
||||
</button>
|
||||
<div
|
||||
class="sd-perm-menu"
|
||||
x-show="a.permOpen"
|
||||
@click.outside="a.permOpen = false"
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'edit' }"
|
||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Edit, suggest and comment</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'comment' }"
|
||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Suggest and comment only</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'view' }"
|
||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Read only</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeShare(a.id); a.permOpen=false"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- General Access -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">General access</div>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
class="sd-access-btn"
|
||||
@click="accessMenuOpen = !accessMenuOpen"
|
||||
>
|
||||
<span
|
||||
x-text="generalAccess === 'invited' ? 'Only people invited' : 'Anyone with the link'"
|
||||
></span>
|
||||
<span class="chevron-down">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="sd-access-menu"
|
||||
x-show="accessMenuOpen"
|
||||
@click.outside="accessMenuOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
>People need to be invited</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
>
|
||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Can view the page</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Public permission when link is open -->
|
||||
<div
|
||||
x-show="generalAccess === 'anyone'"
|
||||
style="
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
"
|
||||
>Anyone with the link</span
|
||||
>
|
||||
<select class="sd-select-sm" x-model="publicPerm">
|
||||
<option value="viewer">Can view</option>
|
||||
<option value="commenter">Can comment</option>
|
||||
<option value="editor">Can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- v5.5.0: Cover & Icon -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">{{ fd_icon('image',14) }} Cover & Icon</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="sd-btn-secondary" @click="toggleCover()">
|
||||
<span x-show="!coverOpen">{{ fd_icon('image',14) }} Cover</span>
|
||||
<span x-show="coverOpen">{{ fd_icon('x',14) }} Hide</span>
|
||||
</button>
|
||||
<button class="sd-btn-secondary" @click="toggleIcon()">
|
||||
<span x-show="!iconOpen">{{ fd_icon('sparkles',14) }} Icon</span>
|
||||
<span x-show="iconOpen">{{ fd_icon('x',14) }} Hide</span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="coverOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
|
||||
<input type="text" class="sd-input" x-model="coverUrl" placeholder="Cover image URL" style="flex:1;min-width:200px;">
|
||||
<button class="sd-btn-primary" @click="setCoverUrl()" :disabled="coverLoading" x-show="!coverLoading">Set</button>
|
||||
<button class="sd-btn-primary" :disabled="coverLoading" x-show="coverLoading">Saving…</button>
|
||||
<label class="sd-btn-secondary" style="display:flex;align-items:center;gap:6px;">
|
||||
{{ fd_icon('upload',14) }} Upload
|
||||
<input type="file" accept="image/*" @change="uploadCover($event)" style="display:none;">
|
||||
</label>
|
||||
<button class="sd-btn-danger" @click="removeCover()" x-show="coverUrl">Remove</button>
|
||||
</div>
|
||||
<div x-show="coverUrl" style="max-width:320px;border-radius:8px;overflow:hidden;border:1px solid var(--border);">
|
||||
<img :src="coverUrl" style="width:100%;height:auto;display:block;">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
>? Learn about sharing</a
|
||||
>
|
||||
<button class="sd-footer-action" @click="copyPageLink">
|
||||
{{ fd_icon("link",14) }} Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PUBLISH TAB ═══════ -->
|
||||
<div
|
||||
class="sd-body"
|
||||
x-show="shareTab === 'publish'"
|
||||
x-transition
|
||||
>
|
||||
<!-- Before publishing -->
|
||||
<div x-show="!pagePublished" class="sd-publish-hero">
|
||||
<div class="sd-publish-preview">
|
||||
<div class="sd-preview-bar"></div>
|
||||
<div class="sd-preview-title">
|
||||
{{ fd_icon('globe',14) }} {{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sd-publish-heading">Publish to web</h3>
|
||||
<p class="sd-publish-desc">
|
||||
Make this page visible to anyone on the internet.
|
||||
You can share the link with anyone.
|
||||
</p>
|
||||
<button
|
||||
class="sd-btn-primary sd-btn-full"
|
||||
@click="publishPage()"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<p class="sd-publish-note">
|
||||
Your page will be visible to anyone with the link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- After publishing -->
|
||||
<div x-show="pagePublished">
|
||||
<div class="sd-url-bar">
|
||||
<span
|
||||
style="font-size: 13px; color: var(--text-dim)"
|
||||
>{{ workspace_key or 'flowdeck' }}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="sd-url-input"
|
||||
:value="publishedUrl || pageUrl"
|
||||
readonly
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="sd-btn-primary"
|
||||
style="
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
"
|
||||
@click="copyPageLink"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Settings list -->
|
||||
<div class="sd-settings">
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("link",14) }} Link expires</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Never ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Off ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("file",14) }} Allow duplicate as template</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("edit",14) }} Allow editing</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowEdit"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("message-square",14) }} Allow comments</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowComments"
|
||||
checked
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<button
|
||||
class="sd-danger-btn"
|
||||
@click="unpublishPage()"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Activity Popover ═══════════ -->
|
||||
<div class="activity-popover"
|
||||
x-show="activityOpen"
|
||||
@click.outside="!_justOpened && (activityOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
|
||||
<template x-if="pageCreated">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- More menu dropdown -->
|
||||
<div
|
||||
class="more-menu"
|
||||
x-show="moreOpen"
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="toggleBacklinks">
|
||||
{{ fd_icon("link-2",14) }} Linked from
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleVersions">
|
||||
{{ fd_icon("history",14) }} Version history
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleImport">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
<div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div>
|
||||
<template x-if="exportOpen">
|
||||
<div class="more-menu-sub">
|
||||
<div class="more-menu-item" @click="exportPage('markdown')"> Markdown (.md)</div>
|
||||
<div class="more-menu-item" @click="exportPage('html')"> HTML (.html)</div>
|
||||
<div class="more-menu-item" @click="exportPage('pdf')"> PDF (.pdf)</div>
|
||||
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="contentFormat === 'file' && fileUrl">
|
||||
<div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="downloadFile()">
|
||||
{{ fd_icon("download",14) }} Download
|
||||
</div>
|
||||
<div class="more-menu-item" @click="copyFileContent()">
|
||||
{{ fd_icon("copy",14) }} Copy content
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Move to Dialog ═══════════ -->
|
||||
<div class="move-dialog"
|
||||
x-show="moveOpen"
|
||||
@click.outside="moveOpen = false"
|
||||
x-transition.opacity.scale
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:320px;padding:16px;">
|
||||
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
|
||||
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
|
||||
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
|
||||
<template x-for="ws in moveWorkspaces" :key="ws.id">
|
||||
<div class="move-ws-item"
|
||||
@click="doMove(ws.id)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
|
||||
<span>{{ fd_icon("folder",14) }}</span>
|
||||
<span x-text="ws.name" style="flex:1;"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
|
||||
No workspaces available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area" x-show="coverUrl" style="position:relative;height:240px;overflow:hidden;">
|
||||
<img :src="coverUrl" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="blocks-container"
|
||||
id="_blocksCt"
|
||||
@click="onCtClick($event)"
|
||||
></div>
|
||||
<div
|
||||
id="_slashMenu"
|
||||
class="slash-menu"
|
||||
style="display: none; position: fixed; z-index: 999"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ @mention autocomplete (v4.9.0) ═══════════ -->
|
||||
<div id="_mentionMenu" class="mention-menu"
|
||||
style="display:none;position:fixed;z-index:1200;min-width:240px;max-height:280px;overflow-y:auto;
|
||||
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
||||
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
||||
|
||||
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
|
||||
<button type="button" id="_commentSelBtn"
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
|
||||
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
|
||||
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
|
||||
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
||||
<div class="comments-drawer-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Comments</span>
|
||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
No comments yet. Select some text and click 💬 Comment.
|
||||
</div>
|
||||
<template x-for="c in comments" :key="c.id">
|
||||
<div class="comment-thread" style="margin-bottom:18px;"
|
||||
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
|
||||
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
|
||||
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
|
||||
</div>
|
||||
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
|
||||
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
|
||||
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
|
||||
@click="deleteComment(c.id)">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
|
||||
<textarea x-model="commentDraft" rows="2"
|
||||
placeholder="Add a comment... Use @ to mention someone."
|
||||
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
|
||||
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
<div
|
||||
class="sd-toast"
|
||||
x-show="toastVisible"
|
||||
x-transition
|
||||
x-text="toastMsg"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
||||
<div
|
||||
class="get-started-toolbar"
|
||||
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
||||
>
|
||||
<span class="gs-label">Get started with</span>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="askAI()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("sparkles",14) }}</span> Ask AI
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="meetingNote()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="createDatabase()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="createForm()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="gsMoreOpen = !gsMoreOpen"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
|
||||
</button>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
<span>⋯</span>
|
||||
</button>
|
||||
<div
|
||||
class="gs-more-dropdown"
|
||||
x-show="gsMoreOpen"
|
||||
@click.outside="gsMoreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("bar-chart",14) }} Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("copy",14) }} Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon('list',14) }} List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("image",14) }} Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
<span x-show="!saving&&!dirty&&lastSaved"
|
||||
>Saved <span x-text="lastSaved"></span
|
||||
></span>
|
||||
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
|
||||
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
|
||||
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
|
||||
@click.self="closeDbTemplatePicker()">
|
||||
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Create a database</h3>
|
||||
<button class="flowdeck-modal-close" @click="closeDbTemplatePicker()">×</button>
|
||||
</div>
|
||||
<div style="padding:12px 20px;overflow-y:auto;">
|
||||
<div x-show="dbTplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Loading templates…</div>
|
||||
<div x-show="!dbTplLoading">
|
||||
<!-- Blank -->
|
||||
<div class="fd-dbtpl-card" @click="createDbFromTemplate(null)"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
|
||||
<span style="font-size:22px;">📋</span>
|
||||
<div><div style="font-weight:600;color:var(--text,#fff);font-size:14px;">Blank database</div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);">Start from an empty database</div></div>
|
||||
</div>
|
||||
<template x-for="t in dbTemplates" :key="t.id">
|
||||
<div class="fd-dbtpl-card" @click="createDbFromTemplate(t.name)"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
|
||||
<span style="font-size:22px;" x-text="t.icon||'📋'"></span>
|
||||
<div style="flex:1;"><div style="font-weight:600;color:var(--text,#fff);font-size:14px;" x-text="t.name"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);" x-text="t.description||''"></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!dbTplLoading && !dbTemplates.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
|
||||
No templates available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
|
||||
<div class="versions-popover"
|
||||
x-show="versionsOpen"
|
||||
@click.outside="versionsOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:380px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('history',14) }} Version history</span>
|
||||
<button class="topbar-btn" @click="versionsOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="versionsLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!versionsLoading && versionsList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No versions yet</div>
|
||||
</template>
|
||||
<template x-for="v in versionsList" :key="v.id">
|
||||
<div class="version-item"
|
||||
style="padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;"
|
||||
@click="restoreVersion(v.id)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="v.note || 'Edited'"></div>
|
||||
<span style="font-size:12px;color:var(--text-dim);" x-text="v.author ? v.author + ' · ' : ''"></span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;" x-text="formatDate(v.created_at)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Backlinks Popover ═══════════ -->
|
||||
<div class="backlinks-popover"
|
||||
x-show="backlinksOpen"
|
||||
@click.outside="backlinksOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:360px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('link-2',14) }} Linked from</span>
|
||||
<button class="topbar-btn" @click="backlinksOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="backlinksLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!backlinksLoading && backlinksList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Import Modal ═══════════ -->
|
||||
<div class="import-modal"
|
||||
x-show="importOpen"
|
||||
@click.self="importOpen = false"
|
||||
x-transition.opacity
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;">
|
||||
<div class="import-box" style="width:560px;max-width:94vw;max-height:76vh;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text);">{{ fd_icon('download',14) }} Import page</h3>
|
||||
<button class="flowdeck-modal-close" @click="importOpen = false">×</button>
|
||||
</div>
|
||||
<div style="padding:20px;overflow-y:auto;flex:1;">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
<!-- Markdown text import -->
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">Markdown text</label>
|
||||
<textarea x-model="importText" rows="10" placeholder="Paste Markdown here…" style="width:100%;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:vertical;outline:none;box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import as new page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
|
||||
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,505 @@
|
||||
<style>
|
||||
/* ── v5.13.0 Realtime : présence + curseurs live ── */
|
||||
.rt-presence{display:inline-flex;align-items:center;gap:2px;margin-right:4px;}
|
||||
.rt-presence .rt-avatar{width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;color:#fff;border:2px solid var(--bg-secondary,#1f1f1f);cursor:default;user-select:none;}
|
||||
.rt-presence .rt-avatar.rt-me{opacity:.85;box-shadow:0 0 0 1px var(--text-primary,#e6e6e6);}
|
||||
.rt-presence .rt-count{font-size:11px;color:var(--text-tertiary,#999);margin-left:2px;}
|
||||
.rt-cursors{position:fixed;inset:0;pointer-events:none;z-index:1200;}
|
||||
.rt-cursor{position:fixed;transform:translate(-1px,-1px);min-width:2px;width:2px;z-index:1201;border-radius:1px;}
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
ops.push({ type: 'update', block: clone(b) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify((blocks || []).map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
out = (blocks || []).map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
} catch (e) { return; }
|
||||
const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; });
|
||||
(E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); });
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
{% macro render_workspace_tree(pages, depth=0) %}
|
||||
{% for page in pages %}
|
||||
{% set safe_name = page.name | replace("'", "\\'") %}
|
||||
<li class="sidebar-item ws-tree-item"
|
||||
data-id="{{ page.db_id }}"
|
||||
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
||||
data-depth="{{ depth }}"
|
||||
data-name="{{ safe_name }}"
|
||||
:class="{ active: activeNodeId === {{ page.db_id }} }"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, {{ page.db_id }})"
|
||||
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
||||
@dragleave="dragLeave($event)"
|
||||
@drop.prevent="drop($event, {{ page.db_id }})"
|
||||
style="padding-left:{{ 4 + depth * 12 }}px;"
|
||||
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||
@touchstart="wsTouchStart($event)"
|
||||
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ safe_name }}', {{ 'true' if page.is_folder else 'false' }})"
|
||||
@touchmove="wsTouchMove($event)">
|
||||
{% if page.is_folder %}
|
||||
<button class="tree-chevron"
|
||||
@click.stop="toggleTreeFolder({{ page.db_id }})"
|
||||
:class="{ open: expandedFolders[{{ page.db_id }}] }"
|
||||
title="Toggle folder">▶</button>
|
||||
{% else %}
|
||||
<span style="width:12px;flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
|
||||
{% if page.is_shared %}<span class="page-shared-badge" title="Partagé">👥</span>{% endif %}
|
||||
{% if page.is_folder %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
draggable="false"
|
||||
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
|
||||
{% else %}
|
||||
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if page.children %}
|
||||
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
||||
<ul style="list-style:none;margin:0;padding:0;">
|
||||
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
@@ -1,11 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}👤{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
{% block title_prefix %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">👤</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
|
||||
<h1>Account Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +50,7 @@
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
|
||||
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
|
||||
<span style="font-size:20px;">🔗</span>
|
||||
<span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600;">Gitea</div>
|
||||
<div style="font-size:12px; color:var(--text-dim);">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{# FlowDeck — Agent message fragment (v4.10.0). Rendered standalone for history. #}
|
||||
<div class="fd-agent-msg fd-agent-msg-{{ role }}">
|
||||
<div class="fd-agent-msg-avatar">
|
||||
{% if role == 'assistant' %}🤖{% else %}👤{% endif %}
|
||||
</div>
|
||||
<div class="fd-agent-msg-body">
|
||||
<div class="fd-agent-msg-meta">{{ role }} · {{ model or '' }}</div>
|
||||
<div class="fd-agent-msg-content">{{ content }}</div>
|
||||
{% if tool_calls %}
|
||||
<ul class="fd-agent-msg-tools">
|
||||
{% for call in (tool_calls | fromjson_json) %}
|
||||
<li>🛠 {{ call.name }} — <code>{{ call.arguments | tojson }}</code></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
+1720
-152
File diff suppressed because it is too large
Load Diff
+29
-15
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
{% block title_prefix %}Kanban board new{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Cover image -->
|
||||
@@ -15,7 +16,7 @@
|
||||
<!-- Page title -->
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
|
||||
<h1>{{ repo }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,31 +41,31 @@
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📊</span> Kanban board
|
||||
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" data-view="detailed"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📋</span> Detailed board
|
||||
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" data-view="table"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">☰</span> Table view
|
||||
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
|
||||
</button>
|
||||
<button class="view-tab" data-view="status"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📈</span> Status overview
|
||||
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
|
||||
</button>
|
||||
<button class="view-tab" data-view="teamload"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">👥</span> Team Load
|
||||
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +111,7 @@
|
||||
<span class="sort-direction" @click="toggleDir(i)">
|
||||
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
|
||||
</span>
|
||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button>
|
||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
|
||||
@@ -118,8 +119,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="toolbar-btn" title="Properties">⚡</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
|
||||
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
|
||||
<button class="toolbar-btn">⋯</button>
|
||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||
</div>
|
||||
@@ -151,9 +152,9 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -244,7 +245,11 @@
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, { method: 'POST' })
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
@@ -277,7 +282,16 @@
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date"><span>📅</span> {{ card.due_date }}</div>
|
||||
<div class="card-date"><span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user