From bc88b421b62d84a14a43903e9c785fcfd3dfcfea Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 11:34:04 +0200 Subject: [PATCH] scaffold tasks extension Empty skeleton mirroring the events extension layout: config/manifest, pyproject + Makefile + ruff/mypy config, FastAPI routers wired into tasks_ext, NostrClient bootstrap stubs, and an empty static/routes.json. Models, migrations, CRUD, Nostr publisher/sync, and the API/template layers land in follow-up commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 11 +++++++ LICENSE | 21 +++++++++++++ Makefile | 32 +++++++++++++++++++ README.md | 32 +++++++++++++++++++ __init__.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++ config.json | 25 +++++++++++++++ crud.py | 5 +++ description.md | 9 ++++++ manifest.json | 9 ++++++ migrations.py | 1 + models.py | 1 + pyproject.toml | 67 ++++++++++++++++++++++++++++++++++++++++ static/routes.json | 14 +++++++++ toc.md | 29 +++++++++++++++++ views.py | 16 ++++++++++ views_api.py | 5 +++ 16 files changed, 354 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 description.md create mode 100644 manifest.json create mode 100644 migrations.py create mode 100644 models.py create mode 100644 pyproject.toml create mode 100644 static/routes.json create mode 100644 toc.md create mode 100644 views.py create mode 100644 views_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ff8eec --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.venv/ +node_modules/ +data/ +*.egg-info/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc60ed4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 aiolabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..97f8835 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +all: format check + +format: black ruff + +check: mypy checkblack checkruff + +mypy: + uv run mypy . + +black: + uv run black . + +ruff: + uv run ruff check . --fix + +checkruff: + uv run ruff check . + +checkblack: + uv run black --check . + +test: + PYTHONUNBUFFERED=1 \ + DEBUG=true \ + uv run pytest + +install-pre-commit-hook: + @echo "Installing pre-commit hook to git" + uv run pre-commit install + +pre-commit: + uv run pre-commit run --all-files diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbb2d75 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Tasks — LNbits extension + +LNbits-side counterpart to the webapp `tasks` module. Stores and syncs +recurring tasks / chores as Nostr NIP-52 calendar events. + +## Data model + +- **Kind 31922** — task (parameterized replaceable). Tagged with + `event-type: task` so activities consumers can filter it out + (see aiolabs/webapp#25 for context). +- **Kind 31925** — task completion / RSVP-ish status update. Carries + `task-status` (`claimed`, `in-progress`, `completed`, `blocked`, + `cancelled`), an optional `occurrence` date for recurring tasks, and + `completed_at` when finalized. +- **Kind 5** — NIP-09 deletions for the above. + +Recurrence is encoded as task-only tags: `recurrence` (`daily` | +`weekly`), `recurrence-day` (weekday name for weekly), `recurrence-end` +(YYYY-MM-DD). + +## What this extension does + +- Caches tasks and completions in a local sqlite/postgres schema. +- Publishes mutations to Nostr via the `nostrclient` extension's + internal WebSocket bridge. +- Subscribes to inbound 31922/31925 events filtered to `event-type=task` + so the local DB stays in sync with the relay set. + +## Status + +Early scaffold. Mirrors `events`' extension layout. See +`~/dev/webapp/src/modules/tasks/` for the canonical client-side model. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..2806685 --- /dev/null +++ b/__init__.py @@ -0,0 +1,77 @@ +import asyncio + +from fastapi import APIRouter +from loguru import logger + +from .crud import db +from .views import tasks_generic_router +from .views_api import tasks_api_router + +tasks_ext: APIRouter = APIRouter(prefix="/tasks", tags=["Tasks"]) +tasks_ext.include_router(tasks_generic_router) +tasks_ext.include_router(tasks_api_router) + +tasks_static_files = [ + { + "path": "/tasks/static", + "name": "tasks_static", + } +] + +scheduled_tasks: list[asyncio.Task] = [] + +# Module-level NostrClient — None when nostrclient is unavailable. Set by the +# bootstrap task in tasks_start() and read via dynamic attribute lookup from +# nostr_hooks.publish_or_delete_task_event. +nostr_client = None + + +def tasks_stop(): + for task in scheduled_tasks: + try: + task.cancel() + except Exception as ex: + logger.warning(ex) + + global nostr_client + if nostr_client: + asyncio.get_event_loop().create_task(nostr_client.stop()) + + +def tasks_start(): + from lnbits.tasks import create_permanent_unique_task + + async def _start_nostr_client(): + global nostr_client + await asyncio.sleep(10) # Wait for nostrclient to be ready + try: + from .nostr.nostr_client import NostrClient + + nostr_client = NostrClient() + logger.info("[TASKS] Starting NostrClient for NIP-52 sync") + await nostr_client.run_forever() + except Exception as exc: + logger.warning(f"[TASKS] NostrClient failed to start: {exc}") + logger.info("[TASKS] Tasks will work without Nostr sync") + + task1 = create_permanent_unique_task("ext_tasks_nostr", _start_nostr_client) + scheduled_tasks.append(task1) + + async def _sync_nostr_events(): + global nostr_client + await asyncio.sleep(15) + if not nostr_client: + logger.info("[TASKS] No NostrClient, skipping Nostr sync") + return + try: + from .nostr_sync import wait_for_nostr_events + + await wait_for_nostr_events(nostr_client) + except Exception as exc: + logger.error(f"[TASKS] Nostr sync task failed: {exc}") + + task2 = create_permanent_unique_task("ext_tasks_nostr_sync", _sync_nostr_events) + scheduled_tasks.append(task2) + + +__all__ = ["db", "tasks_ext", "tasks_start", "tasks_static_files", "tasks_stop"] diff --git a/config.json b/config.json new file mode 100644 index 0000000..c4cb90a --- /dev/null +++ b/config.json @@ -0,0 +1,25 @@ +{ + "id": "tasks", + "version": "0.1.0", + "name": "Tasks", + "repo": "https://git.atitlan.io/aiolabs/tasks", + "short_description": "Recurring tasks and chore-tracking, published over Nostr", + "description": "", + "tile": "/tasks/static/image/tasks.png", + "min_lnbits_version": "1.4.1", + "contributors": [ + { + "name": "padreug", + "uri": "https://git.atitlan.io/padreug", + "role": "Developer" + } + ], + "images": [], + "description_md": "https://git.atitlan.io/aiolabs/tasks/raw/branch/main/description.md", + "terms_and_conditions_md": "https://git.atitlan.io/aiolabs/tasks/raw/branch/main/toc.md", + "license": "MIT", + "paid_features": "", + "tags": ["Productivity"], + "donate": "", + "hidden": false +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..b542278 --- /dev/null +++ b/crud.py @@ -0,0 +1,5 @@ +from lnbits.db import Database + +db = Database("ext_tasks") + +# CRUD functions are filled in by the next commit. diff --git a/description.md b/description.md new file mode 100644 index 0000000..b5a1643 --- /dev/null +++ b/description.md @@ -0,0 +1,9 @@ +Recurring tasks and chore tracking for LNbits, synced over Nostr. + +Features: + +- Author tasks with optional recurrence (daily / weekly). +- Multiple participants per task; anyone can claim, start, or complete. +- Federated state via NIP-52 calendar events (kind 31922) and RSVPs + (kind 31925) with a `task-status` extension. +- Per-occurrence completion tracking for recurring tasks. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..eeb55a1 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "tasks", + "organisation": "aiolabs", + "repository": "tasks" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..be88854 --- /dev/null +++ b/migrations.py @@ -0,0 +1 @@ +# Migrations are filled in by the next commit. diff --git a/models.py b/models.py new file mode 100644 index 0000000..3270f77 --- /dev/null +++ b/models.py @@ -0,0 +1 @@ +# Pydantic models are filled in by the next commit. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..484c90a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,67 @@ +[project] +name = "lnbits-tasks" +version = "0.0.0" +requires-python = ">=3.10,<3.13" +description = "Recurring tasks and chore tracking for LNbits, federated over Nostr (NIP-52)." +authors = [{ name = "aiolabs", email = "dev@aiolabs.dev" }] +urls = { Homepage = "https://git.atitlan.io/aiolabs/tasks", Repository = "https://git.atitlan.io/aiolabs/tasks" } +dependencies = [ "lnbits>1" ] + +[dependency-groups] +dev = [ + "black", + "pytest-asyncio", + "pytest", + "mypy", + "pre-commit", + "ruff", +] + +[tool.poetry] +package-mode = false + +[tool.mypy] +exclude = "(nostr/*)" +plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.pytest.ini_options] +log_cli = false +testpaths = [ + "tests" +] + +[tool.black] +line-length = 88 + +[tool.ruff] +line-length = 88 +exclude = [ + "nostr", +] + +[tool.ruff.lint] +select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] +ignore = ["C901"] + +fixable = ["ALL"] +unfixable = [] + +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.lint.pep8-naming] +classmethod-decorators = [ + "validator", + "root_validator", +] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.Query", +] diff --git a/static/routes.json b/static/routes.json new file mode 100644 index 0000000..c1ff471 --- /dev/null +++ b/static/routes.json @@ -0,0 +1,14 @@ +[ + { + "path": "/tasks/", + "name": "PageTasks", + "template": "/tasks/static/js/index.vue", + "component": "/tasks/static/js/index.js" + }, + { + "path": "/tasks/:id", + "name": "PageTaskDisplay", + "template": "/tasks/static/js/display.vue", + "component": "/tasks/static/js/display.js" + } +] diff --git a/toc.md b/toc.md new file mode 100644 index 0000000..efd02cb --- /dev/null +++ b/toc.md @@ -0,0 +1,29 @@ +# Terms and Conditions for LNbits Extension + +## 1. Acceptance of Terms + +By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension. + +## 2. License + +The Extension is free and open-source software, released under the MIT license. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license. + +## 3. No Warranty + +The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms. + +## 4. Limitation of Liability + +In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction. + +## 5. Modification of Terms + +The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension. + +## 6. General Provisions + +If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension. + +## 7. Contact Information + +If you have any questions about these Terms, please contact the developer at dev@aiolabs.dev. diff --git a/views.py b/views.py new file mode 100644 index 0000000..860922e --- /dev/null +++ b/views.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends +from lnbits.core.views.generic import index, index_public +from lnbits.decorators import check_account_id_exists + +tasks_generic_router = APIRouter() + +tasks_generic_router.add_api_route( + "/", + methods=["GET"], + endpoint=index, + dependencies=[Depends(check_account_id_exists)], +) + +tasks_generic_router.add_api_route( + "/{task_id}", methods=["GET"], endpoint=index_public +) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..bda3896 --- /dev/null +++ b/views_api.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter + +tasks_api_router = APIRouter(prefix="/api/v1/tasks") + +# API routes are filled in by the next commit.