vikunja-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vikunja_python/__init__.py +0 -0
- vikunja_python/cli/__init__.py +0 -0
- vikunja_python/cli/main.py +264 -0
- vikunja_python/core/__init__.py +0 -0
- vikunja_python/core/client.py +106 -0
- vikunja_python/core/models/__init__.py +377 -0
- vikunja_python/core/models/api_token.py +131 -0
- vikunja_python/core/models/auth.py +34 -0
- vikunja_python/core/models/base.py +193 -0
- vikunja_python/core/models/bulk_assignees.py +98 -0
- vikunja_python/core/models/filter.py +134 -0
- vikunja_python/core/models/label.py +230 -0
- vikunja_python/core/models/link_sharing.py +138 -0
- vikunja_python/core/models/migration.py +404 -0
- vikunja_python/core/models/phase6_medium.py +74 -0
- vikunja_python/core/models/project.py +217 -0
- vikunja_python/core/models/relation.py +199 -0
- vikunja_python/core/models/task.py +261 -0
- vikunja_python/core/models/task_expansion.py +252 -0
- vikunja_python/core/models/user.py +838 -0
- vikunja_python/core/models/webhook.py +270 -0
- vikunja_python/mcp/__init__.py +0 -0
- vikunja_python/mcp/server.py +678 -0
- vikunja_python-0.1.0.dist-info/METADATA +16 -0
- vikunja_python-0.1.0.dist-info/RECORD +28 -0
- vikunja_python-0.1.0.dist-info/WHEEL +5 -0
- vikunja_python-0.1.0.dist-info/entry_points.txt +3 -0
- vikunja_python-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
import typer
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich import print as rprint
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
from vikunja_python.core.client import VikunjaClient
|
|
13
|
+
from vikunja_python.core.models.task import Task
|
|
14
|
+
from vikunja_python.core.models.project import Project
|
|
15
|
+
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Vikunja CLI - Manage your tasks from the terminal")
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
def get_client():
|
|
22
|
+
base_url = os.getenv("VIKUNJA_URL")
|
|
23
|
+
token = os.getenv("VIKUNJA_API_TOKEN")
|
|
24
|
+
if not base_url or not token:
|
|
25
|
+
rprint("[bold red]Error:[/bold red] VIKUNJA_URL and VIKUNJA_API_TOKEN must be set in .env")
|
|
26
|
+
raise typer.Exit(1)
|
|
27
|
+
return VikunjaClient(base_url, token)
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def list_tasks(
|
|
31
|
+
project_id: Optional[int] = typer.Option(None, "--project-id", "-p", help="Project ID to list tasks from"),
|
|
32
|
+
page: int = typer.Option(1, help="Page number"),
|
|
33
|
+
per_page: int = typer.Option(20, help="Items per page"),
|
|
34
|
+
filter: Optional[str] = typer.Option(None, help="Vikunja filter string"),
|
|
35
|
+
expand: Optional[List[str]] = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
help=(
|
|
38
|
+
"Fields to expand: 'subtasks', 'comments', 'reactions', 'buckets', 'comment_count', 'is_unread'. "
|
|
39
|
+
"Note: list-tasks returns descriptions and assignees by default."
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
):
|
|
43
|
+
"""List tasks with rich formatting."""
|
|
44
|
+
async def _list():
|
|
45
|
+
async with get_client() as client:
|
|
46
|
+
params = {"page": page, "per_page": per_page}
|
|
47
|
+
if filter: params["filter"] = filter
|
|
48
|
+
if expand: params["expand"] = expand
|
|
49
|
+
|
|
50
|
+
if project_id is None:
|
|
51
|
+
path = "/tasks"
|
|
52
|
+
elif project_id < 0:
|
|
53
|
+
path = "/tasks"
|
|
54
|
+
params["project_id"] = project_id
|
|
55
|
+
else:
|
|
56
|
+
path = f"/projects/{project_id}/tasks"
|
|
57
|
+
data = await client.request("GET", path, params=params)
|
|
58
|
+
|
|
59
|
+
if isinstance(data, dict) and "error" in data:
|
|
60
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
table = Table(title="Vikunja Tasks")
|
|
64
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
65
|
+
table.add_column("Status", width=6)
|
|
66
|
+
table.add_column("Title", style="magenta")
|
|
67
|
+
table.add_column("Assignees", style="green")
|
|
68
|
+
table.add_column("Due Date", style="yellow")
|
|
69
|
+
table.add_column("Labels", style="blue")
|
|
70
|
+
|
|
71
|
+
tasks = [Task(**item) for item in data]
|
|
72
|
+
for t in tasks:
|
|
73
|
+
status = "[green]DONE[/green]" if t.done else "[yellow]TODO[/yellow]"
|
|
74
|
+
due = t.due_date.strftime("%Y-%m-%d %H:%M") if t.due_date else ""
|
|
75
|
+
labels = ", ".join(l.title for l in t.labels) if t.labels else ""
|
|
76
|
+
assignees = ", ".join(u.username for u in t.assignees) if t.assignees else ""
|
|
77
|
+
|
|
78
|
+
table.add_row(str(t.id), status, t.title, assignees, due, labels)
|
|
79
|
+
if t.description:
|
|
80
|
+
# Add description preview in a dimmed style
|
|
81
|
+
desc = t.description.split('\n')[0]
|
|
82
|
+
if len(desc) > 80: desc = desc[:77] + "..."
|
|
83
|
+
table.add_row("", "", f"[dim] Desc: {desc}[/dim]", "", "", "")
|
|
84
|
+
|
|
85
|
+
console.print(table)
|
|
86
|
+
|
|
87
|
+
asyncio.run(_list())
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def list_saved_filter(
|
|
91
|
+
filter_id: int = typer.Option(..., "--filter-id", "-f", help="Saved filter ID (negative, e.g., -2 for Due in 3 Days, -3 Overdue, -4 Due Today)"),
|
|
92
|
+
page: int = typer.Option(1, help="Page number"),
|
|
93
|
+
per_page: int = typer.Option(50, help="Items per page"),
|
|
94
|
+
):
|
|
95
|
+
"""List tasks from a saved filter."""
|
|
96
|
+
async def _list():
|
|
97
|
+
async with get_client() as client:
|
|
98
|
+
params = {"page": page, "per_page": per_page, "project_id": filter_id}
|
|
99
|
+
data = await client.request("GET", "/tasks", params=params)
|
|
100
|
+
if isinstance(data, dict) and "error" in data:
|
|
101
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
table = Table(title=f"Saved Filter {filter_id}")
|
|
105
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
106
|
+
table.add_column("Status", width=6)
|
|
107
|
+
table.add_column("Title", style="magenta")
|
|
108
|
+
table.add_column("Due Date", style="yellow")
|
|
109
|
+
table.add_column("Labels", style="blue")
|
|
110
|
+
|
|
111
|
+
tasks = [Task(**item) for item in data]
|
|
112
|
+
for t in tasks:
|
|
113
|
+
status = "[green]DONE[/green]" if t.done else "[yellow]TODO[/yellow]"
|
|
114
|
+
due = t.due_date.strftime("%Y-%m-%d %H:%M") if t.due_date else ""
|
|
115
|
+
labels = ", ".join(l.title for l in t.labels) if t.labels else ""
|
|
116
|
+
table.add_row(str(t.id), status, t.title, due, labels)
|
|
117
|
+
|
|
118
|
+
console.print(table)
|
|
119
|
+
asyncio.run(_list())
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def summary(
|
|
123
|
+
top_n: int = typer.Option(5, "--top", "-t", help="Number of top tasks to show per filter"),
|
|
124
|
+
):
|
|
125
|
+
"""Quick dashboard summary — counts and top tasks for overdue, due today, due soon."""
|
|
126
|
+
async def _summary():
|
|
127
|
+
async with get_client() as client:
|
|
128
|
+
data = await client.get_dashboard_summary()
|
|
129
|
+
rprint(f"[bold white]Vikunja Task Summary: {data['total']} urgent[/bold white]")
|
|
130
|
+
for section in ["overdue", "due_today", "due_soon"]:
|
|
131
|
+
s = data["filters"].get(section, {})
|
|
132
|
+
labels = {"overdue": "🔴 Overdue", "due_today": "🟡 Due Today", "due_soon": "🟢 Due in 3 Days"}
|
|
133
|
+
rprint(f"\n[bold]{labels.get(section, section)} ({s.get('count', 0)})[/bold]")
|
|
134
|
+
for t in s.get("tasks", [])[:top_n]:
|
|
135
|
+
due = t.get("due_date", "")
|
|
136
|
+
if due and due.startswith("0001"):
|
|
137
|
+
due = ""
|
|
138
|
+
elif due and len(due) > 10:
|
|
139
|
+
due = due[:10]
|
|
140
|
+
due_str = f" ({due})" if due else ""
|
|
141
|
+
rprint(f" #{t['id']} {t['title']}{due_str}")
|
|
142
|
+
if not s.get("tasks"):
|
|
143
|
+
rprint(" [dim]None 🎉[/dim]")
|
|
144
|
+
asyncio.run(_summary())
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def get_project(project_id: int):
|
|
148
|
+
"""Get project details and views."""
|
|
149
|
+
async def _get():
|
|
150
|
+
async with get_client() as client:
|
|
151
|
+
data = await client.request("GET", f"/projects/{project_id}")
|
|
152
|
+
if isinstance(data, dict) and "error" in data:
|
|
153
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
p = Project(**data)
|
|
157
|
+
rprint(Panel(f"[bold cyan]Project:[/bold cyan] {p.title} (ID: {p.id})\n[dim]{p.description or 'No description'}[/dim]"))
|
|
158
|
+
|
|
159
|
+
if p.views:
|
|
160
|
+
table = Table(title="Project Views")
|
|
161
|
+
table.add_column("ID", style="cyan")
|
|
162
|
+
table.add_column("Title", style="magenta")
|
|
163
|
+
table.add_column("Kind", style="yellow")
|
|
164
|
+
for v in p.views:
|
|
165
|
+
table.add_row(str(v.get('id')), v.get('title'), v.get('view_kind'))
|
|
166
|
+
console.print(table)
|
|
167
|
+
asyncio.run(_get())
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def list_view_tasks(project_id: int, view_id: int):
|
|
171
|
+
"""List all tasks in a specific project view with full descriptions."""
|
|
172
|
+
async def _list():
|
|
173
|
+
async with get_client() as client:
|
|
174
|
+
data = await client.request("GET", f"/projects/{project_id}/views/{view_id}/tasks")
|
|
175
|
+
if isinstance(data, dict) and "error" in data:
|
|
176
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Flatten buckets if needed
|
|
180
|
+
all_tasks = []
|
|
181
|
+
if isinstance(data, list) and len(data) > 0:
|
|
182
|
+
if "tasks" in data[0]:
|
|
183
|
+
for bucket in data:
|
|
184
|
+
for t_item in bucket.get("tasks", []):
|
|
185
|
+
all_tasks.append(Task(**t_item))
|
|
186
|
+
else:
|
|
187
|
+
all_tasks = [Task(**item) for item in data]
|
|
188
|
+
|
|
189
|
+
for t in all_tasks:
|
|
190
|
+
status = "[green]DONE[/green]" if t.done else "[yellow]TODO[/yellow]"
|
|
191
|
+
rprint(f"[bold cyan]ID: {t.id}[/bold cyan] {status} [bold magenta]{t.title}[/bold magenta]")
|
|
192
|
+
if t.assignees:
|
|
193
|
+
rprint(f"[dim] Assignees: {', '.join(u.username for u in t.assignees)}[/dim]")
|
|
194
|
+
if t.description:
|
|
195
|
+
rprint(Panel(t.description, subtitle="Description", border_style="dim"))
|
|
196
|
+
rprint("-" * 20)
|
|
197
|
+
asyncio.run(_list())
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def get_task(task_id: int):
|
|
201
|
+
"""Get full details for a single task."""
|
|
202
|
+
async def _get():
|
|
203
|
+
async with get_client() as client:
|
|
204
|
+
data = await client.request("GET", f"/tasks/{task_id}")
|
|
205
|
+
if isinstance(data, dict) and "error" in data:
|
|
206
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
t = Task(**data)
|
|
210
|
+
status = "[bold green]DONE[/bold green]" if t.done else "[bold yellow]TODO[/bold yellow]"
|
|
211
|
+
|
|
212
|
+
rprint(Panel(
|
|
213
|
+
f"[bold cyan]ID:[/bold cyan] {t.id} {status}\n"
|
|
214
|
+
f"[bold cyan]Title:[/bold cyan] {t.title}\n"
|
|
215
|
+
f"[bold cyan]Project:[/bold cyan] {t.project_id}\n"
|
|
216
|
+
f"[bold cyan]Due:[/bold cyan] {t.due_date or 'None'}\n"
|
|
217
|
+
f"[bold cyan]Priority:[/bold cyan] {t.priority}\n"
|
|
218
|
+
f"[bold cyan]Labels:[/bold cyan] {', '.join(l.title for l in t.labels) if t.labels else 'None'}\n"
|
|
219
|
+
f"[bold cyan]Assignees:[/bold cyan] {', '.join(u.username for u in t.assignees) if t.assignees else 'None'}\n\n"
|
|
220
|
+
f"[bold white]Description:[/bold white]\n{t.description or 'No description'}",
|
|
221
|
+
title=f"Task {t.id}",
|
|
222
|
+
border_style="blue"
|
|
223
|
+
))
|
|
224
|
+
asyncio.run(_get())
|
|
225
|
+
|
|
226
|
+
@app.command()
|
|
227
|
+
def create_task(
|
|
228
|
+
title: str,
|
|
229
|
+
project_id: int,
|
|
230
|
+
description: Optional[str] = typer.Option(None, help="Task description"),
|
|
231
|
+
due_date: Optional[str] = typer.Option(None, help="Due date (natural language ok)")
|
|
232
|
+
):
|
|
233
|
+
"""Create a new task."""
|
|
234
|
+
async def _create():
|
|
235
|
+
async with get_client() as client:
|
|
236
|
+
payload = {"title": title}
|
|
237
|
+
if description: payload["description"] = description
|
|
238
|
+
if due_date: payload["due_date"] = due_date
|
|
239
|
+
|
|
240
|
+
data = await client.request("PUT", f"/projects/{project_id}/tasks", json=payload)
|
|
241
|
+
if isinstance(data, dict) and "error" in data:
|
|
242
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
243
|
+
else:
|
|
244
|
+
rprint(f"[bold green]Task created![/bold green] ID: {data['id']}")
|
|
245
|
+
|
|
246
|
+
asyncio.run(_create())
|
|
247
|
+
|
|
248
|
+
@app.command()
|
|
249
|
+
def create_label(title: str, hex_color: Optional[str] = typer.Option(None, help="Label color")):
|
|
250
|
+
"""Create a new label."""
|
|
251
|
+
async def _create():
|
|
252
|
+
async with get_client() as client:
|
|
253
|
+
payload = {"title": title}
|
|
254
|
+
if hex_color: payload["hex_color"] = hex_color
|
|
255
|
+
data = await client.request("PUT", "/labels", json=payload)
|
|
256
|
+
if isinstance(data, dict) and "error" in data:
|
|
257
|
+
rprint(f"[bold red]Error:[/bold red] {data['error']}")
|
|
258
|
+
else:
|
|
259
|
+
rprint(f"[bold green]Label created![/bold green] ID: {data['id']} - {data['title']}")
|
|
260
|
+
|
|
261
|
+
asyncio.run(_create())
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
# Set up logging for CLI/MCP
|
|
9
|
+
def setup_logging(is_mcp: bool = False):
|
|
10
|
+
# Support VIKUNJA_DEBUG=true or 1 to enable DEBUG logs
|
|
11
|
+
debug_val = os.getenv("VIKUNJA_DEBUG", "").lower()
|
|
12
|
+
level = logging.DEBUG if debug_val in ("1", "true", "yes", "on") else logging.INFO
|
|
13
|
+
|
|
14
|
+
if is_mcp:
|
|
15
|
+
# MCP must log to stderr to avoid corrupting JSON-RPC on stdout
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=level,
|
|
18
|
+
format="%(levelname)s: %(message)s",
|
|
19
|
+
stream=sys.stderr,
|
|
20
|
+
force=True # Ensure we override any default handlers from libraries
|
|
21
|
+
)
|
|
22
|
+
else:
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=level,
|
|
25
|
+
format="%(levelname)s: %(message)s",
|
|
26
|
+
force=True
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class VikunjaClient:
|
|
30
|
+
"""
|
|
31
|
+
Core HTTP client for Vikunja API.
|
|
32
|
+
Handles both JWT and API Key authentication.
|
|
33
|
+
"""
|
|
34
|
+
def __init__(self, base_url: str, token: str, is_api_key: bool = True):
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self.token = token
|
|
37
|
+
self.is_api_key = is_api_key
|
|
38
|
+
|
|
39
|
+
headers = {
|
|
40
|
+
"Authorization": f"Bearer {token}",
|
|
41
|
+
"Content-Type": "application/json"
|
|
42
|
+
}
|
|
43
|
+
# Respect SSL_CERT_FILE explicitly — more reliable than relying on
|
|
44
|
+
# certifi's env-var propagation which can be lost in subprocess spawns.
|
|
45
|
+
verify = os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE") or True
|
|
46
|
+
self.client = httpx.AsyncClient(
|
|
47
|
+
base_url=self.base_url,
|
|
48
|
+
headers=headers,
|
|
49
|
+
timeout=30.0,
|
|
50
|
+
verify=verify,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def __aenter__(self):
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
57
|
+
await self.client.aclose()
|
|
58
|
+
|
|
59
|
+
async def request(self, method: str, path: str, **kwargs):
|
|
60
|
+
"""Wrapper for httpx requests with error handling."""
|
|
61
|
+
try:
|
|
62
|
+
resp = await self.client.request(method, path, **kwargs)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
if resp.status_code == 204:
|
|
65
|
+
return None
|
|
66
|
+
return resp.json()
|
|
67
|
+
except httpx.HTTPStatusError as e:
|
|
68
|
+
# Return structured error for LLM/CLI to digest
|
|
69
|
+
error_data = {"error": str(e), "status_code": e.response.status_code}
|
|
70
|
+
try:
|
|
71
|
+
error_data["details"] = e.response.json()
|
|
72
|
+
except:
|
|
73
|
+
error_data["details"] = e.response.text
|
|
74
|
+
return error_data
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return {"error": str(e), "status_code": 500}
|
|
77
|
+
|
|
78
|
+
async def get_dashboard_summary(self) -> dict:
|
|
79
|
+
"""Quick polling summary for ePaper dashboard. Queries saved filters in parallel."""
|
|
80
|
+
filters = {
|
|
81
|
+
"overdue": -3,
|
|
82
|
+
"due_today": -4,
|
|
83
|
+
"due_soon": -2,
|
|
84
|
+
}
|
|
85
|
+
import asyncio
|
|
86
|
+
async def fetch(label: str, filter_id: int) -> dict:
|
|
87
|
+
data = await self.request("GET", "/tasks", params={
|
|
88
|
+
"page": 1, "per_page": 10, "project_id": filter_id
|
|
89
|
+
})
|
|
90
|
+
if isinstance(data, dict) and "error" in data:
|
|
91
|
+
return {"label": label, "count": 0, "tasks": [], "error": data["error"]}
|
|
92
|
+
tasks = data if isinstance(data, list) else []
|
|
93
|
+
return {
|
|
94
|
+
"label": label,
|
|
95
|
+
"count": len(tasks),
|
|
96
|
+
"tasks": [
|
|
97
|
+
{"id": t.get("id"), "title": t.get("title"),
|
|
98
|
+
"due_date": t.get("due_date"), "priority": t.get("priority", 0)}
|
|
99
|
+
for t in tasks[:10]
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
results = await asyncio.gather(*[
|
|
103
|
+
fetch(label, fid) for label, fid in filters.items()
|
|
104
|
+
])
|
|
105
|
+
total = sum(r["count"] for r in results)
|
|
106
|
+
return {"total": total, "filters": {r["label"]: r for r in results}}
|