diff --git a/docs/TOOL-JOPLIN-OPENWEBUI.md b/docs/TOOL-JOPLIN-OPENWEBUI.md index e33f1f0..90e0f54 100644 --- a/docs/TOOL-JOPLIN-OPENWEBUI.md +++ b/docs/TOOL-JOPLIN-OPENWEBUI.md @@ -1,28 +1,30 @@ -# Joplin Tool for Open WebUI +# Joplin Tool for Open WebUI (Clipper API) ## Description -Permet a Effi de lire et ecrire des notes dans Joplin via l'API Clipper (locale). Gestion de notes, notebooks et TODOs directement depuis le chat. - -**Important** : L'API Clipper fonctionne uniquement quand l'app Joplin desktop est ouverte et le Web Clipper active (Options → Web Clipper). +Permet a Effi de lire et ecrire des notes dans Joplin via l'API Clipper locale. +**Necessite Joplin desktop ouvert** avec le Web Clipper active (Options → Web Clipper → Enable). +Les notes sont synchronisees automatiquement via le serveur Joplin (joplin.folzocorp.fr). ## Installation 1. **Admin Panel** → **Tools** → **Create Tool** (ou +) 2. **Name** : `Joplin` -3. **Description** : `Notes Joplin. Commandes: list_notebooks(), list_notes(notebook_id), get_note(id), create_note(title, body, notebook_id), update_note(id, title, body), search_notes(query), create_notebook(name), add_todo(note_id, text), toggle_todo(note_id, completed)` +3. **Description** : `Notes Joplin. Commandes: ping(), list_notebooks(), list_notes(notebook_id), get_note(id), create_note(title, body, notebook_id), update_note(id, title, body), search_notes(query), create_notebook(name), add_todo(note_id, text), toggle_todo(note_id, completed), delete_note(id)` 4. **Coller le code ci-dessous** 5. **Save and Activate** 6. **Configurer les Valves** via la roue crantee : - `joplin_url` : `http://192.168.1.11:41184` (IP du PC ou Joplin est ouvert) - - `joplin_api_token` : votre jeton API (Options > Web Clipper > Jeton d'authentification) + - `joplin_api_token` : votre jeton d'authentification (Options → Web Clipper dans Joplin) + +**Note** : Quand le PC gamer est eteint, le Tool affichera une erreur. C'est normal — il faut Joplin ouvert. ## Tool Code ```python """ -Joplin Tool for Open WebUI -Lire et ecrire des notes Joplin via l'API Clipper +Joplin Tool for Open WebUI - Clipper API Version +Necessite Joplin desktop ouvert + Web Clipper active. API doc: https://joplinapp.org/help/api/references/rest_api/ """ import json @@ -55,7 +57,7 @@ class Tools: def _check(self): if not self.valves.joplin_api_token: - return "[ERROR] Jeton API Joplin non configure. Ajoutez-le dans les Valves du Tool (Options > Web Clipper dans Joplin)." + return "[ERROR] Jeton API Joplin non configure. Ajoutez-le dans les Valves (Options > Web Clipper dans Joplin)." def _get(self, path, params=None): err = self._check() @@ -69,42 +71,42 @@ class Tools: return {"error": f"[ERROR] Impossible de joindre Joplin. Verifiez que l'app Joplin est ouverte et le Web Clipper active sur {self.valves.joplin_url}"} except requests.exceptions.HTTPError as e: if e.response.status_code == 403: - return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API Joplin dans les Valves."} - return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text}"} + return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API dans les Valves."} + return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text[:200]}"} except Exception as e: return {"error": f"[ERROR] {type(e).__name__}: {e}"} - def _post(self, path, body, params=None): + def _post(self, path, body): err = self._check() if err: return {"error": err} try: - r = requests.post(self._url(path), params=self._params(params), json=body, timeout=15) + r = requests.post(self._url(path), params=self._params(), json=body, timeout=15) r.raise_for_status() return r.json() except requests.exceptions.ConnectionError: return {"error": f"[ERROR] Impossible de joindre Joplin. Verifiez que l'app est ouverte sur {self.valves.joplin_url}"} except requests.exceptions.HTTPError as e: if e.response.status_code == 403: - return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API Joplin dans les Valves."} - return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text}"} + return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API dans les Valves."} + return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text[:200]}"} except Exception as e: return {"error": f"[ERROR] {type(e).__name__}: {e}"} - def _put(self, path, body, params=None): + def _put(self, path, body): err = self._check() if err: return {"error": err} try: - r = requests.put(self._url(path), params=self._params(params), json=body, timeout=15) + r = requests.put(self._url(path), params=self._params(), json=body, timeout=15) r.raise_for_status() return r.json() except requests.exceptions.ConnectionError: return {"error": f"[ERROR] Impossible de joindre Joplin. Verifiez que l'app est ouverte sur {self.valves.joplin_url}"} except requests.exceptions.HTTPError as e: if e.response.status_code == 403: - return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API Joplin dans les Valves."} - return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text}"} + return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API dans les Valves."} + return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text[:200]}"} except Exception as e: return {"error": f"[ERROR] {type(e).__name__}: {e}"} @@ -115,58 +117,56 @@ class Tools: try: r = requests.delete(self._url(path), params=self._params(), timeout=15) r.raise_for_status() - return {"ok": True} + return r.json() if r.text else {"ok": True} except Exception as e: return {"error": f"[ERROR] {type(e).__name__}: {e}"} def ping(self) -> str: """Verifie que Joplin est accessible. :return: Statut de la connexion""" + if not self.valves.joplin_api_token: + return "[ERROR] Jeton API Joplin non configure. Ajoutez-le dans les Valves." try: r = requests.get(f"{self.valves.joplin_url.rstrip('/')}/ping", timeout=5) r.raise_for_status() return f"[OK] Joplin accessible: {r.text}" + except requests.exceptions.ConnectionError: + return f"[ERROR] Joplin inaccessible. Verifiez que l'app est ouverte et le Web Clipper active sur {self.valves.joplin_url}" except Exception as e: - return f"[ERROR] Joplin inaccessible sur {self.valves.joplin_url}: {e}" + return f"[ERROR] {type(e).__name__}: {e}" def list_notebooks(self) -> str: """Liste tous les notebooks Joplin avec leur ID. - :return: Liste des notebooks""" + :return: Liste des notebooks en arborescence""" data = self._get("folders", params={"fields": "id,title,parent_id"}) if isinstance(data, dict) and "error" in data: return data["error"] items = data if isinstance(data, list) else data.get("items", []) if not items: return "[INFO] Aucun notebook trouve." - # Build tree structure by_id = {nb["id"]: nb for nb in items} + for nb in items: + nb.setdefault("children", []) roots = [] for nb in items: parent = nb.get("parent_id", "") if not parent: roots.append(nb) - else: - parent_nb = by_id.get(parent) - if parent_nb: - if "children" not in parent_nb: - parent_nb["children"] = [] - parent_nb["children"].append(nb) + elif parent in by_id: + by_id[parent]["children"].append(nb) lines = [] - def _format(nb, depth=0): - prefix = " " * depth - nid = nb.get("id", "?") - title = nb.get("title", "Sans titre") - lines.append(f"{prefix}- {title} (ID: {nid})") - for child in nb.get("children", []): - _format(child, depth + 1) - for root in roots: - _format(root) + def _fmt(nb, d=0): + lines.append(f"{' '*d}- {nb['title']} (ID: {nb['id']})") + for c in nb.get("children", []): + _fmt(c, d + 1) + for r in roots: + _fmt(r) return "\n".join(lines) def create_notebook(self, name: str, parent_id: str = "") -> str: - """Cree un nouveau notebook Joplin. + """Cree un nouveau notebook. :param name: Nom du notebook - :param parent_id: ID du notebook parent (optionnel, pour creer un sous-notebook) + :param parent_id: ID du notebook parent (optionnel) :return: Confirmation avec ID""" body = {"title": name} if parent_id: @@ -175,12 +175,12 @@ class Tools: if isinstance(result, dict) and "error" in result: return result["error"] nid = result.get("id", "?") - return f"[OK] Notebook '{name}' cree (ID: {nid}). Il sera synchronise sur tous vos appareils via le serveur Joplin." + return f"[OK] Notebook '{name}' cree (ID: {nid})" def list_notes(self, notebook_id: str) -> str: - """Liste les notes dans un notebook Joplin. + """Liste les notes dans un notebook. :param notebook_id: ID du notebook - :return: Liste des notes avec TODOs identifies""" + :return: Liste des notes""" data = self._get(f"folders/{notebook_id}/notes", params={"fields": "id,title,is_todo,todo_completed,updated_time"}) if isinstance(data, dict) and "error" in data: return data["error"] @@ -201,9 +201,9 @@ class Tools: return "\n".join(lines) def get_note(self, note_id: str) -> str: - """Recupere le contenu complet d'une note Joplin. + """Recupere le contenu complet d'une note. :param note_id: ID de la note - :return: Contenu complet de la note en Markdown""" + :return: Contenu Markdown de la note""" data = self._get(f"notes/{note_id}", params={"fields": "id,title,body,is_todo,todo_completed,updated_time,parent_id"}) if isinstance(data, dict) and "error" in data: return data["error"] @@ -221,16 +221,13 @@ class Tools: return f"{header}\n\n{body}" def create_note(self, title: str, body: str = "", notebook_id: str = "", is_todo: bool = False) -> str: - """Cree une nouvelle note Joplin. + """Cree une nouvelle note. :param title: Titre de la note - :param body: Contenu Markdown de la note (optionnel) - :param notebook_id: ID du notebook ou placer la note (optionnel) - :param is_todo: Creer comme tache TODO (default: False) + :param body: Contenu Markdown (optionnel) + :param notebook_id: ID du notebook (optionnel) + :param is_todo: Creer comme TODO (default: False) :return: Confirmation avec ID""" - note_data = { - "title": title, - "is_todo": 1 if is_todo else 0, - } + note_data = {"title": title, "is_todo": 1 if is_todo else 0} if body: note_data["body"] = body if notebook_id: @@ -240,11 +237,11 @@ class Tools: return result["error"] nid = result.get("id", "?") kind = "TODO" if is_todo else "note" - return f"[OK] {kind} '{title}' creee (ID: {nid}). Visible dans Joplin apres synchronisation." + return f"[OK] {kind} '{title}' creee (ID: {nid})" def update_note(self, note_id: str, title: str = None, body: str = None) -> str: - """Met a jour une note Joplin existante. - :param note_id: ID de la note a modifier + """Met a jour une note existante. + :param note_id: ID de la note :param title: Nouveau titre (optionnel) :param body: Nouveau contenu Markdown (optionnel) :return: Confirmation""" @@ -261,9 +258,9 @@ class Tools: return f"[OK] Note {note_id} mise a jour." def add_todo(self, note_id: str, todo_text: str) -> str: - """Ajoute une ligne TODO a la fin d'une note existante. + """Ajoute une ligne TODO a la fin d'une note. :param note_id: ID de la note - :param todo_text: Texte de la tache a ajouter + :param todo_text: Texte de la tache :return: Confirmation""" current = self._get(f"notes/{note_id}", params={"fields": "body,title"}) if isinstance(current, dict) and "error" in current: @@ -277,8 +274,8 @@ class Tools: return f"[OK] TODO ajoute a '{current.get('title', '?')}' : {todo_text}" def search_notes(self, query: str) -> str: - """Recherche des notes dans Joplin par mot-cle. - :param query: Requete de recherche (supporte la syntaxe Joplin) + """Recherche des notes par mot-cle. + :param query: Requete de recherche :return: Liste des notes correspondantes""" data = self._get("search", params={"query": query, "fields": "id,title,is_todo,todo_completed", "limit": "20"}) if isinstance(data, dict) and "error" in data: @@ -293,23 +290,20 @@ class Tools: is_todo = note.get("is_todo", 0) status = "" if is_todo: - status = " [TODO]" + (" OK" if note.get("todo_completed") else " a faire") + status = " [TODO]" + (" OK" if note.get("todo_completed") else "") lines.append(f"- {title}{status} (ID: {nid})") has_more = data.get("has_more", False) if isinstance(data, dict) else False if has_more: - lines.append(f"... et d'autres resultats") + lines.append("... et d'autres resultats") return "\n".join(lines) def toggle_todo(self, note_id: str, completed: bool = True) -> str: - """Marque un TODO comme termine ou non termine. + """Marque un TODO comme termine ou non. :param note_id: ID du TODO :param completed: True pour termine, False pour a faire :return: Confirmation""" import time - note_data = { - "is_todo": 1, - "todo_completed": int(time.time() * 1000) if completed else 0, - } + note_data = {"is_todo": 1, "todo_completed": int(time.time() * 1000) if completed else 0} result = self._put(f"notes/{note_id}", note_data) if isinstance(result, dict) and "error" in result: return result["error"] @@ -317,8 +311,8 @@ class Tools: return f"[OK] TODO {note_id} marque comme {status}." def delete_note(self, note_id: str) -> str: - """Supprime une note Joplin (deplace dans la corbeille). - :param note_id: ID de la note a supprimer + """Supprime une note (corbeille). + :param note_id: ID de la note :return: Confirmation""" result = self._delete(f"notes/{note_id}") if isinstance(result, dict) and "error" in result: