fix: Joplin Tool uses Clipper API (localhost:41184) not Server API
- Server API (/api/items/) does not expose note CRUD, only sync protocol - Clipper API is the only way to read/write notes remotely - Requires Joplin desktop open + Web Clipper enabled - Added ping() function to check connectivity - Valves: joplin_url, joplin_api_token
This commit is contained in:
@@ -1,28 +1,30 @@
|
|||||||
# Joplin Tool for Open WebUI
|
# Joplin Tool for Open WebUI (Clipper API)
|
||||||
|
|
||||||
## Description
|
## 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.
|
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).
|
||||||
**Important** : L'API Clipper fonctionne uniquement quand l'app Joplin desktop est ouverte et le Web Clipper active (Options → Web Clipper).
|
Les notes sont synchronisees automatiquement via le serveur Joplin (joplin.folzocorp.fr).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. **Admin Panel** → **Tools** → **Create Tool** (ou +)
|
1. **Admin Panel** → **Tools** → **Create Tool** (ou +)
|
||||||
2. **Name** : `Joplin`
|
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**
|
4. **Coller le code ci-dessous**
|
||||||
5. **Save and Activate**
|
5. **Save and Activate**
|
||||||
6. **Configurer les Valves** via la roue crantee :
|
6. **Configurer les Valves** via la roue crantee :
|
||||||
- `joplin_url` : `http://192.168.1.11:41184` (IP du PC ou Joplin est ouvert)
|
- `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
|
## Tool Code
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""
|
"""
|
||||||
Joplin Tool for Open WebUI
|
Joplin Tool for Open WebUI - Clipper API Version
|
||||||
Lire et ecrire des notes Joplin via l'API Clipper
|
Necessite Joplin desktop ouvert + Web Clipper active.
|
||||||
API doc: https://joplinapp.org/help/api/references/rest_api/
|
API doc: https://joplinapp.org/help/api/references/rest_api/
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -55,7 +57,7 @@ class Tools:
|
|||||||
|
|
||||||
def _check(self):
|
def _check(self):
|
||||||
if not self.valves.joplin_api_token:
|
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):
|
def _get(self, path, params=None):
|
||||||
err = self._check()
|
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}"}
|
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:
|
except requests.exceptions.HTTPError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API Joplin dans les Valves."}
|
return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API dans les Valves."}
|
||||||
return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text}"}
|
return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text[:200]}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
||||||
|
|
||||||
def _post(self, path, body, params=None):
|
def _post(self, path, body):
|
||||||
err = self._check()
|
err = self._check()
|
||||||
if err:
|
if err:
|
||||||
return {"error": err}
|
return {"error": err}
|
||||||
try:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
return {"error": f"[ERROR] Impossible de joindre Joplin. Verifiez que l'app est ouverte sur {self.valves.joplin_url}"}
|
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:
|
except requests.exceptions.HTTPError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API Joplin dans les Valves."}
|
return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API dans les Valves."}
|
||||||
return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text}"}
|
return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text[:200]}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
||||||
|
|
||||||
def _put(self, path, body, params=None):
|
def _put(self, path, body):
|
||||||
err = self._check()
|
err = self._check()
|
||||||
if err:
|
if err:
|
||||||
return {"error": err}
|
return {"error": err}
|
||||||
try:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
return {"error": f"[ERROR] Impossible de joindre Joplin. Verifiez que l'app est ouverte sur {self.valves.joplin_url}"}
|
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:
|
except requests.exceptions.HTTPError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API Joplin dans les Valves."}
|
return {"error": "[ERROR] Acces refuse. Verifiez votre jeton API dans les Valves."}
|
||||||
return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text}"}
|
return {"error": f"[ERROR] HTTP {e.response.status_code}: {e.response.text[:200]}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
||||||
|
|
||||||
@@ -115,58 +117,56 @@ class Tools:
|
|||||||
try:
|
try:
|
||||||
r = requests.delete(self._url(path), params=self._params(), timeout=15)
|
r = requests.delete(self._url(path), params=self._params(), timeout=15)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return {"ok": True}
|
return r.json() if r.text else {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
return {"error": f"[ERROR] {type(e).__name__}: {e}"}
|
||||||
|
|
||||||
def ping(self) -> str:
|
def ping(self) -> str:
|
||||||
"""Verifie que Joplin est accessible.
|
"""Verifie que Joplin est accessible.
|
||||||
:return: Statut de la connexion"""
|
: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:
|
try:
|
||||||
r = requests.get(f"{self.valves.joplin_url.rstrip('/')}/ping", timeout=5)
|
r = requests.get(f"{self.valves.joplin_url.rstrip('/')}/ping", timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return f"[OK] Joplin accessible: {r.text}"
|
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:
|
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:
|
def list_notebooks(self) -> str:
|
||||||
"""Liste tous les notebooks Joplin avec leur ID.
|
"""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"})
|
data = self._get("folders", params={"fields": "id,title,parent_id"})
|
||||||
if isinstance(data, dict) and "error" in data:
|
if isinstance(data, dict) and "error" in data:
|
||||||
return data["error"]
|
return data["error"]
|
||||||
items = data if isinstance(data, list) else data.get("items", [])
|
items = data if isinstance(data, list) else data.get("items", [])
|
||||||
if not items:
|
if not items:
|
||||||
return "[INFO] Aucun notebook trouve."
|
return "[INFO] Aucun notebook trouve."
|
||||||
# Build tree structure
|
|
||||||
by_id = {nb["id"]: nb for nb in items}
|
by_id = {nb["id"]: nb for nb in items}
|
||||||
|
for nb in items:
|
||||||
|
nb.setdefault("children", [])
|
||||||
roots = []
|
roots = []
|
||||||
for nb in items:
|
for nb in items:
|
||||||
parent = nb.get("parent_id", "")
|
parent = nb.get("parent_id", "")
|
||||||
if not parent:
|
if not parent:
|
||||||
roots.append(nb)
|
roots.append(nb)
|
||||||
else:
|
elif parent in by_id:
|
||||||
parent_nb = by_id.get(parent)
|
by_id[parent]["children"].append(nb)
|
||||||
if parent_nb:
|
|
||||||
if "children" not in parent_nb:
|
|
||||||
parent_nb["children"] = []
|
|
||||||
parent_nb["children"].append(nb)
|
|
||||||
lines = []
|
lines = []
|
||||||
def _format(nb, depth=0):
|
def _fmt(nb, d=0):
|
||||||
prefix = " " * depth
|
lines.append(f"{' '*d}- {nb['title']} (ID: {nb['id']})")
|
||||||
nid = nb.get("id", "?")
|
for c in nb.get("children", []):
|
||||||
title = nb.get("title", "Sans titre")
|
_fmt(c, d + 1)
|
||||||
lines.append(f"{prefix}- {title} (ID: {nid})")
|
for r in roots:
|
||||||
for child in nb.get("children", []):
|
_fmt(r)
|
||||||
_format(child, depth + 1)
|
|
||||||
for root in roots:
|
|
||||||
_format(root)
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def create_notebook(self, name: str, parent_id: str = "") -> str:
|
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 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"""
|
:return: Confirmation avec ID"""
|
||||||
body = {"title": name}
|
body = {"title": name}
|
||||||
if parent_id:
|
if parent_id:
|
||||||
@@ -175,12 +175,12 @@ class Tools:
|
|||||||
if isinstance(result, dict) and "error" in result:
|
if isinstance(result, dict) and "error" in result:
|
||||||
return result["error"]
|
return result["error"]
|
||||||
nid = result.get("id", "?")
|
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:
|
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
|
: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"})
|
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:
|
if isinstance(data, dict) and "error" in data:
|
||||||
return data["error"]
|
return data["error"]
|
||||||
@@ -201,9 +201,9 @@ class Tools:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def get_note(self, note_id: str) -> str:
|
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
|
: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"})
|
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:
|
if isinstance(data, dict) and "error" in data:
|
||||||
return data["error"]
|
return data["error"]
|
||||||
@@ -221,16 +221,13 @@ class Tools:
|
|||||||
return f"{header}\n\n{body}"
|
return f"{header}\n\n{body}"
|
||||||
|
|
||||||
def create_note(self, title: str, body: str = "", notebook_id: str = "", is_todo: bool = False) -> str:
|
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 title: Titre de la note
|
||||||
:param body: Contenu Markdown de la note (optionnel)
|
:param body: Contenu Markdown (optionnel)
|
||||||
:param notebook_id: ID du notebook ou placer la note (optionnel)
|
:param notebook_id: ID du notebook (optionnel)
|
||||||
:param is_todo: Creer comme tache TODO (default: False)
|
:param is_todo: Creer comme TODO (default: False)
|
||||||
:return: Confirmation avec ID"""
|
:return: Confirmation avec ID"""
|
||||||
note_data = {
|
note_data = {"title": title, "is_todo": 1 if is_todo else 0}
|
||||||
"title": title,
|
|
||||||
"is_todo": 1 if is_todo else 0,
|
|
||||||
}
|
|
||||||
if body:
|
if body:
|
||||||
note_data["body"] = body
|
note_data["body"] = body
|
||||||
if notebook_id:
|
if notebook_id:
|
||||||
@@ -240,11 +237,11 @@ class Tools:
|
|||||||
return result["error"]
|
return result["error"]
|
||||||
nid = result.get("id", "?")
|
nid = result.get("id", "?")
|
||||||
kind = "TODO" if is_todo else "note"
|
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:
|
def update_note(self, note_id: str, title: str = None, body: str = None) -> str:
|
||||||
"""Met a jour une note Joplin existante.
|
"""Met a jour une note existante.
|
||||||
:param note_id: ID de la note a modifier
|
:param note_id: ID de la note
|
||||||
:param title: Nouveau titre (optionnel)
|
:param title: Nouveau titre (optionnel)
|
||||||
:param body: Nouveau contenu Markdown (optionnel)
|
:param body: Nouveau contenu Markdown (optionnel)
|
||||||
:return: Confirmation"""
|
:return: Confirmation"""
|
||||||
@@ -261,9 +258,9 @@ class Tools:
|
|||||||
return f"[OK] Note {note_id} mise a jour."
|
return f"[OK] Note {note_id} mise a jour."
|
||||||
|
|
||||||
def add_todo(self, note_id: str, todo_text: str) -> str:
|
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 note_id: ID de la note
|
||||||
:param todo_text: Texte de la tache a ajouter
|
:param todo_text: Texte de la tache
|
||||||
:return: Confirmation"""
|
:return: Confirmation"""
|
||||||
current = self._get(f"notes/{note_id}", params={"fields": "body,title"})
|
current = self._get(f"notes/{note_id}", params={"fields": "body,title"})
|
||||||
if isinstance(current, dict) and "error" in current:
|
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}"
|
return f"[OK] TODO ajoute a '{current.get('title', '?')}' : {todo_text}"
|
||||||
|
|
||||||
def search_notes(self, query: str) -> str:
|
def search_notes(self, query: str) -> str:
|
||||||
"""Recherche des notes dans Joplin par mot-cle.
|
"""Recherche des notes par mot-cle.
|
||||||
:param query: Requete de recherche (supporte la syntaxe Joplin)
|
:param query: Requete de recherche
|
||||||
:return: Liste des notes correspondantes"""
|
:return: Liste des notes correspondantes"""
|
||||||
data = self._get("search", params={"query": query, "fields": "id,title,is_todo,todo_completed", "limit": "20"})
|
data = self._get("search", params={"query": query, "fields": "id,title,is_todo,todo_completed", "limit": "20"})
|
||||||
if isinstance(data, dict) and "error" in data:
|
if isinstance(data, dict) and "error" in data:
|
||||||
@@ -293,23 +290,20 @@ class Tools:
|
|||||||
is_todo = note.get("is_todo", 0)
|
is_todo = note.get("is_todo", 0)
|
||||||
status = ""
|
status = ""
|
||||||
if is_todo:
|
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})")
|
lines.append(f"- {title}{status} (ID: {nid})")
|
||||||
has_more = data.get("has_more", False) if isinstance(data, dict) else False
|
has_more = data.get("has_more", False) if isinstance(data, dict) else False
|
||||||
if has_more:
|
if has_more:
|
||||||
lines.append(f"... et d'autres resultats")
|
lines.append("... et d'autres resultats")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def toggle_todo(self, note_id: str, completed: bool = True) -> str:
|
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 note_id: ID du TODO
|
||||||
:param completed: True pour termine, False pour a faire
|
:param completed: True pour termine, False pour a faire
|
||||||
:return: Confirmation"""
|
:return: Confirmation"""
|
||||||
import time
|
import time
|
||||||
note_data = {
|
note_data = {"is_todo": 1, "todo_completed": int(time.time() * 1000) if completed else 0}
|
||||||
"is_todo": 1,
|
|
||||||
"todo_completed": int(time.time() * 1000) if completed else 0,
|
|
||||||
}
|
|
||||||
result = self._put(f"notes/{note_id}", note_data)
|
result = self._put(f"notes/{note_id}", note_data)
|
||||||
if isinstance(result, dict) and "error" in result:
|
if isinstance(result, dict) and "error" in result:
|
||||||
return result["error"]
|
return result["error"]
|
||||||
@@ -317,8 +311,8 @@ class Tools:
|
|||||||
return f"[OK] TODO {note_id} marque comme {status}."
|
return f"[OK] TODO {note_id} marque comme {status}."
|
||||||
|
|
||||||
def delete_note(self, note_id: str) -> str:
|
def delete_note(self, note_id: str) -> str:
|
||||||
"""Supprime une note Joplin (deplace dans la corbeille).
|
"""Supprime une note (corbeille).
|
||||||
:param note_id: ID de la note a supprimer
|
:param note_id: ID de la note
|
||||||
:return: Confirmation"""
|
:return: Confirmation"""
|
||||||
result = self._delete(f"notes/{note_id}")
|
result = self._delete(f"notes/{note_id}")
|
||||||
if isinstance(result, dict) and "error" in result:
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
|||||||
Reference in New Issue
Block a user