Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
23 changed files with 89 additions and 810 deletions
62
CHANGELOG.md
62
CHANGELOG.md
|
|
@ -9,62 +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
|
||||
|
|
@ -261,11 +205,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
|
||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...HEAD
|
||||
[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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ 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.
|
||||
- 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 folders hide when 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,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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -96,23 +96,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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,9 @@ 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 +27,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,
|
||||
|
|
@ -587,12 +586,10 @@ def feed(request):
|
|||
source_scoped = apply_feed_filters(
|
||||
live, "", 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()
|
||||
|
|
@ -626,7 +623,6 @@ def feed(request):
|
|||
"today": today,
|
||||
"title_query": title_query,
|
||||
"kind_choices": Artifact.KIND_CHOICES,
|
||||
"label_palette": request.user.labels.all(),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -641,31 +637,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):
|
||||
|
|
@ -723,21 +694,6 @@ def empty_trash(request):
|
|||
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."""
|
||||
|
|
@ -1534,25 +1490,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 +1523,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.6.0"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
18
docs/api.md
18
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,7 +449,7 @@ 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
|
||||
|
|
@ -464,7 +464,7 @@ There is no API endpoint to undelete or purge yet — that's app-UI-only.
|
|||
|
||||
```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);
|
||||
|
|
@ -1144,75 +1131,11 @@ kbd {
|
|||
color: var(--danger-text-plain);
|
||||
}
|
||||
|
||||
.row-menu-divider {
|
||||
/* System folders (Keep / Deleted) at the bottom of the sidebar */
|
||||
.source-folders {
|
||||
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);
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-trash-button {
|
||||
|
|
@ -1240,32 +1163,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%;
|
||||
|
|
|
|||
|
|
@ -35,27 +35,33 @@
|
|||
<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 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">
|
||||
<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 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">
|
||||
<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">
|
||||
{% if keep_count or trash_count %}
|
||||
<div class="source-folders">
|
||||
{% if keep_count %}
|
||||
<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 %}
|
||||
<span class="source-count">{{ keep_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if trash_count %}
|
||||
<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 %}
|
||||
<span class="source-count">{{ trash_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
|
@ -67,7 +73,7 @@
|
|||
{% 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>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
@ -76,7 +82,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 +94,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 +124,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 +135,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>
|
||||
|
|
@ -146,18 +152,6 @@
|
|||
<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>
|
||||
|
|
@ -245,7 +239,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 +348,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) => {
|
||||
|
|
@ -424,17 +418,13 @@
|
|||
|
||||
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";
|
||||
badge.textContent = "0";
|
||||
link.appendChild(badge);
|
||||
}
|
||||
badge.textContent = String(next);
|
||||
badge.textContent = String((Number(badge.textContent) || 0) + delta);
|
||||
};
|
||||
|
||||
const addSourceLink = (name, count) => {
|
||||
|
|
@ -453,17 +443,15 @@
|
|||
`<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.
|
||||
// Insert alphabetically among the per-source links (after "All sources").
|
||||
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);
|
||||
const others = sourceLinks().filter(
|
||||
(l) => l.querySelector(".source-name")?.textContent.trim() !== "All sources",
|
||||
);
|
||||
const after = others.find(
|
||||
(l) => (l.querySelector(".source-name")?.textContent.trim() || "") > name,
|
||||
);
|
||||
list.insertBefore(link, after || null);
|
||||
};
|
||||
|
||||
// From the newly-arrived rows: bump each source's unread count (adding a
|
||||
|
|
@ -478,12 +466,8 @@
|
|||
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);
|
||||
}
|
||||
if (existing) bumpSourceBadge(existing, n);
|
||||
else addSourceLink(source, n);
|
||||
});
|
||||
const allLink = findSourceLink("All sources");
|
||||
if (allLink && total) bumpSourceBadge(allLink, total);
|
||||
|
|
@ -494,7 +478,7 @@
|
|||
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"}`;
|
||||
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const bumpFolderCount = (folder, delta) => {
|
||||
|
|
@ -563,22 +547,11 @@
|
|||
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));
|
||||
|
|
@ -610,62 +583,8 @@
|
|||
} 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();
|
||||
|
|
@ -695,59 +614,6 @@
|
|||
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 +626,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 +641,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});
|
||||
|
|
@ -1013,7 +879,7 @@
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -128,31 +128,3 @@ class TrashKeepViewTestCase(TestCase):
|
|||
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