Skip to content
  • Home
  • Aktuell
  • Tags
  • 0 Ungelesen 0
  • Kategorien
  • Unreplied
  • Beliebt
  • GitHub
  • Docu
  • Hilfe
Skins
  • Light
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dark
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Standard: (Kein Skin)
  • Kein Skin
Einklappen
ioBroker Logo

Community Forum

donate donate
  1. ioBroker Community Home
  2. Deutsch
  3. Off Topic
  4. Lokal Notizen verwalten

NEWS

  • Jahresrückblick 2025 – unser neuer Blogbeitrag ist online! ✨
    BluefoxB
    Bluefox
    17
    1
    3.9k

  • Neuer Blogbeitrag: Monatsrückblick - Dezember 2025 🎄
    BluefoxB
    Bluefox
    13
    1
    1.2k

  • Weihnachtsangebot 2025! 🎄
    BluefoxB
    Bluefox
    25
    1
    2.5k

Lokal Notizen verwalten

Geplant Angeheftet Gesperrt Verschoben Off Topic
25 Beiträge 5 Kommentatoren 166 Aufrufe 7 Watching
  • Älteste zuerst
  • Neuste zuerst
  • Meiste Stimmen
Antworten
  • In einem neuen Thema antworten
Anmelden zum Antworten
Dieses Thema wurde gelöscht. Nur Nutzer mit entsprechenden Rechten können es sehen.
  • P Offline
    P Offline
    peterfido
    schrieb am zuletzt editiert von
    #8

    @david-g. prima, das klingt echt gut.

    Gruß

    Peterfido


    Proxmox auf Intel NUC12WSHi5
    ioBroker: Debian (VM)
    CCU: Debmatic (VM)
    Influx: Debian (VM)
    Grafana: Debian (VM)
    eBus: Debian (VM)
    Zigbee: Debian (VM) mit zigbee2mqtt

    1 Antwort Letzte Antwort
    0
    • P Offline
      P Offline
      peterfido
      schrieb zuletzt editiert von peterfido
      #9

      @david-g.
      die drei Dateien habe ich heruntergeladen und in /static/ abgelegt. Das war relativ unspektakulär.

      Gut finde ich, dass man die Notizen in der Reihenfolge sortieren kann, wie man möchte.
      Wenn man mehrere Zeilen markiert und dann auf Liste klickt, wird vor dem ersten Eintrag ein - eingefügt. Die restlichen Zeilen bleiben, wie sie sind ohne - vorneweg
      Gehe ich an den Anfang der Zeile und klicke auf Liste, wird vor dem Text - Punkt eingefügt.

      image.png

      Evtl. lässt sich das noch optimieren, dass man das Feld Liste feststellen kann und so automatisch alles als Liste fortgeführt wird.

      Ähnlich ist es bei der To-Do-Liste da wird vor der ersten Zeile -[] eingefügt.
      eeb08ff8-84d2-462c-9026-b40c1490358a-image.png

      Geht man an den Anfang der Zeile, wird noch Aufgabe ergänzt.
      ac350a6e-4b1a-45c4-bc90-202e3e726c1b-image.png

      Anschließend lassen die Punkte sich abhaken.
      5039d4f2-ce3e-45ca-b337-3c46462832e2-image.png

      Allerdings nur, wenn man vorne aufs Kästchen klickt. Klickt man auf den Text, leider nicht. Wobei bei einer Bedienung am Handy, wenn man scrollt, so nicht aus versehen was (de-)markiert wird.

      Erstellt man eine Unter-Ebene, wird der aktuelle Eintrag zu einem Ordner-Symbol, der Inhalt / die Funktionalität bleiben erhalten.
      0c372574-f944-4249-8fb0-05835686dae6-image.png

      Dann hatte ich, um das Scrollverhalten testen zu können, die Packliste erweitert. Beim Speichern kam dann die Meldung, dass schon auf einem anderen Gerät geändert wurde, und ich die Datei neu laden soll. Da wäre eine Option, "trotzdem speichern" gut.

      Beim zweiten Versuch klappe das mit dem Speichern, das Handy scheint jedoch den no-cache zu ignorieren. Zum Aktualisieren musste ich bissel warten. Ob das hin- und herswitchen zwischen den Notizen von Vorteil war, weiß ich nicht.

      Dann habe ich am Handy ein paar Punkte der To-Do-Liste abgehakt. Diese Änderungen kamen erst nach einer Aktualisierung per F5 am Browser an.

      Setze ich am Handy Häkchen, und am PC andere Häkchen, kommt die Meldung, ich solle Aktualisieren.

      Vielleicht besteht die Möglichkeit, Änderungen zu erkennen und selbstständig neu zu laden.

      Was ich jetz noch gar nicht bedacht hatte war, dass man immer mit dem Server verbunden sein muss. Man kann also nicht auf die Notizen / Listen zugreifen, wenn man offline ist. Z.B. im Flugzeug. Das ist nicht schlimm, man muss nur daran denken, dass man sich dann für die Reise relevante Notizen woanders ablegt.

      Dann habe ich eine Untere Ebene gelöscht. Das wird auch akzeptiert, das Ordner-Symbol wechsel zum Dokument-Symbol. Danach kommt dann die Meldung, dass an einem anderen Gerät geändert wurde, und ich neu laden soll. Diese Meldung wäre vorher gut.

      Ein Abbrechen-Button wäre noch gut. Vielleicht etwas abgesetzt von den anderen Schaltflächen.

      Wenn ich ein Bild aus der Notiz lösche (den Teil mit dem img), dann bleibt das Bild im Ordner und wird beim Archiv mit exportiert. Allerdings kommt man nicht so ohne Weiteres an das Bild für eine andere Notiz / Rückgängig zur Weiterverwendung dran.

      Das Update wird wohl ein neues Skript sein. Da dürfte nichts verlorengehen. Die Änderungen mit den lokalen .js und .css durchführen und dann das Skript ausführen, sollte reichen.

      PDFs werden als Dateityp nicht genommen. Bilder aus der Zwischenablage auch nicht.

      Ansonsten gefällt mir das schon.

      Gruß

      Peterfido


      Proxmox auf Intel NUC12WSHi5
      ioBroker: Debian (VM)
      CCU: Debmatic (VM)
      Influx: Debian (VM)
      Grafana: Debian (VM)
      eBus: Debian (VM)
      Zigbee: Debian (VM) mit zigbee2mqtt

      David G.D 1 Antwort Letzte Antwort
      0
      • P peterfido

        @david-g.
        die drei Dateien habe ich heruntergeladen und in /static/ abgelegt. Das war relativ unspektakulär.

        Gut finde ich, dass man die Notizen in der Reihenfolge sortieren kann, wie man möchte.
        Wenn man mehrere Zeilen markiert und dann auf Liste klickt, wird vor dem ersten Eintrag ein - eingefügt. Die restlichen Zeilen bleiben, wie sie sind ohne - vorneweg
        Gehe ich an den Anfang der Zeile und klicke auf Liste, wird vor dem Text - Punkt eingefügt.

        image.png

        Evtl. lässt sich das noch optimieren, dass man das Feld Liste feststellen kann und so automatisch alles als Liste fortgeführt wird.

        Ähnlich ist es bei der To-Do-Liste da wird vor der ersten Zeile -[] eingefügt.
        eeb08ff8-84d2-462c-9026-b40c1490358a-image.png

        Geht man an den Anfang der Zeile, wird noch Aufgabe ergänzt.
        ac350a6e-4b1a-45c4-bc90-202e3e726c1b-image.png

        Anschließend lassen die Punkte sich abhaken.
        5039d4f2-ce3e-45ca-b337-3c46462832e2-image.png

        Allerdings nur, wenn man vorne aufs Kästchen klickt. Klickt man auf den Text, leider nicht. Wobei bei einer Bedienung am Handy, wenn man scrollt, so nicht aus versehen was (de-)markiert wird.

        Erstellt man eine Unter-Ebene, wird der aktuelle Eintrag zu einem Ordner-Symbol, der Inhalt / die Funktionalität bleiben erhalten.
        0c372574-f944-4249-8fb0-05835686dae6-image.png

        Dann hatte ich, um das Scrollverhalten testen zu können, die Packliste erweitert. Beim Speichern kam dann die Meldung, dass schon auf einem anderen Gerät geändert wurde, und ich die Datei neu laden soll. Da wäre eine Option, "trotzdem speichern" gut.

        Beim zweiten Versuch klappe das mit dem Speichern, das Handy scheint jedoch den no-cache zu ignorieren. Zum Aktualisieren musste ich bissel warten. Ob das hin- und herswitchen zwischen den Notizen von Vorteil war, weiß ich nicht.

        Dann habe ich am Handy ein paar Punkte der To-Do-Liste abgehakt. Diese Änderungen kamen erst nach einer Aktualisierung per F5 am Browser an.

        Setze ich am Handy Häkchen, und am PC andere Häkchen, kommt die Meldung, ich solle Aktualisieren.

        Vielleicht besteht die Möglichkeit, Änderungen zu erkennen und selbstständig neu zu laden.

        Was ich jetz noch gar nicht bedacht hatte war, dass man immer mit dem Server verbunden sein muss. Man kann also nicht auf die Notizen / Listen zugreifen, wenn man offline ist. Z.B. im Flugzeug. Das ist nicht schlimm, man muss nur daran denken, dass man sich dann für die Reise relevante Notizen woanders ablegt.

        Dann habe ich eine Untere Ebene gelöscht. Das wird auch akzeptiert, das Ordner-Symbol wechsel zum Dokument-Symbol. Danach kommt dann die Meldung, dass an einem anderen Gerät geändert wurde, und ich neu laden soll. Diese Meldung wäre vorher gut.

        Ein Abbrechen-Button wäre noch gut. Vielleicht etwas abgesetzt von den anderen Schaltflächen.

        Wenn ich ein Bild aus der Notiz lösche (den Teil mit dem img), dann bleibt das Bild im Ordner und wird beim Archiv mit exportiert. Allerdings kommt man nicht so ohne Weiteres an das Bild für eine andere Notiz / Rückgängig zur Weiterverwendung dran.

        Das Update wird wohl ein neues Skript sein. Da dürfte nichts verlorengehen. Die Änderungen mit den lokalen .js und .css durchführen und dann das Skript ausführen, sollte reichen.

        PDFs werden als Dateityp nicht genommen. Bilder aus der Zwischenablage auch nicht.

        Ansonsten gefällt mir das schon.

        David G.D Online
        David G.D Online
        David G.
        schrieb zuletzt editiert von David G.
        #10

        @peterfido

        Zusammenfassung von deinen Punkten:

        Mit der Liste und den Todos schaue ich mal.

        Die Meldung mit dem anderen Gerät habe ich extra eingebaut. Das Tool ist nicht für gleichzeitige Multiuser geeignet.

        An Änderungen erkennen und neu laden hätte ich aicu mal gedacht. Aber was ist, wenn du grade was am bearbeiten bist und dann die Seite neu lädt?

        Schaue mal was geht.

        P.s.
        Skizzen sind jetzt oben eingebunden.

        EDIT
        Hans was überarbeitet.
        Man kann jetzt mehrmals am Stück auf Checkbox oder Auflistung gehen und es wird immer sauber in eine neue Zeile geschrieben.
        Auch kann man mehrere Zeilen markieren und alles zu Auflistung oder Checkboxen machen, würde also alles schreiben und es dann so machen und nicht automatisch als Liste etc fortsetzen.
        Ein Dateiupload ist drinnen. Habs auf 20mb beschränkt. Es geht um Notizen.

        Abbrechen ist auch drinnen.

        Kannst ja mal schauen. Einfach nochmal drüber laufen lassen. Daten bleiben erhalten.
        Pacjezes erstmal nicht in den ersten Post.

        Eidt 2
        Cide nochmal angepasst. Er holt sich jetzt alle 30sek die Daten neu falls woanders was geändert wurde, aber NUR wenn man sich in keiner offenen Notiz befindet.

        #!/bin/bash
         
        # Root-Rechte prüfen
        if [ "$EUID" -ne 0 ]; then
          echo "FEHLER: Bitte führe dieses Skript als Root (z. B. mit sudo) aus!"
          exit 1
        fi
         
        # 1. Port abfragen
        echo "Welcher Port soll für das Notiz-Tool genutzt werden? (Standard: 8080)"
        read -p "Port: " USER_PORT
        if [ -z "$USER_PORT" ]; then 
            USER_PORT=8080
        fi
         
        # 2. Autostart abfragen
        echo "Soll das Notiz-Tool automatisch beim Systemstart geladen werden? (Y/n)"
        read -p "Autostart: " AUTOSTART_CONFIRM
        if [ -z "$AUTOSTART_CONFIRM" ]; then 
            AUTOSTART_CONFIRM="y"
        fi
         
        # 3. Cronjob Abfragen
        echo "Soll ein nächtlicher Cronjob (03:00 Uhr) zum Bereinigen ungenutzter Dateien angelegt werden? (Y/n)"
        read -p "Cleanup-Cronjob: " CRON_CONFIRM
        if [ -z "$CRON_CONFIRM" ]; then 
            CRON_CONFIRM="y"
        fi
         
        echo "Soll ein tägliches Voll-Backup (JSON + Uploads als .tar.gz) um 04:00 Uhr eingerichtet werden? (Y/n)"
        read -p "Backup-Cronjob: " BACKUP_CONFIRM
        if [ -z "$BACKUP_CONFIRM" ]; then 
            BACKUP_CONFIRM="y"
        fi
         
        # 4. Variablen definieren
        INSTALL_DIR="/opt/notiz-tool"
        SERVICE_NAME="notizen.service"
         
        echo "--- Starte Setup in $INSTALL_DIR auf Port $USER_PORT ---"
         
        # 5. System-Abhängigkeiten
        apt update && apt install -y python3 python3-pip python3-venv cron
         
        # 6. Verzeichnisstruktur erstellen
        mkdir -p $INSTALL_DIR/static $INSTALL_DIR/templates $INSTALL_DIR/uploads $INSTALL_DIR/backups
         
        # 7. Python Umgebung
        python3 -m venv $INSTALL_DIR/venv
        $INSTALL_DIR/venv/bin/pip install flask werkzeug
         
        # 8. Dateien schreiben
         
        # app.py
        cat << 'EOF' > $INSTALL_DIR/app.py
        from flask import Flask, render_template, request, jsonify, send_from_directory, session, redirect, url_for, send_file
        from werkzeug.security import generate_password_hash, check_password_hash
        import json
        import os
        import uuid
        import tarfile
        import io
        import shutil
        import time
        import base64
         
        app = Flask(__name__)
        app.secret_key = os.urandom(24)
         
        # Erhöht auf 50MB Backend-Limit, das 20MB Limit wird im Frontend für den User durchgesetzt
        app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 
         
        # Erlaubte Dateien großzügig erweitert
        ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'pdf', 'zip', 'tar', 'gz', 'rar', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'mp3', 'mp4', 'mkv', 'avi'}
         
        DATA_FILE = 'data.json'
        UPLOAD_FOLDER = 'uploads'
         
        @app.after_request
        def add_header(response):
            if request.path.startswith('/uploads/'):
                response.headers['Cache-Control'] = 'public, max-age=31536000'
                return response
            response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0'
            response.headers['Pragma'] = 'no-cache'
            response.headers['Expires'] = '-1'
            return response
         
        def allowed_file(filename):
            return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
         
        def check_auth():
            if os.path.exists(DATA_FILE):
                with open(DATA_FILE, 'r') as f:
                    data = json.load(f)
                if data.get('settings', {}).get('password_enabled') and not session.get('logged_in'):
                    return False
            return True
         
        @app.before_request
        def require_login():
            if request.endpoint in ['login', 'static']: 
                return
            if not check_auth():
                if request.path.startswith('/api/'): 
                    return jsonify({"error": "Unauthorized"}), 401
                return redirect(url_for('login'))
         
        @app.route('/login', methods=['GET', 'POST'])
        def login():
            with open(DATA_FILE, 'r') as f: 
                data = json.load(f)
            settings = data.get('settings', {})
            v = str(time.time())
            
            if not settings.get('password_enabled'): 
                return redirect(url_for('index'))
                
            if request.method == 'POST':
                if check_password_hash(settings.get('password_hash', ''), request.form.get('password')):
                    session['logged_in'] = True
                    return redirect(url_for('index'))
                return render_template('login.html', theme=settings.get('theme', 'dark'), accent=settings.get('accent', '#27ae60'), error="Falsches Passwort", v=v)
                
            return render_template('login.html', theme=settings.get('theme', 'dark'), accent=settings.get('accent', '#27ae60'), v=v)
         
        @app.route('/logout')
        def logout():
            session.pop('logged_in', None)
            return redirect(url_for('login'))
         
        @app.route('/')
        def index():
            return render_template('index.html', v=str(time.time()))
         
        @app.route('/api/password', methods=['POST'])
        def set_password():
            req = request.json
            with open(DATA_FILE, 'r') as f: 
                data = json.load(f)
                
            if not data.get('settings'): 
                data['settings'] = {}
                
            if req.get('enabled'):
                data['settings']['password_enabled'] = True
                data['settings']['password_hash'] = generate_password_hash(req.get('password'))
            else:
                data['settings']['password_enabled'] = False
                data['settings']['password_hash'] = ""
                
            with open(DATA_FILE, 'w') as f: 
                json.dump(data, f, indent=4)
                
            return jsonify({"status": "success", "last_modified": int(os.path.getmtime(DATA_FILE) * 1000)})
         
        @app.route('/api/notes', methods=['GET', 'POST'])
        def handle_notes():
            if request.method == 'POST':
                req_data = request.json
                client_time = req_data.pop('last_modified', None)
                
                if os.path.exists(DATA_FILE) and client_time is not None:
                    if int(os.path.getmtime(DATA_FILE) * 1000) > client_time + 100:
                        return jsonify({"status": "error", "message": "Konflikt"}), 409
                        
                with open(DATA_FILE, 'w') as f: 
                    json.dump(req_data, f, indent=4)
                    
                return jsonify({"status": "success", "last_modified": int(os.path.getmtime(DATA_FILE) * 1000)})
                
            with open(DATA_FILE, 'r') as f:
                data = json.load(f)
                data['last_modified'] = int(os.path.getmtime(DATA_FILE) * 1000)
                return jsonify(data)
         
        @app.route('/uploads/<filename>')
        def uploaded_file(filename):
            return send_from_directory(UPLOAD_FOLDER, filename)
         
        @app.route('/api/upload', methods=['POST'])
        def upload_file():
            file = request.files.get('file') or request.files.get('image')
            if not file: 
                return jsonify({"error": "Fehler: Keine Datei gefunden"}), 400
                
            if file and allowed_file(file.filename):
                ext = file.filename.rsplit('.', 1)[1].lower()
                filename = f"{uuid.uuid4().hex}.{ext}"
                file.save(os.path.join(UPLOAD_FOLDER, filename))
                return jsonify({"filename": filename, "original": file.filename})
                
            return jsonify({"error": "Unerlaubter Dateityp"}), 400
         
        @app.route('/api/sketch', methods=['POST'])
        def save_sketch():
            data = request.json
            sketch_id = data.get('id')
            if not sketch_id: 
                sketch_id = uuid.uuid4().hex
            
            png_data = data['image'].split(',')[1]
            with open(os.path.join(UPLOAD_FOLDER, f"sketch_{sketch_id}.png"), "wb") as fh:
                fh.write(base64.b64decode(png_data))
                
            with open(os.path.join(UPLOAD_FOLDER, f"sketch_{sketch_id}.json"), "w") as fh:
                json.dump({"bg": data['bg'], "strokes": data['strokes']}, fh)
                
            return jsonify({"id": sketch_id})
         
        @app.route('/api/sketch/<sketch_id>', methods=['GET'])
        def load_sketch(sketch_id):
            path = os.path.join(UPLOAD_FOLDER, f"sketch_{sketch_id}.json")
            if os.path.exists(path):
                with open(path, 'r') as f: 
                    return jsonify(json.load(f))
            return jsonify({"error": "not found"}), 404
         
        @app.route('/api/export', methods=['GET'])
        def export_backup():
            memory_file = io.BytesIO()
            with tarfile.open(fileobj=memory_file, mode='w:gz') as tar:
                tar.add(DATA_FILE, arcname='data.json')
                if os.path.exists(UPLOAD_FOLDER):
                    tar.add(UPLOAD_FOLDER, arcname='uploads')
            memory_file.seek(0)
            return send_file(memory_file, download_name='notes_backup.tar.gz', as_attachment=True)
         
        @app.route('/api/import', methods=['POST'])
        def import_backup():
            if 'file' not in request.files: 
                return jsonify({"error": "Keine Datei"}), 400
                
            file = request.files['file']
            
            if file.filename.lower().endswith('.json'):
                file.save(DATA_FILE)
                return jsonify({"status": "success"})
                
            try:
                with tarfile.open(fileobj=file, mode='r:*') as tar:
                    names = tar.getnames()
                    has_data = any(n == 'data.json' or n.endswith('/data.json') for n in names)
                    
                    if not has_data:
                        return jsonify({"error": "Kein gültiges Backup! (data.json fehlt)"}), 400
                    
                    for f in os.listdir(UPLOAD_FOLDER):
                        try: 
                            os.remove(os.path.join(UPLOAD_FOLDER, f))
                        except: 
                            pass
                        
                    for member in tar.getmembers():
                        if member.name == 'data.json' or member.name.endswith('/data.json'):
                            source = tar.extractfile(member)
                            if source:
                                with open(DATA_FILE, "wb") as target:
                                    shutil.copyfileobj(source, target)
                        elif 'uploads/' in member.name and member.isfile():
                            filename = os.path.basename(member.name)
                            source = tar.extractfile(member)
                            if source:
                                with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as target:
                                    shutil.copyfileobj(source, target)
                                    
                return jsonify({"status": "success"})
                
            except tarfile.TarError:
                return jsonify({"error": f"Datei ist kein gültiges Archiv. Name war: {file.filename}"}), 400
            except Exception as e:
                return jsonify({"error": str(e)}), 500
         
        if __name__ == '__main__':
            pass
        EOF
         
        echo "app.run(host='0.0.0.0', port=$USER_PORT, debug=False)" >> $INSTALL_DIR/app.py
         
        # cleanup.py
        cat << 'EOF' > $INSTALL_DIR/cleanup.py
        import json
        import os
         
        DATA_FILE = '/opt/notiz-tool/data.json'
        UPLOAD_FOLDER = '/opt/notiz-tool/uploads'
         
        if not os.path.exists(DATA_FILE) or not os.path.exists(UPLOAD_FOLDER): 
            exit()
         
        with open(DATA_FILE, 'r') as f: 
            data = json.load(f)
         
        used_files = set()
         
        def extract_files(nodes):
            for node in nodes:
                text = node.get('text', '')
                for file_name in os.listdir(UPLOAD_FOLDER):
                    if file_name in text: 
                        used_files.add(file_name)
                    
                    if file_name.startswith('sketch_') and file_name.endswith('.png'):
                        sketch_id = file_name.replace('sketch_', '').replace('.png', '')
                        if f"[sketch:{sketch_id}]" in text:
                            used_files.add(file_name)
                            used_files.add(f"sketch_{sketch_id}.json")
                            
                if 'children' in node: 
                    extract_files(node['children'])
         
        extract_files(data.get('content', []))
         
        for file_name in os.listdir(UPLOAD_FOLDER):
            if file_name not in used_files:
                try: 
                    os.remove(os.path.join(UPLOAD_FOLDER, file_name))
                except: 
                    pass
        EOF
         
        # backup.sh
        cat << 'EOF' > $INSTALL_DIR/backup.sh
        #!/bin/bash
        cd /opt/notiz-tool
        if [ -f data.json ]; then
            tar -czf backups/backup_$(date +%u).tar.gz data.json uploads/
        fi
        EOF
         
        # static/style.css
        cat << 'EOF' > $INSTALL_DIR/static/style.css
        :root { 
            --bg-color: #1a1a1a; 
            --sidebar-bg: #252525; 
            --text-color: #e0e0e0; 
            --accent: #27ae60; 
            --accent-rgb: 39, 174, 96; 
            --border-color: #333; 
            --sidebar-width: 300px; 
            --code-bg: #2d2d2d; 
            --code-text: #f8f8f2; 
        }
         
        [data-theme="light"] { 
            --bg-color: #f5f5f5; 
            --sidebar-bg: #ffffff; 
            --text-color: #333; 
            --border-color: #ddd; 
            --code-bg: #f0f0f0; 
            --code-text: #222; 
        }
         
        html { overscroll-behavior: none; }
         
        body { 
            margin: 0; 
            display: flex; 
            font-family: sans-serif; 
            background: var(--bg-color); 
            color: var(--text-color); 
            overflow: hidden; 
            height: 100dvh; 
            width: 100vw; 
            position: fixed; 
            top: 0; 
            left: 0; 
        }
         
        #sidebar { 
            width: var(--sidebar-width); 
            height: 100%; 
            background: var(--sidebar-bg); 
            border-right: 1px solid var(--border-color); 
            display: flex; 
            flex-direction: column; 
            transition: margin-left 0.3s ease; 
            flex-shrink: 0; 
            z-index: 10; 
        }
         
        body.sidebar-hidden #sidebar { 
            margin-left: calc(-1 * var(--sidebar-width)); 
        }
         
        .sidebar-header { 
            height: 60px; 
            min-height: 60px; 
            flex-shrink: 0; 
            display: flex; 
            justify-content: space-between; 
            align-items: center; 
            padding: 0 15px; 
            border-bottom: 1px solid var(--border-color); 
            box-sizing: border-box; 
            background: var(--sidebar-bg); 
        }
         
        #tree { 
            flex-grow: 1; 
            overflow-y: auto; 
            padding: 10px 0 50px 0; 
        }
         
        .tree-group { 
            min-height: 10px; 
            padding-left: 15px; 
        }
         
        .tree-item-container { 
            margin: 2px 0; 
        }
         
        .tree-item { 
            display: flex; 
            align-items: center; 
            padding: 5px; 
            border-radius: 4px; 
            cursor: pointer; 
        }
         
        .tree-item.active { 
            background: rgba(var(--accent-rgb), 0.2); 
            color: var(--accent); 
            font-weight: bold; 
        }
         
        .search-wrapper { 
            position: relative; 
            margin-bottom: 10px; 
            height: 40px; 
        }
         
        #search-input { 
            width: 100%; 
            height: 100%; 
            background: rgba(255,255,255,0.05); 
            border: 1px solid var(--border-color); 
            color: inherit; 
            padding: 0 35px 0 12px; 
            border-radius: 5px; 
            box-sizing: border-box; 
            font-size: 0.95em; 
        }
         
        #search-input:focus { 
            outline: none; 
            border-color: var(--accent); 
        }
         
        #clear-search { 
            position: absolute; 
            right: 5px; 
            top: 50%; 
            transform: translateY(-50%); 
            width: 30px; 
            height: 30px; 
            display: none; 
            align-items: center; 
            justify-content: center; 
            cursor: pointer; 
            opacity: 0.5; 
            font-size: 1.1em; 
            user-select: none; 
            line-height: 1; 
        }
         
        #clear-search:hover { 
            opacity: 1; 
            color: var(--accent); 
        }
         
        .drag-handle { 
            display: none; 
            padding: 0 5px 0 0; 
            cursor: grab; 
            color: #888; 
            font-weight: bold; 
            user-select: none; 
            font-size: 1.2em; 
        }
         
        body.edit-mode-active .drag-handle { 
            display: inline-block; 
        }
         
        body.edit-mode-active .tree-item { 
            cursor: default; 
            border: 1px dashed transparent; 
        }
         
        body.edit-mode-active .tree-item:hover { 
            border: 1px dashed rgba(255,255,255,0.1); 
        }
         
        body.edit-mode-active #toggle-all-btn { 
            display: none; 
        }
         
        #sort-btn { 
            display: none; 
        }
         
        body.edit-mode-active #sort-btn { 
            display: inline-block; 
        }
         
        .tree-icon { 
            padding: 0 8px; 
            font-size: 1.1em; 
            user-select: none; 
        }
         
        .tree-text { 
            flex-grow: 1; 
            padding: 2px 5px; 
        }
         
        button { 
            background: none; 
            border: none; 
            color: inherit; 
            cursor: pointer; 
            font-family: inherit; 
            font-size: inherit; 
        }
         
        .add-sub-btn, 
        .delete-btn { 
            display: none; 
            font-weight: bold; 
            margin-left: 5px; 
        }
         
        body.edit-mode-active .add-sub-btn, 
        body.edit-mode-active .delete-btn { 
            display: inline-block; 
        }
         
        .add-sub-btn { 
            color: var(--accent) !important; 
            margin-left: auto; 
        }
         
        .delete-btn { 
            color: #e74c3c !important; 
        }
         
        .toolbar { 
            margin-bottom: 12px; 
            display: flex; 
            flex-wrap: wrap; 
            gap: 4px; 
            align-items: stretch; 
            position: relative; 
            z-index: 20; 
        }
         
        .tool-btn { 
            display: flex; 
            flex-direction: column; 
            align-items: center; 
            justify-content: center; 
            min-width: 40px; 
            min-height: 40px; 
            border: 1px solid var(--border-color); 
            border-radius: 4px; 
            padding: 2px 4px; 
            background: rgba(255,255,255,0.02); 
            transition: background 0.2s; 
        }
         
        .tool-btn:hover { 
            background: rgba(255,255,255,0.08); 
        }
         
        .tool-btn span { 
            font-size: 0.6em; 
            margin-top: 2px; 
            opacity: 0.8; 
            white-space: nowrap;
        }
         
        .tool-btn i { 
            font-style: normal; 
            font-size: 1em; 
        }
         
        .color-tool { 
            min-width: 46px; 
        }
         
        .color-row { 
            display: flex; 
            align-items: center; 
            justify-content: center; 
            gap: 3px; 
            width: 100%; 
            height: 20px; 
            margin-top: 0; 
        }
         
        .color-row span { 
            font-size: 1em !important; 
            cursor: pointer; 
            margin: 0 !important; 
            line-height: 20px; 
            display: flex; 
            align-items: center; 
        }
         
        #text-color-input { 
            width: 16px; 
            height: 16px; 
            padding: 0; 
            border: 1px solid var(--border-color); 
            background: none; 
            cursor: pointer; 
            border-radius: 3px; 
            appearance: none; 
            -webkit-appearance: none; 
            display: block; 
            margin: 0; 
            flex-shrink: 0; 
        }
         
        #text-color-input::-webkit-color-swatch-wrapper { padding: 0; }
        #text-color-input::-webkit-color-swatch { border: none; border-radius: 2px; }
         
        #editor { 
            flex-grow: 1; 
            height: 100%; 
            overflow-y: auto; 
            padding: 60px 40px; 
            box-sizing: border-box; 
            position: relative; 
        }
         
        #display-area { 
            line-height: 1.5; 
            overflow-wrap: break-word; 
            min-height: 1.5em; 
        }
         
        #display-area div { 
            min-height: 1.2em; 
        }
         
        b, strong { font-weight: bold; }
         
        input, textarea { 
            width: 100%; 
            background: rgba(255,255,255,0.05); 
            color: inherit; 
            border: 1px solid var(--border-color); 
            padding: 12px; 
            border-radius: 5px; 
            box-sizing: border-box; 
            margin-bottom: 10px; 
            font-family: inherit; 
            transition: border-color 0.2s; 
        }
         
        .code-container { 
            position: relative; 
            background: var(--code-bg); 
            color: var(--code-text); 
            padding: 15px; 
            border-radius: 5px; 
            margin: 10px 0; 
            border: 1px solid var(--border-color); 
        }
         
        .copy-badge { 
            position: absolute; 
            top: 5px; 
            right: 5px; 
            background: var(--accent) !important; 
            color: white; 
            padding: 2px 8px !important; 
            font-size: 0.7em; 
            border-radius: 3px; 
            opacity: 0.7; 
        }
         
        .modal-overlay { 
            display: none; 
            position: fixed; 
            top: 0; 
            left: 0; 
            width: 100%; 
            height: 100%; 
            background: rgba(0,0,0,0.7); 
            z-index: 2000; 
            justify-content: center; 
            align-items: center; 
        }
         
        .modal { 
            background: var(--sidebar-bg); 
            padding: 25px; 
            border-radius: 12px; 
            border: 1px solid var(--border-color); 
            text-align: center; 
            max-width: 400px; 
        }
         
        .modal-btns { 
            display: flex; 
            gap: 10px; 
            justify-content: center; 
            margin-top: 20px; 
        }
         
        .btn-save { 
            background: var(--accent) !important; 
            color: white; 
            padding: 8px 20px; 
            border-radius: 5px; 
        }
         
        .btn-discard { 
            background: #e74c3c !important; 
            color: white; 
            padding: 8px 20px; 
            border-radius: 5px; 
        }
         
        .btn-cancel { 
            border: 1px solid var(--border-color) !important; 
            padding: 8px 20px; 
            border-radius: 5px; 
        }
         
        #mobile-toggle-btn { 
            position: fixed; 
            left: var(--sidebar-width); 
            top: 20px; 
            z-index: 1010; 
            background: var(--accent) !important; 
            color: white; 
            padding: 10px !important; 
            border-radius: 0 5px 5px 0; 
            transition: left 0.3s ease; 
        }
         
        body.sidebar-hidden #mobile-toggle-btn { 
            left: 0; 
        }
         
        .header-actions { 
            position: fixed; 
            top: 15px; 
            right: 20px; 
            z-index: 1000; 
        }
         
        .dropdown-content { 
            display: none; 
            position: absolute; 
            right: 0; 
            top: 40px; 
            background: var(--sidebar-bg); 
            border: 1px solid var(--border-color); 
            min-width: 220px; 
            border-radius: 8px; 
            overflow: hidden; 
            box-shadow: 0 4px 15px rgba(0,0,0,0.3); 
        }
         
        .menu-row { 
            display: flex; 
            align-items: center; 
            height: 50px; 
            border-bottom: 1px solid var(--border-color); 
            padding: 0 15px; 
            box-sizing: border-box; 
            cursor: pointer; 
            font-size: 14px; 
            transition: background 0.2s; 
        }
         
        .menu-row:last-child { border-bottom: none; }
        .menu-row:hover { background: rgba(255,255,255,0.05); }
        .menu-row span { flex-grow: 1; }
         
        #accent-color-picker { 
            width: 40px; 
            height: 25px; 
            border: none; 
            background: none; 
            cursor: pointer; 
            padding: 0; 
        }
         
        .note-img { 
            max-width: 250px; 
            max-height: 250px; 
            border-radius: 4px; 
            cursor: pointer; 
            border: 1px solid var(--border-color); 
            margin: 10px 0; 
            object-fit: cover; 
            transition: opacity 0.2s; 
        }
         
        .note-img:hover { opacity: 0.8; }
        .sketch-img { border: 2px dashed var(--accent); } 
         
        #lightbox { 
            display: none; 
            position: fixed; 
            top: 0; 
            left: 0; 
            width: 100vw; 
            height: 100vh; 
            background: rgba(0,0,0,0.85); 
            z-index: 3000; 
            justify-content: center; 
            align-items: center; 
        }
         
        #lightbox img { 
            max-width: 90%; 
            max-height: 90%; 
            border-radius: 8px; 
            box-shadow: 0 5px 25px rgba(0,0,0,0.5); 
        }
         
        #edit-mode { position: relative; }
         
        #mention-dropdown { 
            display: none; 
            position: absolute; 
            top: 65px; 
            left: 0; 
            width: 100%; 
            max-width: 400px; 
            background: var(--sidebar-bg); 
            border: 1px solid var(--accent); 
            border-radius: 8px; 
            max-height: 250px; 
            overflow-y: auto; 
            z-index: 1000; 
            box-shadow: 0 10px 30px rgba(0,0,0,0.5); 
        }
         
        .mention-item { 
            padding: 10px 15px; 
            cursor: pointer; 
            border-bottom: 1px solid var(--border-color); 
        }
         
        .mention-item:last-child { border-bottom: none; }
        .mention-item:hover { background: rgba(var(--accent-rgb), 0.2); }
         
        .mention-path { 
            font-size: 0.75em; 
            color: #888; 
            display: block; 
            margin-top: 3px; 
        }
         
        .note-link { 
            color: var(--accent); 
            text-decoration: none; 
            font-weight: bold; 
            padding: 2px 6px; 
            background: rgba(var(--accent-rgb), 0.1); 
            border-radius: 4px; 
            border: 1px solid rgba(var(--accent-rgb), 0.3); 
            transition: all 0.2s; 
            cursor: pointer; 
            display: inline-block; 
            margin: 0 2px; 
        }
         
        .note-link:hover { 
            background: var(--accent); 
            color: white; 
        }
         
        .dead-link { 
            color: #888; 
            text-decoration: none; 
            padding: 2px 6px; 
            background: rgba(255,255,255,0.05); 
            border-radius: 4px; 
            border: 1px solid var(--border-color); 
            display: inline-block; 
            margin: 0 2px; 
            cursor: not-allowed; 
        }
         
        blockquote { 
            border-left: 4px solid var(--accent); 
            margin: 10px 0; 
            padding: 10px 15px; 
            background: rgba(var(--accent-rgb), 0.05); 
            border-radius: 0 5px 5px 0; 
            font-style: italic; 
            color: #aaa; 
        }
         
        hr { 
            border: 0; 
            border-top: 1px solid var(--border-color); 
            margin: 20px 0; 
        }
         
        .task-list-item { 
            list-style-type: none; 
            display: flex; 
            align-items: center; 
            gap: 8px; 
            margin: 5px 0; 
        }
         
        input[type="checkbox"].task-check { 
            width: 16px; 
            height: 16px; 
            margin: 0; 
            cursor: pointer; 
            accent-color: var(--accent); 
            flex-shrink: 0; 
        }
         
        .spoiler { 
            margin: 15px 0; 
            border: 1px solid var(--border-color); 
            border-radius: 6px; 
            background: rgba(255,255,255,0.02); 
            overflow: hidden; 
        }
         
        .spoiler summary { 
            font-weight: bold; 
            cursor: pointer; 
            padding: 12px 15px; 
            background: rgba(var(--accent-rgb), 0.1); 
            user-select: none; 
            outline: none; 
            transition: background 0.2s; 
        }
         
        .spoiler summary:hover { 
            background: rgba(var(--accent-rgb), 0.2); 
        }
         
        .spoiler[open] summary { 
            border-bottom: 1px solid var(--border-color); 
        }
         
        .spoiler-content { 
            padding: 15px; 
        }
         
        /* Sketch Modal CSS */
        #sketch-modal .modal { 
            width: 1000px;
            max-width: 95vw; 
            max-height: 95vh; 
            display: flex; 
            flex-direction: column; 
            padding: 15px; 
            box-sizing: border-box;
        }
         
        #sketch-toolbar { 
            display: flex; 
            gap: 15px; 
            margin-bottom: 10px; 
            align-items: center; 
            flex-wrap: wrap; 
            background: rgba(255,255,255,0.05); 
            padding: 10px; 
            border-radius: 8px; 
            flex-shrink: 0;
            max-height: 40vh; 
            overflow-y: auto;
        }
         
        #canvas-wrapper {
            display: flex;
            justify-content: center;
            align-items: center;
            width: 100%;
        }
         
        #sketch-canvas { 
            width: 100%; 
            max-width: calc((95vh - 220px) * 1.333); 
            aspect-ratio: 4 / 3; 
            border: 1px solid var(--border-color); 
            border-radius: 5px; 
            touch-action: none; 
            cursor: crosshair; 
            box-shadow: 0 5px 25px rgba(0,0,0,0.4); 
        }
         
        .sketch-tool { display: flex; align-items: center; gap: 5px; font-size: 0.9em; }
        .sketch-tool input[type="color"] { width: 30px; height: 30px; padding: 0; border: none; border-radius: 4px; cursor: pointer; }
        .sketch-btn { padding: 5px 10px; border-radius: 4px; border: 1px solid var(--border-color); cursor: pointer; background: var(--sidebar-bg); color: var(--text-color); }
        .sketch-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
        EOF
         
        # static/script.js
        cat << 'EOF' > $INSTALL_DIR/static/script.js
        var fullData = {content: [], settings: {accent: '#27ae60', theme: 'dark', password_enabled: false}};
        var activeId = null;
        var collapsedIds = new Set();
        var sortables = [];
        var currentLastModified = 0;
         
        // --- SKETCH LOGIK ---
        let sketchCanvas, sketchCtx, isDrawing = false, sketchStrokes = [], currentStroke = null;
        let sketchColor = '#000000', sketchWidth = 8, sketchMode = 'pen', sketchBg = 'white', activeSketchId = null;
         
        function initSketcher() {
            sketchCanvas = document.getElementById('sketch-canvas');
            sketchCtx = sketchCanvas.getContext('2d');
            
            sketchCanvas.width = 1200;
            sketchCanvas.height = 900;
         
            const getPos = (e) => {
                const r = sketchCanvas.getBoundingClientRect();
                const scaleX = sketchCanvas.width / r.width;
                const scaleY = sketchCanvas.height / r.height;
                let cx = e.clientX, cy = e.clientY;
                if(e.touches && e.touches.length > 0) { 
                    cx = e.touches[0].clientX; 
                    cy = e.touches[0].clientY; 
                }
                return { 
                    x: (cx - r.left) * scaleX, 
                    y: (cy - r.top) * scaleY 
                };
            };
         
            const startDraw = (e) => {
                e.preventDefault(); 
                isDrawing = true; 
                const p = getPos(e);
                
                currentStroke = { 
                    color: sketchMode === 'eraser' ? sketchBg : sketchColor, 
                    width: sketchWidth, 
                    mode: sketchMode, 
                    points: [p] 
                };
                sketchStrokes.push(currentStroke);
            };
         
            const draw = (e) => {
                if (!isDrawing) return; 
                e.preventDefault();
                currentStroke.points.push(getPos(e));
                redrawSketch();
            };
         
            const endDraw = () => { 
                isDrawing = false; 
            };
         
            sketchCanvas.addEventListener('mousedown', startDraw); 
            sketchCanvas.addEventListener('mousemove', draw);
            window.addEventListener('mouseup', endDraw);
            sketchCanvas.addEventListener('touchstart', startDraw, {passive: false}); 
            sketchCanvas.addEventListener('touchmove', draw, {passive: false});
            window.addEventListener('touchend', endDraw);
        }
         
        function redrawSketch() {
            sketchCtx.globalAlpha = 1.0; 
            sketchCtx.fillStyle = sketchBg;
            sketchCtx.fillRect(0, 0, sketchCanvas.width, sketchCanvas.height);
            sketchCtx.lineCap = 'round';
            sketchCtx.lineJoin = 'round';
         
            for (let s of sketchStrokes) {
                if (s.points.length < 2) continue;
                
                if (s.mode === 'highlighter') {
                    sketchCtx.globalAlpha = 0.4;
                } else {
                    sketchCtx.globalAlpha = 1.0;
                }
         
                sketchCtx.beginPath();
                sketchCtx.strokeStyle = s.color;
                sketchCtx.lineWidth = s.width;
                sketchCtx.moveTo(s.points[0].x, s.points[0].y);
                
                for (let i = 1; i < s.points.length - 1; i++) {
                    let xc = (s.points[i].x + s.points[i + 1].x) / 2;
                    let yc = (s.points[i].y + s.points[i + 1].y) / 2;
                    sketchCtx.quadraticCurveTo(s.points[i].x, s.points[i].y, xc, yc);
                }
                
                sketchCtx.lineTo(s.points[s.points.length - 1].x, s.points[s.points.length - 1].y);
                sketchCtx.stroke();
            }
            
            sketchCtx.globalAlpha = 1.0;
        }
         
        function undoSketch() {
            if (sketchStrokes.length > 0) {
                sketchStrokes.pop();
                redrawSketch();
            }
        }
         
        async function openSketch(id = null) {
            document.getElementById('sketch-modal').style.display = 'flex';
            if(!sketchCanvas) initSketcher();
            activeSketchId = id;
            sketchStrokes = [];
            
            if (id) {
                try {
                    const res = await fetch(`/api/sketch/${id}`);
                    if(res.ok) {
                        const data = await res.json();
                        sketchBg = data.bg || 'white';
                        document.getElementById('sketch-bg-select').value = sketchBg;
                        sketchStrokes = data.strokes || [];
                    }
                } catch(e) {
                    console.error("Konnte Skizze nicht laden.");
                }
            } else {
                sketchBg = document.getElementById('sketch-bg-select').value;
            }
            setSketchMode('pen');
            redrawSketch();
        }
         
        function setSketchMode(mode) {
            sketchMode = mode;
            document.getElementById('btn-pen').classList.toggle('active', mode === 'pen');
            document.getElementById('btn-highlighter').classList.toggle('active', mode === 'highlighter');
            document.getElementById('btn-eraser').classList.toggle('active', mode === 'eraser');
        }
         
        function setSketchBg(bg) {
            sketchBg = bg;
            sketchStrokes.forEach(s => { 
                if (s.mode === 'eraser') {
                    s.color = bg; 
                } else if (!s.mode && (s.color === 'white' || s.color === 'black')) {
                    if (s.color !== bg && sketchMode === 'eraser') s.color = bg;
                }
            });
            redrawSketch();
        }
         
        function updateCurrentNode() {
            const n = findNode(fullData.content, activeId);
            if(n) { 
                n.text = document.getElementById('node-text').value; 
            }
        }
         
        async function saveSketch() {
            const dataUrl = sketchCanvas.toDataURL("image/png");
            const payload = { 
                id: activeSketchId, 
                bg: sketchBg, 
                strokes: sketchStrokes, 
                image: dataUrl 
            };
            
            const res = await fetch('/api/sketch', { 
                method: 'POST', 
                headers: {'Content-Type': 'application/json'}, 
                body: JSON.stringify(payload) 
            });
            
            const data = await res.json();
            
            if (!activeSketchId && data.id) {
                wrapSelection(`[sketch:${data.id}]`, '', '');
            }
            
            document.getElementById('sketch-modal').style.display = 'none';
            
            const ta = document.getElementById('node-text');
            if (ta) {
                ta.value = ta.value.replace(`[sketch:${data.id}]`, `[sketch:${data.id}] `).trim();
                updateCurrentNode();
            }
            
            document.querySelectorAll('.sketch-img').forEach(img => {
                if (img.src.includes(data.id)) {
                    img.src = `/uploads/sketch_${data.id}.png?v=` + Date.now();
                }
            });
        }
        // --- SKETCH LOGIK ENDE ---
         
        function cleanDataArray(arr) {
            if (!arr) return [];
            return arr.filter(item => item !== null && item !== undefined).map(item => ({
                ...item,
                children: cleanDataArray(item.children)
            }));
        }
         
        async function loadData() { 
            const sState = localStorage.getItem('sidebarState') || 'closed'; 
            if (sState === 'closed') {
                document.body.classList.add('sidebar-hidden'); 
            }
            
            const savedCollapsed = localStorage.getItem('collapsedNodes');
            if (savedCollapsed) {
                collapsedIds = new Set(JSON.parse(savedCollapsed));
            }
            
            try { 
                const res = await fetch('/api/notes?_t=' + Date.now()); 
                const data = await res.json(); 
                currentLastModified = data.last_modified || 0;
                
                fullData = data && data.content ? data : {content: [], settings: {accent: '#27ae60', theme: 'dark', password_enabled: false}}; 
                if(!fullData.settings) {
                    fullData.settings = {accent: '#27ae60', theme: 'dark', password_enabled: false}; 
                }
                
                fullData.content = cleanDataArray(fullData.content);
                
                document.body.setAttribute('data-theme', fullData.settings.theme || 'dark'); 
                applyAccentColor(fullData.settings.accent); 
                updateMenuUI();
                
                if (!savedCollapsed && fullData.content.length > 0) {
                    initAllCollapsed(fullData.content);
                }
                
                renderTree(); 
                
                const lastId = localStorage.getItem('lastActiveId'); 
                if (lastId && findNode(fullData.content, lastId)) {
                    selectNode(lastId); 
                }
            } catch (e) {
                console.error(e);
            } 
        }
         
        function updateMenuUI() {
            const pwdBtn = document.getElementById('pwd-toggle-text'); 
            const logoutBtn = document.getElementById('logout-btn');
            if(pwdBtn) {
                pwdBtn.innerText = fullData.settings.password_enabled ? '🔓 Passwortschutz aus' : '🔒 Passwortschutz an';
            }
            if(logoutBtn) {
                logoutBtn.style.display = fullData.settings.password_enabled ? 'flex' : 'none';
            }
        }
         
        function togglePassword() {
            if (fullData.settings.password_enabled) {
                showModal("Passwortschutz", "Deaktivieren?", [
                    { label: "Ja", class: "btn-discard", action: async () => {
                        const res = await fetch('/api/password', { 
                            method: 'POST', 
                            headers: {'Content-Type': 'application/json'}, 
                            body: JSON.stringify({enabled: false}) 
                        });
                        const data = await res.json(); 
                        if(data.status === 'success') {
                            currentLastModified = data.last_modified;
                        }
                        fullData.settings.password_enabled = false; 
                        updateMenuUI();
                    }}, 
                    { label: "Abbruch", class: "btn-cancel", action: () => {} }
                ]);
            } else {
                showModal("Passwortschutz", "Neues Passwort:", [
                    { label: "Speichern", class: "btn-save", action: async () => {
                        const pwd = document.getElementById('modal-input').value;
                        if(pwd) {
                            const res = await fetch('/api/password', { 
                                method: 'POST', 
                                headers: {'Content-Type': 'application/json'}, 
                                body: JSON.stringify({enabled: true, password: pwd}) 
                            });
                            const data = await res.json(); 
                            if(data.status === 'success') {
                                currentLastModified = data.last_modified;
                            }
                            fullData.settings.password_enabled = true; 
                            updateMenuUI();
                        }
                    }}, 
                    { label: "Abbruch", class: "btn-cancel", action: () => {} }
                ], true);
            }
        }
         
        function initAllCollapsed(items) { 
            items.forEach(item => { 
                if (item.children && item.children.length > 0) { 
                    collapsedIds.add(item.id); 
                    initAllCollapsed(item.children); 
                } 
            }); 
            saveCollapsedToLocal(); 
        }
         
        function saveCollapsedToLocal() { 
            localStorage.setItem('collapsedNodes', JSON.stringify(Array.from(collapsedIds))); 
        }
         
        function toggleAllFolders() {
            const searchTerm = document.getElementById('search-input').value;
            let totalFolders = 0;
            
            function countFolders(items) { 
                items.forEach(i => { 
                    if (i.children && i.children.length > 0) { 
                        totalFolders++; 
                        countFolders(i.children); 
                    } 
                }); 
            }
            countFolders(fullData.content);
         
            if (collapsedIds.size >= totalFolders / 2 && totalFolders > 0) {
                collapsedIds.clear();
            } else {
                function collect(items) { 
                    items.forEach(i => { 
                        if(i.children && i.children.length > 0) { 
                            collapsedIds.add(i.id); 
                            collect(i.children); 
                        } 
                    }); 
                } 
                collect(fullData.content);
            }
            saveCollapsedToLocal(); 
            if (searchTerm) filterTree(); else renderTree();
        }
         
        function clearSearch() { 
            document.getElementById('search-input').value = ''; 
            document.getElementById('clear-search').style.display = 'none'; 
            renderTree(); 
        }
         
        function filterTree() {
            const term = document.getElementById('search-input').value.toLowerCase(); 
            const clearBtn = document.getElementById('clear-search');
            
            if (!term) { 
                clearBtn.style.display = 'none'; 
                renderTree(); 
                return; 
            }
            
            clearBtn.style.display = 'flex'; 
            const container = document.getElementById('tree'); 
            container.innerHTML = '';
            
            const rootGroup = document.createElement('div'); 
            rootGroup.className = 'tree-group'; 
            container.appendChild(rootGroup);
            
            function getFilteredItems(items) { 
                let results = []; 
                items.forEach(item => { 
                    const matchInTitle = item.title && item.title.toLowerCase().includes(term); 
                    const matchInText = item.text && item.text.toLowerCase().includes(term); 
                    const filteredChildren = item.children ? getFilteredItems(item.children) : []; 
                    if (matchInTitle || matchInText || filteredChildren.length > 0) {
                        results.push({ ...item, children: filteredChildren }); 
                    }
                }); 
                return results; 
            }
            
            renderItems(getFilteredItems(fullData.content), rootGroup);
        }
         
        function applyAccentColor(hex) { 
            document.documentElement.style.setProperty('--accent', hex); 
            const r = parseInt(hex.slice(1,3), 16), 
                  g = parseInt(hex.slice(3,5), 16), 
                  b = parseInt(hex.slice(5,7), 16); 
            document.documentElement.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`); 
            const p = document.getElementById('accent-color-picker'); 
            if(p) p.value = hex; 
        }
         
        function updateGlobalAccent(hex) { 
            fullData.settings.accent = hex; 
            applyAccentColor(hex); 
            saveToServer(); 
        }
         
        function toggleTheme() { 
            const newTheme = document.body.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; 
            fullData.settings.theme = newTheme; 
            document.body.setAttribute('data-theme', newTheme); 
            saveToServer(); 
        }
         
        function renderTree() { 
            const container = document.getElementById('tree'); 
            container.innerHTML = ''; 
            const rootGroup = document.createElement('div'); 
            rootGroup.className = 'tree-group'; 
            container.appendChild(rootGroup); 
            renderItems(fullData.content, rootGroup); 
            if (document.body.classList.contains('edit-mode-active')) {
                initSortables(); 
            }
        }
         
        function renderItems(items, parent) { 
            const isEdit = document.body.classList.contains('edit-mode-active'); 
            items.forEach(item => { 
                if (!item) return;
                
                const isFolder = item.children && item.children.length > 0; 
                const isCollapsed = isEdit ? false : collapsedIds.has(item.id); 
                
                const div = document.createElement('div'); 
                div.className = 'tree-item-container'; 
                div.setAttribute('data-id', item.id); 
                
                const wrapper = document.createElement('div'); 
                wrapper.className = 'tree-item' + (item.id === activeId ? ' active' : ''); 
                
                const handle = document.createElement('span'); 
                handle.className = 'drag-handle'; 
                handle.innerHTML = '⋮⋮';
                
                const icon = document.createElement('span'); 
                icon.className = 'tree-icon'; 
                icon.innerText = isFolder ? (isCollapsed ? '📁' : '📂') : '📄'; 
                
                icon.onclick = (e) => { 
                    e.stopPropagation(); 
                    if (!isEdit && isFolder) { 
                        if (collapsedIds.has(item.id)) collapsedIds.delete(item.id); 
                        else collapsedIds.add(item.id); 
                        saveCollapsedToLocal(); 
                        const searchTerm = document.getElementById('search-input').value; 
                        if (searchTerm) filterTree(); else renderTree(); 
                    } 
                }; 
                
                const text = document.createElement('span'); 
                text.className = 'tree-text'; 
                text.innerText = item.title || 'Unbenannt'; 
                text.onclick = (e) => { 
                    e.stopPropagation(); 
                    if (!isEdit) tryNavigation(item.id); 
                }; 
                
                const addBtn = document.createElement('button'); 
                addBtn.className = 'add-sub-btn'; 
                addBtn.innerText = '+'; 
                addBtn.onclick = (e) => { 
                    e.stopPropagation(); 
                    addItem(item.id); 
                }; 
                
                const delBtn = document.createElement('button'); 
                delBtn.className = 'delete-btn'; 
                delBtn.innerText = '×'; 
                delBtn.onclick = (e) => { 
                    e.stopPropagation(); 
                    deleteItem(item.id); 
                }; 
                
                wrapper.append(handle, icon, text, addBtn, delBtn); 
                div.appendChild(wrapper); 
                
                const childGroup = document.createElement('div'); 
                childGroup.className = 'tree-group'; 
                
                if (isFolder && !isCollapsed) { 
                    renderItems(item.children, childGroup); 
                }
                
                div.appendChild(childGroup); 
                parent.appendChild(div); 
            }); 
        }
         
        function initSortables() { 
            sortables.forEach(s => s.destroy()); 
            sortables = []; 
            document.querySelectorAll('.tree-group').forEach(el => { 
                sortables.push(new Sortable(el, { 
                    group: 'nested', 
                    animation: 150, 
                    handle: '.drag-handle', 
                    fallbackOnBody: true, 
                    onEnd: (evt) => { 
                        if (evt.oldIndex !== evt.newIndex || evt.to !== evt.from) { 
                            rebuildDataFromDOM(); 
                        } 
                    } 
                })); 
            }); 
        }
         
        function toggleEditMode() { 
            const isNowEdit = document.body.classList.toggle('edit-mode-active'); 
            if (!isNowEdit) { 
                sortables.forEach(s => s.destroy()); 
                sortables = []; 
            }
            renderTree(); 
        }
         
        function rebuildDataFromDOM() { 
            if (!document.body.classList.contains('edit-mode-active')) return;
            
            function parse(container) { 
                return Array.from(container.querySelectorAll(':scope > .tree-item-container')).map(div => { 
                    const id = div.getAttribute('data-id'); 
                    const original = findNode(fullData.content, id); 
                    const sub = div.querySelector(':scope > .tree-group'); 
                    let parsedChildren = sub ? parse(sub) : [];
                    if (parsedChildren.length === 0 && original && original.children && original.children.length > 0 && collapsedIds.has(id)) { 
                        parsedChildren = original.children; 
                    }
                    return { 
                        id: id, 
                        title: original ? original.title : 'Unbenannt', 
                        text: original ? original.text : '', 
                        children: parsedChildren 
                    }; 
                }); 
            } 
            
            const rg = document.querySelector('#tree > .tree-group'); 
            if(rg) { 
                const newData = parse(rg);
                if (newData.length >= (fullData.content.length / 2) || fullData.content.length === 0) { 
                    fullData.content = newData; 
                    saveToServer(); 
                }
            } 
        }
         
        function selectNode(id) { 
            activeId = id; 
            localStorage.setItem('lastActiveId', id); 
            const node = findNode(fullData.content, id); 
            
            if (node) { 
                document.getElementById('no-selection').style.display = 'none'; 
                document.getElementById('edit-area').style.display = 'block'; 
                document.getElementById('node-title').value = node.title; 
                document.getElementById('node-text').value = node.text; 
                
                const pathData = getPath(fullData.content, id) || []; 
                const breadcrumbEl = document.getElementById('breadcrumb'); 
                breadcrumbEl.innerHTML = '';
                
                pathData.forEach((p, idx) => { 
                    const span = document.createElement('span'); 
                    span.innerText = p.title; 
                    span.style.cursor = 'pointer'; 
                    span.onclick = () => tryNavigation(p.id); 
                    span.onmouseover = () => span.style.textDecoration = 'underline'; 
                    span.onmouseout = () => span.style.textDecoration = 'none'; 
                    breadcrumbEl.appendChild(span); 
                    if(idx < pathData.length - 1) {
                        breadcrumbEl.appendChild(document.createTextNode(' / ')); 
                    }
                });
                
                disableEdit(); 
                
                document.querySelectorAll('.tree-item').forEach(el => el.classList.remove('active')); 
                const activeEl = document.querySelector(`.tree-item-container[data-id="${id}"] > .tree-item`); 
                if(activeEl) activeEl.classList.add('active'); 
            } 
        }
         
        async function saveChanges() { 
            const node = findNode(fullData.content, activeId); 
            if (node) { 
                node.title = document.getElementById('node-title').value; 
                node.text = document.getElementById('node-text').value; 
                await saveToServer(); 
                renderTree(); 
            } 
        }
         
        async function saveToServer() { 
            fullData.last_modified = currentLastModified; 
            const res = await fetch('/api/notes', { 
                method: 'POST', 
                headers: {'Content-Type': 'application/json'}, 
                body: JSON.stringify(fullData) 
            }); 
            
            if (res.status === 409) { 
                showModal("⚠️ Achtung!", "Geändert auf anderem Gerät! Lade neu (F5).", [
                    { label: "OK", class: "btn-cancel", action: () => {} }
                ]); 
                return false; 
            }
            
            const data = await res.json(); 
            if (data.status === 'success') { 
                currentLastModified = data.last_modified; 
                return true; 
            } 
            return false;
        }
         
        function findNode(items, id) { 
            for (let item of items) { 
                if (item.id === id) return item; 
                if (item.children) { 
                    const f = findNode(item.children, id); 
                    if (f) return f; 
                } 
            } 
            return null; 
        }
         
        function getPath(items, id, path = []) { 
            for (let item of items) { 
                const n = [...path, {title: item.title, id: item.id}]; 
                if (item.id === id) return n; 
                if (item.children) { 
                    const r = getPath(item.children, id, n); 
                    if (r) return r; 
                } 
            } 
            return null; 
        }
         
        function tryNavigation(id) { 
            const node = findNode(fullData.content, activeId); 
            if (node && document.getElementById('edit-mode').style.display === 'block') { 
                if (document.getElementById('node-title').value !== node.title || document.getElementById('node-text').value !== node.text) { 
                    showModal("Ungespeichert", "Speichern?", [ 
                        { label: "Ja", class: "btn-save", action: () => { saveChanges(); selectNode(id); } }, 
                        { label: "Nein", class: "btn-discard", action: () => selectNode(id) }, 
                        { label: "Abbruch", class: "btn-cancel", action: () => {} } 
                    ]); 
                    return; 
                } 
            } 
            selectNode(id); 
        }
         
        window.toggleTask = async function(targetIdx, currentlyChecked) {
            const node = findNode(fullData.content, activeId); 
            if(!node) return;
            
            let tIndex = 0;
            let lines = node.text.split('\n');
            
            for (let i = 0; i < lines.length; i++) {
                let t = lines[i].trim();
                if (t.startsWith('- [ ] ') || t.startsWith('- [x] ') || t.startsWith('- [X] ')) {
                    if (tIndex === targetIdx) { 
                        if (currentlyChecked) { 
                            lines[i] = lines[i].replace(/- \[[xX]\] /, '- [ ] '); 
                        } else { 
                            lines[i] = lines[i].replace(/- \[ \] /, '- [x] '); 
                        } 
                        break; 
                    } 
                    tIndex++;
                }
            }
            
            node.text = lines.join('\n'); 
            const ta = document.getElementById('node-text'); 
            if(ta) ta.value = node.text;
            
            disableEdit(); 
            await saveToServer();
        };
         
        function renderMarkdown(text) { 
            if (!text) return ''; 
            let html = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 
            
            html = html.replace(/\[img:(.*?)\]/g, '<img src="/uploads/$1" class="note-img" onclick="openLightbox(this.src)">');
            html = html.replace(/\[sketch:([a-zA-Z0-9]+)\]/g, '<img src="/uploads/sketch_$1.png?v='+Date.now()+'" class="note-img sketch-img" title="Skizze bearbeiten" onclick="openSketch(\'$1\')">');
            html = html.replace(/\[file:([a-zA-Z0-9.\-]+)\|([^\]]+)\]/g, '<a href="/uploads/$1" target="_blank" class="note-link">📎 $2</a>');
            html = html.replace(/\[file:([a-zA-Z0-9.\-]+)\]/g, '<a href="/uploads/$1" target="_blank" class="note-link">📎 Datei Herunterladen</a>');
            
            html = html.replace(/\[note:([a-zA-Z0-9]+)\|([^\]]+)\]/g, (match, id, title) => {
                if (findNode(fullData.content, id)) { 
                    return '<a href="#" onclick="tryNavigation(\'' + id + '\'); return false;" class="note-link">@ ' + title + '</a>'; 
                } else { 
                    return '<span class="dead-link" title="Notiz wurde gelöscht">@ <del>' + title + '</del></span>'; 
                }
            });
            
            html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:var(--accent); text-decoration:underline;">$1</a>');
         
            let last = ""; 
            while (last !== html) { 
                last = html; 
                html = html.replace(/\[(#[0-9a-fA-F]{6})\]([\s\S]*?)\[\/#\]/g, '<span style="color:$1">$2</span>'); 
                html = html.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>'); 
                html = html.replace(/_(.*?)_/g, '<i>$1</i>'); 
                html = html.replace(/~~(.*?)~~/g, '<s>$1</s>'); 
            } 
            
            let parts = html.split("'''"); 
            let res = ''; 
            window.taskIndexCounter = 0; 
            
            for (let i = 0; i < parts.length; i++) { 
                if (i % 2 === 1) { 
                    let content = parts[i].trim(); 
                    let lines = content.split('\n'); 
                    let langClass = ''; 
                    
                    if (lines.length > 0 && lines[0].length < 15 && /^[a-z0-9]+$/.test(lines[0].trim())) { 
                        langClass = ' class="language-' + lines[0].trim() + '"'; 
                        content = lines.slice(1).join('\n'); 
                    } 
                    
                    res += '<div class="code-container"><button class="copy-badge" onclick="copyToClipboard(this)">Copy</button><pre><code' + langClass + '>' + content + '</code></pre></div>'; 
                } else { 
                    res += parts[i].split('\n').map(line => {
                        let t = line.trim(); 
                        if (t === '') return '<br>'; 
                        if (t === '---') return '<hr>';
                        if (t.startsWith('### ')) return '<h3>' + line.substring(4) + '</h3>'; 
                        if (t.startsWith('## ')) return '<h2>' + line.substring(3) + '</h2>'; 
                        if (t.startsWith('# ')) return '<h1>' + line.substring(2) + '</h1>';
                        
                        if (t.startsWith('&gt;')) { 
                            let quoteText = line.substring(line.indexOf('&gt;') + 4); 
                            if(quoteText.startsWith(' ')) quoteText = quoteText.substring(1); 
                            return '<blockquote>' + quoteText + '</blockquote>'; 
                        }
                        
                        if (t.startsWith('[s=')) { 
                            let endIdx = t.indexOf(']'); 
                            if (endIdx !== -1) { 
                                let title = t.substring(3, endIdx) || 'Spoiler'; 
                                let rest = t.substring(endIdx + 1).trim(); 
                                let out = '<details class="spoiler"><summary>' + title + '</summary><div class="spoiler-content">'; 
                                if (rest) { 
                                    if (rest.endsWith('[/s]')) { 
                                        out += rest.substring(0, rest.length - 4) + '</div></details>'; 
                                    } else { 
                                        out += rest; 
                                    } 
                                } 
                                return out; 
                            } 
                        }
                        
                        if (t.endsWith('[/s]')) { 
                            let rest = t.substring(0, t.length - 4).trim(); 
                            let out = ''; 
                            if (rest) out = '<div>' + rest + '</div>'; 
                            return out + '</div></details>'; 
                        }
                        
                        if (t.startsWith('- [ ] ')) { 
                            let text = line.substring(line.indexOf('- [ ] ') + 6); 
                            let idx = window.taskIndexCounter++; 
                            return '<div class="task-list-item"><input type="checkbox" class="task-check" onclick="toggleTask(' + idx + ', false)"> <span>' + text + '</span></div>'; 
                        }
                        
                        if (t.startsWith('- [x] ') || t.startsWith('- [X] ')) { 
                            let text = line.substring(line.indexOf('] ') + 2); 
                            let idx = window.taskIndexCounter++; 
                            return '<div class="task-list-item"><input type="checkbox" class="task-check" checked onclick="toggleTask(' + idx + ', true)"> <span><del>' + text + '</del></span></div>'; 
                        }
                        
                        if (t.startsWith('- ')) {
                            return '<div style="margin-left: 20px;">• ' + line.substring(line.indexOf('- ')+2) + '</div>'; 
                        }
                        
                        return '<div>' + line + '</div>';
                    }).join(''); 
                } 
            } 
            return res; 
        }
         
        function showModal(title, text, buttons, showInput=false) { 
            document.getElementById('modal-title').innerText = title; 
            document.getElementById('modal-text').innerText = text; 
            const inp = document.getElementById('modal-input'); 
            inp.style.display = showInput ? 'block' : 'none'; 
            inp.value = ''; 
            const container = document.getElementById('modal-btns-container'); 
            container.innerHTML = ''; 
            
            buttons.forEach(btn => { 
                const b = document.createElement('button'); 
                b.innerText = btn.label; 
                b.className = btn.class; 
                b.onclick = () => { 
                    document.getElementById('custom-modal').style.display = 'none'; 
                    btn.action(); 
                }; 
                container.appendChild(b); 
            }); 
            
            document.getElementById('custom-modal').style.display = 'flex'; 
            if (showInput) setTimeout(() => inp.focus(), 100); 
        }
         
        function deleteItem(id) { 
            showModal("Löschen", "Sicher?", [ 
                { label: "Löschen", class: "btn-discard", action: () => { 
                    removeFromArr(fullData.content, id); 
                    if (activeId === id) activeId = null; 
                    renderTree(); 
                    saveToServer(); 
                } }, 
                { label: "Abbruch", class: "btn-cancel", action: () => {} } 
            ]); 
        }
         
        function removeFromArr(arr, id) { 
            for (let i = 0; i < arr.length; i++) { 
                if (arr[i].id === id) { 
                    arr.splice(i, 1); 
                    return true; 
                } 
                if (arr[i].children && removeFromArr(arr[i].children, id)) return true; 
            } 
            return false; 
        }
         
        async function addItem(parentId) { 
            document.getElementById('search-input').value = ''; 
            document.getElementById('clear-search').style.display = 'none';
            
            const newId = Date.now().toString() + Math.random().toString(36).substring(2, 6); 
            const newItem = { id: newId, title: 'Neu', text: '', children: [] }; 
            
            if (parentId) { 
                const p = findNode(fullData.content, parentId); 
                if(p) { 
                    if(!p.children) p.children = []; 
                    p.children.push(newItem); 
                    collapsedIds.delete(parentId); 
                    saveCollapsedToLocal(); 
                } 
            } else {
                fullData.content.push(newItem); 
            }
            
            renderTree(); 
            selectNode(newItem.id); 
            enableEdit(); 
            await saveToServer();
        }
         
        function wrapSelection(b, a, p = "") { 
            const ta = document.getElementById('node-text'); 
            const s = ta.selectionStart;
            const e = ta.selectionEnd; 
            const txt = ta.value.substring(s, e) || p; 
            ta.value = ta.value.substring(0, s) + b + txt + a + ta.value.substring(e); 
            ta.focus(); 
            ta.setSelectionRange(s + b.length, s + b.length + txt.length); 
        }
         
        function handleListAction(prefix, placeholder) {
            const ta = document.getElementById('node-text');
            const start = ta.selectionStart;
            const end = ta.selectionEnd;
            const text = ta.value;
            const selectedText = text.substring(start, end);
         
            if (selectedText.includes('\n')) {
                const lines = selectedText.split('\n');
                const newLines = lines.map(line => {
                    if (line.trim() === '') return line; 
                    if (line.trim().startsWith(prefix.trim())) return line; 
                    return prefix + line;
                });
                const newText = newLines.join('\n');
                ta.value = text.substring(0, start) + newText + text.substring(end);
                ta.setSelectionRange(start, start + newText.length);
                ta.focus();
                return;
            }
         
            const textBefore = text.substring(0, start);
            if (selectedText === placeholder && textBefore.endsWith(prefix)) {
                const insertStr = '\n' + prefix + placeholder;
                ta.value = text.substring(0, end) + insertStr + text.substring(end);
                const newStart = end + '\n'.length + prefix.length;
                ta.setSelectionRange(newStart, newStart + placeholder.length);
                ta.focus();
                return;
            }
         
            let insertPrefix = prefix;
            if (textBefore.length > 0 && !textBefore.endsWith('\n')) {
                insertPrefix = '\n' + prefix;
            }
         
            const insertStr = insertPrefix + (selectedText || placeholder);
            ta.value = text.substring(0, start) + insertStr + text.substring(end);
            const selectStart = start + insertPrefix.length;
            ta.setSelectionRange(selectStart, selectStart + (selectedText || placeholder).length);
            ta.focus();
        }
         
        function applyColor() { 
            wrapSelection(`[${document.getElementById('text-color-input').value}]`, `[/#]`, "Farbe"); 
        }
         
        function insertCodeTag() { 
            wrapSelection("'''\n", "\n'''", "CODE"); 
        }
         
        function copyToClipboard(btn) { 
            const code = btn.nextElementSibling.innerText; 
            const el = document.createElement('textarea'); 
            el.value = code; 
            document.body.appendChild(el); 
            el.select(); 
            document.execCommand('copy'); 
            document.body.removeChild(el); 
            btn.innerText = 'Copied!'; 
            setTimeout(() => btn.innerText = 'Copy', 2000); 
        }
         
        function toggleSettings(e) { 
            e.stopPropagation(); 
            const m = document.getElementById('dropdown-menu'); 
            m.style.display = m.style.display === 'block' ? 'none' : 'block'; 
        }
         
        document.addEventListener('click', () => { 
            const m = document.getElementById('dropdown-menu'); 
            if (m) m.style.display = 'none'; 
        });
         
        function exportData() { 
            window.location.href = '/api/export'; 
        }
         
        async function importData(e) { 
            const f = e.target.files[0]; 
            if (!f) return; 
            
            const fd = new FormData(); 
            fd.append('file', f); 
            document.getElementById('import-file').value = '';
            
            try { 
                const res = await fetch('/api/import', { method: 'POST', body: fd }); 
                if(res.ok) { 
                    location.reload(); 
                } else { 
                    const errData = await res.json(); 
                    showModal("Fehler beim Import", "Die Datei konnte nicht verarbeitet werden:\n\n" + (errData.error || "Unbekannter Fehler"), [
                        { label: "Verstanden", class: "btn-cancel", action: () => {} }
                    ]); 
                } 
            } catch(e) { 
                showModal("Verbindungsfehler", "Upload fehlgeschlagen.", [
                    { label: "OK", class: "btn-cancel", action: () => {} }
                ]); 
            }
        }
         
        function enableEdit() { 
            document.getElementById('view-mode').style.display = 'none'; 
            document.getElementById('edit-mode').style.display = 'block'; 
        }
         
        function disableEdit() { 
            const n = findNode(fullData.content, activeId); 
            if (n) { 
                document.getElementById('view-title').innerText = n.title; 
                document.getElementById('display-area').innerHTML = renderMarkdown(n.text); 
                if(window.hljs) hljs.highlightAll(); 
            } 
            document.getElementById('view-mode').style.display = 'block'; 
            document.getElementById('edit-mode').style.display = 'none'; 
        }
         
        function cancelEdit() {
            const n = findNode(fullData.content, activeId);
            if (n) {
                document.getElementById('node-title').value = n.title;
                document.getElementById('node-text').value = n.text;
            }
            disableEdit();
        }
         
        function toggleSidebar() { 
            const h = document.body.classList.toggle('sidebar-hidden'); 
            localStorage.setItem('sidebarState', h ? 'closed' : 'open'); 
            document.querySelector('#mobile-toggle-btn span').innerText = h ? '▶' : '◀'; 
        }
         
        async function uploadImage() { 
            const input = document.createElement('input'); 
            input.type = 'file'; 
            input.accept = 'image/*'; 
            
            input.onchange = async (e) => { 
                const file = e.target.files[0]; 
                if (!file) return; 
                
                const fd = new FormData(); 
                fd.append('image', file); 
                
                try { 
                    const res = await fetch('/api/upload', { method: 'POST', body: fd }); 
                    const data = await res.json(); 
                    if(data.filename) {
                        wrapSelection(`[img:${data.filename}]`, '', ''); 
                    } else {
                        showModal("Fehler", "Ungültiger Dateityp oder Datei zu groß.", [
                            { label: "OK", class: "btn-cancel", action: () => {} }
                        ]); 
                    }
                } catch(err) {
                    console.error(err);
                } 
            }; 
            input.click(); 
        }
         
        async function uploadGenericFile() { 
            const input = document.createElement('input'); 
            input.type = 'file'; 
            
            input.onchange = async (e) => { 
                const file = e.target.files[0]; 
                if (!file) return; 
                
                if (file.size > 20 * 1024 * 1024) {
                    showModal("Zu groß", "Die Datei darf maximal 20 MB groß sein.", [{ label: "Verstanden", class: "btn-cancel", action: () => {} }]);
                    return;
                }
         
                const fd = new FormData(); 
                fd.append('file', file); 
                
                try { 
                    const res = await fetch('/api/upload', { method: 'POST', body: fd }); 
                    const data = await res.json(); 
                    if(data.filename) {
                        const isImg = file.type.startsWith('image/');
                        if (isImg) {
                            wrapSelection(`[img:${data.filename}]`, '', ''); 
                        } else {
                            wrapSelection(`[file:${data.filename}|${data.original}]`, '', ''); 
                        }
                    } else {
                        showModal("Fehler", "Ungültiger Dateityp oder Datei zu groß.", [
                            { label: "OK", class: "btn-cancel", action: () => {} }
                        ]); 
                    }
                } catch(err) {
                    console.error(err);
                } 
            }; 
            input.click(); 
        }
         
        function openLightbox(src) { 
            document.getElementById('lightbox-img').src = src; 
            document.getElementById('lightbox').style.display = 'flex'; 
        }
         
        function closeLightbox() { 
            document.getElementById('lightbox').style.display = 'none'; 
            document.getElementById('lightbox-img').src = ''; 
        }
         
        function initDragAndDrop() { 
            const ta = document.getElementById('node-text'); 
            
            ta.addEventListener('dragover', e => { 
                e.preventDefault(); 
                ta.style.border = '1px dashed var(--accent)'; 
            }); 
            
            ta.addEventListener('dragleave', e => { 
                e.preventDefault(); 
                ta.style.border = '1px solid var(--border-color)'; 
            }); 
            
            ta.addEventListener('drop', async e => { 
                e.preventDefault(); 
                ta.style.border = '1px solid var(--border-color)'; 
                
                if(e.dataTransfer.files && e.dataTransfer.files.length > 0) { 
                    const f = e.dataTransfer.files[0]; 
                    
                    if (f.size > 20 * 1024 * 1024) {
                        showModal("Zu groß", "Maximal 20 MB erlaubt.", [{label: "OK", class: "btn-cancel", action: () => {}}]);
                        return;
                    }
         
                    const fd = new FormData(); 
                    fd.append('file', f); 
                    try { 
                        const res = await fetch('/api/upload', { method: 'POST', body: fd }); 
                        const data = await res.json(); 
                        if(data.filename) { 
                            const isImg = f.type.startsWith('image/');
                            const txt = isImg ? `[img:${data.filename}]` : `[file:${data.filename}|${data.original}]`; 
                            
                            const s = ta.selectionStart;
                            const end = ta.selectionEnd;
                            ta.value = ta.value.substring(0, s) + txt + ta.value.substring(end); 
                            ta.focus(); 
                            ta.setSelectionRange(s + txt.length, s + txt.length); 
                        } 
                    } catch(err) {
                        console.error(err);
                    } 
                } 
            }); 
        }
         
        function getAllNotesFlat(nodes, path="") { 
            let res = []; 
            nodes.forEach(n => { 
                let currentPath = path ? path + " / " + n.title : n.title; 
                res.push({id: n.id, title: n.title, path: currentPath}); 
                if(n.children) {
                    res = res.concat(getAllNotesFlat(n.children, currentPath)); 
                }
            }); 
            return res; 
        }
         
        function initMentionSystem() {
            const ta = document.getElementById('node-text'); 
            const dropdown = document.getElementById('mention-dropdown');
            
            ta.addEventListener('input', function() {
                let cursor = ta.selectionStart; 
                let textBefore = ta.value.substring(0, cursor); 
                let match = textBefore.match(/(?:^|\s)@([^\n]{0,30})$/);
                
                if (match) {
                    let search = match[1].toLowerCase(); 
                    let allNotes = getAllNotesFlat(fullData.content).filter(n => n.id !== activeId);
                    let filtered = allNotes.filter(n => n.title.toLowerCase().includes(search) || n.path.toLowerCase().includes(search));
                    
                    if (filtered.length > 0) {
                        dropdown.innerHTML = '';
                        filtered.forEach(n => { 
                            let div = document.createElement('div'); 
                            div.className = 'mention-item'; 
                            div.innerHTML = `<strong>${n.title}</strong><span class="mention-path">${n.path}</span>`; 
                            div.onclick = () => insertMention(n.id, n.title, match[1].length + 1); 
                            dropdown.appendChild(div); 
                        });
                        dropdown.style.display = 'block';
                    } else { 
                        dropdown.style.display = 'none'; 
                    }
                } else { 
                    dropdown.style.display = 'none'; 
                }
            });
            
            document.addEventListener('click', (e) => { 
                if(e.target !== ta && !dropdown.contains(e.target)) {
                    dropdown.style.display = 'none'; 
                }
            });
        }
         
        function insertMention(id, title, replaceLength) {
            let ta = document.getElementById('node-text'); 
            let cursor = ta.selectionStart; 
            let start = cursor - replaceLength; 
            let text = ta.value;
            
            let linkCode = `[note:${id}|${title}] `; 
            ta.value = text.substring(0, start) + linkCode + text.substring(cursor); 
            ta.focus();
            
            let newCursor = start + linkCode.length; 
            ta.setSelectionRange(newCursor, newCursor); 
            document.getElementById('mention-dropdown').style.display = 'none';
        }
         
        function triggerMentionButton() {
            let ta = document.getElementById('node-text'); 
            let s = ta.selectionStart; 
            let prefix = (s === 0 || ta.value.charAt(s - 1) === '\n' || ta.value.charAt(s - 1) === ' ') ? '@' : ' @';
            
            ta.value = ta.value.substring(0, s) + prefix + ta.value.substring(ta.selectionEnd); 
            ta.focus(); 
            ta.setSelectionRange(s + prefix.length, s + prefix.length); 
            ta.dispatchEvent(new Event('input'));
        }
         
        function confirmAutoSort() { 
            showModal("Sortieren?", "Automatisch sortieren?\nAchtung: Dies kann nicht automatisch rückgängig gemacht werden.", [ 
                { label: "Ja, Sortieren", class: "btn-discard", action: async () => { await applyAutoSort(); } }, 
                { label: "Abbrechen", class: "btn-cancel", action: () => {} } 
            ]); 
        }
         
        async function applyAutoSort() { 
            const sortRecursive = (list) => { 
                list.sort((a, b) => { 
                    const aIsFolder = a.children && a.children.length > 0; 
                    const bIsFolder = b.children && b.children.length > 0; 
                    if (aIsFolder && !bIsFolder) return -1; 
                    if (!aIsFolder && bIsFolder) return 1; 
                    return a.title.localeCompare(b.title, undefined, {numeric: true, sensitivity: 'base'}); 
                }); 
                list.forEach(item => { 
                    if(item.children) sortRecursive(item.children); 
                }); 
            }; 
            sortRecursive(fullData.content); 
            await saveToServer(); 
            renderTree(); 
        }
         
        window.onload = () => { 
            loadData(); 
            initDragAndDrop(); 
            initMentionSystem(); 
        };
        EOF
         
        # templates/login.html
        cat << 'EOF' > $INSTALL_DIR/templates/login.html
        <!DOCTYPE html>
        <html lang="de">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width,initial-scale=1.0">
            <title>Login - Notes</title>
            <link rel="stylesheet" href="/static/style.css?v={{ v }}">
            <style> 
                body { 
                    display: flex; 
                    justify-content: center; 
                    align-items: center; 
                } 
                
                .login-box { 
                    background: var(--sidebar-bg); 
                    padding: 30px; 
                    border-radius: 8px; 
                    border: 1px solid var(--border-color); 
                    text-align: center; 
                    width: 300px; 
                    box-shadow: 0 5px 20px rgba(0,0,0,0.2); 
                } 
            </style>
        </head>
        <body data-theme="{{ theme }}">
            <div class="login-box">
                <h2 style="margin-top: 0">Login</h2>
                {% if error %}<p style="color:#e74c3c; font-size: 0.9em; margin-bottom: 15px;">{{ error }}</p>{% endif %}
                <form method="POST">
                    <input type="password" name="password" placeholder="Passwort eingeben" required autofocus>
                    <button type="submit" style="width:100%; background:{{ accent }} !important; color:white; padding:10px; border-radius:5px; margin-top:10px; font-weight:bold;">Einloggen</button>
                </form>
            </div>
        </body>
        </html>
        EOF
         
        # templates/index.html
        cat << 'EOF' > $INSTALL_DIR/templates/index.html
        <!DOCTYPE html>
        <html lang="de">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width,initial-scale=1.0">
            <title>Notes</title>
            <link rel="stylesheet" href="/static/style.css?v={{ v }}">
            <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
            <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/tomorrow-night-blue.min.css">
            <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
        </head>
        <body data-theme="dark">
            <div class="header-actions">
                <div class="dropdown">
                    <button onclick="toggleSettings(event)" style="font-size:1.4em">⚙️</button>
                    <div class="dropdown-content" id="dropdown-menu">
                        <div class="menu-row" onclick="toggleTheme()"><span>🌓 Theme wechseln</span></div>
                        <div class="menu-row"><span>🎨 Akzentfarbe</span><input type="color" id="accent-color-picker" onchange="updateGlobalAccent(this.value)" onclick="event.stopPropagation()"></div>
                        <div class="menu-row" onclick="exportData()"><span>📤 Backup laden (Vollständig)</span></div>
                        <div class="menu-row" onclick="document.getElementById('import-file').click()"><span>📥 Restore (tar.gz / json)</span></div>
                        <div class="menu-row" onclick="togglePassword()"><span id="pwd-toggle-text">🔒 Passwortschutz an</span></div>
                        <div class="menu-row" id="logout-btn" style="display:none; color:#e74c3c;" onclick="window.location.href='/logout'"><span>🚪 Abmelden</span></div>
                        <input type="file" id="import-file" style="display:none" onchange="importData(event)">
                    </div>
                </div>
            </div>
            
            <button id="mobile-toggle-btn" onclick="toggleSidebar()"><span>◀</span></button>
            
            <div id="sidebar">
                <div class="sidebar-header">
                    <h3 style="margin:0">Notizen</h3>
                    <div style="display:flex; gap:8px;">
                        <button id="toggle-all-btn" onclick="toggleAllFolders()" title="Alle auf/zu">↔️</button>
                        <button id="sort-btn" onclick="confirmAutoSort()" title="Automatisch sortieren">⇅</button>
                        <button onclick="toggleEditMode()" title="Bearbeiten">✏️</button>
                    </div>
                </div>
                <div style="padding:15px; flex-shrink: 0;">
                    <div class="search-wrapper">
                        <input type="text" id="search-input" placeholder="Suchen..." oninput="filterTree()">
                        <span id="clear-search" onclick="clearSearch()">✕</span>
                    </div>
                    <button onclick="addItem()" style="width:100%;background:var(--accent) !important;color:white;padding:8px;border-radius:4px;font-weight:bold;">+ Hauptkategorie</button>
                </div>
                <div id="tree"></div>
            </div>
            
            <div id="editor">
                <div id="no-selection" style="margin-top:50px;text-align:center;opacity:0.5">Wähle eine Notiz aus.</div>
                <div id="edit-area" style="display:none">
                    <div id="breadcrumb" style="font-size:0.8em;color:var(--accent);margin-bottom:15px;"></div>
                    
                    <div id="view-mode">
                        <div style="display:flex; align-items:center; gap:12px; margin-bottom:20px;">
                            <h1 id="view-title" style="margin:0"></h1>
                            <button onclick="enableEdit()" style="font-size:1.2em">✏️</button>
                        </div>
                        <div id="display-area"></div>
                    </div>
                    
                    <div id="edit-mode" style="display:none">
                        <div id="mention-dropdown"></div>
         
                        <div class="toolbar">
                            <button class="tool-btn" onclick="saveChanges();disableEdit();" style="background:var(--accent) !important; color:white;"><i>💾</i><span>OK</span></button>
                            <button class="tool-btn" onclick="cancelEdit()" style="color:#e74c3c;"><i>❌</i><span>Abbruch</span></button>
                            
                            <button class="tool-btn" onclick="wrapSelection('**','**', 'Fett')"><i><b>B</b></i><span>Fett</span></button>
                            <button class="tool-btn" onclick="wrapSelection('_','_', 'Kursiv')"><i style="font-style:italic; font-family:serif;">I</i><span>Kursiv</span></button>
                            <button class="tool-btn" onclick="wrapSelection('~~','~~', 'Text')"><i style="text-decoration:line-through;">S</i><span>Streich</span></button>
                            
                            <button class="tool-btn" onclick="wrapSelection('### ','', 'Überschrift')"><i style="font-weight:bold;">H</i><span>Titel</span></button>
                            <button class="tool-btn" onclick="handleListAction('- ', 'Punkt')"><i style="font-weight:bold;">•—</i><span>Liste</span></button>
                            <button class="tool-btn" onclick="handleListAction('- [ ] ', 'Aufgabe')"><i>☑</i><span>To-Do</span></button>
                            
                            <button class="tool-btn" onclick="wrapSelection('> ','', 'Zitat')"><i style="font-family:serif;">"</i><span>Zitat</span></button>
                            <button class="tool-btn" onclick="wrapSelection('[s=Spoiler-Titel]\n','\n[/s]', 'Text hier...')"><i>👁️‍🗨️</i><span>Spoiler</span></button>
                            <button class="tool-btn" onclick="wrapSelection('\n---\n','', '')"><i>—</i><span>Linie</span></button>
         
                            <button class="tool-btn" onclick="insertCodeTag()"><i>💻</i><span>Code</span></button>
                            <button class="tool-btn" onclick="uploadImage()"><i>🖼️</i><span>Bild</span></button>
                            <button class="tool-btn" onclick="uploadGenericFile()"><i>📎</i><span>Datei</span></button>
                            <button class="tool-btn" onclick="openSketch()"><i>🖌️</i><span>Skizze</span></button>
                            <button class="tool-btn" onclick="triggerMentionButton()"><i>@</i><span>Verweis</span></button>
                            <button class="tool-btn" onclick="wrapSelection('[','](https://)', 'Link-Text')"><i>🔗</i><span>Web-Link</span></button>
                            
                            <div class="tool-btn color-tool">
                                <div class="color-row">
                                    <span onclick="applyColor()">🎨</span>
                                    <input type="color" id="text-color-input" value="#27ae60">
                                </div>
                                <span>Farbe</span>
                            </div>
                        </div>
                        <input type="text" id="node-title" placeholder="Titel">
                        <textarea id="node-text" placeholder="Text oder Bild hier ablegen..." style="height:60vh"></textarea>
                    </div>
                    
                    <button onclick="addItem(activeId)" style="margin-top:20px;border:1px solid var(--accent) !important;color:var(--accent);padding:5px 10px;border-radius:4px;">+ Unter-Ebene</button>
                </div>
            </div>
            
            <div id="sketch-modal" class="modal-overlay">
                <div class="modal">
                    <h3 style="margin-top:0">Skizzenblock</h3>
                    <div id="sketch-toolbar">
                        <div class="sketch-tool">
                            <span>Hintergrund:</span>
                            <select id="sketch-bg-select" onchange="setSketchBg(this.value)" style="padding:5px; border-radius:4px;">
                                <option value="white">Weiß</option>
                                <option value="black">Schwarz</option>
                            </select>
                        </div>
                        <div class="sketch-tool">
                            <span>Farbe:</span>
                            <input type="color" onchange="sketchColor=this.value" value="#000000">
                        </div>
                        <div class="sketch-tool">
                            <span>Dicke:</span>
                            <input type="range" min="1" max="50" value="8" onchange="sketchWidth=this.value" style="width: 80px;">
                        </div>
                        
                        <button id="btn-pen" class="sketch-btn active" onclick="setSketchMode('pen')">✏️ Stift</button>
                        <button id="btn-highlighter" class="sketch-btn" onclick="setSketchMode('highlighter')">🖍️ Marker</button>
                        <button id="btn-eraser" class="sketch-btn" onclick="setSketchMode('eraser')">🧽 Radierer</button>
                        
                        <button class="sketch-btn" onclick="undoSketch()" style="color:#f39c12;">↩️ Zurück</button>
                        <button class="sketch-btn" onclick="sketchStrokes=[]; redrawSketch();" style="color:#e74c3c;">🗑️ Leeren</button>
                        
                        <div style="flex-grow:1; text-align:right;">
                            <button class="btn-cancel" onclick="document.getElementById('sketch-modal').style.display='none'">Abbruch</button>
                            <button class="btn-save" onclick="saveSketch()">Speichern</button>
                        </div>
                    </div>
                    
                    <div id="canvas-wrapper">
                        <canvas id="sketch-canvas"></canvas>
                    </div>
                    
                </div>
            </div>
         
            <div id="custom-modal" class="modal-overlay">
                <div class="modal">
                    <h3 id="modal-title"></h3>
                    <p id="modal-text" style="white-space: pre-wrap;"></p>
                    <input type="password" id="modal-input" style="display:none; margin-top: 15px; width: 100%; box-sizing: border-box;" placeholder="Passwort...">
                    <div class="modal-btns" id="modal-btns-container"></div>
                </div>
            </div>
            
            <div id="lightbox" onclick="closeLightbox()">
                <img id="lightbox-img" src="">
            </div>
            
            <script src="/static/script.js?v={{ v }}"></script>
        </body>
        </html>
        EOF
         
        # Sicherheit & Rechte
        echo "--- Richte Berechtigungen ein ---"
         
        if ! id -u notizen > /dev/null 2>&1; then 
            useradd -r -s /bin/false notizen
        fi
         
        if [ ! -f $INSTALL_DIR/data.json ]; then 
            echo '{"settings": {"accent": "#27ae60", "theme": "dark", "password_enabled": false, "password_hash": ""}, "content": []}' > $INSTALL_DIR/data.json
        fi
         
        chown -R notizen:notizen $INSTALL_DIR
        find $INSTALL_DIR -type d -exec chmod 750 {} \;
        find $INSTALL_DIR -type f -exec chmod 640 {} \;
         
        chmod 750 $INSTALL_DIR/backup.sh
        chmod 750 $INSTALL_DIR/cleanup.py
         
        # 10. Autostart Logik
        if [[ "$AUTOSTART_CONFIRM" =~ ^[Yy]$ ]]; then
            echo "--- Erstelle Systemd Service ---"
            cat << EOF > /etc/systemd/system/$SERVICE_NAME
        [Unit]
        Description=Notizen Flask App
        After=network.target
         
        [Service]
        User=notizen
        Group=notizen
        WorkingDirectory=$INSTALL_DIR
        ExecStart=$INSTALL_DIR/venv/bin/python $INSTALL_DIR/app.py
        Restart=always
         
        [Install]
        WantedBy=multi-user.target
        EOF
            systemctl daemon-reload
            systemctl enable $SERVICE_NAME
            systemctl restart $SERVICE_NAME
            echo "Autostart wurde aktiviert."
        fi
         
        # 11. Cronjobs verwalten
        if [[ "$CRON_CONFIRM" =~ ^[Yy]$ ]] || [[ "$BACKUP_CONFIRM" =~ ^[Yy]$ ]]; then
            echo "--- Richte Cronjobs ein ---"
            rm -f /etc/cron.d/notizen-tool
            if [[ "$CRON_CONFIRM" =~ ^[Yy]$ ]]; then 
                echo "0 3 * * * notizen /usr/bin/python3 $INSTALL_DIR/cleanup.py" >> /etc/cron.d/notizen-tool
            fi
            if [[ "$BACKUP_CONFIRM" =~ ^[Yy]$ ]]; then 
                echo "0 4 * * * notizen $INSTALL_DIR/backup.sh" >> /etc/cron.d/notizen-tool
            fi
            chmod 644 /etc/cron.d/notizen-tool
        fi
         
        echo "------------------------------------------------"
        echo "Installation abgeschlossen!"
        echo "------------------------------------------------"
        
        

        Zeigt eure Lovelace-Visualisierung klick
        (Auch ideal um sich Anregungen zu holen)

        Meine Tabellen für eure Visualisierung klick

        1 Antwort Letzte Antwort
        1
        • David G.D David G.

          Hey,

          ich suche schon lange ein kleines Programm womit ich via Browser meine Notizen strukturiert sammeln kann.
          Irgendwie hat mir nichts wirklich gefallen.
          Zu überladen, Code wurde nicht korrekt angezeigt, zu wenig Funktionen etc.

          Dann habe ich gedacht gebe ich mich mal selber an was dran (Weil es mir aber auch Spaß macht sowas zu probieren).

          Würde mich freuen wenn einer von euch auch einen Mehrwert für sich dran findet oder Feedback gibt.

          HINWEIS
          Das Programm ist komplett mit KI geschrieben.
          Ich hab es mit verschiedenen KIs gegenprüfen lassen ob es Sicherheitstechnische Bedenken gibt. Ich selber finde den Code so weit ich es interpretiert bekomme auch okay.

          Wenn alles mit KI gecodet ist hier auch eine Beschreibung aus der KI 🤣:

          🚀 Die Architektur (Unter der Haube)

          • Backend: Python (Flask). Keine aufwendige Datenbank nötig, alle Texte und Strukturen werden in einer sauberen data.json gespeichert.
          • Frontend: Pures Vanilla JS, HTML und CSS. Keine überladenen Frameworks, die Ladezeiten fressen.
          • Hosting: Läuft perfekt im LXC-Container, auf einem Raspberry Pi oder einer kleinen VM.

          ✨ Features & Funktionen

          📝 Editor & Inhalte

          • Markdown-Support: Überschriften, Fett, Kursiv, Durchgestrichen, Zitate und Trennlinien.
          • Interaktive To-Do-Listen: Checkboxen (- [ ]) können direkt in der Lese-Ansicht abgehakt werden. Der Status wird live und unsichtbar im Hintergrund gespeichert.
          • Code-Blöcke: Syntax-Highlighting für Code-Schnipsel inkl. praktischem "Copy"-Button.
          • Spoiler-Tags: Einklappbare Textblöcke ([s=Titel]...[/s]) für lange Logs oder Skripte.
          • Verlinkungen (Mentions): Tippt man ein @ in den Editor, öffnet sich ein Dropdown, über das man direkt andere Notizen verlinken kann.
          • Integrierter Skizzenblock: Über das Pinsel-Icon öffnet sich ein Zeichen-Tool für schnelle Mindmaps oder handschriftliche Notizen. Sie lassen sich per Klick in der Notiz jederzeit nachträglich weiterbearbeiten und aktualisieren sich nach dem Speichern sofort live ohne Neuladen.

          📂 Organisation & UI

          • Unendliche Verschachtelung: Ordner in Ordner in Ordner – baut euch euren Baum, wie ihr ihn braucht.
          • Sicherer Sortier-Modus: Es gibt einen dedizierten Sortier-Modus (⇅-Button). Mit einem Klick lassen sich hier alle Ordner und Dateien automatisch "natürlich" sortieren (Ordner nach oben, dann 1, 2, 11, A, B).
          • Live-Suche: Durchsucht den gesamten Notizbaum und die Inhalte in Millisekunden.
          • Design anpassbar: Wechsel zwischen Dark- und Light-Mode sowie ein Color-Picker für die globale Akzentfarbe (z. B. Grün, Blau, Orange).
          • 100% Mobile Ready: Die Seitenleiste lässt sich auf dem Handy einklappen. Zudem gibt es spezielle CSS-Fixes, damit die mobile Bildschirmtastatur das Layout nicht zerschießt.

          🖼️ Medien-Handling

          • Bilder und Dateien per Drag & Drop: Bilder können einfach vom Desktop in den Editor gezogen oder über einen Button hochgeladen werden (lokal gespeichert).
          • Lightbox: Klickt man auf ein Bild in einer Notiz, öffnet es sich großzentriert.

          🛡️ Sicherheit, Backup & Stabilität

          • Passwortschutz: Lässt sich direkt im Web-Interface aktivieren/deaktivieren.
          • Konflikt-Erkennung: Warnt vor dem Überschreiben, wenn die Notiz auf einem anderen Gerät parallel geändert wurde.
          • 1-Klick Backup & Restore: Jederzeit ein vollständiges .tar.gz-Archiv (JSON + Bilder) laden und wiederherstellen.

          🛠️ Installation

          Das Ganze lässt sich über ein einziges, interaktives Bash-Skript installieren.

          Warum braucht das Skript sudo bzw. root Rechte?
          Damit das Tool sauber und sicher als Hintergrunddienst auf eurem Server läuft, greift das Skript tief ins System ein. Es installiert zwingend benötigte System-Pakete (python3, python3-pip, python3-venv und cron), legt die Ordnerstruktur sicher unter /opt/notiz-tool an, erstellt einen eigenen System-Benutzer ohne Login-Rechte (useradd), richtet einen systemd-Service für den Autostart ein und trägt auf Wunsch Cronjobs unter /etc/cron.d/ ein.

          So funktioniert die Installation:

          1. Erstellt eine neue Datei auf eurem Server und fügt das Skript (siehe unten) ein:
            nano install.sh
          2. Macht das Skript ausführbar:
            chmod +x install.sh
          3. Führt das Skript mit Root-Rechten aus:
            sudo ./install.sh

          Das Skript führt euch dann durch ein kurzes Menü und fragt 4 Dinge ab:

          1. Port: Unter welchem Port soll das Web-Interface erreichbar sein? (Standard: 8080)
          2. Autostart: Soll ein systemd-Service angelegt werden, damit das Tool nach einem Server-Reboot automatisch wieder startet?
          3. Medienbereinigung: Soll ein nächtlicher Cronjob eingerichtet werden, der prüft, ob es im uploads/-Ordner verwaiste Bilder gibt, und diese löscht?
          4. Backup: Soll ein täglicher Cronjob eingerichtet werden, der nachts automatisch ein .tar.gz Archiv eurer Daten anlegt?

          🌐 Externe Abhängigkeiten (Privacy Info)
          Um das Installations-Skript so kompakt wie möglich zu halten, lädt das Web-Interface standardmäßig zwei kleine Bibliotheken über öffentliche CDNs:

          • SortableJS (via jsdelivr): Für die geschmeidige Drag-and-Drop Sortierung im Seitenmenü.
          • Highlight.js (via cdnjs): Für das Syntax-Highlighting der Code-Blöcke (JS-Skript und CSS-Theme).

          Wem das aus Privacy-Gründen ein Dorn im Auge ist oder wer das Tool komplett vom Internet isoliert im reinen Offline-Netz betreiben möchte, kann das in 2 Minuten ändern:
          Ladet euch die entsprechenden .js und .css Dateien einfach herunter, packt sie in den Ordner /opt/notiz-tool/static/ und passt die Pfade oben im Header der Datei /opt/notiz-tool/templates/index.html so an, dass sie auf die lokalen Dateien zeigen. Danach funkt das Tool zu 100 % nicht mehr nach Hause!

          1000061978.jpg 1000061976.jpg 1000061974.jpg 1000061972.jpg 1000061980.jpg
          1000062021.jpg

          Installation geht am einfachsten über eine kleine Setupdatei auf github.

          wget https://raw.githubusercontent.com/ipod86/Notizen/main/setup.sh
          
          chmod +x setup.sh
          
          sudo ./setup.sh
          
          OliverIOO Offline
          OliverIOO Offline
          OliverIO
          schrieb zuletzt editiert von
          #11

          @David-G.

          Biete das doch direkt als docker Container an. Das dockerfile kannst du dir auch mit ki schreiben lassen.
          Hier habe ich ein ähnliches Projekt

          https://github.com/oweitman/fail2bancontrol
          https://hub.docker.com/r/oweitman/fail2bancontrol

          Meine Adapter und Widgets
          TVProgram, SqueezeboxRPC, OpenLiga, RSSFeed, MyTime,, pi-hole2, vis-json-template, skiinfo, vis-mapwidgets, vis-2-widgets-rssfeed
          Links im Profil

          David G.D 1 Antwort Letzte Antwort
          0
          • OliverIOO OliverIO

            @David-G.

            Biete das doch direkt als docker Container an. Das dockerfile kannst du dir auch mit ki schreiben lassen.
            Hier habe ich ein ähnliches Projekt

            https://github.com/oweitman/fail2bancontrol
            https://hub.docker.com/r/oweitman/fail2bancontrol

            David G.D Online
            David G.D Online
            David G.
            schrieb zuletzt editiert von
            #12

            @OliverIO

            Da habe ich mich noch nie mit beschäftigt.
            Muss ich das ja nicht viel mehr pflegen, nicht dass die Leute in ein oder zwei Jahren ganz veraltete Programmversion am Laufen haben.

            Jetzt ist ja alles immer aktuell über das Betriebssystem.

            Zeigt eure Lovelace-Visualisierung klick
            (Auch ideal um sich Anregungen zu holen)

            Meine Tabellen für eure Visualisierung klick

            OliverIOO 1 Antwort Letzte Antwort
            0
            • P Offline
              P Offline
              peterfido
              schrieb zuletzt editiert von
              #13

              @david-g. vielen Dank. Ich teste es zeitnah. Die einzige Hürde war das Skript auf die Debian VM zu bekommen. Zumindest, wenn man, wie ich gestern, nicht am PC sitzt. Das ISO-Image war eine Idee, ich ich jetzt mal ausprobiert habe. Und, es hat funktioniert. Aber nicht jeder hat Proxmox. Per wget aus Github wäre wohl auch einfach gegangen. Aber so oft wird das wohl nicht aktualisiert werden müssen.

              "Multiuser" ist mein Standard-Anwendungsfall. Notizen für eine Reise / Ausflug / Shoppen bearbeite ich mit dem Gerät, was ich gerade zur Hand habe. Also oft Android-Tablet, PC, Handy.

              Das mit dem Neuladen hatte ich bei den Tests vorhin zweimal. Die Meldung kommt dann halt beim Speichern und die Änderungen waren dann weg. Abbrechen hatte ich so direkt nicht gefunden. Da gucke ich nochmal genauer hin.

              Gruß

              Peterfido


              Proxmox auf Intel NUC12WSHi5
              ioBroker: Debian (VM)
              CCU: Debmatic (VM)
              Influx: Debian (VM)
              Grafana: Debian (VM)
              eBus: Debian (VM)
              Zigbee: Debian (VM) mit zigbee2mqtt

              David G.D 1 Antwort Letzte Antwort
              0
              • David G.D David G.

                @OliverIO

                Da habe ich mich noch nie mit beschäftigt.
                Muss ich das ja nicht viel mehr pflegen, nicht dass die Leute in ein oder zwei Jahren ganz veraltete Programmversion am Laufen haben.

                Jetzt ist ja alles immer aktuell über das Betriebssystem.

                OliverIOO Offline
                OliverIOO Offline
                OliverIO
                schrieb zuletzt editiert von
                #14

                @David-G.

                wenn ich ein release commit mache, erzeugt github automatisch einen aktualisierten container und publiziert den auch noch dockerhub

                Meine Adapter und Widgets
                TVProgram, SqueezeboxRPC, OpenLiga, RSSFeed, MyTime,, pi-hole2, vis-json-template, skiinfo, vis-mapwidgets, vis-2-widgets-rssfeed
                Links im Profil

                1 Antwort Letzte Antwort
                0
                • P peterfido

                  @david-g. vielen Dank. Ich teste es zeitnah. Die einzige Hürde war das Skript auf die Debian VM zu bekommen. Zumindest, wenn man, wie ich gestern, nicht am PC sitzt. Das ISO-Image war eine Idee, ich ich jetzt mal ausprobiert habe. Und, es hat funktioniert. Aber nicht jeder hat Proxmox. Per wget aus Github wäre wohl auch einfach gegangen. Aber so oft wird das wohl nicht aktualisiert werden müssen.

                  "Multiuser" ist mein Standard-Anwendungsfall. Notizen für eine Reise / Ausflug / Shoppen bearbeite ich mit dem Gerät, was ich gerade zur Hand habe. Also oft Android-Tablet, PC, Handy.

                  Das mit dem Neuladen hatte ich bei den Tests vorhin zweimal. Die Meldung kommt dann halt beim Speichern und die Änderungen waren dann weg. Abbrechen hatte ich so direkt nicht gefunden. Da gucke ich nochmal genauer hin.

                  David G.D Online
                  David G.D Online
                  David G.
                  schrieb zuletzt editiert von
                  #15

                  @peterfido sagte in Lokal Notizen verwalten:

                  Multiuser

                  Multiuser bedeutet eigentlich auch, zwei oder mehr Personen die gleichzeitig mit arbeiten.

                  Jetzt sollte es aber sicherer sein, wenn du nicht innerhalb von 30sek die Geräte wechselst.

                  Gann das Script ja auf GitHub verfügbar machen.
                  Dort kann man ja auch vorher reinschauen.

                  @OliverIO sagte in Lokal Notizen verwalten:

                  @David-G.

                  wenn ich ein release commit mache, erzeugt github automatisch einen aktualisierten container und publiziert den auch noch dockerhub

                  Arbeitest du dann mit latest Images auf denen du aufbaust?
                  Docker war nur so meins. Im Moment Wechsel ich nach und nach wo es geht von Docker Containern zu CTs in Proxmox.

                  Zeigt eure Lovelace-Visualisierung klick
                  (Auch ideal um sich Anregungen zu holen)

                  Meine Tabellen für eure Visualisierung klick

                  OliverIOO 1 Antwort Letzte Antwort
                  0
                  • P Offline
                    P Offline
                    peterfido
                    schrieb zuletzt editiert von
                    #16

                    Github oder so wäre gar nicht verkehrt. Die Änderung ist nur im Post #10? Oder auch im Download in #1?

                    Beim Texteditor muss ich aufpassen, dass das Zeilenende im korrekten Format ist. Auch die genutzte Codierung auf meinem Tablet weiß ich gerade nicht. Der Download gestern ging einfach zu handhaben.

                    Gruß

                    Peterfido


                    Proxmox auf Intel NUC12WSHi5
                    ioBroker: Debian (VM)
                    CCU: Debmatic (VM)
                    Influx: Debian (VM)
                    Grafana: Debian (VM)
                    eBus: Debian (VM)
                    Zigbee: Debian (VM) mit zigbee2mqtt

                    David G.D 1 Antwort Letzte Antwort
                    0
                    • David G.D David G.

                      @peterfido sagte in Lokal Notizen verwalten:

                      Multiuser

                      Multiuser bedeutet eigentlich auch, zwei oder mehr Personen die gleichzeitig mit arbeiten.

                      Jetzt sollte es aber sicherer sein, wenn du nicht innerhalb von 30sek die Geräte wechselst.

                      Gann das Script ja auf GitHub verfügbar machen.
                      Dort kann man ja auch vorher reinschauen.

                      @OliverIO sagte in Lokal Notizen verwalten:

                      @David-G.

                      wenn ich ein release commit mache, erzeugt github automatisch einen aktualisierten container und publiziert den auch noch dockerhub

                      Arbeitest du dann mit latest Images auf denen du aufbaust?
                      Docker war nur so meins. Im Moment Wechsel ich nach und nach wo es geht von Docker Containern zu CTs in Proxmox.

                      OliverIOO Offline
                      OliverIOO Offline
                      OliverIO
                      schrieb zuletzt editiert von OliverIO
                      #17

                      @David-G. sagte in Lokal Notizen verwalten:

                      Arbeitest du dann mit latest Images auf denen du aufbaust?

                      Das ist das schöne, ich muss mich gar nicht mit Betriebssystem beschäftigen.
                      Ich nehme ein vorhandenes Image direkt von python. Solange die Version vom aktuellsten Betriebssystem unterstützt wird, wird das so bereitgestellt.

                      https://hub.docker.com/_/python#shared-tags

                      Also python:3-slim ist immer das letzte 3er stable von python mit dem letzten stable von debian slim ist immer die schmale Ausführung des Betriebssystems mit nur dem nötigsten.
                      https://hub.docker.com/layers/library/python/3-slim
                      in diesem Fall trixie. Gesamtgröße image ist ca 42MB

                      python bietet eine vielzahl von versions tags an. da kann man alles auswählen was man will

                      ok, in meinem aktuellen container verweise ich auf eine feste version 3.12-slim

                      Meine Adapter und Widgets
                      TVProgram, SqueezeboxRPC, OpenLiga, RSSFeed, MyTime,, pi-hole2, vis-json-template, skiinfo, vis-mapwidgets, vis-2-widgets-rssfeed
                      Links im Profil

                      1 Antwort Letzte Antwort
                      0
                      • P Offline
                        P Offline
                        peterfido
                        schrieb zuletzt editiert von peterfido
                        #18

                        @david-g. ich muss nachher wohl doch an den PC. Die Installation auf dem Weg wie gestern meldet zumindest ein Berechtigungsproblem in Zeile 50.
                        1000074487.jpg

                        Edit: lief wohl trotzdem durch:
                        1000074491.jpg
                        Ich bin begeistert.
                        Evtl. den Schieber für die Stiftstärke breiter machen oder einen Wert anzeigen und +- Tasten. Zumindest ich brauchte mit dem Stylus einige Anläufe, bis die Strichstärke wie gewünscht war, da der Schieberegler anfangs nicht fein justiert werden kann. Es braucht für die erste Änderung eine gewisse Bewegungsstrecke des Schiebers.

                        Gruß

                        Peterfido


                        Proxmox auf Intel NUC12WSHi5
                        ioBroker: Debian (VM)
                        CCU: Debmatic (VM)
                        Influx: Debian (VM)
                        Grafana: Debian (VM)
                        eBus: Debian (VM)
                        Zigbee: Debian (VM) mit zigbee2mqtt

                        1 Antwort Letzte Antwort
                        0
                        • P peterfido

                          Github oder so wäre gar nicht verkehrt. Die Änderung ist nur im Post #10? Oder auch im Download in #1?

                          Beim Texteditor muss ich aufpassen, dass das Zeilenende im korrekten Format ist. Auch die genutzte Codierung auf meinem Tablet weiß ich gerade nicht. Der Download gestern ging einfach zu handhaben.

                          David G.D Online
                          David G.D Online
                          David G.
                          schrieb zuletzt editiert von
                          #19

                          @peterfido sagte in Lokal Notizen verwalten:

                          Github oder so wäre gar nicht verkehrt. Die Änderung ist nur im Post #10? Oder auch im Download in #1?

                          Beim Texteditor muss ich aufpassen, dass das Zeilenende im korrekten Format ist. Auch die genutzte Codierung auf meinem Tablet weiß ich gerade nicht. Der Download gestern ging einfach zu handhaben.

                          Hab es hochgeladen mit kleiner Setupankeitung
                          https://github.com/ipod86/Notizen/blob/main/README.md

                          @OliverIO sagte in Lokal Notizen verwalten:

                          @David-G. sagte in Lokal Notizen verwalten:

                          Arbeitest du dann mit latest Images auf denen du aufbaust?

                          Das ist das schöne, ich muss mich gar nicht mit Betriebssystem beschäftigen.
                          Ich nehme ein vorhandenes Image direkt von python. Solange die Version vom aktuellsten Betriebssystem unterstützt wird, wird das so bereitgestellt.

                          https://hub.docker.com/_/python#shared-tags

                          Also python:3-slim ist immer das letzte 3er stable von python mit dem letzten stable von debian slim ist immer die schmale Ausführung des Betriebssystems mit nur dem nötigsten.
                          https://hub.docker.com/layers/library/python/3-slim
                          in diesem Fall trixie. Gesamtgröße image ist ca 42MB

                          python bietet eine vielzahl von versions tags an. da kann man alles auswählen was man will

                          ok, in meinem aktuellen container verweise ich auf eine feste version 3.12-slim

                          Ich schaue mir das mal an wie das läuft. Ob cron oder so drinnen ist oder da was angepasst werden muss.

                          Zeigt eure Lovelace-Visualisierung klick
                          (Auch ideal um sich Anregungen zu holen)

                          Meine Tabellen für eure Visualisierung klick

                          1 Antwort Letzte Antwort
                          1
                          • P Offline
                            P Offline
                            peterfido
                            schrieb zuletzt editiert von
                            #20

                            Das ist wesentlich einfacher. Bei der Anleitung im Github ist die URL zu dem Skript zweimal hinter wget. nicht, dass da jemand drüber stolpert.

                            Ich nehme dann mein iso-Image aus Post#5 (oder so) wieder raus.

                            Gruß

                            Peterfido


                            Proxmox auf Intel NUC12WSHi5
                            ioBroker: Debian (VM)
                            CCU: Debmatic (VM)
                            Influx: Debian (VM)
                            Grafana: Debian (VM)
                            eBus: Debian (VM)
                            Zigbee: Debian (VM) mit zigbee2mqtt

                            David G.D 1 Antwort Letzte Antwort
                            0
                            • P peterfido

                              Das ist wesentlich einfacher. Bei der Anleitung im Github ist die URL zu dem Skript zweimal hinter wget. nicht, dass da jemand drüber stolpert.

                              Ich nehme dann mein iso-Image aus Post#5 (oder so) wieder raus.

                              David G.D Online
                              David G.D Online
                              David G.
                              schrieb zuletzt editiert von
                              #21

                              @peterfido

                              URL ist korrigiert und der Fehler den du hattest wegen Zeile 50 hoffe ich auch.

                              Zeigt eure Lovelace-Visualisierung klick
                              (Auch ideal um sich Anregungen zu holen)

                              Meine Tabellen für eure Visualisierung klick

                              1 Antwort Letzte Antwort
                              0
                              • David G.D Online
                                David G.D Online
                                David G.
                                schrieb zuletzt editiert von David G.
                                #22

                                Hab noch was neues eingebaut.
                                Einen Reminder.

                                Im Bearbeitungsmodus einer Notiz kann man jetzt ein Datum oder Datum mit Uhrzeit vergeben.

                                Wenn erreicht bekommt man im Baum der Notizen bis zur entsprechenden Notiz ein leicht blinkendes Warnsymbol angezeigt.

                                1000062079.jpg 1000062077.jpg

                                Edit:
                                Warte aber noch kurz mit der Version, noch ein Bug dass man nach 30sek aus dem Editor fliegt.

                                Zeigt eure Lovelace-Visualisierung klick
                                (Auch ideal um sich Anregungen zu holen)

                                Meine Tabellen für eure Visualisierung klick

                                1 Antwort Letzte Antwort
                                1
                                • P Offline
                                  P Offline
                                  peterfido
                                  schrieb zuletzt editiert von
                                  #23

                                  Ok. Zur Not den 30 - Sekunden Refresh wieder rausnehmen. Heute komme ich nicht mehr an den PC. Kann Samstag Morgen werden.

                                  Gruß

                                  Peterfido


                                  Proxmox auf Intel NUC12WSHi5
                                  ioBroker: Debian (VM)
                                  CCU: Debmatic (VM)
                                  Influx: Debian (VM)
                                  Grafana: Debian (VM)
                                  eBus: Debian (VM)
                                  Zigbee: Debian (VM) mit zigbee2mqtt

                                  David G.D 1 Antwort Letzte Antwort
                                  0
                                  • P peterfido

                                    Ok. Zur Not den 30 - Sekunden Refresh wieder rausnehmen. Heute komme ich nicht mehr an den PC. Kann Samstag Morgen werden.

                                    David G.D Online
                                    David G.D Online
                                    David G.
                                    schrieb zuletzt editiert von
                                    #24

                                    @peterfido

                                    Und ich hab bis morgen vormittag kein KI Guthaben mehr ^^.

                                    Zeigt eure Lovelace-Visualisierung klick
                                    (Auch ideal um sich Anregungen zu holen)

                                    Meine Tabellen für eure Visualisierung klick

                                    1 Antwort Letzte Antwort
                                    0
                                    • MartinPM Online
                                      MartinPM Online
                                      MartinP
                                      schrieb zuletzt editiert von
                                      #25

                                      Ich habe gitea als Docker Container.
                                      Bisher nutze ich das auch für Notizensammlung. Bietet aber natürlich nicht den ganzen Funktionsumfang, den Du beschreibst.
                                      Dafür kann man mit git Mechanismen die repositories auf allen Plattformen synchron halten.
                                      Im Browser natürlich dann nur auf der URL des gitea containers

                                      Intel(R) Celeron(R) CPU N3000 @ 1.04GHz 8G RAM 480G SSD
                                      Virtualization : unprivileged lxc container (debian 13) on Proxmox 9.1.5)
                                      Linux pve 6.17.9-1-pve
                                      6 GByte RAM für den Container
                                      Fritzbox 6591 FW 8.20 (Vodafone Leih-Box)
                                      Remote-Access über Wireguard der Fritzbox

                                      1 Antwort Letzte Antwort
                                      0
                                      Antworten
                                      • In einem neuen Thema antworten
                                      Anmelden zum Antworten
                                      • Älteste zuerst
                                      • Neuste zuerst
                                      • Meiste Stimmen


                                      Support us

                                      ioBroker
                                      Community Adapters
                                      Donate

                                      521

                                      Online

                                      32.7k

                                      Benutzer

                                      82.4k

                                      Themen

                                      1.3m

                                      Beiträge
                                      Community
                                      Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
                                      ioBroker Community 2014-2025
                                      logo
                                      • Anmelden

                                      • Du hast noch kein Konto? Registrieren

                                      • Anmelden oder registrieren, um zu suchen
                                      • Erster Beitrag
                                        Letzter Beitrag
                                      0
                                      • Home
                                      • Aktuell
                                      • Tags
                                      • Ungelesen 0
                                      • Kategorien
                                      • Unreplied
                                      • Beliebt
                                      • GitHub
                                      • Docu
                                      • Hilfe