Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a354809a | |||
| fcd7da853f |
19 changed files with 582 additions and 43 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -9,6 +9,24 @@ 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
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
- 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`.
|
||||
|
|
|
|||
29
artifacts/migrations/0017_label.py
Normal file
29
artifacts/migrations/0017_label.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# 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,6 +96,23 @@ 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,6 +10,7 @@ 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"),
|
||||
|
|
@ -28,6 +29,7 @@ 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,8 +15,9 @@ import nh3
|
|||
import openpyxl
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth import update_session_auth_hash, 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
|
||||
|
|
@ -36,7 +37,7 @@ from rest_framework.views import APIView
|
|||
|
||||
from .demo_data import create_demo_artifacts
|
||||
from .forms import AgentFeedLoginForm
|
||||
from .models import ApiKey, Artifact, Asset, TagColor
|
||||
from .models import ApiKey, Artifact, Asset, Label, TagColor
|
||||
from .storage import (
|
||||
QuotaExceeded,
|
||||
SIDECAR_SUFFIXES,
|
||||
|
|
@ -625,6 +626,7 @@ def feed(request):
|
|||
"today": today,
|
||||
"title_query": title_query,
|
||||
"kind_choices": Artifact.KIND_CHOICES,
|
||||
"label_palette": request.user.labels.all(),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -639,6 +641,31 @@ 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):
|
||||
|
|
@ -1507,10 +1534,25 @@ 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()},
|
||||
{
|
||||
"active_section": "account",
|
||||
"key_count": request.user.api_keys.count(),
|
||||
"password_form": password_form,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1540,6 +1582,49 @@ 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.0"
|
||||
__version__ = "0.7.1"
|
||||
|
|
|
|||
|
|
@ -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-token>" }
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||
```
|
||||
|
||||
## 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-token>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-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-token>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-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-token>" `
|
||||
-H "Authorization: Bearer <your-personal-key>" `
|
||||
-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-token>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-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-token>" }
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||
```
|
||||
|
||||
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-token>
|
||||
Authorization: Bearer <your-personal-key>
|
||||
```
|
||||
|
||||
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-token>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
|
@ -241,7 +241,7 @@ Endpoint:
|
|||
```http
|
||||
POST /api/artifacts
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <your-personal-token>
|
||||
Authorization: Bearer <your-personal-key>
|
||||
```
|
||||
|
||||
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-token>" `
|
||||
-H "Authorization: Bearer <your-personal-key>" `
|
||||
-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-token>
|
||||
Authorization: Bearer <your-personal-key>
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|
|
@ -392,7 +392,7 @@ Endpoint:
|
|||
```http
|
||||
PATCH /api/artifacts/{id}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-personal-token>
|
||||
Authorization: Bearer <your-personal-key>
|
||||
```
|
||||
|
||||
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-token>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
|
@ -449,7 +449,7 @@ Endpoint:
|
|||
|
||||
```http
|
||||
DELETE /api/artifacts/{id}
|
||||
Authorization: Bearer <your-personal-token>
|
||||
Authorization: Bearer <your-personal-key>
|
||||
```
|
||||
|
||||
**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-token>"
|
||||
-H "Authorization: Bearer <your-personal-key>"
|
||||
```
|
||||
|
||||
## 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 token to post through:
|
||||
a personal API key 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-token>" \
|
||||
-H "Authorization: Bearer <your-personal-key>" \
|
||||
-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-token
|
||||
AGENTFEED_TOKEN=your-personal-api-key
|
||||
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 token (Settings -> API Keys in the app);
|
||||
# AGENTFEED_TOKEN is your personal per-user API key (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 token from "
|
||||
"error: AGENTFEED_TOKEN is not set. Get your personal API key from "
|
||||
f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1144,6 +1144,48 @@ kbd {
|
|||
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 {
|
||||
|
|
@ -1198,6 +1240,32 @@ 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%;
|
||||
|
|
|
|||
|
|
@ -47,22 +47,16 @@
|
|||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{% if keep_count or trash_count %}
|
||||
<nav class="system-folders" aria-label="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>
|
||||
<span class="source-count">{{ keep_count }}</span>
|
||||
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
|
||||
</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>
|
||||
<span class="source-count">{{ trash_count }}</span>
|
||||
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
||||
|
|
@ -152,6 +146,18 @@
|
|||
<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>
|
||||
|
|
@ -418,13 +424,17 @@
|
|||
|
||||
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((Number(badge.textContent) || 0) + delta);
|
||||
badge.textContent = String(next);
|
||||
};
|
||||
|
||||
const addSourceLink = (name, count) => {
|
||||
|
|
@ -553,11 +563,22 @@
|
|||
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));
|
||||
|
|
@ -589,8 +610,62 @@
|
|||
} 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();
|
||||
|
|
|
|||
|
|
@ -10,4 +10,27 @@
|
|||
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,6 +29,7 @@
|
|||
<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>
|
||||
|
|
|
|||
54
templates/artifacts/settings_labels.html
Normal file
54
templates/artifacts/settings_labels.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{% 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 %}
|
||||
54
tests/views/test_settings_account.py
Normal file
54
tests/views/test_settings_account.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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"))
|
||||
113
tests/views/test_settings_labels.py
Normal file
113
tests/views/test_settings_labels.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue