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 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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ things-api = things_api.app:main
@@ -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.