diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb6ed3..8acf954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,150 +9,6 @@ former name, "Daybook".) _Nothing yet._ -## [0.7.1] — 2026-09-08 - -### Added -- **Custom labels.** Curate a personal palette of coloured labels in - **Settings → Labels**, then apply them to any message from its ⋯ menu (or - right-click) — the menu stays open so you can apply several at once, with a - checkmark showing which are applied. Labels are unified with tags: applying - one adds it to the message's tags and renders as a chip. Backed by - `POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip - colours sync to the global `TagColor`. -- **Change your password in-app** from **Settings → Account**: enter the current - password, then a new one. Uses Django's `PasswordChangeForm` and keeps the - session logged in after the change. - -### Changed -- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when - empty); the count badge only appears when the folder is non-empty. - -## [0.7.0] — 2026-09-07 - -### Added -- **Mark all as read.** Click a source's unread badge in the sidebar to mark - all of that source's messages read (clears the badge); click the **All - sources** total to mark everything read. Owner-scoped, excludes trashed; any - visible feed rows update too. Backed by `POST /feed/mark-read`. - -## [0.6.3] — 2026-09-07 - -### Changed -- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed - count, empty states, preview, About, Storage). The model, API - (`/api/artifacts`), and posting skill keep "artifact" as the internal term — - no contract change for agents. -- Sidebar: the Keep/Deleted folders now have spacing between them and share the - "All sources" bold+blue-tint styling (they're special folders). - -### Fixed -- About page now points to Settings → API Keys (was the stale "Settings → - Account / personal API token"). - -## [0.6.2] — 2026-09-07 - -### Changed -- Source sidebar is now ordered by **most recent message** (Telegram-style) - instead of alphabetically; "All sources" stays pinned at the top. Live - updates move a source to the top when it receives a new message. - -## [0.6.1] — 2026-09-07 - -### Changed -- Sidebar: the **Keep / Deleted folders are pinned to the bottom** — only the - source list scrolls, so the folders stay reachable no matter how many sources - you have. **"All sources"** is now visually distinct (bold + subtle blue tint) - as the primary entry. (Internal: `.source-folders` class renamed to - `.system-folders`.) - -## [0.6.0] — 2026-09-07 - -### Added -- **Per-artifact actions menu.** A **⋯** button appears on row hover, and - **right-clicking** a feed row opens the same floating menu — **Keep / - Open / Copy link / Delete**. Dismisses on click-away or Escape, edge-flips - to stay on-screen. The standalone one-click Delete icon stays; the Trash - view keeps its dedicated Undelete. (Share and Add-label will join the menu - in later slices.) - -## [0.5.1] — 2026-09-07 - -### Added -- **Automatic Trash purge.** Trashed artifacts older than a ~3-day retention - window are now permanently removed automatically. It's cron-less: a delete - triggers a sweep that runs on a **background thread** (off the request, so - the click never blocks), tolerant of concurrent runs. Manual "Empty Trash" - still works for immediate reclaim. Controlled by `AGENTFEED_ASYNC_PURGE` - (on by default; tests run it inline). - -## [0.5.0] — 2026-09-07 - -### Added -- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable - **Trash** instead of removing it. Feed rows get a hover **Delete** icon; a - **"Deleted"** folder at the bottom of the sidebar lists trashed items with a - hover **Undelete**, plus an **Empty Trash** button to purge permanently. - Trashed items are **off-quota** (deleting frees quota immediately) and - eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too - (idempotent); trashed items are hidden from all API reads. -- **Keep folder.** A sidebar folder listing kept artifacts (a view over the - existing `is_kept` flag), mirroring Trash. Both hide when empty. -- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`, - `/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views. - -### Changed -- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above. - -### Note -- Permanent purge is manual (**Empty Trash**) in this release; automatic - purge-on-delete (background, ~3-day retention) is the planned fast-follow. - -### Changed -- Live feed updates now also keep the **source sidebar** current: as new posts - arrive, per-source and total unread counts bump, and a source posting for the - first time is added to the list (alphabetically) — all derived client-side - from the polled rows, no extra request. Sources added live stay - keyboard-navigable. Caveat: while filtered to a single source, *other* new - sources still only appear on reload (the poll is scoped to the selected view). - -## [0.4.0] — 2026-09-04 - -### Added -- **Live feed updates.** New artifacts now appear at the top of the feed - automatically, without a manual refresh — the browser polls a lightweight - `GET /feed/updates` every 5s (paused while the tab is hidden, one immediate - poll on refocus). Owner-scoped and **respects the active source/label/kind/q - filters**, so a new post that doesn't match your current view won't show. The - currently selected/previewed artifact stays put and visibly selected. Fresh - rows display "now" and age into their real time after a few minutes. - (Chosen over SSE/WebSockets deliberately: zero new infrastructure on the - sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.) - -### Changed -- Extracted the feed row markup into a reusable `_artifact_rows.html` partial - (shared by the day-group render and the live-updates endpoint). - -## [0.3.2] — 2026-09-04 - -### Added -- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the - metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an - agent can read back and verify exactly what it posted: exact source text for - `markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`, - `404` for render-only `html_fragment`/`deck`. The metadata response now - includes `api_raw_url`, and `/api/capabilities` advertises the route. - -## [0.3.1] — 2026-09-04 - -### Changed -- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`) - now accept a bearer token as well as a browser session, owner-scoped — an - agent can read back what it posted with the same token. Unauthenticated API - requests return a JSON `403` instead of a 302 redirect to the browser login. -- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) — - they expose no user data, so an agent can fetch the contract and posting - instructions before it has a key. - ## [0.3.0] — 2026-09-04 ### Changed @@ -261,18 +117,7 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD -[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0 -[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3 -[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2 -[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1 -[0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0 -[0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1 -[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0 -[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1 -[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 -[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 -[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...HEAD [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 [0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1 [0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index 86baea8..c0fa33e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,10 @@ The core product principle is: agents publish content, not files. The server own - Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account. - Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. - `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. -- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key. +- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. - Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - `TagColor` (label color overrides) stays global/shared across all users. -- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both the Keep and Deleted folders are always shown in the sidebar (even when empty); the count badge only appears when the folder is non-empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately. - Use random 8-character artifact IDs. - Start as a plain Django project; add Docker after core ingest and viewer work. - Preserve original markdown as `.md` alongside rendered `.html`. diff --git a/artifacts/migrations/0016_artifact_deleted_at.py b/artifacts/migrations/0016_artifact_deleted_at.py deleted file mode 100644 index 0454f2f..0000000 --- a/artifacts/migrations/0016_artifact_deleted_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.15 on 2026-09-07 11:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('artifacts', '0015_migrate_tokens_to_apikeys'), - ] - - operations = [ - migrations.AddField( - model_name='artifact', - name='deleted_at', - field=models.DateTimeField(blank=True, db_index=True, null=True), - ), - ] diff --git a/artifacts/migrations/0017_label.py b/artifacts/migrations/0017_label.py deleted file mode 100644 index aedcc1a..0000000 --- a/artifacts/migrations/0017_label.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.2.15 on 2026-09-08 13:46 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('artifacts', '0016_artifact_deleted_at'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Label', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=40)), - ('color', models.CharField(max_length=20)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['name'], - 'unique_together': {('user', 'name')}, - }, - ), - ] diff --git a/artifacts/models.py b/artifacts/models.py index dcba524..bbd14a8 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -60,9 +60,6 @@ class Artifact(models.Model): metadata = models.JSONField(default=dict, blank=True) read_at = models.DateTimeField(null=True, blank=True) is_kept = models.BooleanField(default=False) - # Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota, - # recoverable via undelete, purged by Empty Trash). NULL = live. - deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) size_bytes = models.BigIntegerField(default=0) # NULL = not shared. Many rows share NULL (SQL treats each NULL as # distinct, so `unique=True` still holds); a real value must be unique @@ -77,10 +74,6 @@ class Artifact(models.Model): def is_shared(self): return bool(self.share_token) - @property - def is_deleted(self): - return self.deleted_at is not None - class Meta: ordering = ["-created_at"] @@ -96,23 +89,6 @@ class TagColor(models.Model): return f"{self.name} ({self.color})" -class Label(models.Model): - """A user's reusable label in their personal palette. Applying one from the - feed UI just adds its `name` to an artifact's `tags`; the chip colour lives - in the global TagColor table (kept in sync when the palette entry is saved).""" - - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels") - name = models.CharField(max_length=40) - color = models.CharField(max_length=20) # #RRGGBB - - class Meta: - unique_together = ("user", "name") - ordering = ["name"] - - def __str__(self): - return f"{self.name} ({self.color})" - - class UserProfile(models.Model): DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB diff --git a/artifacts/storage.py b/artifacts/storage.py index df53526..1ac53cd 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -7,11 +7,9 @@ SUM rather than a stat of every file. """ import shutil -import threading from datetime import timedelta from django.conf import settings -from django.db import connection from django.db.models import F, Sum from django.utils import timezone @@ -72,13 +70,8 @@ def artifact_total_bytes(artifact): def user_usage_bytes(user): - # Trashed artifacts are off-quota (deleting relieves quota immediately); - # their files linger on disk until Empty Trash / purge, but don't count. artifact_bytes = ( - Artifact.objects.filter(owner=user, deleted_at__isnull=True) - .aggregate(total=Sum("size_bytes")) - .get("total") - or 0 + Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 ) asset_bytes = ( Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 @@ -120,10 +113,8 @@ def make_room_for(user, incoming_bytes): if used + incoming_bytes <= quota: return [] - # Trashed artifacts are eviction-protected (recovery window is guaranteed) - # and already off-quota, so they're never eviction candidates. unkept = list( - Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at") + Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at") ) # Check whether eviction can possibly succeed before deleting anything - # otherwise a doomed request would still destroy every unkept artifact on @@ -146,55 +137,6 @@ def make_room_for(user, incoming_bytes): return evicted -def purge_trashed(user): - """Permanently remove all of a user's trashed artifacts (files + rows). - Backs the "Empty Trash" action.""" - trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False)) - for artifact in trashed: - delete_artifact_files(artifact) - artifact.delete() - return len(trashed) - - -TRASH_RETENTION = timedelta(days=3) - - -def purge_expired_trash(now=None): - """Permanently remove trashed artifacts older than the retention window - (all users). Tolerant of already-gone rows so concurrent purges are safe. - Returns the number purged.""" - now = now or timezone.now() - cutoff = now - TRASH_RETENTION - expired = list(Artifact.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff)) - purged = 0 - for artifact in expired: - try: - delete_artifact_files(artifact) - artifact.delete() - purged += 1 - except Exception: - # Another concurrent purge got there first, or a file's already - # gone — a purge error must never surface to anyone. - pass - return purged - - -def spawn_trash_purge(): - """Run purge_expired_trash off the request thread (cron-less: triggered by - deletes). Falls back to inline when async is disabled (tests).""" - if not getattr(settings, "AGENTFEED_ASYNC_PURGE", True): - purge_expired_trash() - return - - def _run(): - try: - purge_expired_trash() - finally: - connection.close() # threads get their own connection; don't leak it - - threading.Thread(target=_run, daemon=True).start() - - ASSET_ORPHAN_GRACE = timedelta(hours=24) diff --git a/artifacts/urls.py b/artifacts/urls.py index d7adc5d..1cbd657 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,15 +6,9 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), path("feed/older", views.feed_older, name="feed-older"), - path("feed/updates", views.feed_updates, name="feed-updates"), path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), - path("a//label", views.toggle_artifact_label, name="artifact-toggle-label"), - path("a//delete", views.delete_artifact, name="artifact-delete"), - path("a//undelete", views.undelete_artifact, name="artifact-undelete"), - path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), - path("feed/mark-read", views.mark_all_read, name="feed-mark-read"), path("a/", views.artifact_view, name="artifact"), path("s/", views.shared_artifact_view, name="artifact-shared"), path("assets/", views.asset_view, name="asset"), @@ -25,11 +19,9 @@ urlpatterns = [ path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), - path("api/artifacts//raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"), path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), - path("settings/labels/", views.settings_labels, name="settings-labels"), path("settings/users/", views.settings_users, name="settings-users"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/storage/", views.settings_storage, name="settings-storage"), diff --git a/artifacts/views.py b/artifacts/views.py index bd2fccc..d1978ed 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -15,18 +15,16 @@ import nh3 import openpyxl from django.conf import settings from django.contrib import messages -from django.contrib.auth import update_session_auth_hash, views as auth_views +from django.contrib.auth import views as auth_views from django.contrib.auth.decorators import login_required -from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.db import OperationalError, transaction -from django.db.models import Count, Max, Q, Sum +from django.db.models import Count, Q, Sum from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from django.template.defaultfilters import date as date_filter, slugify -from django.template.loader import render_to_string +from django.template.defaultfilters import slugify from django.urls import reverse -from django.utils.dateparse import parse_date, parse_datetime +from django.utils.dateparse import parse_date from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -37,7 +35,7 @@ from rest_framework.views import APIView from .demo_data import create_demo_artifacts from .forms import AgentFeedLoginForm -from .models import ApiKey, Artifact, Asset, Label, TagColor +from .models import ApiKey, Artifact, Asset, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, @@ -45,8 +43,6 @@ from .storage import ( delete_artifact_files, disk_free_bytes, make_room_for, - purge_trashed, - spawn_trash_purge, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -86,14 +82,12 @@ DEFAULT_THEMES = { } +@method_decorator(login_required, name="get") class ArtifactListCreateView(APIView): parser_classes = [JSONParser, FormParser, MultiPartParser] def get(self, request): - user = resolve_request_user(request) - if user is None: - return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True) + artifacts = Artifact.objects.filter(owner=request.user) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): @@ -229,12 +223,10 @@ def asset_view(request, asset_id): return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream") +@method_decorator(login_required, name="get") class ArtifactDetailView(APIView): def get(self, request, artifact_id): - user = resolve_request_user(request) - if user is None: - return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): @@ -242,7 +234,7 @@ class ArtifactDetailView(APIView): if user is None: return HttpResponseForbidden("Invalid or missing API token") - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) data = request.data not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) if not_editable: @@ -301,59 +293,24 @@ class ArtifactDetailView(APIView): if user is None: return HttpResponseForbidden("Invalid or missing API token") - # Soft-delete: move to Trash (recoverable, off-quota) rather than - # permanently removing. Purged later via Empty Trash. Idempotent. artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) - if artifact.deleted_at is None: - artifact.deleted_at = timezone.now() - artifact.save(update_fields=["deleted_at"]) - spawn_trash_purge() # sweep expired trash off the request thread + delete_artifact_files(artifact) + artifact.delete() return Response(status=204) -class ArtifactRawView(APIView): - """The artifact's full content, for read-back / verification. Same auth as - the detail endpoint (bearer token or session), owner-scoped. - - GET /api/artifacts/{id}/raw - - markdown / html_full / json / csv -> exact posted source (text/plain) - - image / pdf / binary -> the stored file bytes - - html_fragment / deck -> 404 (render-only; no raw kept) - """ - - def get(self, request, artifact_id): - user = resolve_request_user(request) - if user is None: - return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) - - raw_path = raw_artifact_path(artifact) - if raw_path and raw_path.exists(): - return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8") - - if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}: - path = safe_artifact_path(artifact.path) - if path.exists(): - content_type, _ = mimetypes.guess_type(path.name) - return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") - - return Response( - {"detail": "No raw content available for this kind (render-only)."}, status=404 - ) - - class CapabilitiesView(APIView): """Static capability info. Reachable by a logged-in session or a valid API token, so agents can check connectivity before posting. Exposes no user data.""" def get(self, request): - # Public: capability/contract info only, no user data. + if not request.user.is_authenticated and resolve_token_user(request) is None: + return HttpResponseForbidden("Login or a valid API token is required") from daybook_project import __version__ methods = { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/artifacts/{id}/raw": ["GET"], "/api/assets": ["POST"], "/api/capabilities": ["GET"], "/api/skill": ["GET"], @@ -405,9 +362,9 @@ class SkillView(APIView): """ def get(self, request): - # Public: the skill is bootstrapping instructions (how to authenticate - # and post) and contains no secrets — an agent shouldn't need a key to - # learn how to use one. + if not request.user.is_authenticated and resolve_token_user(request) is None: + return HttpResponseForbidden("Login or a valid API token is required") + origin = request.build_absolute_uri("/").rstrip("/") try: skill = render_posting_skill(origin) @@ -523,40 +480,21 @@ def feed(request): selected_artifact_id = request.GET.get("artifact", "") preview_none = request.GET.get("preview") == "none" selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" - selected_folder = request.GET.get("folder", "") - if selected_folder not in ("deleted", "keep"): - selected_folder = "" - # Live (non-trashed) artifacts drive the normal feed and the source sidebar. - live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True) - if selected_folder == "deleted": - # System folder: all trashed items, not source-scoped. - selected_source = "" - base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False) - elif selected_folder == "keep": - selected_source = "" - base = live.filter(is_kept=True) - else: - base = live + all_artifacts = Artifact.objects.filter(owner=request.user) artifacts = apply_feed_filters( - base, selected_source, selected_label, selected_kind, title_query + all_artifacts, selected_source, selected_label, selected_kind, title_query ) - if selected_folder: - # System folders show everything (no 7-day window / infinite scroll). - window_start = today - window_artifacts = list(artifacts.order_by("-created_at")) - older_count = 0 - else: - window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) - if not artifacts.filter(day__gte=window_start).exists(): - # Nothing recent for these filters: fall back to the most recent days - # that do match, so selecting a source is never met with a blank list. - recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) - if recent_days: - window_start = recent_days[-1] - window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) - older_count = artifacts.filter(day__lt=window_start).count() + window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) + if not artifacts.filter(day__gte=window_start).exists(): + # Nothing recent for these filters: fall back to the most recent days that + # do match, so selecting a source is never met with a blank list. + recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) + if recent_days: + window_start = recent_days[-1] + window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) + older_count = artifacts.filter(day__lt=window_start).count() overrides = tag_color_overrides() day_groups = group_artifacts_by_day(window_artifacts, overrides) @@ -566,8 +504,6 @@ def feed(request): selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None) toggle_query = {"view": "rendered"} - if selected_folder: - toggle_query["folder"] = selected_folder if selected_source: toggle_query["source"] = selected_source if selected_label: @@ -582,22 +518,18 @@ def feed(request): toggle_query["view"] = "raw" raw_url = f"?{urlencode(toggle_query)}" - # Source counts + labels always come from live artifacts, so the sidebar is - # the same whichever folder you're viewing. + # Source counts come from the other filters only, so the sidebar still shows + # every source available under the current label/type/name filters. source_scoped = apply_feed_filters( - live, "", selected_label, selected_kind, title_query + all_artifacts, "", selected_label, selected_kind, title_query ) - # Sources ordered by most recent message first (Telegram-style); "All - # sources" is rendered separately and stays pinned at the top. source_counts = ( source_scoped.values("source") - .annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at")) - .order_by("-latest") + .annotate(count=Count("id", filter=Q(read_at__isnull=True))) + .order_by("source") ) - keep_count = live.filter(is_kept=True).count() - trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count() - all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags}) + all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags}) return render( request, "artifacts/feed.html", @@ -606,14 +538,8 @@ def feed(request): "day_groups": day_groups, "older_count": older_count, "window_start": window_start, - # Marker for live polling: newest artifact shown, else now. - "feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(), "source_counts": source_counts, "total_count": source_scoped.filter(read_at__isnull=True).count(), - "keep_count": keep_count, - "trash_count": trash_count, - "selected_folder": selected_folder, - "in_trash": selected_folder == "deleted", "all_labels": labels_with_colors_list(all_labels, overrides), "raw_url": raw_url, "rendered_url": rendered_url, @@ -626,7 +552,6 @@ def feed(request): "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, - "label_palette": request.user.labels.all(), }, ) @@ -641,31 +566,6 @@ def mark_artifact_read(request, artifact_id): return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) -@login_required -@require_POST -def toggle_artifact_label(request, artifact_id): - """Toggle a palette label on/off for one of the user's own messages. The - label name is written into/removed from the artifact's `tags` list (labels - are unified with tags). Only names in the user's palette are accepted.""" - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True) - name = (request.POST.get("name") or "").strip() - label = Label.objects.filter(user=request.user, name=name).first() - if label is None: - return JsonResponse({"error": "No such label in your palette."}, status=400) - tags = list(artifact.tags) - if name in tags: - tags.remove(name) - applied = False - else: - tags.append(name) - applied = True - artifact.tags = tags - artifact.save(update_fields=["tags"]) - # Keep the chip colour in sync with the palette (TagColor is global). - TagColor.objects.update_or_create(name=name, defaults={"color": label.color}) - return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags}) - - @login_required @require_POST def toggle_artifact_keep(request, artifact_id): @@ -692,58 +592,12 @@ def toggle_artifact_share(request, artifact_id): return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url}) -@login_required -@require_POST -def delete_artifact(request, artifact_id): - """Soft-delete: move to Trash (recoverable, off-quota). Idempotent.""" - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) - if artifact.deleted_at is None: - artifact.deleted_at = timezone.now() - artifact.save(update_fields=["deleted_at"]) - spawn_trash_purge() # sweep expired trash off the request thread - return JsonResponse({"id": artifact.id, "deleted": True}) - - -@login_required -@require_POST -def undelete_artifact(request, artifact_id): - """Restore a trashed artifact back to the live feed.""" - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) - if artifact.deleted_at is not None: - artifact.deleted_at = None - artifact.save(update_fields=["deleted_at"]) - return JsonResponse({"id": artifact.id, "deleted": False}) - - -@login_required -@require_POST -def empty_trash(request): - """Permanently purge all of the user's trashed artifacts.""" - count = purge_trashed(request.user) - return JsonResponse({"purged": count}) - - -@login_required -@require_POST -def mark_all_read(request): - """Mark all of the user's unread (non-trashed) messages as read, optionally - scoped to one source. Backs the sidebar 'Mark all as read' action.""" - unread = Artifact.objects.filter( - owner=request.user, deleted_at__isnull=True, read_at__isnull=True - ) - source = request.POST.get("source", "") - if source: - unread = unread.filter(source=source) - marked = unread.update(read_at=timezone.now()) - return JsonResponse({"marked": marked, "source": source}) - - @login_required def feed_older(request): """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" before = parse_date(request.GET.get("before", "")) or timezone.localdate() artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), + Artifact.objects.filter(owner=request.user), request.GET.get("source", ""), request.GET.get("label", ""), request.GET.get("kind", ""), @@ -777,58 +631,6 @@ def feed_older(request): ) -@login_required -def feed_updates(request): - """New artifacts since a timestamp, as row HTML for the feed to prepend live. - Owner-scoped and filter-aware — respects the source/label/kind/q the browser - is currently viewing, so a new post that doesn't match won't appear.""" - after = parse_datetime(request.GET.get("after", "") or "") - if after is None: - return JsonResponse({"count": 0}) - - selected_source = request.GET.get("source", "") - selected_label = request.GET.get("label", "") - selected_kind = request.GET.get("kind", "") - title_query = request.GET.get("q", "") - artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), - selected_source, - selected_label, - selected_kind, - title_query, - ).filter(created_at__gt=after).order_by("-created_at") - - new = list(artifacts[:100]) - if not new: - return JsonResponse({"count": 0}) - - overrides = tag_color_overrides() - for artifact in new: - artifact.label_chips = tags_with_colors(artifact.tags, overrides) - - html = render_to_string( - "artifacts/_artifact_rows.html", - { - "artifacts": new, - "selected_artifact": None, - "selected_source": selected_source, - "selected_label": selected_label, - "selected_kind": selected_kind, - "title_query": title_query, - "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", - }, - request=request, - ) - return JsonResponse( - { - "count": len(new), - "newest": new[0].created_at.isoformat(), - "today_label": date_filter(timezone.localdate(), "D, M j Y"), - "html": html, - } - ) - - @login_required @xframe_options_sameorigin def artifact_view(request, artifact_id): @@ -1271,15 +1073,6 @@ def resolve_token_user(request): return api_key.user -def resolve_request_user(request): - """The acting user from either a browser session or a bearer API key. - Used by the JSON read endpoints so agents can read back with the same token - they post with (owner-scoped). None if neither authenticates.""" - if request.user.is_authenticated: - return request.user - return resolve_token_user(request) - - def normalize_tags(tags): if isinstance(tags, str): return [tag.strip() for tag in tags.split(",") if tag.strip()] @@ -1469,7 +1262,6 @@ def serialize_artifact(artifact, request): "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), - "api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])), "raw_available": raw_artifact_path(artifact) is not None, "source": artifact.source, "title": artifact.title, @@ -1534,25 +1326,10 @@ def settings_home(request): @login_required def settings_account(request): - if request.method == "POST": - password_form = PasswordChangeForm(request.user, request.POST) - if password_form.is_valid(): - user = password_form.save() - # Keep the current session valid — changing the password rotates the - # session auth hash, which would otherwise log the user out. - update_session_auth_hash(request, user) - messages.success(request, "Password changed.") - return redirect("settings-account") - else: - password_form = PasswordChangeForm(request.user) return render( request, "artifacts/settings_account.html", - { - "active_section": "account", - "key_count": request.user.api_keys.count(), - "password_form": password_form, - }, + {"active_section": "account", "key_count": request.user.api_keys.count()}, ) @@ -1582,49 +1359,6 @@ def settings_api_keys(request): ) -LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") - - -@login_required -def settings_labels(request): - if request.method == "POST": - action = request.POST.get("action") - if action == "create": - name = (request.POST.get("name") or "").strip() - color = (request.POST.get("color") or "").strip() - if not name: - messages.error(request, "Give the label a name.") - elif not LABEL_HEX_RE.match(color): - messages.error(request, "Pick a colour (#RRGGBB).") - elif Label.objects.filter(user=request.user, name=name).exists(): - messages.error(request, f"You already have a label called '{name}'.") - else: - Label.objects.create(user=request.user, name=name, color=color) - TagColor.objects.update_or_create(name=name, defaults={"color": color}) - messages.success(request, f"Created label '{name}'.") - elif action == "update": - color = (request.POST.get("color") or "").strip() - label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first() - if label is None: - messages.error(request, "No such label.") - elif not LABEL_HEX_RE.match(color): - messages.error(request, "Pick a colour (#RRGGBB).") - else: - label.color = color - label.save(update_fields=["color"]) - TagColor.objects.update_or_create(name=label.name, defaults={"color": color}) - messages.success(request, f"Updated '{label.name}'.") - elif action == "delete": - deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete() - messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.") - return redirect("settings-labels") - return render( - request, - "artifacts/settings_labels.html", - {"active_section": "labels", "labels": request.user.labels.all()}, - ) - - @login_required def settings_users(request): if not request.user.is_superuser: diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index a5bfd5e..665c74e 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.7.1" +__version__ = "0.3.0" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index a041730..ab989ea 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -19,10 +19,6 @@ AGENTFEED_KEY_PROVISIONING_API = os.environ.get( "AGENTFEED_KEY_PROVISIONING_API", "" ).lower() in {"1", "true", "yes", "on"} -# Purge expired Trash off the request thread (triggered by deletes, no cron). -# Tests override this to run inline so purge behavior is deterministic. -AGENTFEED_ASYNC_PURGE = True - # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # on login/POST. The proxy must forward X-Forwarded-Proto: https. diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index 29339ac..942ccee 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -15,7 +15,7 @@ Also confirms your token is valid: ```powershell Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" ` - -Headers @{ Authorization = "Bearer " } + -Headers @{ Authorization = "Bearer " } ``` ## 2. Publish Markdown @@ -39,7 +39,7 @@ $body = @{ $artifact = Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body @@ -69,7 +69,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -78,7 +78,7 @@ Invoke-RestMethod ` ```powershell curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` - -H "Authorization: Bearer " ` + -H "Authorization: Bearer " ` -F "source=orion" ` -F "source_type=agent" ` -F "source_name=Orion Agent" ` @@ -120,7 +120,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Method Patch ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -131,7 +131,7 @@ Invoke-RestMethod ` Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Method Delete ` - -Headers @{ Authorization = "Bearer " } + -Headers @{ Authorization = "Bearer " } ``` Deletes the artifact's database row and its files. You can only delete artifacts your own token owns. diff --git a/docs/api.md b/docs/api.md index 413a238..b3f6ee8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,15 +16,11 @@ Authorization: Bearer Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. -Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept -**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a -token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an -agent can read back what it posted with the same token. Unauthenticated API requests get a -JSON `403`, never a browser-login redirect. +Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a +logged-in browser session, not a bearer token — visit the app in a browser and log in first. -`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose -no user data), so an agent can discover the contract and fetch posting instructions before -it has a key. +`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check +connectivity before posting. ## Artifact Kinds @@ -108,7 +104,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -188,7 +184,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -241,7 +237,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -288,7 +284,7 @@ PowerShell/curl example: ```powershell curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` - -H "Authorization: Bearer " ` + -H "Authorization: Bearer " ` -F "source=orion" ` -F "source_type=agent" ` -F "source_name=Orion Agent" ` @@ -305,7 +301,7 @@ artifact), upload it first and reference the returned URL in your content. ```http POST /api/assets Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` | Field | Notes | @@ -347,7 +343,7 @@ Endpoint: GET /api/artifacts ``` -Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts. +Requires a logged-in browser session. Returns only the current user's artifacts. Supported filters (combine freely; all are ANDed): @@ -392,7 +388,7 @@ Endpoint: ```http PATCH /api/artifacts/{id} Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Editable fields: @@ -438,7 +434,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` -Method Patch ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -449,48 +445,42 @@ Endpoint: ```http DELETE /api/artifacts/{id} -Authorization: Bearer +Authorization: Bearer ``` -**Soft-delete:** moves the artifact to Trash rather than removing it — it's -recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden -from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it -as gone). Files stay on disk until the trash is purged (Empty Trash in the app). -Returns `204` with no body. +Removes the artifact's database row and its files (primary file, markdown +sidecar, and any attached assets). Returns `204` with no body on success. -**Idempotent:** deleting an already-trashed id returns `204` again (not `404`). -You can only delete artifacts your own token owns; another user's returns `404`. -There is no API endpoint to undelete or purge yet — that's app-UI-only. +You can only delete artifacts your own token owns; others return `404`, as +does a second delete of the same id. ```bash curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ - -H "Authorization: Bearer " + -H "Authorization: Bearer " ``` ## Rendered And Raw Content -**For agents (bearer token):** fetch the full content from the API — separate -from the metadata `GET /api/artifacts/{id}`: +Rendered browser view: ```http -GET /api/artifacts/{id}/raw +GET /a/{id} ``` -Same auth as the detail endpoint (bearer token or session), owner-scoped, so an -agent can read back and verify exactly what it posted. The metadata response -includes `api_raw_url` pointing here. +Raw/source view: -- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as - `text/plain`. -- `image` / `pdf` / `binary` → the stored file bytes (with its content type). -- `html_fragment` / `deck` → `404` (render-only; no raw source is kept). +```http +GET /a/{id}?raw=1 +``` -Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line -endings. +Raw currently exists for: -**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and -`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint -above for token access. +| Kind | Raw content | +|---|---| +| `markdown` | Preserved `.md` source. | +| `html_full` | Stored HTML file served as text. | +| `json` | Preserved original JSON text. | +| `csv` | Preserved original CSV text. | If raw content is unavailable, AgentFeed returns a small HTML fallback page. @@ -551,10 +541,11 @@ This is generated from the server's actual configuration, so it's the authoritative source if this document and a running instance ever disagree. Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or -simply to check the instance is reachable. It's public — no auth required: +simply to check the instance is reachable and their token is valid. Unlike the other read +endpoints, it accepts a bearer token as well as a browser session: ```bash -curl http://127.0.0.1:8001/api/capabilities +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities ``` ## Posting Skill @@ -572,13 +563,14 @@ instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own origin**, so the text a caller receives already points at the right server; a recipient only has to fill in their `AGENTFEED_TOKEN`. -**Public** — no auth required (like capabilities), so an agent can fetch its -posting instructions before it has a key. The default response is raw Markdown -(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`. +Same auth as capabilities (a browser session **or** a bearer token), so a +logged-in user can copy it to hand to their agent, or an agent can fetch it +directly. The default response is raw Markdown (`text/markdown`); add +`?format=json` for `{"version", "url", "skill"}`. ```bash -# Fetch the ready-to-use instructions for this instance (no auth needed) -curl http://127.0.0.1:8001/api/skill +# Fetch the ready-to-use instructions for this instance +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/skill ``` Discover it programmatically via `skill_url` in the capabilities response. diff --git a/docs/deployment.md b/docs/deployment.md index 27d1fad..ad22fce 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -77,7 +77,7 @@ docker compose -f deploy/docker-compose.yml up -d --build ``` Create your first login (superuser) so you have something to log in with and -a personal API key to post through: +a personal API token to post through: ```bash docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser @@ -100,7 +100,7 @@ smoke-test post: ```bash curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ - -H "Authorization: Bearer " \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "source": "deploy-smoke", diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 75b4508..90897d9 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -20,7 +20,7 @@ them, or set them in your agent harness's own env config. ```text AGENTFEED_URL=http://127.0.0.1:8001 -AGENTFEED_TOKEN=your-personal-api-key +AGENTFEED_TOKEN=your-personal-api-token AGENTFEED_SOURCE=agent ``` diff --git a/skills/agentfeed-posting/scripts/agentfeed_post.py b/skills/agentfeed-posting/scripts/agentfeed_post.py index 049f78f..4d676de 100644 --- a/skills/agentfeed-posting/scripts/agentfeed_post.py +++ b/skills/agentfeed-posting/scripts/agentfeed_post.py @@ -27,7 +27,7 @@ DEFAULT_SOURCE = "agent" def main(): args = parse_args() base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/") - # AGENTFEED_TOKEN is your personal per-user API key (Settings -> API Keys in the app); + # AGENTFEED_TOKEN is your personal per-user token (Settings -> API Keys in the app); # there is no shared fallback token, so posting without it cannot work. token = os.environ.get("AGENTFEED_TOKEN", "") source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE) @@ -43,7 +43,7 @@ def main(): if not token: print( - "error: AGENTFEED_TOKEN is not set. Get your personal API key from " + "error: AGENTFEED_TOKEN is not set. Get your personal API token from " f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.", file=sys.stderr, ) diff --git a/static/agentfeed.css b/static/agentfeed.css index 788b473..805b76b 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -360,7 +360,6 @@ h1 { /* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */ .source-list { - flex: 1 1 auto; /* fill available height so system folders pin to the bottom */ scrollbar-color: transparent transparent; scrollbar-width: none; } @@ -514,18 +513,6 @@ h1 { font-size: 0.7rem; } -/* The unread badge doubles as a "mark all as read" button. */ -.source-count[data-mark-read] { - cursor: pointer; - border-radius: 8px; - padding: 0 0.35rem; -} - -.source-count[data-mark-read]:hover { - background: rgba(74, 110, 168, 0.28); - color: var(--text); -} - .pane-heading span, .artifact-time { color: var(--muted); @@ -1058,173 +1045,6 @@ kbd { color: var(--muted); } -/* Newly live-inserted feed rows briefly highlight, then settle. */ -@keyframes agentfeed-row-arrive { - from { background: var(--surface-alt); } - to { background: transparent; } -} - -.artifact-row.just-arrived { - animation: agentfeed-row-arrive 1.5s ease-out; -} - -/* Hover/focus row actions (delete in the feed, undelete in Trash) */ -.artifact-row { - position: relative; -} - -.row-actions { - position: absolute; - right: 0.4rem; - top: 50%; - transform: translateY(-50%); - display: flex; - gap: 0.15rem; - opacity: 0; -} - -.artifact-row:hover .row-actions, -.artifact-row:focus-within .row-actions { - opacity: 1; -} - -.row-action { - cursor: pointer; - font-size: 0.95rem; - line-height: 1; - padding: 0.15rem 0.3rem; - border-radius: 4px; - background: var(--surface); - color: var(--muted); -} - -.row-action:hover { - background: var(--surface-alt); - color: var(--text); -} - -.row-delete:hover { - color: var(--danger-text-plain); -} - -/* Per-row actions menu (opened by the ⋯ trigger or right-click) */ -.row-menu { - position: fixed; - z-index: 100; - min-width: 9rem; - background: var(--surface); - border: 1px solid var(--control-border); - border-radius: 8px; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14); - padding: 0.25rem; - display: flex; - flex-direction: column; -} - -.row-menu[hidden] { - display: none; -} - -.row-menu button { - background: transparent; - border: 0; - border-radius: 5px; - color: var(--text); - cursor: pointer; - font-size: 0.85rem; - padding: 0.4rem 0.6rem; - text-align: left; -} - -.row-menu button:hover { - background: var(--surface-alt); -} - -.row-menu button.danger { - color: var(--danger-text-plain); -} - -.row-menu-divider { - border-top: 1px solid var(--grid-line); - margin: 0.25rem 0; -} - -.row-menu-labels { - display: flex; - flex-direction: column; - max-height: 13rem; - overflow-y: auto; -} - -.row-menu-label { - align-items: center; - display: flex; - gap: 0.45rem; -} - -.row-menu-dot { - border-radius: 50%; - flex: 0 0 auto; - height: 0.7rem; - width: 0.7rem; -} - -.row-menu-label-name { - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Checkmark only visible once the label is applied to the row. */ -.row-menu-check { - flex: 0 0 auto; - opacity: 0; -} - -.row-menu-label.applied .row-menu-check { - opacity: 1; -} - -/* System folders (Keep / Deleted): pinned at the bottom of the sidebar, - below the scrolling source list. */ -.system-folders { - border-top: 1px solid var(--grid-line); - display: flex; - flex-direction: column; - flex-shrink: 0; - gap: 0.25rem; /* space Keep / Deleted apart */ - padding: 0.4rem 0.6rem 0.3rem; -} - -/* "Special" sidebar entries — "All sources" and the Keep/Deleted folders — - share a distinct look (bold + subtle blue tint) to set them apart from the - individual sources. */ -.all-sources-link, -.folder-link { - font-weight: 600; -} - -.all-sources-link:not(.selected), -.folder-link:not(.selected) { - background: rgba(74, 110, 168, 0.14); -} - -.all-sources-link:not(.selected):hover, -.folder-link:not(.selected):hover { - background: rgba(74, 110, 168, 0.22); -} - -.empty-trash-button { - background: var(--danger-bg); - border: 1px solid var(--danger-text); - border-radius: 6px; - color: var(--danger-text); - cursor: pointer; - font-size: 0.78rem; - padding: 0.2rem 0.55rem; -} - .api-key-create { align-items: end; display: flex; @@ -1240,32 +1060,6 @@ kbd { gap: 0.3rem; } -.password-change { - display: flex; - flex-direction: column; - gap: 0.85rem; - max-width: 22rem; -} - -.password-change .form-field { - display: flex; - flex-direction: column; - font-weight: 600; - gap: 0.3rem; -} - -.password-change button { - align-self: start; -} - -.form-errors { - color: var(--danger, #d1453b); - font-weight: 400; - list-style: none; - margin: 0.15rem 0 0; - padding: 0; -} - .api-key-table { border-collapse: collapse; width: 100%; diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html deleted file mode 100644 index 37c9ed1..0000000 --- a/templates/artifacts/_artifact_rows.html +++ /dev/null @@ -1,42 +0,0 @@ -{% for artifact in artifacts %} - - - - {{ artifact.title }} - {% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %} - K - 🔗 - {% if artifact.label_chips %} - - {% for label in artifact.label_chips %} - {{ label.name }} - {% endfor %} - - {% endif %} - - {{ artifact.source }} - - {% if in_trash %} - ↩ - {% else %} - 🗑 - ⋯ - {% endif %} - - -{% endfor %} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 63971f4..111fb86 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,10 +1,43 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index eedd25d..55bfdb7 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -27,7 +27,6 @@ data-selected-kind="{{ selected_kind }}" data-title-query="{{ title_query }}" data-selected-view="{{ selected_view }}" - data-folder="{{ selected_folder }}" data-window-start="{{ window_start|date:'Y-m-d' }}" data-older-count="{{ older_count }}">
- -