things-api 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.
- things_api/__init__.py +1 -0
- things_api/app.py +98 -0
- things_api/auth.py +26 -0
- things_api/config.py +29 -0
- things_api/models.py +117 -0
- things_api/ratelimit.py +55 -0
- things_api/routers/__init__.py +0 -0
- things_api/routers/areas.py +16 -0
- things_api/routers/lists.py +51 -0
- things_api/routers/projects.py +111 -0
- things_api/routers/search.py +61 -0
- things_api/routers/tags.py +22 -0
- things_api/routers/todos.py +133 -0
- things_api/services/__init__.py +0 -0
- things_api/services/reader.py +73 -0
- things_api/services/writer.py +86 -0
- things_api-0.1.0.dist-info/METADATA +134 -0
- things_api-0.1.0.dist-info/RECORD +21 -0
- things_api-0.1.0.dist-info/WHEEL +4 -0
- things_api-0.1.0.dist-info/entry_points.txt +2 -0
- things_api-0.1.0.dist-info/licenses/LICENSE +21 -0
things_api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""REST API for Things 3."""
|
things_api/app.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""FastAPI application factory and entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from things_api.auth import require_token
|
|
12
|
+
from things_api.config import Settings
|
|
13
|
+
from things_api.models import HealthResponse
|
|
14
|
+
from things_api.ratelimit import AuthRateLimiter
|
|
15
|
+
from things_api.services.reader import ThingsReader
|
|
16
|
+
from things_api.services.writer import ThingsWriter
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
22
|
+
"""Create and configure the FastAPI application."""
|
|
23
|
+
if settings is None:
|
|
24
|
+
settings = Settings()
|
|
25
|
+
|
|
26
|
+
app = FastAPI(
|
|
27
|
+
title="Things API",
|
|
28
|
+
description="REST API for Things 3 — expose your tasks over HTTP",
|
|
29
|
+
version="0.1.0",
|
|
30
|
+
docs_url=None,
|
|
31
|
+
redoc_url=None,
|
|
32
|
+
dependencies=[require_token(settings.things_api_token.get_secret_value())],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
app.add_middleware(AuthRateLimiter, max_failures=10, window=60)
|
|
36
|
+
|
|
37
|
+
reader = ThingsReader(
|
|
38
|
+
db_path=str(settings.things_db_path) if settings.things_db_path else None,
|
|
39
|
+
)
|
|
40
|
+
writer = (
|
|
41
|
+
ThingsWriter(
|
|
42
|
+
auth_token=settings.things_auth_token.get_secret_value(),
|
|
43
|
+
verify_timeout=settings.things_verify_timeout,
|
|
44
|
+
)
|
|
45
|
+
if settings.write_enabled
|
|
46
|
+
else None
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
app.state.settings = settings
|
|
50
|
+
app.state.reader = reader
|
|
51
|
+
app.state.writer = writer
|
|
52
|
+
|
|
53
|
+
@app.exception_handler(RuntimeError)
|
|
54
|
+
async def runtime_error_handler(request: Request, exc: RuntimeError):
|
|
55
|
+
logger.exception("Unhandled runtime error")
|
|
56
|
+
return JSONResponse(
|
|
57
|
+
status_code=500,
|
|
58
|
+
content={"detail": "Internal server error"},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@app.get("/health", response_model=HealthResponse)
|
|
62
|
+
async def health():
|
|
63
|
+
try:
|
|
64
|
+
reader.inbox()
|
|
65
|
+
db_ok = True
|
|
66
|
+
except Exception:
|
|
67
|
+
db_ok = False
|
|
68
|
+
|
|
69
|
+
return HealthResponse(
|
|
70
|
+
status="healthy" if db_ok else "degraded",
|
|
71
|
+
read=db_ok,
|
|
72
|
+
write=settings.write_enabled,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from things_api.routers import todos, projects, lists, tags, areas, search
|
|
76
|
+
app.include_router(todos.router)
|
|
77
|
+
app.include_router(projects.router)
|
|
78
|
+
app.include_router(lists.router)
|
|
79
|
+
app.include_router(tags.router)
|
|
80
|
+
app.include_router(areas.router)
|
|
81
|
+
app.include_router(search.router)
|
|
82
|
+
|
|
83
|
+
return app
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
"""CLI entrypoint."""
|
|
88
|
+
logging.basicConfig(
|
|
89
|
+
level=logging.INFO,
|
|
90
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
91
|
+
)
|
|
92
|
+
settings = Settings()
|
|
93
|
+
app = create_app(settings)
|
|
94
|
+
uvicorn.run(
|
|
95
|
+
app,
|
|
96
|
+
host=settings.things_api_host,
|
|
97
|
+
port=settings.things_api_port,
|
|
98
|
+
)
|
things_api/auth.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Bearer token authentication."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, status
|
|
6
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
7
|
+
|
|
8
|
+
_scheme = HTTPBearer(auto_error=False)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def require_token(expected_token: str):
|
|
12
|
+
"""Return a FastAPI dependency that validates a Bearer token."""
|
|
13
|
+
|
|
14
|
+
async def _verify(
|
|
15
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(_scheme),
|
|
16
|
+
) -> None:
|
|
17
|
+
if credentials is None or not hmac.compare_digest(
|
|
18
|
+
credentials.credentials, expected_token
|
|
19
|
+
):
|
|
20
|
+
raise HTTPException(
|
|
21
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
22
|
+
detail="Invalid or missing API token",
|
|
23
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return Depends(_verify)
|
things_api/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Application settings loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
"""Things API configuration.
|
|
11
|
+
|
|
12
|
+
All values can be set via environment variables or a .env file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
things_api_host: str = "0.0.0.0"
|
|
16
|
+
things_api_port: int = 5225
|
|
17
|
+
things_api_token: SecretStr
|
|
18
|
+
things_auth_token: SecretStr | None = None
|
|
19
|
+
things_db_path: Path | None = None
|
|
20
|
+
things_verify_timeout: float = 0.5
|
|
21
|
+
|
|
22
|
+
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def write_enabled(self) -> bool:
|
|
26
|
+
"""Whether write operations are available."""
|
|
27
|
+
return self.things_auth_token is not None and bool(
|
|
28
|
+
self.things_auth_token.get_secret_value()
|
|
29
|
+
)
|
things_api/models.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Pydantic request/response models for the Things API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# --- Requests ---
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CreateTodoRequest(BaseModel):
|
|
14
|
+
"""Create a new todo."""
|
|
15
|
+
|
|
16
|
+
title: str
|
|
17
|
+
notes: str | None = None
|
|
18
|
+
when: str | None = None
|
|
19
|
+
deadline: str | None = None
|
|
20
|
+
tags: list[str] | None = None
|
|
21
|
+
checklist_items: list[str] | None = None
|
|
22
|
+
list_id: str | None = None
|
|
23
|
+
list_title: str | None = None
|
|
24
|
+
heading: str | None = None
|
|
25
|
+
heading_id: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UpdateTodoRequest(BaseModel):
|
|
29
|
+
"""Update an existing todo. All fields optional."""
|
|
30
|
+
|
|
31
|
+
title: str | None = None
|
|
32
|
+
notes: str | None = None
|
|
33
|
+
prepend_notes: str | None = None
|
|
34
|
+
append_notes: str | None = None
|
|
35
|
+
when: str | None = None
|
|
36
|
+
deadline: str | None = None
|
|
37
|
+
tags: list[str] | None = None
|
|
38
|
+
add_tags: list[str] | None = None
|
|
39
|
+
checklist_items: list[str] | None = None
|
|
40
|
+
prepend_checklist_items: list[str] | None = None
|
|
41
|
+
append_checklist_items: list[str] | None = None
|
|
42
|
+
list_id: str | None = None
|
|
43
|
+
list_title: str | None = None
|
|
44
|
+
heading: str | None = None
|
|
45
|
+
heading_id: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CreateProjectRequest(BaseModel):
|
|
49
|
+
"""Create a new project."""
|
|
50
|
+
|
|
51
|
+
title: str
|
|
52
|
+
notes: str | None = None
|
|
53
|
+
when: str | None = None
|
|
54
|
+
deadline: str | None = None
|
|
55
|
+
tags: list[str] | None = None
|
|
56
|
+
area_id: str | None = None
|
|
57
|
+
area_title: str | None = None
|
|
58
|
+
todos: list[str] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UpdateProjectRequest(BaseModel):
|
|
62
|
+
"""Update an existing project. All fields optional."""
|
|
63
|
+
|
|
64
|
+
title: str | None = None
|
|
65
|
+
notes: str | None = None
|
|
66
|
+
prepend_notes: str | None = None
|
|
67
|
+
append_notes: str | None = None
|
|
68
|
+
when: str | None = None
|
|
69
|
+
deadline: str | None = None
|
|
70
|
+
tags: list[str] | None = None
|
|
71
|
+
add_tags: list[str] | None = None
|
|
72
|
+
area_id: str | None = None
|
|
73
|
+
area_title: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DeleteAction(str, Enum):
|
|
77
|
+
"""Action when deleting (Things has no true deletion)."""
|
|
78
|
+
|
|
79
|
+
COMPLETE = "complete"
|
|
80
|
+
CANCEL = "cancel"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DeleteRequest(BaseModel):
|
|
84
|
+
"""Body for DELETE endpoints."""
|
|
85
|
+
|
|
86
|
+
action: DeleteAction = DeleteAction.COMPLETE
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Responses ---
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TodoResponse(BaseModel):
|
|
93
|
+
"""A Things todo item."""
|
|
94
|
+
|
|
95
|
+
model_config = {"extra": "allow"}
|
|
96
|
+
|
|
97
|
+
uuid: str
|
|
98
|
+
title: str
|
|
99
|
+
status: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProjectResponse(BaseModel):
|
|
103
|
+
"""A Things project."""
|
|
104
|
+
|
|
105
|
+
model_config = {"extra": "allow"}
|
|
106
|
+
|
|
107
|
+
uuid: str
|
|
108
|
+
title: str
|
|
109
|
+
status: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class HealthResponse(BaseModel):
|
|
113
|
+
"""Health check response."""
|
|
114
|
+
|
|
115
|
+
status: str
|
|
116
|
+
read: bool
|
|
117
|
+
write: bool
|
things_api/ratelimit.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""In-memory rate limiter for failed authentication attempts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from fastapi import Request, Response
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthRateLimiter(BaseHTTPMiddleware):
|
|
13
|
+
"""Tracks 401 responses per client IP and blocks after too many failures.
|
|
14
|
+
|
|
15
|
+
Uses a sliding window: only failures within the last `window` seconds count.
|
|
16
|
+
Once `max_failures` is reached, subsequent requests from that IP receive
|
|
17
|
+
429 Too Many Requests until the window expires.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, app, max_failures: int = 10, window: int = 60) -> None:
|
|
21
|
+
super().__init__(app)
|
|
22
|
+
self._max_failures = max_failures
|
|
23
|
+
self._window = window
|
|
24
|
+
self._failures: dict[str, list[float]] = defaultdict(list)
|
|
25
|
+
|
|
26
|
+
def _client_ip(self, request: Request) -> str:
|
|
27
|
+
return request.client.host if request.client else "unknown"
|
|
28
|
+
|
|
29
|
+
def _prune(self, ip: str) -> None:
|
|
30
|
+
"""Remove entries older than the window."""
|
|
31
|
+
cutoff = time.monotonic() - self._window
|
|
32
|
+
self._failures[ip] = [t for t in self._failures[ip] if t > cutoff]
|
|
33
|
+
if not self._failures[ip]:
|
|
34
|
+
del self._failures[ip]
|
|
35
|
+
|
|
36
|
+
async def dispatch(
|
|
37
|
+
self, request: Request, call_next: RequestResponseEndpoint
|
|
38
|
+
) -> Response:
|
|
39
|
+
ip = self._client_ip(request)
|
|
40
|
+
self._prune(ip)
|
|
41
|
+
|
|
42
|
+
if len(self._failures.get(ip, [])) >= self._max_failures:
|
|
43
|
+
return Response(
|
|
44
|
+
content='{"detail":"Too many failed attempts. Try again later."}',
|
|
45
|
+
status_code=429,
|
|
46
|
+
media_type="application/json",
|
|
47
|
+
headers={"Retry-After": str(self._window)},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
response = await call_next(request)
|
|
51
|
+
|
|
52
|
+
if response.status_code == 401:
|
|
53
|
+
self._failures[ip].append(time.monotonic())
|
|
54
|
+
|
|
55
|
+
return response
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Area endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
router = APIRouter(prefix="/areas", tags=["areas"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("")
|
|
14
|
+
async def list_areas(request: Request, include_items: bool = False):
|
|
15
|
+
"""List all areas."""
|
|
16
|
+
return request.app.state.reader.areas(include_items=include_items)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Smart list endpoints — inbox, today, upcoming, anytime, someday, logbook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
router = APIRouter(tags=["lists"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/inbox")
|
|
14
|
+
async def inbox(request: Request):
|
|
15
|
+
"""Get inbox todos."""
|
|
16
|
+
return request.app.state.reader.inbox()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/today")
|
|
20
|
+
async def today(request: Request):
|
|
21
|
+
"""Get today's todos."""
|
|
22
|
+
return request.app.state.reader.today()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/upcoming")
|
|
26
|
+
async def upcoming(request: Request):
|
|
27
|
+
"""Get upcoming todos."""
|
|
28
|
+
return request.app.state.reader.upcoming()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/anytime")
|
|
32
|
+
async def anytime(request: Request):
|
|
33
|
+
"""Get anytime todos."""
|
|
34
|
+
return request.app.state.reader.anytime()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/someday")
|
|
38
|
+
async def someday(request: Request):
|
|
39
|
+
"""Get someday todos."""
|
|
40
|
+
return request.app.state.reader.someday()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/logbook")
|
|
44
|
+
async def logbook(request: Request, period: str | None = None, limit: int | None = None):
|
|
45
|
+
"""Get completed todos from the logbook."""
|
|
46
|
+
filters = {}
|
|
47
|
+
if period:
|
|
48
|
+
filters["last"] = period
|
|
49
|
+
if limit:
|
|
50
|
+
filters["limit"] = limit
|
|
51
|
+
return request.app.state.reader.logbook(**filters)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Project CRUD endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from things_api.models import (
|
|
12
|
+
CreateProjectRequest,
|
|
13
|
+
DeleteAction,
|
|
14
|
+
DeleteRequest,
|
|
15
|
+
UpdateProjectRequest,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
router = APIRouter(prefix="/projects", tags=["projects"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("")
|
|
23
|
+
async def list_projects(request: Request):
|
|
24
|
+
"""List all projects."""
|
|
25
|
+
return request.app.state.reader.projects()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/{project_id}")
|
|
29
|
+
async def get_project(request: Request, project_id: str):
|
|
30
|
+
"""Get a project with its todos."""
|
|
31
|
+
result = request.app.state.reader.get(project_id)
|
|
32
|
+
if result is None:
|
|
33
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("")
|
|
38
|
+
async def create_project(request: Request, body: CreateProjectRequest):
|
|
39
|
+
"""Create a new project."""
|
|
40
|
+
writer = request.app.state.writer
|
|
41
|
+
if writer is None:
|
|
42
|
+
raise HTTPException(
|
|
43
|
+
status_code=503, detail="Write operations not configured"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
params = body.model_dump(exclude_none=True)
|
|
47
|
+
if "todos" in params:
|
|
48
|
+
params["todos"] = "\n".join(params["todos"])
|
|
49
|
+
|
|
50
|
+
await writer.create_project(**params)
|
|
51
|
+
|
|
52
|
+
return JSONResponse(
|
|
53
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
54
|
+
content={"status": "accepted", "title": body.title},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.put("/{project_id}")
|
|
59
|
+
async def update_project(
|
|
60
|
+
request: Request, project_id: str, body: UpdateProjectRequest
|
|
61
|
+
):
|
|
62
|
+
"""Update an existing project."""
|
|
63
|
+
writer = request.app.state.writer
|
|
64
|
+
if writer is None:
|
|
65
|
+
raise HTTPException(
|
|
66
|
+
status_code=503, detail="Write operations not configured"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
existing = request.app.state.reader.get(project_id)
|
|
70
|
+
if existing is None:
|
|
71
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
72
|
+
|
|
73
|
+
params = body.model_dump(exclude_none=True)
|
|
74
|
+
await writer.update_project(uuid=project_id, **params)
|
|
75
|
+
|
|
76
|
+
await asyncio.sleep(request.app.state.settings.things_verify_timeout)
|
|
77
|
+
updated = request.app.state.reader.get(project_id)
|
|
78
|
+
if updated:
|
|
79
|
+
return updated
|
|
80
|
+
return JSONResponse(
|
|
81
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
82
|
+
content={"status": "accepted", "uuid": project_id},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.delete("/{project_id}")
|
|
87
|
+
async def delete_project(
|
|
88
|
+
request: Request, project_id: str, body: DeleteRequest | None = None
|
|
89
|
+
):
|
|
90
|
+
"""Complete or cancel a project (irreversible).
|
|
91
|
+
|
|
92
|
+
Things 3 does not support true deletion. This endpoint marks the project as
|
|
93
|
+
completed (default) or cancelled. Pass {"action": "cancel"} to cancel instead.
|
|
94
|
+
"""
|
|
95
|
+
writer = request.app.state.writer
|
|
96
|
+
if writer is None:
|
|
97
|
+
raise HTTPException(
|
|
98
|
+
status_code=503, detail="Write operations not configured"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
existing = request.app.state.reader.get(project_id)
|
|
102
|
+
if existing is None:
|
|
103
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
104
|
+
|
|
105
|
+
action = body.action if body else DeleteAction.COMPLETE
|
|
106
|
+
if action == DeleteAction.CANCEL:
|
|
107
|
+
await writer.cancel_project(uuid=project_id)
|
|
108
|
+
else:
|
|
109
|
+
await writer.complete_project(uuid=project_id)
|
|
110
|
+
|
|
111
|
+
return {"status": action.value, "uuid": project_id}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Search endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
router = APIRouter(prefix="/search", tags=["search"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SearchStatus(str, Enum):
|
|
15
|
+
INCOMPLETE = "incomplete"
|
|
16
|
+
COMPLETED = "completed"
|
|
17
|
+
CANCELED = "canceled"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SearchType(str, Enum):
|
|
21
|
+
TO_DO = "to-do"
|
|
22
|
+
PROJECT = "project"
|
|
23
|
+
HEADING = "heading"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("")
|
|
27
|
+
async def search(request: Request, q: str):
|
|
28
|
+
"""Full-text search across todos."""
|
|
29
|
+
if not q.strip():
|
|
30
|
+
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
|
|
31
|
+
return request.app.state.reader.search(q)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/advanced")
|
|
35
|
+
async def advanced_search(
|
|
36
|
+
request: Request,
|
|
37
|
+
status: SearchStatus | None = None,
|
|
38
|
+
tag: str | None = None,
|
|
39
|
+
area: str | None = None,
|
|
40
|
+
type: SearchType | None = None,
|
|
41
|
+
start_date: str = Query(None, pattern=r"^\d{4}-\d{2}-\d{2}$"),
|
|
42
|
+
deadline: str = Query(None, pattern=r"^\d{4}-\d{2}-\d{2}$"),
|
|
43
|
+
last: str = Query(None, pattern=r"^\d+[dwmy]$"),
|
|
44
|
+
):
|
|
45
|
+
"""Filtered search with multiple criteria."""
|
|
46
|
+
filters = {}
|
|
47
|
+
if status:
|
|
48
|
+
filters["status"] = status.value
|
|
49
|
+
if tag:
|
|
50
|
+
filters["tag"] = tag
|
|
51
|
+
if area:
|
|
52
|
+
filters["area"] = area
|
|
53
|
+
if type:
|
|
54
|
+
filters["type"] = type.value
|
|
55
|
+
if start_date:
|
|
56
|
+
filters["start_date"] = start_date
|
|
57
|
+
if deadline:
|
|
58
|
+
filters["deadline"] = deadline
|
|
59
|
+
if last:
|
|
60
|
+
filters["last"] = last
|
|
61
|
+
return request.app.state.reader.todos(**filters)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tag endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
router = APIRouter(prefix="/tags", tags=["tags"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("")
|
|
14
|
+
async def list_tags(request: Request, include_items: bool = False):
|
|
15
|
+
"""List all tags."""
|
|
16
|
+
return request.app.state.reader.tags(include_items=include_items)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/{tag}/items")
|
|
20
|
+
async def tag_items(request: Request, tag: str):
|
|
21
|
+
"""Get items with a specific tag."""
|
|
22
|
+
return request.app.state.reader.tags_for_item(tag_title=tag)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Todo CRUD endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from things_api.models import (
|
|
12
|
+
CreateTodoRequest,
|
|
13
|
+
DeleteAction,
|
|
14
|
+
DeleteRequest,
|
|
15
|
+
UpdateTodoRequest,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
router = APIRouter(prefix="/todos", tags=["todos"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("")
|
|
23
|
+
async def list_todos(
|
|
24
|
+
request: Request,
|
|
25
|
+
project_id: str | None = None,
|
|
26
|
+
area_id: str | None = None,
|
|
27
|
+
tag: str | None = None,
|
|
28
|
+
include_checklist: bool = False,
|
|
29
|
+
):
|
|
30
|
+
"""List all incomplete todos, with optional filters."""
|
|
31
|
+
reader = request.app.state.reader
|
|
32
|
+
filters = {}
|
|
33
|
+
if project_id:
|
|
34
|
+
filters["project"] = project_id
|
|
35
|
+
if area_id:
|
|
36
|
+
filters["area"] = area_id
|
|
37
|
+
if tag:
|
|
38
|
+
filters["tag"] = tag
|
|
39
|
+
if include_checklist:
|
|
40
|
+
filters["include_items"] = True
|
|
41
|
+
return reader.todos(**filters)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/{todo_id}")
|
|
45
|
+
async def get_todo(request: Request, todo_id: str):
|
|
46
|
+
"""Get a specific todo by ID."""
|
|
47
|
+
result = request.app.state.reader.get(todo_id)
|
|
48
|
+
if result is None:
|
|
49
|
+
raise HTTPException(status_code=404, detail="Todo not found")
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post("")
|
|
54
|
+
async def create_todo(request: Request, body: CreateTodoRequest):
|
|
55
|
+
"""Create a new todo."""
|
|
56
|
+
writer = request.app.state.writer
|
|
57
|
+
if writer is None:
|
|
58
|
+
raise HTTPException(
|
|
59
|
+
status_code=503, detail="Write operations not configured"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
params = body.model_dump(exclude_none=True)
|
|
63
|
+
if "checklist_items" in params:
|
|
64
|
+
params["checklist_items"] = "\n".join(params["checklist_items"])
|
|
65
|
+
|
|
66
|
+
await writer.create_todo(**params)
|
|
67
|
+
|
|
68
|
+
return JSONResponse(
|
|
69
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
70
|
+
content={"status": "accepted", "title": body.title},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.put("/{todo_id}")
|
|
75
|
+
async def update_todo(request: Request, todo_id: str, body: UpdateTodoRequest):
|
|
76
|
+
"""Update an existing todo."""
|
|
77
|
+
writer = request.app.state.writer
|
|
78
|
+
if writer is None:
|
|
79
|
+
raise HTTPException(
|
|
80
|
+
status_code=503, detail="Write operations not configured"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
existing = request.app.state.reader.get(todo_id)
|
|
84
|
+
if existing is None:
|
|
85
|
+
raise HTTPException(status_code=404, detail="Todo not found")
|
|
86
|
+
|
|
87
|
+
params = body.model_dump(exclude_none=True)
|
|
88
|
+
for list_field in (
|
|
89
|
+
"checklist_items",
|
|
90
|
+
"prepend_checklist_items",
|
|
91
|
+
"append_checklist_items",
|
|
92
|
+
):
|
|
93
|
+
if list_field in params:
|
|
94
|
+
params[list_field] = "\n".join(params[list_field])
|
|
95
|
+
|
|
96
|
+
await writer.update_todo(uuid=todo_id, **params)
|
|
97
|
+
|
|
98
|
+
await asyncio.sleep(request.app.state.settings.things_verify_timeout)
|
|
99
|
+
updated = request.app.state.reader.get(todo_id)
|
|
100
|
+
if updated:
|
|
101
|
+
return updated
|
|
102
|
+
return JSONResponse(
|
|
103
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
104
|
+
content={"status": "accepted", "uuid": todo_id},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.delete("/{todo_id}")
|
|
109
|
+
async def delete_todo(
|
|
110
|
+
request: Request, todo_id: str, body: DeleteRequest | None = None
|
|
111
|
+
):
|
|
112
|
+
"""Complete or cancel a todo (irreversible).
|
|
113
|
+
|
|
114
|
+
Things 3 does not support true deletion. This endpoint marks the item as
|
|
115
|
+
completed (default) or cancelled. Pass {"action": "cancel"} to cancel instead.
|
|
116
|
+
"""
|
|
117
|
+
writer = request.app.state.writer
|
|
118
|
+
if writer is None:
|
|
119
|
+
raise HTTPException(
|
|
120
|
+
status_code=503, detail="Write operations not configured"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
existing = request.app.state.reader.get(todo_id)
|
|
124
|
+
if existing is None:
|
|
125
|
+
raise HTTPException(status_code=404, detail="Todo not found")
|
|
126
|
+
|
|
127
|
+
action = body.action if body else DeleteAction.COMPLETE
|
|
128
|
+
if action == DeleteAction.CANCEL:
|
|
129
|
+
await writer.cancel_todo(uuid=todo_id)
|
|
130
|
+
else:
|
|
131
|
+
await writer.complete_todo(uuid=todo_id)
|
|
132
|
+
|
|
133
|
+
return {"status": action.value, "uuid": todo_id}
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Read-only access to Things 3 data via things.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import things
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ThingsReader:
|
|
9
|
+
"""Wraps things.py, injecting db_path when configured."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, db_path: str | None = None) -> None:
|
|
12
|
+
self._db_path = db_path
|
|
13
|
+
|
|
14
|
+
def _kwargs(self, **extra) -> dict:
|
|
15
|
+
"""Build kwargs, adding filepath if configured."""
|
|
16
|
+
kw = {k: v for k, v in extra.items() if v is not None}
|
|
17
|
+
if self._db_path:
|
|
18
|
+
kw["filepath"] = self._db_path
|
|
19
|
+
return kw
|
|
20
|
+
|
|
21
|
+
def todos(self, **filters) -> list[dict]:
|
|
22
|
+
return things.todos(**self._kwargs(**filters))
|
|
23
|
+
|
|
24
|
+
def projects(self, **filters) -> list[dict]:
|
|
25
|
+
return things.projects(**self._kwargs(**filters))
|
|
26
|
+
|
|
27
|
+
def areas(self, include_items: bool = False) -> list[dict]:
|
|
28
|
+
return things.areas(**self._kwargs(include_items=include_items))
|
|
29
|
+
|
|
30
|
+
def tags(self, include_items: bool = False) -> list[dict]:
|
|
31
|
+
return things.tags(**self._kwargs(include_items=include_items))
|
|
32
|
+
|
|
33
|
+
def get(self, uuid: str) -> dict | None:
|
|
34
|
+
return things.get(uuid, **self._kwargs())
|
|
35
|
+
|
|
36
|
+
def search(self, query: str) -> list[dict]:
|
|
37
|
+
return things.search(query, **self._kwargs())
|
|
38
|
+
|
|
39
|
+
def inbox(self) -> list[dict]:
|
|
40
|
+
return things.inbox(**self._kwargs())
|
|
41
|
+
|
|
42
|
+
def today(self) -> list[dict]:
|
|
43
|
+
return things.today(**self._kwargs())
|
|
44
|
+
|
|
45
|
+
def upcoming(self) -> list[dict]:
|
|
46
|
+
return things.upcoming(**self._kwargs())
|
|
47
|
+
|
|
48
|
+
def anytime(self) -> list[dict]:
|
|
49
|
+
return things.anytime(**self._kwargs())
|
|
50
|
+
|
|
51
|
+
def someday(self) -> list[dict]:
|
|
52
|
+
return things.someday(**self._kwargs())
|
|
53
|
+
|
|
54
|
+
def logbook(self, **filters) -> list[dict]:
|
|
55
|
+
return things.logbook(**self._kwargs(**filters))
|
|
56
|
+
|
|
57
|
+
def completed(self, **filters) -> list[dict]:
|
|
58
|
+
return things.completed(**self._kwargs(**filters))
|
|
59
|
+
|
|
60
|
+
def canceled(self, **filters) -> list[dict]:
|
|
61
|
+
return things.canceled(**self._kwargs(**filters))
|
|
62
|
+
|
|
63
|
+
def trash(self) -> list[dict]:
|
|
64
|
+
return things.trash(**self._kwargs())
|
|
65
|
+
|
|
66
|
+
def deadlines(self) -> list[dict]:
|
|
67
|
+
return things.deadlines(**self._kwargs())
|
|
68
|
+
|
|
69
|
+
def checklist_items(self, todo_uuid: str) -> list[dict]:
|
|
70
|
+
return things.checklist_items(todo_uuid, **self._kwargs())
|
|
71
|
+
|
|
72
|
+
def tags_for_item(self, tag_title: str) -> list[dict]:
|
|
73
|
+
return things.tags(title=tag_title, include_items=True, **self._kwargs())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Write to Things 3 via the URL scheme."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from urllib.parse import quote, urlencode
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ThingsWriter:
|
|
13
|
+
"""Creates and updates Things items via the URL scheme."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, auth_token: str, verify_timeout: float = 0.5) -> None:
|
|
16
|
+
self._auth_token = auth_token
|
|
17
|
+
self._verify_timeout = verify_timeout
|
|
18
|
+
|
|
19
|
+
def _build_url(self, command: str, **params) -> str:
|
|
20
|
+
"""Build a things:/// URL with encoded parameters."""
|
|
21
|
+
filtered = {}
|
|
22
|
+
for key, value in params.items():
|
|
23
|
+
if value is None:
|
|
24
|
+
continue
|
|
25
|
+
url_key = key.replace("_", "-")
|
|
26
|
+
if isinstance(value, list):
|
|
27
|
+
filtered[url_key] = ",".join(str(v) for v in value)
|
|
28
|
+
elif isinstance(value, bool):
|
|
29
|
+
filtered[url_key] = "true" if value else "false"
|
|
30
|
+
else:
|
|
31
|
+
filtered[url_key] = str(value)
|
|
32
|
+
qs = urlencode(filtered, quote_via=quote)
|
|
33
|
+
return f"things:///{command}?{qs}" if qs else f"things:///{command}"
|
|
34
|
+
|
|
35
|
+
async def _execute(self, url: str) -> None:
|
|
36
|
+
"""Open a Things URL scheme command."""
|
|
37
|
+
redacted = url.replace(self._auth_token, "REDACTED")
|
|
38
|
+
redacted = redacted.replace(
|
|
39
|
+
quote(self._auth_token, safe=""), "REDACTED"
|
|
40
|
+
)
|
|
41
|
+
logger.info("Executing: %s", redacted)
|
|
42
|
+
|
|
43
|
+
proc = await asyncio.create_subprocess_exec(
|
|
44
|
+
"open", url,
|
|
45
|
+
stderr=asyncio.subprocess.PIPE,
|
|
46
|
+
)
|
|
47
|
+
returncode = await proc.wait()
|
|
48
|
+
if returncode != 0:
|
|
49
|
+
stderr = await proc.stderr.read() if proc.stderr else b""
|
|
50
|
+
logger.error(
|
|
51
|
+
"URL scheme command failed (exit %d): %s",
|
|
52
|
+
returncode, stderr.decode(),
|
|
53
|
+
)
|
|
54
|
+
raise RuntimeError("Write operation failed")
|
|
55
|
+
|
|
56
|
+
async def create_todo(self, **params) -> None:
|
|
57
|
+
url = self._build_url("add", **params)
|
|
58
|
+
await self._execute(url)
|
|
59
|
+
|
|
60
|
+
async def update_todo(self, uuid: str, **params) -> None:
|
|
61
|
+
url = self._build_url(
|
|
62
|
+
"update", id=uuid, auth_token=self._auth_token, **params
|
|
63
|
+
)
|
|
64
|
+
await self._execute(url)
|
|
65
|
+
|
|
66
|
+
async def complete_todo(self, uuid: str) -> None:
|
|
67
|
+
await self.update_todo(uuid, completed=True)
|
|
68
|
+
|
|
69
|
+
async def cancel_todo(self, uuid: str) -> None:
|
|
70
|
+
await self.update_todo(uuid, canceled=True)
|
|
71
|
+
|
|
72
|
+
async def create_project(self, **params) -> None:
|
|
73
|
+
url = self._build_url("add-project", **params)
|
|
74
|
+
await self._execute(url)
|
|
75
|
+
|
|
76
|
+
async def update_project(self, uuid: str, **params) -> None:
|
|
77
|
+
url = self._build_url(
|
|
78
|
+
"update-project", id=uuid, auth_token=self._auth_token, **params
|
|
79
|
+
)
|
|
80
|
+
await self._execute(url)
|
|
81
|
+
|
|
82
|
+
async def complete_project(self, uuid: str) -> None:
|
|
83
|
+
await self.update_project(uuid, completed=True)
|
|
84
|
+
|
|
85
|
+
async def cancel_project(self, uuid: str) -> None:
|
|
86
|
+
await self.update_project(uuid, canceled=True)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: things-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: REST API for Things 3 — expose your tasks over HTTP
|
|
5
|
+
Project-URL: Homepage, https://github.com/jaydenk/things-api
|
|
6
|
+
Project-URL: Documentation, https://github.com/jaydenk/things-api/tree/main/docs
|
|
7
|
+
Project-URL: Issues, https://github.com/jaydenk/things-api/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jaydenk/things-api/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Jayden Kerr
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,gtd,rest,tasks,things,things3
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: MacOS X
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: fastapi<1,>=0.135
|
|
22
|
+
Requires-Dist: pydantic-settings<3,>=2.13
|
|
23
|
+
Requires-Dist: things-py<2,>=1.0
|
|
24
|
+
Requires-Dist: uvicorn[standard]<1,>=0.34
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: httpx>=0.28; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.25; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Things API
|
|
32
|
+
|
|
33
|
+
REST API for [Things 3](https://culturedcode.com/things/) — expose your tasks over HTTP.
|
|
34
|
+
|
|
35
|
+
Things API reads directly from the Things SQLite database via [things.py](https://github.com/thingsapi/things.py) and writes back through the [Things URL scheme](https://culturedcode.com/things/support/articles/2803573/). It runs as a lightweight FastAPI service on any Mac where Things is installed, giving you full programmatic access to your tasks from tools like [n8n](https://n8n.io/), [curl](https://curl.se/), or any HTTP client.
|
|
36
|
+
|
|
37
|
+
## Getting started
|
|
38
|
+
|
|
39
|
+
**Requirements:** macOS with [Things 3](https://culturedcode.com/things/) installed, Python 3.12+, and [uv](https://docs.astral.sh/uv/).
|
|
40
|
+
|
|
41
|
+
### 1. Clone and install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
git clone https://github.com/jaydenk/things-api.git
|
|
45
|
+
cd things-api
|
|
46
|
+
uv venv
|
|
47
|
+
uv pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Configure
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
cp env.example .env
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Open `.env` and set your API token — this is the bearer token that authenticates every request:
|
|
57
|
+
|
|
58
|
+
```dotenv
|
|
59
|
+
THINGS_API_TOKEN=choose-a-secure-random-string
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
To enable write operations (creating, updating, completing todos), you also need a Things URL scheme auth token. To get one, open **Things > Settings > General > Enable Things URLs** and copy the token:
|
|
63
|
+
|
|
64
|
+
```dotenv
|
|
65
|
+
THINGS_AUTH_TOKEN=your-things-url-scheme-token
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Without `THINGS_AUTH_TOKEN`, the API runs in **read-only mode**.
|
|
69
|
+
|
|
70
|
+
See [docs/configuration.md](docs/configuration.md) for all configuration options.
|
|
71
|
+
|
|
72
|
+
### 3. Run
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv run things-api
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The server starts on `http://localhost:5225`.
|
|
79
|
+
|
|
80
|
+
### 4. Try it
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
# Health check
|
|
84
|
+
curl http://localhost:5225/health \
|
|
85
|
+
-H "Authorization: Bearer YOUR_TOKEN"
|
|
86
|
+
|
|
87
|
+
# List today's tasks
|
|
88
|
+
curl http://localhost:5225/today \
|
|
89
|
+
-H "Authorization: Bearer YOUR_TOKEN"
|
|
90
|
+
|
|
91
|
+
# Create a todo (requires THINGS_AUTH_TOKEN)
|
|
92
|
+
curl -X POST http://localhost:5225/todos \
|
|
93
|
+
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
94
|
+
-H "Content-Type: application/json" \
|
|
95
|
+
-d '{"title": "Buy milk", "when": "today"}'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
To run the server persistently (auto-start at login, auto-restart on crash), see the [deployment guide](docs/deployment.md).
|
|
99
|
+
|
|
100
|
+
## Endpoints overview
|
|
101
|
+
|
|
102
|
+
Every endpoint requires a valid `Authorization: Bearer <token>` header.
|
|
103
|
+
|
|
104
|
+
| Resource | Endpoints | Description |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| **Todos** | `GET` `POST` `PUT` `DELETE` `/todos` | Full CRUD for todos |
|
|
107
|
+
| **Projects** | `GET` `POST` `PUT` `DELETE` `/projects` | Full CRUD for projects |
|
|
108
|
+
| **Smart lists** | `GET` `/inbox` `/today` `/upcoming` `/anytime` `/someday` `/logbook` | Read-only access to Things smart lists |
|
|
109
|
+
| **Tags** | `GET` `/tags` | List tags and items by tag |
|
|
110
|
+
| **Areas** | `GET` `/areas` | List areas |
|
|
111
|
+
| **Search** | `GET` `/search` `/search/advanced` | Full-text and filtered search |
|
|
112
|
+
| **Health** | `GET` `/health` | Service status and database connectivity |
|
|
113
|
+
|
|
114
|
+
> **Note:** `DELETE` on todos and projects is **irreversible** — it completes or cancels the item. Things 3 does not support true deletion.
|
|
115
|
+
|
|
116
|
+
See [docs/api-reference.md](docs/api-reference.md) for full endpoint details, request/response schemas, and query parameters.
|
|
117
|
+
|
|
118
|
+
## Limitations
|
|
119
|
+
|
|
120
|
+
- **macOS only** — Things 3 is a Mac app. The API must run on the same machine.
|
|
121
|
+
- **GUI session required for writes** — Write operations invoke the Things URL scheme, which requires an active GUI session.
|
|
122
|
+
- **No true deletion** — `DELETE` endpoints complete or cancel items instead.
|
|
123
|
+
|
|
124
|
+
## Further documentation
|
|
125
|
+
|
|
126
|
+
- [Configuration reference](docs/configuration.md) — All environment variables and their defaults
|
|
127
|
+
- [API reference](docs/api-reference.md) — Full endpoint documentation with request/response details
|
|
128
|
+
- [Deployment guide](docs/deployment.md) — Running as a launchd service, n8n integration
|
|
129
|
+
- [Development guide](docs/development.md) — Setting up a dev environment, running tests
|
|
130
|
+
- [Changelog](CHANGELOG.md) — Version history
|
|
131
|
+
|
|
132
|
+
## Licence
|
|
133
|
+
|
|
134
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
things_api/__init__.py,sha256=H94apNAWCbc_PGWaUvvcX2yterpCmgpRi9yow12wjBw,29
|
|
2
|
+
things_api/app.py,sha256=jm_2ENpMPsGo8J-HS7t81mnEWgLyTxN1smBTPFLgy6Q,2825
|
|
3
|
+
things_api/auth.py,sha256=n95hJlhbPsp6aRKYmxtLcGCfXDqwBC8--a1e_uZdvyI,804
|
|
4
|
+
things_api/config.py,sha256=Xu7EZvrMD0xOmKITVbXdtkoZJ-UwBnKp46JA3lB1Dq4,833
|
|
5
|
+
things_api/models.py,sha256=MAjTs34vqhRHYax2iRLyzxSFqvL1MtpZC_2RGTMGTaU,2631
|
|
6
|
+
things_api/ratelimit.py,sha256=wj8jCpor_jnd3KaPj7p7XTk0E9dhxDv22bsrUpmVaqg,1941
|
|
7
|
+
things_api/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
things_api/routers/areas.py,sha256=pYZ67_O4dlKeQZlmt0usSy8TIvIT2_ac28GJZYY5i_E,388
|
|
9
|
+
things_api/routers/lists.py,sha256=0mSZb2btM-s6Eps99cd6maxJkKuJOSbmq_wX3qMs1_g,1258
|
|
10
|
+
things_api/routers/projects.py,sha256=1x5U5UibqVTfQykuUUu2GlW9Gir8YGWYwHHcB0mFkrQ,3322
|
|
11
|
+
things_api/routers/search.py,sha256=CQnlNKZ9v_Wp9nUgH6omcFnLIWaNQCLJXvEitjeo28w,1596
|
|
12
|
+
things_api/routers/tags.py,sha256=lH0WNZ1l_Mh7v1cuPuJkJiEXU1of8n_Q5Za98MYGnCU,567
|
|
13
|
+
things_api/routers/todos.py,sha256=XjjFeNhmVEERxMinbT05dz6WyjAugK2tH3qxa-_2Ci0,3882
|
|
14
|
+
things_api/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
things_api/services/reader.py,sha256=8derO3VekFJ9f5req5fBeathy0MSC5ER4W3iiqa9a6k,2407
|
|
16
|
+
things_api/services/writer.py,sha256=cq6bmKb16bxg5GzPBq5cQXr_ZaxKl0UK625_TkBsYTs,3045
|
|
17
|
+
things_api-0.1.0.dist-info/METADATA,sha256=cw2SHZIxCbmATbdiMXxhODhYPXMehFn8bLsJ2KKNMrM,5069
|
|
18
|
+
things_api-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
19
|
+
things_api-0.1.0.dist-info/entry_points.txt,sha256=oZW5kJ2NnWHznrwxLZkkKoLB4BB-Tk4GJWRJ-yfguXU,51
|
|
20
|
+
things_api-0.1.0.dist-info/licenses/LICENSE,sha256=JeD-u7MIr0sv5zfK1mRZlHxZZwmIEcYeDsWjR5w3uhA,1068
|
|
21
|
+
things_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT Licence
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jayden Kerr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|