Compare commits
No commits in common. "HEAD" and "v0.3.0" have entirely different histories.
30 changed files with 172 additions and 1988 deletions
157
CHANGELOG.md
157
CHANGELOG.md
|
|
@ -9,150 +9,6 @@ former name, "Daybook".)
|
|||
|
||||
_Nothing yet._
|
||||
|
||||
## [0.7.1] — 2026-09-08
|
||||
|
||||
### Added
|
||||
- **Custom labels.** Curate a personal palette of coloured labels in
|
||||
**Settings → Labels**, then apply them to any message from its ⋯ menu (or
|
||||
right-click) — the menu stays open so you can apply several at once, with a
|
||||
checkmark showing which are applied. Labels are unified with tags: applying
|
||||
one adds it to the message's tags and renders as a chip. Backed by
|
||||
`POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip
|
||||
colours sync to the global `TagColor`.
|
||||
- **Change your password in-app** from **Settings → Account**: enter the current
|
||||
password, then a new one. Uses Django's `PasswordChangeForm` and keeps the
|
||||
session logged in after the change.
|
||||
|
||||
### Changed
|
||||
- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when
|
||||
empty); the count badge only appears when the folder is non-empty.
|
||||
|
||||
## [0.7.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Mark all as read.** Click a source's unread badge in the sidebar to mark
|
||||
all of that source's messages read (clears the badge); click the **All
|
||||
sources** total to mark everything read. Owner-scoped, excludes trashed; any
|
||||
visible feed rows update too. Backed by `POST /feed/mark-read`.
|
||||
|
||||
## [0.6.3] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed
|
||||
count, empty states, preview, About, Storage). The model, API
|
||||
(`/api/artifacts`), and posting skill keep "artifact" as the internal term —
|
||||
no contract change for agents.
|
||||
- Sidebar: the Keep/Deleted folders now have spacing between them and share the
|
||||
"All sources" bold+blue-tint styling (they're special folders).
|
||||
|
||||
### Fixed
|
||||
- About page now points to Settings → API Keys (was the stale "Settings →
|
||||
Account / personal API token").
|
||||
|
||||
## [0.6.2] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
- Source sidebar is now ordered by **most recent message** (Telegram-style)
|
||||
instead of alphabetically; "All sources" stays pinned at the top. Live
|
||||
updates move a source to the top when it receives a new message.
|
||||
|
||||
## [0.6.1] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
- Sidebar: the **Keep / Deleted folders are pinned to the bottom** — only the
|
||||
source list scrolls, so the folders stay reachable no matter how many sources
|
||||
you have. **"All sources"** is now visually distinct (bold + subtle blue tint)
|
||||
as the primary entry. (Internal: `.source-folders` class renamed to
|
||||
`.system-folders`.)
|
||||
|
||||
## [0.6.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Per-artifact actions menu.** A **⋯** button appears on row hover, and
|
||||
**right-clicking** a feed row opens the same floating menu — **Keep /
|
||||
Open / Copy link / Delete**. Dismisses on click-away or Escape, edge-flips
|
||||
to stay on-screen. The standalone one-click Delete icon stays; the Trash
|
||||
view keeps its dedicated Undelete. (Share and Add-label will join the menu
|
||||
in later slices.)
|
||||
|
||||
## [0.5.1] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Automatic Trash purge.** Trashed artifacts older than a ~3-day retention
|
||||
window are now permanently removed automatically. It's cron-less: a delete
|
||||
triggers a sweep that runs on a **background thread** (off the request, so
|
||||
the click never blocks), tolerant of concurrent runs. Manual "Empty Trash"
|
||||
still works for immediate reclaim. Controlled by `AGENTFEED_ASYNC_PURGE`
|
||||
(on by default; tests run it inline).
|
||||
|
||||
## [0.5.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable
|
||||
**Trash** instead of removing it. Feed rows get a hover **Delete** icon; a
|
||||
**"Deleted"** folder at the bottom of the sidebar lists trashed items with a
|
||||
hover **Undelete**, plus an **Empty Trash** button to purge permanently.
|
||||
Trashed items are **off-quota** (deleting frees quota immediately) and
|
||||
eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too
|
||||
(idempotent); trashed items are hidden from all API reads.
|
||||
- **Keep folder.** A sidebar folder listing kept artifacts (a view over the
|
||||
existing `is_kept` flag), mirroring Trash. Both hide when empty.
|
||||
- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`,
|
||||
`/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views.
|
||||
|
||||
### Changed
|
||||
- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above.
|
||||
|
||||
### Note
|
||||
- Permanent purge is manual (**Empty Trash**) in this release; automatic
|
||||
purge-on-delete (background, ~3-day retention) is the planned fast-follow.
|
||||
|
||||
### Changed
|
||||
- Live feed updates now also keep the **source sidebar** current: as new posts
|
||||
arrive, per-source and total unread counts bump, and a source posting for the
|
||||
first time is added to the list (alphabetically) — all derived client-side
|
||||
from the polled rows, no extra request. Sources added live stay
|
||||
keyboard-navigable. Caveat: while filtered to a single source, *other* new
|
||||
sources still only appear on reload (the poll is scoped to the selected view).
|
||||
|
||||
## [0.4.0] — 2026-09-04
|
||||
|
||||
### Added
|
||||
- **Live feed updates.** New artifacts now appear at the top of the feed
|
||||
automatically, without a manual refresh — the browser polls a lightweight
|
||||
`GET /feed/updates` every 5s (paused while the tab is hidden, one immediate
|
||||
poll on refocus). Owner-scoped and **respects the active source/label/kind/q
|
||||
filters**, so a new post that doesn't match your current view won't show. The
|
||||
currently selected/previewed artifact stays put and visibly selected. Fresh
|
||||
rows display "now" and age into their real time after a few minutes.
|
||||
(Chosen over SSE/WebSockets deliberately: zero new infrastructure on the
|
||||
sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.)
|
||||
|
||||
### Changed
|
||||
- Extracted the feed row markup into a reusable `_artifact_rows.html` partial
|
||||
(shared by the day-group render and the live-updates endpoint).
|
||||
|
||||
## [0.3.2] — 2026-09-04
|
||||
|
||||
### Added
|
||||
- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the
|
||||
metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an
|
||||
agent can read back and verify exactly what it posted: exact source text for
|
||||
`markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`,
|
||||
`404` for render-only `html_fragment`/`deck`. The metadata response now
|
||||
includes `api_raw_url`, and `/api/capabilities` advertises the route.
|
||||
|
||||
## [0.3.1] — 2026-09-04
|
||||
|
||||
### Changed
|
||||
- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`)
|
||||
now accept a bearer token as well as a browser session, owner-scoped — an
|
||||
agent can read back what it posted with the same token. Unauthenticated API
|
||||
requests return a JSON `403` instead of a 302 redirect to the browser login.
|
||||
- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) —
|
||||
they expose no user data, so an agent can fetch the contract and posting
|
||||
instructions before it has a key.
|
||||
|
||||
## [0.3.0] — 2026-09-04
|
||||
|
||||
### Changed
|
||||
|
|
@ -261,18 +117,7 @@ First tagged release.
|
|||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
||||
|
||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD
|
||||
[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0
|
||||
[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0
|
||||
[0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0
|
||||
[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0
|
||||
[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1
|
||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...HEAD
|
||||
[0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@ The core product principle is: agents publish content, not files. The server own
|
|||
- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account.
|
||||
- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself.
|
||||
- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either.
|
||||
- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key.
|
||||
- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions.
|
||||
- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient.
|
||||
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
|
||||
- `TagColor` (label color overrides) stays global/shared across all users.
|
||||
- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both the Keep and Deleted folders are always shown in the sidebar (even when empty); the count badge only appears when the folder is non-empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately.
|
||||
- Use random 8-character artifact IDs.
|
||||
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
||||
- Preserve original markdown as `.md` alongside rendered `.html`.
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 5.2.15 on 2026-09-07 11:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0015_migrate_tokens_to_apikeys'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Generated by Django 5.2.15 on 2026-09-08 13:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0016_artifact_deleted_at'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Label',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=40)),
|
||||
('color', models.CharField(max_length=20)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
'unique_together': {('user', 'name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -60,9 +60,6 @@ class Artifact(models.Model):
|
|||
metadata = models.JSONField(default=dict, blank=True)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
is_kept = models.BooleanField(default=False)
|
||||
# Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota,
|
||||
# recoverable via undelete, purged by Empty Trash). NULL = live.
|
||||
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
size_bytes = models.BigIntegerField(default=0)
|
||||
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
|
||||
# distinct, so `unique=True` still holds); a real value must be unique
|
||||
|
|
@ -77,10 +74,6 @@ class Artifact(models.Model):
|
|||
def is_shared(self):
|
||||
return bool(self.share_token)
|
||||
|
||||
@property
|
||||
def is_deleted(self):
|
||||
return self.deleted_at is not None
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
|
@ -96,23 +89,6 @@ class TagColor(models.Model):
|
|||
return f"{self.name} ({self.color})"
|
||||
|
||||
|
||||
class Label(models.Model):
|
||||
"""A user's reusable label in their personal palette. Applying one from the
|
||||
feed UI just adds its `name` to an artifact's `tags`; the chip colour lives
|
||||
in the global TagColor table (kept in sync when the palette entry is saved)."""
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels")
|
||||
name = models.CharField(max_length=40)
|
||||
color = models.CharField(max_length=20) # #RRGGBB
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "name")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.color})"
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ SUM rather than a stat of every file.
|
|||
"""
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.models import F, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
|
|
@ -72,13 +70,8 @@ def artifact_total_bytes(artifact):
|
|||
|
||||
|
||||
def user_usage_bytes(user):
|
||||
# Trashed artifacts are off-quota (deleting relieves quota immediately);
|
||||
# their files linger on disk until Empty Trash / purge, but don't count.
|
||||
artifact_bytes = (
|
||||
Artifact.objects.filter(owner=user, deleted_at__isnull=True)
|
||||
.aggregate(total=Sum("size_bytes"))
|
||||
.get("total")
|
||||
or 0
|
||||
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
)
|
||||
asset_bytes = (
|
||||
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
|
|
@ -120,10 +113,8 @@ def make_room_for(user, incoming_bytes):
|
|||
if used + incoming_bytes <= quota:
|
||||
return []
|
||||
|
||||
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
|
||||
# and already off-quota, so they're never eviction candidates.
|
||||
unkept = list(
|
||||
Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at")
|
||||
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
||||
)
|
||||
# Check whether eviction can possibly succeed before deleting anything -
|
||||
# otherwise a doomed request would still destroy every unkept artifact on
|
||||
|
|
@ -146,55 +137,6 @@ def make_room_for(user, incoming_bytes):
|
|||
return evicted
|
||||
|
||||
|
||||
def purge_trashed(user):
|
||||
"""Permanently remove all of a user's trashed artifacts (files + rows).
|
||||
Backs the "Empty Trash" action."""
|
||||
trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False))
|
||||
for artifact in trashed:
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
return len(trashed)
|
||||
|
||||
|
||||
TRASH_RETENTION = timedelta(days=3)
|
||||
|
||||
|
||||
def purge_expired_trash(now=None):
|
||||
"""Permanently remove trashed artifacts older than the retention window
|
||||
(all users). Tolerant of already-gone rows so concurrent purges are safe.
|
||||
Returns the number purged."""
|
||||
now = now or timezone.now()
|
||||
cutoff = now - TRASH_RETENTION
|
||||
expired = list(Artifact.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff))
|
||||
purged = 0
|
||||
for artifact in expired:
|
||||
try:
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
purged += 1
|
||||
except Exception:
|
||||
# Another concurrent purge got there first, or a file's already
|
||||
# gone — a purge error must never surface to anyone.
|
||||
pass
|
||||
return purged
|
||||
|
||||
|
||||
def spawn_trash_purge():
|
||||
"""Run purge_expired_trash off the request thread (cron-less: triggered by
|
||||
deletes). Falls back to inline when async is disabled (tests)."""
|
||||
if not getattr(settings, "AGENTFEED_ASYNC_PURGE", True):
|
||||
purge_expired_trash()
|
||||
return
|
||||
|
||||
def _run():
|
||||
try:
|
||||
purge_expired_trash()
|
||||
finally:
|
||||
connection.close() # threads get their own connection; don't leak it
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
|
||||
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,15 +6,9 @@ from . import views
|
|||
urlpatterns = [
|
||||
path("", views.feed, name="feed"),
|
||||
path("feed/older", views.feed_older, name="feed-older"),
|
||||
path("feed/updates", views.feed_updates, name="feed-updates"),
|
||||
path("a/<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"),
|
||||
|
|
@ -25,11 +19,9 @@ urlpatterns = [
|
|||
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
|
||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||
path("api/artifacts/<str:artifact_id>/raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"),
|
||||
path("settings/", views.settings_home, name="settings-home"),
|
||||
path("settings/account/", views.settings_account, name="settings-account"),
|
||||
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
|
||||
path("settings/labels/", views.settings_labels, name="settings-labels"),
|
||||
path("settings/users/", views.settings_users, name="settings-users"),
|
||||
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
|
||||
path("settings/storage/", views.settings_storage, name="settings-storage"),
|
||||
|
|
|
|||
|
|
@ -15,18 +15,16 @@ import nh3
|
|||
import openpyxl
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash, views as auth_views
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import OperationalError, transaction
|
||||
from django.db.models import Count, Max, Q, Sum
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.defaultfilters import date as date_filter, slugify
|
||||
from django.template.loader import render_to_string
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
|
|
@ -37,7 +35,7 @@ from rest_framework.views import APIView
|
|||
|
||||
from .demo_data import create_demo_artifacts
|
||||
from .forms import AgentFeedLoginForm
|
||||
from .models import ApiKey, Artifact, Asset, Label, TagColor
|
||||
from .models import ApiKey, Artifact, Asset, TagColor
|
||||
from .storage import (
|
||||
QuotaExceeded,
|
||||
SIDECAR_SUFFIXES,
|
||||
|
|
@ -45,8 +43,6 @@ from .storage import (
|
|||
delete_artifact_files,
|
||||
disk_free_bytes,
|
||||
make_room_for,
|
||||
purge_trashed,
|
||||
spawn_trash_purge,
|
||||
sweep_orphan_assets,
|
||||
total_allocated_bytes,
|
||||
user_quota_bytes,
|
||||
|
|
@ -86,14 +82,12 @@ DEFAULT_THEMES = {
|
|||
}
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactListCreateView(APIView):
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def get(self, request):
|
||||
user = resolve_request_user(request)
|
||||
if user is None:
|
||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||
artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True)
|
||||
artifacts = Artifact.objects.filter(owner=request.user)
|
||||
if source := request.query_params.get("source"):
|
||||
artifacts = artifacts.filter(source=source)
|
||||
if label := request.query_params.get("label"):
|
||||
|
|
@ -229,12 +223,10 @@ def asset_view(request, asset_id):
|
|||
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactDetailView(APIView):
|
||||
def get(self, request, artifact_id):
|
||||
user = resolve_request_user(request)
|
||||
if user is None:
|
||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def patch(self, request, artifact_id):
|
||||
|
|
@ -242,7 +234,7 @@ class ArtifactDetailView(APIView):
|
|||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
data = request.data
|
||||
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
|
||||
if not_editable:
|
||||
|
|
@ -301,59 +293,24 @@ class ArtifactDetailView(APIView):
|
|||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
# Soft-delete: move to Trash (recoverable, off-quota) rather than
|
||||
# permanently removing. Purged later via Empty Trash. Idempotent.
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
if artifact.deleted_at is None:
|
||||
artifact.deleted_at = timezone.now()
|
||||
artifact.save(update_fields=["deleted_at"])
|
||||
spawn_trash_purge() # sweep expired trash off the request thread
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class ArtifactRawView(APIView):
|
||||
"""The artifact's full content, for read-back / verification. Same auth as
|
||||
the detail endpoint (bearer token or session), owner-scoped.
|
||||
|
||||
GET /api/artifacts/{id}/raw
|
||||
- markdown / html_full / json / csv -> exact posted source (text/plain)
|
||||
- image / pdf / binary -> the stored file bytes
|
||||
- html_fragment / deck -> 404 (render-only; no raw kept)
|
||||
"""
|
||||
|
||||
def get(self, request, artifact_id):
|
||||
user = resolve_request_user(request)
|
||||
if user is None:
|
||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
|
||||
|
||||
raw_path = raw_artifact_path(artifact)
|
||||
if raw_path and raw_path.exists():
|
||||
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
|
||||
|
||||
if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}:
|
||||
path = safe_artifact_path(artifact.path)
|
||||
if path.exists():
|
||||
content_type, _ = mimetypes.guess_type(path.name)
|
||||
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
||||
|
||||
return Response(
|
||||
{"detail": "No raw content available for this kind (render-only)."}, status=404
|
||||
)
|
||||
|
||||
|
||||
class CapabilitiesView(APIView):
|
||||
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
||||
so agents can check connectivity before posting. Exposes no user data."""
|
||||
|
||||
def get(self, request):
|
||||
# Public: capability/contract info only, no user data.
|
||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||
return HttpResponseForbidden("Login or a valid API token is required")
|
||||
from daybook_project import __version__
|
||||
|
||||
methods = {
|
||||
"/api/artifacts": ["GET", "POST"],
|
||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||
"/api/artifacts/{id}/raw": ["GET"],
|
||||
"/api/assets": ["POST"],
|
||||
"/api/capabilities": ["GET"],
|
||||
"/api/skill": ["GET"],
|
||||
|
|
@ -405,9 +362,9 @@ class SkillView(APIView):
|
|||
"""
|
||||
|
||||
def get(self, request):
|
||||
# Public: the skill is bootstrapping instructions (how to authenticate
|
||||
# and post) and contains no secrets — an agent shouldn't need a key to
|
||||
# learn how to use one.
|
||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||
return HttpResponseForbidden("Login or a valid API token is required")
|
||||
|
||||
origin = request.build_absolute_uri("/").rstrip("/")
|
||||
try:
|
||||
skill = render_posting_skill(origin)
|
||||
|
|
@ -523,35 +480,16 @@ def feed(request):
|
|||
selected_artifact_id = request.GET.get("artifact", "")
|
||||
preview_none = request.GET.get("preview") == "none"
|
||||
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
||||
selected_folder = request.GET.get("folder", "")
|
||||
if selected_folder not in ("deleted", "keep"):
|
||||
selected_folder = ""
|
||||
|
||||
# Live (non-trashed) artifacts drive the normal feed and the source sidebar.
|
||||
live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True)
|
||||
if selected_folder == "deleted":
|
||||
# System folder: all trashed items, not source-scoped.
|
||||
selected_source = ""
|
||||
base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False)
|
||||
elif selected_folder == "keep":
|
||||
selected_source = ""
|
||||
base = live.filter(is_kept=True)
|
||||
else:
|
||||
base = live
|
||||
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||
artifacts = apply_feed_filters(
|
||||
base, selected_source, selected_label, selected_kind, title_query
|
||||
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||
)
|
||||
|
||||
if selected_folder:
|
||||
# System folders show everything (no 7-day window / infinite scroll).
|
||||
window_start = today
|
||||
window_artifacts = list(artifacts.order_by("-created_at"))
|
||||
older_count = 0
|
||||
else:
|
||||
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||
if not artifacts.filter(day__gte=window_start).exists():
|
||||
# Nothing recent for these filters: fall back to the most recent days
|
||||
# that do match, so selecting a source is never met with a blank list.
|
||||
# Nothing recent for these filters: fall back to the most recent days that
|
||||
# do match, so selecting a source is never met with a blank list.
|
||||
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||
if recent_days:
|
||||
window_start = recent_days[-1]
|
||||
|
|
@ -566,8 +504,6 @@ def feed(request):
|
|||
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
||||
|
||||
toggle_query = {"view": "rendered"}
|
||||
if selected_folder:
|
||||
toggle_query["folder"] = selected_folder
|
||||
if selected_source:
|
||||
toggle_query["source"] = selected_source
|
||||
if selected_label:
|
||||
|
|
@ -582,22 +518,18 @@ def feed(request):
|
|||
toggle_query["view"] = "raw"
|
||||
raw_url = f"?{urlencode(toggle_query)}"
|
||||
|
||||
# Source counts + labels always come from live artifacts, so the sidebar is
|
||||
# the same whichever folder you're viewing.
|
||||
# Source counts come from the other filters only, so the sidebar still shows
|
||||
# every source available under the current label/type/name filters.
|
||||
source_scoped = apply_feed_filters(
|
||||
live, "", selected_label, selected_kind, title_query
|
||||
all_artifacts, "", selected_label, selected_kind, title_query
|
||||
)
|
||||
# Sources ordered by most recent message first (Telegram-style); "All
|
||||
# sources" is rendered separately and stays pinned at the top.
|
||||
source_counts = (
|
||||
source_scoped.values("source")
|
||||
.annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at"))
|
||||
.order_by("-latest")
|
||||
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
||||
.order_by("source")
|
||||
)
|
||||
keep_count = live.filter(is_kept=True).count()
|
||||
trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count()
|
||||
|
||||
all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags})
|
||||
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
|
||||
return render(
|
||||
request,
|
||||
"artifacts/feed.html",
|
||||
|
|
@ -606,14 +538,8 @@ def feed(request):
|
|||
"day_groups": day_groups,
|
||||
"older_count": older_count,
|
||||
"window_start": window_start,
|
||||
# Marker for live polling: newest artifact shown, else now.
|
||||
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
|
||||
"source_counts": source_counts,
|
||||
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
||||
"keep_count": keep_count,
|
||||
"trash_count": trash_count,
|
||||
"selected_folder": selected_folder,
|
||||
"in_trash": selected_folder == "deleted",
|
||||
"all_labels": labels_with_colors_list(all_labels, overrides),
|
||||
"raw_url": raw_url,
|
||||
"rendered_url": rendered_url,
|
||||
|
|
@ -626,7 +552,6 @@ def feed(request):
|
|||
"today": today,
|
||||
"title_query": title_query,
|
||||
"kind_choices": Artifact.KIND_CHOICES,
|
||||
"label_palette": request.user.labels.all(),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -641,31 +566,6 @@ def mark_artifact_read(request, artifact_id):
|
|||
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_artifact_label(request, artifact_id):
|
||||
"""Toggle a palette label on/off for one of the user's own messages. The
|
||||
label name is written into/removed from the artifact's `tags` list (labels
|
||||
are unified with tags). Only names in the user's palette are accepted."""
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True)
|
||||
name = (request.POST.get("name") or "").strip()
|
||||
label = Label.objects.filter(user=request.user, name=name).first()
|
||||
if label is None:
|
||||
return JsonResponse({"error": "No such label in your palette."}, status=400)
|
||||
tags = list(artifact.tags)
|
||||
if name in tags:
|
||||
tags.remove(name)
|
||||
applied = False
|
||||
else:
|
||||
tags.append(name)
|
||||
applied = True
|
||||
artifact.tags = tags
|
||||
artifact.save(update_fields=["tags"])
|
||||
# Keep the chip colour in sync with the palette (TagColor is global).
|
||||
TagColor.objects.update_or_create(name=name, defaults={"color": label.color})
|
||||
return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_artifact_keep(request, artifact_id):
|
||||
|
|
@ -692,58 +592,12 @@ def toggle_artifact_share(request, artifact_id):
|
|||
return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_artifact(request, artifact_id):
|
||||
"""Soft-delete: move to Trash (recoverable, off-quota). Idempotent."""
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
if artifact.deleted_at is None:
|
||||
artifact.deleted_at = timezone.now()
|
||||
artifact.save(update_fields=["deleted_at"])
|
||||
spawn_trash_purge() # sweep expired trash off the request thread
|
||||
return JsonResponse({"id": artifact.id, "deleted": True})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def undelete_artifact(request, artifact_id):
|
||||
"""Restore a trashed artifact back to the live feed."""
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
if artifact.deleted_at is not None:
|
||||
artifact.deleted_at = None
|
||||
artifact.save(update_fields=["deleted_at"])
|
||||
return JsonResponse({"id": artifact.id, "deleted": False})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def empty_trash(request):
|
||||
"""Permanently purge all of the user's trashed artifacts."""
|
||||
count = purge_trashed(request.user)
|
||||
return JsonResponse({"purged": count})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def mark_all_read(request):
|
||||
"""Mark all of the user's unread (non-trashed) messages as read, optionally
|
||||
scoped to one source. Backs the sidebar 'Mark all as read' action."""
|
||||
unread = Artifact.objects.filter(
|
||||
owner=request.user, deleted_at__isnull=True, read_at__isnull=True
|
||||
)
|
||||
source = request.POST.get("source", "")
|
||||
if source:
|
||||
unread = unread.filter(source=source)
|
||||
marked = unread.update(read_at=timezone.now())
|
||||
return JsonResponse({"marked": marked, "source": source})
|
||||
|
||||
|
||||
@login_required
|
||||
def feed_older(request):
|
||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
||||
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
|
||||
artifacts = apply_feed_filters(
|
||||
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
|
||||
Artifact.objects.filter(owner=request.user),
|
||||
request.GET.get("source", ""),
|
||||
request.GET.get("label", ""),
|
||||
request.GET.get("kind", ""),
|
||||
|
|
@ -777,58 +631,6 @@ def feed_older(request):
|
|||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def feed_updates(request):
|
||||
"""New artifacts since a timestamp, as row HTML for the feed to prepend live.
|
||||
Owner-scoped and filter-aware — respects the source/label/kind/q the browser
|
||||
is currently viewing, so a new post that doesn't match won't appear."""
|
||||
after = parse_datetime(request.GET.get("after", "") or "")
|
||||
if after is None:
|
||||
return JsonResponse({"count": 0})
|
||||
|
||||
selected_source = request.GET.get("source", "")
|
||||
selected_label = request.GET.get("label", "")
|
||||
selected_kind = request.GET.get("kind", "")
|
||||
title_query = request.GET.get("q", "")
|
||||
artifacts = apply_feed_filters(
|
||||
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
|
||||
selected_source,
|
||||
selected_label,
|
||||
selected_kind,
|
||||
title_query,
|
||||
).filter(created_at__gt=after).order_by("-created_at")
|
||||
|
||||
new = list(artifacts[:100])
|
||||
if not new:
|
||||
return JsonResponse({"count": 0})
|
||||
|
||||
overrides = tag_color_overrides()
|
||||
for artifact in new:
|
||||
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
|
||||
|
||||
html = render_to_string(
|
||||
"artifacts/_artifact_rows.html",
|
||||
{
|
||||
"artifacts": new,
|
||||
"selected_artifact": None,
|
||||
"selected_source": selected_source,
|
||||
"selected_label": selected_label,
|
||||
"selected_kind": selected_kind,
|
||||
"title_query": title_query,
|
||||
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"count": len(new),
|
||||
"newest": new[0].created_at.isoformat(),
|
||||
"today_label": date_filter(timezone.localdate(), "D, M j Y"),
|
||||
"html": html,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@xframe_options_sameorigin
|
||||
def artifact_view(request, artifact_id):
|
||||
|
|
@ -1271,15 +1073,6 @@ def resolve_token_user(request):
|
|||
return api_key.user
|
||||
|
||||
|
||||
def resolve_request_user(request):
|
||||
"""The acting user from either a browser session or a bearer API key.
|
||||
Used by the JSON read endpoints so agents can read back with the same token
|
||||
they post with (owner-scoped). None if neither authenticates."""
|
||||
if request.user.is_authenticated:
|
||||
return request.user
|
||||
return resolve_token_user(request)
|
||||
|
||||
|
||||
def normalize_tags(tags):
|
||||
if isinstance(tags, str):
|
||||
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
|
|
@ -1469,7 +1262,6 @@ def serialize_artifact(artifact, request):
|
|||
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
||||
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
|
||||
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
|
||||
"api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])),
|
||||
"raw_available": raw_artifact_path(artifact) is not None,
|
||||
"source": artifact.source,
|
||||
"title": artifact.title,
|
||||
|
|
@ -1534,25 +1326,10 @@ def settings_home(request):
|
|||
|
||||
@login_required
|
||||
def settings_account(request):
|
||||
if request.method == "POST":
|
||||
password_form = PasswordChangeForm(request.user, request.POST)
|
||||
if password_form.is_valid():
|
||||
user = password_form.save()
|
||||
# Keep the current session valid — changing the password rotates the
|
||||
# session auth hash, which would otherwise log the user out.
|
||||
update_session_auth_hash(request, user)
|
||||
messages.success(request, "Password changed.")
|
||||
return redirect("settings-account")
|
||||
else:
|
||||
password_form = PasswordChangeForm(request.user)
|
||||
return render(
|
||||
request,
|
||||
"artifacts/settings_account.html",
|
||||
{
|
||||
"active_section": "account",
|
||||
"key_count": request.user.api_keys.count(),
|
||||
"password_form": password_form,
|
||||
},
|
||||
{"active_section": "account", "key_count": request.user.api_keys.count()},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1582,49 +1359,6 @@ def settings_api_keys(request):
|
|||
)
|
||||
|
||||
|
||||
LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_labels(request):
|
||||
if request.method == "POST":
|
||||
action = request.POST.get("action")
|
||||
if action == "create":
|
||||
name = (request.POST.get("name") or "").strip()
|
||||
color = (request.POST.get("color") or "").strip()
|
||||
if not name:
|
||||
messages.error(request, "Give the label a name.")
|
||||
elif not LABEL_HEX_RE.match(color):
|
||||
messages.error(request, "Pick a colour (#RRGGBB).")
|
||||
elif Label.objects.filter(user=request.user, name=name).exists():
|
||||
messages.error(request, f"You already have a label called '{name}'.")
|
||||
else:
|
||||
Label.objects.create(user=request.user, name=name, color=color)
|
||||
TagColor.objects.update_or_create(name=name, defaults={"color": color})
|
||||
messages.success(request, f"Created label '{name}'.")
|
||||
elif action == "update":
|
||||
color = (request.POST.get("color") or "").strip()
|
||||
label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first()
|
||||
if label is None:
|
||||
messages.error(request, "No such label.")
|
||||
elif not LABEL_HEX_RE.match(color):
|
||||
messages.error(request, "Pick a colour (#RRGGBB).")
|
||||
else:
|
||||
label.color = color
|
||||
label.save(update_fields=["color"])
|
||||
TagColor.objects.update_or_create(name=label.name, defaults={"color": color})
|
||||
messages.success(request, f"Updated '{label.name}'.")
|
||||
elif action == "delete":
|
||||
deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete()
|
||||
messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.")
|
||||
return redirect("settings-labels")
|
||||
return render(
|
||||
request,
|
||||
"artifacts/settings_labels.html",
|
||||
{"active_section": "labels", "labels": request.user.labels.all()},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_users(request):
|
||||
if not request.user.is_superuser:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Single source of truth for the app version. Bump this, commit, then tag the
|
||||
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
||||
# GET /api/capabilities ("version").
|
||||
__version__ = "0.7.1"
|
||||
__version__ = "0.3.0"
|
||||
|
|
|
|||
|
|
@ -19,10 +19,6 @@ AGENTFEED_KEY_PROVISIONING_API = os.environ.get(
|
|||
"AGENTFEED_KEY_PROVISIONING_API", ""
|
||||
).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
# Purge expired Trash off the request thread (triggered by deletes, no cron).
|
||||
# Tests override this to run inline so purge behavior is deterministic.
|
||||
AGENTFEED_ASYNC_PURGE = True
|
||||
|
||||
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
|
||||
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
|
||||
# on login/POST. The proxy must forward X-Forwarded-Proto: https.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Also confirms your token is valid:
|
|||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" }
|
||||
```
|
||||
|
||||
## 2. Publish Markdown
|
||||
|
|
@ -39,7 +39,7 @@ $body = @{
|
|||
$artifact = Invoke-RestMethod `
|
||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||
-Method Post `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ $body = @{
|
|||
Invoke-RestMethod `
|
||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||
-Method Post `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
|
@ -78,7 +78,7 @@ Invoke-RestMethod `
|
|||
|
||||
```powershell
|
||||
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||
-H "Authorization: Bearer <your-personal-key>" `
|
||||
-H "Authorization: Bearer <your-personal-token>" `
|
||||
-F "source=orion" `
|
||||
-F "source_type=agent" `
|
||||
-F "source_name=Orion Agent" `
|
||||
|
|
@ -120,7 +120,7 @@ $body = @{
|
|||
Invoke-RestMethod `
|
||||
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
||||
-Method Patch `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
|
@ -131,7 +131,7 @@ Invoke-RestMethod `
|
|||
Invoke-RestMethod `
|
||||
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
||||
-Method Delete `
|
||||
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||
-Headers @{ Authorization = "Bearer <your-personal-token>" }
|
||||
```
|
||||
|
||||
Deletes the artifact's database row and its files. You can only delete artifacts your own token owns.
|
||||
|
|
|
|||
88
docs/api.md
88
docs/api.md
|
|
@ -16,15 +16,11 @@ Authorization: Bearer <your-personal-key>
|
|||
|
||||
Every artifact belongs to the user whose key authenticated the POST — feeds are private per user.
|
||||
|
||||
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept
|
||||
**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a
|
||||
token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an
|
||||
agent can read back what it posted with the same token. Unauthenticated API requests get a
|
||||
JSON `403`, never a browser-login redirect.
|
||||
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
|
||||
logged-in browser session, not a bearer token — visit the app in a browser and log in first.
|
||||
|
||||
`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose
|
||||
no user data), so an agent can discover the contract and fetch posting instructions before
|
||||
it has a key.
|
||||
`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check
|
||||
connectivity before posting.
|
||||
|
||||
## Artifact Kinds
|
||||
|
||||
|
|
@ -108,7 +104,7 @@ Endpoint:
|
|||
```http
|
||||
POST /api/artifacts
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
|
@ -188,7 +184,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 +237,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 +284,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 +301,7 @@ artifact), upload it first and reference the returned URL in your content.
|
|||
```http
|
||||
POST /api/assets
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|
|
@ -347,7 +343,7 @@ Endpoint:
|
|||
GET /api/artifacts
|
||||
```
|
||||
|
||||
Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts.
|
||||
Requires a logged-in browser session. Returns only the current user's artifacts.
|
||||
|
||||
Supported filters (combine freely; all are ANDed):
|
||||
|
||||
|
|
@ -392,7 +388,7 @@ Endpoint:
|
|||
```http
|
||||
PATCH /api/artifacts/{id}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
Editable fields:
|
||||
|
|
@ -438,7 +434,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,48 +445,42 @@ Endpoint:
|
|||
|
||||
```http
|
||||
DELETE /api/artifacts/{id}
|
||||
Authorization: Bearer <your-personal-key>
|
||||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
**Soft-delete:** moves the artifact to Trash rather than removing it — it's
|
||||
recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden
|
||||
from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it
|
||||
as gone). Files stay on disk until the trash is purged (Empty Trash in the app).
|
||||
Returns `204` with no body.
|
||||
Removes the artifact's database row and its files (primary file, markdown
|
||||
sidecar, and any attached assets). Returns `204` with no body on success.
|
||||
|
||||
**Idempotent:** deleting an already-trashed id returns `204` again (not `404`).
|
||||
You can only delete artifacts your own token owns; another user's returns `404`.
|
||||
There is no API endpoint to undelete or purge yet — that's app-UI-only.
|
||||
You can only delete artifacts your own token owns; others return `404`, as
|
||||
does a second delete of the same id.
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
|
||||
-H "Authorization: Bearer <your-personal-key>"
|
||||
-H "Authorization: Bearer <your-personal-token>"
|
||||
```
|
||||
|
||||
## Rendered And Raw Content
|
||||
|
||||
**For agents (bearer token):** fetch the full content from the API — separate
|
||||
from the metadata `GET /api/artifacts/{id}`:
|
||||
Rendered browser view:
|
||||
|
||||
```http
|
||||
GET /api/artifacts/{id}/raw
|
||||
GET /a/{id}
|
||||
```
|
||||
|
||||
Same auth as the detail endpoint (bearer token or session), owner-scoped, so an
|
||||
agent can read back and verify exactly what it posted. The metadata response
|
||||
includes `api_raw_url` pointing here.
|
||||
Raw/source view:
|
||||
|
||||
- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as
|
||||
`text/plain`.
|
||||
- `image` / `pdf` / `binary` → the stored file bytes (with its content type).
|
||||
- `html_fragment` / `deck` → `404` (render-only; no raw source is kept).
|
||||
```http
|
||||
GET /a/{id}?raw=1
|
||||
```
|
||||
|
||||
Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line
|
||||
endings.
|
||||
Raw currently exists for:
|
||||
|
||||
**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and
|
||||
`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint
|
||||
above for token access.
|
||||
| Kind | Raw content |
|
||||
|---|---|
|
||||
| `markdown` | Preserved `.md` source. |
|
||||
| `html_full` | Stored HTML file served as text. |
|
||||
| `json` | Preserved original JSON text. |
|
||||
| `csv` | Preserved original CSV text. |
|
||||
|
||||
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
|
||||
|
||||
|
|
@ -551,10 +541,11 @@ This is generated from the server's actual configuration, so it's the
|
|||
authoritative source if this document and a running instance ever disagree.
|
||||
|
||||
Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or
|
||||
simply to check the instance is reachable. It's public — no auth required:
|
||||
simply to check the instance is reachable and their token is valid. Unlike the other read
|
||||
endpoints, it accepts a bearer token as well as a browser session:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8001/api/capabilities
|
||||
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
|
||||
```
|
||||
|
||||
## Posting Skill
|
||||
|
|
@ -572,13 +563,14 @@ instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own
|
|||
origin**, so the text a caller receives already points at the right server;
|
||||
a recipient only has to fill in their `AGENTFEED_TOKEN`.
|
||||
|
||||
**Public** — no auth required (like capabilities), so an agent can fetch its
|
||||
posting instructions before it has a key. The default response is raw Markdown
|
||||
(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`.
|
||||
Same auth as capabilities (a browser session **or** a bearer token), so a
|
||||
logged-in user can copy it to hand to their agent, or an agent can fetch it
|
||||
directly. The default response is raw Markdown (`text/markdown`); add
|
||||
`?format=json` for `{"version", "url", "skill"}`.
|
||||
|
||||
```bash
|
||||
# Fetch the ready-to-use instructions for this instance (no auth needed)
|
||||
curl http://127.0.0.1:8001/api/skill
|
||||
# Fetch the ready-to-use instructions for this instance
|
||||
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/skill
|
||||
```
|
||||
|
||||
Discover it programmatically via `skill_url` in the capabilities response.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1058,173 +1045,6 @@ kbd {
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Newly live-inserted feed rows briefly highlight, then settle. */
|
||||
@keyframes agentfeed-row-arrive {
|
||||
from { background: var(--surface-alt); }
|
||||
to { background: transparent; }
|
||||
}
|
||||
|
||||
.artifact-row.just-arrived {
|
||||
animation: agentfeed-row-arrive 1.5s ease-out;
|
||||
}
|
||||
|
||||
/* Hover/focus row actions (delete in the feed, undelete in Trash) */
|
||||
.artifact-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.artifact-row:hover .row-actions,
|
||||
.artifact-row:focus-within .row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row-action:hover {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row-delete:hover {
|
||||
color: var(--danger-text-plain);
|
||||
}
|
||||
|
||||
/* Per-row actions menu (opened by the ⋯ trigger or right-click) */
|
||||
.row-menu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
min-width: 9rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-menu button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.row-menu button:hover {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.row-menu button.danger {
|
||||
color: var(--danger-text-plain);
|
||||
}
|
||||
|
||||
.row-menu-divider {
|
||||
border-top: 1px solid var(--grid-line);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.row-menu-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 13rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-menu-label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.row-menu-dot {
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
height: 0.7rem;
|
||||
width: 0.7rem;
|
||||
}
|
||||
|
||||
.row-menu-label-name {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Checkmark only visible once the label is applied to the row. */
|
||||
.row-menu-check {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row-menu-label.applied .row-menu-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* System folders (Keep / Deleted): pinned at the bottom of the sidebar,
|
||||
below the scrolling source list. */
|
||||
.system-folders {
|
||||
border-top: 1px solid var(--grid-line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: 0.25rem; /* space Keep / Deleted apart */
|
||||
padding: 0.4rem 0.6rem 0.3rem;
|
||||
}
|
||||
|
||||
/* "Special" sidebar entries — "All sources" and the Keep/Deleted folders —
|
||||
share a distinct look (bold + subtle blue tint) to set them apart from the
|
||||
individual sources. */
|
||||
.all-sources-link,
|
||||
.folder-link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.all-sources-link:not(.selected),
|
||||
.folder-link:not(.selected) {
|
||||
background: rgba(74, 110, 168, 0.14);
|
||||
}
|
||||
|
||||
.all-sources-link:not(.selected):hover,
|
||||
.folder-link:not(.selected):hover {
|
||||
background: rgba(74, 110, 168, 0.22);
|
||||
}
|
||||
|
||||
.empty-trash-button {
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid var(--danger-text);
|
||||
border-radius: 6px;
|
||||
color: var(--danger-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.api-key-create {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
|
|
@ -1240,32 +1060,6 @@ kbd {
|
|||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.password-change {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.password-change .form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: 600;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.password-change button {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.form-errors {
|
||||
color: var(--danger, #d1453b);
|
||||
font-weight: 400;
|
||||
list-style: none;
|
||||
margin: 0.15rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.api-key-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
{% for artifact in artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
|
||||
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||
data-artifact-id="{{ artifact.id }}"
|
||||
data-title="{{ artifact.title }}"
|
||||
data-kind="{{ artifact.kind }}"
|
||||
data-source="{{ artifact.source }}"
|
||||
data-source-type="{{ artifact.source_type }}"
|
||||
data-source-name="{{ artifact.source_name }}"
|
||||
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
||||
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
||||
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
||||
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
|
||||
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
|
||||
data-tags="{{ artifact.tags|join:', ' }}"
|
||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
||||
<span class="artifact-row-main">
|
||||
<span class="artifact-title">{{ artifact.title }}</span>
|
||||
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
|
||||
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
|
||||
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
|
||||
{% if artifact.label_chips %}
|
||||
<span class="tag-chips">
|
||||
{% for label in artifact.label_chips %}
|
||||
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="artifact-source">{{ artifact.source }}</span>
|
||||
<span class="row-actions">
|
||||
{% if in_trash %}
|
||||
<span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">↩</span>
|
||||
{% else %}
|
||||
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">🗑</span>
|
||||
<span class="row-action row-menu-trigger" role="button" tabindex="0" data-menu-trigger title="More actions" aria-label="More actions for {{ artifact.title }}">⋯</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
|
@ -1,10 +1,43 @@
|
|||
{% for group in day_groups %}
|
||||
<div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
|
||||
<div class="day-group">
|
||||
<div class="day-separator">
|
||||
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
|
||||
<span class="day-separator-count">{{ group.artifacts|length }}</span>
|
||||
</div>
|
||||
{% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
|
||||
{% for artifact in group.artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
|
||||
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||
data-artifact-id="{{ artifact.id }}"
|
||||
data-title="{{ artifact.title }}"
|
||||
data-kind="{{ artifact.kind }}"
|
||||
data-source="{{ artifact.source }}"
|
||||
data-source-type="{{ artifact.source_type }}"
|
||||
data-source-name="{{ artifact.source_name }}"
|
||||
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
||||
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
||||
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
||||
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
|
||||
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
|
||||
data-tags="{{ artifact.tags|join:', ' }}"
|
||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
||||
<span class="artifact-row-main">
|
||||
<span class="artifact-title">{{ artifact.title }}</span>
|
||||
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
|
||||
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
|
||||
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
|
||||
{% if artifact.label_chips %}
|
||||
<span class="tag-chips">
|
||||
{% for label in artifact.label_chips %}
|
||||
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="artifact-source">{{ artifact.source }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if has_more %}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
data-selected-kind="{{ selected_kind }}"
|
||||
data-title-query="{{ title_query }}"
|
||||
data-selected-view="{{ selected_view }}"
|
||||
data-folder="{{ selected_folder }}"
|
||||
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||
data-older-count="{{ older_count }}">
|
||||
<aside class="source-sidebar" aria-label="Sources">
|
||||
|
|
@ -35,48 +34,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 %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||
<span class="source-name">All sources</span>
|
||||
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
|
||||
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% for entry in source_counts %}
|
||||
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none" data-source="{{ entry.source }}">
|
||||
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||
<span class="source-name">{{ entry.source }}</span>
|
||||
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
|
||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<nav class="system-folders" aria-label="Folders">
|
||||
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&view={{ selected_view }}&preview=none" data-folder-link="keep">
|
||||
<span class="source-name">★ Keep</span>
|
||||
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
||||
<span class="source-name">🗑 Deleted</span>
|
||||
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
||||
|
||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||
<div class="pane-bar pane-heading">
|
||||
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
{% if selected_folder == 'deleted' %}
|
||||
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
|
||||
{% else %}
|
||||
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
|
||||
{% endif %}
|
||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
|
||||
{% if day_groups %}
|
||||
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||
{% else %}
|
||||
<div class="empty compact">
|
||||
<h3>No messages</h3>
|
||||
<h3>No artifacts</h3>
|
||||
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -88,7 +72,7 @@
|
|||
<section class="preview-pane">
|
||||
<header class="pane-bar preview-actions" data-actions>
|
||||
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
|
||||
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
|
||||
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
|
||||
</div>
|
||||
<nav class="view-toggle" aria-label="Preview mode">
|
||||
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
||||
|
|
@ -118,8 +102,8 @@
|
|||
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
||||
{% elif selected_artifact.kind == "binary" %}
|
||||
<div class="empty preview-empty">
|
||||
<h2>Binary Message</h2>
|
||||
<p>This message type cannot be previewed inline yet.</p>
|
||||
<h2>Binary Artifact</h2>
|
||||
<p>This artifact type cannot be previewed inline yet.</p>
|
||||
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
@ -129,10 +113,10 @@
|
|||
{% else %}
|
||||
<div class="empty preview-empty">
|
||||
{% if artifacts %}
|
||||
<h2>No message loaded</h2>
|
||||
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
|
||||
<h2>No artifact loaded</h2>
|
||||
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
||||
{% else %}
|
||||
<h2>No messages for this day</h2>
|
||||
<h2>No artifacts for this day</h2>
|
||||
<p>This day is ready for new agent output.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -141,25 +125,6 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<div class="row-menu" hidden data-row-menu>
|
||||
<button type="button" data-menu-action="keep">Keep</button>
|
||||
<button type="button" data-menu-action="open">Open</button>
|
||||
<button type="button" data-menu-action="copy">Copy link</button>
|
||||
<button type="button" data-menu-action="delete" class="danger">Delete</button>
|
||||
{% if label_palette %}
|
||||
<div class="row-menu-divider" role="separator"></div>
|
||||
<div class="row-menu-labels">
|
||||
{% for label in label_palette %}
|
||||
<button type="button" class="row-menu-label" data-label-name="{{ label.name }}" data-label-color="{{ label.color }}">
|
||||
<span class="row-menu-dot" style="background:{{ label.color }}"></span>
|
||||
<span class="row-menu-label-name">{{ label.name }}</span>
|
||||
<span class="row-menu-check" aria-hidden="true">✓</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const feed = document.querySelector(".feed");
|
||||
|
|
@ -230,8 +195,7 @@
|
|||
const artifactList = document.querySelector("[data-artifact-list]");
|
||||
// Rows grow as older pages load, so always read them live rather than caching an array.
|
||||
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
||||
// Live-queried so sources added by live updates are keyboard-navigable too.
|
||||
const sourceLinks = () => Array.from(document.querySelectorAll(".source-link"));
|
||||
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
|
||||
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
||||
|
||||
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||
|
|
@ -245,7 +209,7 @@
|
|||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
} catch {
|
||||
window.prompt("Copy message link", absoluteUrl);
|
||||
window.prompt("Copy artifact link", absoluteUrl);
|
||||
}
|
||||
const original = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
|
|
@ -354,7 +318,7 @@
|
|||
if (!actions) return;
|
||||
const previewTitle = actions.querySelector("[data-preview-title]");
|
||||
if (previewTitle) {
|
||||
previewTitle.textContent = "No message loaded";
|
||||
previewTitle.textContent = "No artifact loaded";
|
||||
previewTitle.removeAttribute("title");
|
||||
}
|
||||
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||
|
|
@ -419,335 +383,6 @@
|
|||
});
|
||||
};
|
||||
|
||||
const findSourceLink = (name) =>
|
||||
sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name);
|
||||
|
||||
const bumpSourceBadge = (link, delta) => {
|
||||
let badge = link.querySelector(".source-count");
|
||||
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
|
||||
if (next <= 0) {
|
||||
if (badge) badge.remove(); // empty folder/source shows no count badge
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "source-count";
|
||||
link.appendChild(badge);
|
||||
}
|
||||
badge.textContent = String(next);
|
||||
};
|
||||
|
||||
const addSourceLink = (name, count) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("source", name);
|
||||
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
|
||||
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||
params.set("view", feed.dataset.selectedView || "rendered");
|
||||
params.set("preview", "none");
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "source-link";
|
||||
link.href = `?${params.toString()}`;
|
||||
link.innerHTML =
|
||||
`<span class="source-name">${escapeHtml(name)}</span>` +
|
||||
`<span class="source-count">${count}</span>`;
|
||||
|
||||
// Newest activity goes to the top of the per-source list (just under
|
||||
// the pinned "All sources"), matching the recency ordering.
|
||||
const list = document.querySelector(".source-list");
|
||||
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
||||
list.insertBefore(link, firstSource);
|
||||
};
|
||||
|
||||
const moveSourceToTop = (link) => {
|
||||
const list = document.querySelector(".source-list");
|
||||
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
||||
if (firstSource !== link) list.insertBefore(link, firstSource);
|
||||
};
|
||||
|
||||
// From the newly-arrived rows: bump each source's unread count (adding a
|
||||
// sidebar entry for a source seen for the first time) plus the total.
|
||||
const registerNewSources = (rows) => {
|
||||
const counts = {};
|
||||
rows.forEach((row) => {
|
||||
const source = row.dataset.source;
|
||||
if (source) counts[source] = (counts[source] || 0) + 1;
|
||||
});
|
||||
let total = 0;
|
||||
Object.entries(counts).forEach(([source, n]) => {
|
||||
total += n;
|
||||
const existing = findSourceLink(source);
|
||||
if (existing) {
|
||||
bumpSourceBadge(existing, n);
|
||||
moveSourceToTop(existing); // most-recent activity -> top (recency order)
|
||||
} else {
|
||||
addSourceLink(source, n);
|
||||
}
|
||||
});
|
||||
const allLink = findSourceLink("All sources");
|
||||
if (allLink && total) bumpSourceBadge(allLink, total);
|
||||
};
|
||||
|
||||
// --- Delete / undelete / empty trash ---
|
||||
const updateArtifactCount = (delta) => {
|
||||
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
||||
if (!header) return;
|
||||
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
|
||||
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const bumpFolderCount = (folder, delta) => {
|
||||
const link = document.querySelector(`[data-folder-link="${folder}"]`);
|
||||
if (link) bumpSourceBadge(link, delta);
|
||||
};
|
||||
|
||||
const removeRowFromList = (row) => {
|
||||
const group = row.closest(".day-group");
|
||||
row.remove();
|
||||
if (group) {
|
||||
const remaining = group.querySelectorAll(".artifact-row").length;
|
||||
const count = group.querySelector(".day-separator-count");
|
||||
if (count) count.textContent = String(remaining);
|
||||
if (remaining === 0) group.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const softDeleteRow = async (id) => {
|
||||
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/delete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
} catch { return; }
|
||||
if (!row) return;
|
||||
if (row.classList.contains("selected")) clearPreview();
|
||||
if (row.classList.contains("unread")) decrementSourceCount(row.dataset.source);
|
||||
removeRowFromList(row);
|
||||
updateArtifactCount(-1);
|
||||
bumpFolderCount("deleted", 1); // best-effort; the folder appears on next nav if it's the first delete
|
||||
};
|
||||
|
||||
const undeleteRow = async (id) => {
|
||||
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/undelete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
} catch { return; }
|
||||
if (!row) return;
|
||||
removeRowFromList(row);
|
||||
bumpFolderCount("deleted", -1);
|
||||
// The restored item reappears in the feed on the next navigation (full render).
|
||||
};
|
||||
|
||||
const emptyTrashButton = document.querySelector("[data-empty-trash]");
|
||||
if (emptyTrashButton) {
|
||||
emptyTrashButton.addEventListener("click", async () => {
|
||||
if (!window.confirm("Permanently delete everything in Trash? This can't be undone.")) return;
|
||||
try {
|
||||
const response = await fetch("{% url 'feed-empty-trash' %}", {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
} catch { return; }
|
||||
window.location.href = window.location.pathname; // trash empty; folder gone
|
||||
});
|
||||
}
|
||||
|
||||
// --- Per-row actions menu (⋯ trigger + right-click) ---
|
||||
const rowMenu = document.querySelector("[data-row-menu]");
|
||||
let menuRowId = null;
|
||||
|
||||
const currentMenuRow = () =>
|
||||
menuRowId ? artifactList.querySelector(`.artifact-row[data-artifact-id="${menuRowId}"]`) : null;
|
||||
|
||||
const closeRowMenu = () => {
|
||||
if (rowMenu) rowMenu.hidden = true;
|
||||
menuRowId = null;
|
||||
};
|
||||
|
||||
const parseRowTags = (row) =>
|
||||
(row.dataset.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
const refreshMenuLabelState = (row) => {
|
||||
const applied = new Set(parseRowTags(row));
|
||||
rowMenu.querySelectorAll(".row-menu-label").forEach((btn) => {
|
||||
btn.classList.toggle("applied", applied.has(btn.dataset.labelName));
|
||||
});
|
||||
};
|
||||
|
||||
const openRowMenu = (row, x, y) => {
|
||||
if (!rowMenu || !row) return;
|
||||
menuRowId = row.dataset.artifactId;
|
||||
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
|
||||
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
|
||||
refreshMenuLabelState(row);
|
||||
rowMenu.hidden = false;
|
||||
const rect = rowMenu.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
|
||||
const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
|
||||
rowMenu.style.left = `${left}px`;
|
||||
rowMenu.style.top = `${top}px`;
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
try { await navigator.clipboard.writeText(text); } catch (e) { /* clipboard unavailable */ }
|
||||
};
|
||||
|
||||
const keepFromMenu = async (row, id) => {
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/keep`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
row.dataset.kept = data.is_kept ? "1" : "0";
|
||||
row.classList.toggle("kept", data.is_kept);
|
||||
const badge = row.querySelector("[data-kept-badge]");
|
||||
if (badge) badge.hidden = !data.is_kept;
|
||||
bumpFolderCount("keep", data.is_kept ? 1 : -1);
|
||||
if (feed.dataset.folder === "keep" && !data.is_kept) removeRowFromList(row);
|
||||
const keepButton = document.querySelector("[data-keep-button]");
|
||||
if (keepButton && keepButton.dataset.artifactId === id) {
|
||||
keepButton.classList.toggle("kept", data.is_kept);
|
||||
keepButton.textContent = data.is_kept ? "✓ Kept" : "Keep";
|
||||
}
|
||||
} catch (e) { /* leave as-is */ }
|
||||
};
|
||||
|
||||
const addRowChip = (row, name, color) => {
|
||||
const main = row.querySelector(".artifact-row-main");
|
||||
if (!main) return;
|
||||
let chips = main.querySelector(".tag-chips");
|
||||
if (!chips) {
|
||||
chips = document.createElement("span");
|
||||
chips.className = "tag-chips";
|
||||
main.appendChild(chips);
|
||||
}
|
||||
if ([...chips.querySelectorAll(".tag-chip")].some((c) => c.textContent === name)) return;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "tag-chip";
|
||||
chip.style.background = color;
|
||||
chip.textContent = name;
|
||||
chips.appendChild(chip);
|
||||
};
|
||||
|
||||
const removeRowChip = (row, name) => {
|
||||
const chips = row.querySelector(".tag-chips");
|
||||
if (!chips) return;
|
||||
chips.querySelectorAll(".tag-chip").forEach((c) => { if (c.textContent === name) c.remove(); });
|
||||
if (!chips.querySelector(".tag-chip")) chips.remove();
|
||||
};
|
||||
|
||||
// Toggle a palette label on the current menu row. Leaves the menu open so
|
||||
// several labels can be applied in one go.
|
||||
const toggleLabelFromMenu = async (btn) => {
|
||||
const row = currentMenuRow();
|
||||
const id = menuRowId;
|
||||
if (!row || !id) return;
|
||||
const name = btn.dataset.labelName;
|
||||
const body = new URLSearchParams();
|
||||
body.set("name", name);
|
||||
let data;
|
||||
try {
|
||||
const response = await fetch(`/a/${id}/label`, {
|
||||
method: "POST",
|
||||
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
data = await response.json();
|
||||
} catch (e) { return; }
|
||||
row.dataset.tags = data.tags.join(", ");
|
||||
btn.classList.toggle("applied", data.applied);
|
||||
if (data.applied) addRowChip(row, data.name, data.color);
|
||||
else removeRowChip(row, data.name);
|
||||
};
|
||||
|
||||
if (rowMenu) {
|
||||
rowMenu.addEventListener("click", (event) => {
|
||||
const labelBtn = event.target.closest(".row-menu-label");
|
||||
if (labelBtn) {
|
||||
toggleLabelFromMenu(labelBtn);
|
||||
return; // keep the menu open for applying multiple labels
|
||||
}
|
||||
const btn = event.target.closest("[data-menu-action]");
|
||||
if (!btn) return;
|
||||
const row = currentMenuRow();
|
||||
const id = menuRowId;
|
||||
closeRowMenu();
|
||||
if (!row || !id) return;
|
||||
const action = btn.dataset.menuAction;
|
||||
if (action === "delete") softDeleteRow(id);
|
||||
else if (action === "keep") keepFromMenu(row, id);
|
||||
else if (action === "open") window.open(row.dataset.renderedUrl, "_blank", "noopener");
|
||||
else if (action === "copy") copyText(window.location.origin + row.dataset.renderedUrl);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (rowMenu && !rowMenu.hidden && !rowMenu.contains(event.target)) closeRowMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeRowMenu();
|
||||
});
|
||||
|
||||
// Right-click a feed row opens the same menu at the cursor.
|
||||
artifactList.addEventListener("contextmenu", (event) => {
|
||||
const row = event.target.closest(".artifact-row");
|
||||
if (!row || feed.dataset.folder) return; // normal feed only
|
||||
event.preventDefault();
|
||||
openRowMenu(row, event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
// Click a source's unread badge to mark all of that source's messages read.
|
||||
const markSourceRead = async (badge) => {
|
||||
const link = badge.closest(".source-link");
|
||||
if (!link) return;
|
||||
const isAll = link.classList.contains("all-sources-link");
|
||||
const source = link.dataset.source || "";
|
||||
const body = new URLSearchParams();
|
||||
if (!isAll && source) body.set("source", source);
|
||||
try {
|
||||
const response = await fetch("{% url 'feed-mark-read' %}", {
|
||||
method: "POST",
|
||||
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
} catch (e) { return; }
|
||||
if (isAll) {
|
||||
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
|
||||
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
|
||||
} else {
|
||||
const n = parseInt(badge.textContent, 10) || 0;
|
||||
badge.remove();
|
||||
const allBadge = document.querySelector(".all-sources-link .source-count");
|
||||
if (allBadge) {
|
||||
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
|
||||
if (t > 0) allBadge.textContent = String(t);
|
||||
else allBadge.remove();
|
||||
}
|
||||
artifactList
|
||||
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
|
||||
.forEach((r) => r.classList.remove("unread"));
|
||||
}
|
||||
};
|
||||
|
||||
const sourceListEl = document.querySelector(".source-list");
|
||||
if (sourceListEl) {
|
||||
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
|
||||
sourceListEl.addEventListener("click", (event) => {
|
||||
const badge = badgeFromEvent(event);
|
||||
if (!badge) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
markSourceRead(badge);
|
||||
});
|
||||
sourceListEl.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
const badge = badgeFromEvent(event);
|
||||
if (!badge) return;
|
||||
event.preventDefault();
|
||||
markSourceRead(badge);
|
||||
});
|
||||
}
|
||||
|
||||
const renderPreview = (row, pushState = true) => {
|
||||
navigationMode = "artifacts";
|
||||
markRead(row);
|
||||
|
|
@ -760,7 +395,7 @@
|
|||
: kind === "image"
|
||||
? `<img class="preview-image" src="${source}" alt="${title}">`
|
||||
: kind === "binary"
|
||||
? `<div class="empty preview-empty"><h2>Binary Message</h2><p>This message type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||
|
||||
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
||||
|
|
@ -775,7 +410,7 @@
|
|||
}
|
||||
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
||||
disableActions();
|
||||
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No message loaded</h2><p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p></div>`;
|
||||
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
||||
window.history.pushState({}, "", noPreviewUrl());
|
||||
const currentSource = document.querySelector(".source-link.selected");
|
||||
if (currentSource) currentSource.focus({preventScroll: true});
|
||||
|
|
@ -783,21 +418,6 @@
|
|||
|
||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||
artifactList.addEventListener("click", (event) => {
|
||||
// Row action icons (delete/undelete/menu) intercept before selection.
|
||||
const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action.hasAttribute("data-menu-trigger")) {
|
||||
const rect = action.getBoundingClientRect();
|
||||
openRowMenu(action.closest(".artifact-row"), rect.right, rect.bottom);
|
||||
} else if (action.hasAttribute("data-delete")) {
|
||||
softDeleteRow(action.getAttribute("data-delete"));
|
||||
} else {
|
||||
undeleteRow(action.getAttribute("data-undelete"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest(".artifact-row");
|
||||
if (!row) return;
|
||||
event.preventDefault();
|
||||
|
|
@ -818,13 +438,12 @@
|
|||
};
|
||||
|
||||
const moveSource = (direction) => {
|
||||
const links = sourceLinks();
|
||||
if (!links.length) return;
|
||||
if (!sourceLinks.length) return;
|
||||
navigationMode = "sources";
|
||||
const current = document.querySelector(".source-link.selected");
|
||||
const currentIndex = current ? links.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
|
||||
const nextSource = links[nextIndex];
|
||||
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
||||
const nextSource = sourceLinks[nextIndex];
|
||||
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
||||
};
|
||||
|
||||
|
|
@ -933,98 +552,6 @@
|
|||
|
||||
observeSentinel();
|
||||
|
||||
// --- Live updates: poll for new artifacts and prepend them at the top. ---
|
||||
// Cheap: a small "anything since X?" fetch every few seconds, paused while
|
||||
// the tab is hidden. New rows are inserted without touching the selected
|
||||
// row or the preview, so what you're reading stays put. Respects the
|
||||
// active source/label/kind/q filters (passed through below).
|
||||
const LIVE_POLL_MS = 5000;
|
||||
const NOW_TTL_MS = 3 * 60 * 1000; // show "now", then age into the real time
|
||||
let liveSince = artifactList.dataset.since || null;
|
||||
let livePolling = false;
|
||||
|
||||
const buildUpdatesUrl = () => {
|
||||
const url = new URL("{% url 'feed-updates' %}", window.location.origin);
|
||||
const p = url.searchParams;
|
||||
if (liveSince) p.set("after", liveSince);
|
||||
p.set("view", feed.dataset.selectedView || "rendered");
|
||||
if (feed.dataset.selectedSource) p.set("source", feed.dataset.selectedSource);
|
||||
if (feed.dataset.selectedLabel) p.set("label", feed.dataset.selectedLabel);
|
||||
if (feed.dataset.selectedKind) p.set("kind", feed.dataset.selectedKind);
|
||||
if (feed.dataset.titleQuery) p.set("q", feed.dataset.titleQuery);
|
||||
return url;
|
||||
};
|
||||
|
||||
const ensureTodayGroup = (todayLabel) => {
|
||||
let group = artifactList.querySelector(".day-group[data-today]");
|
||||
if (group) return group;
|
||||
const empty = artifactList.querySelector(".empty");
|
||||
if (empty) empty.remove();
|
||||
group = document.createElement("div");
|
||||
group.className = "day-group";
|
||||
group.setAttribute("data-today", "1");
|
||||
group.innerHTML =
|
||||
`<div class="day-separator"><span>${escapeHtml(todayLabel)} <em class="today-badge">Today</em></span>` +
|
||||
`<span class="day-separator-count">0</span></div>`;
|
||||
artifactList.insertBefore(group, artifactList.firstChild);
|
||||
return group;
|
||||
};
|
||||
|
||||
const markArrived = (rows) => {
|
||||
rows.forEach((row) => {
|
||||
const timeEl = row.querySelector(".artifact-time");
|
||||
if (timeEl) {
|
||||
timeEl.dataset.realtime = timeEl.textContent;
|
||||
timeEl.textContent = "now";
|
||||
setTimeout(() => {
|
||||
if (timeEl.dataset.realtime) timeEl.textContent = timeEl.dataset.realtime;
|
||||
}, NOW_TTL_MS);
|
||||
}
|
||||
row.classList.add("just-arrived");
|
||||
setTimeout(() => row.classList.remove("just-arrived"), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const pollUpdates = async () => {
|
||||
// Don't poll while viewing a system folder (Deleted/Keep) — new live
|
||||
// posts don't belong in those views.
|
||||
if (livePolling || document.hidden || !liveSince || feed.dataset.folder) return;
|
||||
livePolling = true;
|
||||
try {
|
||||
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!data.count) return;
|
||||
const group = ensureTodayGroup(data.today_label);
|
||||
const separator = group.querySelector(".day-separator");
|
||||
// Newest-first batch goes immediately below the separator, above prior rows.
|
||||
separator.insertAdjacentHTML("afterend", data.html);
|
||||
const inserted = [];
|
||||
let node = separator.nextElementSibling;
|
||||
while (node && inserted.length < data.count) {
|
||||
if (node.classList.contains("artifact-row")) inserted.push(node);
|
||||
node = node.nextElementSibling;
|
||||
}
|
||||
markArrived(inserted);
|
||||
registerNewSources(inserted); // update sidebar: counts + any new source
|
||||
liveSince = data.newest;
|
||||
const dayCount = group.querySelector(".day-separator-count");
|
||||
if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count);
|
||||
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
||||
if (header) {
|
||||
const total = (parseInt(header.textContent, 10) || 0) + data.count;
|
||||
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore; retry next tick
|
||||
} finally {
|
||||
livePolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(pollUpdates, LIVE_POLL_MS);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) pollUpdates(); });
|
||||
|
||||
// Populate the details panel for a server-preselected artifact so the first
|
||||
// "Details" click shows content immediately.
|
||||
const preselected = selectedRow();
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
</div>
|
||||
|
||||
<p>A self-hosted, multi-user journal where AI agents, scripts, or people
|
||||
publish messages — reports, HTML, decks, images, PDFs, JSON, CSV — and each
|
||||
publish artifacts — reports, HTML, decks, images, PDFs, JSON, CSV — and each
|
||||
logged-in user browses their own time-ordered, label-filterable feed. The
|
||||
server owns IDs, styling, and storage; agents publish content, not files.</p>
|
||||
|
||||
<p class="storage-note">Post to it with a personal API key from
|
||||
Settings → API Keys; the current API capabilities (including
|
||||
this version) are available at <code>/api/capabilities</code>.</p>
|
||||
<p class="storage-note">Post to it with a personal API token from
|
||||
Settings → Account; the current API capabilities (including this
|
||||
version) are available at <code>/api/capabilities</code>.</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -10,27 +10,4 @@
|
|||
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
||||
</p>
|
||||
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
||||
|
||||
<h3>Change password</h3>
|
||||
<p>Enter your current password, then choose a new one.</p>
|
||||
<form method="post" class="password-change">
|
||||
{% csrf_token %}
|
||||
{% if password_form.non_field_errors %}
|
||||
<ul class="form-errors">
|
||||
{% for error in password_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% for field in password_form %}
|
||||
<label class="form-field">
|
||||
<span>{{ field.label }}</span>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<ul class="form-errors">
|
||||
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@
|
|||
<span class="settings-nav-heading">You</span>
|
||||
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
|
||||
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
|
||||
<a class="{% if active_section == 'labels' %}selected{% endif %}" href="{% url 'settings-labels' %}">Labels</a>
|
||||
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
|
||||
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
{% block settings_content %}
|
||||
<h2>Demo Data</h2>
|
||||
<p>Generate one sample message of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
||||
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Generate Demo Data</button>
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
{% extends "artifacts/settings_base.html" %}
|
||||
|
||||
{% block settings_content %}
|
||||
<h2>Labels</h2>
|
||||
<p>
|
||||
Build a palette of labels you can apply to any message from its ⋯ menu
|
||||
in the feed. A label is just a coloured tag — applying one adds it to the
|
||||
message and it shows as a chip next to the title. Deleting a label here only
|
||||
removes it from this palette; messages that already carry it keep the chip.
|
||||
</p>
|
||||
|
||||
<h3>Add a label</h3>
|
||||
<form method="post" class="label-create">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="create">
|
||||
<label>Name <input type="text" name="name" placeholder="e.g. follow-up" required autocomplete="off" maxlength="40"></label>
|
||||
<label>Colour <input type="color" name="color" value="#4a6ea8"></label>
|
||||
<button type="submit">Add label</button>
|
||||
</form>
|
||||
|
||||
<h3>Your labels</h3>
|
||||
{% if labels %}
|
||||
<table class="label-table">
|
||||
<thead>
|
||||
<tr><th>Label</th><th>Colour</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for label in labels %}
|
||||
<tr>
|
||||
<td><span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span></td>
|
||||
<td>
|
||||
<form method="post" class="label-recolor">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="hidden" name="label_id" value="{{ label.pk }}">
|
||||
<input type="color" name="color" value="{{ label.color }}" onchange="this.form.submit()" title="Change colour">
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Remove "{{ label.name|escapejs }}" from your palette? Messages already labelled keep the chip.');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="label_id" value="{{ label.pk }}">
|
||||
<button type="submit" class="label-delete">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No labels yet. Add one above, then pick it from a message's ⋯ menu.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -11,14 +11,14 @@
|
|||
</p>
|
||||
|
||||
<ul class="storage-facts">
|
||||
<li>{{ artifact_count }} message{{ artifact_count|pluralize }} total</li>
|
||||
<li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
|
||||
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted</li>
|
||||
</ul>
|
||||
|
||||
<p class="storage-note">
|
||||
When your quota fills, the oldest <strong>unkept</strong> messages are deleted
|
||||
automatically to make room for new ones. Click <strong>Keep</strong> on a message
|
||||
to protect it. If the quota is full of kept messages, new posts are rejected until
|
||||
When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
|
||||
automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
|
||||
to protect it. If the quota is full of kept artifacts, new posts are rejected until
|
||||
you free space.
|
||||
</p>
|
||||
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<h3>Admin</h3>
|
||||
<ul class="storage-facts">
|
||||
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
|
||||
<li>Free space on the storage disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
|
||||
<li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
|
||||
</ul>
|
||||
{% if total_allocated > disk_free %}
|
||||
<p class="storage-warning">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from artifacts.models import ApiKey, Artifact
|
|||
User = get_user_model()
|
||||
|
||||
|
||||
@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests
|
||||
class ArtifactAPITestCase(APITestCase):
|
||||
"""Exercises the ingest/list/detail API against a real (temp) filesystem
|
||||
and a real (test) database, the way an agent posting over HTTP would."""
|
||||
|
|
@ -59,10 +58,9 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
# -- capabilities -------------------------------------------------
|
||||
|
||||
def test_capabilities_is_public(self):
|
||||
# Capability/contract info only, no user data — public, no auth needed.
|
||||
def test_capabilities_requires_auth(self):
|
||||
response = self.client.get(reverse("capabilities-api"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_capabilities_with_token(self):
|
||||
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
|
||||
|
|
@ -87,10 +85,9 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
# -- skill ----------------------------------------------------------
|
||||
|
||||
def test_skill_is_public(self):
|
||||
# Bootstrapping instructions, no secrets — public, no auth needed.
|
||||
def test_skill_requires_auth(self):
|
||||
response = self.client.get(reverse("skill-api"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_skill_returns_markdown_with_origin_filled_in(self):
|
||||
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
|
||||
|
|
@ -159,10 +156,9 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
# -- list (GET) -------------------------------------------------------
|
||||
|
||||
def test_list_requires_auth(self):
|
||||
# No session and no token -> JSON 403, never a browser-login redirect.
|
||||
def test_list_requires_session_login(self):
|
||||
response = self.client.get(reverse("artifact-api"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.status_code, 302) # redirected to login
|
||||
|
||||
def test_list_scoped_to_owner(self):
|
||||
self.create_artifact()
|
||||
|
|
@ -173,15 +169,6 @@ class ArtifactAPITestCase(APITestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
|
||||
|
||||
def test_list_readable_with_bearer_token(self):
|
||||
# Agents read back with the same token they post with; owner-scoped.
|
||||
self.create_artifact()
|
||||
self.create_artifact(token=self.other_token, title="Not mine")
|
||||
|
||||
response = self.client.get(reverse("artifact-api"), **self.auth_headers(self.token))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
|
||||
|
||||
def test_list_filter_by_label(self):
|
||||
self.create_artifact(tags=["urgent"])
|
||||
self.create_artifact(title="Second", tags=["later"])
|
||||
|
|
@ -233,67 +220,6 @@ class ArtifactAPITestCase(APITestCase):
|
|||
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_detail_readable_with_bearer_token(self):
|
||||
# The api_url returned by POST is fetchable with the posting token.
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
||||
response = self.client.get(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["id"], artifact_id)
|
||||
|
||||
# ...but only the owner's token; another user's token gets 404.
|
||||
response = self.client.get(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_detail_requires_auth(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
# -- raw content (GET {id}/raw) ---------------------------------------
|
||||
|
||||
def _raw_body(self, response):
|
||||
return b"".join(response.streaming_content).decode()
|
||||
|
||||
def test_raw_returns_exact_content_with_bearer(self):
|
||||
create = self.create_artifact(content="# Exact\n\nverify me")
|
||||
artifact_id = create.data["id"]
|
||||
# metadata detail advertises the raw endpoint
|
||||
self.assertTrue(create.data["api_raw_url"].endswith(f"/api/artifacts/{artifact_id}/raw"))
|
||||
|
||||
response = self.client.get(
|
||||
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||
# Content round-trips faithfully; normalize newlines since the stored
|
||||
# sidecar is written in the host's text mode (CRLF on Windows dev, LF on
|
||||
# the Linux deploy).
|
||||
self.assertEqual(self._raw_body(response).replace("\r\n", "\n"), "# Exact\n\nverify me")
|
||||
|
||||
def test_raw_requires_auth(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
response = self.client.get(reverse("artifact-raw-api", args=[artifact_id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_raw_owner_scoped(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
response = self.client.get(
|
||||
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.other_token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_raw_not_available_for_render_only_kind(self):
|
||||
artifact_id = self.create_artifact(kind="html_fragment", content="<p>hi</p>").data["id"]
|
||||
response = self.client.get(
|
||||
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_patch_updates_title_and_tags(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
response = self.client.patch(
|
||||
|
|
@ -335,8 +261,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
# -- delete (DELETE) --------------------------------------------------
|
||||
|
||||
def test_delete_soft_deletes_to_trash(self):
|
||||
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
|
||||
def test_delete_removes_artifact_and_files(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
artifact = Artifact.objects.get(pk=artifact_id)
|
||||
stored_path = self.artifacts_root / artifact.path
|
||||
|
|
@ -346,12 +271,9 @@ class ArtifactAPITestCase(APITestCase):
|
|||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
# Row still exists, marked deleted; files kept on disk until purge.
|
||||
artifact.refresh_from_db()
|
||||
self.assertIsNotNone(artifact.deleted_at)
|
||||
self.assertTrue(stored_path.exists())
|
||||
# But it's gone from the API's list/detail (hidden like the feed).
|
||||
self.assertEqual(self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)).data, [])
|
||||
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
|
||||
self.assertFalse(stored_path.exists())
|
||||
self.assertFalse(stored_path.with_suffix(".md").exists())
|
||||
|
||||
def test_delete_requires_token(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
|
@ -367,8 +289,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||
|
||||
def test_delete_is_idempotent(self):
|
||||
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
|
||||
def test_delete_twice_second_call_404s(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
first = self.client.delete(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
|
|
@ -378,7 +299,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
second = self.client.delete(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(second.status_code, 204)
|
||||
self.assertEqual(second.status_code, 404)
|
||||
|
||||
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
from datetime import timedelta
|
||||
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class FeedUpdatesViewTestCase(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._n = 0
|
||||
|
||||
def make(self, owner=None, title="Note", source="agent"):
|
||||
self._n += 1
|
||||
return Artifact.objects.create(
|
||||
id=f"upd{self._n:05d}",
|
||||
owner=owner or self.user,
|
||||
source=source,
|
||||
title=title,
|
||||
kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(),
|
||||
path=f"{source}/x.html",
|
||||
original_kind=Artifact.MARKDOWN,
|
||||
size_bytes=1,
|
||||
)
|
||||
|
||||
def marker(self, seconds_ago=60):
|
||||
return (timezone.now() - timedelta(seconds=seconds_ago)).isoformat()
|
||||
|
||||
def get(self, **params):
|
||||
return self.client.get(reverse("feed-updates"), params)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.get(after=self.marker())
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_new_since_marker(self):
|
||||
self.make(title="Fresh Post")
|
||||
response = self.get(after=self.marker())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertIn("Fresh Post", data["html"])
|
||||
self.assertIn("newest", data)
|
||||
self.assertIn("today_label", data)
|
||||
|
||||
def test_nothing_newer_than_now(self):
|
||||
self.make()
|
||||
response = self.get(after=self.marker(seconds_ago=-60)) # 60s in the future
|
||||
self.assertEqual(response.json()["count"], 0)
|
||||
|
||||
def test_missing_marker_returns_zero(self):
|
||||
self.make()
|
||||
self.assertEqual(self.get().json()["count"], 0)
|
||||
|
||||
def test_owner_scoped(self):
|
||||
self.make(owner=self.other, title="Not mine")
|
||||
data = self.get(after=self.marker()).json()
|
||||
self.assertEqual(data["count"], 0)
|
||||
|
||||
def test_respects_source_filter(self):
|
||||
self.make(title="Hermes post", source="hermes")
|
||||
self.make(title="Janus post", source="janus")
|
||||
data = self.get(after=self.marker(), source="hermes").json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertIn("Hermes post", data["html"])
|
||||
self.assertNotIn("Janus post", data["html"])
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ChangePasswordViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="oldpassword")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _post(self, old, new1, new2):
|
||||
return self.client.post(
|
||||
reverse("settings-account"),
|
||||
{"old_password": old, "new_password1": new1, "new_password2": new2},
|
||||
)
|
||||
|
||||
def test_get_renders_form(self):
|
||||
response = self.client.get(reverse("settings-account"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Change password")
|
||||
self.assertContains(response, "old_password")
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("settings-account"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_valid_change_updates_password(self):
|
||||
response = self._post("oldpassword", "brandnewpass", "brandnewpass")
|
||||
self.assertRedirects(response, reverse("settings-account"))
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("brandnewpass"))
|
||||
self.assertFalse(self.user.check_password("oldpassword"))
|
||||
|
||||
def test_valid_change_keeps_session(self):
|
||||
self._post("oldpassword", "brandnewpass", "brandnewpass")
|
||||
# Session must survive the password change (update_session_auth_hash),
|
||||
# so a follow-up authenticated request still succeeds.
|
||||
response = self.client.get(reverse("settings-account"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_wrong_current_password_rejected(self):
|
||||
response = self._post("WRONG", "brandnewpass", "brandnewpass")
|
||||
self.assertEqual(response.status_code, 200) # re-render with errors, no redirect
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("oldpassword"))
|
||||
|
||||
def test_mismatched_new_password_rejected(self):
|
||||
response = self._post("oldpassword", "brandnewpass", "different")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("oldpassword"))
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from artifacts.models import Artifact, Label, TagColor
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LabelPaletteTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="pw")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def url(self):
|
||||
return reverse("settings-labels")
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get(self.url()).status_code, 302)
|
||||
|
||||
def test_create_label_sets_palette_and_tagcolor(self):
|
||||
self.client.post(self.url(), {"action": "create", "name": "follow-up", "color": "#112233"})
|
||||
label = Label.objects.get(user=self.user, name="follow-up")
|
||||
self.assertEqual(label.color, "#112233")
|
||||
self.assertEqual(TagColor.objects.get(name="follow-up").color, "#112233")
|
||||
|
||||
def test_create_rejects_bad_color(self):
|
||||
self.client.post(self.url(), {"action": "create", "name": "x", "color": "red"})
|
||||
self.assertFalse(Label.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_create_requires_name(self):
|
||||
self.client.post(self.url(), {"action": "create", "name": " ", "color": "#112233"})
|
||||
self.assertFalse(Label.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_duplicate_name_same_user_rejected(self):
|
||||
Label.objects.create(user=self.user, name="dup", color="#111111")
|
||||
self.client.post(self.url(), {"action": "create", "name": "dup", "color": "#222222"})
|
||||
self.assertEqual(Label.objects.filter(user=self.user, name="dup").count(), 1)
|
||||
|
||||
def test_same_name_different_user_allowed(self):
|
||||
other = User.objects.create_user(username="bob", password="pw")
|
||||
Label.objects.create(user=other, name="shared", color="#111111")
|
||||
self.client.post(self.url(), {"action": "create", "name": "shared", "color": "#222222"})
|
||||
self.assertTrue(Label.objects.filter(user=self.user, name="shared").exists())
|
||||
|
||||
def test_recolor_updates_tagcolor(self):
|
||||
label = Label.objects.create(user=self.user, name="urgent", color="#111111")
|
||||
self.client.post(self.url(), {"action": "update", "label_id": label.pk, "color": "#ff0000"})
|
||||
label.refresh_from_db()
|
||||
self.assertEqual(label.color, "#ff0000")
|
||||
self.assertEqual(TagColor.objects.get(name="urgent").color, "#ff0000")
|
||||
|
||||
def test_delete_removes_from_palette_only(self):
|
||||
label = Label.objects.create(user=self.user, name="temp", color="#111111")
|
||||
art = Artifact.objects.create(
|
||||
id="lbl00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(), path="agent/lbl00001.html", original_kind=Artifact.MARKDOWN,
|
||||
tags=["temp"],
|
||||
)
|
||||
self.client.post(self.url(), {"action": "delete", "label_id": label.pk})
|
||||
self.assertFalse(Label.objects.filter(pk=label.pk).exists())
|
||||
art.refresh_from_db()
|
||||
self.assertEqual(art.tags, ["temp"]) # chip stays on the message
|
||||
|
||||
|
||||
class ToggleLabelTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="pw")
|
||||
self.other = User.objects.create_user(username="bob", password="pw")
|
||||
self.client.force_login(self.user)
|
||||
self.label = Label.objects.create(user=self.user, name="star", color="#abcdef")
|
||||
self.art = Artifact.objects.create(
|
||||
id="tog00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(), path="agent/tog00001.html", original_kind=Artifact.MARKDOWN,
|
||||
)
|
||||
|
||||
def toggle(self, art_id, name):
|
||||
return self.client.post(reverse("artifact-toggle-label", args=[art_id]), {"name": name})
|
||||
|
||||
def test_toggle_on_then_off(self):
|
||||
r = self.toggle(self.art.id, "star")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json()["applied"])
|
||||
self.art.refresh_from_db()
|
||||
self.assertIn("star", self.art.tags)
|
||||
|
||||
r = self.toggle(self.art.id, "star")
|
||||
self.assertFalse(r.json()["applied"])
|
||||
self.art.refresh_from_db()
|
||||
self.assertNotIn("star", self.art.tags)
|
||||
|
||||
def test_toggle_syncs_tagcolor(self):
|
||||
self.toggle(self.art.id, "star")
|
||||
self.assertEqual(TagColor.objects.get(name="star").color, "#abcdef")
|
||||
|
||||
def test_name_not_in_palette_rejected(self):
|
||||
r = self.toggle(self.art.id, "nope")
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.art.refresh_from_db()
|
||||
self.assertEqual(self.art.tags, [])
|
||||
|
||||
def test_owner_scoped(self):
|
||||
theirs = Artifact.objects.create(
|
||||
id="tog00002", owner=self.other, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(), path="agent/tog00002.html", original_kind=Artifact.MARKDOWN,
|
||||
)
|
||||
self.assertEqual(self.toggle(theirs.id, "star").status_code, 404)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(self.toggle(self.art.id, "star").status_code, 302)
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import shutil
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from artifacts.models import Artifact
|
||||
from artifacts.storage import purge_expired_trash, user_usage_bytes
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# Run the purge inline (no background thread) so its effects are deterministic.
|
||||
@override_settings(AGENTFEED_ASYNC_PURGE=False)
|
||||
class TrashKeepViewTestCase(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-trash-"))
|
||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||
cls.settings_override.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.settings_override.disable()
|
||||
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="pw")
|
||||
self.other = User.objects.create_user(username="bob", password="pw")
|
||||
self.client.force_login(self.user)
|
||||
self._n = 0
|
||||
|
||||
def make(self, owner=None, title="Note", source="agent", is_kept=False, size_bytes=1):
|
||||
self._n += 1
|
||||
return Artifact.objects.create(
|
||||
id=f"trs{self._n:05d}",
|
||||
owner=owner or self.user,
|
||||
source=source,
|
||||
title=title,
|
||||
kind=Artifact.MARKDOWN,
|
||||
day=timezone.localdate(),
|
||||
path=f"{source}/trs{self._n:05d}.html",
|
||||
original_kind=Artifact.MARKDOWN,
|
||||
size_bytes=size_bytes,
|
||||
is_kept=is_kept,
|
||||
)
|
||||
|
||||
def test_delete_soft_deletes(self):
|
||||
a = self.make()
|
||||
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
a.refresh_from_db()
|
||||
self.assertIsNotNone(a.deleted_at)
|
||||
|
||||
def test_delete_requires_login(self):
|
||||
self.client.logout()
|
||||
a = self.make()
|
||||
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_delete_scoped_to_owner(self):
|
||||
a = self.make(owner=self.other)
|
||||
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 404)
|
||||
a.refresh_from_db()
|
||||
self.assertIsNone(a.deleted_at)
|
||||
|
||||
def test_deleted_hidden_from_feed_shown_in_trash(self):
|
||||
a = self.make(title="ThrowawayThing")
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertNotIn("ThrowawayThing", self.client.get("/").content.decode())
|
||||
self.assertIn("ThrowawayThing", self.client.get("/?folder=deleted").content.decode())
|
||||
|
||||
def test_undelete_restores(self):
|
||||
a = self.make(title="ComeBack")
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
r = self.client.post(reverse("artifact-undelete", args=[a.id]))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
a.refresh_from_db()
|
||||
self.assertIsNone(a.deleted_at)
|
||||
self.assertIn("ComeBack", self.client.get("/").content.decode())
|
||||
|
||||
def test_empty_trash_purges(self):
|
||||
a = self.make()
|
||||
b = self.make()
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.client.post(reverse("artifact-delete", args=[b.id]))
|
||||
r = self.client.post(reverse("feed-empty-trash"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()["purged"], 2)
|
||||
self.assertFalse(Artifact.objects.filter(pk__in=[a.id, b.id]).exists())
|
||||
|
||||
def test_deleted_is_off_quota(self):
|
||||
a = self.make(size_bytes=1000)
|
||||
self.assertEqual(user_usage_bytes(self.user), 1000)
|
||||
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||
self.assertEqual(user_usage_bytes(self.user), 0)
|
||||
|
||||
def test_keep_folder_shows_only_kept(self):
|
||||
self.make(title="KeptOne", is_kept=True)
|
||||
self.make(title="NormalOne")
|
||||
page = self.client.get("/?folder=keep").content.decode()
|
||||
self.assertIn("KeptOne", page)
|
||||
self.assertNotIn("NormalOne", page)
|
||||
|
||||
def test_purge_expired_trash_removes_old_only(self):
|
||||
old = self.make()
|
||||
recent = self.make()
|
||||
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
|
||||
Artifact.objects.filter(pk=recent.id).update(deleted_at=timezone.now())
|
||||
purged = purge_expired_trash()
|
||||
self.assertEqual(purged, 1)
|
||||
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||
self.assertTrue(Artifact.objects.filter(pk=recent.id).exists())
|
||||
|
||||
def test_delete_triggers_expired_purge(self):
|
||||
old = self.make()
|
||||
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
|
||||
fresh = self.make()
|
||||
# Deleting fresh runs the purge (inline here) -> the expired one goes,
|
||||
# the freshly-trashed one stays.
|
||||
self.client.post(reverse("artifact-delete", args=[fresh.id]))
|
||||
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||
self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())
|
||||
|
||||
def test_mark_all_read_across_sources(self):
|
||||
self.make(source="a")
|
||||
self.make(source="b")
|
||||
r = self.client.post(reverse("feed-mark-read"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()["marked"], 2)
|
||||
self.assertFalse(Artifact.objects.filter(owner=self.user, read_at__isnull=True).exists())
|
||||
|
||||
def test_mark_all_read_scoped_to_source(self):
|
||||
a = self.make(source="hermes")
|
||||
b = self.make(source="janus")
|
||||
self.assertEqual(self.client.post(reverse("feed-mark-read"), {"source": "hermes"}).json()["marked"], 1)
|
||||
a.refresh_from_db()
|
||||
b.refresh_from_db()
|
||||
self.assertIsNotNone(a.read_at)
|
||||
self.assertIsNone(b.read_at)
|
||||
|
||||
def test_mark_all_read_owner_scoped(self):
|
||||
theirs = self.make(owner=self.other)
|
||||
self.client.post(reverse("feed-mark-read"))
|
||||
theirs.refresh_from_db()
|
||||
self.assertIsNone(theirs.read_at)
|
||||
|
||||
def test_mark_all_read_excludes_trashed(self):
|
||||
a = self.make()
|
||||
Artifact.objects.filter(pk=a.id).update(deleted_at=timezone.now())
|
||||
self.assertEqual(self.client.post(reverse("feed-mark-read")).json()["marked"], 0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue