commit 3974276c9b80bd73b0d179cf5b10f96f0b5b0134 Author: peter Date: Wed Jun 10 15:48:19 2026 +0100 Initial Daybook MVP diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..624a5fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python + +# Virtualenvs +.venv/ +venv/ +env/ + +# Django/local runtime +db.sqlite3 +artifacts_store/ +django-runserver.log +django-runserver.err.log + +# Static/build/cache +staticfiles/ +.coverage +htmlcov/ +.pytest_cache/ + +# Secrets/env +.env +.env.* + +# IDE/OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fdc260b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md + +## Project + +Daybook is a self-hosted Django + Django REST Framework web app where AI agents publish artifacts and a human browses them in a time-ordered, persona-filterable feed. + +The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs. + +## Current MVP Decisions + +- Use a single shared ingest API token for now. +- Require `persona` in every artifact payload. +- Do not add reader authentication in the local/self-hosted MVP. +- 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`. + +## Architecture Notes + +- Planning source: `planning/daybook_planning.md`. +- Django project: `daybook_project/`. +- Main app: `artifacts/`. +- Theme CSS: `static/themes/`. +- Feed template: `templates/artifacts/feed.html`. +- Stored artifact files live under `artifacts_store/{persona}/{YYYY-MM-DD}/`. + +## Implementation Guidelines + +- Prefer small, direct changes over broad abstractions. +- Keep ingest normalization server-side. +- The viewer should only need to render HTML, image, or PDF. +- Do not make agents responsible for styling, filenames, or storage paths. +- Preserve existing planning decisions unless the user explicitly changes them. +- Avoid adding backwards-compatibility code before anything has shipped. + +## Commands + +- Install dependencies: `pip install -r requirements.txt` +- Run migrations: `python manage.py migrate` +- Start dev server: `python manage.py runserver` +- Django checks: `python manage.py check` + +## API Defaults + +- MVP ingest endpoint: `POST /api/artifacts` +- MVP list endpoint: `GET /api/artifacts?persona=&day=&tag=` +- Artifact view: `GET /a/{id}` +- Development token: `Authorization: Bearer dev-token` + +## OpenCode + +This project is initialized with `opencode.json`, which loads this file as project instructions. After changing `opencode.json`, `.opencode/`, or this file, restart OpenCode so the updated instructions are loaded. diff --git a/artifacts/__init__.py b/artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/admin.py b/artifacts/admin.py new file mode 100644 index 0000000..1fcc62f --- /dev/null +++ b/artifacts/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Artifact + + +@admin.register(Artifact) +class ArtifactAdmin(admin.ModelAdmin): + list_display = ("id", "title", "persona", "kind", "day", "created_at") + list_filter = ("persona", "kind", "day") + search_fields = ("id", "title", "persona") diff --git a/artifacts/apps.py b/artifacts/apps.py new file mode 100644 index 0000000..b296983 --- /dev/null +++ b/artifacts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ArtifactsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "artifacts" diff --git a/artifacts/migrations/0001_initial.py b/artifacts/migrations/0001_initial.py new file mode 100644 index 0000000..e2d38dd --- /dev/null +++ b/artifacts/migrations/0001_initial.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Artifact", + fields=[ + ("id", models.CharField(max_length=8, primary_key=True, serialize=False)), + ("persona", models.CharField(db_index=True, max_length=80)), + ("title", models.CharField(max_length=200)), + ("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)), + ("theme", models.CharField(blank=True, max_length=40)), + ("day", models.DateField(db_index=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("path", models.CharField(max_length=500)), + ("original_kind", models.CharField(max_length=20)), + ("tags", models.JSONField(blank=True, default=list)), + ("source_prompt", models.TextField(blank=True)), + ], + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/artifacts/migrations/__init__.py b/artifacts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/models.py b/artifacts/models.py new file mode 100644 index 0000000..249f728 --- /dev/null +++ b/artifacts/models.py @@ -0,0 +1,39 @@ +from django.db import models + + +class Artifact(models.Model): + HTML_FRAGMENT = "html_fragment" + HTML_FULL = "html_full" + MARKDOWN = "markdown" + DECK = "deck" + IMAGE = "image" + PDF = "pdf" + BINARY = "binary" + + KIND_CHOICES = [ + (HTML_FRAGMENT, "HTML fragment"), + (HTML_FULL, "Full HTML"), + (MARKDOWN, "Markdown"), + (DECK, "Deck"), + (IMAGE, "Image"), + (PDF, "PDF"), + (BINARY, "Binary"), + ] + + id = models.CharField(max_length=8, primary_key=True) + persona = models.CharField(max_length=80, db_index=True) + title = models.CharField(max_length=200) + kind = models.CharField(max_length=20, choices=KIND_CHOICES) + theme = models.CharField(max_length=40, blank=True) + day = models.DateField(db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + path = models.CharField(max_length=500) + original_kind = models.CharField(max_length=20) + tags = models.JSONField(default=list, blank=True) + source_prompt = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.title} ({self.persona})" diff --git a/artifacts/urls.py b/artifacts/urls.py new file mode 100644 index 0000000..4215301 --- /dev/null +++ b/artifacts/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("", views.feed, name="feed"), + path("a/", views.artifact_view, name="artifact"), + path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), +] diff --git a/artifacts/views.py b/artifacts/views.py new file mode 100644 index 0000000..7c2b847 --- /dev/null +++ b/artifacts/views.py @@ -0,0 +1,343 @@ +import html +import mimetypes +import secrets +from pathlib import Path +from urllib.parse import urlencode + +import markdown +from django.conf import settings +from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404, render +from django.template.defaultfilters import slugify +from django.urls import reverse +from django.utils.dateparse import parse_date +from django.utils import timezone +from django.views.decorators.clickjacking import xframe_options_sameorigin +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Artifact + + +TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK} +DEFAULT_THEMES = { + Artifact.HTML_FRAGMENT: "report", + Artifact.MARKDOWN: "report", + Artifact.DECK: "deck", +} + + +class ArtifactListCreateView(APIView): + parser_classes = [JSONParser, FormParser, MultiPartParser] + + def get(self, request): + artifacts = Artifact.objects.all() + if persona := request.query_params.get("persona"): + artifacts = artifacts.filter(persona=persona) + if day := request.query_params.get("day"): + artifacts = artifacts.filter(day=day) + if tag := request.query_params.get("tag"): + artifacts = artifacts.filter(tags__contains=[tag]) + + return Response([serialize_artifact(artifact, request) for artifact in artifacts]) + + def post(self, request): + if not has_valid_token(request): + return HttpResponseForbidden("Invalid or missing API token") + + if request.content_type and request.content_type.startswith("multipart/"): + artifact = create_binary_artifact(request.data) + if isinstance(artifact, Response): + return artifact + return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) + + data = request.data + kind = data.get("kind") + if kind not in TEXT_KINDS: + return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400) + + required = ["persona", "title", "content"] + missing = [field for field in required if not data.get(field)] + if missing: + return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) + + artifact = create_text_artifact(data, request) + return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) + + +def feed(request): + persona = request.GET.get("persona", "") + selected_kind = request.GET.get("kind", "") + title_query = request.GET.get("q", "") + selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate() + selected_artifact_id = request.GET.get("artifact", "") + selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" + all_artifacts = Artifact.objects.all() + artifacts = all_artifacts + if persona: + artifacts = artifacts.filter(persona=persona) + if selected_kind: + artifacts = artifacts.filter(kind=selected_kind) + if title_query: + artifacts = artifacts.filter(title__icontains=title_query) + + days = list(artifacts.dates("day", "day", order="DESC")) + if selected_day not in days: + days.append(selected_day) + days.sort(reverse=True) + + day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at") + selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first() + toggle_query = {"day": selected_day.isoformat(), "view": "rendered"} + if persona: + toggle_query["persona"] = persona + if selected_kind: + toggle_query["kind"] = selected_kind + if title_query: + toggle_query["q"] = title_query + if selected_artifact: + toggle_query["artifact"] = selected_artifact.id + rendered_url = f"?{urlencode(toggle_query)}" + toggle_query["view"] = "raw" + raw_url = f"?{urlencode(toggle_query)}" + personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct() + return render( + request, + "artifacts/feed.html", + { + "artifacts": day_artifacts, + "days": days, + "personas": personas, + "raw_url": raw_url, + "rendered_url": rendered_url, + "selected_artifact": selected_artifact, + "selected_day": selected_day, + "selected_kind": selected_kind, + "selected_persona": persona, + "selected_view": selected_view, + "title_query": title_query, + "kind_choices": Artifact.KIND_CHOICES, + }, + ) + + +@xframe_options_sameorigin +def artifact_view(request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id) + if request.GET.get("raw") == "1": + 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") + return HttpResponse(raw_unavailable_html(artifact), content_type="text/html") + + path = safe_artifact_path(artifact.path) + if not path.exists(): + raise Http404("Artifact file not found") + + content_type, _ = mimetypes.guess_type(path.name) + return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") + + +def create_text_artifact(data, request): + now = timezone.localtime() + artifact_id = generate_id() + persona = slugify(data["persona"]) or "unknown" + kind = data["kind"] + theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base") + title = data["title"] + content = data["content"] + + directory = Path(persona) / now.date().isoformat() + target_dir = settings.ARTIFACTS_ROOT / directory + target_dir.mkdir(parents=True, exist_ok=True) + + if kind == Artifact.HTML_FULL: + rendered = content + elif kind == Artifact.MARKDOWN: + rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request) + (target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8") + elif kind == Artifact.DECK: + rendered = wrap_deck(content, title, request) + else: + rendered = wrap_html(content, title, theme, request) + + stored_path = directory / f"{artifact_id}.html" + (settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8") + + return Artifact.objects.create( + id=artifact_id, + persona=persona, + title=title, + kind=kind, + theme=theme, + day=now.date(), + path=stored_path.as_posix(), + original_kind=kind, + tags=normalize_tags(data.get("tags", [])), + source_prompt=data.get("source_prompt", ""), + ) + + +def create_binary_artifact(data): + uploaded = data.get("file") + required = ["persona", "title"] + missing = [field for field in required if not data.get(field)] + if not uploaded: + missing.append("file") + if missing: + return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) + + now = timezone.localtime() + artifact_id = generate_id() + persona = slugify(data["persona"]) or "unknown" + title = data["title"] + content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" + kind = binary_kind(content_type) + + directory = Path(persona) / now.date().isoformat() + target_dir = settings.ARTIFACTS_ROOT / directory + target_dir.mkdir(parents=True, exist_ok=True) + + suffix = safe_suffix(uploaded.name, content_type) + stored_path = directory / f"{artifact_id}{suffix}" + with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target: + for chunk in uploaded.chunks(): + target.write(chunk) + + return Artifact.objects.create( + id=artifact_id, + persona=persona, + title=title, + kind=kind, + theme="", + day=now.date(), + path=stored_path.as_posix(), + original_kind=kind, + tags=normalize_tags(data.get("tags", [])), + source_prompt=data.get("source_prompt", ""), + ) + + +def wrap_html(body, title, theme, request): + stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css") + return f""" + + + + + {html.escape(title)} + + + +{body} + + +""" + + +def wrap_deck(body, title, request): + stylesheet = request.build_absolute_uri("/static/themes/deck.css") + return f""" + + + + + {html.escape(title)} + + + +
{body}
+ + +""" + + +def generate_id(): + while True: + artifact_id = secrets.token_hex(4) + if not Artifact.objects.filter(pk=artifact_id).exists(): + return artifact_id + + +def has_valid_token(request): + expected = getattr(settings, "DAYBOOK_API_TOKEN", "") + auth = request.headers.get("Authorization", "") + return bool(expected) and auth == f"Bearer {expected}" + + +def normalize_tags(tags): + if isinstance(tags, str): + return [tag.strip() for tag in tags.split(",") if tag.strip()] + if isinstance(tags, list): + return [str(tag).strip() for tag in tags if str(tag).strip()] + return [] + + +def binary_kind(content_type): + if content_type.startswith("image/"): + return Artifact.IMAGE + if content_type == "application/pdf": + return Artifact.PDF + return Artifact.BINARY + + +def safe_suffix(filename, content_type): + suffix = Path(filename).suffix.lower() + if suffix and suffix[1:].isalnum() and len(suffix) <= 10: + return suffix + return mimetypes.guess_extension(content_type) or ".bin" + + +def serialize_artifact(artifact, request): + return { + "id": artifact.id, + "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), + "persona": artifact.persona, + "title": artifact.title, + "kind": artifact.kind, + "theme": artifact.theme, + "day": artifact.day.isoformat(), + "created_at": artifact.created_at.isoformat(), + "tags": artifact.tags, + "source_prompt": artifact.source_prompt, + } + + +def safe_artifact_path(relative_path): + root = settings.ARTIFACTS_ROOT.resolve() + path = (root / relative_path).resolve() + if root not in path.parents and path != root: + raise Http404("Invalid artifact path") + return path + + +def raw_artifact_path(artifact): + path = safe_artifact_path(artifact.path) + if artifact.kind == Artifact.MARKDOWN: + return path.with_suffix(".md") + if artifact.kind == Artifact.HTML_FULL: + return path + return None + + +def raw_unavailable_html(artifact): + return f""" + + + + + Raw source unavailable + + + +
+

Raw source unavailable

+

Daybook does not have preserved raw source for {html.escape(artifact.title)}.

+
+ + +""" diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daybook_project/asgi.py b/daybook_project/asgi.py new file mode 100644 index 0000000..ff1dd2e --- /dev/null +++ b/daybook_project/asgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.asgi import get_asgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings") + +application = get_asgi_application() diff --git a/daybook_project/settings.py b/daybook_project/settings.py new file mode 100644 index 0000000..805cbf7 --- /dev/null +++ b/daybook_project/settings.py @@ -0,0 +1,70 @@ +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = "dev-only-change-me" +DEBUG = True +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "artifacts", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "daybook_project.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "daybook_project.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +ARTIFACTS_ROOT = BASE_DIR / "artifacts_store" +DAYBOOK_API_TOKEN = "dev-token" diff --git a/daybook_project/urls.py b/daybook_project/urls.py new file mode 100644 index 0000000..07b927f --- /dev/null +++ b/daybook_project/urls.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from django.urls import include, path + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("artifacts.urls")), +] diff --git a/daybook_project/wsgi.py b/daybook_project/wsgi.py new file mode 100644 index 0000000..4c9ad58 --- /dev/null +++ b/daybook_project/wsgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..6914cca --- /dev/null +++ b/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..1ec6f3f --- /dev/null +++ b/opencode.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["AGENTS.md"], + "permission": "allow" +} diff --git a/planning/daybook_planning.md b/planning/daybook_planning.md new file mode 100644 index 0000000..93fc1b9 --- /dev/null +++ b/planning/daybook_planning.md @@ -0,0 +1,216 @@ +# Daybook — Project Planning + +## Purpose + +**Daybook** is a self-hosted web application where AI agents publish their +outputs (task lists, research reports, slide decks, images, etc.) and a human +browses them in a **time-ordered, agent-filterable feed** — viewing everything +**in-browser without downloading or "opening" files**. + +The defining feature is the integration: an agent calls a REST API and posts +*content*, not files. For HTML it can send just a body fragment and Daybook +supplies the CSS. The agent never deals with styling, file naming, or storage. + +## Core principles + +- **View, don't download.** Everything renders inline in the browser. +- **Agent-agnostic.** Any agent/persona can publish; the human filters by persona. +- **Time-ordered.** The primary axis is the day; flipping back to yesterday is trivial. +- **Minimal agent burden.** Agents send content + a few metadata fields. The server + owns styling, storage layout, IDs, and dates. +- **Self-hosted.** A single deployable unit running on the user's own machine/server. +- **Dumb viewer, smart ingest.** Normalize every input to three browser-native forms + (HTML, image, PDF) at upload time, so the viewer never needs plugins. + +## Actors + +- **Agent (publisher):** an authenticated machine client (e.g. Claude Code, a + persona script). POSTs artifacts via REST. +- **Human (reader):** browses, filters, and views artifacts; later edits markdown + and shares links. + +## Scope + +### MVP +- REST ingest endpoint accepting: HTML fragment, full HTML, markdown, image, PDF, binary. +- Server-side **named CSS themes**; automatic wrapping of HTML fragments. +- Storage in a `persona/day/id` layout plus a metadata table. +- Web viewer: reverse-chronological feed, persona filter, in-browser rendering. + +### Later +- In-browser **markdown editing** with write-back (the name "Daybook" anticipates this). +- Slide decks via a **reveal.js** theme; `pptx`/`docx`/`xlsx` → PDF fallback. +- **Share links** (password + expiry) for individual artifacts. +- Tags, search, and full-text indexing. + +## Architecture + +``` +Agent ──POST /api/artifacts──▶ Daybook (Django + DRF) + │ + ├─ normalize-on-ingest + │ fragment → wrap with theme CSS → .html + │ markdown → render → .html + │ deck → reveal.js scaffold → .html + │ office → LibreOffice headless → .pdf + │ image/pdf → store as-is + │ + ├─ write file to artifacts/{persona}/{day}/{id}.{ext} + └─ insert Artifact row (metadata index) + +Human ──GET / (feed)──▶ list Artifact rows (reverse-chron, persona filter) + ──GET /a/{id}──▶ render via + {% elif selected_artifact.kind == "image" %} + {{ selected_artifact.title }} + {% elif selected_artifact.kind == "binary" %} +
+

Binary Artifact

+

This artifact type cannot be previewed inline yet.

+

Open or download it.

+
+ {% else %} + + {% endif %} + + + {% else %} +
+

Select an artifact

+

Choose an item from the middle column to preview it here.

+

POST to /api/artifacts with Authorization: Bearer dev-token.

+
+ {% endif %} + + + + + +