Compare commits
No commits in common. "main" and "v0.4.0" have entirely different histories.
28 changed files with 97 additions and 1518 deletions
116
CHANGELOG.md
116
CHANGELOG.md
|
|
@ -9,112 +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
|
||||
|
|
@ -261,15 +155,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
|
||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...HEAD
|
||||
[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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ The core product principle is: agents publish content, not files. The server own
|
|||
- 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`.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,6 @@ urlpatterns = [
|
|||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
|
||||
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
|
||||
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
|
||||
path("a/<str:artifact_id>/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/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||
|
|
@ -29,7 +24,6 @@ urlpatterns = [
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -15,12 +15,11 @@ 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
|
||||
|
|
@ -37,7 +36,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 +44,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,
|
||||
|
|
@ -93,7 +90,7 @@ class ArtifactListCreateView(APIView):
|
|||
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=user)
|
||||
if source := request.query_params.get("source"):
|
||||
artifacts = artifacts.filter(source=source)
|
||||
if label := request.query_params.get("label"):
|
||||
|
|
@ -234,7 +231,7 @@ class ArtifactDetailView(APIView):
|
|||
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=user)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def patch(self, request, artifact_id):
|
||||
|
|
@ -242,7 +239,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,13 +298,9 @@ 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)
|
||||
|
||||
|
||||
|
|
@ -325,7 +318,7 @@ class ArtifactRawView(APIView):
|
|||
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=user)
|
||||
|
||||
raw_path = raw_artifact_path(artifact)
|
||||
if raw_path and raw_path.exists():
|
||||
|
|
@ -523,35 +516,16 @@ 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.
|
||||
# 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]
|
||||
|
|
@ -566,8 +540,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 +554,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",
|
||||
|
|
@ -610,10 +578,6 @@ def feed(request):
|
|||
"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 +590,6 @@ def feed(request):
|
|||
"today": today,
|
||||
"title_query": title_query,
|
||||
"kind_choices": Artifact.KIND_CHOICES,
|
||||
"label_palette": request.user.labels.all(),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -641,31 +604,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 +630,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", ""),
|
||||
|
|
@ -791,7 +683,7 @@ def feed_updates(request):
|
|||
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),
|
||||
Artifact.objects.filter(owner=request.user),
|
||||
selected_source,
|
||||
selected_label,
|
||||
selected_kind,
|
||||
|
|
@ -1534,25 +1426,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 +1459,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:
|
||||
|
|
|
|||
|
|
@ -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.4.0"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Also confirms your token is valid:
|
|||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" }
|
||||
```
|
||||
|
||||
## 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 <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-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 <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-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 <your-personal-key>" `
|
||||
-H "Authorization: Bearer <your-personal-token>" `
|
||||
-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 <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-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 <your-personal-key>" }
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" }
|
||||
```
|
||||
|
||||
Deletes the artifact's database row and its files. You can only delete artifacts your own token owns.
|
||||
|
|
|
|||
30
docs/api.md
30
docs/api.md
|
|
@ -108,7 +108,7 @@ Endpoint:
|
|||
```http
|
||||
POST /api/artifacts
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
|
@ -188,7 +188,7 @@ $body = @{
|
|||
Invoke-RestMethod `
|
||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||
-Method Post `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
|
@ -241,7 +241,7 @@ Endpoint:
|
|||
```http
|
||||
POST /api/artifacts
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
|
@ -288,7 +288,7 @@ PowerShell/curl example:
|
|||
|
||||
```powershell
|
||||
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||
-H "Authorization: Bearer <your-personal-key>" `
|
||||
-H "Authorization: Bearer <your-personal-token>" `
|
||||
-F "source=orion" `
|
||||
-F "source_type=agent" `
|
||||
-F "source_name=Orion Agent" `
|
||||
|
|
@ -305,7 +305,7 @@ artifact), upload it first and reference the returned URL in your content.
|
|||
```http
|
||||
POST /api/assets
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|
|
@ -392,7 +392,7 @@ Endpoint:
|
|||
```http
|
||||
PATCH /api/artifacts/{id}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
Editable fields:
|
||||
|
|
@ -438,7 +438,7 @@ $body = @{
|
|||
Invoke-RestMethod `
|
||||
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
|
||||
-Method Patch `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
|
@ -449,22 +449,18 @@ Endpoint:
|
|||
|
||||
```http
|
||||
DELETE /api/artifacts/{id}
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
**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 <your-personal-key>"
|
||||
-H "Authorization: Bearer <your-personal-token>"
|
||||
```
|
||||
|
||||
## Rendered And Raw Content
|
||||
|
|
|
|||
|
|
@ -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 <your-personal-key>" \
|
||||
-H "Authorization: Bearer <your-personal-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"source": "deploy-smoke",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1068,163 +1055,6 @@ kbd {
|
|||
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 +1070,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%;
|
||||
|
|
|
|||
|
|
@ -30,13 +30,5 @@
|
|||
{% endif %}
|
||||
</span>
|
||||
<span class="artifact-source">{{ artifact.source }}</span>
|
||||
<span class="row-actions">
|
||||
{% if in_trash %}
|
||||
<span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">↩</span>
|
||||
{% else %}
|
||||
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">🗑</span>
|
||||
<span class="row-action row-menu-trigger" role="button" tabindex="0" data-menu-trigger title="More actions" aria-label="More actions for {{ artifact.title }}">⋯</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
<aside class="source-sidebar" aria-label="Sources">
|
||||
|
|
@ -35,40 +34,25 @@
|
|||
<h2>Sources</h2>
|
||||
</div>
|
||||
<nav class="source-list">
|
||||
<a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||
<span class="source-name">All sources</span>
|
||||
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
|
||||
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% for entry in source_counts %}
|
||||
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none" data-source="{{ entry.source }}">
|
||||
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||
<span class="source-name">{{ entry.source }}</span>
|
||||
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
|
||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<nav class="system-folders" aria-label="Folders">
|
||||
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&view={{ selected_view }}&preview=none" data-folder-link="keep">
|
||||
<span class="source-name">★ Keep</span>
|
||||
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
||||
<span class="source-name">🗑 Deleted</span>
|
||||
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
||||
|
||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||
<div class="pane-bar pane-heading">
|
||||
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
{% if selected_folder == 'deleted' %}
|
||||
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
|
||||
{% else %}
|
||||
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
|
||||
{% endif %}
|
||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
||||
|
|
@ -76,7 +60,7 @@
|
|||
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||
{% else %}
|
||||
<div class="empty compact">
|
||||
<h3>No messages</h3>
|
||||
<h3>No artifacts</h3>
|
||||
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -88,7 +72,7 @@
|
|||
<section class="preview-pane">
|
||||
<header class="pane-bar preview-actions" data-actions>
|
||||
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
|
||||
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
|
||||
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
|
||||
</div>
|
||||
<nav class="view-toggle" aria-label="Preview mode">
|
||||
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
||||
|
|
@ -118,8 +102,8 @@
|
|||
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
||||
{% elif selected_artifact.kind == "binary" %}
|
||||
<div class="empty preview-empty">
|
||||
<h2>Binary Message</h2>
|
||||
<p>This message type cannot be previewed inline yet.</p>
|
||||
<h2>Binary Artifact</h2>
|
||||
<p>This artifact type cannot be previewed inline yet.</p>
|
||||
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
@ -129,10 +113,10 @@
|
|||
{% else %}
|
||||
<div class="empty preview-empty">
|
||||
{% if artifacts %}
|
||||
<h2>No message loaded</h2>
|
||||
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
|
||||
<h2>No artifact loaded</h2>
|
||||
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
||||
{% else %}
|
||||
<h2>No messages for this day</h2>
|
||||
<h2>No artifacts for this day</h2>
|
||||
<p>This day is ready for new agent output.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -141,25 +125,6 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<div class="row-menu" hidden data-row-menu>
|
||||
<button type="button" data-menu-action="keep">Keep</button>
|
||||
<button type="button" data-menu-action="open">Open</button>
|
||||
<button type="button" data-menu-action="copy">Copy link</button>
|
||||
<button type="button" data-menu-action="delete" class="danger">Delete</button>
|
||||
{% if label_palette %}
|
||||
<div class="row-menu-divider" role="separator"></div>
|
||||
<div class="row-menu-labels">
|
||||
{% for label in label_palette %}
|
||||
<button type="button" class="row-menu-label" data-label-name="{{ label.name }}" data-label-color="{{ label.color }}">
|
||||
<span class="row-menu-dot" style="background:{{ label.color }}"></span>
|
||||
<span class="row-menu-label-name">{{ label.name }}</span>
|
||||
<span class="row-menu-check" aria-hidden="true">✓</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const feed = document.querySelector(".feed");
|
||||
|
|
@ -230,8 +195,7 @@
|
|||
const artifactList = document.querySelector("[data-artifact-list]");
|
||||
// Rows grow as older pages load, so always read them live rather than caching an array.
|
||||
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
||||
// Live-queried so sources added by live updates are keyboard-navigable too.
|
||||
const sourceLinks = () => Array.from(document.querySelectorAll(".source-link"));
|
||||
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
|
||||
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
||||
|
||||
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||
|
|
@ -245,7 +209,7 @@
|
|||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
} catch {
|
||||
window.prompt("Copy message link", absoluteUrl);
|
||||
window.prompt("Copy artifact link", absoluteUrl);
|
||||
}
|
||||
const original = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
|
|
@ -354,7 +318,7 @@
|
|||
if (!actions) return;
|
||||
const previewTitle = actions.querySelector("[data-preview-title]");
|
||||
if (previewTitle) {
|
||||
previewTitle.textContent = "No message loaded";
|
||||
previewTitle.textContent = "No artifact loaded";
|
||||
previewTitle.removeAttribute("title");
|
||||
}
|
||||
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||
|
|
@ -419,335 +383,6 @@
|
|||
});
|
||||
};
|
||||
|
||||
const findSourceLink = (name) =>
|
||||
sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name);
|
||||
|
||||
const bumpSourceBadge = (link, delta) => {
|
||||
let badge = link.querySelector(".source-count");
|
||||
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
|
||||
if (next <= 0) {
|
||||
if (badge) badge.remove(); // empty folder/source shows no count badge
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "source-count";
|
||||
link.appendChild(badge);
|
||||
}
|
||||
badge.textContent = String(next);
|
||||
};
|
||||
|
||||
const addSourceLink = (name, count) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("source", name);
|
||||
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
|
||||
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||
params.set("view", feed.dataset.selectedView || "rendered");
|
||||
params.set("preview", "none");
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "source-link";
|
||||
link.href = `?${params.toString()}`;
|
||||
link.innerHTML =
|
||||
`<span class="source-name">${escapeHtml(name)}</span>` +
|
||||
`<span class="source-count">${count}</span>`;
|
||||
|
||||
// Newest activity goes to the top of the per-source list (just under
|
||||
// the pinned "All sources"), matching the recency ordering.
|
||||
const list = document.querySelector(".source-list");
|
||||
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
||||
list.insertBefore(link, firstSource);
|
||||
};
|
||||
|
||||
const moveSourceToTop = (link) => {
|
||||
const list = document.querySelector(".source-list");
|
||||
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
||||
if (firstSource !== link) list.insertBefore(link, firstSource);
|
||||
};
|
||||
|
||||
// From the newly-arrived rows: bump each source's unread count (adding a
|
||||
// sidebar entry for a source seen for the first time) plus the total.
|
||||
const registerNewSources = (rows) => {
|
||||
const counts = {};
|
||||
rows.forEach((row) => {
|
||||
const source = row.dataset.source;
|
||||
if (source) counts[source] = (counts[source] || 0) + 1;
|
||||
});
|
||||
let total = 0;
|
||||
Object.entries(counts).forEach(([source, n]) => {
|
||||
total += n;
|
||||
const existing = findSourceLink(source);
|
||||
if (existing) {
|
||||
bumpSourceBadge(existing, n);
|
||||
moveSourceToTop(existing); // most-recent activity -> top (recency order)
|
||||
} else {
|
||||
addSourceLink(source, n);
|
||||
}
|
||||
});
|
||||
const allLink = findSourceLink("All sources");
|
||||
if (allLink && total) bumpSourceBadge(allLink, total);
|
||||
};
|
||||
|
||||
// --- Delete / undelete / empty trash ---
|
||||
const updateArtifactCount = (delta) => {
|
||||
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
||||
if (!header) return;
|
||||
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
|
||||
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const bumpFolderCount = (folder, delta) => {
|
||||
const link = document.querySelector(`[data-folder-link="${folder}"]`);
|
||||
if (link) bumpSourceBadge(link, delta);
|
||||
};
|
||||
|
||||
const removeRowFromList = (row) => {
|
||||
const group = row.closest(".day-group");
|
||||
row.remove();
|
||||
if (group) {
|
||||
const remaining = group.querySelectorAll(".artifact-row").length;
|
||||
const count = group.querySelector(".day-separator-count");
|
||||
if (count) count.textContent = String(remaining);
|
||||
if (remaining === 0) group.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const softDeleteRow = async (id) => {
|
||||
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/delete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
} catch { return; }
|
||||
if (!row) return;
|
||||
if (row.classList.contains("selected")) clearPreview();
|
||||
if (row.classList.contains("unread")) decrementSourceCount(row.dataset.source);
|
||||
removeRowFromList(row);
|
||||
updateArtifactCount(-1);
|
||||
bumpFolderCount("deleted", 1); // best-effort; the folder appears on next nav if it's the first delete
|
||||
};
|
||||
|
||||
const undeleteRow = async (id) => {
|
||||
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/undelete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
} catch { return; }
|
||||
if (!row) return;
|
||||
removeRowFromList(row);
|
||||
bumpFolderCount("deleted", -1);
|
||||
// The restored item reappears in the feed on the next navigation (full render).
|
||||
};
|
||||
|
||||
const emptyTrashButton = document.querySelector("[data-empty-trash]");
|
||||
if (emptyTrashButton) {
|
||||
emptyTrashButton.addEventListener("click", async () => {
|
||||
if (!window.confirm("Permanently delete everything in Trash? This can't be undone.")) return;
|
||||
try {
|
||||
const response = await fetch("{% url 'feed-empty-trash' %}", {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
} catch { return; }
|
||||
window.location.href = window.location.pathname; // trash empty; folder gone
|
||||
});
|
||||
}
|
||||
|
||||
// --- Per-row actions menu (⋯ trigger + right-click) ---
|
||||
const rowMenu = document.querySelector("[data-row-menu]");
|
||||
let menuRowId = null;
|
||||
|
||||
const currentMenuRow = () =>
|
||||
menuRowId ? artifactList.querySelector(`.artifact-row[data-artifact-id="${menuRowId}"]`) : null;
|
||||
|
||||
const closeRowMenu = () => {
|
||||
if (rowMenu) rowMenu.hidden = true;
|
||||
menuRowId = null;
|
||||
};
|
||||
|
||||
const parseRowTags = (row) =>
|
||||
(row.dataset.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
const refreshMenuLabelState = (row) => {
|
||||
const applied = new Set(parseRowTags(row));
|
||||
rowMenu.querySelectorAll(".row-menu-label").forEach((btn) => {
|
||||
btn.classList.toggle("applied", applied.has(btn.dataset.labelName));
|
||||
});
|
||||
};
|
||||
|
||||
const openRowMenu = (row, x, y) => {
|
||||
if (!rowMenu || !row) return;
|
||||
menuRowId = row.dataset.artifactId;
|
||||
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
|
||||
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
|
||||
refreshMenuLabelState(row);
|
||||
rowMenu.hidden = false;
|
||||
const rect = rowMenu.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
|
||||
const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
|
||||
rowMenu.style.left = `${left}px`;
|
||||
rowMenu.style.top = `${top}px`;
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
try { await navigator.clipboard.writeText(text); } catch (e) { /* clipboard unavailable */ }
|
||||
};
|
||||
|
||||
const keepFromMenu = async (row, id) => {
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/keep`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
row.dataset.kept = data.is_kept ? "1" : "0";
|
||||
row.classList.toggle("kept", data.is_kept);
|
||||
const badge = row.querySelector("[data-kept-badge]");
|
||||
if (badge) badge.hidden = !data.is_kept;
|
||||
bumpFolderCount("keep", data.is_kept ? 1 : -1);
|
||||
if (feed.dataset.folder === "keep" && !data.is_kept) removeRowFromList(row);
|
||||
const keepButton = document.querySelector("[data-keep-button]");
|
||||
if (keepButton && keepButton.dataset.artifactId === id) {
|
||||
keepButton.classList.toggle("kept", data.is_kept);
|
||||
keepButton.textContent = data.is_kept ? "✓ Kept" : "Keep";
|
||||
}
|
||||
} catch (e) { /* leave as-is */ }
|
||||
};
|
||||
|
||||
const addRowChip = (row, name, color) => {
|
||||
const main = row.querySelector(".artifact-row-main");
|
||||
if (!main) return;
|
||||
let chips = main.querySelector(".tag-chips");
|
||||
if (!chips) {
|
||||
chips = document.createElement("span");
|
||||
chips.className = "tag-chips";
|
||||
main.appendChild(chips);
|
||||
}
|
||||
if ([...chips.querySelectorAll(".tag-chip")].some((c) => c.textContent === name)) return;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "tag-chip";
|
||||
chip.style.background = color;
|
||||
chip.textContent = name;
|
||||
chips.appendChild(chip);
|
||||
};
|
||||
|
||||
const removeRowChip = (row, name) => {
|
||||
const chips = row.querySelector(".tag-chips");
|
||||
if (!chips) return;
|
||||
chips.querySelectorAll(".tag-chip").forEach((c) => { if (c.textContent === name) c.remove(); });
|
||||
if (!chips.querySelector(".tag-chip")) chips.remove();
|
||||
};
|
||||
|
||||
// Toggle a palette label on the current menu row. Leaves the menu open so
|
||||
// several labels can be applied in one go.
|
||||
const toggleLabelFromMenu = async (btn) => {
|
||||
const row = currentMenuRow();
|
||||
const id = menuRowId;
|
||||
if (!row || !id) return;
|
||||
const name = btn.dataset.labelName;
|
||||
const body = new URLSearchParams();
|
||||
body.set("name", name);
|
||||
let data;
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/label`, {
|
||||
method: "POST",
|
||||
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
data = await response.json();
|
||||
} catch (e) { return; }
|
||||
row.dataset.tags = data.tags.join(", ");
|
||||
btn.classList.toggle("applied", data.applied);
|
||||
if (data.applied) addRowChip(row, data.name, data.color);
|
||||
else removeRowChip(row, data.name);
|
||||
};
|
||||
|
||||
if (rowMenu) {
|
||||
rowMenu.addEventListener("click", (event) => {
|
||||
const labelBtn = event.target.closest(".row-menu-label");
|
||||
if (labelBtn) {
|
||||
toggleLabelFromMenu(labelBtn);
|
||||
return; // keep the menu open for applying multiple labels
|
||||
}
|
||||
const btn = event.target.closest("[data-menu-action]");
|
||||
if (!btn) return;
|
||||
const row = currentMenuRow();
|
||||
const id = menuRowId;
|
||||
closeRowMenu();
|
||||
if (!row || !id) return;
|
||||
const action = btn.dataset.menuAction;
|
||||
if (action === "delete") softDeleteRow(id);
|
||||
else if (action === "keep") keepFromMenu(row, id);
|
||||
else if (action === "open") window.open(row.dataset.renderedUrl, "_blank", "noopener");
|
||||
else if (action === "copy") copyText(window.location.origin + row.dataset.renderedUrl);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (rowMenu && !rowMenu.hidden && !rowMenu.contains(event.target)) closeRowMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeRowMenu();
|
||||
});
|
||||
|
||||
// Right-click a feed row opens the same menu at the cursor.
|
||||
artifactList.addEventListener("contextmenu", (event) => {
|
||||
const row = event.target.closest(".artifact-row");
|
||||
if (!row || feed.dataset.folder) return; // normal feed only
|
||||
event.preventDefault();
|
||||
openRowMenu(row, event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
// Click a source's unread badge to mark all of that source's messages read.
|
||||
const markSourceRead = async (badge) => {
|
||||
const link = badge.closest(".source-link");
|
||||
if (!link) return;
|
||||
const isAll = link.classList.contains("all-sources-link");
|
||||
const source = link.dataset.source || "";
|
||||
const body = new URLSearchParams();
|
||||
if (!isAll && source) body.set("source", source);
|
||||
try {
|
||||
const response = await fetch("{% url 'feed-mark-read' %}", {
|
||||
method: "POST",
|
||||
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
} catch (e) { return; }
|
||||
if (isAll) {
|
||||
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
|
||||
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
|
||||
} else {
|
||||
const n = parseInt(badge.textContent, 10) || 0;
|
||||
badge.remove();
|
||||
const allBadge = document.querySelector(".all-sources-link .source-count");
|
||||
if (allBadge) {
|
||||
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
|
||||
if (t > 0) allBadge.textContent = String(t);
|
||||
else allBadge.remove();
|
||||
}
|
||||
artifactList
|
||||
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
|
||||
.forEach((r) => r.classList.remove("unread"));
|
||||
}
|
||||
};
|
||||
|
||||
const sourceListEl = document.querySelector(".source-list");
|
||||
if (sourceListEl) {
|
||||
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
|
||||
sourceListEl.addEventListener("click", (event) => {
|
||||
const badge = badgeFromEvent(event);
|
||||
if (!badge) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
markSourceRead(badge);
|
||||
});
|
||||
sourceListEl.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
const badge = badgeFromEvent(event);
|
||||
if (!badge) return;
|
||||
event.preventDefault();
|
||||
markSourceRead(badge);
|
||||
});
|
||||
}
|
||||
|
||||
const renderPreview = (row, pushState = true) => {
|
||||
navigationMode = "artifacts";
|
||||
markRead(row);
|
||||
|
|
@ -760,7 +395,7 @@
|
|||
: kind === "image"
|
||||
? `<img class="preview-image" src="${source}" alt="${title}">`
|
||||
: kind === "binary"
|
||||
? `<div class="empty preview-empty"><h2>Binary Message</h2><p>This message type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||
|
||||
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
||||
|
|
@ -775,7 +410,7 @@
|
|||
}
|
||||
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
||||
disableActions();
|
||||
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No message loaded</h2><p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p></div>`;
|
||||
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
||||
window.history.pushState({}, "", noPreviewUrl());
|
||||
const currentSource = document.querySelector(".source-link.selected");
|
||||
if (currentSource) currentSource.focus({preventScroll: true});
|
||||
|
|
@ -783,21 +418,6 @@
|
|||
|
||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||
artifactList.addEventListener("click", (event) => {
|
||||
// Row action icons (delete/undelete/menu) intercept before selection.
|
||||
const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action.hasAttribute("data-menu-trigger")) {
|
||||
const rect = action.getBoundingClientRect();
|
||||
openRowMenu(action.closest(".artifact-row"), rect.right, rect.bottom);
|
||||
} else if (action.hasAttribute("data-delete")) {
|
||||
softDeleteRow(action.getAttribute("data-delete"));
|
||||
} else {
|
||||
undeleteRow(action.getAttribute("data-undelete"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest(".artifact-row");
|
||||
if (!row) return;
|
||||
event.preventDefault();
|
||||
|
|
@ -818,13 +438,12 @@
|
|||
};
|
||||
|
||||
const moveSource = (direction) => {
|
||||
const links = sourceLinks();
|
||||
if (!links.length) return;
|
||||
if (!sourceLinks.length) return;
|
||||
navigationMode = "sources";
|
||||
const current = document.querySelector(".source-link.selected");
|
||||
const currentIndex = current ? links.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
|
||||
const nextSource = links[nextIndex];
|
||||
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
||||
const nextSource = sourceLinks[nextIndex];
|
||||
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
||||
};
|
||||
|
||||
|
|
@ -986,9 +605,7 @@
|
|||
};
|
||||
|
||||
const pollUpdates = async () => {
|
||||
// Don't poll while viewing a system folder (Deleted/Keep) — new live
|
||||
// posts don't belong in those views.
|
||||
if (livePolling || document.hidden || !liveSince || feed.dataset.folder) return;
|
||||
if (livePolling || document.hidden || !liveSince) return;
|
||||
livePolling = true;
|
||||
try {
|
||||
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
||||
|
|
@ -1006,14 +623,13 @@
|
|||
node = node.nextElementSibling;
|
||||
}
|
||||
markArrived(inserted);
|
||||
registerNewSources(inserted); // update sidebar: counts + any new source
|
||||
liveSince = data.newest;
|
||||
const dayCount = group.querySelector(".day-separator-count");
|
||||
if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count);
|
||||
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
||||
if (header) {
|
||||
const total = (parseInt(header.textContent, 10) || 0) + data.count;
|
||||
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
||||
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore; retry next tick
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
</div>
|
||||
|
||||
<p>A self-hosted, multi-user journal where AI agents, scripts, or people
|
||||
publish messages — reports, HTML, decks, images, PDFs, JSON, CSV — and each
|
||||
publish artifacts — reports, HTML, decks, images, PDFs, JSON, CSV — and each
|
||||
logged-in user browses their own time-ordered, label-filterable feed. The
|
||||
server owns IDs, styling, and storage; agents publish content, not files.</p>
|
||||
|
||||
<p class="storage-note">Post to it with a personal API key from
|
||||
Settings → API Keys; the current API capabilities (including
|
||||
this version) are available at <code>/api/capabilities</code>.</p>
|
||||
<p class="storage-note">Post to it with a personal API token from
|
||||
Settings → Account; the current API capabilities (including this
|
||||
version) are available at <code>/api/capabilities</code>.</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -10,27 +10,4 @@
|
|||
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
||||
</p>
|
||||
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
||||
|
||||
<h3>Change password</h3>
|
||||
<p>Enter your current password, then choose a new one.</p>
|
||||
<form method="post" class="password-change">
|
||||
{% csrf_token %}
|
||||
{% if password_form.non_field_errors %}
|
||||
<ul class="form-errors">
|
||||
{% for error in password_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% for field in password_form %}
|
||||
<label class="form-field">
|
||||
<span>{{ field.label }}</span>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<ul class="form-errors">
|
||||
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@
|
|||
<span class="settings-nav-heading">You</span>
|
||||
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
|
||||
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
|
||||
<a class="{% if active_section == 'labels' %}selected{% endif %}" href="{% url 'settings-labels' %}">Labels</a>
|
||||
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
|
||||
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
{% block settings_content %}
|
||||
<h2>Demo Data</h2>
|
||||
<p>Generate one sample message of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
||||
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Generate Demo Data</button>
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
{% extends "artifacts/settings_base.html" %}
|
||||
|
||||
{% block settings_content %}
|
||||
<h2>Labels</h2>
|
||||
<p>
|
||||
Build a palette of labels you can apply to any message from its ⋯ menu
|
||||
in the feed. A label is just a coloured tag — applying one adds it to the
|
||||
message and it shows as a chip next to the title. Deleting a label here only
|
||||
removes it from this palette; messages that already carry it keep the chip.
|
||||
</p>
|
||||
|
||||
<h3>Add a label</h3>
|
||||
<form method="post" class="label-create">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="create">
|
||||
<label>Name <input type="text" name="name" placeholder="e.g. follow-up" required autocomplete="off" maxlength="40"></label>
|
||||
<label>Colour <input type="color" name="color" value="#4a6ea8"></label>
|
||||
<button type="submit">Add label</button>
|
||||
</form>
|
||||
|
||||
<h3>Your labels</h3>
|
||||
{% if labels %}
|
||||
<table class="label-table">
|
||||
<thead>
|
||||
<tr><th>Label</th><th>Colour</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for label in labels %}
|
||||
<tr>
|
||||
<td><span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span></td>
|
||||
<td>
|
||||
<form method="post" class="label-recolor">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="hidden" name="label_id" value="{{ label.pk }}">
|
||||
<input type="color" name="color" value="{{ label.color }}" onchange="this.form.submit()" title="Change colour">
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Remove "{{ label.name|escapejs }}" from your palette? Messages already labelled keep the chip.');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="label_id" value="{{ label.pk }}">
|
||||
<button type="submit" class="label-delete">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No labels yet. Add one above, then pick it from a message's ⋯ menu.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -11,14 +11,14 @@
|
|||
</p>
|
||||
|
||||
<ul class="storage-facts">
|
||||
<li>{{ artifact_count }} message{{ artifact_count|pluralize }} total</li>
|
||||
<li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
|
||||
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted</li>
|
||||
</ul>
|
||||
|
||||
<p class="storage-note">
|
||||
When your quota fills, the oldest <strong>unkept</strong> messages are deleted
|
||||
automatically to make room for new ones. Click <strong>Keep</strong> on a message
|
||||
to protect it. If the quota is full of kept messages, new posts are rejected until
|
||||
When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
|
||||
automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
|
||||
to protect it. If the quota is full of kept artifacts, new posts are rejected until
|
||||
you free space.
|
||||
</p>
|
||||
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<h3>Admin</h3>
|
||||
<ul class="storage-facts">
|
||||
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
|
||||
<li>Free space on the storage disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
|
||||
<li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
|
||||
</ul>
|
||||
{% if total_allocated > disk_free %}
|
||||
<p class="storage-warning">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from artifacts.models import ApiKey, Artifact
|
|||
User = get_user_model()
|
||||
|
||||
|
||||
@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests
|
||||
class ArtifactAPITestCase(APITestCase):
|
||||
"""Exercises the ingest/list/detail API against a real (temp) filesystem
|
||||
and a real (test) database, the way an agent posting over HTTP would."""
|
||||
|
|
@ -335,8 +334,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
# -- delete (DELETE) --------------------------------------------------
|
||||
|
||||
def test_delete_soft_deletes_to_trash(self):
|
||||
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
|
||||
def test_delete_removes_artifact_and_files(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
artifact = Artifact.objects.get(pk=artifact_id)
|
||||
stored_path = self.artifacts_root / artifact.path
|
||||
|
|
@ -346,12 +344,9 @@ class ArtifactAPITestCase(APITestCase):
|
|||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
# Row still exists, marked deleted; files kept on disk until purge.
|
||||
artifact.refresh_from_db()
|
||||
self.assertIsNotNone(artifact.deleted_at)
|
||||
self.assertTrue(stored_path.exists())
|
||||
# But it's gone from the API's list/detail (hidden like the feed).
|
||||
self.assertEqual(self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)).data, [])
|
||||
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
|
||||
self.assertFalse(stored_path.exists())
|
||||
self.assertFalse(stored_path.with_suffix(".md").exists())
|
||||
|
||||
def test_delete_requires_token(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
|
@ -367,8 +362,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||
|
||||
def test_delete_is_idempotent(self):
|
||||
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
|
||||
def test_delete_twice_second_call_404s(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
first = self.client.delete(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
|
|
@ -378,7 +372,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
second = self.client.delete(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(second.status_code, 204)
|
||||
self.assertEqual(second.status_code, 404)
|
||||
|
||||
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ChangePasswordViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="oldpassword")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _post(self, old, new1, new2):
|
||||
return self.client.post(
|
||||
reverse("settings-account"),
|
||||
{"old_password": old, "new_password1": new1, "new_password2": new2},
|
||||
)
|
||||
|
||||
def test_get_renders_form(self):
|
||||
response = self.client.get(reverse("settings-account"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Change password")
|
||||
self.assertContains(response, "old_password")
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("settings-account"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_valid_change_updates_password(self):
|
||||
response = self._post("oldpassword", "brandnewpass", "brandnewpass")
|
||||
self.assertRedirects(response, reverse("settings-account"))
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("brandnewpass"))
|
||||
self.assertFalse(self.user.check_password("oldpassword"))
|
||||
|
||||
def test_valid_change_keeps_session(self):
|
||||
self._post("oldpassword", "brandnewpass", "brandnewpass")
|
||||
# Session must survive the password change (update_session_auth_hash),
|
||||
# so a follow-up authenticated request still succeeds.
|
||||
response = self.client.get(reverse("settings-account"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_wrong_current_password_rejected(self):
|
||||
response = self._post("WRONG", "brandnewpass", "brandnewpass")
|
||||
self.assertEqual(response.status_code, 200) # re-render with errors, no redirect
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("oldpassword"))
|
||||
|
||||
def test_mismatched_new_password_rejected(self):
|
||||
response = self._post("oldpassword", "brandnewpass", "different")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("oldpassword"))
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from artifacts.models import Artifact, Label, TagColor
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LabelPaletteTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="pw")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def url(self):
|
||||
return reverse("settings-labels")
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get(self.url()).status_code, 302)
|
||||
|
||||
def test_create_label_sets_palette_and_tagcolor(self):
|
||||
self.client.post(self.url(), {"action": "create", "name": "follow-up", "color": "#112233"})
|
||||
label = Label.objects.get(user=self.user, name="follow-up")
|
||||
self.assertEqual(label.color, "#112233")
|
||||
self.assertEqual(TagColor.objects.get(name="follow-up").color, "#112233")
|
||||
|
||||
def test_create_rejects_bad_color(self):
|
||||
self.client.post(self.url(), {"action": "create", "name": "x", "color": "red"})
|
||||
self.assertFalse(Label.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_create_requires_name(self):
|
||||
self.client.post(self.url(), {"action": "create", "name": " ", "color": "#112233"})
|
||||
self.assertFalse(Label.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_duplicate_name_same_user_rejected(self):
|
||||
Label.objects.create(user=self.user, name="dup", color="#111111")
|
||||
self.client.post(self.url(), {"action": "create", "name": "dup", "color": "#222222"})
|
||||
self.assertEqual(Label.objects.filter(user=self.user, name="dup").count(), 1)
|
||||
|
||||
def test_same_name_different_user_allowed(self):
|
||||
other = User.objects.create_user(username="bob", password="pw")
|
||||
Label.objects.create(user=other, name="shared", color="#111111")
|
||||
self.client.post(self.url(), {"action": "create", "name": "shared", "color": "#222222"})
|
||||
self.assertTrue(Label.objects.filter(user=self.user, name="shared").exists())
|
||||
|
||||
def test_recolor_updates_tagcolor(self):
|
||||
label = Label.objects.create(user=self.user, name="urgent", color="#111111")
|
||||
self.client.post(self.url(), {"action": "update", "label_id": label.pk, "color": "#ff0000"})
|
||||
label.refresh_from_db()
|
||||
self.assertEqual(label.color, "#ff0000")
|
||||
self.assertEqual(TagColor.objects.get(name="urgent").color, "#ff0000")
|
||||
|
||||
def test_delete_removes_from_palette_only(self):
|
||||
label = Label.objects.create(user=self.user, name="temp", color="#111111")
|
||||
art = Artifact.objects.create(
|
||||
id="lbl00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(), path="agent/lbl00001.html", original_kind=Artifact.MARKDOWN,
|
||||
tags=["temp"],
|
||||
)
|
||||
self.client.post(self.url(), {"action": "delete", "label_id": label.pk})
|
||||
self.assertFalse(Label.objects.filter(pk=label.pk).exists())
|
||||
art.refresh_from_db()
|
||||
self.assertEqual(art.tags, ["temp"]) # chip stays on the message
|
||||
|
||||
|
||||
class ToggleLabelTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="pw")
|
||||
self.other = User.objects.create_user(username="bob", password="pw")
|
||||
self.client.force_login(self.user)
|
||||
self.label = Label.objects.create(user=self.user, name="star", color="#abcdef")
|
||||
self.art = Artifact.objects.create(
|
||||
id="tog00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(), path="agent/tog00001.html", original_kind=Artifact.MARKDOWN,
|
||||
)
|
||||
|
||||
def toggle(self, art_id, name):
|
||||
return self.client.post(reverse("artifact-toggle-label", args=[art_id]), {"name": name})
|
||||
|
||||
def test_toggle_on_then_off(self):
|
||||
r = self.toggle(self.art.id, "star")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json()["applied"])
|
||||
self.art.refresh_from_db()
|
||||
self.assertIn("star", self.art.tags)
|
||||
|
||||
r = self.toggle(self.art.id, "star")
|
||||
self.assertFalse(r.json()["applied"])
|
||||
self.art.refresh_from_db()
|
||||
self.assertNotIn("star", self.art.tags)
|
||||
|
||||
def test_toggle_syncs_tagcolor(self):
|
||||
self.toggle(self.art.id, "star")
|
||||
self.assertEqual(TagColor.objects.get(name="star").color, "#abcdef")
|
||||
|
||||
def test_name_not_in_palette_rejected(self):
|
||||
r = self.toggle(self.art.id, "nope")
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.art.refresh_from_db()
|
||||
self.assertEqual(self.art.tags, [])
|
||||
|
||||
def test_owner_scoped(self):
|
||||
theirs = Artifact.objects.create(
|
||||
id="tog00002", owner=self.other, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(), path="agent/tog00002.html", original_kind=Artifact.MARKDOWN,
|
||||
)
|
||||
self.assertEqual(self.toggle(theirs.id, "star").status_code, 404)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(self.toggle(self.art.id, "star").status_code, 302)
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import shutil
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from artifacts.models import Artifact
|
||||
from artifacts.storage import purge_expired_trash, user_usage_bytes
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# Run the purge inline (no background thread) so its effects are deterministic.
|
||||
@override_settings(AGENTFEED_ASYNC_PURGE=False)
|
||||
class TrashKeepViewTestCase(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-trash-"))
|
||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||
cls.settings_override.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.settings_override.disable()
|
||||
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="pw")
|
||||
self.other = User.objects.create_user(username="bob", password="pw")
|
||||
self.client.force_login(self.user)
|
||||
self._n = 0
|
||||
|
||||
def make(self, owner=None, title="Note", source="agent", is_kept=False, size_bytes=1):
|
||||
self._n += 1
|
||||
return Artifact.objects.create(
|
||||
id=f"trs{self._n:05d}",
|
||||
owner=owner or self.user,
|
||||
source=source,
|
||||
title=title,
|
||||
kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(),
|
||||
path=f"{source}/trs{self._n:05d}.html",
|
||||
original_kind=Artifact.MARKDOWN,
|
||||
size_bytes=size_bytes,
|
||||
is_kept=is_kept,
|
||||
)
|
||||
|
||||
def test_delete_soft_deletes(self):
|
||||
a = self.make()
|
||||
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
a.refresh_from_db()
|
||||
self.assertIsNotNone(a.deleted_at)
|
||||
|
||||
def test_delete_requires_login(self):
|
||||
self.client.logout()
|
||||
a = self.make()
|
||||
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_delete_scoped_to_owner(self):
|
||||
a = self.make(owner=self.other)
|
||||
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 404)
|
||||
a.refresh_from_db()
|
||||
self.assertIsNone(a.deleted_at)
|
||||
|
||||
def test_deleted_hidden_from_feed_shown_in_trash(self):
|
||||
a = self.make(title="ThrowawayThing")
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertNotIn("ThrowawayThing", self.client.get("/").content.decode())
|
||||
self.assertIn("ThrowawayThing", self.client.get("/?folder=deleted").content.decode())
|
||||
|
||||
def test_undelete_restores(self):
|
||||
a = self.make(title="ComeBack")
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
r = self.client.post(reverse("artifact-undelete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
a.refresh_from_db()
|
||||
self.assertIsNone(a.deleted_at)
|
||||
self.assertIn("ComeBack", self.client.get("/").content.decode())
|
||||
|
||||
def test_empty_trash_purges(self):
|
||||
a = self.make()
|
||||
b = self.make()
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.client.post(reverse("artifact-delete", args=[b.id]))
|
||||
r = self.client.post(reverse("feed-empty-trash"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()["purged"], 2)
|
||||
self.assertFalse(Artifact.objects.filter(pk__in=[a.id, b.id]).exists())
|
||||
|
||||
def test_deleted_is_off_quota(self):
|
||||
a = self.make(size_bytes=1000)
|
||||
self.assertEqual(user_usage_bytes(self.user), 1000)
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(user_usage_bytes(self.user), 0)
|
||||
|
||||
def test_keep_folder_shows_only_kept(self):
|
||||
self.make(title="KeptOne", is_kept=True)
|
||||
self.make(title="NormalOne")
|
||||
page = self.client.get("/?folder=keep").content.decode()
|
||||
self.assertIn("KeptOne", page)
|
||||
self.assertNotIn("NormalOne", page)
|
||||
|
||||
def test_purge_expired_trash_removes_old_only(self):
|
||||
old = self.make()
|
||||
recent = self.make()
|
||||
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
|
||||
Artifact.objects.filter(pk=recent.id).update(deleted_at=timezone.now())
|
||||
purged = purge_expired_trash()
|
||||
self.assertEqual(purged, 1)
|
||||
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||
self.assertTrue(Artifact.objects.filter(pk=recent.id).exists())
|
||||
|
||||
def test_delete_triggers_expired_purge(self):
|
||||
old = self.make()
|
||||
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
|
||||
fresh = self.make()
|
||||
# Deleting fresh runs the purge (inline here) -> the expired one goes,
|
||||
# the freshly-trashed one stays.
|
||||
self.client.post(reverse("artifact-delete", args=[fresh.id]))
|
||||
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||
self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())
|
||||
|
||||
def test_mark_all_read_across_sources(self):
|
||||
self.make(source="a")
|
||||
self.make(source="b")
|
||||
r = self.client.post(reverse("feed-mark-read"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()["marked"], 2)
|
||||
self.assertFalse(Artifact.objects.filter(owner=self.user, read_at__isnull=True).exists())
|
||||
|
||||
def test_mark_all_read_scoped_to_source(self):
|
||||
a = self.make(source="hermes")
|
||||
b = self.make(source="janus")
|
||||
self.assertEqual(self.client.post(reverse("feed-mark-read"), {"source": "hermes"}).json()["marked"], 1)
|
||||
a.refresh_from_db()
|
||||
b.refresh_from_db()
|
||||
self.assertIsNotNone(a.read_at)
|
||||
self.assertIsNone(b.read_at)
|
||||
|
||||
def test_mark_all_read_owner_scoped(self):
|
||||
theirs = self.make(owner=self.other)
|
||||
self.client.post(reverse("feed-mark-read"))
|
||||
theirs.refresh_from_db()
|
||||
self.assertIsNone(theirs.read_at)
|
||||
|
||||
def test_mark_all_read_excludes_trashed(self):
|
||||
a = self.make()
|
||||
Artifact.objects.filter(pk=a.id).update(deleted_at=timezone.now())
|
||||
self.assertEqual(self.client.post(reverse("feed-mark-read")).json()["marked"], 0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue