Refactor frontend into ES modules with modern tooling
CI / lint (push) Successful in 25s
CI / security (push) Successful in 19s
CI / test (push) Successful in 29s
CI / build (push) Successful in 8s
CI / e2e (push) Failing after 21s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
@@ -0,0 +1,42 @@
|
||||
# ObsiGate PWA Icons
|
||||
|
||||
## Generated Icons
|
||||
|
||||
This directory contains PWA icons in SVG format.
|
||||
|
||||
### Converting to PNG
|
||||
|
||||
For production, convert these SVG files to PNG:
|
||||
|
||||
**Using ImageMagick:**
|
||||
```bash
|
||||
for file in *.svg; do
|
||||
size=$(echo $file | grep -oP '\d+x\d+' | head -1 | cut -d'x' -f1)
|
||||
convert -background none -resize ${size}x${size} "$file" "${file%.svg}.png"
|
||||
done
|
||||
```
|
||||
|
||||
**Using Inkscape:**
|
||||
```bash
|
||||
for file in *.svg; do
|
||||
size=$(echo $file | grep -oP '\d+x\d+' | head -1 | cut -d'x' -f1)
|
||||
inkscape "$file" --export-filename="${file%.svg}.png" --export-width=$size
|
||||
done
|
||||
```
|
||||
|
||||
**Online tools:**
|
||||
- https://cloudconvert.com/svg-to-png
|
||||
- https://convertio.co/svg-png/
|
||||
|
||||
### Icon Types
|
||||
|
||||
- **Regular icons**: Standard app icons with rounded corners
|
||||
- **Maskable icons**: Icons with safe zone padding for adaptive icons
|
||||
- **Search icon**: Icon for the search shortcut
|
||||
|
||||
### Sizes
|
||||
|
||||
- 72x72, 96x96, 128x128, 144x144, 152x152: Mobile devices
|
||||
- 192x192: Android home screen
|
||||
- 384x384: High-res displays
|
||||
- 512x512: Splash screens and high-DPI displays
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="128" height="128" rx="19.2" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(32.0, 25.6)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="64.0" height="76.8" rx="2.56" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="6.4" y="0" width="2.56" height="76.8" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="12.8" y1="19.2" x2="57.6" y2="19.2" stroke="#1a1a1a" stroke-width="1.28" opacity="0.3"/>
|
||||
<line x1="12.8" y1="32.0" x2="57.6" y2="32.0" stroke="#1a1a1a" stroke-width="1.28" opacity="0.3"/>
|
||||
<line x1="12.8" y1="44.8" x2="57.6" y2="44.8" stroke="#1a1a1a" stroke-width="1.28" opacity="0.3"/>
|
||||
<line x1="12.8" y1="57.6" x2="44.8" y2="57.6" stroke="#1a1a1a" stroke-width="1.28" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="144" height="144" rx="21.599999999999998" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(36.0, 28.8)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="72.0" height="86.39999999999999" rx="2.88" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="7.2" y="0" width="2.88" height="86.39999999999999" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="14.4" y1="21.599999999999998" x2="64.8" y2="21.599999999999998" stroke="#1a1a1a" stroke-width="1.44" opacity="0.3"/>
|
||||
<line x1="14.4" y1="36.0" x2="64.8" y2="36.0" stroke="#1a1a1a" stroke-width="1.44" opacity="0.3"/>
|
||||
<line x1="14.4" y1="50.4" x2="64.8" y2="50.4" stroke="#1a1a1a" stroke-width="1.44" opacity="0.3"/>
|
||||
<line x1="14.4" y1="64.8" x2="50.4" y2="64.8" stroke="#1a1a1a" stroke-width="1.44" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="152" height="152" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="152" height="152" rx="22.8" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(38.0, 30.400000000000002)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="76.0" height="91.2" rx="3.04" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="7.6000000000000005" y="0" width="3.04" height="91.2" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="15.200000000000001" y1="22.8" x2="68.4" y2="22.8" stroke="#1a1a1a" stroke-width="1.52" opacity="0.3"/>
|
||||
<line x1="15.200000000000001" y1="38.0" x2="68.4" y2="38.0" stroke="#1a1a1a" stroke-width="1.52" opacity="0.3"/>
|
||||
<line x1="15.200000000000001" y1="53.199999999999996" x2="68.4" y2="53.199999999999996" stroke="#1a1a1a" stroke-width="1.52" opacity="0.3"/>
|
||||
<line x1="15.200000000000001" y1="68.4" x2="53.199999999999996" y2="68.4" stroke="#1a1a1a" stroke-width="1.52" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Full background for maskable -->
|
||||
<rect width="192" height="192" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon (centered with padding) -->
|
||||
<g transform="translate(57.25, 49.6)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="76.5" height="91.8" rx="3.06" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="7.65" y="0" width="3.06" height="91.8" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="15.3" y1="22.95" x2="68.85000000000001" y2="22.95" stroke="#1a1a1a" stroke-width="1.53" opacity="0.3"/>
|
||||
<line x1="15.3" y1="38.25" x2="68.85000000000001" y2="38.25" stroke="#1a1a1a" stroke-width="1.53" opacity="0.3"/>
|
||||
<line x1="15.3" y1="53.55" x2="68.85000000000001" y2="53.55" stroke="#1a1a1a" stroke-width="1.53" opacity="0.3"/>
|
||||
<line x1="15.3" y1="68.85000000000001" x2="53.55" y2="68.85000000000001" stroke="#1a1a1a" stroke-width="1.53" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="192" height="192" rx="28.799999999999997" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(48.0, 38.400000000000006)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="96.0" height="115.19999999999999" rx="3.84" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="9.600000000000001" y="0" width="3.84" height="115.19999999999999" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="19.200000000000003" y1="28.799999999999997" x2="86.4" y2="28.799999999999997" stroke="#1a1a1a" stroke-width="1.92" opacity="0.3"/>
|
||||
<line x1="19.200000000000003" y1="48.0" x2="86.4" y2="48.0" stroke="#1a1a1a" stroke-width="1.92" opacity="0.3"/>
|
||||
<line x1="19.200000000000003" y1="67.19999999999999" x2="86.4" y2="67.19999999999999" stroke="#1a1a1a" stroke-width="1.92" opacity="0.3"/>
|
||||
<line x1="19.200000000000003" y1="86.4" x2="67.19999999999999" y2="86.4" stroke="#1a1a1a" stroke-width="1.92" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="384" height="384" viewBox="0 0 384 384" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="384" height="384" rx="57.599999999999994" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(96.0, 76.80000000000001)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="192.0" height="230.39999999999998" rx="7.68" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="19.200000000000003" y="0" width="7.68" height="230.39999999999998" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="38.400000000000006" y1="57.599999999999994" x2="172.8" y2="57.599999999999994" stroke="#1a1a1a" stroke-width="3.84" opacity="0.3"/>
|
||||
<line x1="38.400000000000006" y1="96.0" x2="172.8" y2="96.0" stroke="#1a1a1a" stroke-width="3.84" opacity="0.3"/>
|
||||
<line x1="38.400000000000006" y1="134.39999999999998" x2="172.8" y2="134.39999999999998" stroke="#1a1a1a" stroke-width="3.84" opacity="0.3"/>
|
||||
<line x1="38.400000000000006" y1="172.8" x2="134.39999999999998" y2="172.8" stroke="#1a1a1a" stroke-width="3.84" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Full background for maskable -->
|
||||
<rect width="512" height="512" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon (centered with padding) -->
|
||||
<g transform="translate(153.25, 132.8)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="204.5" height="245.39999999999998" rx="8.18" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="20.450000000000003" y="0" width="8.18" height="245.39999999999998" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="40.900000000000006" y1="61.349999999999994" x2="184.05" y2="61.349999999999994" stroke="#1a1a1a" stroke-width="4.09" opacity="0.3"/>
|
||||
<line x1="40.900000000000006" y1="102.25" x2="184.05" y2="102.25" stroke="#1a1a1a" stroke-width="4.09" opacity="0.3"/>
|
||||
<line x1="40.900000000000006" y1="143.14999999999998" x2="184.05" y2="143.14999999999998" stroke="#1a1a1a" stroke-width="4.09" opacity="0.3"/>
|
||||
<line x1="40.900000000000006" y1="184.05" x2="143.14999999999998" y2="184.05" stroke="#1a1a1a" stroke-width="4.09" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" rx="76.8" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(128.0, 102.4)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="256.0" height="307.2" rx="10.24" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="25.6" y="0" width="10.24" height="307.2" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="51.2" y1="76.8" x2="230.4" y2="76.8" stroke="#1a1a1a" stroke-width="5.12" opacity="0.3"/>
|
||||
<line x1="51.2" y1="128.0" x2="230.4" y2="128.0" stroke="#1a1a1a" stroke-width="5.12" opacity="0.3"/>
|
||||
<line x1="51.2" y1="179.2" x2="230.4" y2="179.2" stroke="#1a1a1a" stroke-width="5.12" opacity="0.3"/>
|
||||
<line x1="51.2" y1="230.4" x2="179.2" y2="230.4" stroke="#1a1a1a" stroke-width="5.12" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="72" height="72" rx="10.799999999999999" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(18.0, 14.4)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="36.0" height="43.199999999999996" rx="1.44" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="3.6" y="0" width="1.44" height="43.199999999999996" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="7.2" y1="10.799999999999999" x2="32.4" y2="10.799999999999999" stroke="#1a1a1a" stroke-width="0.72" opacity="0.3"/>
|
||||
<line x1="7.2" y1="18.0" x2="32.4" y2="18.0" stroke="#1a1a1a" stroke-width="0.72" opacity="0.3"/>
|
||||
<line x1="7.2" y1="25.2" x2="32.4" y2="25.2" stroke="#1a1a1a" stroke-width="0.72" opacity="0.3"/>
|
||||
<line x1="7.2" y1="32.4" x2="25.2" y2="32.4" stroke="#1a1a1a" stroke-width="0.72" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="96" height="96" rx="14.399999999999999" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Book icon -->
|
||||
<g transform="translate(24.0, 19.200000000000003)">
|
||||
<!-- Book cover -->
|
||||
<rect x="0" y="0" width="48.0" height="57.599999999999994" rx="1.92" fill="white" opacity="0.95"/>
|
||||
|
||||
<!-- Book spine -->
|
||||
<rect x="4.800000000000001" y="0" width="1.92" height="57.599999999999994" fill="#1a1a1a" opacity="0.2"/>
|
||||
|
||||
<!-- Book pages lines -->
|
||||
<line x1="9.600000000000001" y1="14.399999999999999" x2="43.2" y2="14.399999999999999" stroke="#1a1a1a" stroke-width="0.96" opacity="0.3"/>
|
||||
<line x1="9.600000000000001" y1="24.0" x2="43.2" y2="24.0" stroke="#1a1a1a" stroke-width="0.96" opacity="0.3"/>
|
||||
<line x1="9.600000000000001" y1="33.599999999999994" x2="43.2" y2="33.599999999999994" stroke="#1a1a1a" stroke-width="0.96" opacity="0.3"/>
|
||||
<line x1="9.600000000000001" y1="43.2" x2="33.599999999999994" y2="43.2" stroke="#1a1a1a" stroke-width="0.96" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="96" height="96" rx="14.399999999999999" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Search icon -->
|
||||
<g transform="translate(19.200000000000003, 19.200000000000003)">
|
||||
<circle cx="24.0" cy="24.0" r="17.28" fill="none" stroke="white" stroke-width="4.800000000000001" opacity="0.95"/>
|
||||
<line x1="36.480000000000004" y1="36.480000000000004" x2="48.0" y2="48.0" stroke="white" stroke-width="4.800000000000001" stroke-linecap="round" opacity="0.95"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 865 B |
@@ -0,0 +1,457 @@
|
||||
/* ObsiGate — AI Editor Toolbar
|
||||
Provides AI-powered text editing features:
|
||||
- Inline completion (Ctrl+J)
|
||||
- Edit (improve, fix spelling, shorter, longer, simplify)
|
||||
- Tone (professional, casual)
|
||||
- Translate (multiple languages)
|
||||
- Generate (explain, summarize, continue)
|
||||
- AI Custom Rewrite
|
||||
- AI Toolbox (to list, to table, frontmatter, to canvas)
|
||||
*/
|
||||
import { api } from './auth.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// ── API call helper ──
|
||||
async function aiAction(endpoint, text, extra = {}) {
|
||||
const body = { text, ...extra };
|
||||
const data = await api(`/api/ai/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// ── Get selected text from CodeMirror ──
|
||||
function getSelection(editorView) {
|
||||
if (!editorView) return '';
|
||||
const { from, to } = editorView.state.selection.main;
|
||||
if (from === to) {
|
||||
// No selection — get entire document
|
||||
return editorView.state.doc.toString();
|
||||
}
|
||||
return editorView.state.doc.sliceString(from, to);
|
||||
}
|
||||
|
||||
// ── Replace or insert text ──
|
||||
function replaceSelection(editorView, newText, mode = 'replace') {
|
||||
if (!editorView) return;
|
||||
const { from, to } = editorView.state.selection.main;
|
||||
if (mode === 'append') {
|
||||
// Insert at end of selection
|
||||
editorView.dispatch({
|
||||
changes: { from: to, insert: '\n' + newText },
|
||||
selection: { anchor: to + 1 + newText.length },
|
||||
});
|
||||
} else if (mode === 'before') {
|
||||
editorView.dispatch({
|
||||
changes: { from: from, insert: newText + '\n' },
|
||||
});
|
||||
} else {
|
||||
// Replace selection
|
||||
editorView.dispatch({
|
||||
changes: { from, to, insert: newText },
|
||||
selection: { anchor: from + newText.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Show toast notification ──
|
||||
function showToast(msg, type = 'info') {
|
||||
// Use global showToast if available, otherwise console
|
||||
if (typeof window._obsigateShowToast === 'function') {
|
||||
window._obsigateShowToast(msg, type);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dropdown menu builder ──
|
||||
function createMenu(items, parentEl) {
|
||||
// Remove any existing menu
|
||||
const existing = document.querySelector('.ai-dropdown-menu');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ai-dropdown-menu';
|
||||
|
||||
// Position relative to parent, but attached to body to avoid
|
||||
// being clipped by overflow:hidden on the editor container.
|
||||
const rect = parentEl.getBoundingClientRect();
|
||||
const menuTop = rect.bottom + 4;
|
||||
const menuLeft = rect.left;
|
||||
|
||||
Object.assign(menu.style, {
|
||||
position: 'fixed',
|
||||
top: menuTop + 'px',
|
||||
left: menuLeft + 'px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 0',
|
||||
minWidth: '200px',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: '10000',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
});
|
||||
|
||||
// If menu overflows bottom of viewport, flip above the button
|
||||
requestAnimationFrame(() => {
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
if (menuRect.bottom > window.innerHeight - 8) {
|
||||
menu.style.top = Math.max(8, rect.top - menuRect.height - 4) + 'px';
|
||||
}
|
||||
// If menu overflows right edge, align right
|
||||
if (menuRect.right > window.innerWidth - 8) {
|
||||
menu.style.left = Math.max(8, window.innerWidth - menuRect.width - 8) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
items.forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ai-menu-item';
|
||||
Object.assign(el.style, {
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
background: 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
});
|
||||
el.innerHTML = item.label;
|
||||
if (item.hint) {
|
||||
const hint = document.createElement('span');
|
||||
hint.style.cssText = 'margin-left:auto;font-size:0.65rem;color:var(--text-muted)';
|
||||
hint.textContent = item.hint;
|
||||
el.appendChild(hint);
|
||||
}
|
||||
if (item.children) {
|
||||
const arrow = document.createElement('span');
|
||||
arrow.style.cssText = 'margin-left:auto;font-size:0.7rem;color:var(--text-muted)';
|
||||
arrow.textContent = '▶';
|
||||
el.appendChild(arrow);
|
||||
}
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.style.background = 'rgba(255,255,255,0.06)';
|
||||
// Show submenu
|
||||
if (item.children) {
|
||||
const sub = el.querySelector('.ai-submenu');
|
||||
if (!sub) {
|
||||
const subMenu = createSubMenu(item.children, el);
|
||||
el.appendChild(subMenu);
|
||||
}
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseleave', (e) => {
|
||||
el.style.background = 'transparent';
|
||||
});
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.action) item.action();
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(el);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Close on outside click
|
||||
const closeHandler = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
|
||||
function createSubMenu(items, parentEl) {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ai-submenu';
|
||||
Object.assign(menu.style, {
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: '0',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 0',
|
||||
minWidth: '180px',
|
||||
zIndex: '101',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
});
|
||||
|
||||
items.forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ai-menu-item';
|
||||
Object.assign(el.style, {
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
el.innerHTML = item.label;
|
||||
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; });
|
||||
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.action) item.action();
|
||||
// Close all menus
|
||||
document.querySelectorAll('.ai-dropdown-menu, .ai-submenu').forEach(m => m.remove());
|
||||
});
|
||||
menu.appendChild(el);
|
||||
});
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
// ── Main AI Toolbar ──
|
||||
export async function createAIToolbar(container, getEditorView) {
|
||||
// Check if AI is configured
|
||||
let aiConfigured = false;
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
aiConfigured = status.configured;
|
||||
} catch { aiConfigured = false; }
|
||||
|
||||
if (!aiConfigured) {
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border-color)';
|
||||
hint.textContent = t('ai.not_configured');
|
||||
container.appendChild(hint);
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ai-toolbar';
|
||||
Object.assign(toolbar.style, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
background: 'var(--bg-secondary)',
|
||||
flexWrap: 'wrap',
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
zIndex: '5',
|
||||
});
|
||||
|
||||
function ev() { return getEditorView(); }
|
||||
|
||||
// ── Inline completion button ──
|
||||
const aiBtn = document.createElement('button');
|
||||
aiBtn.className = 'ai-toolbar-btn';
|
||||
aiBtn.innerHTML = '✦ AI';
|
||||
Object.assign(aiBtn.style, {
|
||||
color: '#60a5fa',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.75rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
});
|
||||
aiBtn.title = t('ai.inline_completion_title');
|
||||
aiBtn.addEventListener('click', async () => {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
if (!text.trim()) return;
|
||||
const origHTML = aiBtn.innerHTML;
|
||||
aiBtn.innerHTML = '⏳ AI...';
|
||||
aiBtn.disabled = true;
|
||||
try {
|
||||
const result = await aiAction('inline-complete', text);
|
||||
replaceSelection(v, result, 'append');
|
||||
showToast(t('ai.completion_added'), 'success');
|
||||
} catch (e) {
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||||
} finally {
|
||||
aiBtn.innerHTML = origHTML;
|
||||
aiBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Edit menu ──
|
||||
const editBtn = createDropdownBtn(t('ai.edit'), [
|
||||
{ label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') },
|
||||
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' },
|
||||
{ label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' },
|
||||
{ label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||||
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
|
||||
], t('ai.hint_edit_selection'));
|
||||
|
||||
// ── Tone menu ──
|
||||
const toneBtn = createDropdownBtn('Ton', [
|
||||
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
|
||||
], 'Change le ton du texte');
|
||||
|
||||
// ── Translate menu ──
|
||||
const translateBtn = createDropdownBtn('Traduire', [
|
||||
{ label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) },
|
||||
{ label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) },
|
||||
{ label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) },
|
||||
{ label: '🇩🇪 German', action: () => action('translate', { target_lang: 'German' }) },
|
||||
{ label: '🇫🇷 French', action: () => action('translate', { target_lang: 'French' }) },
|
||||
{ label: '🇪🇸 Spanish', action: () => action('translate', { target_lang: 'Spanish' }) },
|
||||
], t('ai.hint_translate'));
|
||||
|
||||
// ── Generate menu ──
|
||||
const genBtn = createDropdownBtn(t('ai.generate'), [
|
||||
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
|
||||
], t('ai.hint_generate'));
|
||||
|
||||
// ── Custom Rewrite ──
|
||||
const rewriteBtn = document.createElement('button');
|
||||
rewriteBtn.className = 'ai-toolbar-btn';
|
||||
rewriteBtn.innerHTML = t('ai.rewrite');
|
||||
rewriteBtn.title = t('ai.rewrite_title');
|
||||
Object.assign(rewriteBtn.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text-primary)',
|
||||
});
|
||||
rewriteBtn.addEventListener('click', async () => {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
const instruction = prompt(t('ai.rewrite_prompt'), '');
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const result = await aiAction('rewrite', text, { instruction });
|
||||
replaceSelection(v, result);
|
||||
showToast(t('ai.text_rewritten'), 'success');
|
||||
} catch (e) {
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Toolbox menu ──
|
||||
const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [
|
||||
{ label: '📋 Convert to list', action: () => action('to-list') },
|
||||
{ label: '📊 Convert to table', action: () => action('to-table') },
|
||||
{ label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') },
|
||||
{ label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') },
|
||||
], 'Outils de conversion');
|
||||
|
||||
toolbar.appendChild(aiBtn);
|
||||
toolbar.appendChild(createSeparator());
|
||||
toolbar.appendChild(editBtn);
|
||||
toolbar.appendChild(toneBtn);
|
||||
toolbar.appendChild(translateBtn);
|
||||
toolbar.appendChild(createSeparator());
|
||||
toolbar.appendChild(genBtn);
|
||||
toolbar.appendChild(rewriteBtn);
|
||||
toolbar.appendChild(toolboxBtn);
|
||||
|
||||
container.insertBefore(toolbar, container.firstChild);
|
||||
|
||||
// ── Action helper ──
|
||||
async function action(endpoint, extra = {}, mode = 'replace') {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
if (!text.trim()) {
|
||||
showToast(t('ai.select_text'), 'warning');
|
||||
return;
|
||||
}
|
||||
showToast(t('ai.processing'), 'info');
|
||||
|
||||
// Show processing indicator in the editor header
|
||||
_showProcessingIndicator(true);
|
||||
|
||||
// Dispatch AI processing start event
|
||||
document.dispatchEvent(new CustomEvent('ai-processing-start', { detail: { endpoint, mode } }));
|
||||
try {
|
||||
const result = await aiAction(endpoint, text, extra);
|
||||
replaceSelection(v, result, mode);
|
||||
showToast(t('ai.text_processed'), 'success');
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
if (msg.includes('401')) {
|
||||
showToast(t('ai.invalid_api_key'), 'error');
|
||||
} else if (msg.includes('402') || msg.includes('429')) {
|
||||
showToast(t('ai.quota_exceeded'), 'error');
|
||||
} else {
|
||||
showToast('AI: ' + msg, 'error');
|
||||
}
|
||||
} finally {
|
||||
_showProcessingIndicator(false);
|
||||
// Dispatch AI processing end event
|
||||
document.dispatchEvent(new CustomEvent('ai-processing-end'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── In-editor processing indicator ──
|
||||
function _showProcessingIndicator(show) {
|
||||
let el = document.getElementById('ai-processing-indicator');
|
||||
if (show) {
|
||||
if (!el) {
|
||||
el = document.createElement('span');
|
||||
el.id = 'ai-processing-indicator';
|
||||
el.className = 'ai-processing-indicator';
|
||||
el.innerHTML = '<span class="ai-spinner"></span> AI...';
|
||||
el.title = 'Traitement AI en cours';
|
||||
const header = document.querySelector('#editor-modal .editor-header');
|
||||
if (header) header.appendChild(el);
|
||||
}
|
||||
el.style.display = 'inline-flex';
|
||||
} else if (el) {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// ── Keyboard shortcut Ctrl+J for inline completion ──
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'j') {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
e.preventDefault();
|
||||
aiBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function createDropdownBtn(text, items, tooltip = '') {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ai-toolbar-btn';
|
||||
btn.innerHTML = text + ' ▾';
|
||||
btn.title = tooltip || text;
|
||||
Object.assign(btn.style, {
|
||||
position: 'relative',
|
||||
fontSize: '0.7rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text-primary)',
|
||||
});
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
createMenu(items, btn);
|
||||
});
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.08)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
|
||||
return btn;
|
||||
}
|
||||
|
||||
function createSeparator() {
|
||||
const sep = document.createElement('span');
|
||||
sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px';
|
||||
return sep;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* ObsiGate — Module orchestrator (ES modules).
|
||||
Replaces the monolithic frontend/app.js IIFE.
|
||||
All code lives in frontend/js/*.js modules. */
|
||||
|
||||
import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n, t } from './i18n.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
import * as Sidebar from './sidebar.js';
|
||||
import * as Auth from './auth.js';
|
||||
import * as Viewer from './viewer.js';
|
||||
import * as Sync from './sync.js';
|
||||
import { initGraphView } from './graph.js';
|
||||
import * as Legacy from './legacy.js';
|
||||
import { initCommandPalette } from './palette.js';
|
||||
import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
// =========================================================================
|
||||
async function init() {
|
||||
// 1. Init i18n FIRST — loads fallback (FR) + selected locale
|
||||
// Server language check may fail (no auth yet), falls to localStorage
|
||||
// LocalStorage has correct language if user set it previously
|
||||
await initI18n();
|
||||
|
||||
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
UI.initTheme();
|
||||
UI.initHeaderMenu();
|
||||
UI.initCustomDropdowns();
|
||||
|
||||
document.getElementById("theme-toggle").addEventListener("click", UI.toggleTheme);
|
||||
document.getElementById("header-logo").addEventListener("click", Legacy.goHome);
|
||||
const refreshBtn = document.getElementById("header-refresh-btn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", Legacy.goHome);
|
||||
|
||||
Legacy.initSearch();
|
||||
UI.initSidebarToggle();
|
||||
UI.initMobile();
|
||||
Sidebar.initVaultContext();
|
||||
Legacy.initSidebarTabs();
|
||||
Legacy.initHelpModal();
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
Legacy.initRecentTab();
|
||||
|
||||
UI.RightSidebarManager.init();
|
||||
UI.FindInPageManager.init();
|
||||
UI.ContextMenuManager.init();
|
||||
UI.TabManager.init();
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
|
||||
if (authOk) {
|
||||
Legacy.initSyncStatus();
|
||||
|
||||
try {
|
||||
await Promise.all([Legacy.loadVaultSettings(), Sidebar.loadVaults(), Sidebar.loadTags()]);
|
||||
|
||||
initDragDrop();
|
||||
|
||||
Legacy.DashboardRecentWidget.init();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("popup") === "true") {
|
||||
document.body.classList.add("popup-mode");
|
||||
}
|
||||
|
||||
if (window.location.hash && window.location.hash.startsWith("#file=")) {
|
||||
const hashVal = window.location.hash.substring(6);
|
||||
const sepIndex = hashVal.indexOf(":");
|
||||
if (sepIndex > -1) {
|
||||
const vault = decodeURIComponent(hashVal.substring(0, sepIndex));
|
||||
const path = decodeURIComponent(hashVal.substring(sepIndex + 1));
|
||||
Viewer.openFile(vault, path);
|
||||
}
|
||||
} else if (urlParams.get("popup") !== "true") {
|
||||
Legacy.showWelcome();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize ObsiGate:", err);
|
||||
UI.showToast(t("error.initialization"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
initGraphView();
|
||||
initMermaid();
|
||||
setupFocusMode();
|
||||
Utils.safeCreateIcons();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init();
|
||||
Sync.init();
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu } from './ui.js';
|
||||
import { t, getLocale, setLocale } from './i18n.js';
|
||||
import { showWelcome } from './viewer.js';
|
||||
|
||||
// ── Global logout handler for header dropdown onclick ────────────────────
|
||||
// Defined at module top level so it's always available, even if
|
||||
// renderUserMenu() hasn't been called yet.
|
||||
// Uses a getter to lazy-resolve AuthManager (defined later in this module).
|
||||
window.handleLogout = () => {
|
||||
AuthManager.logout();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch JSON from an API endpoint with optional AbortSignal support.
|
||||
* Surfaces errors to the user via toast instead of silently failing.
|
||||
*
|
||||
* @param {string} path - API URL path.
|
||||
* @param {object} [opts] - Fetch options (may include signal).
|
||||
* @returns {Promise<any>} Parsed JSON response.
|
||||
*/
|
||||
async function api(path, opts) {
|
||||
var res;
|
||||
try {
|
||||
// Inject auth header if authenticated
|
||||
const authHeaders = AuthManager.getAuthHeaders();
|
||||
const mergedOpts = opts || {};
|
||||
// Auto-set Content-Type for JSON bodies
|
||||
if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) {
|
||||
mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" };
|
||||
}
|
||||
if (authHeaders) {
|
||||
mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders };
|
||||
}
|
||||
mergedOpts.credentials = "include";
|
||||
res = await fetch(path, mergedOpts);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") throw err; // let callers handle abort
|
||||
showToast(t("auth.network_error"), "error");
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 401 && AuthManager._authEnabled) {
|
||||
// Token expired — try refresh
|
||||
try {
|
||||
await AuthManager.refreshAccessToken();
|
||||
// Retry the request with new token
|
||||
const retryHeaders = AuthManager.getAuthHeaders();
|
||||
const retryOpts = opts || {};
|
||||
retryOpts.headers = { ...retryOpts.headers, ...retryHeaders };
|
||||
retryOpts.credentials = "include";
|
||||
res = await fetch(path, retryOpts);
|
||||
} catch (refreshErr) {
|
||||
AuthManager.clearSession();
|
||||
AuthManager.showLoginScreen();
|
||||
throw new Error("Session expirée");
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
var detail = "";
|
||||
try {
|
||||
var body = await res.json();
|
||||
detail = body.detail || "";
|
||||
} catch (_) {
|
||||
/* no json body */
|
||||
}
|
||||
showToast(detail || "Erreur API : " + res.status, "error");
|
||||
throw new Error(detail || "API error: " + res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthManager — Authentication state & token management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AuthManager = {
|
||||
ACCESS_TOKEN_KEY: "obsigate_access_token",
|
||||
TOKEN_EXPIRY_KEY: "obsigate_token_expiry",
|
||||
USER_KEY: "obsigate_user",
|
||||
_authEnabled: false,
|
||||
|
||||
// ── Token storage (sessionStorage) ─────────────────────────────
|
||||
|
||||
saveToken(tokenData) {
|
||||
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
||||
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token);
|
||||
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString());
|
||||
if (tokenData.user) {
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
||||
}
|
||||
},
|
||||
|
||||
getToken() {
|
||||
return sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
|
||||
},
|
||||
|
||||
getUser() {
|
||||
const raw = sessionStorage.getItem(this.USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
},
|
||||
|
||||
isTokenExpired() {
|
||||
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
|
||||
if (!expiry) return true;
|
||||
// Renew 60s before expiration
|
||||
return Date.now() > parseInt(expiry) - 60000;
|
||||
},
|
||||
|
||||
clearSession() {
|
||||
sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
||||
sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
|
||||
sessionStorage.removeItem(this.USER_KEY);
|
||||
},
|
||||
|
||||
getAuthHeaders() {
|
||||
const token = this.getToken();
|
||||
if (!token || !this._authEnabled) return null;
|
||||
return { Authorization: "Bearer " + token };
|
||||
},
|
||||
|
||||
// ── API calls ──────────────────────────────────────────────────
|
||||
|
||||
async login(username, password, rememberMe) {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Erreur de connexion");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: "Bearer " + token } : {},
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (e) {
|
||||
/* continue even if API fails */
|
||||
}
|
||||
this.clearSession();
|
||||
this.showLoginScreen();
|
||||
},
|
||||
|
||||
async refreshAccessToken() {
|
||||
const response = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.clearSession();
|
||||
throw new Error("Session expirée");
|
||||
}
|
||||
const data = await response.json();
|
||||
const expiry = Date.now() + data.expires_in * 1000;
|
||||
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token);
|
||||
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString());
|
||||
return data.access_token;
|
||||
},
|
||||
|
||||
// ── UI controls ────────────────────────────────────────────────
|
||||
|
||||
showLoginScreen() {
|
||||
const app = document.getElementById("app");
|
||||
const login = document.getElementById("login-screen");
|
||||
if (app) app.classList.add("hidden");
|
||||
if (login) {
|
||||
login.classList.remove("hidden");
|
||||
const usernameInput = document.getElementById("login-username");
|
||||
if (usernameInput) usernameInput.focus();
|
||||
}
|
||||
},
|
||||
|
||||
showApp() {
|
||||
const login = document.getElementById("login-screen");
|
||||
const app = document.getElementById("app");
|
||||
if (login) login.classList.add("hidden");
|
||||
if (app) app.classList.remove("hidden");
|
||||
this.renderUserMenu();
|
||||
},
|
||||
|
||||
renderUserMenu() {
|
||||
const user = this.getUser();
|
||||
const userMenu = document.getElementById("user-menu");
|
||||
if (!userMenu) return;
|
||||
if (!user || !this._authEnabled) {
|
||||
userMenu.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
||||
safeCreateIcons();
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
|
||||
|
||||
const adminRow = document.getElementById("admin-menu-row");
|
||||
if (adminRow) {
|
||||
if (user.role === "admin") {
|
||||
adminRow.classList.remove("hidden");
|
||||
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
|
||||
adminRow.onclick = () => {
|
||||
closeHeaderMenu();
|
||||
AdminPanel.show();
|
||||
};
|
||||
} else {
|
||||
adminRow.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/status");
|
||||
const data = await res.json();
|
||||
this._authEnabled = data.auth_enabled;
|
||||
return data;
|
||||
} catch (e) {
|
||||
this._authEnabled = false;
|
||||
return { auth_enabled: false };
|
||||
}
|
||||
},
|
||||
|
||||
async initAuth() {
|
||||
const status = await this.checkAuthStatus();
|
||||
if (!status.auth_enabled) {
|
||||
// Auth disabled — show app immediately
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auth enabled — check for existing session
|
||||
if (this.getToken() && !this.isTokenExpired()) {
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try silent refresh
|
||||
try {
|
||||
await this.refreshAccessToken();
|
||||
// Fetch user info
|
||||
const token = this.getToken();
|
||||
const res = await fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
/* silent refresh failed */
|
||||
}
|
||||
|
||||
// No valid session — show login
|
||||
this.showLoginScreen();
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login form handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initLoginForm() {
|
||||
const form = document.getElementById("login-form");
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById("login-username").value;
|
||||
const password = document.getElementById("login-password").value;
|
||||
const rememberMe = document.getElementById("remember-me").checked;
|
||||
const errorEl = document.getElementById("login-error");
|
||||
const btn = document.getElementById("login-btn");
|
||||
|
||||
btn.disabled = true;
|
||||
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
||||
btn.querySelector(".btn-text").textContent = "Connexion...";
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.login(username, password, rememberMe);
|
||||
AuthManager.showApp();
|
||||
|
||||
// Re-sync language from server now that we're authenticated
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
if (user.language && user.language !== getLocale()) {
|
||||
await setLocale(user.language);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* non-bloquant */ }
|
||||
|
||||
// Load app data after successful login
|
||||
try {
|
||||
// Dynamic imports to avoid circular dependency with sidebar.js
|
||||
const { loadVaults, loadTags } = await import('./sidebar.js');
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
// Start SSE sync now that auth cookie is set (dynamic import to avoid circular dep)
|
||||
const { IndexUpdateManager } = await import('./sync.js');
|
||||
IndexUpdateManager.connect();
|
||||
// Sync offline file index now that we're authenticated
|
||||
const { syncFileIndexFromServer } = await import('./offline.js');
|
||||
syncFileIndexFromServer();
|
||||
// Show dashboard
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
console.error("Failed to load data after login:", err);
|
||||
}
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
document.getElementById("login-password").value = "";
|
||||
document.getElementById("login-password").focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.querySelector(".btn-spinner").classList.add("hidden");
|
||||
btn.querySelector(".btn-text").textContent = "Se connecter";
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle password visibility
|
||||
const toggleBtn = document.getElementById("toggle-password");
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const input = document.getElementById("login-password");
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin Panel — User management (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AdminPanel = {
|
||||
_modal: null,
|
||||
_allVaults: [],
|
||||
|
||||
show() {
|
||||
this._createModal();
|
||||
this._modal.classList.add("active");
|
||||
this._loadUsers();
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (this._modal) this._modal.classList.remove("active");
|
||||
},
|
||||
|
||||
_createModal() {
|
||||
if (this._modal) return;
|
||||
this._modal = document.createElement("div");
|
||||
this._modal.className = "editor-modal";
|
||||
this._modal.id = "admin-modal";
|
||||
this._modal.innerHTML = `
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">⚙️ ${t('admin.title')}</div>
|
||||
<div class="editor-actions">
|
||||
<button class="editor-btn" id="admin-close" title="Fermer">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body" id="admin-body">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">${t('admin.new_user')}</button>
|
||||
</div>
|
||||
<div id="admin-users-list" class="admin-users-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(this._modal);
|
||||
safeCreateIcons();
|
||||
|
||||
document.getElementById("admin-close").addEventListener("click", () => this.hide());
|
||||
document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null));
|
||||
},
|
||||
|
||||
async _loadUsers() {
|
||||
try {
|
||||
const users = await api("/api/auth/admin/users");
|
||||
// Also load available vaults
|
||||
try {
|
||||
const vaultsData = await api("/api/vaults");
|
||||
this._allVaults = vaultsData.map((v) => v.name);
|
||||
} catch (e) {
|
||||
this._allVaults = [];
|
||||
}
|
||||
this._renderUsers(users);
|
||||
} catch (err) {
|
||||
document.getElementById("admin-users-list").innerHTML = '<p style="color:var(--danger);padding:16px;">Erreur : ' + err.message + "</p>";
|
||||
}
|
||||
},
|
||||
|
||||
_renderUsers(users) {
|
||||
const container = document.getElementById("admin-users-list");
|
||||
if (!users.length) {
|
||||
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table class="admin-table"><thead><tr>' +
|
||||
'<th>' + t('admin.col_user') + '</th><th>' + t('admin.col_role') + '</th><th>' + t('admin.col_vaults') + '</th><th>' + t('admin.col_status') + '</th><th>' + t('admin.col_last_login') + '</th><th>' + t('admin.col_actions') + '</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
users.forEach((u) => {
|
||||
const vaults = u.vaults.includes("*") ? t('common.all') : u.vaults.join(", ") || t('common.none');
|
||||
const status = u.active ? "✅" : "🔴";
|
||||
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US', { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : t('common.never');
|
||||
html +=
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
u.username +
|
||||
"</strong>" +
|
||||
(u.display_name && u.display_name !== u.username ? "<br><small>" + u.display_name + "</small>" : "") +
|
||||
"</td>" +
|
||||
'<td><span class="admin-role-badge admin-role-' +
|
||||
u.role +
|
||||
'">' +
|
||||
u.role +
|
||||
"</span></td>" +
|
||||
'<td><span class="admin-vaults-text">' +
|
||||
vaults +
|
||||
"</span></td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
"<td><small>" +
|
||||
lastLogin +
|
||||
"</small></td>" +
|
||||
'<td class="admin-actions">' +
|
||||
'<button class="admin-action-btn" data-action="edit" data-username="' +
|
||||
u.username +
|
||||
'" title="' + t('common.edit') + '">✏️</button>' +
|
||||
'<button class="admin-action-btn danger" data-action="delete" data-username="' +
|
||||
u.username +
|
||||
'" title="' + t('common.delete') + '">🗑️</button>' +
|
||||
"</td></tr>";
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
container.innerHTML = html;
|
||||
|
||||
// Bind action buttons
|
||||
container.querySelectorAll('[data-action="edit"]').forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const user = users.find((u) => u.username === btn.dataset.username);
|
||||
if (user) this._showUserForm(user);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('[data-action="delete"]').forEach((btn) => {
|
||||
btn.addEventListener("click", () => this._deleteUser(btn.dataset.username));
|
||||
});
|
||||
},
|
||||
|
||||
_showUserForm(user) {
|
||||
const isEdit = !!user;
|
||||
const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur";
|
||||
const vaultCheckboxes = this._allVaults
|
||||
.map((v) => {
|
||||
const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : "";
|
||||
return '<label class="checkbox-label"><input type="checkbox" name="vault" value="' + v + '" ' + checked + "><span>" + v + "</span></label>";
|
||||
})
|
||||
.join("");
|
||||
const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : "";
|
||||
|
||||
// Create form modal overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "admin-form-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="admin-form-card">
|
||||
<h3>${title}</h3>
|
||||
<form id="admin-user-form">
|
||||
${!isEdit ? '<div class="form-group"><label>Nom d\'utilisateur</label><input type="text" name="username" required pattern="[a-zA-Z0-9_\-]{2,32}" placeholder="username"></div>' : ""}
|
||||
<div class="form-group"><label>Nom affiché</label><input type="text" name="display_name" value="${isEdit ? user.display_name || "" : ""}"></div>
|
||||
<div class="form-group"><label>${isEdit ? "Nouveau mot de passe (vide = inchangé)" : "Mot de passe"}</label><input type="password" name="password" ${!isEdit ? 'required minlength="8"' : ""} placeholder="${isEdit ? "Laisser vide pour ne pas changer" : "Min. 8 caractères"}"></div>
|
||||
<div class="form-group"><label>Rôle</label><select name="role"><option value="user" ${isEdit && user.role === "user" ? "selected" : ""}>Utilisateur</option><option value="admin" ${isEdit && user.role === "admin" ? "selected" : ""}>Admin</option></select></div>
|
||||
<div class="form-group">
|
||||
<label>Vaults autorisées</label>
|
||||
<div class="admin-vault-list">${vaultCheckboxes}</div>
|
||||
<label class="checkbox-label" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"><input type="checkbox" id="admin-all-vaults" ${allVaultsChecked}><span><strong>Accès total</strong> (toutes les vaults, y compris futures)</span></label>
|
||||
</div>
|
||||
${isEdit ? '<div class="form-group"><label>Compte actif</label><label class="checkbox-label"><input type="checkbox" name="active" ' + (user.active ? "checked" : "") + "><span>Actif</span></label></div>" : ""}
|
||||
<div class="admin-form-actions">
|
||||
<button type="button" class="config-btn-secondary" id="admin-form-cancel">Annuler</button>
|
||||
<button type="submit" class="btn-login" style="font-size:0.85rem;padding:6px 20px;">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
this._modal.appendChild(overlay);
|
||||
|
||||
document.getElementById("admin-form-cancel").addEventListener("click", () => overlay.remove());
|
||||
|
||||
document.getElementById("admin-user-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const allVaults = document.getElementById("admin-all-vaults").checked;
|
||||
const selectedVaults = allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
const updates = {
|
||||
display_name: form.display_name.value || null,
|
||||
role: form.role.value,
|
||||
vaults: selectedVaults,
|
||||
};
|
||||
if (form.password.value) updates.password = form.password.value;
|
||||
const activeCheckbox = form.querySelector('input[name="active"]');
|
||||
if (activeCheckbox) updates.active = activeCheckbox.checked;
|
||||
await api("/api/auth/admin/users/" + user.username, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
} else {
|
||||
await api("/api/auth/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: form.username.value,
|
||||
password: form.password.value,
|
||||
display_name: form.display_name.value || null,
|
||||
role: form.role.value,
|
||||
vaults: selectedVaults,
|
||||
}),
|
||||
});
|
||||
}
|
||||
overlay.remove();
|
||||
this._loadUsers();
|
||||
showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async _deleteUser(username) {
|
||||
const currentUser = AuthManager.getUser();
|
||||
if (currentUser && currentUser.username === username) {
|
||||
showToast(t("auth.cannot_delete_self"), "error");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return;
|
||||
try {
|
||||
await api("/api/auth/admin/users/" + username, { method: "DELETE" });
|
||||
this._loadUsers();
|
||||
showToast(t("auth.user_deleted"), "success");
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export { api, AuthManager, initLoginForm, AdminPanel };
|
||||
@@ -0,0 +1,350 @@
|
||||
/* ObsiGate — Autocomplete intelligent
|
||||
* Partagé entre éditeur Forge (textarea) et CodeMirror.
|
||||
* Détecte le contexte autour du curseur et suggère des complétions.
|
||||
*
|
||||
* Contexte supporté : mermaid, code fence, frontmatter, markdown, wikilink, table, texte libre.
|
||||
*
|
||||
* API :
|
||||
* suggest(text, cursorPos) → { context, items: [{label, detail, insert, from, to}] }
|
||||
* CompletionSource pour CodeMirror : codeMirrorCompletionSource(context)
|
||||
*/
|
||||
|
||||
// ── Suggestion Data ──────────────────────────────────────────────────
|
||||
var MERMAID_TYPES = [
|
||||
{ label: 'flowchart TD', detail: 'Diagramme de flux (top-down)', insert: 'flowchart TD\n ' },
|
||||
{ label: 'flowchart LR', detail: 'Diagramme de flux (gauche-droite)', insert: 'flowchart LR\n ' },
|
||||
{ label: 'sequenceDiagram', detail: 'Sequence (acteurs + messages)', insert: 'sequenceDiagram\n ' },
|
||||
{ label: 'classDiagram', detail: 'Diagramme de classes UML', insert: 'classDiagram\n ' },
|
||||
{ label: 'stateDiagram-v2', detail: 'Diagramme d\'etats', insert: 'stateDiagram-v2\n ' },
|
||||
{ label: 'erDiagram', detail: 'Diagramme Entite-Relation', insert: 'erDiagram\n ' },
|
||||
{ label: 'journey', detail: 'Parcours utilisateur', insert: 'journey\n ' },
|
||||
{ label: 'gantt', detail: 'Diagramme de Gantt', insert: 'gantt\n ' },
|
||||
{ label: 'pie', detail: 'Diagramme circulaire', insert: 'pie\n ' },
|
||||
{ label: 'gitGraph', detail: 'Historique Git', insert: 'gitGraph\n ' },
|
||||
{ label: 'mindmap', detail: 'Carte mentale', insert: 'mindmap\n ' },
|
||||
{ label: 'timeline', detail: 'Ligne du temps', insert: 'timeline\n ' },
|
||||
{ label: 'quadrantChart', detail: 'Graphique quadrants', insert: 'quadrantChart\n ' },
|
||||
{ label: 'xychart-beta', detail: 'Graphique XY', insert: 'xychart-beta\n ' },
|
||||
{ label: 'block-beta', detail: 'Diagramme de blocs', insert: 'block-beta\n ' }
|
||||
];
|
||||
|
||||
var MERMAID_SYNTAX = {
|
||||
'flowchart': [
|
||||
{ label: 'A-->B', detail: 'Fleche', insert: '-->' },
|
||||
{ label: 'A--text-->B', detail: 'Fleche avec texte', insert: '-- texte -->' },
|
||||
{ label: 'A-.->B', detail: 'Fleche pointillee', insert: '-.->' },
|
||||
{ label: 'A==>B', detail: 'Fleche epaisse', insert: '==>' },
|
||||
{ label: 'subgraph', detail: 'Sous-graphe', insert: 'subgraph titre\n \nend' },
|
||||
{ label: 'style A fill:#xxx', detail: 'Style de noeud', insert: 'style A fill:#333,stroke:#58a6ff' },
|
||||
{ label: 'classDef', detail: 'Classe CSS', insert: 'classDef default fill:#333,stroke:#58a6ff' }
|
||||
],
|
||||
'sequenceDiagram': [
|
||||
{ label: 'participant X', detail: 'Declarer acteur', insert: 'participant Nom' },
|
||||
{ label: 'A->>B: msg', detail: 'Message', insert: '->> : ' },
|
||||
{ label: 'A-->>B: msg', detail: 'Message asynchrone', insert: '-->> : ' },
|
||||
{ label: 'Note over A,B', detail: 'Note', insert: 'Note over A,B: ' },
|
||||
{ label: 'loop text', detail: 'Boucle', insert: 'loop description\n \nend' },
|
||||
{ label: 'alt/else', detail: 'Alternative', insert: 'alt condition\n \nelse\n \nend' },
|
||||
{ label: 'activate A', detail: 'Activer ligne de vie', insert: 'activate A' }
|
||||
],
|
||||
'classDiagram': [
|
||||
{ label: 'class Nom {', detail: 'Declarer classe', insert: 'class Nom {\n \n}' },
|
||||
{ label: '+methode()', detail: 'Methode publique', insert: '+nom() Type' },
|
||||
{ label: '-attribut', detail: 'Attribut prive', insert: '-nom Type' },
|
||||
{ label: 'Heritage', detail: 'Relation heritage', insert: ' <|-- ' },
|
||||
{ label: 'Composition', detail: 'Relation composition', insert: ' *-- ' }
|
||||
],
|
||||
'gantt': [
|
||||
{ label: 'dateFormat', detail: 'Format de date', insert: 'dateFormat YYYY-MM-DD' },
|
||||
{ label: 'section', detail: 'Section Gantt', insert: 'section Nom' },
|
||||
{ label: 'tache : apres', detail: 'Tache avec dependance', insert: ' : after tache1, 3d' }
|
||||
],
|
||||
'pie': [
|
||||
{ label: 'title', detail: 'Titre du pie', insert: 'title Titre du graphique' },
|
||||
{ label: '"Label" : val', detail: 'Valeur', insert: '"Label" : 50' }
|
||||
],
|
||||
'mindmap': [
|
||||
{ label: 'root', detail: 'Racine', insert: 'root((Sujet))' },
|
||||
{ label: 'branche', detail: 'Sous-sujet', insert: '\n Sous-sujet' },
|
||||
{ label: 'leaf', detail: 'Feuille', insert: '\n Detail' }
|
||||
]
|
||||
};
|
||||
|
||||
var CODE_LANGUAGES = [
|
||||
{ label: 'python', detail: 'Python', insert: 'python' },
|
||||
{ label: 'javascript', detail: 'JavaScript', insert: 'javascript' },
|
||||
{ label: 'typescript', detail: 'TypeScript', insert: 'typescript' },
|
||||
{ label: 'bash', detail: 'Bash / Shell', insert: 'bash' },
|
||||
{ label: 'sql', detail: 'SQL', insert: 'sql' },
|
||||
{ label: 'json', detail: 'JSON', insert: 'json' },
|
||||
{ label: 'yaml', detail: 'YAML', insert: 'yaml' },
|
||||
{ label: 'html', detail: 'HTML', insert: 'html' },
|
||||
{ label: 'css', detail: 'CSS', insert: 'css' },
|
||||
{ label: 'dockerfile', detail: 'Dockerfile', insert: 'dockerfile' },
|
||||
{ label: 'mermaid', detail: 'Mermaid (diagrammes)', insert: 'mermaid' },
|
||||
{ label: 'markdown', detail: 'Markdown', insert: 'markdown' },
|
||||
{ label: 'rust', detail: 'Rust', insert: 'rust' },
|
||||
{ label: 'go', detail: 'Go', insert: 'go' },
|
||||
{ label: 'java', detail: 'Java', insert: 'java' }
|
||||
];
|
||||
|
||||
var CODE_SNIPPETS = {
|
||||
python: [
|
||||
{ label: 'def', detail: 'Fonction', insert: 'def nom(params):\n ' },
|
||||
{ label: 'class', detail: 'Classe', insert: 'class Nom:\n def __init__(self):\n ' },
|
||||
{ label: 'if/else', detail: 'Condition', insert: 'if condition:\n \nelse:\n ' },
|
||||
{ label: 'for', detail: 'Boucle for', insert: 'for item in iterable:\n ' },
|
||||
{ label: 'try/except', detail: 'Try-except', insert: 'try:\n \nexcept Exception as e:\n ' },
|
||||
{ label: 'import', detail: 'Import', insert: 'import ' },
|
||||
{ label: 'print', detail: 'Print', insert: 'print()' },
|
||||
{ label: 'lambda', detail: 'Lambda', insert: 'lambda x: ' }
|
||||
],
|
||||
javascript: [
|
||||
{ label: 'function', detail: 'Fonction', insert: 'function nom(params) {\n \n}' },
|
||||
{ label: 'const', detail: 'Constante', insert: 'const nom = ' },
|
||||
{ label: '=>', detail: 'Fleche', insert: '(params) => {\n \n}' },
|
||||
{ label: 'if/else', detail: 'Condition', insert: 'if (cond) {\n \n} else {\n \n}' },
|
||||
{ label: 'for', detail: 'Boucle for', insert: 'for (let i = 0; i < n; i++) {\n \n}' },
|
||||
{ label: 'try/catch', detail: 'Try-catch', insert: 'try {\n \n} catch (e) {\n \n}' },
|
||||
{ label: 'console.log', detail: 'Console log', insert: 'console.log()' },
|
||||
{ label: 'async/await', detail: 'Async function', insert: 'async function nom() {\n await \n}' }
|
||||
],
|
||||
bash: [
|
||||
{ label: '#!/bin/bash', detail: 'Shebang', insert: '#!/bin/bash' },
|
||||
{ label: 'if/fi', detail: 'Condition', insert: 'if [ condition ]; then\n \nfi' },
|
||||
{ label: 'for', detail: 'Boucle for', insert: 'for item in list; do\n \ndone' },
|
||||
{ label: 'case/esac', detail: 'Case', insert: 'case var in\n pattern)\n ;;\nesac' },
|
||||
{ label: 'function', detail: 'Fonction', insert: 'nom() {\n \n}' }
|
||||
],
|
||||
sql: [
|
||||
{ label: 'SELECT', detail: 'Select', insert: 'SELECT colonnes\nFROM table' },
|
||||
{ label: 'JOIN', detail: 'Join', insert: 'JOIN table2 ON table1.id = table2.id' },
|
||||
{ label: 'WHERE', detail: 'Where', insert: 'WHERE condition' },
|
||||
{ label: 'GROUP BY', detail: 'Group by', insert: 'GROUP BY colonne' },
|
||||
{ label: 'ORDER BY', detail: 'Order by', insert: 'ORDER BY colonne DESC' },
|
||||
{ label: 'INSERT INTO', detail: 'Insert', insert: 'INSERT INTO table (cols) VALUES (vals)' },
|
||||
{ label: 'CREATE TABLE', detail: 'Create table', insert: 'CREATE TABLE nom (\n id INTEGER PRIMARY KEY,\n \n)' }
|
||||
]
|
||||
};
|
||||
|
||||
var FRONTMATTER_FIELDS = [
|
||||
{ label: 'title:', detail: 'Titre du document', insert: 'title: ' },
|
||||
{ label: 'tags:', detail: 'Tags (liste)', insert: 'tags:\n - ' },
|
||||
{ label: 'date:', detail: 'Date', insert: 'date: ' + new Date().toISOString().slice(0,10) },
|
||||
{ label: 'status:', detail: 'Statut', insert: 'status: en cours' },
|
||||
{ label: 'category:', detail: 'Categorie', insert: 'category: ' },
|
||||
{ label: 'publish:', detail: 'Publier (true/false)', insert: 'publish: true' },
|
||||
{ label: 'favoris:', detail: 'Favoris (true/false)', insert: 'favoris: true' },
|
||||
{ label: 'author:', detail: 'Auteur', insert: 'author: ' },
|
||||
{ label: 'description:', detail: 'Description', insert: 'description: ' }
|
||||
];
|
||||
|
||||
var MARKDOWN_FORMAT = [
|
||||
{ label: '# H1', detail: 'Titre niveau 1', insert: '# ' },
|
||||
{ label: '## H2', detail: 'Titre niveau 2', insert: '## ' },
|
||||
{ label: '### H3', detail: 'Titre niveau 3', insert: '### ' },
|
||||
{ label: '**gras**', detail: 'Texte en gras', insert: '****' },
|
||||
{ label: '*italique*', detail: 'Texte en italique', insert: '**' },
|
||||
{ label: '`code`', detail: 'Code inline', insert: '``' },
|
||||
{ label: '[lien](url)', detail: 'Lien', insert: '[]()' },
|
||||
{ label: '', detail: 'Image', insert: '![]()' },
|
||||
{ label: '- liste', detail: 'Liste a puces', insert: '- ' },
|
||||
{ label: '1. liste', detail: 'Liste numerotee', insert: '1. ' },
|
||||
{ label: '> citation', detail: 'Citation', insert: '> ' },
|
||||
{ label: '---', detail: 'Separateur horizontal', insert: '\n---\n' },
|
||||
{ label: '> [!note]', detail: 'Callout Note', insert: '> [!note] Titre\n> Contenu' },
|
||||
{ label: '> [!warning]', detail: 'Callout Warning', insert: '> [!warning] Titre\n> Contenu' },
|
||||
{ label: '> [!tip]', detail: 'Callout Tip', insert: '> [!tip] Titre\n> Contenu' },
|
||||
{ label: '> [!danger]', detail: 'Callout Danger', insert: '> [!danger] Titre\n> Contenu' }
|
||||
];
|
||||
|
||||
// ── Context Detection ─────────────────────────────────────────────────
|
||||
function detectContext(text, cursorPos) {
|
||||
var before = text.slice(0, cursorPos);
|
||||
var after = text.slice(cursorPos);
|
||||
var lines = before.split('\n');
|
||||
var currentLine = lines[lines.length - 1] || '';
|
||||
var lineNum = lines.length;
|
||||
|
||||
// Check for code fence context (look backwards for unclosed ```)
|
||||
var inFence = null;
|
||||
var fenceLang = '';
|
||||
var fenceStart = 0;
|
||||
for (var i = lines.length - 1; i >= 0; i--) {
|
||||
var l = lines[i].trim();
|
||||
var m = l.match(/^```(\w*)/);
|
||||
if (m) {
|
||||
if (inFence === null) {
|
||||
inFence = true;
|
||||
fenceLang = m[1] || '';
|
||||
fenceStart = i + 1;
|
||||
} else {
|
||||
inFence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
if (fenceLang === 'mermaid') return { type: 'mermaid', lang: '', line: currentLine };
|
||||
return { type: 'code', lang: fenceLang, line: currentLine };
|
||||
}
|
||||
|
||||
// Check for frontmatter context (between first and second ---)
|
||||
if (lineNum >= 2) {
|
||||
var firstSep = -1, secondSep = -1;
|
||||
for (var j = 0; j < lines.length; j++) {
|
||||
if (lines[j].trim() === '---') {
|
||||
if (firstSep === -1) firstSep = j;
|
||||
else if (secondSep === -1) { secondSep = j; break; }
|
||||
}
|
||||
}
|
||||
if (firstSep === 0 && secondSep === -1) {
|
||||
return { type: 'frontmatter', line: currentLine };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for wikilink
|
||||
if (/\[\[$/.test(currentLine) || /\[\[[^\]]*$/.test(currentLine)) {
|
||||
var partial = (currentLine.match(/\[\[([^\]]*)$/) || ['',''])[1];
|
||||
return { type: 'wikilink', partial: partial, line: currentLine };
|
||||
}
|
||||
|
||||
// Check for markdown link
|
||||
if (/\[.*\]\($/.test(currentLine)) {
|
||||
return { type: 'link', line: currentLine };
|
||||
}
|
||||
|
||||
// Check for triple backtick (about to open fence)
|
||||
if (/```$/.test(before) || /^```\s*$/.test(currentLine)) {
|
||||
return { type: 'fence_open', line: currentLine };
|
||||
}
|
||||
|
||||
// Check if inside a markdown table
|
||||
if (/^\|.*\|$/.test(currentLine.trim())) {
|
||||
return { type: 'table', line: currentLine };
|
||||
}
|
||||
|
||||
// Plain text / markdown
|
||||
return { type: 'markdown', line: currentLine };
|
||||
}
|
||||
|
||||
// ── Suggestion Engine ─────────────────────────────────────────────────
|
||||
function suggest(text, cursorPos) {
|
||||
var ctx = detectContext(text, cursorPos);
|
||||
var items = [];
|
||||
var prefix = '';
|
||||
|
||||
var currentLine = ctx.line;
|
||||
|
||||
switch (ctx.type) {
|
||||
case 'mermaid':
|
||||
// Try to match mermaid type already declared
|
||||
var mermaidMatch = text.match(/```mermaid\s*\n(\w+)/);
|
||||
if (mermaidMatch) {
|
||||
var mmType = mermaidMatch[1];
|
||||
items = MERMAID_SYNTAX[mmType] || [];
|
||||
}
|
||||
if (items.length === 0) {
|
||||
items = MERMAID_TYPES;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
items = CODE_SNIPPETS[ctx.lang] || [];
|
||||
break;
|
||||
|
||||
case 'fence_open':
|
||||
items = CODE_LANGUAGES;
|
||||
break;
|
||||
|
||||
case 'frontmatter':
|
||||
prefix = currentLine.trim();
|
||||
items = FRONTMATTER_FIELDS.filter(function(f) {
|
||||
return f.label.startsWith(prefix) || prefix === '';
|
||||
}).map(function(f) {
|
||||
return { label: f.label, detail: f.detail, insert: prefix ? f.insert : f.insert };
|
||||
});
|
||||
return {
|
||||
context: ctx.type,
|
||||
items: items.length > 0 ? items : FRONTMATTER_FIELDS.map(function(f) {
|
||||
return { label: f.label, detail: f.detail, insert: f.insert };
|
||||
})
|
||||
};
|
||||
|
||||
case 'wikilink':
|
||||
return {
|
||||
context: 'wikilink',
|
||||
items: [],
|
||||
partial: ctx.partial,
|
||||
async: true // requires file index lookup
|
||||
};
|
||||
|
||||
case 'markdown':
|
||||
default:
|
||||
// At start of line: markdown formatting
|
||||
if (currentLine.trim().length < 3) {
|
||||
items = MARKDOWN_FORMAT;
|
||||
} else {
|
||||
items = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var filtered = items.filter(function(item) {
|
||||
return !prefix || item.label.toLowerCase().indexOf(prefix.toLowerCase()) !== -1;
|
||||
});
|
||||
|
||||
return {
|
||||
context: ctx.type,
|
||||
items: filtered.length > 0 ? filtered : items
|
||||
};
|
||||
}
|
||||
|
||||
// ── Wikilink async completion (fetches file list from server) ─────────
|
||||
var _fileCache = null;
|
||||
var _fileCacheTime = 0;
|
||||
|
||||
function searchWikilinks(partial, callback) {
|
||||
var now = Date.now();
|
||||
// Use cached file list if fresh (< 30s)
|
||||
if (_fileCache && (now - _fileCacheTime) < 30000) {
|
||||
callback(filterFiles(partial));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fetch('/api/search/advanced?q=**&vault=all&limit=500')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
_fileCache = (data.results || []).map(function(r) {
|
||||
return {
|
||||
path: r.path,
|
||||
title: r.title || r.path.replace(/\.md$/i, ''),
|
||||
vault: r.vault,
|
||||
name: r.path.split('/').pop().replace(/\.md$/i, '')
|
||||
};
|
||||
});
|
||||
_fileCacheTime = now;
|
||||
callback(filterFiles(partial));
|
||||
})
|
||||
.catch(function() { callback([]); });
|
||||
} catch(e) { callback([]); }
|
||||
}
|
||||
|
||||
function filterFiles(partial) {
|
||||
if (!_fileCache) return [];
|
||||
var q = (partial || '').toLowerCase();
|
||||
return _fileCache.filter(function(f) {
|
||||
return f.name.toLowerCase().indexOf(q) !== -1 || f.path.toLowerCase().indexOf(q) !== -1;
|
||||
}).slice(0, 20).map(function(f) {
|
||||
return {
|
||||
label: f.name,
|
||||
detail: f.path,
|
||||
insert: f.path + ']]',
|
||||
vault: f.vault
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT };
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Backup Manager module for ObsiGate.
|
||||
*
|
||||
* Full-page view to browse, preview, restore, and delete backups across vaults.
|
||||
* Accessible from the backup/diff viewer footer.
|
||||
*/
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
var state = { backups: [], filter: '', preview: null };
|
||||
|
||||
export function openBackupManager() {
|
||||
var area = document.getElementById('content-area');
|
||||
if (!area) return;
|
||||
area.innerHTML = '';
|
||||
|
||||
var container = document.createElement('div');
|
||||
container.className = 'backup-manager';
|
||||
container.id = 'backup-manager';
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="backup-manager-header">' +
|
||||
'<h2><i data-lucide="archive" style="width:20px;height:20px"></i> Gestion des backups</h2>' +
|
||||
'<div class="backup-manager-actions">' +
|
||||
'<input type="text" id="bm-filter" class="backup-manager-filter" placeholder="Filtrer par fichier...">' +
|
||||
'<button class="backup-manager-btn" id="bm-refresh" title="Rafraichir"><i data-lucide="refresh-cw" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="backup-manager-stats" id="bm-stats"></div>' +
|
||||
'<div class="backup-manager-body">' +
|
||||
'<div class="backup-manager-list" id="bm-list"></div>' +
|
||||
'<div class="backup-manager-preview" id="bm-preview" style="display:none">' +
|
||||
'<div class="bm-preview-header">' +
|
||||
'<h3 id="bm-preview-title">Apercu</h3>' +
|
||||
'<button class="backup-manager-btn" id="bm-preview-close" title="Fermer"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'<pre class="bm-preview-content" id="bm-preview-content"></pre>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
area.appendChild(container);
|
||||
safeCreateIcons();
|
||||
|
||||
document.getElementById('bm-filter').addEventListener('input', function() {
|
||||
state.filter = this.value.toLowerCase();
|
||||
render();
|
||||
});
|
||||
document.getElementById('bm-refresh').addEventListener('click', load);
|
||||
document.getElementById('bm-preview-close').addEventListener('click', function() {
|
||||
document.getElementById('bm-preview').style.display = 'none';
|
||||
});
|
||||
|
||||
state.preview = null;
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var list = document.getElementById('bm-list');
|
||||
list.innerHTML = '<div class="tree-loading" style="padding:2rem;text-align:center"><div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div></div>';
|
||||
|
||||
try {
|
||||
var data = await api('/api/backups');
|
||||
state.backups = data.backups || [];
|
||||
updateStats(data);
|
||||
render();
|
||||
} catch (err) {
|
||||
list.innerHTML = '<div class="backup-manager-error">Erreur: ' + escapeHtml(err.message || 'Inconnue') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
var stats = document.getElementById('bm-stats');
|
||||
if (!stats) return;
|
||||
var totalSize = formatSize(data.total_size_bytes || 0);
|
||||
stats.innerHTML =
|
||||
'<span class="bm-stat"><strong>' + data.total + '</strong> backups</span>' +
|
||||
'<span class="bm-stat"><strong>' + totalSize + '</strong> total</span>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var list = document.getElementById('bm-list');
|
||||
if (!list) return;
|
||||
|
||||
var filtered = state.backups;
|
||||
if (state.filter) {
|
||||
filtered = state.backups.filter(function(b) {
|
||||
return b.file.toLowerCase().indexOf(state.filter) !== -1 || b.vault.toLowerCase().indexOf(state.filter) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = '<div class="backup-manager-empty">Aucun backup trouve.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var grouped = {};
|
||||
filtered.forEach(function(b) {
|
||||
if (!grouped[b.vault]) grouped[b.vault] = [];
|
||||
grouped[b.vault].push(b);
|
||||
});
|
||||
|
||||
var html = '';
|
||||
var vaultNames = Object.keys(grouped).sort();
|
||||
vaultNames.forEach(function(vault) {
|
||||
var vaultBackups = grouped[vault];
|
||||
var vaultSize = vaultBackups.reduce(function(s, b) { return s + b.size; }, 0);
|
||||
|
||||
html += '<div class="bm-vault-group">';
|
||||
html += '<div class="bm-vault-header">';
|
||||
html += '<h3 class="bm-vault-name"><i data-lucide="database" style="width:14px;height:14px"></i> ' + escapeHtml(vault) + '</h3>';
|
||||
html += '<span class="bm-vault-meta">' + vaultBackups.length + ' backups · ' + formatSize(vaultSize) + '</span>';
|
||||
html += '<button class="bm-vault-purge" data-vault="' + escapeHtml(vault) + '" title="Supprimer tous les backups de ce vault"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Vider</button>';
|
||||
html += '</div>';
|
||||
|
||||
var byFile = {};
|
||||
vaultBackups.forEach(function(b) {
|
||||
if (!byFile[b.file]) byFile[b.file] = [];
|
||||
byFile[b.file].push(b);
|
||||
});
|
||||
|
||||
var fileNames = Object.keys(byFile).sort();
|
||||
fileNames.forEach(function(file) {
|
||||
var backups = byFile[file];
|
||||
backups.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
var totalFileSize = backups.reduce(function(s, b) { return s + b.size; }, 0);
|
||||
|
||||
html += '<div class="bm-file-group">';
|
||||
html += '<div class="bm-file-header">';
|
||||
html += '<span class="bm-file-name"><i data-lucide="file-text" style="width:14px;height:14px"></i> ' + escapeHtml(file) + '</span>';
|
||||
html += '<span class="bm-file-meta">' + backups.length + ' versions · ' + formatSize(totalFileSize) + '</span>';
|
||||
html += '<button class="bm-file-purge" data-vault="' + escapeHtml(vault) + '" data-file="' + escapeHtml(file) + '" title="Supprimer tous les backups de ce fichier"><i data-lucide="trash-2" style="width:12px;height:12px"></i></button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="bm-backup-list">';
|
||||
backups.forEach(function(b) {
|
||||
var d = new Date(b.timestamp * 1000);
|
||||
var ds = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
html += '<div class="bm-backup-item' + (state.preview === b.full_path ? ' active' : '') + '" data-path="' + escapeHtml(b.full_path) + '" data-vault="' + escapeHtml(b.vault) + '" data-file="' + escapeHtml(b.file) + '">';
|
||||
html += '<span class="bm-backup-date">' + ds + '</span>';
|
||||
html += '<span class="bm-backup-size">' + formatSize(b.size) + '</span>';
|
||||
html += '<div class="bm-backup-actions">';
|
||||
html += '<button class="bm-backup-preview" title="Apercu"><i data-lucide="eye" style="width:12px;height:12px"></i></button>';
|
||||
html += '<button class="bm-backup-restore" title="Restaurer cette version"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>';
|
||||
html += '<button class="bm-backup-delete" data-path="' + escapeHtml(b.full_path) + '" title="Supprimer"><i data-lucide="x" style="width:12px;height:12px"></i></button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
safeCreateIcons();
|
||||
|
||||
// Bind vault purge
|
||||
list.querySelectorAll('.bm-vault-purge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer TOUS les backups du vault "' + btn.dataset.vault + '" ?')) {
|
||||
purgeVault(btn.dataset.vault);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind file purge
|
||||
list.querySelectorAll('.bm-file-purge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer TOUS les backups de "' + btn.dataset.file + '" ?')) {
|
||||
purgeFile(btn.dataset.vault, btn.dataset.file);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind backup item clicks (preview) and action buttons
|
||||
list.querySelectorAll('.bm-backup-item').forEach(function(item) {
|
||||
item.addEventListener('click', function(e) {
|
||||
// Don't trigger preview if clicking an action button
|
||||
if (e.target.closest('button')) return;
|
||||
state.preview = item.dataset.path;
|
||||
render();
|
||||
previewBackup(item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind action buttons
|
||||
list.querySelectorAll('.bm-backup-preview').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = btn.closest('.bm-backup-item');
|
||||
state.preview = item.dataset.path;
|
||||
render();
|
||||
previewBackup(item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.bm-backup-restore').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = btn.closest('.bm-backup-item');
|
||||
restoreBackup(item.dataset.vault, item.dataset.file);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.bm-backup-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer ce backup ?')) {
|
||||
deleteBackups([btn.dataset.path]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function previewBackup(fpath) {
|
||||
var panel = document.getElementById('bm-preview');
|
||||
var content = document.getElementById('bm-preview-content');
|
||||
var title = document.getElementById('bm-preview-title');
|
||||
panel.style.display = 'block';
|
||||
content.textContent = t("common.loading");
|
||||
|
||||
try {
|
||||
var data = await api('/api/backups/content?path=' + encodeURIComponent(fpath));
|
||||
content.textContent = data.content || '(fichier vide)';
|
||||
title.textContent = 'Apercu - ' + (data.name || '');
|
||||
} catch (err) {
|
||||
content.textContent = 'Erreur: ' + (err.message || 'Inconnue');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(vault, file) {
|
||||
// Find the backup timestamp from the currently selected backup
|
||||
var item = document.querySelector('.bm-backup-item.active');
|
||||
if (!item) { showToast(t('backup.select_to_restore'), 'error'); return; }
|
||||
|
||||
var backupPath = item.dataset.path;
|
||||
// Extract timestamp from the filename
|
||||
var parts = backupPath.replace(/\\/g, '/').split('/').pop().split('.');
|
||||
// The timestamp is the second-to-last component before .bak
|
||||
var ts = null;
|
||||
for (var i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'bak' && i > 0 && /^\d+$/.test(parts[i-1])) {
|
||||
ts = parseInt(parts[i-1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ts) { showToast(t('backup.timestamp_not_found'), 'error'); return; }
|
||||
|
||||
if (!confirm('Restaurer "' + file + '" a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.')) return;
|
||||
|
||||
try {
|
||||
var resp = await api('/api/file/' + encodeURIComponent(vault) + '/restore?path=' + encodeURIComponent(file), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: ts }),
|
||||
});
|
||||
if (resp && resp.success) {
|
||||
showToast(t('backup.file_restored'), 'success');
|
||||
load();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + (err.message || 'Inconnue'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackups(paths) {
|
||||
try {
|
||||
var resp = await api('/api/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: paths }),
|
||||
});
|
||||
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeFile(vault, file) {
|
||||
try {
|
||||
var resp = await api('/api/backups/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault: vault, file: file }),
|
||||
});
|
||||
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeVault(vault) {
|
||||
try {
|
||||
var resp = await api('/api/backups/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault: vault }),
|
||||
});
|
||||
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' o';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Backup & Diff viewer module for ObsiGate.
|
||||
*
|
||||
* Provides a modal UI to browse file backups, compare versions with
|
||||
* a unified diff view, and restore previous versions.
|
||||
*
|
||||
* Usage:
|
||||
* import { openBackupViewer } from './backups.js';
|
||||
* openBackupViewer(vaultName, filePath);
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
vault: null,
|
||||
path: null,
|
||||
backups: [],
|
||||
overlay: null,
|
||||
leftVersion: null,
|
||||
rightVersion: null,
|
||||
diffText: '',
|
||||
diffLeft: '',
|
||||
diffRight: '',
|
||||
diffMode: 'unified',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function openBackupViewer(vault, path) {
|
||||
state.vault = vault;
|
||||
state.path = path;
|
||||
state.backups = [];
|
||||
state.leftVersion = null;
|
||||
state.rightVersion = null;
|
||||
state.diffText = '';
|
||||
state.diffLeft = '';
|
||||
state.diffRight = '';
|
||||
|
||||
const overlay = createOverlay();
|
||||
document.body.appendChild(overlay);
|
||||
state.overlay = overlay;
|
||||
requestAnimationFrame(function() { overlay.classList.add('active'); });
|
||||
|
||||
setBody(renderLoading());
|
||||
|
||||
try {
|
||||
var url = '/api/file/' + encodeURIComponent(vault) + '/backups?path=' + encodeURIComponent(path);
|
||||
var data = await api(url);
|
||||
state.backups = data.backups || [];
|
||||
} catch (err) {
|
||||
var msg = err && err.detail || err && err.message || 'Erreur inconnue';
|
||||
setBody(renderError('Erreur API: ' + msg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.backups.length === 0) {
|
||||
setBody(renderEmpty());
|
||||
return;
|
||||
}
|
||||
|
||||
state.leftVersion = state.backups[0].timestamp;
|
||||
state.rightVersion = null;
|
||||
renderMainView();
|
||||
await loadDiff();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _escHandler = null;
|
||||
|
||||
function createOverlay() {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'obsigate-modal-overlay backup-viewer-overlay';
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'backup-viewer-panel';
|
||||
panel.id = 'backup-viewer-panel';
|
||||
overlay.appendChild(panel);
|
||||
|
||||
// Close button -- static, never replaced by innerHTML
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'editor-btn backup-close-btn';
|
||||
closeBtn.id = 'backup-close';
|
||||
closeBtn.title = 'Fermer';
|
||||
closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;z-index:1002;flex-shrink:0';
|
||||
closeBtn.innerHTML = '<i data-lucide="x" style="width:15px;height:15px"></i>';
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
overlay.appendChild(closeBtn);
|
||||
|
||||
// Escape key
|
||||
_escHandler = function(e) {
|
||||
if (e.key === 'Escape' && state.overlay) close();
|
||||
};
|
||||
document.addEventListener('keydown', _escHandler);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setBody(html) {
|
||||
var panel = document.getElementById('backup-viewer-panel');
|
||||
if (panel) panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!state.overlay) return;
|
||||
if (_escHandler) {
|
||||
document.removeEventListener('keydown', _escHandler);
|
||||
_escHandler = null;
|
||||
}
|
||||
state.overlay.classList.remove('active');
|
||||
setTimeout(function() {
|
||||
if (state.overlay) state.overlay.remove();
|
||||
state.overlay = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Global click handler for close button
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest ? e.target.closest('#backup-close') : null;
|
||||
if (btn && state.overlay) close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderLoading() {
|
||||
return '<div class="backup-header"><h3>Backups</h3></div>' +
|
||||
'<div class="backup-body">' +
|
||||
'<div class="tree-loading" style="padding:2rem;text-align:center">' +
|
||||
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||
'<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des backups...</p>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return '<div class="backup-header"><h3>Backups</h3></div>' +
|
||||
'<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">' +
|
||||
'<p>' + escapeHtml(msg) + '</p></div>';
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return '<div class="backup-header"><h3>Backups - ' + escapeHtml(state.path) + '</h3></div>' +
|
||||
'<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">' +
|
||||
'<p>Aucun backup disponible pour ce fichier.</p>' +
|
||||
'<p style="font-size:0.8rem;margin-top:0.5rem">Les backups sont crees automatiquement avant chaque modification.</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderMainView() {
|
||||
var b = state.backups;
|
||||
var leftOpts = b.map(function(bk) {
|
||||
var sel = bk.timestamp === state.leftVersion ? ' selected' : '';
|
||||
return '<option value="' + bk.timestamp + '"' + sel + '>' + formatTimestamp(bk.timestamp) + ' (' + formatSize(bk.size) + ')</option>';
|
||||
}).join('');
|
||||
|
||||
var rightOpts = ['<option value="current"' + (state.rightVersion === null ? ' selected' : '') + '>Version actuelle</option>']
|
||||
.concat(b.map(function(bk) {
|
||||
var sel = bk.timestamp === state.rightVersion ? ' selected' : '';
|
||||
return '<option value="' + bk.timestamp + '"' + sel + '>' + formatTimestamp(bk.timestamp) + ' (' + formatSize(bk.size) + ')</option>';
|
||||
})).join('');
|
||||
|
||||
var html =
|
||||
'<div class="backup-header">' +
|
||||
'<div class="backup-header-left">' +
|
||||
'<h3>'+t('backups.title')+'</h3>' +
|
||||
'<span class="backup-file-path">' + escapeHtml(state.path) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="backup-header-right">' +
|
||||
'<span class="backup-count">' + b.length + ' backup' + (b.length > 1 ? 's' : '') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="backup-toolbar">' +
|
||||
'<div class="backup-selector-group">' +
|
||||
'<label>Comparer :</label>' +
|
||||
'<select class="backup-select" id="backup-left-select">' + leftOpts + '</select>' +
|
||||
'</div>' +
|
||||
'<span class="backup-vs-label">vs</span>' +
|
||||
'<div class="backup-selector-group">' +
|
||||
'<select class="backup-select" id="backup-right-select">' + rightOpts + '</select>' +
|
||||
'</div>' +
|
||||
'<button class="backup-restore-btn" id="backup-restore-btn" title="Restaurer la version de gauche">' +
|
||||
'<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer' +
|
||||
'</button>' +
|
||||
'<button class="backup-diff-mode-btn" id="backup-diff-mode-btn" title="Vue cote a cote">' +
|
||||
'<i data-lucide="columns-2" class="icon-sm"></i> Cote-a-cote' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="backup-diff-container" id="backup-diff-container">' +
|
||||
'<div class="backup-diff-loading">' +
|
||||
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="backup-footer">' +
|
||||
'<span class="backup-footer-hint">Backups crees lors de la sauvegarde manuelle (Ctrl+S)</span>' +
|
||||
'<button class="backup-footer-manage-btn" id="backup-manage-btn">Gerer tous les backups</button>' +
|
||||
'</div>';
|
||||
|
||||
setBody(html);
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindEvents() {
|
||||
var leftSelect = document.getElementById('backup-left-select');
|
||||
if (leftSelect) {
|
||||
leftSelect.addEventListener('change', async function() {
|
||||
state.leftVersion = parseInt(leftSelect.value);
|
||||
await loadDiff();
|
||||
});
|
||||
}
|
||||
|
||||
var rightSelect = document.getElementById('backup-right-select');
|
||||
if (rightSelect) {
|
||||
rightSelect.addEventListener('change', async function() {
|
||||
var val = rightSelect.value;
|
||||
state.rightVersion = val === 'current' ? null : parseInt(val);
|
||||
await loadDiff();
|
||||
});
|
||||
}
|
||||
|
||||
var restoreBtn = document.getElementById('backup-restore-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.addEventListener('click', async function() {
|
||||
await restoreBackup();
|
||||
});
|
||||
}
|
||||
|
||||
var modeBtn = document.getElementById('backup-diff-mode-btn');
|
||||
var manageBtn = document.getElementById('backup-manage-btn');
|
||||
if (manageBtn) {
|
||||
manageBtn.addEventListener('click', function() {
|
||||
close();
|
||||
import('./backup-manager.js').then(function(m) { m.openBackupManager(); });
|
||||
});
|
||||
}
|
||||
|
||||
if (modeBtn) {
|
||||
modeBtn.addEventListener('click', function() {
|
||||
state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside';
|
||||
var container = document.getElementById('backup-diff-container');
|
||||
if (container) {
|
||||
container.innerHTML = state.diffMode === 'sidebyside'
|
||||
? renderDiffSideBySide(state.diffLeft, state.diffRight)
|
||||
: renderDiff(state.diffText);
|
||||
}
|
||||
var icon = modeBtn.querySelector('i');
|
||||
if (icon) icon.setAttribute('data-lucide', state.diffMode === 'sidebyside' ? 'columns-2' : 'align-justify');
|
||||
var textNodes = [];
|
||||
for (var i = 0; i < modeBtn.childNodes.length; i++) {
|
||||
if (modeBtn.childNodes[i].nodeType === 3) textNodes.push(modeBtn.childNodes[i]);
|
||||
}
|
||||
textNodes.forEach(function(n) { n.remove(); });
|
||||
modeBtn.appendChild(document.createTextNode(state.diffMode === 'sidebyside' ? ' Unifiee' : ' Cote-a-cote'));
|
||||
safeCreateIcons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadDiff() {
|
||||
var container = document.getElementById('backup-diff-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="backup-diff-loading">' +
|
||||
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||
'</div>';
|
||||
|
||||
var url = '/api/file/' + encodeURIComponent(state.vault) + '/diff?path=' + encodeURIComponent(state.path) + '&version=' + state.leftVersion;
|
||||
if (state.rightVersion !== null) {
|
||||
url += '&compare_with=' + state.rightVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
var data = await api(url);
|
||||
state.diffText = data.diff || '';
|
||||
state.diffLeft = data.left_content || '';
|
||||
state.diffRight = data.right_content || '';
|
||||
container.innerHTML = state.diffMode === 'sidebyside'
|
||||
? renderDiffSideBySide(state.diffLeft, state.diffRight)
|
||||
: renderDiff(state.diffText);
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="backup-diff-error">Erreur: ' + escapeHtml((err && err.detail) || (err && err.message) || 'Unknown') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!state.leftVersion) return;
|
||||
|
||||
var confirmed = confirm(
|
||||
'Restaurer "' + state.path + '" a la version du ' + formatTimestamp(state.leftVersion) + ' ?\n\n' +
|
||||
'La version actuelle sera sauvegardee avant la restauration.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
var restoreBtn = document.getElementById('backup-restore-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restauration...';
|
||||
}
|
||||
|
||||
try {
|
||||
var resp = await api(
|
||||
'/api/file/' + encodeURIComponent(state.vault) + '/restore?path=' + encodeURIComponent(state.path),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: state.leftVersion }),
|
||||
}
|
||||
);
|
||||
|
||||
if (resp && resp.success) {
|
||||
showToast(t('backup.file_restored'), 'success');
|
||||
await refreshSidebarTreePreservingState();
|
||||
close();
|
||||
if (typeof TabManager !== 'undefined' && TabManager.reloadCurrent) {
|
||||
TabManager.reloadCurrent();
|
||||
}
|
||||
} else {
|
||||
showToast(t('backup.restore_failed'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + ((err && err.detail) || (err && err.message) || 'Unknown'), 'error');
|
||||
} finally {
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = false;
|
||||
restoreBtn.innerHTML = '<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer';
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderDiff(diffText) {
|
||||
if (!diffText) {
|
||||
return '<div class="backup-diff-empty">Aucune difference - les versions sont identiques.</div>';
|
||||
}
|
||||
|
||||
var html = '<div class="backup-diff-output"><table class="backup-diff-table">';
|
||||
var lines = diffText.split('\n');
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
var cssClass = 'diff-context';
|
||||
var prefix = ' ';
|
||||
if (line.indexOf('@@') === 0) { cssClass = 'diff-hunk'; prefix = ''; }
|
||||
else if (line.indexOf('+') === 0) { cssClass = 'diff-add'; prefix = '+'; }
|
||||
else if (line.indexOf('-') === 0) { cssClass = 'diff-del'; prefix = '-'; }
|
||||
else if (line.indexOf('---') === 0 || line.indexOf('+++') === 0) { cssClass = 'diff-header-line'; prefix = ''; }
|
||||
|
||||
html += '<tr class="' + cssClass + '"><td class="diff-prefix">' + prefix + '</td><td class="diff-content"><pre>' + escapeHtml(line) + '</pre></td></tr>';
|
||||
}
|
||||
|
||||
html += '</table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-by-side diff rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderDiffSideBySide(leftText, rightText) {
|
||||
if (!leftText && !rightText) {
|
||||
return '<div class="backup-diff-empty">Aucune difference - les versions sont identiques.</div>';
|
||||
}
|
||||
|
||||
var leftLines = (leftText || '').split('\n');
|
||||
var rightLines = (rightText || '').split('\n');
|
||||
var sm = new DifflibSeqMatcher(leftLines, rightLines);
|
||||
var ops = sm.getOpcodes();
|
||||
|
||||
var html = '<div class="backup-diff-output backup-diff-sidebyside"><table class="backup-diff-table sidebyside"><colgroup><col style="width:50%"><col style="width:50%"></colgroup><tbody>';
|
||||
|
||||
for (var oi = 0; oi < ops.length; oi++) {
|
||||
var op = ops[oi];
|
||||
var tag = op[0], i1 = op[1], i2 = op[2], j1 = op[3], j2 = op[4];
|
||||
|
||||
if (tag === 'equal') {
|
||||
for (var k = 0; k < (i2 - i1); k++) {
|
||||
html += '<tr class="diff-context"><td class="diff-content sidebyside-left"><pre>' + escapeHtml(leftLines[i1 + k]) + '</pre></td><td class="diff-content sidebyside-right"><pre>' + escapeHtml(rightLines[j1 + k]) + '</pre></td></tr>';
|
||||
}
|
||||
} else if (tag === 'delete') {
|
||||
for (var k2 = i1; k2 < i2; k2++) {
|
||||
html += '<tr class="diff-del"><td class="diff-content sidebyside-left"><pre>' + escapeHtml(leftLines[k2]) + '</pre></td><td class="diff-content sidebyside-right diff-empty-cell"></td></tr>';
|
||||
}
|
||||
} else if (tag === 'insert') {
|
||||
for (var k3 = j1; k3 < j2; k3++) {
|
||||
html += '<tr class="diff-add"><td class="diff-content sidebyside-left diff-empty-cell"></td><td class="diff-content sidebyside-right"><pre>' + escapeHtml(rightLines[k3]) + '</pre></td></tr>';
|
||||
}
|
||||
} else if (tag === 'replace') {
|
||||
var maxLen = Math.max(i2 - i1, j2 - j1);
|
||||
for (var k4 = 0; k4 < maxLen; k4++) {
|
||||
var leftLine = (i1 + k4 < i2) ? escapeHtml(leftLines[i1 + k4]) : '';
|
||||
var rightLine = (j1 + k4 < j2) ? escapeHtml(rightLines[j1 + k4]) : '';
|
||||
var leftClass = (i1 + k4 < i2) ? 'diff-del' : 'diff-empty-cell';
|
||||
var rightClass = (j1 + k4 < j2) ? 'diff-add' : 'diff-empty-cell';
|
||||
html += '<tr><td class="diff-content sidebyside-left ' + leftClass + '"><pre>' + leftLine + '</pre></td><td class="diff-content sidebyside-right ' + rightClass + '"><pre>' + rightLine + '</pre></td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Minimal SequenceMatcher for line-based diff
|
||||
function DifflibSeqMatcher(a, b) {
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
}
|
||||
|
||||
DifflibSeqMatcher.prototype.getOpcodes = function() {
|
||||
var m = this.a.length, n = this.b.length;
|
||||
var dp = [];
|
||||
for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; }
|
||||
for (var i = 1; i <= m; i++) {
|
||||
for (var j = 1; j <= n; j++) {
|
||||
if (this.a[i - 1] === this.b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
var ops = [];
|
||||
var i = m, j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && this.a[i - 1] === this.b[j - 1]) {
|
||||
var ei = i - 1, ej = j - 1;
|
||||
while (ei > 0 && ej > 0 && this.a[ei - 1] === this.b[ej - 1]) { ei--; ej--; }
|
||||
if (ei < i) ops.unshift(['equal', ei, i, ej, j]);
|
||||
i = ei; j = ej;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.unshift(['insert', i, i, j - 1, j]);
|
||||
j--;
|
||||
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
|
||||
ops.unshift(['delete', i - 1, i, j, j]);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
var merged = [];
|
||||
for (var k = 0; k < ops.length; k++) {
|
||||
if (merged.length > 0 && merged[merged.length - 1][0] === 'delete' && ops[k][0] === 'insert' &&
|
||||
merged[merged.length - 1][4] === ops[k][3]) {
|
||||
var del = merged.pop();
|
||||
merged.push(['replace', del[1], del[2], ops[k][3], ops[k][4]]);
|
||||
} else {
|
||||
merged.push(ops[k]);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
var d = new Date(ts * 1000);
|
||||
var pad = function(n) { return String(n).padStart(2, '0'); };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' o';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
|
||||
import { api } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
|
||||
import { icon, openFile } from './viewer.js';
|
||||
import { TabManager, showToast } from './ui.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files
|
||||
// ---------------------------------------------------------------------------
|
||||
let _recentRefreshTimer = null;
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard Recent Files Widget
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Dashboard Stats Widget ──
|
||||
const DashboardStatsWidget = {
|
||||
async load() {
|
||||
const grid = document.getElementById("dashboard-stats-grid");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = `<div class="dashboard-stats-loading">${t('common.loading')}</div>`;
|
||||
try {
|
||||
const data = await api("/api/dashboard");
|
||||
this.render(data);
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="dashboard-recent-empty">${t('dashboard.error_prefix')}${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
render(data) {
|
||||
const grid = document.getElementById("dashboard-stats-grid");
|
||||
if (!grid) return;
|
||||
const fmtSize = (bytes) => bytes < 1024 ? `${bytes}${t('dashboard.size_bytes')}` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)}${t('dashboard.size_kb')}` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)}${t('dashboard.size_mb')}` : `${(bytes/1073741824).toFixed(1)}${t('dashboard.size_gb')}`;
|
||||
const items = [
|
||||
{ icon: "files", label: t('dashboard.stats_files'), value: data.total_files.toLocaleString() },
|
||||
{ icon: "tags", label: t('dashboard.stats_tags'), value: data.total_tags.toLocaleString() },
|
||||
{ icon: "hard-drive", label: t('dashboard.stats_size'), value: fmtSize(data.total_size_bytes) },
|
||||
{ icon: "folder-open", label: t('dashboard.stats_vaults'), value: data.vaults.length.toString() },
|
||||
];
|
||||
grid.innerHTML = items.map(i => `
|
||||
<div class="stat-card">
|
||||
<i data-lucide="${i.icon}" class="stat-icon"></i>
|
||||
<span class="stat-value">${i.value}</span>
|
||||
<span class="stat-label">${i.label}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
safeCreateIcons();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Dashboard Shared Widget ──
|
||||
const DashboardSharedWidget = {
|
||||
async load() {
|
||||
const grid = document.getElementById("dashboard-shared-grid");
|
||||
const empty = document.getElementById("dashboard-shared-empty");
|
||||
if (!grid) return;
|
||||
try {
|
||||
const shares = await api("/api/shares");
|
||||
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
|
||||
if (empty) empty.style.display = "none";
|
||||
grid.innerHTML = shares.map(s => `
|
||||
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
|
||||
<div class="shared-card-header">
|
||||
<i data-lucide="file-text" style="width:14px;height:14px"></i>
|
||||
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span>
|
||||
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
|
||||
</div>
|
||||
<div class="shared-card-meta">
|
||||
<span>${s.access_count || 0} ${t('dashboard.views', {count: s.access_count || 0})}</span>
|
||||
${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""}
|
||||
</div>
|
||||
<div class="shared-card-actions">
|
||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
lucide.createIcons();
|
||||
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const url = b.dataset.url;
|
||||
try { await navigator.clipboard.writeText(url); } catch { const ta = document.createElement("textarea"); ta.value=url; ta.style.position="fixed"; ta.style.left="-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); }
|
||||
showToast(t("config.link_copied"), "success");
|
||||
}));
|
||||
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const card = b.closest(".shared-card");
|
||||
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api(`/api/share/${b.dataset.id}`, { method: "DELETE" });
|
||||
showToast(t("config.share_revoked"), "success");
|
||||
this.load();
|
||||
}));
|
||||
grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
|
||||
TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
} catch (err) { if (empty) empty.style.display = ""; }
|
||||
}
|
||||
};
|
||||
|
||||
// ── Dashboard Conflicts Widget ──
|
||||
const DashboardConflictsWidget = {
|
||||
async load() {
|
||||
const container = document.getElementById("dashboard-conflicts-container");
|
||||
if (!container) return;
|
||||
try {
|
||||
const data = await api("/api/conflicts");
|
||||
if (data.total === 0) { container.innerHTML = ""; return; }
|
||||
this.render(data.conflicts, container);
|
||||
} catch (err) { container.innerHTML = ""; }
|
||||
},
|
||||
render(conflicts, container) {
|
||||
container.innerHTML = `
|
||||
<div class="dashboard-section">
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-title-row">
|
||||
<i data-lucide="alert-triangle" class="dashboard-icon" style="color:var(--accent-orange)"></i>
|
||||
<h2>${t('dashboard.conflicts_title')}</h2>
|
||||
<span class="dashboard-badge" style="background:var(--accent-orange)">${conflicts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-conflicts-grid">
|
||||
${conflicts.map(c => `
|
||||
<div class="conflict-card">
|
||||
<div class="conflict-info">
|
||||
<span class="conflict-vault">${escapeHtml(c.vault)}</span>
|
||||
<span class="conflict-name">${escapeHtml(c.conflict_path.split("/").pop())}</span>
|
||||
<span class="conflict-date">${t('dashboard.conflict_date')}${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
|
||||
</div>
|
||||
<div class="conflict-actions">
|
||||
<button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">${t('dashboard.keep_original')}</button>
|
||||
<button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">${t('dashboard.keep_conflict')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
lucide.createIcons();
|
||||
container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local")));
|
||||
container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict")));
|
||||
},
|
||||
async _resolve(d, action) {
|
||||
try {
|
||||
await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) });
|
||||
showToast(t("dashboard.conflict_resolved"), "success");
|
||||
this.load();
|
||||
} catch (err) { showToast(t('dashboard.error_prefix') + err.message, "error"); }
|
||||
}
|
||||
};
|
||||
|
||||
const DashboardRecentWidget = {
|
||||
_cache: [],
|
||||
_currentFilter: "",
|
||||
|
||||
async load(vaultFilter = "") {
|
||||
const v = vaultFilter || state.selectedContextVault || "all";
|
||||
this._currentFilter = v;
|
||||
this.showLoading();
|
||||
|
||||
let url = "/api/recent?mode=opened";
|
||||
if (v !== "all") url += `&vault=${encodeURIComponent(v)}`;
|
||||
|
||||
try {
|
||||
const data = await api(url);
|
||||
this._cache = data.files || [];
|
||||
this.render();
|
||||
} catch (err) {
|
||||
console.error("Dashboard: Failed to load recent files:", err);
|
||||
this.showError();
|
||||
}
|
||||
},
|
||||
|
||||
async toggleBookmark(vault, path, title, card) {
|
||||
try {
|
||||
const data = await api("/api/bookmarks/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vault, path, title }),
|
||||
});
|
||||
|
||||
// Refresh both widgets to keep sync
|
||||
DashboardBookmarkWidget.load();
|
||||
|
||||
// Update current card icon if it exists
|
||||
if (card) {
|
||||
const btn = card.querySelector(".dashboard-card-bookmark-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", data.bookmarked);
|
||||
const icon = btn.querySelector("i");
|
||||
if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus");
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to refresh the current list to reflect bookmark status across all cards
|
||||
// To avoid flickering, just update the cache and re-render if needed or do a silent refresh
|
||||
this._cache.forEach(f => {
|
||||
if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle bookmark:", err);
|
||||
showToast(t("dashboard.pin_error"), "error");
|
||||
}
|
||||
},
|
||||
|
||||
showLoading() {
|
||||
const grid = document.getElementById("dashboard-recent-grid");
|
||||
const loading = document.getElementById("dashboard-loading");
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
const count = document.getElementById("dashboard-count");
|
||||
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (loading) loading.classList.add("active");
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (count) count.textContent = "";
|
||||
},
|
||||
|
||||
render() {
|
||||
const grid = document.getElementById("dashboard-recent-grid");
|
||||
const loading = document.getElementById("dashboard-loading");
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
const count = document.getElementById("dashboard-count");
|
||||
|
||||
if (loading) loading.classList.remove("active");
|
||||
|
||||
if (!this._cache || this._cache.length === 0) {
|
||||
this.showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (count) count.textContent = `${this._cache.length} ${this._cache.length > 1 ? t('dashboard.count_files_plural') : t('dashboard.count_files')}`;
|
||||
|
||||
if (!grid) return;
|
||||
grid.innerHTML = "";
|
||||
|
||||
this._cache.forEach((f, index) => {
|
||||
const card = this._createCard(f, index);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
_createCard(file, index) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "dashboard-card";
|
||||
card.setAttribute("data-vault", file.vault);
|
||||
card.setAttribute("data-path", file.path);
|
||||
card.style.animationDelay = `${Math.min(index * 50, 400)}ms`;
|
||||
|
||||
// Header with icon and vault badge
|
||||
const header = document.createElement("div");
|
||||
header.className = "dashboard-card-header";
|
||||
|
||||
const iconContainer = document.createElement("div");
|
||||
iconContainer.className = "dashboard-card-icon";
|
||||
const fileIconName = getFileIcon(file.path);
|
||||
try {
|
||||
iconContainer.appendChild(icon(fileIconName, 24));
|
||||
} catch (e) {
|
||||
console.error("Error creating icon:", fileIconName, e);
|
||||
// Fallback to default file icon
|
||||
iconContainer.appendChild(icon("file", 24));
|
||||
}
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "dashboard-vault-badge";
|
||||
badge.textContent = file.vault;
|
||||
|
||||
const bookmarkBtn = document.createElement("button");
|
||||
bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`;
|
||||
bookmarkBtn.title = file.bookmarked ? t('dashboard.bookmark_remove') : t('dashboard.bookmark_add');
|
||||
bookmarkBtn.innerHTML = `<i data-lucide="${file.bookmarked ? "bookmark" : "bookmark-plus"}" style="width:14px;height:14px"></i>`;
|
||||
|
||||
bookmarkBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleBookmark(file.vault, file.path, file.title, card);
|
||||
});
|
||||
|
||||
header.appendChild(iconContainer);
|
||||
header.appendChild(badge);
|
||||
header.appendChild(bookmarkBtn);
|
||||
card.appendChild(header);
|
||||
|
||||
// Title
|
||||
const title = document.createElement("h3");
|
||||
title.className = "dashboard-card-title";
|
||||
title.textContent = file.title || file.path.split("/").pop();
|
||||
title.title = file.title || file.path;
|
||||
card.appendChild(title);
|
||||
|
||||
// Path (compact)
|
||||
const pathParts = file.path.split("/");
|
||||
if (pathParts.length > 1) {
|
||||
const path = document.createElement("div");
|
||||
path.className = "dashboard-card-path";
|
||||
path.textContent = pathParts.slice(0, -1).join(" / ");
|
||||
path.title = file.path;
|
||||
card.appendChild(path);
|
||||
}
|
||||
|
||||
// Footer with time and tags
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "dashboard-card-footer";
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "dashboard-card-time";
|
||||
time.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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> ${file.mtime_human || this._humanizeDelta(file.mtime)}`;
|
||||
|
||||
footer.appendChild(time);
|
||||
|
||||
// Tags
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "dashboard-card-tags";
|
||||
file.tags.slice(0, 3).forEach((tag) => {
|
||||
const tagEl = document.createElement("span");
|
||||
tagEl.className = "tag-pill";
|
||||
tagEl.textContent = tag;
|
||||
tags.appendChild(tagEl);
|
||||
});
|
||||
footer.appendChild(tags);
|
||||
}
|
||||
|
||||
card.appendChild(footer);
|
||||
|
||||
// Click handler
|
||||
card.addEventListener("click", () => {
|
||||
openFile(file.vault, file.path);
|
||||
});
|
||||
|
||||
return card;
|
||||
},
|
||||
|
||||
showEmpty() {
|
||||
const grid = document.getElementById("dashboard-recent-grid");
|
||||
const loading = document.getElementById("dashboard-loading");
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
const count = document.getElementById("dashboard-count");
|
||||
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (loading) loading.classList.remove("active");
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
if (count) count.textContent = t('dashboard.zero_files');
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
showError() {
|
||||
this.showEmpty();
|
||||
const empty = document.getElementById("dashboard-recent-empty");
|
||||
if (empty) {
|
||||
const msg = empty.querySelector("span");
|
||||
if (msg) msg.textContent = t('dashboard.load_error');
|
||||
}
|
||||
},
|
||||
|
||||
_humanizeDelta(mtime) {
|
||||
const delta = Date.now() / 1000 - mtime;
|
||||
if (delta < 60) return t('dashboard.just_now');
|
||||
if (delta < 3600) return t('dashboard.min_ago', { n: Math.floor(delta / 60) });
|
||||
if (delta < 86400) return t('dashboard.hour_ago', { n: Math.floor(delta / 3600) });
|
||||
if (delta < 604800) return t('dashboard.day_ago', { n: Math.floor(delta / 86400) });
|
||||
return new Date(mtime * 1000).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US', { day: "numeric", month: "short", year: "numeric" });
|
||||
},
|
||||
|
||||
populateVaultFilter() {
|
||||
const select = document.getElementById("dashboard-vault-filter");
|
||||
if (!select) return;
|
||||
|
||||
// Keep first option "Tous les vaults"
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
|
||||
if (typeof state.allVaults !== "undefined" && Array.isArray(state.allVaults)) {
|
||||
state.allVaults.forEach((v) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = v.name;
|
||||
opt.textContent = v.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
syncVaultSelectors();
|
||||
},
|
||||
|
||||
init() {
|
||||
const select = document.getElementById("dashboard-vault-filter");
|
||||
if (select) {
|
||||
select.addEventListener("change", async () => {
|
||||
await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" });
|
||||
});
|
||||
}
|
||||
|
||||
this.populateVaultFilter();
|
||||
|
||||
// Quick Help toggle (with localStorage persistence)
|
||||
var qhToggle = document.getElementById('qh-toggle');
|
||||
var qhBody = document.getElementById('qh-body');
|
||||
if (qhToggle && qhBody) {
|
||||
// Restore saved state
|
||||
if (localStorage.getItem('obsigate-qh-collapsed') === '1') {
|
||||
qhBody.classList.add('collapsed');
|
||||
qhToggle.textContent = '+';
|
||||
qhToggle.title = t('dashboard.show');
|
||||
}
|
||||
qhToggle.addEventListener('click', function() {
|
||||
var collapsed = qhBody.classList.toggle('collapsed');
|
||||
qhToggle.textContent = collapsed ? '+' : '\u2212';
|
||||
qhToggle.title = collapsed ? t('dashboard.show') : t('dashboard.hide');
|
||||
localStorage.setItem('obsigate-qh-collapsed', collapsed ? '1' : '0');
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Dashboard Bookmarks Widget ──
|
||||
// (moved from app.js 3810-3870; logically belongs with dashboard widgets)
|
||||
const DashboardBookmarkWidget = {
|
||||
_cache: [],
|
||||
_currentFilter: "",
|
||||
|
||||
async load(vaultFilter = "") {
|
||||
const v = vaultFilter || state.selectedContextVault || "all";
|
||||
this._currentFilter = v;
|
||||
this.showLoading();
|
||||
|
||||
let url = "/api/bookmarks";
|
||||
if (v !== "all") url += `?vault=${encodeURIComponent(v)}`;
|
||||
|
||||
try {
|
||||
const data = await api(url);
|
||||
this._cache = data.files || [];
|
||||
this.render();
|
||||
} catch (err) {
|
||||
console.error("Dashboard: Failed to load bookmarks:", err);
|
||||
this.showEmpty();
|
||||
}
|
||||
},
|
||||
|
||||
showLoading() {
|
||||
const grid = document.getElementById("dashboard-bookmarks-grid");
|
||||
const empty = document.getElementById("dashboard-bookmarks-empty");
|
||||
const section = document.getElementById("dashboard-bookmarks-section");
|
||||
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (empty) empty.classList.add("hidden");
|
||||
},
|
||||
|
||||
render() {
|
||||
const grid = document.getElementById("dashboard-bookmarks-grid");
|
||||
const empty = document.getElementById("dashboard-bookmarks-empty");
|
||||
const section = document.getElementById("dashboard-bookmarks-section");
|
||||
|
||||
if (!this._cache || this._cache.length === 0) {
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.classList.add("hidden");
|
||||
if (!grid) return;
|
||||
grid.innerHTML = "";
|
||||
|
||||
this._cache.forEach((f, idx) => {
|
||||
const card = DashboardRecentWidget._createCard(f, idx);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
showEmpty() {
|
||||
const grid = document.getElementById("dashboard-bookmarks-grid");
|
||||
const empty = document.getElementById("dashboard-bookmarks-empty");
|
||||
if (grid) grid.innerHTML = "";
|
||||
if (empty) empty.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
export { DashboardRecentWidget, DashboardStatsWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget };
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Drag & Drop module for ObsiGate sidebar tree.
|
||||
*
|
||||
* Allows drag-and-drop of tree items (files and directories) onto
|
||||
* target directories or vault roots to reorganize the vault.
|
||||
*
|
||||
* Usage: import { initDragDrop } from './dragdrop.js';
|
||||
* initDragDrop() after the vault tree is ready.
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Currently dragged item: { vault, path, type, element } */
|
||||
let dragState = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS classes applied during drag operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_OVER_CLASS = 'drag-over';
|
||||
const DRAG_SOURCE_CLASS = 'drag-source';
|
||||
const DRAG_VALID_TARGET_CLASS = 'drag-valid-target';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk up the DOM to find the closest ancestor matching the selector.
|
||||
*/
|
||||
function closest(selector, el) {
|
||||
while (el && el !== document) {
|
||||
if (el.matches && el.matches(selector)) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an element is a valid drop target (directory or vault root).
|
||||
* Returns { vault, targetDir } or null.
|
||||
*/
|
||||
function getDropTarget(el) {
|
||||
// Directory tree item
|
||||
const dirItem = closest('.tree-item[data-path]', el);
|
||||
if (dirItem) {
|
||||
// Only directories are valid targets
|
||||
const icon = dirItem.querySelector('[data-lucide]');
|
||||
const isDir = icon && (icon.getAttribute('data-lucide') === 'folder'
|
||||
|| icon.getAttribute('data-lucide') === 'chevron-right'
|
||||
|| icon.getAttribute('data-lucide') === 'chevron-down');
|
||||
// Heuristic: if the tree item has a child container sibling, it's a directory
|
||||
const hasChildren = dirItem.nextElementSibling
|
||||
&& dirItem.nextElementSibling.classList.contains('tree-children');
|
||||
if (isDir || hasChildren) {
|
||||
return {
|
||||
vault: dirItem.dataset.vault,
|
||||
targetDir: dirItem.dataset.path,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vault item (root of a vault)
|
||||
const vaultItem = closest('.tree-item.vault-item[data-vault]', el);
|
||||
if (vaultItem) {
|
||||
return {
|
||||
vault: vaultItem.dataset.vault,
|
||||
targetDir: '', // empty = vault root
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight drop target and its parent chain.
|
||||
*/
|
||||
function highlightDropTarget(el) {
|
||||
const target = getDropTarget(el);
|
||||
if (!target) return;
|
||||
|
||||
if (target.targetDir) {
|
||||
// Highlight the directory
|
||||
const dirEl = document.querySelector(
|
||||
`.tree-item[data-vault="${CSS.escape(target.vault)}"][data-path="${CSS.escape(target.targetDir)}"]`
|
||||
);
|
||||
if (dirEl) dirEl.classList.add(DRAG_OVER_CLASS);
|
||||
} else {
|
||||
// Highlight the vault
|
||||
const vaultEl = document.querySelector(`.tree-item.vault-item[data-vault="${CSS.escape(target.vault)}"]`);
|
||||
if (vaultEl) vaultEl.classList.add(DRAG_OVER_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all drop target highlights.
|
||||
*/
|
||||
function clearAllHighlights() {
|
||||
document.querySelectorAll(`.${DRAG_OVER_CLASS}`).forEach(el => el.classList.remove(DRAG_OVER_CLASS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the source item is being dropped on itself or inside itself.
|
||||
*/
|
||||
function isInvalidDrop(source, target) {
|
||||
if (source.vault !== target.vault) return true; // cross-vault not supported
|
||||
if (!target.targetDir) {
|
||||
// Target is vault root — check if source is already at root
|
||||
const sourceParent = source.path.includes('/')
|
||||
? source.path.substring(0, source.path.lastIndexOf('/'))
|
||||
: '';
|
||||
return sourceParent === '';
|
||||
}
|
||||
// Can't drop a directory into itself or its children
|
||||
if (source.type === 'directory') {
|
||||
if (source.path === target.targetDir) return true; // dropping on itself
|
||||
if (target.targetDir.startsWith(source.path + '/')) return true; // dropping into a child
|
||||
}
|
||||
// Check if source is already in this directory
|
||||
const sourceParent = source.path.includes('/')
|
||||
? source.path.substring(0, source.path.lastIndexOf('/'))
|
||||
: '';
|
||||
if (sourceParent === target.targetDir) return true; // same directory
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function onDragStart(e) {
|
||||
const treeItem = closest('.tree-item', e.target);
|
||||
if (!treeItem) return;
|
||||
|
||||
// Don't allow dragging vault items themselves
|
||||
if (treeItem.classList.contains('vault-item')) return;
|
||||
|
||||
const vault = treeItem.dataset.vault;
|
||||
const path = treeItem.dataset.path;
|
||||
if (!vault || path === undefined) return;
|
||||
|
||||
// Determine type: directory or file
|
||||
const hasChildren = treeItem.nextElementSibling
|
||||
&& treeItem.nextElementSibling.classList.contains('tree-children');
|
||||
const type = hasChildren ? 'directory' : 'file';
|
||||
|
||||
dragState = { vault, path, type, element: treeItem };
|
||||
|
||||
// Set drag data and visual feedback
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', path);
|
||||
|
||||
// Visual: reduce opacity of source
|
||||
treeItem.classList.add(DRAG_SOURCE_CLASS);
|
||||
|
||||
// Add valid-target indicator to all directories and vaults
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.tree-item').forEach(item => {
|
||||
if (item === treeItem) return;
|
||||
if (item.classList.contains('vault-item')) {
|
||||
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
||||
} else {
|
||||
const icon = item.querySelector('[data-lucide]');
|
||||
const hasChildContainer = item.nextElementSibling
|
||||
&& item.nextElementSibling.classList.contains('tree-children');
|
||||
if (icon && (icon.getAttribute('data-lucide') === 'folder' || hasChildContainer)) {
|
||||
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(e) {
|
||||
if (!dragState) return;
|
||||
e.preventDefault(); // Required to allow drop
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
clearAllHighlights();
|
||||
|
||||
const target = getDropTarget(e.target);
|
||||
if (!target || isInvalidDrop(dragState, target)) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
highlightDropTarget(e.target);
|
||||
}
|
||||
|
||||
function onDragLeave(e) {
|
||||
// Only clear when actually leaving the container, not entering a child
|
||||
const container = document.getElementById('vault-tree');
|
||||
if (!container) return;
|
||||
// Use a small delay to avoid flickering when moving between children
|
||||
clearAllHighlights();
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
clearAllHighlights();
|
||||
|
||||
// Remove drag source visual
|
||||
if (dragState && dragState.element) {
|
||||
dragState.element.classList.remove(DRAG_SOURCE_CLASS);
|
||||
}
|
||||
|
||||
// Remove valid-target indicators
|
||||
document.querySelectorAll(`.${DRAG_VALID_TARGET_CLASS}`).forEach(el => {
|
||||
el.classList.remove(DRAG_VALID_TARGET_CLASS);
|
||||
});
|
||||
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
async function onDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!dragState) return;
|
||||
|
||||
const target = getDropTarget(e.target);
|
||||
if (!target || isInvalidDrop(dragState, target)) {
|
||||
onDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const { vault, path, type } = dragState;
|
||||
const { vault: targetVault, targetDir } = target;
|
||||
|
||||
// Don't move if source and target are the same vault
|
||||
if (vault !== targetVault) {
|
||||
showToast(t('dragdrop.cross_vault_unsupported'), 'error');
|
||||
onDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save drag state before cleanup
|
||||
const savedState = { ...dragState };
|
||||
|
||||
// Clean up visual state immediately
|
||||
onDragEnd();
|
||||
|
||||
// Call API
|
||||
try {
|
||||
const response = await api(`/api/move/${encodeURIComponent(vault)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_path: path,
|
||||
destination_dir: targetDir,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response && response.success) {
|
||||
showToast(`Moved ${type} to ${targetDir || 'vault root'}`, 'success');
|
||||
// Refresh the tree preserving expanded state
|
||||
await refreshSidebarTreePreservingState();
|
||||
} else {
|
||||
showToast(t('dragdrop.move_failed'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Drag & drop move failed:', err);
|
||||
const detail = err?.detail || err?.message || 'Unknown error';
|
||||
showToast(`Move failed: ${detail}`, 'error');
|
||||
// Refresh anyway in case the filesystem state changed
|
||||
try { await refreshSidebarTreePreservingState(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach drag-and-drop event listeners to the vault tree container.
|
||||
* Uses event delegation on the #vault-tree element.
|
||||
*/
|
||||
export function initDragDrop() {
|
||||
const container = document.getElementById('vault-tree');
|
||||
if (!container) {
|
||||
console.warn('DragDrop: vault-tree container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous listeners if any (idempotent)
|
||||
container.removeEventListener('dragstart', onDragStart);
|
||||
container.removeEventListener('dragover', onDragOver);
|
||||
container.removeEventListener('dragleave', onDragLeave);
|
||||
container.removeEventListener('dragend', onDragEnd);
|
||||
container.removeEventListener('drop', onDrop);
|
||||
|
||||
// Attach listeners with delegation
|
||||
container.addEventListener('dragstart', onDragStart);
|
||||
container.addEventListener('dragover', onDragOver);
|
||||
container.addEventListener('dragleave', onDragLeave);
|
||||
container.addEventListener('dragend', onDragEnd);
|
||||
container.addEventListener('drop', onDrop);
|
||||
|
||||
// Make all existing tree items draggable
|
||||
makeAllDraggable();
|
||||
|
||||
// Re-apply draggable after any DOM mutation in the tree
|
||||
const observer = new MutationObserver(() => {
|
||||
makeAllDraggable();
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make all tree items (files and directories) draggable.
|
||||
* Vault items themselves are NOT draggable.
|
||||
*/
|
||||
function makeAllDraggable() {
|
||||
const container = document.getElementById('vault-tree');
|
||||
if (!container) return;
|
||||
|
||||
container.querySelectorAll('.tree-item').forEach(item => {
|
||||
if (item.classList.contains('vault-item')) return;
|
||||
if (!item.hasAttribute('draggable')) {
|
||||
item.setAttribute('draggable', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
/* ObsiGate — Graph View: interactive file/folder relationship visualization.
|
||||
Phase 2: theme colors, tooltips, depth slider, full-vault, search, backlinks. */
|
||||
import { api } from './auth.js';
|
||||
import { t } from './i18n.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { openFile } from './viewer.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme-aware color helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function _cssVar(name, fallback) {
|
||||
return getComputedStyle(document.body).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
get bg() { return _cssVar('--bg-primary', '#1e1e1e'); },
|
||||
get text() { return _cssVar('--text-primary', '#ddd'); },
|
||||
get accent() { return _cssVar('--accent-color', '#2563eb'); },
|
||||
get muted() { return _cssVar('--text-muted', '#888'); },
|
||||
get border() { return _cssVar('--border-color', '#333'); },
|
||||
dir: '#5b9bd5',
|
||||
md: '#70ad47',
|
||||
other: '#999',
|
||||
vault: '#ffc000',
|
||||
backlink: '#e74c3c',
|
||||
highlight: '#ff6b6b',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GraphViewManager
|
||||
// ---------------------------------------------------------------------------
|
||||
export const GraphViewManager = {
|
||||
_canvas: null,
|
||||
_ctx: null,
|
||||
_nodes: [],
|
||||
_edges: [],
|
||||
_offsetX: 0,
|
||||
_offsetY: 0,
|
||||
_zoom: 1,
|
||||
_dragging: false,
|
||||
_dragNode: null,
|
||||
_panning: false,
|
||||
_panStartX: 0,
|
||||
_panStartY: 0,
|
||||
_animFrame: null,
|
||||
_vault: null,
|
||||
_path: null,
|
||||
_nodePositions: {},
|
||||
_width: 0,
|
||||
_height: 0,
|
||||
_scope: 'directory',
|
||||
_depth: 1,
|
||||
_searchTerm: '',
|
||||
_tagFilter: '',
|
||||
_hoveredNode: null,
|
||||
_infoPanel: null,
|
||||
_infoContent: null,
|
||||
_previewPanel: null,
|
||||
_previewContent: null,
|
||||
_previewCache: {},
|
||||
_previewPending: null,
|
||||
_ctrlHoverNode: null,
|
||||
_navHistory: [],
|
||||
_navIndex: -1,
|
||||
|
||||
async open(vault, path, type) {
|
||||
// Push current state to history before navigating (unless navigating via back/fwd)
|
||||
if (this._vault && !this._navNavigating) {
|
||||
this._pushHistory(this._vault, this._path || '');
|
||||
}
|
||||
this._navNavigating = false;
|
||||
|
||||
this._vault = vault;
|
||||
this._path = path;
|
||||
|
||||
const modal = document.getElementById('graph-modal');
|
||||
const title = document.getElementById('graph-title');
|
||||
const statusEl = document.getElementById('graph-status');
|
||||
const canvas = document.getElementById('graph-canvas');
|
||||
const depthSlider = document.getElementById('graph-depth');
|
||||
|
||||
if (!modal || !canvas) return;
|
||||
|
||||
this._infoPanel = document.getElementById('graph-info-panel');
|
||||
this._infoContent = document.getElementById('graph-info-content');
|
||||
this._previewPanel = document.getElementById('graph-preview-panel');
|
||||
this._previewContent = document.getElementById('graph-preview-content');
|
||||
this._depth = depthSlider ? parseInt(depthSlider.value) : 1;
|
||||
this._scope = 'directory';
|
||||
|
||||
title.textContent = t('graph.title', { vault, path: path ? '/' + path : '' });
|
||||
statusEl.textContent = t('graph.loading');
|
||||
modal.classList.add('active');
|
||||
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas.getContext('2d');
|
||||
this._resetView();
|
||||
|
||||
await this._fetchAndRender();
|
||||
this._updateNavButtons();
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
_cache: {},
|
||||
_cacheKey: '',
|
||||
|
||||
_getCacheKey() {
|
||||
return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}|${this._tagFilter}`;
|
||||
},
|
||||
|
||||
async _fetchAndRender() {
|
||||
const statusEl = document.getElementById('graph-status');
|
||||
if (!statusEl) return;
|
||||
|
||||
const cacheKey = this._getCacheKey();
|
||||
|
||||
// Use cache if same parameters
|
||||
if (this._cache[cacheKey]) {
|
||||
const cached = this._cache[cacheKey];
|
||||
this._nodes = cached.nodes;
|
||||
this._edges = cached.edges;
|
||||
this._scope = cached.scope;
|
||||
const scopeLabel = this._scope === 'full' ? t('graph.scope_full') : t('graph.scope_directory');
|
||||
statusEl.textContent = `${t('graph.nodes_count', { count: this._nodes.length })}, ${t('graph.edges_count', { count: this._edges.length })} · ${scopeLabel} · ${t('graph.depth_label', { depth: this._depth })} ${t('graph.cache_label')}`;
|
||||
this._initLayout();
|
||||
this._startRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (this._path) params.set('path', this._path);
|
||||
params.set('depth', String(this._depth));
|
||||
params.set('scope', this._scope);
|
||||
if (this._tagFilter) params.set('tag', this._tagFilter);
|
||||
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/graph/${encodeURIComponent(this._vault)}?${params.toString()}`
|
||||
);
|
||||
this._nodes = data.nodes || [];
|
||||
this._edges = data.edges || [];
|
||||
this._scope = data.scope || 'directory';
|
||||
|
||||
// Cache the result
|
||||
this._cache[cacheKey] = {
|
||||
nodes: this._nodes,
|
||||
edges: this._edges,
|
||||
scope: this._scope,
|
||||
};
|
||||
this._cacheKey = cacheKey;
|
||||
|
||||
const scopeLabel = this._scope === 'full' ? t('graph.scope_full') : t('graph.scope_directory');
|
||||
statusEl.textContent = `${t('graph.nodes_count', { count: this._nodes.length })}, ${t('graph.edges_count', { count: this._edges.length })} · ${scopeLabel} · ${t('graph.depth_label', { depth: this._depth })}`;
|
||||
this._initLayout();
|
||||
this._startRender();
|
||||
} catch (err) {
|
||||
statusEl.textContent = t('graph.error_loading');
|
||||
console.error('Graph error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
reload() {
|
||||
// Called when depth slider or full-vault button changes
|
||||
this._resetView();
|
||||
this._fetchAndRender();
|
||||
},
|
||||
|
||||
setDepth(depth) {
|
||||
this._depth = depth;
|
||||
this.reload();
|
||||
},
|
||||
|
||||
toggleScope() {
|
||||
this._scope = this._scope === 'full' ? 'directory' : 'full';
|
||||
const btn = document.getElementById('graph-full-vault');
|
||||
if (btn) btn.textContent = this._scope === 'full' ? t('graph.btn_directory') : t('graph.btn_full');
|
||||
this.reload();
|
||||
},
|
||||
|
||||
setSearch(term) {
|
||||
this._searchTerm = term;
|
||||
// Parse unified search: #tag → tag filter, otherwise → node search
|
||||
if (term.startsWith('#')) {
|
||||
const tag = term.substring(1).trim();
|
||||
if (tag && tag !== this._tagFilter) {
|
||||
this._tagFilter = tag;
|
||||
this.reload();
|
||||
} else if (!tag && this._tagFilter) {
|
||||
this._tagFilter = '';
|
||||
this.reload();
|
||||
}
|
||||
} else {
|
||||
if (this._tagFilter) {
|
||||
this._tagFilter = '';
|
||||
this.reload();
|
||||
return;
|
||||
}
|
||||
if (term && term.length >= 2) {
|
||||
this.reload();
|
||||
} else if (!term && this._searchTerm !== term) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
const modal = document.getElementById('graph-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
this._navHistory = [];
|
||||
this._navIndex = -1;
|
||||
if (this._animFrame) {
|
||||
cancelAnimationFrame(this._animFrame);
|
||||
this._animFrame = null;
|
||||
}
|
||||
this._hideTooltip();
|
||||
},
|
||||
|
||||
_resetView() {
|
||||
this._offsetX = 0;
|
||||
this._offsetY = 0;
|
||||
this._zoom = 1;
|
||||
this._nodePositions = {};
|
||||
},
|
||||
|
||||
_pushHistory(vault, path) {
|
||||
// Truncate forward history if we navigated back then took a new path
|
||||
if (this._navIndex < this._navHistory.length - 1) {
|
||||
this._navHistory = this._navHistory.slice(0, this._navIndex + 1);
|
||||
}
|
||||
this._navHistory.push({ vault, path });
|
||||
this._navIndex = this._navHistory.length - 1;
|
||||
// Keep max 50 entries
|
||||
if (this._navHistory.length > 50) {
|
||||
this._navHistory.shift();
|
||||
this._navIndex--;
|
||||
}
|
||||
this._updateNavButtons();
|
||||
},
|
||||
|
||||
_updateNavButtons() {
|
||||
const backBtn = document.getElementById('graph-nav-back');
|
||||
const fwdBtn = document.getElementById('graph-nav-fwd');
|
||||
const upBtn = document.getElementById('graph-nav-up');
|
||||
if (backBtn) {
|
||||
backBtn.disabled = this._navIndex <= 0;
|
||||
backBtn.style.opacity = this._navIndex > 0 ? '1' : '0.3';
|
||||
}
|
||||
if (fwdBtn) {
|
||||
fwdBtn.disabled = this._navIndex >= this._navHistory.length - 1;
|
||||
fwdBtn.style.opacity = this._navIndex < this._navHistory.length - 1 ? '1' : '0.3';
|
||||
}
|
||||
// Show "↑ Parent" if we have a path (subfolder) or history
|
||||
if (upBtn) {
|
||||
const hasParent = !!(this._path && this._path.includes('/'));
|
||||
const hasHistory = this._navIndex > 0;
|
||||
upBtn.style.display = (hasParent || hasHistory) ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
goBack() {
|
||||
if (this._navIndex <= 0) return;
|
||||
this._navIndex--;
|
||||
const entry = this._navHistory[this._navIndex];
|
||||
this._navNavigating = true;
|
||||
this.open(entry.vault, entry.path);
|
||||
},
|
||||
|
||||
goForward() {
|
||||
if (this._navIndex >= this._navHistory.length - 1) return;
|
||||
this._navIndex++;
|
||||
const entry = this._navHistory[this._navIndex];
|
||||
this._navNavigating = true;
|
||||
this.open(entry.vault, entry.path);
|
||||
},
|
||||
|
||||
goUp() {
|
||||
if (!this._path || !this._path.includes('/')) {
|
||||
// No subfolder — go to previous history entry
|
||||
this.goBack();
|
||||
return;
|
||||
}
|
||||
// Go to parent directory
|
||||
const parentPath = this._path.split('/').slice(0, -1).join('/');
|
||||
this.open(this._vault, parentPath);
|
||||
},
|
||||
|
||||
_initLayout() {
|
||||
const w = this._canvas.parentElement.clientWidth;
|
||||
const h = this._canvas.parentElement.clientHeight;
|
||||
this._canvas.width = w * devicePixelRatio;
|
||||
this._canvas.height = h * devicePixelRatio;
|
||||
this._canvas.style.width = w + 'px';
|
||||
this._canvas.style.height = h + 'px';
|
||||
this._width = w;
|
||||
this._height = h;
|
||||
this._ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this._ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) * 0.35;
|
||||
|
||||
this._nodes.forEach((node, i) => {
|
||||
const angle = (2 * Math.PI * i) / Math.max(this._nodes.length, 1);
|
||||
this._nodePositions[node.id] = {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_startRender() {
|
||||
const self = this;
|
||||
let lastTime = 0;
|
||||
|
||||
const loop = (time) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.1);
|
||||
lastTime = time;
|
||||
self._simulate(dt);
|
||||
self._draw();
|
||||
self._animFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
this._animFrame = requestAnimationFrame(loop);
|
||||
},
|
||||
|
||||
_simulate(dt) {
|
||||
if (this._dragging || this._dragNode) return;
|
||||
|
||||
const positions = this._nodePositions;
|
||||
const cx = this._width / 2;
|
||||
const cy = this._height / 2;
|
||||
|
||||
// Spring forces (edges) — unchanged
|
||||
for (const edge of this._edges) {
|
||||
const a = positions[edge.source];
|
||||
const b = positions[edge.target];
|
||||
if (!a || !b) continue;
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const targetLen = 80;
|
||||
const force = (dist - targetLen) * 0.01;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Repulsion: Barnes-Hut for >200 nodes, naive O(n²) otherwise
|
||||
if (this._nodes.length > 200) {
|
||||
this._barnesHutRepulsion(positions);
|
||||
} else {
|
||||
this._naiveRepulsion(positions);
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
p.vx += (cx - p.x) * 0.001;
|
||||
p.vy += (cy - p.y) * 0.001;
|
||||
}
|
||||
|
||||
// Apply velocities with damping
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
p.vx *= 0.9;
|
||||
p.vy *= 0.9;
|
||||
p.x += p.vx * dt * 60;
|
||||
p.y += p.vy * dt * 60;
|
||||
}
|
||||
},
|
||||
|
||||
_naiveRepulsion(positions) {
|
||||
for (const n1 of this._nodes) {
|
||||
for (const n2 of this._nodes) {
|
||||
if (n1.id === n2.id) continue;
|
||||
const a = positions[n1.id];
|
||||
const b = positions[n2.id];
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = 2000 / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Barnes-Hut quadtree for O(n log n) repulsion
|
||||
_barnesHutRepulsion(positions) {
|
||||
// Build quadtree
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
const root = this._buildQuadTree(positions, minX, minY, maxX, maxY);
|
||||
|
||||
// Apply forces from quadtree
|
||||
const theta = 0.9; // Barnes-Hut opening angle
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
this._applyQuadTreeForce(root, p, theta);
|
||||
}
|
||||
},
|
||||
|
||||
_buildQuadTree(positions, x0, y0, x1, y1) {
|
||||
const cx = (x0 + x1) / 2;
|
||||
const cy = (y0 + y1) / 2;
|
||||
const cell = { x0, y0, x1, y1, cx, cy, mass: 0, mx: 0, my: 0, children: null };
|
||||
|
||||
// Collect nodes in this cell
|
||||
const contained = [];
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (p && p.x >= x0 && p.x < x1 && p.y >= y0 && p.y < y1) {
|
||||
contained.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (contained.length === 0) return cell;
|
||||
if (contained.length === 1) {
|
||||
cell.mass = 1;
|
||||
cell.mx = contained[0].x;
|
||||
cell.my = contained[0].y;
|
||||
return cell;
|
||||
}
|
||||
|
||||
// Subdivide
|
||||
const midX = cx;
|
||||
const midY = cy;
|
||||
cell.children = [
|
||||
this._buildQuadTree(positions, x0, y0, midX, midY), // NW
|
||||
this._buildQuadTree(positions, midX, y0, x1, midY), // NE
|
||||
this._buildQuadTree(positions, x0, midY, midX, y1), // SW
|
||||
this._buildQuadTree(positions, midX, midY, x1, y1), // SE
|
||||
];
|
||||
|
||||
// Compute center of mass
|
||||
let totalMass = 0;
|
||||
let sumX = 0, sumY = 0;
|
||||
for (const child of cell.children) {
|
||||
if (child.mass > 0) {
|
||||
totalMass += child.mass;
|
||||
sumX += child.mx * child.mass;
|
||||
sumY += child.my * child.mass;
|
||||
}
|
||||
}
|
||||
cell.mass = totalMass;
|
||||
cell.mx = sumX / totalMass;
|
||||
cell.my = sumY / totalMass;
|
||||
|
||||
return cell;
|
||||
},
|
||||
|
||||
_applyQuadTreeForce(cell, p, theta) {
|
||||
if (cell.mass === 0) return;
|
||||
|
||||
const dx = cell.mx - p.x;
|
||||
const dy = cell.my - p.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const size = cell.x1 - cell.x0;
|
||||
|
||||
// If cell is far enough or is a leaf, apply force from center of mass
|
||||
if (!cell.children || size / dist < theta) {
|
||||
const force = 2000 * cell.mass / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
p.vx -= fx;
|
||||
p.vy -= fy;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise recurse into children
|
||||
for (const child of cell.children) {
|
||||
this._applyQuadTreeForce(child, p, theta);
|
||||
}
|
||||
},
|
||||
|
||||
_draw() {
|
||||
const ctx = this._ctx;
|
||||
const w = this._width;
|
||||
const h = this._height;
|
||||
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.translate(this._offsetX, this._offsetY);
|
||||
ctx.scale(this._zoom, this._zoom);
|
||||
|
||||
// Draw edges
|
||||
for (const edge of this._edges) {
|
||||
const a = this._nodePositions[edge.source];
|
||||
const b = this._nodePositions[edge.target];
|
||||
if (!a || !b) continue;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
|
||||
if (edge.relation === 'backlink') {
|
||||
ctx.strokeStyle = COLORS.backlink;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
} else if (edge.relation === 'wikilink') {
|
||||
ctx.strokeStyle = COLORS.accent;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
} else {
|
||||
ctx.strokeStyle = COLORS.muted;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw nodes
|
||||
const searchLower = this._searchTerm.toLowerCase();
|
||||
for (const node of this._nodes) {
|
||||
if (!this._isNodeVisible(node)) continue;
|
||||
const p = this._nodePositions[node.id];
|
||||
if (!p) continue;
|
||||
|
||||
const links = (node.incoming_count || 0) + (node.outgoing_count || 0);
|
||||
const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100));
|
||||
|
||||
// Highlight if search match
|
||||
const isHighlighted = searchLower && (
|
||||
node.name.toLowerCase().includes(searchLower) ||
|
||||
(node.tags || []).some(t => t.toLowerCase().includes(searchLower))
|
||||
);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||||
|
||||
switch (node.type) {
|
||||
case 'directory':
|
||||
ctx.fillStyle = COLORS.dir;
|
||||
break;
|
||||
case 'file':
|
||||
ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other;
|
||||
break;
|
||||
case 'vault':
|
||||
ctx.fillStyle = COLORS.vault;
|
||||
break;
|
||||
default:
|
||||
ctx.fillStyle = COLORS.other;
|
||||
}
|
||||
|
||||
if (isHighlighted) {
|
||||
ctx.shadowColor = COLORS.highlight;
|
||||
ctx.shadowBlur = 12;
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
|
||||
if (isHighlighted) {
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = COLORS.highlight;
|
||||
ctx.lineWidth = 2.5;
|
||||
} else {
|
||||
ctx.strokeStyle = COLORS.bg;
|
||||
ctx.lineWidth = 1.5;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Node label
|
||||
const label = node.name.length > 20 ? node.name.slice(0, 18) + '...' : node.name;
|
||||
ctx.font = `${isHighlighted ? 12 : 11} / ${this._zoom}px -apple-system, sans-serif`;
|
||||
ctx.fillStyle = isHighlighted ? COLORS.highlight : COLORS.text;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, p.x, p.y + r + 12 / this._zoom);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
_getNodeAt(screenX, screenY) {
|
||||
const x = (screenX - this._offsetX) / this._zoom;
|
||||
const y = (screenY - this._offsetY) / this._zoom;
|
||||
|
||||
for (const node of this._nodes) {
|
||||
const p = this._nodePositions[node.id];
|
||||
if (!p) continue;
|
||||
const links = (node.incoming_count || 0) + (node.outgoing_count || 0);
|
||||
const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100));
|
||||
const dx = x - p.x;
|
||||
const dy = y - p.y;
|
||||
if (dx * dx + dy * dy <= r * r + 100) {
|
||||
return { node, pos: p };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
_showTooltip(node, screenX, screenY, isPreview) {
|
||||
if (isPreview) return; // Handled by _showPreviewTooltip
|
||||
if (!this._infoContent) return;
|
||||
const cacheKey = `${this._vault}:${node.path}`;
|
||||
const cached = this._previewCache[cacheKey];
|
||||
const meta = cached?.meta;
|
||||
// Build metadata summary
|
||||
let metaRows = '';
|
||||
if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0) {
|
||||
const fm = meta.frontmatter;
|
||||
const rows = [];
|
||||
if (fm.auteur) rows.push(`<div><span style="color:var(--text-muted)">✍️ Auteur</span> <span>${fm.auteur}</span></div>`);
|
||||
if (fm.date || fm.creation_date) rows.push(`<div><span style="color:var(--text-muted)">📅 Date</span> <span>${fm.date || fm.creation_date}</span></div>`);
|
||||
if (fm.statut) rows.push(`<div><span style="color:var(--text-muted)">📌 Statut</span> <span>${fm.statut}</span></div>`);
|
||||
if (fm.catégorie || fm.categorie) rows.push(`<div><span style="color:var(--text-muted)">${t('graph.category')}</span> <span>${fm.catégorie || fm.categorie}</span></div>`);
|
||||
if (fm.publish) rows.push(`<div><span style="color:var(--text-muted)">✅</span> <span>${t('graph.published')}</span></div>`);
|
||||
if (fm.favoris) rows.push('<div><span style="color:var(--text-muted)">⭐</span> <span>' + t('graph.favorites') + '</span></div>');
|
||||
if (rows.length > 0) metaRows = rows.join('');
|
||||
}
|
||||
const tags = (meta?.tags || node.tags || []).slice(0, 6);
|
||||
const inc = node.incoming_count || 0;
|
||||
const out = node.outgoing_count || 0;
|
||||
const typeIcon = node.type === 'directory' ? '📁' : '📄';
|
||||
const typeLabel = node.type === 'directory' ? t('graph.type_directory') : (node.path || '').endsWith('.md') ? t('graph.type_markdown') : t('graph.type_file');
|
||||
|
||||
this._infoContent.innerHTML = `
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:8px;padding-right:16px">
|
||||
<span style="font-size:1.2rem">${typeIcon}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:0.85rem;word-break:break-word">${meta?.title || node.name}</div>
|
||||
<div style="color:var(--text-muted);font-size:0.7rem;word-break:break-all">${node.path}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px">
|
||||
<span style="font-size:0.6rem;background:var(--bg-secondary);padding:1px 6px;border-radius:3px;color:var(--text-muted)">${typeLabel}</span>
|
||||
${inc + out > 0 ? `<span style="font-size:0.6rem;background:var(--bg-secondary);padding:1px 6px;border-radius:3px;color:var(--text-muted)">🔗 ${out}→ · ${inc}←</span>` : ''}
|
||||
</div>
|
||||
${metaRows ? `<div style="font-size:0.72rem;display:flex;flex-direction:column;gap:2px;padding:6px 0;border-top:1px solid var(--border-color);border-bottom:1px solid var(--border-color);margin-bottom:6px">${metaRows}</div>` : ''}
|
||||
${tags.length > 0 ? `<div style="display:flex;gap:4px;flex-wrap:wrap">${tags.map(t => `<span style="font-size:0.65rem;background:var(--accent-color);color:#fff;padding:1px 6px;border-radius:3px;opacity:0.8">#${t}</span>`).join('')}</div>` : ''}
|
||||
${node.type === 'file' ? '<div style="font-size:0.6rem;color:var(--text-muted);margin-top:6px;opacity:0.6">' + t('graph.ctrl_hover_preview') + '</div>' : ''}
|
||||
`;
|
||||
this._infoPanel.style.display = 'block';
|
||||
},
|
||||
|
||||
_hideTooltip() {
|
||||
// Panels are now sticky — only hide via close button
|
||||
},
|
||||
|
||||
_showPreviewTooltip(node, preview, screenX, screenY) {
|
||||
if (!this._previewContent || this._ctrlHoverNode !== node) return;
|
||||
const cacheKey = `${this._vault}:${node.path}`;
|
||||
const cached = this._previewCache[cacheKey];
|
||||
const meta = cached?.meta;
|
||||
// Format frontmatter summary
|
||||
let fmSummary = '';
|
||||
if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0) {
|
||||
const fm = meta.frontmatter;
|
||||
const items = [];
|
||||
if (fm.auteur) items.push(`✍️ ${fm.auteur}`);
|
||||
if (fm.date || fm.creation_date) items.push(`📅 ${fm.date || fm.creation_date}`);
|
||||
if (fm.statut) items.push(`📌 ${fm.statut}`);
|
||||
if (fm.publish) items.push(t('graph.published_badge'));
|
||||
if (fm.favoris) items.push('⭐ favoris');
|
||||
if (items.length > 0) fmSummary = `<div style="font-size:0.7rem;color:var(--text-muted);margin:4px 0">${items.join(' · ')}</div>`;
|
||||
}
|
||||
const tags = (meta?.tags || node.tags || []).slice(0, 8);
|
||||
this._previewContent.innerHTML = `
|
||||
<div style="font-weight:700;font-size:0.95rem;margin-bottom:4px;word-break:break-word">${meta?.title || node.name}</div>
|
||||
<div style="color:var(--text-muted);font-size:0.7rem;margin-bottom:6px;word-break:break-all">${node.path}</div>
|
||||
${fmSummary}
|
||||
${tags.length > 0 ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px">${tags.map(t => `<span style="font-size:0.65rem;background:var(--accent-color);color:#fff;padding:1px 6px;border-radius:3px;opacity:0.8">#${t}</span>`).join('')}</div>` : ''}
|
||||
<div style="border-top:1px solid var(--border-color);padding-top:10px;font-size:0.82rem;line-height:1.6;white-space:pre-wrap;word-break:break-word">${preview}</div>
|
||||
`;
|
||||
this._previewPanel.style.display = 'block';
|
||||
},
|
||||
|
||||
// --- Advanced controls ---
|
||||
|
||||
_isNodeVisible(node) {
|
||||
const showDir = document.getElementById('graph-filter-dir')?.checked ?? true;
|
||||
const showMd = document.getElementById('graph-filter-md')?.checked ?? true;
|
||||
const showOther = document.getElementById('graph-filter-other')?.checked ?? true;
|
||||
if (node.type === 'directory') return showDir;
|
||||
if (node.type === 'file' && (node.path || '').endsWith('.md')) return showMd;
|
||||
if (node.type === 'file') return showOther;
|
||||
return true;
|
||||
},
|
||||
|
||||
applyTypeFilter() {
|
||||
// Filters are applied during _draw() — nodes/edges not in visible set are skipped
|
||||
},
|
||||
|
||||
exportPNG() {
|
||||
const link = document.createElement('a');
|
||||
link.download = `obsigate-graph-${this._vault}-${Date.now()}.png`;
|
||||
link.href = this._canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
},
|
||||
|
||||
toggleFullscreen() {
|
||||
const container = this._canvas?.parentElement?.parentElement; // editor-container
|
||||
if (!container) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
container.requestFullscreen();
|
||||
}
|
||||
// Re-init layout after fullscreen change
|
||||
setTimeout(() => this._onResize(), 300);
|
||||
},
|
||||
|
||||
focusNode(nodeId) {
|
||||
const pos = this._nodePositions[nodeId];
|
||||
if (!pos) return;
|
||||
this._offsetX = this._width / 2 - pos.x * this._zoom;
|
||||
this._offsetY = this._height / 2 - pos.y * this._zoom;
|
||||
},
|
||||
|
||||
_onMouseDown(e) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const hit = this._getNodeAt(mx, my);
|
||||
if (hit) {
|
||||
this._dragging = true;
|
||||
this._dragNode = hit;
|
||||
this._canvas.style.cursor = 'grabbing';
|
||||
} else {
|
||||
this._panning = true;
|
||||
this._panStartX = e.clientX - this._offsetX;
|
||||
this._panStartY = e.clientY - this._offsetY;
|
||||
this._canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseMove(e) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
if (this._dragging && this._dragNode) {
|
||||
this._dragNode.pos.x = (mx - this._offsetX) / this._zoom;
|
||||
this._dragNode.pos.y = (my - this._offsetY) / this._zoom;
|
||||
this._dragNode.pos.vx = 0;
|
||||
this._dragNode.pos.vy = 0;
|
||||
} else if (this._panning) {
|
||||
this._offsetX = e.clientX - this._panStartX;
|
||||
this._offsetY = e.clientY - this._panStartY;
|
||||
} else {
|
||||
const hit = this._getNodeAt(mx, my);
|
||||
if (hit) {
|
||||
this._canvas.style.cursor = 'pointer';
|
||||
this._canvas.title = hit.node.type === 'file'
|
||||
? `📄 ${hit.node.name} (${t('graph.click_to_open')}${e.ctrlKey ? ', ' + t('graph.ctrl_hover_preview_short') : ''})`
|
||||
: `📁 ${hit.node.name} (cliquer pour explorer)`;
|
||||
|
||||
// Ctrl+hover: fetch content preview for file nodes
|
||||
if (e.ctrlKey && hit.node.type === 'file') {
|
||||
if (this._ctrlHoverNode !== hit.node) {
|
||||
this._ctrlHoverNode = hit.node;
|
||||
this._fetchPreview(hit.node, e.clientX, e.clientY);
|
||||
}
|
||||
this._showTooltip(hit.node, e.clientX, e.clientY, true);
|
||||
} else {
|
||||
this._ctrlHoverNode = null;
|
||||
this._showTooltip(hit.node, e.clientX, e.clientY, false);
|
||||
}
|
||||
} else {
|
||||
this._canvas.style.cursor = 'grab';
|
||||
this._canvas.title = '';
|
||||
this._ctrlHoverNode = null;
|
||||
this._hideTooltip();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async _fetchPreview(node, screenX, screenY) {
|
||||
const cacheKey = `${this._vault}:${node.path}`;
|
||||
if (this._previewCache[cacheKey] !== undefined) {
|
||||
const cached = this._previewCache[cacheKey];
|
||||
this._showPreviewTooltip(node, cached.preview, screenX, screenY);
|
||||
return;
|
||||
}
|
||||
// Show loading indicator
|
||||
this._showPreviewTooltip(node, '<em>' + t('common.loading') + '</em>', screenX, screenY);
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(this._vault)}?path=${encodeURIComponent(node.path)}`);
|
||||
// Strip HTML tags, keep line breaks, limit to 600 chars
|
||||
const text = (data.html || '').replace(/<[^>]*>/g, '').replace(/\n+/g, '\n').trim();
|
||||
const preview = text.substring(0, 600) + (text.length > 600 ? '…' : '');
|
||||
// Cache both preview text and full metadata for tooltips
|
||||
this._previewCache[cacheKey] = {
|
||||
preview,
|
||||
meta: {
|
||||
title: data.title,
|
||||
frontmatter: data.frontmatter || {},
|
||||
tags: data.tags || [],
|
||||
path: node.path,
|
||||
}
|
||||
};
|
||||
this._showPreviewTooltip(node, preview, screenX, screenY);
|
||||
} catch {
|
||||
this._previewCache[cacheKey] = { preview: '(impossible de charger)', meta: null };
|
||||
this._showPreviewTooltip(node, '(impossible de charger le contenu)', screenX, screenY);
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseUp(e) {
|
||||
if (this._dragging && this._dragNode) {
|
||||
const node = this._dragNode.node;
|
||||
this._dragging = false;
|
||||
this._dragNode = null;
|
||||
this._canvas.style.cursor = 'grab';
|
||||
|
||||
if (node.type === 'file') {
|
||||
this.close();
|
||||
openFile(this._vault, node.path);
|
||||
} else if (node.type === 'directory' || node.type === 'vault') {
|
||||
this.close();
|
||||
this._path = node.path || '';
|
||||
this.open(this._vault, this._path, node.type);
|
||||
}
|
||||
}
|
||||
this._panning = false;
|
||||
},
|
||||
|
||||
_onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor));
|
||||
|
||||
this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom);
|
||||
this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom);
|
||||
this._zoom = newZoom;
|
||||
},
|
||||
|
||||
_onResize() {
|
||||
if (!this._canvas || !this._nodes.length) return;
|
||||
const w = this._canvas.parentElement.clientWidth;
|
||||
const h = this._canvas.parentElement.clientHeight;
|
||||
this._canvas.width = w * devicePixelRatio;
|
||||
this._canvas.height = h * devicePixelRatio;
|
||||
this._canvas.style.width = w + 'px';
|
||||
this._canvas.style.height = h + 'px';
|
||||
this._width = w;
|
||||
this._height = h;
|
||||
this._ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this._ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init graph view event listeners
|
||||
// ---------------------------------------------------------------------------
|
||||
export function initGraphView() {
|
||||
const gm = GraphViewManager;
|
||||
const closeBtn = document.getElementById('graph-close');
|
||||
const zoomIn = document.getElementById('graph-zoom-in');
|
||||
const zoomOut = document.getElementById('graph-zoom-out');
|
||||
const reset = document.getElementById('graph-reset');
|
||||
const fullVault = document.getElementById('graph-full-vault');
|
||||
const depthSlider = document.getElementById('graph-depth');
|
||||
const searchInput = document.getElementById('graph-search');
|
||||
const modal = document.getElementById('graph-modal');
|
||||
const canvas = document.getElementById('graph-canvas');
|
||||
|
||||
if (closeBtn) closeBtn.addEventListener('click', () => gm.close());
|
||||
if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) gm.close(); });
|
||||
|
||||
// Navigation buttons
|
||||
const navBack = document.getElementById('graph-nav-back');
|
||||
if (navBack) navBack.addEventListener('click', () => gm.goBack());
|
||||
const navFwd = document.getElementById('graph-nav-fwd');
|
||||
if (navFwd) navFwd.addEventListener('click', () => gm.goForward());
|
||||
const navUp = document.getElementById('graph-nav-up');
|
||||
if (navUp) navUp.addEventListener('click', () => gm.goUp());
|
||||
|
||||
if (zoomIn) zoomIn.addEventListener('click', () => { gm._zoom = Math.min(3, gm._zoom * 1.2); });
|
||||
if (zoomOut) zoomOut.addEventListener('click', () => { gm._zoom = Math.max(0.2, gm._zoom * 0.8); });
|
||||
if (reset) reset.addEventListener('click', () => { gm._offsetX = 0; gm._offsetY = 0; gm._zoom = 1; });
|
||||
if (fullVault) fullVault.addEventListener('click', () => gm.toggleScope());
|
||||
if (depthSlider) depthSlider.addEventListener('input', () => gm.setDepth(parseInt(depthSlider.value)));
|
||||
if (searchInput) {
|
||||
let debounce;
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => gm.setSearch(searchInput.value.trim()), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Export PNG
|
||||
const exportBtn = document.getElementById('graph-export');
|
||||
if (exportBtn) exportBtn.addEventListener('click', () => gm.exportPNG());
|
||||
|
||||
// Fullscreen
|
||||
const fullscreenBtn = document.getElementById('graph-fullscreen');
|
||||
if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => gm.toggleFullscreen());
|
||||
|
||||
// Type filters
|
||||
['dir', 'md', 'other'].forEach(type => {
|
||||
const cb = document.getElementById(`graph-filter-${type}`);
|
||||
if (cb) cb.addEventListener('change', () => gm.applyTypeFilter());
|
||||
});
|
||||
|
||||
if (canvas) {
|
||||
canvas.addEventListener('mousedown', (e) => gm._onMouseDown(e));
|
||||
canvas.addEventListener('mousemove', (e) => gm._onMouseMove(e));
|
||||
canvas.addEventListener('mouseup', (e) => gm._onMouseUp(e));
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
gm._dragging = false;
|
||||
gm._dragNode = null;
|
||||
gm._panning = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
// Panels stay visible until closed via button
|
||||
});
|
||||
canvas.addEventListener('wheel', (e) => gm._onWheel(e), { passive: false });
|
||||
window.addEventListener('resize', () => gm._onResize());
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (modal && modal.classList.contains('active') && e.key === 'Escape') {
|
||||
gm.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close buttons for sticky panels
|
||||
const infoClose = document.getElementById('graph-info-close');
|
||||
if (infoClose) infoClose.addEventListener('click', () => { if (gm._infoPanel) gm._infoPanel.style.display = 'none'; });
|
||||
const previewClose = document.getElementById('graph-preview-close');
|
||||
if (previewClose) previewClose.addEventListener('click', () => { if (gm._previewPanel) gm._previewPanel.style.display = 'none'; });
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* ObsiGate — i18n (internationalisation).
|
||||
*
|
||||
* Architecture:
|
||||
* Flat key→string JSON locale files in /static/locales/{fr,en,...}.json
|
||||
* Static DOM: data-i18n="key" → textContent
|
||||
* data-i18n-placeholder="key" → placeholder
|
||||
* data-i18n-attr:title="key" → title attribute
|
||||
* data-i18n-html="key" → innerHTML (use sparingly)
|
||||
* Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'})
|
||||
* Live reload: setLocale('en') updates every data-i18n element instantly.
|
||||
*
|
||||
* Key naming: section.what — lowercase, dot-separated, descriptive.
|
||||
* e.g. header.search_placeholder button.save error.network
|
||||
*
|
||||
* Usage:
|
||||
* import { t, setLocale, getLocale, initI18n } from './i18n.js';
|
||||
* await initI18n(); // early, before any rendering
|
||||
* el.textContent = t('button.cancel'); // dynamic string
|
||||
* setLocale('en'); // user switches language
|
||||
*/
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let _currentLocale = 'fr';
|
||||
let _strings = {}; // { key: translated_string }
|
||||
let _fallbackStrings = {}; // FR fallback, loaded once
|
||||
let _loaded = false;
|
||||
|
||||
// ── Public helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Return the currently active locale code ('fr', 'en', ...). */
|
||||
export function getLocale() {
|
||||
return _currentLocale;
|
||||
}
|
||||
|
||||
/** Translate a key. If params provided, replace {name} placeholders.
|
||||
* Falls back: key → French → key itself. Never throws. */
|
||||
export function t(key, params) {
|
||||
let s = _strings[key];
|
||||
if (s === undefined) {
|
||||
s = _fallbackStrings[key];
|
||||
if (s === undefined) {
|
||||
console.warn('obsigate i18n: missing key', key);
|
||||
s = key; // last resort — show the key so the dev notices
|
||||
}
|
||||
}
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), String(v));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Reverse-lookup cache (French text → key) ────────────────────────────
|
||||
let _reverseCache = null;
|
||||
|
||||
function _buildReverseCache() {
|
||||
_reverseCache = {};
|
||||
for (const [k, v] of Object.entries(_fallbackStrings)) {
|
||||
if (k.startsWith('_')) continue;
|
||||
// Prefer shorter keys when multiple keys map to the same French text
|
||||
if (!_reverseCache[v] || k.length < _reverseCache[v].length) {
|
||||
_reverseCache[v] = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate a French string to the current locale by reverse lookup.
|
||||
* "Paramètres client sauvegardés" → fr: same, en: "Client settings saved".
|
||||
* If the string is not found in the FR locale, returns it unchanged. */
|
||||
export function tr(frenchText) {
|
||||
if (!frenchText || _currentLocale === 'fr') return frenchText;
|
||||
if (!_reverseCache) _buildReverseCache();
|
||||
var key = _reverseCache[frenchText];
|
||||
return key ? t(key) : frenchText;
|
||||
}
|
||||
|
||||
/** Change locale and re-translate the entire DOM.
|
||||
* Persists to localStorage. */
|
||||
export async function setLocale(locale) {
|
||||
locale = locale || 'fr';
|
||||
if (!_loaded) {
|
||||
// Too early — store intent and apply after init
|
||||
try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ }
|
||||
return;
|
||||
}
|
||||
await _loadLocaleStrings(locale);
|
||||
_applyDOM();
|
||||
document.documentElement.lang = locale;
|
||||
try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
/** Initialise the i18n system. Call once, early, before any rendering.
|
||||
* Priority: server profile > localStorage > navigator.language > 'fr'. */
|
||||
export async function initI18n() {
|
||||
// 1. Determine locale: server profile > localStorage > navigator.language > 'fr'
|
||||
let locale = null;
|
||||
|
||||
// Try server-side language preference (survives clear site data)
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
if (user.language === 'en' || user.language === 'fr') {
|
||||
locale = user.language;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* not logged in or network error — fall through */ }
|
||||
|
||||
// Fall back to localStorage
|
||||
if (!locale) {
|
||||
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
// Fall back to navigator.language
|
||||
if (!locale) {
|
||||
const nav = (navigator.language || '').toLowerCase();
|
||||
locale = nav.startsWith('fr') ? 'fr'
|
||||
: nav.startsWith('en') ? 'en'
|
||||
: 'fr';
|
||||
}
|
||||
|
||||
// 2. Load fallback (FR) — always needed
|
||||
_fallbackStrings = await _fetchLocale('fr');
|
||||
|
||||
// 3. Load selected locale
|
||||
await _loadLocaleStrings(locale);
|
||||
|
||||
// 4. Apply to DOM
|
||||
_applyDOM();
|
||||
document.documentElement.lang = locale;
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function _fetchLocale(locale) {
|
||||
try {
|
||||
const resp = await fetch('/static/locales/' + locale + '.json');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
console.warn('obsigate i18n: failed to load locale ' + locale, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadLocaleStrings(locale) {
|
||||
_currentLocale = locale;
|
||||
if (locale === 'fr') {
|
||||
_strings = _fallbackStrings;
|
||||
} else {
|
||||
_strings = await _fetchLocale(locale);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global exports for non-module scripts (legacy app.js) ──────────────────
|
||||
window.t = t;
|
||||
window.tr = tr;
|
||||
window.getLocale = getLocale;
|
||||
window.setLocale = setLocale;
|
||||
window.initI18n = initI18n;
|
||||
|
||||
/** Walk the DOM and update every element with a data-i18n* attribute. */
|
||||
function _applyDOM() {
|
||||
// textContent
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// placeholder
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.setAttribute('placeholder', t(key));
|
||||
});
|
||||
|
||||
// innerHTML (use sparingly — XSS risk if keys ever hold user content)
|
||||
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n-html');
|
||||
el.innerHTML = t(key);
|
||||
});
|
||||
|
||||
// data-i18n-attr:TITLE → sets any attribute
|
||||
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
|
||||
const raw = el.getAttribute('data-i18n-attr');
|
||||
const colon = raw.indexOf(':');
|
||||
if (colon === -1) return;
|
||||
const attr = raw.substring(0, colon);
|
||||
const key = raw.substring(colon + 1);
|
||||
el.setAttribute(attr, t(key));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/* ObsiGate — Legacy module: remaining functions for the orchestrator
|
||||
Extracted from the monolithic frontend/app.js IIFE.
|
||||
Functions already in other modules are re-exported. */
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { TabManager, closeMobileSidebar } from './ui.js';
|
||||
import { showProgressBar, hideProgressBar, showWelcome } from './viewer.js';
|
||||
|
||||
// --- Search imports ---
|
||||
import {
|
||||
AutocompleteDropdown,
|
||||
SearchChips,
|
||||
SearchHistory,
|
||||
performAdvancedSearch,
|
||||
} from './search.js';
|
||||
|
||||
// --- Dashboard imports ---
|
||||
import {
|
||||
DashboardRecentWidget,
|
||||
DashboardStatsWidget,
|
||||
DashboardBookmarkWidget,
|
||||
DashboardSharedWidget,
|
||||
} from './dashboard.js';
|
||||
|
||||
// --- Re-exports from modules that already have these functions ---
|
||||
export { initSidebarTabs, initConfigModal, initHelpModal, initRecentTab, initAboutModal } from './config.js';
|
||||
export { initSyncStatus } from './sync.js';
|
||||
export { DashboardRecentWidget } from './dashboard.js';
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
async function loadVaultSettings() {
|
||||
try {
|
||||
const settings = await api("/api/vaults/settings/all");
|
||||
state.vaultSettings = settings;
|
||||
} catch (err) {
|
||||
console.error("Failed to load vault settings:", err);
|
||||
state.vaultSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Config helpers (needed by initSearch)
|
||||
// =========================================================================
|
||||
|
||||
const _FRONTEND_CONFIG_KEY = "obsigate-perf-config";
|
||||
|
||||
function _getFrontendConfig() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _getEffective(key, fallback) {
|
||||
const cfg = _getFrontendConfig();
|
||||
return cfg[key] !== undefined ? cfg[key] : fallback;
|
||||
}
|
||||
|
||||
/** Check if user is focused on an input/textarea/contenteditable */
|
||||
function _isInputFocused() {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return document.activeElement?.isContentEditable === true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// initSearch
|
||||
// =========================================================================
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("search-input");
|
||||
if (!input) return;
|
||||
const caseBtn = document.getElementById("rb-case");
|
||||
const wordBtn = document.getElementById("rb-word");
|
||||
const regexBtn = document.getElementById("rb-regex");
|
||||
const filterBtn = document.getElementById("search-filter-btn");
|
||||
const clearBtn = document.getElementById("search-clear-btn");
|
||||
const filterRow = document.getElementById("search-filter-row");
|
||||
const prevBtn = document.getElementById("rb-prev");
|
||||
const nextBtn = document.getElementById("rb-next");
|
||||
const countEl = document.getElementById("rb-count");
|
||||
const resultsBar = document.getElementById("results-bar");
|
||||
|
||||
function _showResultsBar() { if (resultsBar) resultsBar.style.display = 'flex'; }
|
||||
function _hideResultsBar() { if (resultsBar) resultsBar.style.display = 'none'; }
|
||||
|
||||
function _updateToggleUI() {
|
||||
if (caseBtn) caseBtn.classList.toggle("active", state.searchCaseSensitive);
|
||||
if (wordBtn) wordBtn.classList.toggle("active", state.searchWholeWord);
|
||||
if (regexBtn) regexBtn.classList.toggle("active", state.searchRegex);
|
||||
if (filterBtn) filterBtn.classList.toggle("active", state.searchFilterVisible);
|
||||
}
|
||||
|
||||
// Toggle buttons
|
||||
if (caseBtn) caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); });
|
||||
if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); });
|
||||
if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); });
|
||||
if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = state.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); });
|
||||
|
||||
// Result navigation (up/down arrows + Enter)
|
||||
let _searchResultIdx = -1;
|
||||
let _searchResultItems = [];
|
||||
|
||||
function _updateResultHighlight() {
|
||||
_searchResultItems.forEach((el, i) => {
|
||||
el.classList.toggle("search-result-active", i === _searchResultIdx);
|
||||
});
|
||||
if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) {
|
||||
_searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
const countEl = document.getElementById("rb-count");
|
||||
if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`;
|
||||
}
|
||||
|
||||
function _refreshResultItems() {
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
_searchResultIdx = _searchResultItems.length > 0 ? 0 : -1;
|
||||
_updateResultHighlight();
|
||||
if (_searchResultItems.length > 0) _showResultsBar(); else _hideResultsBar();
|
||||
}
|
||||
|
||||
window.navigateSearchResults = function(delta) {
|
||||
_searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
|
||||
if (_searchResultItems.length === 0) return;
|
||||
_searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta));
|
||||
_updateResultHighlight();
|
||||
};
|
||||
|
||||
if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1));
|
||||
if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1));
|
||||
|
||||
function _research() {
|
||||
const q = input.value.trim();
|
||||
if (q.length >= _getEffective("min_query_length", 2)) {
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.searchTimeout = setTimeout(() => {
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
state.advancedSearchOffset = 0;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}, _getEffective("debounce_ms", 300));
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey) {
|
||||
if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); }
|
||||
else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); }
|
||||
else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); }
|
||||
else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize sub-controllers
|
||||
AutocompleteDropdown.init();
|
||||
SearchChips.init();
|
||||
|
||||
// Initially hide clear button
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
|
||||
// Input handler: autocomplete dropdown only (search triggered on Enter or selection)
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
|
||||
// Show autocomplete dropdown while typing
|
||||
AutocompleteDropdown.populate(input.value, input.selectionStart);
|
||||
|
||||
// Clear results when input becomes empty
|
||||
if (!hasText) {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus handler: show history dropdown
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.length === 0) {
|
||||
const historyItems = SearchHistory.filter("").slice(0, 5);
|
||||
if (historyItems.length > 0) {
|
||||
AutocompleteDropdown.populate("", 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation in dropdown
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (AutocompleteDropdown.isVisible()) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
AutocompleteDropdown.navigateDown();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
AutocompleteDropdown.navigateUp();
|
||||
} else if (e.key === "Enter") {
|
||||
// First: check dropdown suggestions (higher priority than search results)
|
||||
if (AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Second: navigate search results if visible
|
||||
const results = document.querySelectorAll(".search-result-item");
|
||||
if (results.length > 0 && _searchResultIdx >= 0) {
|
||||
const el = results[_searchResultIdx];
|
||||
if (el) {
|
||||
const vault = el.dataset.vault;
|
||||
const path = el.dataset.path;
|
||||
if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; }
|
||||
}
|
||||
}
|
||||
// Third: execute search
|
||||
AutocompleteDropdown.hide();
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Escape") {
|
||||
AutocompleteDropdown.hide();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else {
|
||||
// Dropdown not visible: navigate search results or execute search
|
||||
if (e.key === "ArrowDown") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
|
||||
} else if (e.key === "Enter") {
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
state.searchCaseSensitive = false;
|
||||
state.searchWholeWord = false;
|
||||
state.searchRegex = false;
|
||||
_updateToggleUI();
|
||||
_hideResultsBar();
|
||||
SearchChips.clear();
|
||||
AutocompleteDropdown.hide();
|
||||
showWelcome();
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Ctrl+K or Cmd+K: focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
// "/" key: focus search (when not in an input/textarea)
|
||||
if (e.key === "/" && !_isInputFocused()) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
// Escape: blur search input and close dropdown
|
||||
if (e.key === "Escape" && document.activeElement === input) {
|
||||
AutocompleteDropdown.hide();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
const searchInput = document.getElementById("search-input");
|
||||
if (searchInput) searchInput.value = "";
|
||||
|
||||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||||
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
state.showingSource = false;
|
||||
state.cachedRawSource = null;
|
||||
|
||||
closeMobileSidebar();
|
||||
showWelcome();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// loadSavedSearches (needed by showWelcome)
|
||||
// =========================================================================
|
||||
|
||||
async function loadSavedSearches() {
|
||||
const list = document.getElementById("saved-searches-list");
|
||||
const empty = document.getElementById("saved-searches-empty");
|
||||
if (!list) return;
|
||||
try {
|
||||
const searches = await api("/api/saved-searches");
|
||||
if (!searches.length) {
|
||||
list.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
list.innerHTML = searches.map(s => {
|
||||
var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths;
|
||||
var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query);
|
||||
var typeIcon = isDirSearch
|
||||
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
|
||||
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
|
||||
const badges = [];
|
||||
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||||
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||||
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
|
||||
const pathFilters = [];
|
||||
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">\u{1F4E5} ${escapeHtml(s.include_paths)}</span>`);
|
||||
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">\u{1F4E4} ${escapeHtml(s.exclude_paths)}</span>`);
|
||||
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">\u{1F4C1} ${escapeHtml(s.vault)}</span>` : "";
|
||||
return `
|
||||
<div class="saved-search-item${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
|
||||
<div class="saved-search-query">${displayQuery}</div>
|
||||
<div class="saved-search-meta">
|
||||
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
|
||||
${typeIcon}
|
||||
</div>
|
||||
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
|
||||
</div>
|
||||
`}).join("");
|
||||
// Apply saved filter + init filter bar
|
||||
if (typeof _applySavedFilter === 'function') _applySavedFilter();
|
||||
if (typeof _initSavedFilter === 'function') _initSavedFilter();
|
||||
list.querySelectorAll(".saved-search-item").forEach(item => {
|
||||
item.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("saved-search-delete")) return;
|
||||
const idx = Array.from(list.children).indexOf(item);
|
||||
const s = searches[idx];
|
||||
if (!s) return;
|
||||
// Apply the saved search
|
||||
const input = document.getElementById("search-input");
|
||||
if (input) input.value = s.query;
|
||||
state.searchCaseSensitive = s.case_sensitive || false;
|
||||
state.searchWholeWord = s.whole_word || false;
|
||||
state.searchRegex = s.regex || false;
|
||||
if (typeof _updateToggleUI === "function") _updateToggleUI();
|
||||
if (s.include_paths) {
|
||||
const incl = document.getElementById("search-include-input");
|
||||
if (incl) incl.value = s.include_paths;
|
||||
}
|
||||
if (s.exclude_paths) {
|
||||
const excl = document.getElementById("search-exclude-input");
|
||||
if (excl) excl.value = s.exclude_paths;
|
||||
}
|
||||
// Execute the search
|
||||
AutocompleteDropdown.hide();
|
||||
AutocompleteDropdown._suppressNext = true;
|
||||
const vault = s.vault || "all";
|
||||
if (input) { input.dispatchEvent(new Event("input")); }
|
||||
clearTimeout(state.searchTimeout);
|
||||
state.advancedSearchOffset = 0;
|
||||
performAdvancedSearch(s.query, vault, null);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
|
||||
loadSavedSearches();
|
||||
}));
|
||||
safeCreateIcons();
|
||||
} catch (err) { /* silently ignore */ }
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// initDashboardTabs (needed by showWelcome)
|
||||
// =========================================================================
|
||||
|
||||
function initDashboardTabs() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(tab => {
|
||||
// Remove existing listeners by cloning
|
||||
const newTab = tab.cloneNode(true);
|
||||
tab.parentNode.replaceChild(newTab, tab);
|
||||
newTab.addEventListener("click", function() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||||
this.classList.add("active");
|
||||
const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Exports
|
||||
// =========================================================================
|
||||
|
||||
export {
|
||||
loadVaultSettings,
|
||||
initSearch,
|
||||
showWelcome,
|
||||
goHome,
|
||||
};
|
||||
@@ -0,0 +1,845 @@
|
||||
/**
|
||||
* Mermaid.js integration for ObsiGate.
|
||||
*
|
||||
* Handles initialization, rendering, templates, zoom/pan,
|
||||
* fullscreen, focus mode, theme sync, and Obsidian compatibility.
|
||||
*
|
||||
* Usage: import { initMermaid, renderMermaidBlocks } from './mermaid-viewer.js';
|
||||
*/
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration (#20)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _mermaidReady = false;
|
||||
var _currentTheme = 'default';
|
||||
|
||||
export function initMermaid() {
|
||||
if (typeof mermaid === 'undefined') {
|
||||
console.warn('[Mermaid] library not loaded');
|
||||
return;
|
||||
}
|
||||
mermaid.initialize(_getMermaidConfig(_currentTheme === 'dark'));
|
||||
_mermaidReady = true;
|
||||
}
|
||||
|
||||
export function updateMermaidTheme(isDark) {
|
||||
_currentTheme = isDark ? 'dark' : 'default';
|
||||
if (_mermaidReady) {
|
||||
mermaid.initialize(_getMermaidConfig(isDark));
|
||||
}
|
||||
|
||||
// Re-render all existing Mermaid containers with the new theme
|
||||
var containers = document.querySelectorAll('.mermaid-container');
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
reRenderContainer(containers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Unified theme config — used by both init and theme toggle
|
||||
function _getMermaidConfig(isDark) {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'base',
|
||||
fontFamily: 'inherit',
|
||||
themeVariables: isDark ? DARK_THEME : LIGHT_THEME,
|
||||
};
|
||||
}
|
||||
|
||||
// Dark theme — optimized for readability on dark backgrounds
|
||||
var DARK_THEME = {
|
||||
// Flowchart / general shapes
|
||||
primaryColor: '#1e3a5f',
|
||||
primaryTextColor: '#c8daf0',
|
||||
primaryBorderColor: '#4a7ab5',
|
||||
lineColor: '#5a7a9a',
|
||||
secondaryColor: '#2a4a6a',
|
||||
tertiaryColor: '#3a6090',
|
||||
// Background & containers
|
||||
background: '#0f1117',
|
||||
mainBkg: '#1a2030',
|
||||
nodeBorder: '#4a7ab5',
|
||||
clusterBkg: '#1a2235',
|
||||
clusterBorder: '#3a5a80',
|
||||
titleColor: '#a0c0e0',
|
||||
edgeLabelBackground: '#1a2d47',
|
||||
nodeTextColor: '#c8daf0',
|
||||
// Sequence diagram
|
||||
actorBkg: '#1e3a5f',
|
||||
actorBorder: '#4a7ab5',
|
||||
actorTextColor: '#c8daf0',
|
||||
actorLineColor: '#4a7ab5',
|
||||
signalColor: '#c8daf0',
|
||||
signalTextColor: '#c8daf0',
|
||||
labelBoxBkgColor: '#1e3a5f',
|
||||
labelBoxBorderColor: '#4a7ab5',
|
||||
labelTextColor: '#c8daf0',
|
||||
loopTextColor: '#c8daf0',
|
||||
noteBkgColor: '#2a3550',
|
||||
noteBorderColor: '#4a7ab5',
|
||||
noteTextColor: '#c8daf0',
|
||||
activationBkgColor: '#3a5a80',
|
||||
// Gantt
|
||||
sectionBkgColor: '#1a2d47',
|
||||
altSectionBkgColor: '#1e3350',
|
||||
sectionBkgColor2: '#1a2d47',
|
||||
taskBkgColor: '#1e3a5f',
|
||||
taskBorderColor: '#4a7ab5',
|
||||
taskTextColor: '#c8daf0',
|
||||
taskTextOutsideColor: '#8aacca',
|
||||
taskTextLightColor: '#e0e8f0',
|
||||
gridColor: '#2a3a55',
|
||||
activeTaskBkgColor: '#2a5a8a',
|
||||
activeTaskBorderColor: '#6a9ad0',
|
||||
doneTaskBkgColor: '#1a3a4f',
|
||||
doneTaskBorderColor: '#3a7a9a',
|
||||
critBkgColor: '#5a2a3a',
|
||||
critBorderColor: '#a04050',
|
||||
todayLineColor: '#4a7ab5',
|
||||
// Git graph
|
||||
git0: '#4a7ab5',
|
||||
git1: '#3a6a9a',
|
||||
git2: '#5a8ac0',
|
||||
git3: '#2a5a8a',
|
||||
git4: '#6a9ad0',
|
||||
git5: '#3a7aaa',
|
||||
git6: '#4a8abf',
|
||||
git7: '#5a9acf',
|
||||
gitInv0: '#c8daf0',
|
||||
gitInv1: '#b0c8e0',
|
||||
gitInv2: '#d0e0f0',
|
||||
gitInv3: '#a0b8d0',
|
||||
gitBranchLabel0: '#c8daf0',
|
||||
gitBranchLabel1: '#b0c8e0',
|
||||
gitBranchLabel2: '#d0e0f0',
|
||||
commitLabelColor: '#c8daf0',
|
||||
commitLabelBackground: '#1e3a5f',
|
||||
commitLabelFontSize: '11px',
|
||||
tagLabelColor: '#a0c0e0',
|
||||
tagLabelBackground: '#1a2d47',
|
||||
tagLabelBorder: '#4a7ab5',
|
||||
// Pie chart
|
||||
pie1: '#1e3a5f',
|
||||
pie2: '#2a5a8a',
|
||||
pie3: '#153050',
|
||||
pie4: '#3a6a9a',
|
||||
pie5: '#204070',
|
||||
pie6: '#4a7ab5',
|
||||
pie7: '#255080',
|
||||
pie8: '#5a8ac0',
|
||||
pieTitleTextColor: '#c8daf0',
|
||||
pieSectionTextColor: '#c8daf0',
|
||||
pieLegendTextColor: '#a0c0e0',
|
||||
pieStrokeColor: '#0f1117',
|
||||
// Class diagram
|
||||
classText: '#c8daf0',
|
||||
// State diagram
|
||||
labelColor: '#c8daf0',
|
||||
// ER diagram — dark alternating rows for readability
|
||||
fillType0: '#111d30',
|
||||
fillType1: '#152238',
|
||||
fillType2: '#111d30',
|
||||
fillType3: '#152238',
|
||||
fillType4: '#111d30',
|
||||
fillType5: '#152238',
|
||||
fillType6: '#111d30',
|
||||
fillType7: '#152238',
|
||||
entityBorder: '#4a7ab5',
|
||||
entityBackground: '#0d1520',
|
||||
attributeEvenColor: '#ffffff',
|
||||
attributeOddColor: '#c8daf0',
|
||||
// Timeline — section colors
|
||||
cScale0: '#1e3a5f',
|
||||
cScale1: '#2a5a8a',
|
||||
cScale2: '#153050',
|
||||
cScale3: '#3a6a9a',
|
||||
cScale4: '#204070',
|
||||
cScale5: '#4a7ab5',
|
||||
cScaleLabel0: '#c8daf0',
|
||||
cScaleLabel1: '#c8daf0',
|
||||
cScaleLabel2: '#c8daf0',
|
||||
cScaleLabel3: '#c8daf0',
|
||||
cScaleLabel4: '#c8daf0',
|
||||
cScaleLabel5: '#c8daf0',
|
||||
// XY Chart — bar and line colors
|
||||
xyChartBackgroundColor: '#0f1117',
|
||||
xyChartTitleColor: '#c8daf0',
|
||||
xyChartXaxisTitleColor: '#8aacca',
|
||||
xyChartYaxisTitleColor: '#8aacca',
|
||||
plotColor1: '#1e3a5f',
|
||||
plotColor2: '#4a7ab5',
|
||||
plotColor3: '#2a5a8a',
|
||||
plotColor4: '#5a8ac0',
|
||||
plotColor5: '#3a6a9a',
|
||||
plotColor6: '#6a9ad0',
|
||||
plotColor7: '#153050',
|
||||
plotColor8: '#204070',
|
||||
// General text
|
||||
textColor: '#c8daf0',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
// Light theme — clean, minimal
|
||||
var LIGHT_THEME = {
|
||||
primaryColor: '#f0f0f5',
|
||||
primaryTextColor: '#1a1a2e',
|
||||
primaryBorderColor: '#ccc',
|
||||
lineColor: '#666',
|
||||
secondaryColor: '#e8e8f0',
|
||||
tertiaryColor: '#fafafa',
|
||||
background: '#ffffff',
|
||||
mainBkg: '#f5f5fa',
|
||||
nodeBorder: '#ccc',
|
||||
clusterBkg: '#f0f0f5',
|
||||
clusterBorder: '#ddd',
|
||||
titleColor: '#333',
|
||||
edgeLabelBackground: '#f5f5fa',
|
||||
nodeTextColor: '#1a1a2e',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Templates for all diagram types (#22)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export var MERMAID_TEMPLATES = {
|
||||
flowchart: 'flowchart TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[OK]\n B -->|No| D[Cancel]',
|
||||
sequenceDiagram: 'sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi!',
|
||||
classDiagram: 'classDiagram\n class Animal {\n +name: string\n +age: int\n +makeSound()\n }\n class Dog {\n +bark()\n }\n Animal <|-- Dog',
|
||||
'stateDiagram-v2': 'stateDiagram-v2\n [*] --> Idle\n Idle --> Running : start\n Running --> Idle : stop\n Running --> [*] : done',
|
||||
erDiagram: 'erDiagram\n CUSTOMER ||--o{ ORDER : places\n ORDER ||--|{ LINE-ITEM : contains',
|
||||
journey: 'journey\n title My day\n section Morning\n Wake up: 5: Me\n Breakfast: 3: Me\n section Work\n Code: 5: Me, Cat',
|
||||
gantt: 'gantt\n title Project Plan\n dateFormat YYYY-MM-DD\n section Design\n Mockups :a1, 2024-01-01, 7d\n section Dev\n Backend :a2, after a1, 14d',
|
||||
pie: 'pie title Languages\n "Python" : 45\n "JavaScript" : 30\n "Go" : 15\n "Rust" : 10',
|
||||
gitGraph: 'gitGraph\n commit\n branch feature\n checkout feature\n commit\n checkout main\n merge feature',
|
||||
requirementDiagram: 'requirementDiagram\n requirement req1 {\n id: 1\n text: System shall do X\n }\n element Component {\n type: package\n }\n Component - satisfies -> req1',
|
||||
mindmap: 'mindmap\n root((Project))\n Frontend\n HTML\n CSS\n JavaScript\n Backend\n API\n Database',
|
||||
timeline: 'timeline\n title History\n 2020 : Started\n 2022 : v1.0\n 2024 : v2.0',
|
||||
quadrantChart: 'quadrantChart\n title Products\n x-axis Low Reach --> High Reach\n y-axis Low Value --> High Value\n quadrant-1 Stars\n quadrant-2 Question Marks\n Product A: [0.8, 0.6]\n Product B: [0.3, 0.8]',
|
||||
C4Context: 'C4Context\n title System Context\n Person(user, "User")\n System(app, "My App")\n System_Ext(db, "Database")\n Rel(user, app, "Uses")\n Rel(app, db, "Reads/Writes")',
|
||||
C4Container: 'C4Container\n title Containers\n Container(web, "Web App", "React")\n Container(api, "API", "FastAPI")\n ContainerDb(db, "DB", "PostgreSQL")\n Rel(web, api, "REST")\n Rel(api, db, "SQL")',
|
||||
C4Component: 'C4Component\n title API Components\n Container_Boundary(api, "API") {\n Component(auth, "Auth Service")\n Component(search, "Search Engine")\n }',
|
||||
C4Dynamic: 'C4Dynamic\n title Login Flow\n Person(user, "User")\n Container(web, "Web App")\n Container(api, "API")\n Rel(user, web, "POST /login")\n Rel(web, api, "authenticate")',
|
||||
C4Deployment: 'C4Deployment\n title Deployment\n Deployment_Node(cloud, "Cloud") {\n Container(web, "Web")\n Container(api, "API")\n }',
|
||||
'xychart-beta': 'xychart-beta\n title "Sales"\n x-axis [Jan, Feb, Mar, Apr]\n y-axis "Revenue" 0 --> 100\n bar [45, 60, 75, 90]\n line [30, 45, 55, 70]',
|
||||
'block-beta': 'block-beta\n columns 3\n A B C\n D E F',
|
||||
'sankey-beta': 'sankey-beta\n A, B, 50\n A, C, 30\n B, D, 20\n C, D, 30',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering engine (#21)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _renderCounter = 0;
|
||||
|
||||
export async function renderMermaidBlocks(container) {
|
||||
if (!_mermaidReady || typeof mermaid === 'undefined') return;
|
||||
|
||||
var blocks = container.querySelectorAll('pre code.language-mermaid, .mermaid-code');
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
for (var i = 0; i < blocks.length; i++) {
|
||||
var codeBlock = blocks[i];
|
||||
var originalCode = codeBlock.textContent || codeBlock.innerText || '';
|
||||
if (!originalCode.trim()) continue;
|
||||
|
||||
var wrapper = codeBlock.closest('pre') || codeBlock.parentElement;
|
||||
if (!wrapper) continue;
|
||||
|
||||
// Check if already rendered
|
||||
if (wrapper.querySelector('.mermaid-rendered')) continue;
|
||||
|
||||
try {
|
||||
// Preprocess Obsidian syntax before Mermaid parsing (#24)
|
||||
var code = preprocessObsidian(originalCode);
|
||||
await mermaid.parse(code);
|
||||
var id = 'mermaid-' + (++_renderCounter) + '-' + Date.now();
|
||||
var result = await mermaid.render(id, code);
|
||||
|
||||
// Extract diagram type from first non-comment, non-empty line
|
||||
var diagramType = extractDiagramType(code);
|
||||
|
||||
// Build container with header + body
|
||||
var containerDiv = document.createElement('div');
|
||||
containerDiv.className = 'mermaid-container';
|
||||
containerDiv.setAttribute('data-mermaid-code', originalCode);
|
||||
|
||||
// Header bar
|
||||
var header = document.createElement('div');
|
||||
header.className = 'mermaid-header';
|
||||
header.innerHTML =
|
||||
'<span class="mermaid-diagram-type">' + escapeHtml(diagramType) + '</span>' +
|
||||
'<div class="mermaid-header-actions">' +
|
||||
'<button class="mermaid-btn mermaid-zoom-in" title="Zoom +"><i data-lucide="zoom-in" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-zoom-out" title="Zoom -"><i data-lucide="zoom-out" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fullscreen" title="Plein ecran"><i data-lucide="expand" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-copy-svg" title="Copier SVG"><i data-lucide="copy" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-copy-code" title="Copier le code Mermaid"><i data-lucide="file-code-2" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-download" title="Telecharger PNG"><i data-lucide="download" style="width:14px;height:14px"></i></button>' +
|
||||
'<div class="mermaid-toggle">' +
|
||||
'<button class="mermaid-toggle-btn active" data-mode="preview">Preview</button>' +
|
||||
'<button class="mermaid-toggle-btn" data-mode="code">Code</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Body
|
||||
var body = document.createElement('div');
|
||||
body.className = 'mermaid-body';
|
||||
body.innerHTML =
|
||||
'<div class="mermaid-rendered">' + result.svg + '</div>' +
|
||||
'<div class="mermaid-source" style="display:none"><pre><code class="language-mermaid">' + escapeHtml(originalCode) + '</code></pre></div>';
|
||||
|
||||
containerDiv.appendChild(header);
|
||||
containerDiv.appendChild(body);
|
||||
|
||||
wrapper.parentNode.replaceChild(containerDiv, wrapper);
|
||||
|
||||
// Wire up header buttons
|
||||
wireHeaderButtons(containerDiv, originalCode);
|
||||
setupZoomPan(containerDiv);
|
||||
setupCodePreviewToggle(containerDiv);
|
||||
// Patch beta diagram colors for dark mode
|
||||
if (_currentTheme === 'dark') patchBetaColors(containerDiv, diagramType);
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
var errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'mermaid-error';
|
||||
errorDiv.innerHTML = '<strong>Erreur Mermaid</strong><br>' + escapeHtml(err.message || String(err));
|
||||
wrapper.parentNode.replaceChild(errorDiv, wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header button wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wireHeaderButtons(container, originalCode) {
|
||||
// Copy SVG
|
||||
var copySvgBtn = container.querySelector('.mermaid-copy-svg');
|
||||
if (copySvgBtn) {
|
||||
copySvgBtn.addEventListener('click', function() {
|
||||
copySvgToClipboard(container);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy code
|
||||
var copyCodeBtn = container.querySelector('.mermaid-copy-code');
|
||||
if (copyCodeBtn) {
|
||||
copyCodeBtn.addEventListener('click', function() {
|
||||
copyToClipboard(originalCode);
|
||||
});
|
||||
}
|
||||
|
||||
// Download PNG
|
||||
var downloadBtn = container.querySelector('.mermaid-download');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', function() {
|
||||
exportMermaidPNG(container);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code / Preview toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupCodePreviewToggle(container) {
|
||||
var toggleBtns = container.querySelectorAll('.mermaid-toggle-btn');
|
||||
var rendered = container.querySelector('.mermaid-rendered');
|
||||
var source = container.querySelector('.mermaid-source');
|
||||
var zoomBtns = container.querySelectorAll('.mermaid-zoom-in, .mermaid-zoom-out');
|
||||
var fullscreenBtn = container.querySelector('.mermaid-fullscreen');
|
||||
|
||||
for (var i = 0; i < toggleBtns.length; i++) {
|
||||
toggleBtns[i].addEventListener('click', function() {
|
||||
var mode = this.getAttribute('data-mode');
|
||||
|
||||
// Update active state
|
||||
for (var j = 0; j < toggleBtns.length; j++) {
|
||||
toggleBtns[j].classList.remove('active');
|
||||
}
|
||||
this.classList.add('active');
|
||||
|
||||
if (mode === 'preview') {
|
||||
rendered.style.display = '';
|
||||
source.style.display = 'none';
|
||||
// Show zoom/fullscreen buttons in preview mode
|
||||
for (var k = 0; k < zoomBtns.length; k++) zoomBtns[k].style.display = '';
|
||||
if (fullscreenBtn) fullscreenBtn.style.display = '';
|
||||
} else {
|
||||
rendered.style.display = 'none';
|
||||
source.style.display = '';
|
||||
// Hide zoom/fullscreen in code mode
|
||||
for (var k = 0; k < zoomBtns.length; k++) zoomBtns[k].style.display = 'none';
|
||||
if (fullscreenBtn) fullscreenBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagram type extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractDiagramType(code) {
|
||||
var lines = code.trim().split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
// Skip comments and empty lines
|
||||
if (!line || line.indexOf('%%') === 0) continue;
|
||||
// Return the first meaningful word(s)
|
||||
var words = line.split(/\s+/);
|
||||
if (words.length >= 2) {
|
||||
return words[0] + ' ' + words[1];
|
||||
}
|
||||
return words[0] || 'mermaid';
|
||||
}
|
||||
return 'mermaid';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-render a container after theme change
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function reRenderContainer(container) {
|
||||
var code = container.getAttribute('data-mermaid-code');
|
||||
if (!code) return;
|
||||
|
||||
var rendered = container.querySelector('.mermaid-rendered');
|
||||
if (!rendered) return;
|
||||
|
||||
try {
|
||||
var id = 'mermaid-' + (++_renderCounter) + '-' + Date.now();
|
||||
var result = await mermaid.render(id, preprocessObsidian(code));
|
||||
rendered.innerHTML = result.svg;
|
||||
|
||||
// Reset zoom/pan state on the SVG
|
||||
var svg = rendered.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.transform = '';
|
||||
svg.style.cursor = 'grab';
|
||||
}
|
||||
// Patch beta diagram colors for dark mode
|
||||
if (_currentTheme === 'dark') {
|
||||
var diagramType = extractDiagramType(code);
|
||||
patchBetaColors(container, diagramType);
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep old rendering on error
|
||||
console.warn('[Mermaid] Re-render failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patch beta diagram colors for dark mode (inline SVG attributes beat CSS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function patchBetaColors(container, diagramType) {
|
||||
var svg = container.querySelector('.mermaid-rendered svg');
|
||||
if (!svg) return;
|
||||
|
||||
// packet-beta — override white block backgrounds
|
||||
if (diagramType.indexOf('packet') !== -1) {
|
||||
var rects = svg.querySelectorAll('rect');
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
var r = rects[i];
|
||||
var fill = r.getAttribute('fill');
|
||||
// Only override if the fill is light/white (preserve dark fills)
|
||||
if (!fill || fill === '#f4f4f4' || fill === '#fff' || fill === '#ffffff' || fill === 'white' || fill === '#e8e8e8') {
|
||||
r.setAttribute('fill', '#1e3a5f');
|
||||
r.setAttribute('stroke', '#4a7ab5');
|
||||
}
|
||||
}
|
||||
// Text color
|
||||
var texts = svg.querySelectorAll('text');
|
||||
for (var j = 0; j < texts.length; j++) {
|
||||
var t = texts[j];
|
||||
var tfill = t.getAttribute('fill');
|
||||
if (!tfill || tfill === '#000' || tfill === '#333' || tfill === 'black') {
|
||||
t.setAttribute('fill', '#c8daf0');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xychart-beta — override cream bars and orange line
|
||||
if (diagramType.indexOf('xychart') !== -1) {
|
||||
var allRects = svg.querySelectorAll('rect');
|
||||
for (var k = 0; k < allRects.length; k++) {
|
||||
var rr = allRects[k];
|
||||
var f = rr.getAttribute('fill');
|
||||
if (!f || f === '#f4f4f4' || f === '#fff' || f === '#ffffff' || f === 'white' || f.indexOf('cream') !== -1 || f === '#ececec') {
|
||||
rr.setAttribute('fill', '#1e3a5f');
|
||||
rr.setAttribute('stroke', '#4a7ab5');
|
||||
}
|
||||
}
|
||||
// Line color
|
||||
var paths = svg.querySelectorAll('path');
|
||||
for (var m = 0; m < paths.length; m++) {
|
||||
var p = paths[m];
|
||||
var ps = p.getAttribute('stroke');
|
||||
if (!ps || ps === '#ffa500' || ps === 'orange' || ps === '#ff8c00') {
|
||||
p.setAttribute('stroke', '#6a9ad0');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zoom & Pan (#27)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupZoomPan(container) {
|
||||
var svgWrapper = container.querySelector('.mermaid-rendered');
|
||||
if (!svgWrapper) return;
|
||||
var svg = svgWrapper.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
var scale = 1;
|
||||
var panX = 0, panY = 0;
|
||||
var dragging = false, startX, startY;
|
||||
|
||||
svg.style.transformOrigin = '0 0';
|
||||
svg.style.cursor = 'grab';
|
||||
|
||||
function update() {
|
||||
svg.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
|
||||
}
|
||||
|
||||
svg.addEventListener('mousedown', function(e) {
|
||||
dragging = true;
|
||||
startX = e.clientX - panX;
|
||||
startY = e.clientY - panY;
|
||||
svg.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!dragging) return;
|
||||
panX = e.clientX - startX;
|
||||
panY = e.clientY - startY;
|
||||
update();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
dragging = false;
|
||||
if (svg) svg.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
svg.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
scale = Math.max(0.2, Math.min(5, scale * delta));
|
||||
update();
|
||||
}, { passive: false });
|
||||
|
||||
var zoomIn = container.querySelector('.mermaid-zoom-in');
|
||||
var zoomOut = container.querySelector('.mermaid-zoom-out');
|
||||
if (zoomIn) zoomIn.addEventListener('click', function() { scale = Math.min(5, scale * 1.3); update(); });
|
||||
if (zoomOut) zoomOut.addEventListener('click', function() { scale = Math.max(0.2, scale * 0.7); update(); });
|
||||
|
||||
// Fullscreen (#28)
|
||||
var fsBtn = container.querySelector('.mermaid-fullscreen');
|
||||
if (fsBtn) {
|
||||
fsBtn.addEventListener('click', function() {
|
||||
openFullscreen(container);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen mode (#28) — with header bar + full-size diagram
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openFullscreen(container) {
|
||||
var svgWrapper = container.querySelector('.mermaid-rendered');
|
||||
var diagramType = container.querySelector('.mermaid-diagram-type');
|
||||
var typeLabel = diagramType ? diagramType.textContent : 'mermaid';
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'mermaid-fullscreen-overlay';
|
||||
|
||||
// Fullscreen header bar
|
||||
overlay.innerHTML =
|
||||
'<div class="mermaid-fullscreen-header">' +
|
||||
'<span class="mermaid-diagram-type">' + escapeHtml(typeLabel) + '</span>' +
|
||||
'<div class="mermaid-header-actions">' +
|
||||
'<button class="mermaid-btn mermaid-fs-zoom-in" title="Zoom +"><i data-lucide="zoom-in" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-zoom-out" title="Zoom -"><i data-lucide="zoom-out" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-copy-svg" title="Copier SVG"><i data-lucide="copy" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-copy-code" title="Copier le code Mermaid"><i data-lucide="file-code-2" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-download" title="Telecharger PNG"><i data-lucide="download" style="width:14px;height:14px"></i></button>' +
|
||||
'<button class="mermaid-btn mermaid-fs-close" title="Fermer (Echap)"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mermaid-fullscreen-content"></div>';
|
||||
|
||||
var content = overlay.querySelector('.mermaid-fullscreen-content');
|
||||
content.innerHTML = svgWrapper.innerHTML;
|
||||
var svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.cursor = 'default';
|
||||
}
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Fullscreen zoom
|
||||
var fsScale = 1, fsPanX = 0, fsPanY = 0, fsDragging = false, fsStartX, fsStartY;
|
||||
if (svg) {
|
||||
svg.style.transformOrigin = '0 0';
|
||||
function fsUpdate() {
|
||||
svg.style.transform = 'translate(' + fsPanX + 'px, ' + fsPanY + 'px) scale(' + fsScale + ')';
|
||||
}
|
||||
|
||||
svg.addEventListener('mousedown', function(e) {
|
||||
fsDragging = true;
|
||||
fsStartX = e.clientX - fsPanX;
|
||||
fsStartY = e.clientY - fsPanY;
|
||||
svg.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!fsDragging) return;
|
||||
fsPanX = e.clientX - fsStartX;
|
||||
fsPanY = e.clientY - fsStartY;
|
||||
fsUpdate();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
fsDragging = false;
|
||||
if (svg) svg.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
svg.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
fsScale = Math.max(0.2, Math.min(5, fsScale * delta));
|
||||
fsUpdate();
|
||||
}, { passive: false });
|
||||
|
||||
var fsZoomIn = overlay.querySelector('.mermaid-fs-zoom-in');
|
||||
var fsZoomOut = overlay.querySelector('.mermaid-fs-zoom-out');
|
||||
if (fsZoomIn) fsZoomIn.addEventListener('click', function() { fsScale = Math.min(5, fsScale * 1.3); fsUpdate(); });
|
||||
if (fsZoomOut) fsZoomOut.addEventListener('click', function() { fsScale = Math.max(0.2, fsScale * 0.7); fsUpdate(); });
|
||||
}
|
||||
|
||||
// Copy SVG in fullscreen
|
||||
var fsCopySvg = overlay.querySelector('.mermaid-fs-copy-svg');
|
||||
if (fsCopySvg) {
|
||||
fsCopySvg.addEventListener('click', function() {
|
||||
copySvgFromElement(content);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy code in fullscreen
|
||||
var fsCopyCode = overlay.querySelector('.mermaid-fs-copy-code');
|
||||
if (fsCopyCode) {
|
||||
fsCopyCode.addEventListener('click', function() {
|
||||
var code = container.getAttribute('data-mermaid-code') || '';
|
||||
copyToClipboard(code);
|
||||
});
|
||||
}
|
||||
|
||||
// Download PNG in fullscreen
|
||||
var fsDownload = overlay.querySelector('.mermaid-fs-download');
|
||||
if (fsDownload) {
|
||||
fsDownload.addEventListener('click', function() {
|
||||
exportMermaidPNGFromElement(svg);
|
||||
});
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
var escHandler = function(e) {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
overlay.querySelector('.mermaid-fs-close').addEventListener('click', close);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copy SVG to clipboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function copySvgToClipboard(container) {
|
||||
copySvgFromElement(container.querySelector('.mermaid-rendered'));
|
||||
}
|
||||
|
||||
function copySvgFromElement(el) {
|
||||
if (!el) return;
|
||||
var svg = el.querySelector('svg');
|
||||
if (!svg) return;
|
||||
var data = new XMLSerializer().serializeToString(svg);
|
||||
copyToClipboard(data);
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export SVG/PNG (#24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function exportMermaidSVG(container) {
|
||||
var svg = container.querySelector('.mermaid-rendered svg');
|
||||
if (!svg) return;
|
||||
var data = new XMLSerializer().serializeToString(svg);
|
||||
var blob = new Blob([data], { type: 'image/svg+xml' });
|
||||
downloadBlob(blob, 'diagram.svg');
|
||||
}
|
||||
|
||||
export function exportMermaidPNG(container) {
|
||||
var svg = container.querySelector('.mermaid-rendered svg');
|
||||
exportMermaidPNGFromElement(svg);
|
||||
}
|
||||
|
||||
function exportMermaidPNGFromElement(svg) {
|
||||
if (!svg) return;
|
||||
var data = new XMLSerializer().serializeToString(svg);
|
||||
var canvas = document.createElement('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var img = new Image();
|
||||
var blob = new Blob([data], { type: 'image/svg+xml' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = function() {
|
||||
canvas.width = img.width * 2;
|
||||
canvas.height = img.height * 2;
|
||||
ctx.scale(2, 2);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(function(pngBlob) {
|
||||
downloadBlob(pngBlob, 'diagram.png');
|
||||
}, 'image/png');
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copy text to clipboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(function() {
|
||||
fallbackCopy(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus mode (#29) — click diagram to enlarge in a side panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _focusPanel = null;
|
||||
|
||||
export function setupFocusMode() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var container = e.target.closest('.mermaid-container');
|
||||
if (!container) return;
|
||||
// Don't trigger focus if clicking header or controls
|
||||
if (e.target.closest('.mermaid-header') || e.target.closest('button')) return;
|
||||
|
||||
var svgWrapper = container.querySelector('.mermaid-rendered');
|
||||
if (!svgWrapper) return;
|
||||
|
||||
if (_focusPanel && _focusPanel.style.display !== 'none') {
|
||||
updateFocusPanel(svgWrapper);
|
||||
} else {
|
||||
showFocusPanel(svgWrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showFocusPanel(svgWrapper) {
|
||||
if (!_focusPanel) {
|
||||
_focusPanel = document.createElement('div');
|
||||
_focusPanel.className = 'mermaid-focus-panel';
|
||||
_focusPanel.innerHTML =
|
||||
'<div class="mermaid-focus-header">' +
|
||||
'<span>Diagramme</span>' +
|
||||
'<button class="mermaid-focus-close" title="Fermer"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="mermaid-focus-content"></div>';
|
||||
document.body.appendChild(_focusPanel);
|
||||
|
||||
_focusPanel.querySelector('.mermaid-focus-close').addEventListener('click', function() {
|
||||
_focusPanel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
_focusPanel.style.display = 'block';
|
||||
updateFocusPanel(svgWrapper);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
function updateFocusPanel(svgWrapper) {
|
||||
if (!_focusPanel) return;
|
||||
var content = _focusPanel.querySelector('.mermaid-focus-content');
|
||||
content.innerHTML = svgWrapper.innerHTML;
|
||||
var svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = 'auto';
|
||||
svg.style.maxHeight = '80vh';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Obsidian compatibility pre-processor (#24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function preprocessObsidian(code) {
|
||||
return code
|
||||
.replace(/!\[\[([^\]]+?)\]\]/g, function(_, m) {
|
||||
var parts = m.split('|');
|
||||
var src = parts[0].trim();
|
||||
var width = parts[1] ? parts[1].trim() : null;
|
||||
if (width && /^\d+$/.test(width)) {
|
||||
return '<img src="' + src + '" width="' + width + '" />';
|
||||
}
|
||||
return '<img src="' + src + '" />';
|
||||
})
|
||||
.replace(/\[\[([^\]]+?)\]\]/g, '<a href="$1">$1</a>')
|
||||
.replace(/==([^=]+)==/g, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/* ObsiGate — Mobile Bottom Toolbar (Obsidian-style)
|
||||
Phases 1-5: fixed bar, vault home, file palette, commands, tab switcher */
|
||||
import { state } from './state.js';
|
||||
import { TabManager } from './ui.js';
|
||||
|
||||
// ── Open file palette (search files) ──
|
||||
function openFilePalette() {
|
||||
import('./palette.js').then(m => { m.open(''); });
|
||||
}
|
||||
|
||||
// ── Open command palette ──
|
||||
function openCommandPalette() {
|
||||
import('./palette.js').then(m => { m.open('> '); });
|
||||
}
|
||||
|
||||
// ── Tab switcher ──
|
||||
function openTabSwitcher() {
|
||||
const sheet = document.getElementById('mt-tab-switcher');
|
||||
const overlay = document.getElementById('mt-overlay');
|
||||
if (!sheet || !overlay) return;
|
||||
|
||||
renderTabList();
|
||||
sheet.classList.add('open');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
||||
function closeTabSwitcher() {
|
||||
const sheet = document.getElementById('mt-tab-switcher');
|
||||
const overlay = document.getElementById('mt-overlay');
|
||||
if (sheet) sheet.classList.remove('open');
|
||||
if (overlay) overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
function renderTabList() {
|
||||
const list = document.getElementById('mt-tab-list');
|
||||
if (!list) return;
|
||||
|
||||
const tabs = TabManager._tabs || [];
|
||||
const activeId = TabManager._activeTabId;
|
||||
|
||||
if (tabs.length === 0) {
|
||||
list.innerHTML = '<div class="cp-empty" style="padding:20px;text-align:center">Aucun fichier ouvert</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = tabs.map(t => {
|
||||
const isActive = t.id === activeId;
|
||||
const icon = t.path.endsWith('.md') ? '📄' : '📝';
|
||||
return `<div class="mt-tab-item${isActive ? ' active' : ''}" data-tab-id="${t.id}">
|
||||
<span class="mt-tab-item-icon">${icon}</span>
|
||||
<span class="mt-tab-item-name">${t.title || t.path.split('/').pop()}</span>
|
||||
<button class="mt-tab-item-close" data-close="${t.id}" aria-label="Fermer">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.mt-tab-item').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.mt-tab-item-close')) return;
|
||||
const tabId = el.dataset.tabId;
|
||||
if (tabId) {
|
||||
TabManager.activate(tabId);
|
||||
closeTabSwitcher();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.mt-tab-item-close').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const tabId = btn.dataset.close;
|
||||
if (tabId) {
|
||||
TabManager.close(tabId);
|
||||
setTimeout(() => renderTabList(), 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateTabCount() {
|
||||
const badge = document.getElementById('mt-tab-count');
|
||||
if (!badge) return;
|
||||
const count = (TabManager._tabs || []).length;
|
||||
if (count > 0) {
|
||||
badge.style.display = 'flex';
|
||||
badge.textContent = count;
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vault home / sidebar toggle ──
|
||||
function toggleExplorer() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) {
|
||||
if (sidebar.classList.contains('mobile-open')) {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
} else {
|
||||
sidebar.classList.add('mobile-open');
|
||||
}
|
||||
}
|
||||
if (!state.currentVault && state.allVaults && state.allVaults.length > 0) {
|
||||
import('./sidebar.js').then(m => {
|
||||
if (m.setSelectedVaultContext) m.setSelectedVaultContext(state.allVaults[0].name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
export function initMobileToolbar() {
|
||||
const explorerBtn = document.getElementById('mt-explorer');
|
||||
const searchBtn = document.getElementById('mt-search');
|
||||
const paletteBtn = document.getElementById('mt-palette');
|
||||
const tabsBtn = document.getElementById('mt-tabs');
|
||||
const sheetClose = document.getElementById('mt-sheet-close');
|
||||
const closeAll = document.getElementById('mt-close-all');
|
||||
const overlay = document.getElementById('mt-overlay');
|
||||
|
||||
if (explorerBtn) explorerBtn.addEventListener('click', toggleExplorer);
|
||||
if (searchBtn) searchBtn.addEventListener('click', openFilePalette);
|
||||
if (paletteBtn) paletteBtn.addEventListener('click', openCommandPalette);
|
||||
if (tabsBtn) tabsBtn.addEventListener('click', openTabSwitcher);
|
||||
if (sheetClose) sheetClose.addEventListener('click', closeTabSwitcher);
|
||||
if (closeAll) closeAll.addEventListener('click', () => {
|
||||
const tabs = [...(TabManager._tabs || [])];
|
||||
tabs.forEach(t => TabManager.close(t.id));
|
||||
updateTabCount();
|
||||
renderTabList();
|
||||
});
|
||||
if (overlay) overlay.addEventListener('click', closeTabSwitcher);
|
||||
|
||||
setInterval(updateTabCount, 2000);
|
||||
updateTabCount();
|
||||
|
||||
// ── Vault context indicator ──
|
||||
function updateVaultPill() {
|
||||
const pill = document.getElementById('vault-context-pill');
|
||||
if (!pill) return;
|
||||
const vaultName = state.selectedContextVault || 'all';
|
||||
if (vaultName === 'all') {
|
||||
pill.textContent = t("mobile.all_vaults");
|
||||
} else {
|
||||
pill.textContent = '📂 ' + vaultName;
|
||||
}
|
||||
}
|
||||
updateVaultPill();
|
||||
setInterval(updateVaultPill, 1500);
|
||||
|
||||
window._mtUpdateTabs = updateTabCount;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/* ObsiGate — Offline IndexedDB Storage
|
||||
* Stores: file index, file contents, pending changes queue
|
||||
* Schema v1:
|
||||
* files store: { path, vault, title, tags[], snippet, modified, tokens[], weight }
|
||||
* content store: { key: "vault::path", content: string, modified }
|
||||
* pending store: { id, vault, path, content, action, timestamp }
|
||||
*/
|
||||
|
||||
const DB_NAME = 'obsigate-offline';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let _db = null;
|
||||
let _dbPromise = null;
|
||||
|
||||
export const OfflineDB = {
|
||||
|
||||
async open() {
|
||||
if (_db) return _db;
|
||||
if (_dbPromise) return _dbPromise;
|
||||
|
||||
_dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
console.log('[OfflineDB] Upgrading to v' + DB_VERSION);
|
||||
|
||||
// File index store — keyed by composite "vault::path"
|
||||
if (!db.objectStoreNames.contains('files')) {
|
||||
const store = db.createObjectStore('files', { keyPath: 'key' });
|
||||
store.createIndex('vault', 'vault', { unique: false });
|
||||
store.createIndex('modified', 'modified', { unique: false });
|
||||
store.createIndex('title', 'title', { unique: false });
|
||||
}
|
||||
|
||||
// File contents store — keyed by "vault::path"
|
||||
if (!db.objectStoreNames.contains('content')) {
|
||||
db.createObjectStore('content', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// Pending changes queue — auto-increment key
|
||||
if (!db.objectStoreNames.contains('pending')) {
|
||||
const store = db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = (e) => {
|
||||
_db = e.target.result;
|
||||
console.log('[OfflineDB] Database opened');
|
||||
resolve(_db);
|
||||
};
|
||||
|
||||
req.onerror = () => {
|
||||
_dbPromise = null;
|
||||
reject(req.error);
|
||||
};
|
||||
});
|
||||
|
||||
return _dbPromise;
|
||||
},
|
||||
|
||||
/* ── File Index ── */
|
||||
|
||||
async putFileIndex(file) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('files', 'readwrite');
|
||||
const store = tx.objectStore('files');
|
||||
const key = file.vault + '::' + file.path;
|
||||
const doc = { key: key, vault: file.vault, path: file.path,
|
||||
title: file.title, tags: file.tags || [], snippet: file.snippet || '',
|
||||
modified: file.modified || '', tokens: file.tokens || [], weight: file.score || 0 };
|
||||
store.put(doc);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async putFileIndexBatch(files) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('files', 'readwrite');
|
||||
const store = tx.objectStore('files');
|
||||
for (const file of files) {
|
||||
const key = file.vault + '::' + file.path;
|
||||
store.put({
|
||||
key: key, vault: file.vault, path: file.path,
|
||||
title: file.title, tags: file.tags || [], snippet: file.snippet || '',
|
||||
modified: file.modified || '', tokens: file.tokens || [], score: file.score || 0
|
||||
});
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async getFileIndex(vault, path) {
|
||||
const db = await this.open();
|
||||
const key = vault + '::' + path;
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readonly');
|
||||
const store = tx.objectStore('files');
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
},
|
||||
|
||||
/* Search in file index — title matching and prefix */
|
||||
async searchFileIndex(query, vaultFilter) {
|
||||
const db = await this.open();
|
||||
const lowerQ = query.toLowerCase();
|
||||
const words = lowerQ.split(/\s+/).filter(Boolean);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readonly');
|
||||
const store = tx.objectStore('files');
|
||||
const results = [];
|
||||
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) {
|
||||
// Sort by relevance: title match > path match > snippet match
|
||||
results.sort((a, b) => b._score - a._score);
|
||||
resolve(results.slice(0, 50));
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = cursor.value;
|
||||
|
||||
// Vault filter
|
||||
if (vaultFilter && vaultFilter !== 'all' && doc.vault !== vaultFilter) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Score based on match quality
|
||||
let score = 0;
|
||||
const titleLower = doc.title.toLowerCase();
|
||||
const pathLower = doc.path.toLowerCase();
|
||||
const snippetLower = (doc.snippet || '').toLowerCase();
|
||||
|
||||
for (const word of words) {
|
||||
if (titleLower.includes(word)) score += 10;
|
||||
else if (pathLower.includes(word)) score += 5;
|
||||
else if (snippetLower.includes(word)) score += 2;
|
||||
}
|
||||
|
||||
// Full query match bonus
|
||||
if (titleLower.includes(lowerQ)) score += 5;
|
||||
if (pathLower === lowerQ) score += 8;
|
||||
|
||||
if (score > 0) {
|
||||
results.push({
|
||||
vault: doc.vault,
|
||||
path: doc.path,
|
||||
title: doc.title,
|
||||
tags: doc.tags,
|
||||
snippet: doc.snippet,
|
||||
score: score,
|
||||
modified: doc.modified,
|
||||
_score: score
|
||||
});
|
||||
}
|
||||
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async getAllFileIndex(vaultFilter) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readonly');
|
||||
const store = tx.objectStore('files');
|
||||
const results = [];
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { resolve(results); return; }
|
||||
if (!vaultFilter || vaultFilter === 'all' || cursor.value.vault === vaultFilter) {
|
||||
results.push(cursor.value);
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async clearFileIndex(vaultFilter) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('files', 'readwrite');
|
||||
const store = tx.objectStore('files');
|
||||
if (!vaultFilter || vaultFilter === 'all') {
|
||||
store.clear().onsuccess = () => resolve();
|
||||
} else {
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { resolve(); return; }
|
||||
if (cursor.value.vault === vaultFilter) cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ── File Contents ── */
|
||||
|
||||
async putFileContent(vault, path, content, modified) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('content', 'readwrite');
|
||||
const store = tx.objectStore('content');
|
||||
const key = vault + '::' + path;
|
||||
store.put({ key: key, content: content, modified: modified || '' });
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async getFileContent(vault, path) {
|
||||
const db = await this.open();
|
||||
const key = vault + '::' + path;
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('content', 'readonly');
|
||||
const store = tx.objectStore('content');
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
},
|
||||
|
||||
async removeFileContent(vault, path) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('content', 'readwrite');
|
||||
const store = tx.objectStore('content');
|
||||
const key = vault + '::' + path;
|
||||
store.delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
});
|
||||
},
|
||||
|
||||
/* ── Pending Changes Queue ── */
|
||||
|
||||
async addPending(entry) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('pending', 'readwrite');
|
||||
const store = tx.objectStore('pending');
|
||||
const doc = {
|
||||
vault: entry.vault,
|
||||
path: entry.path,
|
||||
content: entry.content || '',
|
||||
action: entry.action || 'save',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
const req = store.add(doc);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
},
|
||||
|
||||
async getPendingCount() {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readonly');
|
||||
const store = tx.objectStore('pending');
|
||||
store.count().onsuccess = (e) => resolve(e.target.result);
|
||||
});
|
||||
},
|
||||
|
||||
async getAllPending() {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readonly');
|
||||
const store = tx.objectStore('pending');
|
||||
const results = [];
|
||||
const index = store.index('timestamp');
|
||||
index.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { resolve(results); return; }
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async clearPending() {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readwrite');
|
||||
tx.objectStore('pending').clear().onsuccess = () => resolve();
|
||||
});
|
||||
},
|
||||
|
||||
async removePendingById(id) {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('pending', 'readwrite');
|
||||
tx.objectStore('pending').delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
});
|
||||
},
|
||||
|
||||
/* ── Stats ── */
|
||||
|
||||
async getStats() {
|
||||
const db = await this.open();
|
||||
const fileCount = await new Promise(r => {
|
||||
db.transaction('files','readonly').objectStore('files').count().onsuccess = e => r(e.target.result);
|
||||
});
|
||||
const contentCount = await new Promise(r => {
|
||||
db.transaction('content','readonly').objectStore('content').count().onsuccess = e => r(e.target.result);
|
||||
});
|
||||
const pendingCount = await this.getPendingCount();
|
||||
return { fileCount, contentCount, pendingCount };
|
||||
},
|
||||
|
||||
/* ── Full Sync ── */
|
||||
|
||||
async syncFileIndex(serverFiles) {
|
||||
// serverFiles: array of {vault,path,title,tags,snippet,modified,tokens}
|
||||
await this.clearFileIndex(null);
|
||||
await this.putFileIndexBatch(serverFiles);
|
||||
console.log('[OfflineDB] File index synced:', serverFiles.length, 'files');
|
||||
},
|
||||
|
||||
async pruneContentCache(maxEntries) {
|
||||
maxEntries = maxEntries || 50;
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction('content', 'readwrite');
|
||||
const store = tx.objectStore('content');
|
||||
const keys = [];
|
||||
store.openCursor().onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) {
|
||||
// Delete oldest if over maxEntries
|
||||
if (keys.length > maxEntries) {
|
||||
const toDelete = keys.slice(0, keys.length - maxEntries);
|
||||
for (const k of toDelete) store.delete(k);
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
keys.push(cursor.value.key);
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Network Status ── */
|
||||
let _online = navigator.onLine;
|
||||
let _listeners = [];
|
||||
|
||||
export const NetworkStatus = {
|
||||
get isOnline() { return _online; },
|
||||
|
||||
init() {
|
||||
window.addEventListener('online', () => {
|
||||
_online = true;
|
||||
_listeners.forEach(fn => fn(true));
|
||||
console.log('[Network] Online');
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
_online = false;
|
||||
_listeners.forEach(fn => fn(false));
|
||||
console.log('[Network] Offline');
|
||||
});
|
||||
},
|
||||
|
||||
onChange(fn) {
|
||||
_listeners.push(fn);
|
||||
return () => { _listeners = _listeners.filter(f => f !== fn); };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
/* ObsiGate — Offline Integration Module
|
||||
* Ties together: IndexedDB, offline search, sync queue, UI indicator
|
||||
*/
|
||||
import { OfflineDB, NetworkStatus } from './offline-db.js';
|
||||
import { state } from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
/* ── Init ── */
|
||||
export async function initOffline() {
|
||||
NetworkStatus.init();
|
||||
|
||||
// Open DB early
|
||||
try {
|
||||
await OfflineDB.open();
|
||||
console.log('[Offline] IndexedDB ready');
|
||||
} catch (e) {
|
||||
console.warn('[Offline] IndexedDB not available:', e);
|
||||
}
|
||||
|
||||
NetworkStatus.onChange((online) => {
|
||||
updateOfflineIndicator();
|
||||
if (online) {
|
||||
syncPendingChanges();
|
||||
showToast(t('offline.reconnected'), 'info');
|
||||
} else {
|
||||
showToast(t('offline.mode_offline'), 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
updateOfflineIndicator();
|
||||
}
|
||||
|
||||
/* ── UI Indicator ── */
|
||||
function updateOfflineIndicator() {
|
||||
const badge = document.getElementById('offline-badge');
|
||||
if (!badge) return;
|
||||
|
||||
if (!NetworkStatus.isOnline) {
|
||||
// Build or show indicator
|
||||
badge.innerHTML = replaceLucideIcon(badge, 'wifi-off',
|
||||
'<i data-lucide="wifi-off" style="width:14px;height:14px"></i>');
|
||||
badge.title = 'Mode hors-ligne';
|
||||
badge.style.display = 'flex';
|
||||
badge.className = 'header-icon offline-indicator offline';
|
||||
|
||||
// Add pending count if any
|
||||
OfflineDB.getPendingCount().then(count => {
|
||||
if (count > 0) {
|
||||
const span = badge.querySelector('.offline-pending') || document.createElement('span');
|
||||
span.className = 'offline-pending';
|
||||
span.textContent = String(count);
|
||||
if (!badge.contains(span)) badge.appendChild(span);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
badge.className = 'header-icon offline-indicator';
|
||||
}
|
||||
}
|
||||
|
||||
function replaceLucideIcon(container, iconName, html) {
|
||||
const existing = container.querySelector('i[data-lucide]');
|
||||
if (existing && existing.getAttribute('data-lucide') === iconName) return container.innerHTML;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Sync File Index from Server ── */
|
||||
export async function syncFileIndexFromServer() {
|
||||
if (!NetworkStatus.isOnline) {
|
||||
console.log('[Offline] Skipping index sync — offline');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Use a wildcard query to get recent files and populate offline index
|
||||
const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200');
|
||||
if (!resp.ok) {
|
||||
console.warn('[Offline] Index sync failed — API returned', resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const files = (data.results || []).map(r => ({
|
||||
vault: r.vault,
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
tags: r.tags || [],
|
||||
snippet: r.snippet || '',
|
||||
modified: r.modified || '',
|
||||
tokens: []
|
||||
}));
|
||||
if (files.length > 0) {
|
||||
await OfflineDB.syncFileIndex(files);
|
||||
console.log('[Offline] Index synced:', files.length, 'files');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Offline] Index sync failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cache File When Viewed ── */
|
||||
export async function cacheViewedFile(vault, path, content, modified) {
|
||||
if (!vault || !path) return;
|
||||
try {
|
||||
await OfflineDB.putFileContent(vault, path, content, modified);
|
||||
await OfflineDB.pruneContentCache(50);
|
||||
} catch (e) {
|
||||
console.warn('[Offline] Cache file failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Offline Search ── */
|
||||
export async function searchOffline(query, vaultFilter) {
|
||||
try {
|
||||
return await OfflineDB.searchFileIndex(query, vaultFilter || state.currentVault);
|
||||
} catch (e) {
|
||||
console.error('[Offline] Search failed:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Get Cached File ── */
|
||||
export async function getCachedFile(vault, path) {
|
||||
try {
|
||||
return await OfflineDB.getFileContent(vault, path);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pending Changes Queue ── */
|
||||
let _syncing = false;
|
||||
|
||||
export async function queueOfflineSave(vault, path, content) {
|
||||
try {
|
||||
const id = await OfflineDB.addPending({ vault, path, content, action: 'save' });
|
||||
updateOfflineIndicator();
|
||||
console.log('[Offline] Queued save:', vault, path, 'id=', id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.error('[Offline] Queue save failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPendingChanges() {
|
||||
if (!NetworkStatus.isOnline || _syncing) return;
|
||||
_syncing = true;
|
||||
|
||||
try {
|
||||
const pending = await OfflineDB.getAllPending();
|
||||
if (pending.length === 0) {
|
||||
_syncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Offline] Syncing', pending.length, 'pending changes...');
|
||||
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
const resp = await fetch('/api/file/' + encodeURIComponent(entry.vault) + '/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: entry.path, content: entry.content })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
await OfflineDB.removePendingById(entry.id);
|
||||
console.log('[Offline] Synced:', entry.vault, entry.path);
|
||||
} else if (resp.status === 409) {
|
||||
// Conflict — keep in queue for manual resolution
|
||||
console.warn('[Offline] Conflict on:', entry.vault, entry.path);
|
||||
} else {
|
||||
console.error('[Offline] Sync failed for:', entry.vault, entry.path, resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Offline] Sync error for:', entry.vault, entry.path, e);
|
||||
break; // Stop on network error
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await OfflineDB.getPendingCount();
|
||||
console.log('[Offline] Sync complete. Remaining:', remaining);
|
||||
updateOfflineIndicator();
|
||||
|
||||
if (remaining === 0) {
|
||||
showToast(t('offline.all_synced'), 'success');
|
||||
} else {
|
||||
showToast(remaining + ' modification(s) en attente de resolution', 'warning');
|
||||
}
|
||||
} finally {
|
||||
_syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Conflict Detection ── */
|
||||
export async function checkConflict(vault, path, localModified) {
|
||||
if (!NetworkStatus.isOnline) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/file/' + encodeURIComponent(vault) + '/info?path=' + encodeURIComponent(path));
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (data.modified && data.modified !== localModified) {
|
||||
return { serverModified: data.modified, localModified };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "module"}
|
||||
@@ -0,0 +1,590 @@
|
||||
/* ObsiGate — Command Palette (Ctrl+Shift+Espace / Ctrl+Alt+Espace) */
|
||||
import { api } from './auth.js';
|
||||
import { showToast, toggleTheme, TabManager } from './ui.js';
|
||||
import { openEditor } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
function triggerGoHome() { const e = document.getElementById('header-logo'); if (e) e.click(); }
|
||||
|
||||
function getCurrentFile() {
|
||||
const id = TabManager._activeTabId;
|
||||
if (id) { const t = TabManager._tabs.find(x => x.id === id); if (t) return { vault: t.vault, path: t.path }; }
|
||||
if (state.currentVault && state.currentPath) return { vault: state.currentVault, path: state.currentPath };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Query parser: +word = required, -word = exclude, word = required ──
|
||||
function parseQuery(raw) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
const parts = (raw || '').split(/\s+/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
else if (p.startsWith('+')) positive.push(p.slice(1).toLowerCase());
|
||||
else positive.push(p.toLowerCase());
|
||||
}
|
||||
return { positive, negative };
|
||||
}
|
||||
|
||||
// ── DOM / state ──
|
||||
let _overlay = null, _input = null, _results = null, _mode = 'files', _sel = 0, _to = null, _items = [];
|
||||
let _browse = { action: null, vault: null, path: '' };
|
||||
let _browseFilterEl = null, _browseFilter = '', _allItems = [];
|
||||
let _browseFilterTimer = null; // debounce for global search
|
||||
|
||||
// ── Commands ── (lazy: built on first access so i18n is ready)
|
||||
let _cmdsCache = null;
|
||||
function getCMDS() {
|
||||
if (_cmdsCache) return _cmdsCache;
|
||||
_cmdsCache = [
|
||||
{ id:'home', label:t('palette.cmd_back_home'), desc:t('palette.cmd_back_home_desc'), cat:'Navigation', act:()=>{close();triggerGoHome();} },
|
||||
{ id:'edit-current', label:'✏️ '+t('editor.edit'), desc:t('editor.edit_current_desc'), cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f)openEditor(f.vault,f.path);else showToast(t('toast.no_open_file'),'error');} },
|
||||
{ id:'popout', label:'🪟 Pop-out', desc:t('palette.popout_desc'), cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f)window.open(`/popout/${encodeURIComponent(f.vault)}/${encodeURIComponent(f.path)}?theme=${document.documentElement.getAttribute('data-theme')||'dark'}`,'popout','width=1000,height=700');else showToast(t('toast.no_open_file'),'error');} },
|
||||
{ id:'create-file', label:t('palette.cmd_create_file'), desc:t('palette.cmd_create_file_desc'), cat:'Fichiers', act:()=>startBrowse('create-file') },
|
||||
{ id:'delete-file', label:'🗑️ '+t('file.delete'), desc:t('palette.delete_file_desc'), cat:'Fichiers', act:()=>startBrowse('delete-file') },
|
||||
{ id:'delete-cur', label:'🗑️ '+t('palette.delete_current'), desc:t('palette.delete_current_desc'), cat:'Fichiers', act:async()=>{close();await delCurFile();} },
|
||||
{ id:'close-cur', label:'✕ '+t('palette.close_current'), desc:t('palette.close_current_desc'), cat:'Fichiers', act:()=>{close();const f=getCurrentFile();if(f){const tab=TabManager._tabs.find(t=>t.vault===f.vault&&t.path===f.path);if(tab)TabManager.close(tab.id);else triggerGoHome();}else{showToast(t('toast.no_open_file'),'error');}} },
|
||||
{ id:'create-dir', label:t('palette.cmd_create_dir'), desc:t('palette.cmd_create_dir_desc'), cat:'Répertoires', act:()=>startBrowse('create-dir') },
|
||||
{ id:'delete-dir', label:t('palette.cmd_delete_dir'), desc:t('palette.cmd_delete_dir_desc'), cat:'Répertoires', act:()=>startBrowse('delete-dir') },
|
||||
{ id:'theme', label:t('palette.cmd_theme'), desc:t('palette.cmd_theme_desc'), cat:'Actions', act:()=>{close();toggleTheme();} },
|
||||
{ id:'reindex', label:'🔄 '+t('vault.reindex'), desc:t('palette.reindex_desc'), cat:'Actions', act:async()=>{close();try{await api('/api/index/reload');showToast(t('sync.index_reloaded'),'success');}catch{showToast(t('common.error'),'error');}} },
|
||||
{ id:'help', label:'❓ '+t('help.help_title'), desc:t('palette.help_desc'), cat:'Système', act:()=>{close();const b=document.getElementById('help-open-btn');if(b)b.click();} },
|
||||
{ id:'config', label:t('palette.cmd_config'), desc:t('palette.cmd_config_desc'), cat:'Système', act:()=>{close();const b=document.getElementById('config-open-btn');if(b)b.click();} },
|
||||
];
|
||||
return _cmdsCache;
|
||||
}
|
||||
|
||||
// ── Browse: vault picker ──
|
||||
function startBrowse(action) {
|
||||
_browse = { action, vault: null, path: '' };
|
||||
_browseFilter = ''; _allItems = [];
|
||||
switchMode('browse-vault');
|
||||
renderVaultPicker();
|
||||
}
|
||||
|
||||
async function renderVaultPicker() {
|
||||
try {
|
||||
const data = await api('/api/vaults');
|
||||
const vaults = Array.isArray(data) ? data : [];
|
||||
_items = vaults.map(v => ({ type:'_vault', vault: v.name, label: (v.type==='DIR'?'📂':'🗂️')+' '+v.name, sublabel: t('palette.vault_count', {count: v.file_count}) }));
|
||||
if (_items.length === 0) {
|
||||
_items = [{ type:'_empty', label: t('palette.no_vaults'), sublabel: '' }];
|
||||
}
|
||||
_allItems = _items;
|
||||
render();
|
||||
} catch { _items = [{ type:'_error', label: t('palette.error_loading'), sublabel: '' }]; _allItems = _items; render(); }
|
||||
}
|
||||
|
||||
// ── Browse: directory navigation ──
|
||||
async function renderBrowse() {
|
||||
const { vault, path } = _browse;
|
||||
_items = [];
|
||||
const bc = document.getElementById('cp-breadcrumb');
|
||||
if (bc) {
|
||||
bc.style.display = 'flex';
|
||||
const segs = path ? path.split('/') : [];
|
||||
let html = `<span class="cp-bc-item cp-bc-root" data-vault="1">${esc(vault)}</span>`;
|
||||
let acc = '';
|
||||
for (const s of segs) { acc = acc ? acc+'/'+s : s; html += `<span class="cp-bc-sep">/</span><span class="cp-bc-item" data-path="${esc(acc)}">${esc(s)}</span>`; }
|
||||
bc.innerHTML = html;
|
||||
bc.querySelectorAll('.cp-bc-item[data-path]').forEach(el => el.addEventListener('click', ()=>{ _browse.path=el.dataset.path; _browseFilter=''; if(_browseFilterEl)_browseFilterEl.value=''; renderBrowse(); }));
|
||||
bc.querySelector('.cp-bc-root').addEventListener('click', ()=>{ _browse.path=''; _browseFilter=''; if(_browseFilterEl)_browseFilterEl.value=''; renderBrowse(); });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`);
|
||||
const items = data.items || [];
|
||||
const dirs = items.filter(i => i.type === 'directory' || i.is_dir);
|
||||
const files = items.filter(i => i.type === 'file' && !i.is_dir);
|
||||
|
||||
// Action row
|
||||
const actLabel = {
|
||||
'create-file': t('palette.cmd_create_file'),
|
||||
'create-dir': t('palette.cmd_create_dir'),
|
||||
'delete-dir': t('palette.cmd_delete_dir'),
|
||||
'delete-file': t('palette.delete_file_desc'),
|
||||
'open-file': t('palette.browse_open_file')
|
||||
};
|
||||
const isDeleteDir = _browse.action === 'delete-dir' && path;
|
||||
const isDeleteFile = _browse.action === 'delete-file';
|
||||
const isOpenFile = _browse.action === 'open-file';
|
||||
const needsName = _browse.action === 'create-file' || _browse.action === 'create-dir';
|
||||
|
||||
if (isDeleteDir) {
|
||||
_items.push({ type:'_action', label: '🗑️ '+t('file.delete')+' '+(path.split('/').pop()||path), sublabel: t('palette.dir_delete_confirm'), act:()=>execDelDir(path) });
|
||||
} else if (needsName) {
|
||||
_items.push({ type:'_action', label: actLabel[_browse.action]||'', sublabel: t('palette.browse_in', {path: path||t('palette.browse_root')}), act:()=>askName() });
|
||||
}
|
||||
|
||||
// Parent
|
||||
if (path) {
|
||||
_items.push({ type:'_nav', label: '📂 .. (parent)', go: path.split('/').slice(0,-1).join('/') });
|
||||
}
|
||||
|
||||
// Subdirs
|
||||
for (const d of dirs) {
|
||||
const fp = path ? path+'/'+d.name : d.name;
|
||||
_items.push({ type:'_nav', label: '📁 '+d.name, go: fp });
|
||||
}
|
||||
|
||||
// Files
|
||||
for (const f of files) {
|
||||
const fp = path ? path+'/'+f.name : f.name;
|
||||
if (isDeleteFile) {
|
||||
_items.push({ type:'_file', label: '📄 '+f.name, sublabel: f.vault+'/'+fp, act:()=>execDelFile(fp) });
|
||||
} else if (isOpenFile) {
|
||||
_items.push({ type:'_file', label: '📄 '+f.name, sublabel: f.vault+'/'+fp, act:()=>{ close(); TabManager.openPersistent(vault, fp); } });
|
||||
} else {
|
||||
_items.push({ type:'_info', label: '📄 '+f.name, sublabel: fp });
|
||||
}
|
||||
}
|
||||
|
||||
if (_items.length === 0) _items.push({ type:'_empty', label: t('palette.empty_dir'), sublabel: '' });
|
||||
} catch { _items = [{ type:'_error', label: t('common.error'), sublabel: '' }]; }
|
||||
|
||||
_allItems = _items;
|
||||
applyBrowseFilter();
|
||||
}
|
||||
|
||||
// ── Browse filter: local when empty, global suggest when text ──
|
||||
function applyBrowseFilter() {
|
||||
const q = _browseFilter.trim();
|
||||
if (!q) {
|
||||
// Empty filter: show local directory items
|
||||
_items = _allItems;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
// Has filter text: debounce and search globally
|
||||
if (_browseFilterTimer) clearTimeout(_browseFilterTimer);
|
||||
_browseFilterTimer = setTimeout(() => searchBrowseGlobal(q), 200);
|
||||
}
|
||||
|
||||
async function searchBrowseGlobal(rawQ) {
|
||||
const { positive, negative } = parseQuery(rawQ);
|
||||
const action = _browse.action;
|
||||
const isDeleteFile = action === 'delete-file';
|
||||
const isOpenFile = action === 'open-file';
|
||||
const isDeleteDir = action === 'delete-dir';
|
||||
const needsName = action === 'create-file' || action === 'create-dir';
|
||||
|
||||
try {
|
||||
const q = positive.join(' ');
|
||||
const vault = _browse.vault || 'all';
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=${encodeURIComponent(vault)}`);
|
||||
const sug = (d.suggestions || []).filter(s => {
|
||||
const fullPath = (s.vault+'/'+s.path).toLowerCase();
|
||||
// Client-side negative filter
|
||||
for (const n of negative) {
|
||||
if (fullPath.includes(n)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_empty', label: t('palette.no_results', {query: rawQ}), sublabel: '' }];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
_items = [];
|
||||
|
||||
// Extract unique directories from result paths
|
||||
const dirSet = new Set();
|
||||
for (const s of sug) {
|
||||
const parts = s.path.split('/');
|
||||
// Build parent directories
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
dirSet.add(s.vault + '|' + parts.slice(0, i + 1).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Show directories as navigable
|
||||
const dirs = [...dirSet].map(d => {
|
||||
const [v, p] = d.split('|');
|
||||
const name = p.split('/').pop();
|
||||
return { type:'_nav', label:'📁 '+name, sublabel:v+'/'+p, go:p, vault:v };
|
||||
}).sort((a,b) => a.sublabel.localeCompare(b.sublabel));
|
||||
|
||||
for (const d of dirs) _items.push(d);
|
||||
|
||||
// Show files
|
||||
for (const s of sug) {
|
||||
const fp = s.path;
|
||||
const name = fp.split('/').pop();
|
||||
if (isDeleteFile) {
|
||||
_items.push({ type:'_file', label:'📄 '+name, sublabel:s.vault+'/'+fp, act:()=>execDelFileBrowsed(s.vault, fp) });
|
||||
} else if (isOpenFile) {
|
||||
_items.push({ type:'_file', label:'📄 '+name, sublabel:s.vault+'/'+fp, act:()=>{ close(); TabManager.openPersistent(s.vault, fp); } });
|
||||
} else if (isDeleteDir) {
|
||||
_items.push({ type:'_file', label:'📁 '+name, sublabel:s.vault+'/'+fp, act:()=>execDelDirBrowsed(s.vault, fp) });
|
||||
} else {
|
||||
_items.push({ type:'_file', label:'📄 '+name, sublabel:s.vault+'/'+fp, act:()=>{ close(); TabManager.openPersistent(s.vault, fp); } });
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
} catch { _items = [{ type:'_error', label: t('palette.search_error'), sublabel: '' }]; render(); }
|
||||
}
|
||||
|
||||
// Navigate to a directory from global search
|
||||
function execNavGlobal(vault, goPath) {
|
||||
_browse.vault = vault;
|
||||
_browse.path = goPath;
|
||||
_browseFilter = '';
|
||||
if (_browseFilterEl) _browseFilterEl.value = '';
|
||||
renderBrowse();
|
||||
}
|
||||
|
||||
async function execDelFileBrowsed(vault, path) {
|
||||
if (!confirm(t("confirm.delete_file_named", {path: vault + "/" + path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execDelDirBrowsed(vault, path) {
|
||||
if (!confirm(t("confirm.delete_dir_named", {path: path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
function onBrowseFilterInput() {
|
||||
_browseFilter = _browseFilterEl ? _browseFilterEl.value : '';
|
||||
applyBrowseFilter();
|
||||
}
|
||||
|
||||
function onBrowseFilterKey(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (_browseFilter) {
|
||||
_browseFilter = '';
|
||||
if (_browseFilterEl) _browseFilterEl.value = '';
|
||||
applyBrowseFilter();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_sel = 0;
|
||||
updSel(_results.querySelectorAll('.cp-item'));
|
||||
_overlay.focus();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const items = _results.querySelectorAll('.cp-item');
|
||||
_sel = Math.max(items.length - 1, 0);
|
||||
updSel(items);
|
||||
_overlay.focus();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_items.length > 0) exec(_sel);
|
||||
} else if (e.key === 'Backspace' && !_browseFilter && (!_browseFilterEl || !_browseFilterEl.value)) {
|
||||
e.preventDefault();
|
||||
const segs = _browse.path ? _browse.path.split('/') : [];
|
||||
if (segs.length > 0) { _browse.path = segs.slice(0, -1).join('/'); renderBrowse(); }
|
||||
else if (_browse.vault) { switchMode('browse-vault'); renderVaultPicker(); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name input modal ──
|
||||
function askName() {
|
||||
const label = _browse.action === 'create-file' ? 'Nom du fichier' : 'Nom du dossier';
|
||||
const ext = _browse.action === 'create-file' ? '(.md ajouté si besoin)' : '';
|
||||
const existingInput = document.getElementById('cp-name-input');
|
||||
if (existingInput) { existingInput.focus(); return; }
|
||||
|
||||
_input.style.display = 'none';
|
||||
const bc = document.getElementById('cp-breadcrumb');
|
||||
if (bc) bc.style.display = 'none';
|
||||
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.id = 'cp-name-input';
|
||||
nameDiv.className = 'cp-name-input';
|
||||
nameDiv.innerHTML = `<input type="text" id="cp-name-field" class="cp-name-field" placeholder="${label} ${ext}" autocomplete="off" spellcheck="false">
|
||||
<span class="cp-name-hint">Enter confirmer · Esc annuler</span>`;
|
||||
_results.before(nameDiv);
|
||||
|
||||
const field = document.getElementById('cp-name-field');
|
||||
field.focus();
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const name = field.value.trim();
|
||||
if (!name) return;
|
||||
cleanupNameInput();
|
||||
if (_browse.action === 'create-file') execCreateFile(name);
|
||||
else execCreateDir(name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cleanupNameInput();
|
||||
_overlay.focus();
|
||||
renderBrowse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupNameInput() {
|
||||
const el = document.getElementById('cp-name-input');
|
||||
if (el) el.remove();
|
||||
const bc = document.getElementById('cp-breadcrumb');
|
||||
if (bc) bc.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Execute actions ──
|
||||
async function execCreateFile(name) {
|
||||
let fp = _browse.path ? _browse.path+'/'+name : name;
|
||||
if (!fp.includes('.')) fp += '.md';
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(_browse.vault)}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path:fp, content:`# ${name.replace('.md','')}\n\n`}) });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`✅ ${fp} créé`,'success'); close(); openEditor(_browse.vault, fp); }
|
||||
else showToast(t('toast.create_error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execCreateDir(name) {
|
||||
const dp = _browse.path ? _browse.path+'/'+name : name;
|
||||
try {
|
||||
const d = await api(`/api/directory/${encodeURIComponent(_browse.vault)}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path:dp}) });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`📁 ${dp} créé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execDelDir(path) {
|
||||
if (!confirm(t("confirm.delete_dir_named", {path: path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/directory/${encodeURIComponent(_browse.vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function execDelFile(path) {
|
||||
if (!confirm(t("confirm.delete_file_named", {path: path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(_browse.vault)}?path=${encodeURIComponent(path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${path} supprimé`,'success'); close(); refreshSidebar(); }
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
async function delCurFile() {
|
||||
const f = getCurrentFile();
|
||||
if (!f) { showToast(t('toast.no_open_file'),'error'); return; }
|
||||
if (!confirm(t("confirm.delete_file_named", {path: f.path}))) return;
|
||||
try {
|
||||
const d = await api(`/api/file/${encodeURIComponent(f.vault)}?path=${encodeURIComponent(f.path)}`, { method:'DELETE' });
|
||||
if (d?.status==='ok' || d?.success) { showToast(`🗑️ ${f.path} supprimé`,'success');
|
||||
const tab = TabManager._tabs.find(t=>t.vault===f.vault&&t.path===f.path); if(tab) TabManager.close(tab.id); triggerGoHome(); refreshSidebar(); }
|
||||
else showToast(t('common.error'),'error');
|
||||
} catch(e) { showToast('Erreur : '+(e.message||e),'error'); }
|
||||
}
|
||||
|
||||
function refreshSidebar() {
|
||||
import('./sidebar.js').then(m => { if (m.loadVaults) m.loadVaults(); }).catch(() => {});
|
||||
}
|
||||
|
||||
// ── DOM ──
|
||||
function createDOM() {
|
||||
if (_overlay) return;
|
||||
_overlay = document.createElement('div');
|
||||
_overlay.className = 'command-palette-overlay';
|
||||
_overlay.tabIndex = -1;
|
||||
_overlay.innerHTML = `<div class="command-palette">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-results"></div>
|
||||
<div class="cp-footer"><span>↑↓ naviguer</span><span>↵ ouvrir/entrer</span><span>⌫ monter</span><span>Esc fermer</span></div></div>`;
|
||||
_input = _overlay.querySelector('.cp-input');
|
||||
_browseFilterEl = _overlay.querySelector('#cp-browse-filter');
|
||||
_results = _overlay.querySelector('.cp-results');
|
||||
_overlay.addEventListener('click', (e) => { if (e.target === _overlay) close(); });
|
||||
_input.addEventListener('keydown', onKeyDown);
|
||||
_input.addEventListener('input', onInput);
|
||||
if (_browseFilterEl) {
|
||||
_browseFilterEl.addEventListener('input', onBrowseFilterInput);
|
||||
_browseFilterEl.addEventListener('keydown', onBrowseFilterKey);
|
||||
}
|
||||
_overlay.addEventListener('keydown', onOverlayKeyDown);
|
||||
document.body.appendChild(_overlay);
|
||||
}
|
||||
|
||||
export function open(q = '') {
|
||||
createDOM();
|
||||
_overlay.classList.add('active');
|
||||
_input.value = q;
|
||||
_mode = q.startsWith('>') ? 'commands' : 'files';
|
||||
_input.disabled = false; _input.style.display = '';
|
||||
_sel = 0; _items = []; _results.innerHTML = '';
|
||||
_input.focus();
|
||||
onInput();
|
||||
}
|
||||
|
||||
export function close() {
|
||||
if (!_overlay) return;
|
||||
_overlay.classList.remove('active');
|
||||
if (_to) clearTimeout(_to); _to = null;
|
||||
if (_browseFilterTimer) clearTimeout(_browseFilterTimer);
|
||||
_mode = 'files';
|
||||
_browseFilter = '';
|
||||
_allItems = [];
|
||||
cleanupNameInput();
|
||||
if (_browseFilterEl) { _browseFilterEl.style.display = 'none'; _browseFilterEl.value = ''; }
|
||||
const bc = document.getElementById('cp-breadcrumb'); if (bc) bc.style.display = 'none';
|
||||
const si = document.querySelector('.search-input'); if (si) si.focus();
|
||||
}
|
||||
|
||||
function isOpen() { return _overlay && _overlay.classList.contains('active'); }
|
||||
|
||||
function switchMode(m) {
|
||||
_mode = m;
|
||||
if (m === 'browse' || m === 'browse-vault') {
|
||||
_input.style.display = 'none'; _input.disabled = true;
|
||||
_overlay.focus();
|
||||
if (m === 'browse' && _browseFilterEl) {
|
||||
_browseFilterEl.style.display = '';
|
||||
_browseFilterEl.value = _browseFilter;
|
||||
_browseFilterEl.focus();
|
||||
} else if (_browseFilterEl) {
|
||||
_browseFilterEl.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
_input.style.display = ''; _input.disabled = false;
|
||||
_input.focus();
|
||||
if (_browseFilterEl) _browseFilterEl.style.display = 'none';
|
||||
const bc = document.getElementById('cp-breadcrumb'); if (bc) bc.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
if (_mode === 'browse' || _mode === 'browse-vault') return;
|
||||
const v = _input.value;
|
||||
const nm = v.startsWith('>') ? 'commands' : 'files';
|
||||
if (nm !== _mode) { _mode = nm; _input.placeholder = _mode==='commands'?'Tapez une commande...':'Rechercher un fichier... (+inclure -exclure)'; }
|
||||
if (_to) clearTimeout(_to);
|
||||
_to = setTimeout(() => { _to = null; if (_mode==='commands') searchCmd(v.slice(1).trim()); else searchFiles(v.trim()); }, 150);
|
||||
}
|
||||
|
||||
async function searchFiles(rawQ) {
|
||||
if (!rawQ) {
|
||||
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Naviguer dans les vaults pour trouver un fichier', act:()=>startBrowse('open-file') }];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
const { positive, negative } = parseQuery(rawQ);
|
||||
|
||||
try {
|
||||
const q = positive.join(' ');
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=all`);
|
||||
let sug = d.suggestions || [];
|
||||
|
||||
// Client-side negative filter
|
||||
if (negative.length > 0) {
|
||||
sug = sug.filter(s => {
|
||||
const fullPath = (s.vault+'/'+s.path).toLowerCase();
|
||||
for (const n of negative) {
|
||||
if (fullPath.includes(n)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Aucun résultat — naviguer manuellement', act:()=>startBrowse('open-file') }];
|
||||
} else {
|
||||
_items = sug.map(s => ({ type:'file', vault:s.vault, path:s.path, label:s.title||s.path.split('/').pop(), sublabel:`${s.vault}/${s.path}` }));
|
||||
}
|
||||
render();
|
||||
} catch { _results.innerHTML = '<div class="cp-empty cp-error">Erreur</div>'; }
|
||||
}
|
||||
|
||||
function searchCmd(q) {
|
||||
if (!q) { _items = getCMDS().map(c => ({ type:'cmd', ...c })); render(); return; }
|
||||
const lq = q.toLowerCase();
|
||||
_items = getCMDS().filter(c => c.label.toLowerCase().includes(lq)||c.desc.toLowerCase().includes(lq)||(c.cat&&c.cat.toLowerCase().includes(lq))).map(c => ({ type:'cmd', ...c }));
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
_sel = 0;
|
||||
if (!_items.length) { _results.innerHTML = '<div class="cp-empty">Aucun résultat</div>'; return; }
|
||||
_results.innerHTML = _items.map((it, i) => `<div class="cp-item ${i===0?'cp-selected':''}" data-idx="${i}"><div class="cp-item-label">${esc(it.label)}</div><div class="cp-item-sublabel">${esc(it.sublabel||'')}</div></div>`).join('');
|
||||
_results.querySelectorAll('.cp-item').forEach(el => {
|
||||
el.addEventListener('click', () => exec(parseInt(el.dataset.idx)));
|
||||
el.addEventListener('mouseenter', () => { document.querySelectorAll('.cp-item').forEach(e=>e.classList.remove('cp-selected')); el.classList.add('cp-selected'); _sel = parseInt(el.dataset.idx); });
|
||||
});
|
||||
scroll();
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const items = _results.querySelectorAll('.cp-item');
|
||||
if (_mode === 'browse' || _mode === 'browse-vault') {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
else if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
if (_mode === 'browse-vault') { _mode='commands'; _input.style.display=''; _input.disabled=false; _input.focus(); searchCmd(''); return; }
|
||||
const segs = _browse.path ? _browse.path.split('/') : [];
|
||||
if (segs.length > 0) { _browse.path = segs.slice(0,-1).join('/'); renderBrowse(); }
|
||||
else if (_browse.vault) { switchMode('browse-vault'); renderVaultPicker(); }
|
||||
}
|
||||
else if (e.key === 'Enter') { e.preventDefault(); exec(_sel); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); _sel = Math.min(_sel+1, _items.length-1); updSel(items); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); _sel = Math.max(_sel-1, 0); updSel(items); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); exec(_sel); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); _sel = Math.min(_sel+1, _items.length-1); updSel(items); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); _sel = Math.max(_sel-1, 0); updSel(items); }
|
||||
else if (e.key === 'Tab') { e.preventDefault(); if (_mode==='files') { _input.value='> '; setTimeout(()=>_input.setSelectionRange(2,2),0); } else _input.value=''; onInput(); }
|
||||
}
|
||||
|
||||
function updSel(items) { items.forEach((el,i)=>el.classList.toggle('cp-selected',i===_sel)); scroll(); }
|
||||
function scroll() { const s = _results.querySelector('.cp-selected'); if (s) s.scrollIntoView({block:'nearest'}); }
|
||||
|
||||
// ── Overlay keydown handler (for browse modes where input is hidden) ──
|
||||
function onOverlayKeyDown(e) {
|
||||
if (_mode !== 'browse' && _mode !== 'browse-vault') return;
|
||||
if (document.getElementById('cp-name-input')) return;
|
||||
if (document.activeElement === _browseFilterEl) return;
|
||||
onKeyDown(e);
|
||||
}
|
||||
|
||||
function exec(idx) {
|
||||
const it = _items[idx]; if (!it) return;
|
||||
if (it.type === 'file') {
|
||||
close();
|
||||
if (window.innerWidth <= 768) {
|
||||
TabManager.openPersistent(it.vault, it.path);
|
||||
} else {
|
||||
TabManager.openPreview(it.vault, it.path);
|
||||
}
|
||||
}
|
||||
else if (it.type === 'cmd' || it.type === '_browse') { it.act(); }
|
||||
else if (it.type === '_vault') { _browse.vault = it.vault; switchMode('browse'); renderBrowse(); }
|
||||
else if (it.type === '_nav') {
|
||||
// Check if this is a global search result with vault info
|
||||
if (it.vault && it.vault !== _browse.vault) { _browse.vault = it.vault; }
|
||||
_browse.path = it.go;
|
||||
_browseFilter='';
|
||||
if(_browseFilterEl) _browseFilterEl.value='';
|
||||
renderBrowse();
|
||||
}
|
||||
else if (it.type === '_action' || it.type === '_file') { it.act(); }
|
||||
}
|
||||
|
||||
function esc(s) { if (!s) return ''; return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"'); }
|
||||
|
||||
export function initCommandPalette() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey||e.metaKey) && e.shiftKey && !e.altKey && (e.key===' '||e.code==='Space')) { e.preventDefault(); if (isOpen()) close(); else open(''); }
|
||||
if ((e.ctrlKey||e.metaKey) && e.altKey && !e.shiftKey && (e.key===' '||e.code==='Space')) { e.preventDefault(); if (isOpen()) close(); else open('> '); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* PaneManager — Multi-pane split view for ObsiGate (v2.0.0-dev)
|
||||
* Supports 1–4 panes in CSS grid, split right/down, resize, drag tabs between panes.
|
||||
* Integrates with existing TabManager singleton.
|
||||
*/
|
||||
|
||||
const PANE_MAX = 4;
|
||||
|
||||
const PaneManager = {
|
||||
panes: [],
|
||||
activePaneId: 0,
|
||||
_paneCounter: 1,
|
||||
layout: 'horizontal',
|
||||
|
||||
init() {
|
||||
const saved = localStorage.getItem('obsigate-panes');
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
if (data.panes && data.panes.length >= 2) {
|
||||
this.layout = data.layout || 'horizontal';
|
||||
this._paneCounter = data.panes.length;
|
||||
this._buildGrid(data.panes.length);
|
||||
data.panes.forEach((p, i) => {
|
||||
this.panes[i].activeTab = p.activeTab;
|
||||
this.panes[i].width = p.width;
|
||||
});
|
||||
this.activePaneId = data.panes[0].id;
|
||||
this._applyResize();
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
_buildGrid(n) {
|
||||
const wrapper = document.querySelector('.content-wrapper');
|
||||
const origTabBar = document.getElementById('tab-bar');
|
||||
const origContent = document.getElementById('content-area');
|
||||
if (!wrapper || !origContent || n < 2) return;
|
||||
|
||||
// Create grid container
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'pane-grid';
|
||||
if (n === 2) grid.style.gridTemplateColumns = this.layout === 'horizontal' ? '1fr 4px 1fr' : '1fr';
|
||||
if (n >= 3) grid.style.gridTemplateColumns = '1fr 4px 1fr';
|
||||
if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
|
||||
|
||||
wrapper.innerHTML = '';
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
for (let i = 0; i < Math.min(n, PANE_MAX); i++) {
|
||||
const pane = this._createPane(i, grid, i === 0 ? origTabBar : null, i === 0 ? origContent : null);
|
||||
this.panes.push(pane);
|
||||
if (i < n - 1) {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'pane-resize-handle';
|
||||
if (this.layout === 'horizontal') handle.style.cursor = 'col-resize';
|
||||
else handle.style.cursor = 'row-resize';
|
||||
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, i, i + 1));
|
||||
handle.addEventListener('dblclick', () => this._resetResize(i, i + 1));
|
||||
grid.appendChild(handle);
|
||||
}
|
||||
}
|
||||
this.activePaneId = 0;
|
||||
this._highlightActive();
|
||||
},
|
||||
|
||||
_createPane(idx, grid, reuseTabBar, reuseContent) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'pane-container';
|
||||
container.dataset.pane = idx;
|
||||
|
||||
if (reuseTabBar && reuseContent) {
|
||||
// Pane 0 reuses existing DOM
|
||||
container.appendChild(reuseTabBar);
|
||||
container.appendChild(reuseContent);
|
||||
reuseContent.id = 'content-area-0';
|
||||
} else {
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'tab-bar pane-tab-bar';
|
||||
tabBar.id = 'tab-bar-' + idx;
|
||||
tabBar.innerHTML = '<div class="tab-list" id="tab-list-' + idx + '"></div>';
|
||||
const content = document.createElement('main');
|
||||
content.className = 'content-area pane-content';
|
||||
content.id = 'content-area-' + idx;
|
||||
container.appendChild(tabBar);
|
||||
container.appendChild(content);
|
||||
}
|
||||
|
||||
container.addEventListener('click', () => {
|
||||
this.activePaneId = idx;
|
||||
this._highlightActive();
|
||||
});
|
||||
|
||||
grid.appendChild(container);
|
||||
return { id: idx, element: container, width: 100 / this.panes.length + '%' };
|
||||
},
|
||||
|
||||
_highlightActive() {
|
||||
this.panes.forEach((p, i) => {
|
||||
p.element.classList.toggle('pane-active', i === this.activePaneId);
|
||||
});
|
||||
},
|
||||
|
||||
splitRight() {
|
||||
if (this.panes.length >= PANE_MAX) return;
|
||||
if (this.panes.length === 0) {
|
||||
// First split: promote single pane to grid
|
||||
this._buildGrid(2);
|
||||
} else {
|
||||
this._addPane();
|
||||
}
|
||||
this._persist();
|
||||
},
|
||||
|
||||
splitDown() {
|
||||
if (this.panes.length >= PANE_MAX) return;
|
||||
this.layout = 'vertical';
|
||||
if (this.panes.length === 0) {
|
||||
this._buildGrid(2);
|
||||
} else {
|
||||
this._addPane();
|
||||
}
|
||||
this._persist();
|
||||
},
|
||||
|
||||
_addPane() {
|
||||
const grid = document.querySelector('.pane-grid');
|
||||
if (!grid) return;
|
||||
const n = this.panes.length + 1;
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'pane-resize-handle';
|
||||
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1));
|
||||
grid.appendChild(handle);
|
||||
const pane = this._createPane(n - 1, grid, null, null);
|
||||
this.panes.push(pane);
|
||||
this.activePaneId = n - 1;
|
||||
this._highlightActive();
|
||||
if (n <= 2) grid.style.gridTemplateColumns = '1fr 4px 1fr';
|
||||
else if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
|
||||
},
|
||||
|
||||
closePane(paneId) {
|
||||
if (this.panes.length <= 1) return;
|
||||
const idx = this.panes.findIndex(p => p.id === paneId);
|
||||
if (idx < 0) return;
|
||||
// Remove pane and its resize handles
|
||||
this.panes[idx].element.remove();
|
||||
const handles = document.querySelectorAll('.pane-resize-handle');
|
||||
if (handles[idx]) handles[idx].remove();
|
||||
else if (handles[idx - 1]) handles[idx - 1].remove();
|
||||
this.panes.splice(idx, 1);
|
||||
if (this.panes.length <= 1) {
|
||||
this._collapseToSingle();
|
||||
} else {
|
||||
this.activePaneId = Math.min(this.activePaneId, this.panes.length - 1);
|
||||
this._highlightActive();
|
||||
}
|
||||
this._persist();
|
||||
},
|
||||
|
||||
_collapseToSingle() {
|
||||
const grid = document.querySelector('.pane-grid');
|
||||
if (!grid) return;
|
||||
const wrapper = grid.parentElement;
|
||||
const pane0 = this.panes[0];
|
||||
wrapper.innerHTML = '';
|
||||
const tabBar = pane0.element.querySelector('.tab-bar') || document.getElementById('tab-bar-0');
|
||||
const content = pane0.element.querySelector('.content-area') || document.getElementById('content-area-0');
|
||||
if (tabBar) wrapper.appendChild(tabBar);
|
||||
if (content) wrapper.appendChild(content);
|
||||
this.panes = [];
|
||||
this.activePaneId = 0;
|
||||
},
|
||||
|
||||
_onResizeStart(e, leftIdx, rightIdx) {
|
||||
e.preventDefault();
|
||||
const grid = document.querySelector('.pane-grid');
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const totalSize = this.layout === 'horizontal' ? grid.offsetWidth : grid.offsetHeight;
|
||||
|
||||
const onMove = (ev) => {
|
||||
const delta = this.layout === 'horizontal' ? ev.clientX - startX : ev.clientY - startY;
|
||||
const pct = (delta / totalSize) * 100;
|
||||
const leftEl = this.panes[leftIdx].element;
|
||||
const rightEl = this.panes[rightIdx].element;
|
||||
const curLeft = parseFloat(leftEl.style.flexBasis || leftEl.style.width || '50');
|
||||
const curRight = parseFloat(rightEl.style.flexBasis || rightEl.style.width || '50');
|
||||
const newLeft = Math.max(20, Math.min(80, curLeft + pct));
|
||||
const newRight = 100 - newLeft;
|
||||
leftEl.style.flexBasis = newLeft + '%';
|
||||
rightEl.style.flexBasis = newRight + '%';
|
||||
this.panes[leftIdx].width = newLeft + '%';
|
||||
this.panes[rightIdx].width = newRight + '%';
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
this._persist();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
|
||||
_resetResize(leftIdx, rightIdx) {
|
||||
this.panes[leftIdx].element.style.flexBasis = '50%';
|
||||
this.panes[rightIdx].element.style.flexBasis = '50%';
|
||||
this._persist();
|
||||
},
|
||||
|
||||
_applyResize() {
|
||||
this.panes.forEach(p => {
|
||||
if (p.width) p.element.style.flexBasis = p.width;
|
||||
});
|
||||
},
|
||||
|
||||
_persist() {
|
||||
if (this.panes.length <= 1) {
|
||||
localStorage.removeItem('obsigate-panes');
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
layout: this.layout,
|
||||
panes: this.panes.map(p => ({ id: p.id, activeTab: p.activeTab, width: p.width })),
|
||||
};
|
||||
localStorage.setItem('obsigate-panes', JSON.stringify(data));
|
||||
},
|
||||
|
||||
getActiveContentArea() {
|
||||
if (this.panes.length < 2) return document.getElementById('content-area');
|
||||
return document.getElementById('content-area-' + this.activePaneId) || document.getElementById('content-area-0');
|
||||
},
|
||||
|
||||
isSplit() {
|
||||
return this.panes.length >= 2;
|
||||
},
|
||||
};
|
||||
|
||||
// Export and auto-init
|
||||
if (typeof window !== 'undefined') {
|
||||
window.PaneManager = PaneManager;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Register keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PaneManager.splitRight();
|
||||
}
|
||||
if (e.ctrlKey && e.altKey && e.shiftKey && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PaneManager.splitDown();
|
||||
}
|
||||
});
|
||||
// Initialize if saved state exists
|
||||
setTimeout(() => PaneManager.init(), 500);
|
||||
});
|
||||
}
|
||||
|
||||
export default PaneManager;
|
||||
@@ -0,0 +1,59 @@
|
||||
/* ObsiGate — Shared mutable application state.
|
||||
Use state.xxx to read/write any value. ES module imports are read-only,
|
||||
so we export a single mutable object instead of individual let bindings. */
|
||||
export const state = {
|
||||
APP_VERSION: "1.5.0",
|
||||
|
||||
// Core navigation
|
||||
currentVault: null,
|
||||
currentPath: null,
|
||||
allVaults: [],
|
||||
selectedContextVault: "all",
|
||||
|
||||
// Search
|
||||
searchTimeout: null,
|
||||
searchAbortController: null,
|
||||
advancedSearchOffset: 0,
|
||||
advancedSearchTotal: 0,
|
||||
advancedSearchSort: "relevance",
|
||||
advancedSearchLastQuery: "",
|
||||
suggestAbortController: null,
|
||||
dropdownActiveIndex: -1,
|
||||
dropdownItems: [],
|
||||
currentSearchId: 0,
|
||||
selectedTags: [],
|
||||
searchCaseSensitive: false,
|
||||
searchWholeWord: false,
|
||||
searchRegex: false,
|
||||
searchFilterVisible: false,
|
||||
|
||||
// Search constants
|
||||
SEARCH_HISTORY_KEY: "obsigate_search_history",
|
||||
MAX_HISTORY_ENTRIES: 50,
|
||||
SUGGEST_DEBOUNCE_MS: 150,
|
||||
ADVANCED_SEARCH_LIMIT: 50,
|
||||
MIN_SEARCH_LENGTH: 2,
|
||||
SEARCH_TIMEOUT_MS: 30000,
|
||||
|
||||
// Viewer state
|
||||
showingSource: false,
|
||||
cachedRawSource: null,
|
||||
editorView: null,
|
||||
editorVault: null,
|
||||
editorPath: null,
|
||||
fallbackEditorEl: null,
|
||||
_iconDebounceTimer: null,
|
||||
|
||||
// Outline/TOC
|
||||
outlineObserver: null,
|
||||
activeHeadingId: null,
|
||||
headingsCache: [],
|
||||
rightSidebarVisible: true,
|
||||
rightSidebarWidth: 280,
|
||||
|
||||
// Sidebar
|
||||
sidebarFilterCaseSensitive: false,
|
||||
activeSidebarTab: "vaults",
|
||||
filterDebounce: null,
|
||||
vaultSettings: {},
|
||||
};
|
||||
@@ -0,0 +1,643 @@
|
||||
/* ObsiGate — Sync: SSE client + PWA registration + Offline mode */
|
||||
import { state } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { loadRecentFiles, initAboutModal } from './config.js';
|
||||
import { openFile } from './viewer.js';
|
||||
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
||||
import { initThemes } from './themes.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE Client — IndexUpdateManager
|
||||
// ---------------------------------------------------------------------------
|
||||
export const IndexUpdateManager = (() => {
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
let recentEvents = [];
|
||||
const MAX_RECENT_EVENTS = 20;
|
||||
let connectionState = "disconnected"; // disconnected | connecting | connected
|
||||
|
||||
function connect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
connectionState = "connecting";
|
||||
_updateBadge();
|
||||
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("connected", (e) => {
|
||||
connectionState = "connected";
|
||||
reconnectDelay = 1000;
|
||||
_updateBadge();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_updated", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_updated", data);
|
||||
_onIndexUpdated(data);
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_reloaded", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_reloaded", data);
|
||||
_onIndexReloaded(data);
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("vault_added", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("vault_added", data);
|
||||
showToast(t('sync.vault_added', { vault: data.vault, count: data.stats.file_count }), "info");
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("vault_removed", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("vault_removed", data);
|
||||
showToast(t('sync.vault_removed', { vault: data.vault }), "info");
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_start", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_start", data);
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
showToast(t('sync.index_started', { count: data.total_vaults }), "info");
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_progress", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_progress", data);
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("index_complete", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
_addEvent("index_complete", data);
|
||||
connectionState = "connected";
|
||||
_updateBadge();
|
||||
showToast(t('sync.index_complete', { count: data.total_files }), "success");
|
||||
loadVaults();
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
_scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function _scheduleReconnect() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function _addEvent(type, data) {
|
||||
recentEvents.unshift({
|
||||
type,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||||
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||||
}
|
||||
}
|
||||
|
||||
async function _onIndexUpdated(data) {
|
||||
// Brief syncing state
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
|
||||
const n = data.total_changes || 0;
|
||||
const vaults = (data.vaults || []).join(", ");
|
||||
// Toast removed: silent auto-indexing — no notification needed
|
||||
|
||||
// Refresh sidebar and tags if affected vault matches current context
|
||||
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
|
||||
if (affectsCurrentVault) {
|
||||
try {
|
||||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||||
// Refresh current file if it was updated
|
||||
if (state.currentVault && state.currentPath) {
|
||||
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
|
||||
if (changed) {
|
||||
openFile(state.currentVault, state.currentPath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error refreshing after index update:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh recent tab if it is active
|
||||
if (state.activeSidebarTab === "recent") {
|
||||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
connectionState = "connected";
|
||||
_updateBadge();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function _onIndexReloaded(data) {
|
||||
connectionState = "syncing";
|
||||
_updateBadge();
|
||||
showToast(t("sync.index_reloaded"), "info");
|
||||
try {
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
} catch (err) {
|
||||
console.error("Error refreshing after full reload:", err);
|
||||
}
|
||||
setTimeout(() => {
|
||||
connectionState = "connected";
|
||||
_updateBadge();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function _updateBadge() {
|
||||
const badge = document.getElementById("sync-badge");
|
||||
if (!badge) return;
|
||||
badge.className = "sync-badge sync-badge--" + connectionState;
|
||||
const labels = {
|
||||
disconnected: t('sync.disconnected'),
|
||||
connecting: "Connexion...",
|
||||
connected: t('sync.connected'),
|
||||
syncing: t('sync.updating'),
|
||||
};
|
||||
badge.title = labels[connectionState] || connectionState;
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return connectionState;
|
||||
}
|
||||
|
||||
function getRecentEvents() {
|
||||
return recentEvents;
|
||||
}
|
||||
|
||||
return { connect, disconnect, getState, getRecentEvents };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync status badge and panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initSyncStatus() {
|
||||
const badge = document.getElementById("sync-badge");
|
||||
if (!badge) return;
|
||||
|
||||
badge.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSyncPanel();
|
||||
});
|
||||
|
||||
IndexUpdateManager.connect();
|
||||
|
||||
// Initialize AI status tracking
|
||||
_initAIStatus();
|
||||
}
|
||||
|
||||
function toggleSyncPanel() {
|
||||
let panel = document.getElementById("sync-panel");
|
||||
if (panel) {
|
||||
panel.remove();
|
||||
return;
|
||||
}
|
||||
// Auto reconnect if disconnected when user opens the panel
|
||||
if (IndexUpdateManager.getState() === "disconnected") {
|
||||
IndexUpdateManager.connect();
|
||||
}
|
||||
panel = document.createElement("div");
|
||||
panel.id = "sync-panel";
|
||||
panel.className = "sync-panel";
|
||||
_renderSyncPanel(panel);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Close on outside click
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _closeSyncPanelOutside(e) {
|
||||
const panel = document.getElementById("sync-panel");
|
||||
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||||
panel.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSyncPanel(panel) {
|
||||
const state = IndexUpdateManager.getState();
|
||||
const events = IndexUpdateManager.getRecentEvents();
|
||||
|
||||
const stateLabels = {
|
||||
disconnected: t('sync.disconnected'),
|
||||
connecting: "Connexion...",
|
||||
connected: t('sync.connected_short'),
|
||||
syncing: "Synchronisation...",
|
||||
};
|
||||
|
||||
let html = `<div class="sync-panel__header">
|
||||
<span class="sync-panel__title">Synchronisation</span>
|
||||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||||
</div>`;
|
||||
|
||||
if (events.length === 0) {
|
||||
html += `<div class="sync-panel__empty">${t('sync.no_recent_events')}</div>`;
|
||||
} else {
|
||||
html += `<div class="sync-panel__events">`;
|
||||
events.slice(0, 10).forEach((ev) => {
|
||||
const time = new Date(ev.timestamp).toLocaleTimeString();
|
||||
const typeLabels = {
|
||||
index_updated: t('sync.event_index_updated'),
|
||||
index_reloaded: "Rechargement",
|
||||
vault_added: t('sync.event_vault_added'),
|
||||
vault_removed: t('sync.event_vault_removed'),
|
||||
index_start: t('sync.event_index_start'),
|
||||
index_progress: t('sync.event_index_progress'),
|
||||
index_complete: "Indexation tech.",
|
||||
};
|
||||
const label = typeLabels[ev.type] || ev.type;
|
||||
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||||
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||||
if (ev.type === "index_progress") detail = t("sync.vault_files", {vault: ev.data.vault, files: ev.data.files});
|
||||
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||||
html += `<div class="sync-panel__event">
|
||||
<span class="sync-panel__event-type">${label}</span>
|
||||
<span class="sync-panel__event-detail">${detail}</span>
|
||||
<span class="sync-panel__event-time">${time}</span>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
panel.innerHTML = html;
|
||||
|
||||
// ── AI Status section ──
|
||||
_renderAIStatusSection(panel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Status Indicator (inside sync panel + header badge animation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _aiProcessing = false;
|
||||
let _aiConfigured = null; // null = unknown, true/false
|
||||
let _aiAutocomplete = null; // null = unknown, {available, server_ok, model_loaded, model, error}
|
||||
|
||||
/** Check if AI is configured by calling the /api/ai/status endpoint.
|
||||
* Updates _aiConfigured, _aiAutocomplete and refreshes the panel if open. */
|
||||
async function _checkAIStatus() {
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
_aiConfigured = status.configured === true;
|
||||
_aiAutocomplete = status.autocomplete || null;
|
||||
} catch {
|
||||
_aiConfigured = false;
|
||||
_aiAutocomplete = null;
|
||||
}
|
||||
_updateAIStatusInPanel();
|
||||
}
|
||||
|
||||
function _renderAIStatusSection(panel) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'sync-panel__ai-section';
|
||||
section.id = 'sync-panel-ai';
|
||||
|
||||
// ---- External provider status ----
|
||||
let extIcon, extText, extClass;
|
||||
if (_aiProcessing) {
|
||||
extIcon = 'loader';
|
||||
extText = t('sync.processing');
|
||||
extClass = 'sync-panel__ai--processing';
|
||||
} else if (_aiConfigured === true) {
|
||||
extIcon = 'check-circle';
|
||||
extText = 'Fournisseur externe actif';
|
||||
extClass = 'sync-panel__ai--active';
|
||||
} else if (_aiConfigured === false) {
|
||||
extIcon = 'x-circle';
|
||||
extText = t('sync.no_provider');
|
||||
extClass = 'sync-panel__ai--inactive';
|
||||
} else {
|
||||
extIcon = 'help-circle';
|
||||
extText = t('sync.verifying');
|
||||
extClass = 'sync-panel__ai--unknown';
|
||||
}
|
||||
|
||||
// ---- Autocomplete (Ollama) status ----
|
||||
let acIcon, acText, acClass;
|
||||
if (_aiAutocomplete && _aiAutocomplete.available) {
|
||||
acIcon = 'check-circle';
|
||||
acText = t('sync.autocomplete_active');
|
||||
acClass = 'sync-panel__ai--active';
|
||||
} else if (_aiAutocomplete && _aiAutocomplete.server_ok && !_aiAutocomplete.model_loaded) {
|
||||
acIcon = 'alert-triangle';
|
||||
acText = t('sync.model_not_loaded');
|
||||
acClass = 'sync-panel__ai--inactive';
|
||||
} else if (_aiAutocomplete && _aiAutocomplete.error) {
|
||||
acIcon = 'x-circle';
|
||||
acText = 'Serveur Ollama injoignable';
|
||||
acClass = 'sync-panel__ai--inactive';
|
||||
} else if (_aiAutocomplete === null) {
|
||||
acIcon = 'help-circle';
|
||||
acText = t('sync.verifying');
|
||||
acClass = 'sync-panel__ai--unknown';
|
||||
} else {
|
||||
acIcon = 'x-circle';
|
||||
acText = t('sync.not_configured');
|
||||
acClass = 'sync-panel__ai--inactive';
|
||||
}
|
||||
|
||||
section.innerHTML = '' +
|
||||
'<div class="sync-panel__ai-title">Intelligence Artificielle</div>' +
|
||||
'<div class="sync-panel__ai-row">' +
|
||||
'<span class="sync-panel__ai-label">Fournisseur</span>' +
|
||||
'<span class="sync-panel__ai-state ' + extClass + '" data-lucide-icon="' + extIcon + '">' +
|
||||
'<i data-lucide="' + extIcon + '" style="width:14px;height:14px"></i>' +
|
||||
extText +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="sync-panel__ai-row">' +
|
||||
'<span class="sync-panel__ai-label">' + t('sync.autocomplete') + '</span>' +
|
||||
'<span class="sync-panel__ai-state ' + acClass + '" data-lucide-icon="' + acIcon + '">' +
|
||||
'<i data-lucide="' + acIcon + '" style="width:14px;height:14px"></i>' +
|
||||
acText +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
|
||||
panel.appendChild(section);
|
||||
// Trigger icon refresh via global
|
||||
if (window._obsigateRefreshIcons) window._obsigateRefreshIcons();
|
||||
}
|
||||
|
||||
/** Update the AI section in the open sync panel. */
|
||||
function _updateAIStatusInPanel() {
|
||||
const section = document.getElementById('sync-panel-ai');
|
||||
if (!section) return;
|
||||
section.innerHTML = '';
|
||||
const panel = document.getElementById('sync-panel');
|
||||
if (panel) {
|
||||
// Re-render just the AI section
|
||||
const oldSection = panel.querySelector('.sync-panel__ai-section');
|
||||
if (oldSection) oldSection.remove();
|
||||
_renderAIStatusSection(panel);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flash the sync badge white during AI processing.
|
||||
* NOTE: now handled via CSS class on #sync-badge.
|
||||
* The visual flash in the editor itself is handled by ai.js
|
||||
* which adds a processing indicator inline. */
|
||||
function _setBadgeAIProcessing(active) {
|
||||
const badge = document.getElementById('sync-badge');
|
||||
if (!badge) return;
|
||||
if (active) {
|
||||
badge.classList.add('sync-badge--ai-processing');
|
||||
} else {
|
||||
badge.classList.remove('sync-badge--ai-processing');
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialize AI status tracking. */
|
||||
function _initAIStatus() {
|
||||
// Check AI status immediately at startup
|
||||
_checkAIStatus();
|
||||
|
||||
// Listen for AI processing events from ai.js
|
||||
document.addEventListener('ai-processing-start', () => {
|
||||
_aiProcessing = true;
|
||||
_setBadgeAIProcessing(true);
|
||||
_updateAIStatusInPanel();
|
||||
});
|
||||
|
||||
document.addEventListener('ai-processing-end', () => {
|
||||
_aiProcessing = false;
|
||||
_setBadgeAIProcessing(false);
|
||||
_updateAIStatusInPanel();
|
||||
});
|
||||
|
||||
// Re-check AI status when sync panel opens (in case config changed)
|
||||
const badge = document.getElementById('sync-badge');
|
||||
if (badge) {
|
||||
badge.addEventListener('click', () => {
|
||||
_checkAIStatus();
|
||||
}, { once: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PWA Service Worker Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
function registerServiceWorker() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("[PWA] Service Worker registered successfully:", registration.scope);
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener("statechange", () => {
|
||||
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
showUpdateNotification();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("[PWA] Service Worker registration failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
const message = document.createElement("div");
|
||||
message.className = "pwa-update-notification";
|
||||
message.innerHTML = `
|
||||
<div class="pwa-update-content">
|
||||
<span>Une nouvelle version d'ObsiGate est disponible !</span>
|
||||
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
|
||||
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(message);
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (message.parentElement) {
|
||||
message.remove();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Handle install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button if desired
|
||||
const installBtn = document.getElementById("pwa-install-btn");
|
||||
if (installBtn) {
|
||||
installBtn.style.display = "block";
|
||||
installBtn.addEventListener("click", async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`[PWA] User response to install prompt: ${outcome}`);
|
||||
deferredPrompt = null;
|
||||
installBtn.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Log when app is installed
|
||||
window.addEventListener("appinstalled", () => {
|
||||
console.log("[PWA] ObsiGate has been installed");
|
||||
showToast(t("sync.install_success"), "success");
|
||||
});
|
||||
|
||||
// ── Dashboard tab switching (runs on page load and after rebuild) ──
|
||||
function initDashboardTabs() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(tab => {
|
||||
// Remove existing listeners by cloning
|
||||
const newTab = tab.cloneNode(true);
|
||||
tab.parentNode.replaceChild(newTab, tab);
|
||||
newTab.addEventListener("click", function() {
|
||||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||||
this.classList.add("active");
|
||||
const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — called by app.js orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
export { initSyncStatus, initDashboardTabs };
|
||||
export function init() {
|
||||
registerServiceWorker();
|
||||
initOffline();
|
||||
initThemes();
|
||||
initAboutModal();
|
||||
// Sync file index after a short delay (let vaults load first).
|
||||
// Gate on auth at execution time (not scheduling time) so that
|
||||
// initAuth() has a chance to complete and set _authEnabled / token.
|
||||
// The login handler triggers syncFileIndexFromServer explicitly on
|
||||
// fresh login, so we only do it here for already-authenticated or
|
||||
// auth-disabled sessions.
|
||||
setTimeout(() => {
|
||||
if (!AuthManager._authEnabled || AuthManager.getToken()) {
|
||||
syncFileIndexFromServer();
|
||||
}
|
||||
}, 3000);
|
||||
initDashboardTabs();
|
||||
|
||||
// Listen for Forge editor rename events
|
||||
window.addEventListener('message', function(e) {
|
||||
if (!e.data || !e.data.type) return;
|
||||
if (e.data.type === 'forge-renamed') {
|
||||
var vault = e.data.vault;
|
||||
var oldPath = e.data.oldPath;
|
||||
var newPath = e.data.newPath;
|
||||
console.log('[Forge] File renamed:', vault, oldPath, '->', newPath);
|
||||
var titleEl = document.getElementById('editor-title');
|
||||
if (titleEl && titleEl.textContent.indexOf('Forge:') === 0) {
|
||||
titleEl.textContent = 'Forge: ' + newPath.split('/').pop();
|
||||
}
|
||||
refreshSidebarTreePreservingState(vault);
|
||||
if (state.currentVault === vault && state.currentPath === oldPath) {
|
||||
import('./viewer.js').then(function(m) { m.openFile(vault, newPath); });
|
||||
}
|
||||
}
|
||||
if (e.data.type === 'forge-close') {
|
||||
var modal = document.getElementById('editor-modal');
|
||||
var forgeFrame = document.getElementById('forge-iframe');
|
||||
if (forgeFrame) forgeFrame.remove();
|
||||
if (modal) modal.classList.remove('active');
|
||||
// Restore brand text
|
||||
var brand = modal.querySelector('.editor-brand');
|
||||
if (brand) brand.textContent = 'ObsiGate';
|
||||
// Restore header/footer
|
||||
var headerEl = modal.querySelector('.editor-header');
|
||||
var footerEl = modal.querySelector('.editor-footer');
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
if (footerEl) footerEl.style.display = '';
|
||||
// Refresh current file content
|
||||
if (state.currentVault && state.currentPath) {
|
||||
import('./viewer.js').then(function(m) { m.openFile(state.currentVault, state.currentPath); });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
/* ObsiGate — Theme System
|
||||
* 15 themes x 2 modes (dark/light)
|
||||
* Persisted in localStorage as 'obsigate-theme'
|
||||
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
|
||||
*/
|
||||
|
||||
// ── Theme definitions ────────────────────────────────────────────────
|
||||
// Each theme has 'dark' and 'light' variants.
|
||||
// CSS variables are applied to :root via setProperty.
|
||||
|
||||
var THEMES = {
|
||||
|
||||
'defaut-obsigate': {
|
||||
name: 'Defaut ObsiGate',
|
||||
desc: 'Theme original',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#0f1117', '--bg-secondary': '#161b22', '--bg-sidebar': '#13161d',
|
||||
'--bg-hover': '#1f2430', '--border': '#21262d', '--text-primary': '#e6edf3',
|
||||
'--text-secondary': '#8b949e', '--text-muted': '#484f58', '--accent': '#58a6ff',
|
||||
'--accent-green': '#3fb950', '--tag-bg': '#1f2a3a', '--tag-text': '#79c0ff',
|
||||
'--code-bg': '#161b22', '--search-bg': '#21262d', '--scrollbar': '#30363d',
|
||||
'--resize-handle': '#30363d', '--overlay-bg': 'rgba(0,0,0,0.5)',
|
||||
'--danger': '#ff7b72', '--danger-bg': '#3d1a18', '--success': '#3fb950', '--success-bg': '#1a3d1f',
|
||||
'--accent-card': '#58a6ff', '--accent-bg': '#1f2a3a', '--accent-text': '#79c0ff',
|
||||
'--green': '#3fb950', '--green-bg': '#1a3d1f', '--purple': '#a371f7', '--purple-bg': '#2d1b4e',
|
||||
'--surface': '#161b22', '--surface2': '#0f1117', '--surface3': '#0d0f14',
|
||||
'--border-md': '#30363d', '--text': '#e6edf3', '--text-2': '#8b949e', '--text-3': '#484f58',
|
||||
'--ok': '#34d399', '--warn': '#fbbf24', '--ai': '#8b5cf6', '--err': '#f87171'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#ffffff', '--bg-secondary': '#f6f8fa', '--bg-sidebar': '#f0f2f5',
|
||||
'--bg-hover': '#e8ecf0', '--border': '#d0d7de', '--text-primary': '#1f2328',
|
||||
'--text-secondary': '#57606a', '--text-muted': '#9198a1', '--accent': '#0969da',
|
||||
'--accent-green': '#1a7f37', '--tag-bg': '#ddf4ff', '--tag-text': '#0969da',
|
||||
'--code-bg': '#f6f8fa', '--search-bg': '#ffffff', '--scrollbar': '#d0d7de',
|
||||
'--resize-handle': '#d0d7de', '--overlay-bg': 'rgba(0,0,0,0.3)',
|
||||
'--danger': '#cf222e', '--danger-bg': '#ffebe9', '--success': '#1a7f37', '--success-bg': '#dafbe1',
|
||||
'--accent-card': '#0969da', '--accent-bg': '#ddf4ff', '--accent-text': '#0969da',
|
||||
'--green': '#1a7f37', '--green-bg': '#dafbe1', '--purple': '#8250df', '--purple-bg': '#fbefff',
|
||||
'--surface': '#f6f8fa', '--surface2': '#ffffff', '--surface3': '#f0f2f5',
|
||||
'--border-md': '#d0d7de', '--text': '#1f2328', '--text-2': '#57606a', '--text-3': '#9198a1',
|
||||
'--ok': '#1a7f37', '--warn': '#9a6700', '--ai': '#8250df', '--err': '#cf222e'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'github': {
|
||||
name: 'GitHub', desc: 'Contraste doux',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#0d1117', '--bg-secondary': '#161b22', '--bg-sidebar': '#0d1117',
|
||||
'--bg-hover': '#1c2128', '--border': '#30363d', '--text-primary': '#f0f6fc',
|
||||
'--text-secondary': '#8b949e', '--text-muted': '#6e7681', '--accent': '#58a6ff',
|
||||
'--accent-green': '#3fb950', '--tag-bg': '#13233a', '--tag-text': '#58a6ff',
|
||||
'--code-bg': '#161b22', '--search-bg': '#0d1117', '--scrollbar': '#30363d',
|
||||
'--resize-handle': '#30363d', '--overlay-bg': 'rgba(0,0,0,0.6)',
|
||||
'--danger': '#f85149', '--danger-bg': '#490202', '--success': '#3fb950', '--success-bg': '#04260f',
|
||||
'--accent-card': '#58a6ff', '--accent-bg': '#13233a', '--accent-text': '#58a6ff',
|
||||
'--green': '#3fb950', '--green-bg': '#04260f', '--purple': '#a371f7', '--purple-bg': '#1c1130',
|
||||
'--surface': '#161b22', '--surface2': '#0d1117', '--surface3': '#010409',
|
||||
'--border-md': '#30363d', '--text': '#f0f6fc', '--text-2': '#8b949e', '--text-3': '#6e7681',
|
||||
'--ok': '#3fb950', '--warn': '#d29922', '--ai': '#a371f7', '--err': '#f85149'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#ffffff', '--bg-secondary': '#f6f8fa', '--bg-sidebar': '#f6f8fa',
|
||||
'--bg-hover': '#eaeef2', '--border': '#d0d7de', '--text-primary': '#1f2328',
|
||||
'--text-secondary': '#656d76', '--text-muted': '#8c959f', '--accent': '#0969da',
|
||||
'--accent-green': '#1a7f37', '--tag-bg': '#ddf4ff', '--tag-text': '#0969da',
|
||||
'--code-bg': '#f6f8fa', '--search-bg': '#ffffff', '--scrollbar': '#d0d7de',
|
||||
'--resize-handle': '#d0d7de', '--overlay-bg': 'rgba(0,0,0,0.25)',
|
||||
'--danger': '#cf222e', '--danger-bg': '#ffebe9', '--success': '#1a7f37', '--success-bg': '#dafbe1',
|
||||
'--accent-card': '#0969da', '--accent-bg': '#ddf4ff', '--accent-text': '#0969da',
|
||||
'--green': '#1a7f37', '--green-bg': '#dafbe1', '--purple': '#8250df', '--purple-bg': '#fbefff',
|
||||
'--surface': '#f6f8fa', '--surface2': '#ffffff', '--surface3': '#ebedf0',
|
||||
'--border-md': '#d0d7de', '--text': '#1f2328', '--text-2': '#656d76', '--text-3': '#8c959f',
|
||||
'--ok': '#1a7f37', '--warn': '#9a6700', '--ai': '#8250df', '--err': '#cf222e'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'linear': {
|
||||
name: 'Linear', desc: 'Dégradés subtils',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#1a1a2e', '--bg-secondary': '#16162a', '--bg-sidebar': '#12121f',
|
||||
'--bg-hover': '#222240', '--border': '#2a2a45', '--text-primary': '#e1e1f5',
|
||||
'--text-secondary': '#8888aa', '--text-muted': '#555577', '--accent': '#5e6ad2',
|
||||
'--accent-green': '#2ecc71', '--tag-bg': '#222050', '--tag-text': '#8b95e0',
|
||||
'--code-bg': '#16162a', '--search-bg': '#1e1e38', '--scrollbar': '#2a2a45',
|
||||
'--resize-handle': '#2a2a45', '--overlay-bg': 'rgba(0,0,0,0.6)',
|
||||
'--danger': '#e0556a', '--danger-bg': '#361520', '--success': '#2ecc71', '--success-bg': '#0d2818',
|
||||
'--accent-card': '#5e6ad2', '--accent-bg': '#222050', '--accent-text': '#8b95e0',
|
||||
'--green': '#2ecc71', '--green-bg': '#0d2818', '--purple': '#9b6bff', '--purple-bg': '#25183d',
|
||||
'--surface': '#16162a', '--surface2': '#1a1a2e', '--surface3': '#12121f',
|
||||
'--border-md': '#2a2a45', '--text': '#e1e1f5', '--text-2': '#8888aa', '--text-3': '#555577',
|
||||
'--ok': '#2ecc71', '--warn': '#f0b429', '--ai': '#9b6bff', '--err': '#e0556a'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#fafafa', '--bg-secondary': '#f0f0f5', '--bg-sidebar': '#e8e8f0',
|
||||
'--bg-hover': '#e0e0ea', '--border': '#d5d5e0', '--text-primary': '#1a1a2e',
|
||||
'--text-secondary': '#5c5c7a', '--text-muted': '#9090a8', '--accent': '#5e6ad2',
|
||||
'--accent-green': '#1a8b4a', '--tag-bg': '#e8e8ff', '--tag-text': '#5e6ad2',
|
||||
'--code-bg': '#f0f0f5', '--search-bg': '#fafafa', '--scrollbar': '#d5d5e0',
|
||||
'--resize-handle': '#d5d5e0', '--overlay-bg': 'rgba(0,0,0,0.2)',
|
||||
'--danger': '#c92a3e', '--danger-bg': '#ffeaea', '--success': '#1a8b4a', '--success-bg': '#e0ffe0',
|
||||
'--accent-card': '#5e6ad2', '--accent-bg': '#e8e8ff', '--accent-text': '#5e6ad2',
|
||||
'--green': '#1a8b4a', '--green-bg': '#e0ffe0', '--purple': '#7c5ce0', '--purple-bg': '#efe8ff',
|
||||
'--surface': '#f0f0f5', '--surface2': '#fafafa', '--surface3': '#e8e8f0',
|
||||
'--border-md': '#d5d5e0', '--text': '#1a1a2e', '--text-2': '#5c5c7a', '--text-3': '#9090a8',
|
||||
'--ok': '#1a8b4a', '--warn': '#9a6700', '--ai': '#7c5ce0', '--err': '#c92a3e'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'spotify': {
|
||||
name: 'Spotify', desc: 'Noir pur & vert',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#000000', '--bg-secondary': '#121212', '--bg-sidebar': '#000000',
|
||||
'--bg-hover': '#1a1a1a', '--border': '#282828', '--text-primary': '#ffffff',
|
||||
'--text-secondary': '#b3b3b3', '--text-muted': '#727272', '--accent': '#1db954',
|
||||
'--accent-green': '#1db954', '--tag-bg': '#0d3317', '--tag-text': '#1db954',
|
||||
'--code-bg': '#121212', '--search-bg': '#242424', '--scrollbar': '#333333',
|
||||
'--resize-handle': '#282828', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#e22134', '--danger-bg': '#2a0a0e', '--success': '#1db954', '--success-bg': '#0d3317',
|
||||
'--accent-card': '#1db954', '--accent-bg': '#0d3317', '--accent-text': '#1db954',
|
||||
'--green': '#1db954', '--green-bg': '#0d3317', '--purple': '#af2896', '--purple-bg': '#2a0d33',
|
||||
'--surface': '#121212', '--surface2': '#000000', '--surface3': '#000000',
|
||||
'--border-md': '#282828', '--text': '#ffffff', '--text-2': '#b3b3b3', '--text-3': '#727272',
|
||||
'--ok': '#1db954', '--warn': '#f59b23', '--ai': '#af2896', '--err': '#e22134'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-sidebar': '#f0f0f0',
|
||||
'--bg-hover': '#e5e5e5', '--border': '#d5d5d5', '--text-primary': '#121212',
|
||||
'--text-secondary': '#575757', '--text-muted': '#8c8c8c', '--accent': '#1db954',
|
||||
'--accent-green': '#1db954', '--tag-bg': '#e0ffe0', '--tag-text': '#128a3c',
|
||||
'--code-bg': '#f5f5f5', '--search-bg': '#ffffff', '--scrollbar': '#d5d5d5',
|
||||
'--resize-handle': '#d5d5d5', '--overlay-bg': 'rgba(0,0,0,0.2)',
|
||||
'--danger': '#c92a2a', '--danger-bg': '#ffe3e3', '--success': '#1db954', '--success-bg': '#e0ffe0',
|
||||
'--accent-card': '#1db954', '--accent-bg': '#e0ffe0', '--accent-text': '#128a3c',
|
||||
'--green': '#1db954', '--green-bg': '#e0ffe0', '--purple': '#8a2be2', '--purple-bg': '#f3e0ff',
|
||||
'--surface': '#f5f5f5', '--surface2': '#ffffff', '--surface3': '#f0f0f0',
|
||||
'--border-md': '#d5d5d5', '--text': '#121212', '--text-2': '#575757', '--text-3': '#8c8c8c',
|
||||
'--ok': '#1db954', '--warn': '#9a6700', '--ai': '#8a2be2', '--err': '#c92a2a'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'notion': {
|
||||
name: 'Notion', desc: 'Lecture propre',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#191919', '--bg-secondary': '#202020', '--bg-sidebar': '#191919',
|
||||
'--bg-hover': '#262626', '--border': '#333333', '--text-primary': '#e6e6e6',
|
||||
'--text-secondary': '#999999', '--text-muted': '#666666', '--accent': '#4b85e8',
|
||||
'--accent-green': '#0ca16a', '--tag-bg': '#1c2744', '--tag-text': '#6ba1f0',
|
||||
'--code-bg': '#202020', '--search-bg': '#262626', '--scrollbar': '#333333',
|
||||
'--resize-handle': '#333333', '--overlay-bg': 'rgba(0,0,0,0.6)',
|
||||
'--danger': '#e03e3e', '--danger-bg': '#2a1010', '--success': '#0ca16a', '--success-bg': '#0d2818',
|
||||
'--accent-card': '#4b85e8', '--accent-bg': '#1c2744', '--accent-text': '#6ba1f0',
|
||||
'--green': '#0ca16a', '--green-bg': '#0d2818', '--purple': '#9065e0', '--purple-bg': '#24183d',
|
||||
'--surface': '#202020', '--surface2': '#191919', '--surface3': '#141414',
|
||||
'--border-md': '#333333', '--text': '#e6e6e6', '--text-2': '#999999', '--text-3': '#666666',
|
||||
'--ok': '#0ca16a', '--warn': '#dfab3e', '--ai': '#9065e0', '--err': '#e03e3e'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#ffffff', '--bg-secondary': '#f7f6f3', '--bg-sidebar': '#f7f6f3',
|
||||
'--bg-hover': '#edece9', '--border': '#e3e2e0', '--text-primary': '#37352f',
|
||||
'--text-secondary': '#787774', '--text-muted': '#abaaa7', '--accent': '#4b85e8',
|
||||
'--accent-green': '#0ca16a', '--tag-bg': '#e8f0fe', '--tag-text': '#4b85e8',
|
||||
'--code-bg': '#f7f6f3', '--search-bg': '#ffffff', '--scrollbar': '#e3e2e0',
|
||||
'--resize-handle': '#e3e2e0', '--overlay-bg': 'rgba(0,0,0,0.15)',
|
||||
'--danger': '#e03e3e', '--danger-bg': '#fdebec', '--success': '#0ca16a', '--success-bg': '#e6f7ec',
|
||||
'--accent-card': '#4b85e8', '--accent-bg': '#e8f0fe', '--accent-text': '#4b85e8',
|
||||
'--green': '#0ca16a', '--green-bg': '#e6f7ec', '--purple': '#9065e0', '--purple-bg': '#f3effc',
|
||||
'--surface': '#f7f6f3', '--surface2': '#ffffff', '--surface3': '#edece9',
|
||||
'--border-md': '#e3e2e0', '--text': '#37352f', '--text-2': '#787774', '--text-3': '#abaaa7',
|
||||
'--ok': '#0ca16a', '--warn': '#dfab3e', '--ai': '#9065e0', '--err': '#e03e3e'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'discord': {
|
||||
name: 'Discord', desc: 'Standard communautaire',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#313338', '--bg-secondary': '#2b2d31', '--bg-sidebar': '#1e1f22',
|
||||
'--bg-hover': '#3c3e45', '--border': '#3f4147', '--text-primary': '#f2f3f5',
|
||||
'--text-secondary': '#b5bac1', '--text-muted': '#80848e', '--accent': '#5865f2',
|
||||
'--accent-green': '#23a559', '--tag-bg': '#28315c', '--tag-text': '#8891f2',
|
||||
'--code-bg': '#2b2d31', '--search-bg': '#383a40', '--scrollbar': '#1a1b1e',
|
||||
'--resize-handle': '#3f4147', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#da373c', '--danger-bg': '#3a1518', '--success': '#23a559', '--success-bg': '#122b1a',
|
||||
'--accent-card': '#5865f2', '--accent-bg': '#28315c', '--accent-text': '#8891f2',
|
||||
'--green': '#23a559', '--green-bg': '#122b1a', '--purple': '#9c5bf0', '--purple-bg': '#2c1a46',
|
||||
'--surface': '#2b2d31', '--surface2': '#313338', '--surface3': '#1e1f22',
|
||||
'--border-md': '#3f4147', '--text': '#f2f3f5', '--text-2': '#b5bac1', '--text-3': '#80848e',
|
||||
'--ok': '#23a559', '--warn': '#f0b232', '--ai': '#9c5bf0', '--err': '#da373c'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#ffffff', '--bg-secondary': '#f2f3f5', '--bg-sidebar': '#e3e5e8',
|
||||
'--bg-hover': '#e3e5e8', '--border': '#d4d7dc', '--text-primary': '#313338',
|
||||
'--text-secondary': '#5c5e66', '--text-muted': '#91949e', '--accent': '#5865f2',
|
||||
'--accent-green': '#248046', '--tag-bg': '#e8eafe', '--tag-text': '#4752c4',
|
||||
'--code-bg': '#f2f3f5', '--search-bg': '#ffffff', '--scrollbar': '#d4d7dc',
|
||||
'--resize-handle': '#d4d7dc', '--overlay-bg': 'rgba(0,0,0,0.2)',
|
||||
'--danger': '#d92028', '--danger-bg': '#fde8e8', '--success': '#248046', '--success-bg': '#e6f7e8',
|
||||
'--accent-card': '#5865f2', '--accent-bg': '#e8eafe', '--accent-text': '#4752c4',
|
||||
'--green': '#248046', '--green-bg': '#e6f7e8', '--purple': '#8b5cf6', '--purple-bg': '#f0ebff',
|
||||
'--surface': '#f2f3f5', '--surface2': '#ffffff', '--surface3': '#e3e5e8',
|
||||
'--border-md': '#d4d7dc', '--text': '#313338', '--text-2': '#5c5e66', '--text-3': '#91949e',
|
||||
'--ok': '#248046', '--warn': '#9a6700', '--ai': '#8b5cf6', '--err': '#d92028'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'dracula': {
|
||||
name: 'Dracula', desc: 'Accents rose & violet',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#282a36', '--bg-secondary': '#21222c', '--bg-sidebar': '#1d1e26',
|
||||
'--bg-hover': '#343746', '--border': '#3b3d54', '--text-primary': '#f8f8f2',
|
||||
'--text-secondary': '#b2b2b2', '--text-muted': '#6272a4', '--accent': '#bd93f9',
|
||||
'--accent-green': '#50fa7b', '--tag-bg': '#2a2240', '--tag-text': '#bd93f9',
|
||||
'--code-bg': '#21222c', '--search-bg': '#343746', '--scrollbar': '#44475a',
|
||||
'--resize-handle': '#3b3d54', '--overlay-bg': 'rgba(0,0,0,0.6)',
|
||||
'--danger': '#ff5555', '--danger-bg': '#3a1a1a', '--success': '#50fa7b', '--success-bg': '#0d2818',
|
||||
'--accent-card': '#bd93f9', '--accent-bg': '#2a2240', '--accent-text': '#bd93f9',
|
||||
'--green': '#50fa7b', '--green-bg': '#0d2818', '--purple': '#bd93f9', '--purple-bg': '#2a2240',
|
||||
'--surface': '#21222c', '--surface2': '#282a36', '--surface3': '#1d1e26',
|
||||
'--border-md': '#3b3d54', '--text': '#f8f8f2', '--text-2': '#b2b2b2', '--text-3': '#6272a4',
|
||||
'--ok': '#50fa7b', '--warn': '#f1fa8c', '--ai': '#bd93f9', '--err': '#ff5555'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#f8f8f2', '--bg-secondary': '#f0f0ea', '--bg-sidebar': '#e8e8e0',
|
||||
'--bg-hover': '#e0e0d8', '--border': '#d5d5cc', '--text-primary': '#282a36',
|
||||
'--text-secondary': '#4d5066', '--text-muted': '#8b8fa8', '--accent': '#8a5cf6',
|
||||
'--accent-green': '#3d8f54', '--tag-bg': '#ede0ff', '--tag-text': '#8a5cf6',
|
||||
'--code-bg': '#f0f0ea', '--search-bg': '#f8f8f2', '--scrollbar': '#d5d5cc',
|
||||
'--resize-handle': '#d5d5cc', '--overlay-bg': 'rgba(0,0,0,0.15)',
|
||||
'--danger': '#d7373f', '--danger-bg': '#fde8e8', '--success': '#3d8f54', '--success-bg': '#e6f7e8',
|
||||
'--accent-card': '#8a5cf6', '--accent-bg': '#ede0ff', '--accent-text': '#8a5cf6',
|
||||
'--green': '#3d8f54', '--green-bg': '#e6f7e8', '--purple': '#8a5cf6', '--purple-bg': '#ede0ff',
|
||||
'--surface': '#f0f0ea', '--surface2': '#f8f8f2', '--surface3': '#e8e8e0',
|
||||
'--border-md': '#d5d5cc', '--text': '#282a36', '--text-2': '#4d5066', '--text-3': '#8b8fa8',
|
||||
'--ok': '#3d8f54', '--warn': '#8b7500', '--ai': '#8a5cf6', '--err': '#d7373f'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'one-dark': {
|
||||
name: 'One Dark', desc: 'Inspire d\'Atom',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#282c34', '--bg-secondary': '#21252b', '--bg-sidebar': '#1c1f26',
|
||||
'--bg-hover': '#333842', '--border': '#3e4452', '--text-primary': '#abb2bf',
|
||||
'--text-secondary': '#828997', '--text-muted': '#5c6370', '--accent': '#61afef',
|
||||
'--accent-green': '#98c379', '--tag-bg': '#1e3350', '--tag-text': '#61afef',
|
||||
'--code-bg': '#21252b', '--search-bg': '#333842', '--scrollbar': '#3e4452',
|
||||
'--resize-handle': '#3e4452', '--overlay-bg': 'rgba(0,0,0,0.6)',
|
||||
'--danger': '#e06c75', '--danger-bg': '#351b1e', '--success': '#98c379', '--success-bg': '#1a2e1a',
|
||||
'--accent-card': '#61afef', '--accent-bg': '#1e3350', '--accent-text': '#61afef',
|
||||
'--green': '#98c379', '--green-bg': '#1a2e1a', '--purple': '#c678dd', '--purple-bg': '#2c1a3a',
|
||||
'--surface': '#21252b', '--surface2': '#282c34', '--surface3': '#1c1f26',
|
||||
'--border-md': '#3e4452', '--text': '#abb2bf', '--text-2': '#828997', '--text-3': '#5c6370',
|
||||
'--ok': '#98c379', '--warn': '#e5c07b', '--ai': '#c678dd', '--err': '#e06c75'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#fafafa', '--bg-secondary': '#f0f0f0', '--bg-sidebar': '#e8e8e8',
|
||||
'--bg-hover': '#e0e0e0', '--border': '#d4d4d4', '--text-primary': '#383a42',
|
||||
'--text-secondary': '#696c77', '--text-muted': '#9ca0a4', '--accent': '#4078f2',
|
||||
'--accent-green': '#50a14f', '--tag-bg': '#e0e8ff', '--tag-text': '#4078f2',
|
||||
'--code-bg': '#f0f0f0', '--search-bg': '#fafafa', '--scrollbar': '#d4d4d4',
|
||||
'--resize-handle': '#d4d4d4', '--overlay-bg': 'rgba(0,0,0,0.15)',
|
||||
'--danger': '#e45649', '--danger-bg': '#fde8e8', '--success': '#50a14f', '--success-bg': '#e6f7e6',
|
||||
'--accent-card': '#4078f2', '--accent-bg': '#e0e8ff', '--accent-text': '#4078f2',
|
||||
'--green': '#50a14f', '--green-bg': '#e6f7e6', '--purple': '#a626a4', '--purple-bg': '#f8e0f8',
|
||||
'--surface': '#f0f0f0', '--surface2': '#fafafa', '--surface3': '#e8e8e8',
|
||||
'--border-md': '#d4d4d4', '--text': '#383a42', '--text-2': '#696c77', '--text-3': '#9ca0a4',
|
||||
'--ok': '#50a14f', '--warn': '#986801', '--ai': '#a626a4', '--err': '#e45649'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'tokyo-night': {
|
||||
name: 'Tokyo Night', desc: 'Bleu neon',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#1a1b26', '--bg-secondary': '#16161e', '--bg-sidebar': '#13131a',
|
||||
'--bg-hover': '#24283b', '--border': '#2f3545', '--text-primary': '#c0caf5',
|
||||
'--text-secondary': '#a9b1d6', '--text-muted': '#565f89', '--accent': '#7aa2f7',
|
||||
'--accent-green': '#9ece6a', '--tag-bg': '#1f2845', '--tag-text': '#7aa2f7',
|
||||
'--code-bg': '#16161e', '--search-bg': '#24283b', '--scrollbar': '#2f3545',
|
||||
'--resize-handle': '#2f3545', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#f7768e', '--danger-bg': '#361a22', '--success': '#9ece6a', '--success-bg': '#1a2e1a',
|
||||
'--accent-card': '#7aa2f7', '--accent-bg': '#1f2845', '--accent-text': '#7aa2f7',
|
||||
'--green': '#9ece6a', '--green-bg': '#1a2e1a', '--purple': '#bb9af7', '--purple-bg': '#241a3a',
|
||||
'--surface': '#16161e', '--surface2': '#1a1b26', '--surface3': '#13131a',
|
||||
'--border-md': '#2f3545', '--text': '#c0caf5', '--text-2': '#a9b1d6', '--text-3': '#565f89',
|
||||
'--ok': '#9ece6a', '--warn': '#e0af68', '--ai': '#bb9af7', '--err': '#f7768e'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#d5d6db', '--bg-secondary': '#c8c9d0', '--bg-sidebar': '#bfc0c8',
|
||||
'--bg-hover': '#bbbbc4', '--border': '#a9aab3', '--text-primary': '#343b58',
|
||||
'--text-secondary': '#565a6e', '--text-muted': '#848cb0', '--accent': '#34548a',
|
||||
'--accent-green': '#485e30', '--tag-bg': '#dfe5f5', '--tag-text': '#34548a',
|
||||
'--code-bg': '#c8c9d0', '--search-bg': '#d5d6db', '--scrollbar': '#a9aab3',
|
||||
'--resize-handle': '#a9aab3', '--overlay-bg': 'rgba(0,0,0,0.15)',
|
||||
'--danger': '#8c4351', '--danger-bg': '#f8e0e0', '--success': '#485e30', '--success-bg': '#e0f0d8',
|
||||
'--accent-card': '#34548a', '--accent-bg': '#dfe5f5', '--accent-text': '#34548a',
|
||||
'--green': '#485e30', '--green-bg': '#e0f0d8', '--purple': '#5a4a9a', '--purple-bg': '#e8e0f8',
|
||||
'--surface': '#c8c9d0', '--surface2': '#d5d6db', '--surface3': '#bfc0c8',
|
||||
'--border-md': '#a9aab3', '--text': '#343b58', '--text-2': '#565a6e', '--text-3': '#848cb0',
|
||||
'--ok': '#485e30', '--warn': '#8f5e15', '--ai': '#5a4a9a', '--err': '#8c4351'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'nord': {
|
||||
name: 'Nord', desc: 'Style arctique',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#2e3440', '--bg-secondary': '#3b4252', '--bg-sidebar': '#242933',
|
||||
'--bg-hover': '#434c5e', '--border': '#4c566a', '--text-primary': '#eceff4',
|
||||
'--text-secondary': '#d8dee9', '--text-muted': '#7b88a1', '--accent': '#88c0d0',
|
||||
'--accent-green': '#a3be8c', '--tag-bg': '#2a3a48', '--tag-text': '#88c0d0',
|
||||
'--code-bg': '#3b4252', '--search-bg': '#434c5e', '--scrollbar': '#4c566a',
|
||||
'--resize-handle': '#4c566a', '--overlay-bg': 'rgba(0,0,0,0.6)',
|
||||
'--danger': '#bf616a', '--danger-bg': '#3a2025', '--success': '#a3be8c', '--success-bg': '#1a2e1a',
|
||||
'--accent-card': '#88c0d0', '--accent-bg': '#2a3a48', '--accent-text': '#88c0d0',
|
||||
'--green': '#a3be8c', '--green-bg': '#1a2e1a', '--purple': '#b48ead', '--purple-bg': '#2a2030',
|
||||
'--surface': '#3b4252', '--surface2': '#2e3440', '--surface3': '#242933',
|
||||
'--border-md': '#4c566a', '--text': '#eceff4', '--text-2': '#d8dee9', '--text-3': '#7b88a1',
|
||||
'--ok': '#a3be8c', '--warn': '#ebcb8b', '--ai': '#b48ead', '--err': '#bf616a'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#eceff4', '--bg-secondary': '#e5e9f0', '--bg-sidebar': '#d8dee9',
|
||||
'--bg-hover': '#d8dee9', '--border': '#c8cfdb', '--text-primary': '#2e3440',
|
||||
'--text-secondary': '#4c566a', '--text-muted': '#7b88a1', '--accent': '#5e81ac',
|
||||
'--accent-green': '#719350', '--tag-bg': '#dfe8f0', '--tag-text': '#5e81ac',
|
||||
'--code-bg': '#e5e9f0', '--search-bg': '#eceff4', '--scrollbar': '#c8cfdb',
|
||||
'--resize-handle': '#c8cfdb', '--overlay-bg': 'rgba(0,0,0,0.12)',
|
||||
'--danger': '#bf616a', '--danger-bg': '#f8e0e0', '--success': '#719350', '--success-bg': '#e0f0d8',
|
||||
'--accent-card': '#5e81ac', '--accent-bg': '#dfe8f0', '--accent-text': '#5e81ac',
|
||||
'--green': '#719350', '--green-bg': '#e0f0d8', '--purple': '#7b5e95', '--purple-bg': '#e8dff0',
|
||||
'--surface': '#e5e9f0', '--surface2': '#eceff4', '--surface3': '#d8dee9',
|
||||
'--border-md': '#c8cfdb', '--text': '#2e3440', '--text-2': '#4c566a', '--text-3': '#7b88a1',
|
||||
'--ok': '#719350', '--warn': '#8f6b20', '--ai': '#7b5e95', '--err': '#bf616a'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'night-owl': {
|
||||
name: 'Night Owl', desc: 'Optimise pour la nuit',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#011627', '--bg-secondary': '#01111d', '--bg-sidebar': '#010d18',
|
||||
'--bg-hover': '#0b2942', '--border': '#1d3b53', '--text-primary': '#d6deeb',
|
||||
'--text-secondary': '#7e97b0', '--text-muted': '#4b6479', '--accent': '#82aaff',
|
||||
'--accent-green': '#c3e88d', '--tag-bg': '#152d45', '--tag-text': '#82aaff',
|
||||
'--code-bg': '#01111d', '--search-bg': '#0b2942', '--scrollbar': '#1d3b53',
|
||||
'--resize-handle': '#1d3b53', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#ef5350', '--danger-bg': '#2a1018', '--success': '#c3e88d', '--success-bg': '#0d2818',
|
||||
'--accent-card': '#82aaff', '--accent-bg': '#152d45', '--accent-text': '#82aaff',
|
||||
'--green': '#c3e88d', '--green-bg': '#0d2818', '--purple': '#c792ea', '--purple-bg': '#201838',
|
||||
'--surface': '#01111d', '--surface2': '#011627', '--surface3': '#010d18',
|
||||
'--border-md': '#1d3b53', '--text': '#d6deeb', '--text-2': '#7e97b0', '--text-3': '#4b6479',
|
||||
'--ok': '#c3e88d', '--warn': '#ffcb8b', '--ai': '#c792ea', '--err': '#ef5350'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#fbfbfb', '--bg-secondary': '#f0f0f0', '--bg-sidebar': '#e8e8e8',
|
||||
'--bg-hover': '#e0e0e0', '--border': '#d4d4d4', '--text-primary': '#403f53',
|
||||
'--text-secondary': '#5f6c7a', '--text-muted': '#8e99a8', '--accent': '#4876d6',
|
||||
'--accent-green': '#2d8a4e', '--tag-bg': '#e0e8ff', '--tag-text': '#4876d6',
|
||||
'--code-bg': '#f0f0f0', '--search-bg': '#fbfbfb', '--scrollbar': '#d4d4d4',
|
||||
'--resize-handle': '#d4d4d4', '--overlay-bg': 'rgba(0,0,0,0.12)',
|
||||
'--danger': '#c92a2a', '--danger-bg': '#ffe3e3', '--success': '#2d8a4e', '--success-bg': '#e0ffe0',
|
||||
'--accent-card': '#4876d6', '--accent-bg': '#e0e8ff', '--accent-text': '#4876d6',
|
||||
'--green': '#2d8a4e', '--green-bg': '#e0ffe0', '--purple': '#7c3aed', '--purple-bg': '#ede0ff',
|
||||
'--surface': '#f0f0f0', '--surface2': '#fbfbfb', '--surface3': '#e8e8e8',
|
||||
'--border-md': '#d4d4d4', '--text': '#403f53', '--text-2': '#5f6c7a', '--text-3': '#8e99a8',
|
||||
'--ok': '#2d8a4e', '--warn': '#8f6b20', '--ai': '#7c3aed', '--err': '#c92a2a'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'anthracite': {
|
||||
name: 'Anthracite', desc: 'Material dark',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#1e1e1e', '--bg-secondary': '#252526', '--bg-sidebar': '#1a1a1a',
|
||||
'--bg-hover': '#2d2d2d', '--border': '#3e3e3e', '--text-primary': '#d4d4d4',
|
||||
'--text-secondary': '#9d9d9d', '--text-muted': '#6e6e6e', '--accent': '#9c7aef',
|
||||
'--accent-green': '#6a9955', '--tag-bg': '#2a2250', '--tag-text': '#9c7aef',
|
||||
'--code-bg': '#252526', '--search-bg': '#2d2d2d', '--scrollbar': '#3e3e3e',
|
||||
'--resize-handle': '#3e3e3e', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#f44747', '--danger-bg': '#3a1a1a', '--success': '#6a9955', '--success-bg': '#1a2e1a',
|
||||
'--accent-card': '#9c7aef', '--accent-bg': '#2a2250', '--accent-text': '#9c7aef',
|
||||
'--green': '#6a9955', '--green-bg': '#1a2e1a', '--purple': '#9c7aef', '--purple-bg': '#2a2250',
|
||||
'--surface': '#252526', '--surface2': '#1e1e1e', '--surface3': '#1a1a1a',
|
||||
'--border-md': '#3e3e3e', '--text': '#d4d4d4', '--text-2': '#9d9d9d', '--text-3': '#6e6e6e',
|
||||
'--ok': '#6a9955', '--warn': '#d7ba7d', '--ai': '#9c7aef', '--err': '#f44747'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#fafafa', '--bg-secondary': '#f5f5f5', '--bg-sidebar': '#eeeeee',
|
||||
'--bg-hover': '#e5e5e5', '--border': '#d5d5d5', '--text-primary': '#212121',
|
||||
'--text-secondary': '#616161', '--text-muted': '#9e9e9e', '--accent': '#7c3aed',
|
||||
'--accent-green': '#388e3c', '--tag-bg': '#ede0ff', '--tag-text': '#7c3aed',
|
||||
'--code-bg': '#f5f5f5', '--search-bg': '#fafafa', '--scrollbar': '#d5d5d5',
|
||||
'--resize-handle': '#d5d5d5', '--overlay-bg': 'rgba(0,0,0,0.12)',
|
||||
'--danger': '#d32f2f', '--danger-bg': '#fde8e8', '--success': '#388e3c', '--success-bg': '#e8f5e9',
|
||||
'--accent-card': '#7c3aed', '--accent-bg': '#ede0ff', '--accent-text': '#7c3aed',
|
||||
'--green': '#388e3c', '--green-bg': '#e8f5e9', '--purple': '#7c3aed', '--purple-bg': '#ede0ff',
|
||||
'--surface': '#f5f5f5', '--surface2': '#fafafa', '--surface3': '#eeeeee',
|
||||
'--border-md': '#d5d5d5', '--text': '#212121', '--text-2': '#616161', '--text-3': '#9e9e9e',
|
||||
'--ok': '#388e3c', '--warn': '#8f6b20', '--ai': '#7c3aed', '--err': '#d32f2f'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'cyberpunk': {
|
||||
name: 'Cyberpunk', desc: 'Neon vibrant',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#0a0a0f', '--bg-secondary': '#111122', '--bg-sidebar': '#08080d',
|
||||
'--bg-hover': '#1a1a33', '--border': '#2a2a55', '--text-primary': '#e0e0ff',
|
||||
'--text-secondary': '#8888cc', '--text-muted': '#555599', '--accent': '#00ffff',
|
||||
'--accent-green': '#00ff88', '--tag-bg': '#0a2a3a', '--tag-text': '#00ffff',
|
||||
'--code-bg': '#111122', '--search-bg': '#1a1a33', '--scrollbar': '#2a2a55',
|
||||
'--resize-handle': '#2a2a55', '--overlay-bg': 'rgba(0,0,0,0.8)',
|
||||
'--danger': '#ff3366', '--danger-bg': '#331122', '--success': '#00ff88', '--success-bg': '#003322',
|
||||
'--accent-card': '#00ffff', '--accent-bg': '#0a2a3a', '--accent-text': '#00ffff',
|
||||
'--green': '#00ff88', '--green-bg': '#003322', '--purple': '#ff00ff', '--purple-bg': '#220033',
|
||||
'--surface': '#111122', '--surface2': '#0a0a0f', '--surface3': '#08080d',
|
||||
'--border-md': '#2a2a55', '--text': '#e0e0ff', '--text-2': '#8888cc', '--text-3': '#555599',
|
||||
'--ok': '#00ff88', '--warn': '#ffaa00', '--ai': '#ff00ff', '--err': '#ff3366'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#f0f0ff', '--bg-secondary': '#e8e8ff', '--bg-sidebar': '#e0e0f8',
|
||||
'--bg-hover': '#d8d8f0', '--border': '#c0c0e0', '--text-primary': '#1a1a33',
|
||||
'--text-secondary': '#4a4a6a', '--text-muted': '#8080a0', '--accent': '#0077cc',
|
||||
'--accent-green': '#008844', '--tag-bg': '#ddeeff', '--tag-text': '#0077cc',
|
||||
'--code-bg': '#e8e8ff', '--search-bg': '#f0f0ff', '--scrollbar': '#c0c0e0',
|
||||
'--resize-handle': '#c0c0e0', '--overlay-bg': 'rgba(0,0,0,0.1)',
|
||||
'--danger': '#cc2244', '--danger-bg': '#ffe0e8', '--success': '#008844', '--success-bg': '#d8ffe0',
|
||||
'--accent-card': '#0077cc', '--accent-bg': '#ddeeff', '--accent-text': '#0077cc',
|
||||
'--green': '#008844', '--green-bg': '#d8ffe0', '--purple': '#8800cc', '--purple-bg': '#f0d8ff',
|
||||
'--surface': '#e8e8ff', '--surface2': '#f0f0ff', '--surface3': '#e0e0f8',
|
||||
'--border-md': '#c0c0e0', '--text': '#1a1a33', '--text-2': '#4a4a6a', '--text-3': '#8080a0',
|
||||
'--ok': '#008844', '--warn': '#996600', '--ai': '#8800cc', '--err': '#cc2244'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'navy-elegance': {
|
||||
name: 'Navy Elegance', desc: 'Bleu profond & or',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#0a1628', '--bg-secondary': '#0d1f3c', '--bg-sidebar': '#081220',
|
||||
'--bg-hover': '#162d50', '--border': '#1e3a5f', '--text-primary': '#d4dde8',
|
||||
'--text-secondary': '#8899b4', '--text-muted': '#556688', '--accent': '#c9a84c',
|
||||
'--accent-green': '#4caf7c', '--tag-bg': '#1e2840', '--tag-text': '#c9a84c',
|
||||
'--code-bg': '#0d1f3c', '--search-bg': '#162d50', '--scrollbar': '#1e3a5f',
|
||||
'--resize-handle': '#1e3a5f', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#d94a5a', '--danger-bg': '#351a22', '--success': '#4caf7c', '--success-bg': '#12281e',
|
||||
'--accent-card': '#c9a84c', '--accent-bg': '#282218', '--accent-text': '#c9a84c',
|
||||
'--green': '#4caf7c', '--green-bg': '#12281e', '--purple': '#8b6fc0', '--purple-bg': '#201a3a',
|
||||
'--surface': '#0d1f3c', '--surface2': '#0a1628', '--surface3': '#081220',
|
||||
'--border-md': '#1e3a5f', '--text': '#d4dde8', '--text-2': '#8899b4', '--text-3': '#556688',
|
||||
'--ok': '#4caf7c', '--warn': '#c9a84c', '--ai': '#8b6fc0', '--err': '#d94a5a'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#f5f0e8', '--bg-secondary': '#ede6d8', '--bg-sidebar': '#e8dfd0',
|
||||
'--bg-hover': '#e0d8c8', '--border': '#c8bda8', '--text-primary': '#2a3a50',
|
||||
'--text-secondary': '#5a6a80', '--text-muted': '#8a9aa8', '--accent': '#8b6914',
|
||||
'--accent-green': '#3d7a4c', '--tag-bg': '#f0e8d0', '--tag-text': '#8b6914',
|
||||
'--code-bg': '#ede6d8', '--search-bg': '#f5f0e8', '--scrollbar': '#c8bda8',
|
||||
'--resize-handle': '#c8bda8', '--overlay-bg': 'rgba(0,0,0,0.1)',
|
||||
'--danger': '#b8383a', '--danger-bg': '#f8e0e0', '--success': '#3d7a4c', '--success-bg': '#e0f0e0',
|
||||
'--accent-card': '#8b6914', '--accent-bg': '#f0e8d0', '--accent-text': '#8b6914',
|
||||
'--green': '#3d7a4c', '--green-bg': '#e0f0e0', '--purple': '#6a4c8a', '--purple-bg': '#e8dff0',
|
||||
'--surface': '#ede6d8', '--surface2': '#f5f0e8', '--surface3': '#e8dfd0',
|
||||
'--border-md': '#c8bda8', '--text': '#2a3a50', '--text-2': '#5a6a80', '--text-3': '#8a9aa8',
|
||||
'--ok': '#3d7a4c', '--warn': '#8b6914', '--ai': '#6a4c8a', '--err': '#b8383a'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'tons-terreux': {
|
||||
name: 'Tons Terreux', desc: 'Sensation organique',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#1c1a16', '--bg-secondary': '#252218', '--bg-sidebar': '#161410',
|
||||
'--bg-hover': '#302b20', '--border': '#3d3628', '--text-primary': '#e8e0d0',
|
||||
'--text-secondary': '#a89880', '--text-muted': '#6b5d48', '--accent': '#c8a050',
|
||||
'--accent-green': '#6b9b50', '--tag-bg': '#2a2418', '--tag-text': '#c8a050',
|
||||
'--code-bg': '#252218', '--search-bg': '#302b20', '--scrollbar': '#3d3628',
|
||||
'--resize-handle': '#3d3628', '--overlay-bg': 'rgba(0,0,0,0.7)',
|
||||
'--danger': '#c86050', '--danger-bg': '#352018', '--success': '#6b9b50', '--success-bg': '#1a2a14',
|
||||
'--accent-card': '#c8a050', '--accent-bg': '#2a2418', '--accent-text': '#c8a050',
|
||||
'--green': '#6b9b50', '--green-bg': '#1a2a14', '--purple': '#9070b0', '--purple-bg': '#221a30',
|
||||
'--surface': '#252218', '--surface2': '#1c1a16', '--surface3': '#161410',
|
||||
'--border-md': '#3d3628', '--text': '#e8e0d0', '--text-2': '#a89880', '--text-3': '#6b5d48',
|
||||
'--ok': '#6b9b50', '--warn': '#c8a050', '--ai': '#9070b0', '--err': '#c86050'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#f8f4ec', '--bg-secondary': '#f0e8d8', '--bg-sidebar': '#e8e0d0',
|
||||
'--bg-hover': '#e0d8c8', '--border': '#c8bda0', '--text-primary': '#3d3028',
|
||||
'--text-secondary': '#6b5d48', '--text-muted': '#9a8a70', '--accent': '#8b6914',
|
||||
'--accent-green': '#5a8440', '--tag-bg': '#f0e0c8', '--tag-text': '#8b6914',
|
||||
'--code-bg': '#f0e8d8', '--search-bg': '#f8f4ec', '--scrollbar': '#c8bda0',
|
||||
'--resize-handle': '#c8bda0', '--overlay-bg': 'rgba(0,0,0,0.1)',
|
||||
'--danger': '#a84040', '--danger-bg': '#f8e0e0', '--success': '#5a8440', '--success-bg': '#e0f0d8',
|
||||
'--accent-card': '#8b6914', '--accent-bg': '#f0e0c8', '--accent-text': '#8b6914',
|
||||
'--green': '#5a8440', '--green-bg': '#e0f0d8', '--purple': '#7058a0', '--purple-bg': '#e8dff8',
|
||||
'--surface': '#f0e8d8', '--surface2': '#f8f4ec', '--surface3': '#e8e0d0',
|
||||
'--border-md': '#c8bda0', '--text': '#3d3028', '--text-2': '#6b5d48', '--text-3': '#9a8a70',
|
||||
'--ok': '#5a8440', '--warn': '#8b6914', '--ai': '#7058a0', '--err': '#a84040'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Theme Engine ─────────────────────────────────────────────────────
|
||||
var _currentTheme = 'defaut-obsigate';
|
||||
var _currentMode = 'dark';
|
||||
|
||||
function applyTheme(themeKey, mode) {
|
||||
var theme = THEMES[themeKey];
|
||||
if (!theme) return;
|
||||
mode = mode || _currentMode || 'dark';
|
||||
var vars = theme.modes[mode];
|
||||
if (!vars) return;
|
||||
|
||||
var root = document.documentElement;
|
||||
Object.keys(vars).forEach(function(key) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
|
||||
_currentTheme = themeKey;
|
||||
_currentMode = mode;
|
||||
try { localStorage.setItem('obsigate-theme', themeKey); } catch(e) {}
|
||||
try { localStorage.setItem('obsigate-theme-mode', mode); } catch(e) {}
|
||||
}
|
||||
|
||||
function toggleThemeMode() {
|
||||
var newMode = _currentMode === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(_currentTheme, newMode);
|
||||
return newMode;
|
||||
}
|
||||
|
||||
function getCurrentTheme() { return _currentTheme; }
|
||||
function getCurrentMode() { return _currentMode; }
|
||||
|
||||
function initThemes() {
|
||||
var saved = null;
|
||||
var savedMode = null;
|
||||
try { saved = localStorage.getItem('obsigate-theme'); } catch(e) {}
|
||||
try { savedMode = localStorage.getItem('obsigate-theme-mode'); } catch(e) {}
|
||||
if (saved && THEMES[saved]) _currentTheme = saved;
|
||||
if (savedMode) _currentMode = savedMode;
|
||||
applyTheme(_currentTheme, _currentMode);
|
||||
}
|
||||
|
||||
export { THEMES, applyTheme, toggleThemeMode, getCurrentTheme, getCurrentMode, initThemes };
|
||||
@@ -0,0 +1,679 @@
|
||||
import { state } from './state.js';
|
||||
import { api } from './auth.js';
|
||||
import { openFile, showWelcome } from './viewer.js';
|
||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
||||
import { createAIToolbar } from './ai.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXT_ICONS = {
|
||||
// Text files
|
||||
".md": "file-text",
|
||||
".txt": "file-text",
|
||||
".log": "file-text",
|
||||
".readme": "file-text",
|
||||
".rst": "file-text",
|
||||
".adoc": "file-text",
|
||||
|
||||
// Web development
|
||||
".html": "file-code",
|
||||
".htm": "file-code",
|
||||
".css": "file-code",
|
||||
".scss": "file-code",
|
||||
".sass": "file-code",
|
||||
".less": "file-code",
|
||||
".js": "file-code",
|
||||
".jsx": "file-code",
|
||||
".ts": "file-code",
|
||||
".tsx": "file-code",
|
||||
".vue": "file-code",
|
||||
".svelte": "file-code",
|
||||
|
||||
// Programming languages
|
||||
".py": "file-code",
|
||||
".java": "file-code",
|
||||
".c": "file-code",
|
||||
".cpp": "file-code",
|
||||
".cc": "file-code",
|
||||
".cxx": "file-code",
|
||||
".h": "file-code",
|
||||
".hpp": "file-code",
|
||||
".cs": "file-code",
|
||||
".go": "file-code",
|
||||
".rs": "file-code",
|
||||
".rb": "file-code",
|
||||
".php": "file-code",
|
||||
".swift": "file-code",
|
||||
".kt": "file-code",
|
||||
".scala": "file-code",
|
||||
".r": "file-code",
|
||||
".m": "file-code",
|
||||
".pl": "file-code",
|
||||
".lua": "file-code",
|
||||
".dart": "file-code",
|
||||
".nim": "file-code",
|
||||
".zig": "file-code",
|
||||
".odin": "file-code",
|
||||
".v": "file-code",
|
||||
".cr": "file-code",
|
||||
".ex": "file-code",
|
||||
".exs": "file-code",
|
||||
".elm": "file-code",
|
||||
".purs": "file-code",
|
||||
".hs": "file-code",
|
||||
".ml": "file-code",
|
||||
".ocaml": "file-code",
|
||||
".fs": "file-code",
|
||||
".fsx": "file-code",
|
||||
".vb": "file-code",
|
||||
".pas": "file-code",
|
||||
".pp": "file-code",
|
||||
".inc": "file-code",
|
||||
|
||||
// Data formats
|
||||
".json": "file-json",
|
||||
".yaml": "file-cog",
|
||||
".yml": "file-cog",
|
||||
".toml": "file-cog",
|
||||
".xml": "file-code",
|
||||
".csv": "table",
|
||||
".tsv": "table",
|
||||
".sql": "database",
|
||||
".db": "database",
|
||||
".sqlite": "database",
|
||||
".sqlite3": "database",
|
||||
".parquet": "database",
|
||||
".avro": "database",
|
||||
|
||||
// Configuration files
|
||||
".ini": "file-cog",
|
||||
".cfg": "file-cog",
|
||||
".conf": "file-cog",
|
||||
".env": "file-cog",
|
||||
".dockerfile": "file-cog",
|
||||
".gitignore": "file-cog",
|
||||
".gitattributes": "file-cog",
|
||||
".editorconfig": "file-cog",
|
||||
".eslintrc": "file-cog",
|
||||
".prettierrc": "file-cog",
|
||||
".babelrc": "file-cog",
|
||||
".tsconfig": "file-cog",
|
||||
"package.json": "file-cog",
|
||||
"package-lock.json": "file-cog",
|
||||
"yarn.lock": "file-cog",
|
||||
"composer.json": "file-cog",
|
||||
"requirements.txt": "file-cog",
|
||||
"pipfile": "file-cog",
|
||||
"gemfile": "file-cog",
|
||||
"cargo.toml": "file-cog",
|
||||
"go.mod": "file-cog",
|
||||
"go.sum": "file-cog",
|
||||
"pom.xml": "file-cog",
|
||||
"build.gradle": "file-cog",
|
||||
"cmakelists.txt": "file-cog",
|
||||
"makefile": "file-cog",
|
||||
|
||||
// Shell scripts
|
||||
".sh": "terminal",
|
||||
".bash": "terminal",
|
||||
".zsh": "terminal",
|
||||
".fish": "terminal",
|
||||
".bat": "terminal",
|
||||
".cmd": "terminal",
|
||||
".ps1": "terminal",
|
||||
".psm1": "terminal",
|
||||
".psd1": "terminal",
|
||||
|
||||
// Document formats
|
||||
".pdf": "file-text",
|
||||
".doc": "file-text",
|
||||
".docx": "file-text",
|
||||
".rtf": "file-text",
|
||||
".odt": "file-text",
|
||||
".tex": "file-text",
|
||||
".latex": "file-text",
|
||||
|
||||
// Image files
|
||||
".png": "file-image",
|
||||
".jpg": "file-image",
|
||||
".jpeg": "file-image",
|
||||
".gif": "file-image",
|
||||
".svg": "file-image",
|
||||
".webp": "file-image",
|
||||
".bmp": "file-image",
|
||||
".ico": "file-image",
|
||||
".tiff": "file-image",
|
||||
".tif": "file-image",
|
||||
|
||||
// Audio files
|
||||
".mp3": "file-music",
|
||||
".wav": "file-music",
|
||||
".flac": "file-music",
|
||||
".aac": "file-music",
|
||||
".ogg": "file-music",
|
||||
".m4a": "file-music",
|
||||
".wma": "file-music",
|
||||
|
||||
// Video files
|
||||
".mp4": "play",
|
||||
".avi": "play",
|
||||
".mov": "play",
|
||||
".wmv": "play",
|
||||
".flv": "play",
|
||||
".webm": "play",
|
||||
".mkv": "play",
|
||||
".m4v": "play",
|
||||
".3gp": "play",
|
||||
|
||||
// Archive files
|
||||
".zip": "file-archive",
|
||||
".rar": "file-archive",
|
||||
".7z": "file-archive",
|
||||
".tar": "file-archive",
|
||||
".gz": "file-archive",
|
||||
".tgz": "file-archive",
|
||||
".bz2": "file-archive",
|
||||
".xz": "file-archive",
|
||||
".deb": "file-archive",
|
||||
".rpm": "file-archive",
|
||||
".dmg": "file-archive",
|
||||
".pkg": "file-archive",
|
||||
".msi": "file-archive",
|
||||
".exe": "file-archive",
|
||||
|
||||
// Font files
|
||||
".ttf": "file-type",
|
||||
".otf": "file-type",
|
||||
".woff": "file-type",
|
||||
".woff2": "file-type",
|
||||
".eot": "file-type",
|
||||
|
||||
// Other common files
|
||||
".key": "file-cog",
|
||||
".pem": "file-cog",
|
||||
".crt": "file-cog",
|
||||
".cert": "file-cog",
|
||||
".p12": "file-cog",
|
||||
".pfx": "file-cog",
|
||||
".lock": "file-cog",
|
||||
".tmp": "file",
|
||||
".bak": "file",
|
||||
".old": "file",
|
||||
".orig": "file",
|
||||
".save": "file",
|
||||
};
|
||||
|
||||
function getFileIcon(name) {
|
||||
const ext = "." + name.split(".").pop().toLowerCase();
|
||||
return EXT_ICONS[ext] || "file";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe CDN helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _iconDebounceTimer = null;
|
||||
|
||||
/**
|
||||
* Debounced icon creation — batches multiple rapid calls into one
|
||||
* DOM scan to avoid excessive reflows when building large trees.
|
||||
*/
|
||||
function safeCreateIcons() {
|
||||
if (typeof lucide === "undefined" || !lucide.createIcons) return;
|
||||
if (state._iconDebounceTimer) return; // already scheduled
|
||||
state._iconDebounceTimer = requestAnimationFrame(() => {
|
||||
state._iconDebounceTimer = null;
|
||||
try {
|
||||
lucide.createIcons();
|
||||
} catch (e) {
|
||||
/* CDN not loaded */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Force-flush icon creation immediately (use sparingly). */
|
||||
function flushIcons() {
|
||||
if (state._iconDebounceTimer) {
|
||||
cancelAnimationFrame(state._iconDebounceTimer);
|
||||
state._iconDebounceTimer = null;
|
||||
}
|
||||
if (typeof lucide !== "undefined" && lucide.createIcons) {
|
||||
try {
|
||||
lucide.createIcons();
|
||||
} catch (e) {
|
||||
/* CDN not loaded */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeHighlight(block) {
|
||||
// Skip mermaid blocks — they're rendered by Mermaid.js, not highlighted
|
||||
if (block.className && block.className.indexOf('language-mermaid') !== -1) return;
|
||||
if (typeof hljs !== "undefined" && hljs.highlightElement) {
|
||||
try {
|
||||
hljs.highlightElement(block);
|
||||
} catch (e) {
|
||||
/* CDN not loaded */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor (CodeMirror)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function openEditor(vaultName, filePath) {
|
||||
state.editorVault = vaultName;
|
||||
state.editorPath = filePath;
|
||||
|
||||
const modal = document.getElementById("editor-modal");
|
||||
const titleInput = document.getElementById("editor-title-input");
|
||||
const filenameInput = document.getElementById("editor-filename-input");
|
||||
const statFile = document.getElementById("editor-stat-file");
|
||||
const statWords = document.getElementById("editor-stat-words");
|
||||
const bodyEl = document.getElementById("editor-body");
|
||||
|
||||
// Restore header/footer (may be hidden by Forge)
|
||||
var headerEl = modal.querySelector(".editor-header");
|
||||
var footerEl = modal.querySelector(".editor-footer");
|
||||
var container = modal.querySelector(".editor-container");
|
||||
if (headerEl) headerEl.style.display = "";
|
||||
if (footerEl) footerEl.style.display = "";
|
||||
if (container) container.style.maxHeight = "";
|
||||
|
||||
filenameInput.value = filePath.split("/").pop();
|
||||
statFile.textContent = vaultName + "/" + filePath;
|
||||
|
||||
// Fetch raw content
|
||||
const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`;
|
||||
const rawData = await api(rawUrl);
|
||||
|
||||
// Clear previous editor
|
||||
bodyEl.innerHTML = "";
|
||||
if (state.editorView) {
|
||||
state.editorView.destroy();
|
||||
state.editorView = null;
|
||||
}
|
||||
state.fallbackEditorEl = null;
|
||||
|
||||
// Re-create AI toolbar container (was cleared by innerHTML above)
|
||||
const aiContainer = document.createElement('div');
|
||||
aiContainer.id = 'ai-toolbar-container';
|
||||
bodyEl.appendChild(aiContainer);
|
||||
|
||||
try {
|
||||
await waitForCodeMirror();
|
||||
|
||||
const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror;
|
||||
|
||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||
const fileExt = filePath.split(".").pop().toLowerCase();
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
saveFile();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
// Auto-save: debounced save after 2s of inactivity
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
|
||||
// Mark dirty
|
||||
var sd = document.getElementById("editor-save-dot");
|
||||
var sl = document.getElementById("editor-save-label");
|
||||
if (sd) sd.className = "editor-save-dot dirty";
|
||||
if (sl) sl.textContent = "Unsaved";
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Add language support based on file extension
|
||||
const langMap = {
|
||||
md: markdown,
|
||||
markdown: markdown,
|
||||
py: python,
|
||||
js: javascript,
|
||||
jsx: javascript,
|
||||
ts: javascript,
|
||||
tsx: javascript,
|
||||
mjs: javascript,
|
||||
cjs: javascript,
|
||||
html: html,
|
||||
htm: html,
|
||||
css: css,
|
||||
scss: css,
|
||||
less: css,
|
||||
json: json,
|
||||
xml: xml,
|
||||
svg: xml,
|
||||
sql: sql,
|
||||
php: php,
|
||||
cpp: cpp,
|
||||
cc: cpp,
|
||||
cxx: cpp,
|
||||
c: cpp,
|
||||
h: cpp,
|
||||
hpp: cpp,
|
||||
java: java,
|
||||
rs: rust,
|
||||
sh: javascript, // Using javascript for shell scripts as fallback
|
||||
bash: javascript,
|
||||
zsh: javascript,
|
||||
};
|
||||
|
||||
const langMode = langMap[fileExt];
|
||||
if (langMode) {
|
||||
extensions.push(langMode());
|
||||
}
|
||||
|
||||
if (currentTheme === "dark") {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
const cmState = EditorState.create({
|
||||
doc: rawData.raw,
|
||||
extensions: extensions,
|
||||
});
|
||||
|
||||
state.editorView = new EditorView({
|
||||
state: cmState,
|
||||
parent: bodyEl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("CodeMirror init failed, falling back to textarea:", err);
|
||||
state.fallbackEditorEl = document.createElement("textarea");
|
||||
state.fallbackEditorEl.className = "fallback-editor";
|
||||
state.fallbackEditorEl.value = rawData.raw;
|
||||
bodyEl.appendChild(state.fallbackEditorEl);
|
||||
}
|
||||
|
||||
// Set up AI toolbar (recreate each time editor opens)
|
||||
var container = document.getElementById('ai-toolbar-container');
|
||||
if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) {
|
||||
createAIToolbar(container, function() { return state.editorView || null; });
|
||||
}
|
||||
|
||||
// Mermaid live preview panel (#26)
|
||||
var existingPreview = document.getElementById('mermaid-live-preview');
|
||||
if (existingPreview) existingPreview.remove();
|
||||
var previewPanel = document.createElement('div');
|
||||
previewPanel.id = 'mermaid-live-preview';
|
||||
previewPanel.className = 'mermaid-live-preview';
|
||||
previewPanel.style.display = 'none';
|
||||
bodyEl.appendChild(previewPanel);
|
||||
|
||||
// Debounced live preview on content change
|
||||
var mermaidTimer = null;
|
||||
var updateMermaidPreview = function() {
|
||||
clearTimeout(mermaidTimer);
|
||||
mermaidTimer = setTimeout(function() {
|
||||
var content = state.editorView ? state.editorView.state.doc.toString() : (state.fallbackEditorEl ? state.fallbackEditorEl.value : '');
|
||||
var match = content.match(/```mermaid\n([\s\S]*?)```/);
|
||||
if (match) {
|
||||
previewPanel.style.display = 'block';
|
||||
previewPanel.innerHTML = '';
|
||||
var code = match[1];
|
||||
import('./mermaid-viewer.js').then(function(m) {
|
||||
// Create a temp container and render
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = '<pre><code class="language-mermaid">' + code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</code></pre>';
|
||||
previewPanel.appendChild(tmp);
|
||||
m.renderMermaidBlocks(previewPanel);
|
||||
});
|
||||
} else {
|
||||
previewPanel.style.display = 'none';
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
if (state.editorView) {
|
||||
// Attach to CodeMirror
|
||||
var extUpdate = state.editorView.state.updateListener;
|
||||
// Use the existing update listener approach - just add debounce on any change
|
||||
}
|
||||
|
||||
modal.classList.add("active");
|
||||
safeCreateIcons();
|
||||
|
||||
// Initial preview if content has mermaid
|
||||
updateMermaidPreview();
|
||||
}
|
||||
|
||||
async function waitForCodeMirror() {
|
||||
let attempts = 0;
|
||||
while (!window.CodeMirror && attempts < 50) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
if (!window.CodeMirror) {
|
||||
throw new Error("CodeMirror failed to load");
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
const vault = state.editorVault;
|
||||
const path = state.editorPath;
|
||||
const modal = document.getElementById("editor-modal");
|
||||
modal.classList.remove("active");
|
||||
// Restore brand text
|
||||
var brand = modal.querySelector('.editor-brand');
|
||||
if (brand) brand.textContent = 'ObsiGate';
|
||||
if (state.editorView) {
|
||||
state.editorView.destroy();
|
||||
state.editorView = null;
|
||||
}
|
||||
state.fallbackEditorEl = null;
|
||||
// Remove forge iframe if present + refresh current file
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame) {
|
||||
forgeFrame.remove();
|
||||
if (state.currentVault && state.currentPath) openFile(state.currentVault, state.currentPath);
|
||||
}
|
||||
state.editorVault = null;
|
||||
state.editorPath = null;
|
||||
// Refresh file content if we're viewing the edited file
|
||||
if (vault && path && state.currentVault === vault && state.currentPath === path) {
|
||||
openFile(vault, path);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(silent = false) {
|
||||
// If Forge is open, delegate save to the iframe
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame && forgeFrame.contentWindow) {
|
||||
if (!state.editorVault || !state.editorPath) return;
|
||||
var fSaveBtn = document.getElementById("editor-save");
|
||||
var fSaveDot = document.getElementById("editor-save-dot");
|
||||
var fSaveLabel = document.getElementById("editor-save-label");
|
||||
if (fSaveDot) { fSaveDot.className = 'editor-save-dot saving'; }
|
||||
if (fSaveLabel) fSaveLabel.textContent = 'Saving...';
|
||||
if (fSaveBtn) { fSaveBtn.disabled = true; }
|
||||
forgeFrame.contentWindow.postMessage({ type: 'parent-save' }, '*');
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!state.editorView && !state.fallbackEditorEl) || !state.editorVault || !state.editorPath) return;
|
||||
|
||||
const content = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value;
|
||||
const saveBtn = document.getElementById("editor-save");
|
||||
const saveDot = document.getElementById("editor-save-dot");
|
||||
const saveLabel = document.getElementById("editor-save-label");
|
||||
|
||||
try {
|
||||
// Update save dot to saving state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot saving'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saving...';
|
||||
if (saveBtn && !silent) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
const backupParam = silent ? '&backup=false' : '';
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}/save?path=${encodeURIComponent(state.editorPath)}${backupParam}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Erreur de sauvegarde");
|
||||
}
|
||||
|
||||
// Update save dot to saved state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saved';
|
||||
|
||||
if (silent) {
|
||||
// Auto-save: brief green flash on save button
|
||||
if (saveBtn) {
|
||||
saveBtn.style.background = '#22c55e';
|
||||
saveBtn.style.color = '#fff';
|
||||
saveBtn.style.borderColor = '#22c55e';
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (saveBtn) {
|
||||
saveBtn.style.background = '';
|
||||
saveBtn.style.color = '';
|
||||
saveBtn.style.borderColor = '';
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
closeEditor();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot err'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Erreur';
|
||||
// If offline, queue the save for later sync
|
||||
if (!navigator.onLine) {
|
||||
try {
|
||||
const { queueOfflineSave } = await import('./offline.js');
|
||||
await queueOfflineSave(state.editorVault, state.editorPath, content);
|
||||
alert('Vous etes hors-ligne. La modification a ete enregistree localement.');
|
||||
} catch (e) { alert('Erreur: ' + err.message); }
|
||||
} else {
|
||||
alert('Erreur: ' + err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
// If Forge is open, delegate delete to the iframe
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame && forgeFrame.contentWindow) {
|
||||
if (!state.editorVault || !state.editorPath) return;
|
||||
forgeFrame.contentWindow.postMessage({ type: 'parent-delete' }, '*');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.editorVault || !state.editorPath) return;
|
||||
|
||||
const deleteBtn = document.getElementById("editor-delete");
|
||||
const originalHTML = deleteBtn.innerHTML;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}?path=${encodeURIComponent(state.editorPath)}`, { method: "DELETE" });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Erreur de suppression");
|
||||
}
|
||||
|
||||
success = true;
|
||||
closeEditor();
|
||||
showWelcome();
|
||||
await refreshSidebarForContext();
|
||||
await refreshTagsForContext();
|
||||
} catch (err) {
|
||||
console.error("Delete error:", err);
|
||||
alert(`Erreur: ${err.message}`);
|
||||
} finally {
|
||||
deleteBtn.innerHTML = originalHTML;
|
||||
deleteBtn.disabled = false;
|
||||
if (success) safeCreateIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const cancelBtn = document.getElementById("editor-cancel");
|
||||
const deleteBtn = document.getElementById("editor-delete");
|
||||
const saveBtn = document.getElementById("editor-save");
|
||||
const modal = document.getElementById("editor-modal");
|
||||
|
||||
cancelBtn.addEventListener("click", closeEditor);
|
||||
deleteBtn.addEventListener("click", deleteFile);
|
||||
saveBtn.addEventListener("click", () => saveFile());
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
closeEditor();
|
||||
}
|
||||
});
|
||||
|
||||
// Fix mouse wheel scrolling in editor
|
||||
modal.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
const editorBody = document.getElementById("editor-body");
|
||||
if (editorBody && editorBody.contains(e.target)) {
|
||||
// Let the editor handle the scroll
|
||||
return;
|
||||
}
|
||||
// Prevent modal from scrolling if not in editor area
|
||||
e.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
|
||||
/** Copy text to clipboard — works on both HTTP and HTTPS */
|
||||
function copyToClipboard(text) {
|
||||
// Try modern API first (HTTPS-only)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
// Fallback for HTTP contexts
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
ta.style.top = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor, copyToClipboard };
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Vault Home Page module for ObsiGate.
|
||||
*
|
||||
* When a specific vault is selected (not "All"), replaces the dashboard
|
||||
* with a non-recursive file listing sorted by modification time (newest first).
|
||||
* Only files from the selected directory are shown (not subdirectories)
|
||||
* to keep the listing fast and lightweight.
|
||||
* The listing updates automatically when browsing into subdirectories.
|
||||
*
|
||||
* The visual style mirrors the search results (.search-result-item) for
|
||||
* consistency.
|
||||
*
|
||||
* Usage:
|
||||
* import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { el } from './viewer.js';
|
||||
import { getFileIcon, safeCreateIcons } from './utils.js';
|
||||
import { TabManager } from './ui.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
active: false,
|
||||
currentVault: null,
|
||||
currentDir: '', // empty = vault root
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if vault home is currently displayed. */
|
||||
export function isVaultHomeActive() {
|
||||
return state.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the vault home page for a vault directory.
|
||||
* @param {string} vaultName - Vault name
|
||||
* @param {string} dirPath - Directory path (empty for root)
|
||||
*/
|
||||
export async function showVaultHome(vaultName, dirPath = '') {
|
||||
if (vaultName === 'all' || !vaultName) {
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
state.active = true;
|
||||
state.currentVault = vaultName;
|
||||
state.currentDir = dirPath || '';
|
||||
|
||||
const area = document.getElementById('content-area');
|
||||
if (!area) return;
|
||||
|
||||
// Remove any existing dashboard
|
||||
const home = document.getElementById('dashboard-home');
|
||||
if (home) home.remove();
|
||||
|
||||
// Build vault home container
|
||||
area.innerHTML = '';
|
||||
|
||||
const container = el('div', { class: 'vault-home', id: 'vault-home' });
|
||||
area.appendChild(container);
|
||||
|
||||
// Breadcrumb
|
||||
renderBreadcrumb(container, vaultName, state.currentDir);
|
||||
|
||||
// Results container
|
||||
const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' });
|
||||
container.appendChild(resultsArea);
|
||||
|
||||
// Show loading
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p data-i18n="vaulthome.loading" style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">${t('vaulthome.loading')}</p>
|
||||
</div>`;
|
||||
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
|
||||
// Also load recent files for this vault (only at root level)
|
||||
if (!state.currentDir) {
|
||||
loadRecentSection(vaultName, container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the vault home directory without rebuilding the whole page.
|
||||
* Called when navigating into a subdirectory.
|
||||
* @param {string} vaultName
|
||||
* @param {string} dirPath
|
||||
*/
|
||||
export async function updateVaultHomeDir(vaultName, dirPath) {
|
||||
if (!state.active || state.currentVault !== vaultName) return;
|
||||
|
||||
state.currentDir = dirPath || '';
|
||||
|
||||
const container = document.getElementById('vault-home');
|
||||
if (!container) return;
|
||||
|
||||
// Update breadcrumb
|
||||
const bc = container.querySelector('.vault-home-breadcrumb');
|
||||
if (bc) {
|
||||
bc.innerHTML = '';
|
||||
buildBreadcrumbHTML(bc, vaultName, state.currentDir);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// Remove recent section when navigating into subdirectory
|
||||
const recentSection = container.querySelector('.vault-home-recent');
|
||||
if (recentSection) recentSection.remove();
|
||||
|
||||
// Reload results
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
if (resultsArea) {
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:20px;height:20px;border-width:2px;margin:0 auto"></div>
|
||||
</div>`;
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree click delegation — update vault home on directory clicks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _treeDelegationInstalled = false;
|
||||
|
||||
function _initTreeDelegation() {
|
||||
if (_treeDelegationInstalled) return;
|
||||
|
||||
const tree = document.getElementById('vault-tree');
|
||||
if (!tree) {
|
||||
// Retry once when DOM might not be ready — use MutationObserver as fallback
|
||||
const observer = new MutationObserver(() => {
|
||||
const t = document.getElementById('vault-tree');
|
||||
if (t) {
|
||||
observer.disconnect();
|
||||
_attachTreeListener(t);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return;
|
||||
}
|
||||
|
||||
_attachTreeListener(tree);
|
||||
}
|
||||
|
||||
function _attachTreeListener(tree) {
|
||||
if (_treeDelegationInstalled) return;
|
||||
_treeDelegationInstalled = true;
|
||||
|
||||
tree.addEventListener('click', (e) => {
|
||||
if (!state.active) return;
|
||||
|
||||
// Find the closest tree-item that is a directory
|
||||
const item = e.target.closest('.tree-item');
|
||||
if (!item || !item.dataset.vault || item.dataset.path === undefined) return;
|
||||
|
||||
const vault = item.dataset.vault;
|
||||
const path = item.dataset.path;
|
||||
|
||||
// Only react to directory clicks (items that have a child container)
|
||||
const hasChildContainer = item.nextElementSibling
|
||||
&& item.nextElementSibling.classList.contains('tree-children');
|
||||
if (!hasChildContainer) return;
|
||||
|
||||
// Update vault home dir
|
||||
updateVaultHomeDir(vault, path);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize delegation when module loads
|
||||
_initTreeDelegation();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
const bc = el('div', { class: 'vault-home-breadcrumb' });
|
||||
buildBreadcrumbHTML(bc, vaultName, dirPath);
|
||||
container.appendChild(bc);
|
||||
}
|
||||
|
||||
function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
// Vault icon + name (root)
|
||||
const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [
|
||||
el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }),
|
||||
el('span', {}, [document.createTextNode(vaultName)]),
|
||||
]);
|
||||
vaultLink.addEventListener('click', () => updateVaultHomeDir(vaultName, ''));
|
||||
vaultLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(vaultLink);
|
||||
|
||||
if (!dirPath) return;
|
||||
|
||||
const segments = dirPath.split('/').filter(Boolean);
|
||||
let cumulative = '';
|
||||
for (const seg of segments) {
|
||||
cumulative = cumulative ? cumulative + '/' + seg : seg;
|
||||
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
|
||||
parentEl.appendChild(sep);
|
||||
const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [
|
||||
el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }),
|
||||
el('span', {}, [document.createTextNode(seg)]),
|
||||
]);
|
||||
segLink.addEventListener('click', () => updateVaultHomeDir(vaultName, cumulative));
|
||||
segLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(segLink);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRender(vaultName, dirPath, container) {
|
||||
try {
|
||||
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=false`;
|
||||
const data = await api(url);
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header
|
||||
const header = el('div', { class: 'vault-home-header' });
|
||||
const countKey = data.files.length > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
|
||||
const countStr = t(countKey, { count: data.files.length });
|
||||
const count = el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
|
||||
document.createTextNode(countStr),
|
||||
]);
|
||||
header.appendChild(count);
|
||||
|
||||
// Directory label
|
||||
const dirLabel = dirPath ? `/${dirPath}` : ` ${t('common.root')}`;
|
||||
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(dirLabel),
|
||||
]);
|
||||
header.appendChild(dirInfo);
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(header);
|
||||
|
||||
// File list — using search-result-item style
|
||||
const list = el('div', { class: 'search-results' });
|
||||
const locale = getLocale();
|
||||
|
||||
data.files.forEach((f) => {
|
||||
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
|
||||
const displayName = f.name.endsWith('.md') ? f.name.slice(0, -3) : f.name;
|
||||
const ext = f.extension || '';
|
||||
|
||||
const titleDiv = el('div', { class: 'search-result-title' }, [
|
||||
document.createTextNode(displayName),
|
||||
]);
|
||||
|
||||
// Format date using current locale
|
||||
const modDate = new Date(f.modified * 1000);
|
||||
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
// Build metadata line: date + size
|
||||
const metaParts = [];
|
||||
metaParts.push(dateStr);
|
||||
metaParts.push(formatSize(f.size));
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode(metaParts.join(' · ')),
|
||||
]);
|
||||
|
||||
const item = el('div', {
|
||||
class: 'search-result-item vault-home-item',
|
||||
'data-vault': f.vault,
|
||||
'data-path': f.path,
|
||||
}, [
|
||||
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
|
||||
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
|
||||
titleDiv,
|
||||
metaDiv,
|
||||
]);
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(list);
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
const errMsg = t('vaulthome.error_loading') + ' ' + escapeHtml(err?.detail || err?.message || t('common.unknown'));
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files section (loaded at vault root only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadRecentSection(vaultName, container) {
|
||||
try {
|
||||
const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`);
|
||||
const files = data.files || [];
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Insert recent section BEFORE the results area
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
const section = el('div', { class: 'vault-home-recent' });
|
||||
|
||||
const header = el('div', { class: 'vault-home-recent-header' });
|
||||
header.innerHTML = `
|
||||
<i data-lucide="clock" style="width:14px;height:14px"></i>
|
||||
<span data-i18n="dashboard.recent">${t('dashboard.recent')}</span>`;
|
||||
section.appendChild(header);
|
||||
|
||||
const list = el('div', { class: 'vault-home-recent-list' });
|
||||
files.forEach((f) => {
|
||||
const title = f.title || f.path.split('/').pop() || '';
|
||||
const displayTitle = title.endsWith('.md') ? title.slice(0, -3) : title;
|
||||
const dir = f.path.includes('/') ? f.path.split('/').slice(0, -1).join('/') + '/' : '';
|
||||
|
||||
const item = el('div', { class: 'vault-home-recent-item' });
|
||||
item.innerHTML = `
|
||||
<i data-lucide="${getFileIcon ? getFileIcon(f.path) : 'file'}" style="width:14px;height:14px;opacity:0.6;flex-shrink:0"></i>
|
||||
<span class="vault-home-recent-title">${escapeHtml(displayTitle)}</span>
|
||||
<span class="vault-home-recent-dir">${escapeHtml(dir)}</span>
|
||||
<span class="vault-home-recent-time">${escapeHtml(f.mtime_human || '')}</span>`;
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
section.appendChild(list);
|
||||
|
||||
// Insert before the results area
|
||||
if (resultsArea) {
|
||||
container.insertBefore(section, resultsArea);
|
||||
} else {
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
} catch {
|
||||
// Silently skip if recent files can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' ' + t('vaulthome.size_bytes');
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' ' + t('vaulthome.size_kb');
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' ' + t('vaulthome.size_mb');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "ObsiGate",
|
||||
"short_name": "ObsiGate",
|
||||
"description": "Porte d'entree web pour vos vaults Obsidian - Accedez, naviguez et recherchez dans toutes vos notes",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#2563eb",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-72x72.svg",
|
||||
"sizes": "72x72",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-96x96.svg",
|
||||
"sizes": "96x96",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-128x128.svg",
|
||||
"sizes": "128x128",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-144x144.svg",
|
||||
"sizes": "144x144",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-152x152.svg",
|
||||
"sizes": "152x152",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-384x384.svg",
|
||||
"sizes": "384x384",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-192x192-maskable.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512-maskable.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Recherche",
|
||||
"short_name": "Recherche",
|
||||
"description": "Rechercher dans vos vaults",
|
||||
"url": "/?action=search",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/search-96x96.svg",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* ObsiGate Service Worker - PWA v2 with IndexedDB offline support */
|
||||
|
||||
const CACHE_VERSION = 'obsigate-v6-i18n';
|
||||
const STATIC_CACHE = CACHE_VERSION + '-static';
|
||||
const DYNAMIC_CACHE = CACHE_VERSION + '-dynamic';
|
||||
const MAX_DYNAMIC_CACHE_SIZE = 100;
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/static/index.html',
|
||||
'/static/app.js',
|
||||
'/static/style.css',
|
||||
'/static/manifest.json',
|
||||
'/static/js/viewer.js',
|
||||
'/static/js/i18n.js',
|
||||
'/static/js/auth.js',
|
||||
'/static/js/dashboard.js',
|
||||
'/static/js/app.js',
|
||||
'/static/locales/en.json',
|
||||
'/static/locales/fr.json'
|
||||
];
|
||||
|
||||
/* ── Install ── */
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing v' + CACHE_VERSION);
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' })));
|
||||
})
|
||||
.catch((err) => console.error('[SW] Cache error:', err))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Activate ── */
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating...');
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name.startsWith('obsigate-') && name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Fetch ── */
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET') return;
|
||||
if (url.pathname === '/api/events') return;
|
||||
if (url.pathname.startsWith('/api/auth/')) return;
|
||||
|
||||
// API requests: Network first, tell client to use IndexedDB if offline
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirstAPI(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: Cache first
|
||||
event.respondWith(cacheFirstStatic(request, url));
|
||||
});
|
||||
|
||||
/* ── Network First for API ── */
|
||||
async function networkFirstAPI(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
// Cache successful GETs in dynamic cache
|
||||
if (response && response.status === 200) {
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Offline — checking cache for:', request.url);
|
||||
|
||||
// Try dynamic cache first
|
||||
const cached = await caches.match(request);
|
||||
if (cached) {
|
||||
// Add offline header so the page can detect
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set('X-ObsiGate-Offline', 'true');
|
||||
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers });
|
||||
}
|
||||
|
||||
// Fallback: tell the page to use IndexedDB
|
||||
return new Response(JSON.stringify({
|
||||
offline: true,
|
||||
endpoint: new URL(request.url).pathname,
|
||||
message: 'Vous etes hors-ligne. Les donnees locales sont utilisees.'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'X-ObsiGate-Offline': 'true' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cache First for Static ── */
|
||||
async function cacheFirstStatic(request, url) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response && response.status === 200) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Offline fallback for HTML navigation
|
||||
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('/')) {
|
||||
const offlinePage = await caches.match('/static/index.html');
|
||||
if (offlinePage) return offlinePage;
|
||||
}
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
async function limitCacheSize(cacheName, maxSize) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > maxSize) {
|
||||
for (let i = 0; i < keys.length - maxSize; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Messages ── */
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data || !data.type) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'SKIP_WAITING':
|
||||
self.skipWaiting();
|
||||
break;
|
||||
case 'CLEAR_CACHE':
|
||||
event.waitUntil(
|
||||
caches.keys().then(names => Promise.all(names.map(n => caches.delete(n))))
|
||||
);
|
||||
break;
|
||||
case 'CLAIM_CLIENTS':
|
||||
event.waitUntil(self.clients.claim());
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Sync ── */
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[SW] Background sync:', event.tag);
|
||||
if (event.tag === 'obsigate-sync') {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({ type: 'SYNC_TRIGGERED' });
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Push ── */
|
||||
self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : 'Nouvelle mise a jour',
|
||||
icon: '/static/icons/icon-192x192.svg',
|
||||
badge: '/static/icons/icon-72x72.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
actions: [
|
||||
{ action: 'open', title: 'Ouvrir ObsiGate' },
|
||||
{ action: 'close', title: 'Fermer' }
|
||||
]
|
||||
};
|
||||
event.waitUntil(self.registration.showNotification('ObsiGate', options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action === 'open') {
|
||||
event.waitUntil(clients.openWindow('/'));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,702 @@
|
||||
A. HISTORY OF THE SOFTWARE
|
||||
==========================
|
||||
|
||||
Python was created in the early 1990s by Guido van Rossum at Stichting
|
||||
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
|
||||
as a successor of a language called ABC. Guido remains Python's
|
||||
principal author, although it includes many contributions from others.
|
||||
|
||||
In 1995, Guido continued his work on Python at the Corporation for
|
||||
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
|
||||
in Reston, Virginia where he released several versions of the
|
||||
software.
|
||||
|
||||
In May 2000, Guido and the Python core development team moved to
|
||||
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
|
||||
year, the PythonLabs team moved to Digital Creations, which became
|
||||
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
|
||||
https://www.python.org/psf/) was formed, a non-profit organization
|
||||
created specifically to own Python-related Intellectual Property.
|
||||
Zope Corporation was a sponsoring member of the PSF.
|
||||
|
||||
All Python releases are Open Source (see https://opensource.org for
|
||||
the Open Source Definition). Historically, most, but not all, Python
|
||||
releases have also been GPL-compatible; the table below summarizes
|
||||
the various releases.
|
||||
|
||||
Release Derived Year Owner GPL-
|
||||
from compatible? (1)
|
||||
|
||||
0.9.0 thru 1.2 1991-1995 CWI yes
|
||||
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
|
||||
1.6 1.5.2 2000 CNRI no
|
||||
2.0 1.6 2000 BeOpen.com no
|
||||
1.6.1 1.6 2001 CNRI yes (2)
|
||||
2.1 2.0+1.6.1 2001 PSF no
|
||||
2.0.1 2.0+1.6.1 2001 PSF yes
|
||||
2.1.1 2.1+2.0.1 2001 PSF yes
|
||||
2.1.2 2.1.1 2002 PSF yes
|
||||
2.1.3 2.1.2 2002 PSF yes
|
||||
2.2 and above 2.1.1 2001-now PSF yes
|
||||
|
||||
Footnotes:
|
||||
|
||||
(1) GPL-compatible doesn't mean that we're distributing Python under
|
||||
the GPL. All Python licenses, unlike the GPL, let you distribute
|
||||
a modified version without making your changes open source. The
|
||||
GPL-compatible licenses make it possible to combine Python with
|
||||
other software that is released under the GPL; the others don't.
|
||||
|
||||
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
|
||||
because its license has a choice of law clause. According to
|
||||
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
|
||||
is "not incompatible" with the GPL.
|
||||
|
||||
Thanks to the many outside volunteers who have worked under Guido's
|
||||
direction to make these releases possible.
|
||||
|
||||
|
||||
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
|
||||
===============================================================
|
||||
|
||||
Python software and documentation are licensed under the
|
||||
Python Software Foundation License Version 2.
|
||||
|
||||
Starting with Python 3.8.6, examples, recipes, and other code in
|
||||
the documentation are dual licensed under the PSF License Version 2
|
||||
and the Zero-Clause BSD license.
|
||||
|
||||
Some software incorporated into Python is under different licenses.
|
||||
The licenses are listed with code falling under that license.
|
||||
|
||||
|
||||
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||
--------------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||
otherwise using this software ("Python") in source or binary form and
|
||||
its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||
distribute, and otherwise use Python alone or in any derivative version,
|
||||
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
|
||||
All Rights Reserved" are retained in Python alone or in any derivative version
|
||||
prepared by Licensee.
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python.
|
||||
|
||||
4. PSF is making Python available to Licensee on an "AS IS"
|
||||
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. Nothing in this License Agreement shall be deemed to create any
|
||||
relationship of agency, partnership, or joint venture between PSF and
|
||||
Licensee. This License Agreement does not grant permission to use PSF
|
||||
trademarks or trade name in a trademark sense to endorse or promote
|
||||
products or services of Licensee, or any third party.
|
||||
|
||||
8. By copying, installing or otherwise using Python, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
|
||||
-------------------------------------------
|
||||
|
||||
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
|
||||
|
||||
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
|
||||
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
|
||||
Individual or Organization ("Licensee") accessing and otherwise using
|
||||
this software in source or binary form and its associated
|
||||
documentation ("the Software").
|
||||
|
||||
2. Subject to the terms and conditions of this BeOpen Python License
|
||||
Agreement, BeOpen hereby grants Licensee a non-exclusive,
|
||||
royalty-free, world-wide license to reproduce, analyze, test, perform
|
||||
and/or display publicly, prepare derivative works, distribute, and
|
||||
otherwise use the Software alone or in any derivative version,
|
||||
provided, however, that the BeOpen Python License is retained in the
|
||||
Software, alone or in any derivative version prepared by Licensee.
|
||||
|
||||
3. BeOpen is making the Software available to Licensee on an "AS IS"
|
||||
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
|
||||
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
|
||||
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
|
||||
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
5. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
6. This License Agreement shall be governed by and interpreted in all
|
||||
respects by the law of the State of California, excluding conflict of
|
||||
law provisions. Nothing in this License Agreement shall be deemed to
|
||||
create any relationship of agency, partnership, or joint venture
|
||||
between BeOpen and Licensee. This License Agreement does not grant
|
||||
permission to use BeOpen trademarks or trade names in a trademark
|
||||
sense to endorse or promote products or services of Licensee, or any
|
||||
third party. As an exception, the "BeOpen Python" logos available at
|
||||
http://www.pythonlabs.com/logos.html may be used according to the
|
||||
permissions granted on that web page.
|
||||
|
||||
7. By copying, installing or otherwise using the software, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
|
||||
---------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Corporation for National
|
||||
Research Initiatives, having an office at 1895 Preston White Drive,
|
||||
Reston, VA 20191 ("CNRI"), and the Individual or Organization
|
||||
("Licensee") accessing and otherwise using Python 1.6.1 software in
|
||||
source or binary form and its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, CNRI
|
||||
hereby grants Licensee a nonexclusive, royalty-free, world-wide
|
||||
license to reproduce, analyze, test, perform and/or display publicly,
|
||||
prepare derivative works, distribute, and otherwise use Python 1.6.1
|
||||
alone or in any derivative version, provided, however, that CNRI's
|
||||
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
|
||||
1995-2001 Corporation for National Research Initiatives; All Rights
|
||||
Reserved" are retained in Python 1.6.1 alone or in any derivative
|
||||
version prepared by Licensee. Alternately, in lieu of CNRI's License
|
||||
Agreement, Licensee may substitute the following text (omitting the
|
||||
quotes): "Python 1.6.1 is made available subject to the terms and
|
||||
conditions in CNRI's License Agreement. This Agreement together with
|
||||
Python 1.6.1 may be located on the internet using the following
|
||||
unique, persistent identifier (known as a handle): 1895.22/1013. This
|
||||
Agreement may also be obtained from a proxy server on the internet
|
||||
using the following URL: http://hdl.handle.net/1895.22/1013".
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python 1.6.1 or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python 1.6.1.
|
||||
|
||||
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
|
||||
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. This License Agreement shall be governed by the federal
|
||||
intellectual property law of the United States, including without
|
||||
limitation the federal copyright law, and, to the extent such
|
||||
U.S. federal law does not apply, by the law of the Commonwealth of
|
||||
Virginia, excluding Virginia's conflict of law provisions.
|
||||
Notwithstanding the foregoing, with regard to derivative works based
|
||||
on Python 1.6.1 that incorporate non-separable material that was
|
||||
previously distributed under the GNU General Public License (GPL), the
|
||||
law of the Commonwealth of Virginia shall govern this License
|
||||
Agreement only as to issues arising under or with respect to
|
||||
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
|
||||
License Agreement shall be deemed to create any relationship of
|
||||
agency, partnership, or joint venture between CNRI and Licensee. This
|
||||
License Agreement does not grant permission to use CNRI trademarks or
|
||||
trade name in a trademark sense to endorse or promote products or
|
||||
services of Licensee, or any third party.
|
||||
|
||||
8. By clicking on the "ACCEPT" button where indicated, or by copying,
|
||||
installing or otherwise using Python 1.6.1, Licensee agrees to be
|
||||
bound by the terms and conditions of this License Agreement.
|
||||
|
||||
ACCEPT
|
||||
|
||||
|
||||
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
|
||||
--------------------------------------------------
|
||||
|
||||
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
|
||||
The Netherlands. All rights reserved.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted,
|
||||
provided that the above copyright notice appear in all copies and that
|
||||
both that copyright notice and this permission notice appear in
|
||||
supporting documentation, and that the name of Stichting Mathematisch
|
||||
Centrum or CWI not be used in advertising or publicity pertaining to
|
||||
distribution of the software without specific, written prior
|
||||
permission.
|
||||
|
||||
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
|
||||
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
|
||||
Additional Conditions for this Windows binary build
|
||||
---------------------------------------------------
|
||||
|
||||
This program is linked with and uses Microsoft Distributable Code,
|
||||
copyrighted by Microsoft Corporation. The Microsoft Distributable Code
|
||||
is embedded in each .exe, .dll and .pyd file as a result of running
|
||||
the code through a linker.
|
||||
|
||||
If you further distribute programs that include the Microsoft
|
||||
Distributable Code, you must comply with the restrictions on
|
||||
distribution specified by Microsoft. In particular, you must require
|
||||
distributors and external end users to agree to terms that protect the
|
||||
Microsoft Distributable Code at least as much as Microsoft's own
|
||||
requirements for the Distributable Code. See Microsoft's documentation
|
||||
(included in its developer tools and on its website at microsoft.com)
|
||||
for specific details.
|
||||
|
||||
Redistribution of the Windows binary build of the Python interpreter
|
||||
complies with this agreement, provided that you do not:
|
||||
|
||||
- alter any copyright, trademark or patent notice in Microsoft's
|
||||
Distributable Code;
|
||||
|
||||
- use Microsoft's trademarks in your programs' names or in a way that
|
||||
suggests your programs come from or are endorsed by Microsoft;
|
||||
|
||||
- distribute Microsoft's Distributable Code to run on a platform other
|
||||
than Microsoft operating systems, run-time technologies or application
|
||||
platforms; or
|
||||
|
||||
- include Microsoft Distributable Code in malicious, deceptive or
|
||||
unlawful programs.
|
||||
|
||||
These restrictions apply only to the Microsoft Distributable Code as
|
||||
defined above, not to Python itself or any programs running on the
|
||||
Python interpreter. The redistribution of the Python interpreter and
|
||||
libraries is governed by the Python Software License included with this
|
||||
file, or by other licenses as marked.
|
||||
|
||||
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
This program, "bzip2", the associated library "libbzip2", and all
|
||||
documentation, are copyright (C) 1996-2019 Julian R Seward. All
|
||||
rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. The origin of this software must not be misrepresented; you must
|
||||
not claim that you wrote the original software. If you use this
|
||||
software in a product, an acknowledgment in the product
|
||||
documentation would be appreciated but is not required.
|
||||
|
||||
3. Altered source versions must be plainly marked as such, and must
|
||||
not be misrepresented as being the original software.
|
||||
|
||||
4. The name of the author may not be used to endorse or promote
|
||||
products derived from this software without specific prior written
|
||||
permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS
|
||||
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
|
||||
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Julian Seward, [email protected]
|
||||
bzip2/libbzip2 version 1.0.8 of 13 July 2019
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
libffi - Copyright (c) 1996-2022 Anthony Green, Red Hat, Inc and others.
|
||||
See source files for details.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
``Software''), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
This software is copyrighted by the Regents of the University of
|
||||
California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState
|
||||
Corporation and other parties. The following terms apply to all files
|
||||
associated with the software unless explicitly disclaimed in
|
||||
individual files.
|
||||
|
||||
The authors hereby grant permission to use, copy, modify, distribute,
|
||||
and license this software and its documentation for any purpose, provided
|
||||
that existing copyright notices are retained in all copies and that this
|
||||
notice is included verbatim in any distributions. No written agreement,
|
||||
license, or royalty fee is required for any of the authorized uses.
|
||||
Modifications to this software may be copyrighted by their authors
|
||||
and need not follow the licensing terms described here, provided that
|
||||
the new terms are clearly indicated on the first page of each file where
|
||||
they apply.
|
||||
|
||||
IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY
|
||||
FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY
|
||||
DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE
|
||||
IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE
|
||||
NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
|
||||
MODIFICATIONS.
|
||||
|
||||
GOVERNMENT USE: If you are acquiring this software on behalf of the
|
||||
U.S. government, the Government shall have only "Restricted Rights"
|
||||
in the software and related documentation as defined in the Federal
|
||||
Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you
|
||||
are acquiring the software on behalf of the Department of Defense, the
|
||||
software shall be classified as "Commercial Computer Software" and the
|
||||
Government shall have only "Restricted Rights" as defined in Clause
|
||||
252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the
|
||||
authors grant the U.S. Government and others acting in its behalf
|
||||
permission to use and distribute the software in accordance with the
|
||||
terms specified in this license.
|
||||
|
||||
This software is copyrighted by the Regents of the University of
|
||||
California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState
|
||||
Corporation, Apple Inc. and other parties. The following terms apply to
|
||||
all files associated with the software unless explicitly disclaimed in
|
||||
individual files.
|
||||
|
||||
The authors hereby grant permission to use, copy, modify, distribute,
|
||||
and license this software and its documentation for any purpose, provided
|
||||
that existing copyright notices are retained in all copies and that this
|
||||
notice is included verbatim in any distributions. No written agreement,
|
||||
license, or royalty fee is required for any of the authorized uses.
|
||||
Modifications to this software may be copyrighted by their authors
|
||||
and need not follow the licensing terms described here, provided that
|
||||
the new terms are clearly indicated on the first page of each file where
|
||||
they apply.
|
||||
|
||||
IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY
|
||||
FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY
|
||||
DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE
|
||||
IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE
|
||||
NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
|
||||
MODIFICATIONS.
|
||||
|
||||
GOVERNMENT USE: If you are acquiring this software on behalf of the
|
||||
U.S. government, the Government shall have only "Restricted Rights"
|
||||
in the software and related documentation as defined in the Federal
|
||||
Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you
|
||||
are acquiring the software on behalf of the Department of Defense, the
|
||||
software shall be classified as "Commercial Computer Software" and the
|
||||
Government shall have only "Restricted Rights" as defined in Clause
|
||||
252.227-7013 (b) (3) of DFARs. Notwithstanding the foregoing, the
|
||||
authors grant the U.S. Government and others acting in its behalf
|
||||
permission to use and distribute the software in accordance with the
|
||||
terms specified in this license.
|
||||
|
||||
Copyright (c) 1993-1999 Ioi Kim Lam.
|
||||
Copyright (c) 2000-2001 Tix Project Group.
|
||||
Copyright (c) 2004 ActiveState
|
||||
|
||||
This software is copyrighted by the above entities
|
||||
and other parties. The following terms apply to all files associated
|
||||
with the software unless explicitly disclaimed in individual files.
|
||||
|
||||
The authors hereby grant permission to use, copy, modify, distribute,
|
||||
and license this software and its documentation for any purpose, provided
|
||||
that existing copyright notices are retained in all copies and that this
|
||||
notice is included verbatim in any distributions. No written agreement,
|
||||
license, or royalty fee is required for any of the authorized uses.
|
||||
Modifications to this software may be copyrighted by their authors
|
||||
and need not follow the licensing terms described here, provided that
|
||||
the new terms are clearly indicated on the first page of each file where
|
||||
they apply.
|
||||
|
||||
IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY
|
||||
FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY
|
||||
DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE
|
||||
IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE
|
||||
NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
|
||||
MODIFICATIONS.
|
||||
|
||||
GOVERNMENT USE: If you are acquiring this software on behalf of the
|
||||
U.S. government, the Government shall have only "Restricted Rights"
|
||||
in the software and related documentation as defined in the Federal
|
||||
Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you
|
||||
are acquiring the software on behalf of the Department of Defense, the
|
||||
software shall be classified as "Commercial Computer Software" and the
|
||||
Government shall have only "Restricted Rights" as defined in Clause
|
||||
252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the
|
||||
authors grant the U.S. Government and others acting in its behalf
|
||||
permission to use and distribute the software in accordance with the
|
||||
terms specified in this license.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Parts of this software are based on the Tcl/Tk software copyrighted by
|
||||
the Regents of the University of California, Sun Microsystems, Inc.,
|
||||
and other parties. The original license terms of the Tcl/Tk software
|
||||
distribution is included in the file docs/license.tcltk.
|
||||
|
||||
Parts of this software are based on the HTML Library software
|
||||
copyrighted by Sun Microsystems, Inc. The original license terms of
|
||||
the HTML Library software distribution is included in the file
|
||||
docs/license.html_lib.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import site
|
||||
import site
|
||||