diff --git a/.env.example b/.env.example index edb147aaf..f1b396a47 100644 --- a/.env.example +++ b/.env.example @@ -101,9 +101,9 @@ SEARXNG_INSTANCE=http://localhost:8080 # Skip the external-context exact-approval pause for unattended local agents. # Keep false for shared or internet-exposed deployments. -# Optional post-external-context tool approval gate. Off by default because it -# can block normal agent work; enable only for deployments that want this fence. -# ODYSSEUS_TOOL_APPROVAL_GATE=0 +# Post-external-context tool approval gate. On by default; set to 0 only for +# trusted single-user setups that accept injected content reaching side-effect tools. +# ODYSSEUS_TOOL_APPROVAL_GATE=1 # Mark session cookies Secure. Left unset, this follows the request scheme: # an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to diff --git a/.gitattributes b/.gitattributes index 8681aee12..e2ae3a6e6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,10 @@ entrypoint.sh text eol=lf docker/entrypoint.sh text eol=lf +# Maintained frontend modules and this lane's byte-identical deployment output. +frontend/src/** text eol=lf +static/js/actionMenuOrder.js text eol=lf + # Windows-native scripts stay CRLF. *.ps1 text eol=crlf *.cmd text eol=crlf @@ -32,3 +36,7 @@ static/lib/** -whitespace linguist-vendored *.ico binary *.woff binary *.woff2 binary + +# Notes reference deployment modules. +static/js/features/notes/** text eol=lf +static/js/shared/ui/confetti.js text eol=lf diff --git a/.github/scripts/validate-searxng-image.sh b/.github/scripts/validate-searxng-image.sh new file mode 100644 index 000000000..3edd5ba16 --- /dev/null +++ b/.github/scripts/validate-searxng-image.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Exercise an already-built image through the normal Compose startup path. +set -euo pipefail + +searxng_repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +searxng_image=${1:?usage: validate-searxng-image.sh IMAGE} +[[ "$searxng_image" =~ ^[a-zA-Z0-9_./:@+-]+$ ]] || { + echo "Invalid image reference" >&2 + exit 2 +} +searxng_checks=$(mktemp -d "${TMPDIR:-/tmp}/searxng-ci-XXXXXXXX") +searxng_project=$(basename "$searxng_checks" | tr '[:upper:]' '[:lower:]') +export SEARXNG_VALIDATION_IMAGE="$searxng_image" +export SEARXNG_VALIDATION_CHECKS="$searxng_checks" +export SEARXNG_VALIDATION_VOLUME="${searxng_project}-settings" +# Never forward an operator's existing secret or load their .env for a CI probe. +export SEARXNG_SECRET=searxng-ci-validation-only +searxng_volume_created=0 +searxng_started=0 +searxng_compose=(docker compose --env-file /dev/null --project-name "$searxng_project" + -f "$searxng_repo/docker-compose.yml" + -f "$searxng_repo/.github/searxng-validation.compose.yml") + +searxng_cleanup() { + local searxng_exit=$? + if [ "$searxng_started" = 1 ]; then + "${searxng_compose[@]}" down --remove-orphans >/dev/null 2>&1 || true + fi + if [ "$searxng_volume_created" = 1 ]; then + docker volume rm "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1 || true + fi + rm -rf -- "$searxng_checks" + return "$searxng_exit" +} +trap searxng_cleanup EXIT + +install -m 0644 "$searxng_repo/scripts/check_ca_trust.py" \ + "$searxng_repo/docker/searxng/validate_image.py" "$searxng_checks/" +install -m 0644 "$searxng_repo/tests/fixtures/ca-trust/incident.json" "$searxng_checks/incident.json" +install -m 0644 "$searxng_repo/config/searxng/settings.yml" "$searxng_checks/settings.yml" +install -m 0644 "$searxng_repo/scripts/migrate_searxng_settings.py" "$searxng_checks/migrate_searxng_settings.py" +if docker volume inspect "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1; then + echo "Refusing to reuse an existing validation volume" >&2 + exit 1 +fi +docker volume create --label "odysseus.searxng-validation=$searxng_project" \ + "$SEARXNG_VALIDATION_VOLUME" >/dev/null +searxng_volume_created=1 +searxng_started=1 +if ! "${searxng_compose[@]}" up --detach --no-deps --no-build --pull never \ + --wait --wait-timeout 150 searxng; then + "${searxng_compose[@]}" logs --no-color searxng >&2 || true + exit 1 +fi +"${searxng_compose[@]}" exec -T searxng /usr/local/searxng/.venv/bin/python \ + /opt/odysseus-checks/validate_image.py --running --live diff --git a/.github/searxng-validation.compose.yml b/.github/searxng-validation.compose.yml new file mode 100644 index 000000000..d1e1c575c --- /dev/null +++ b/.github/searxng-validation.compose.yml @@ -0,0 +1,21 @@ +# CI-only isolation; retain the service's entrypoint, healthcheck and capabilities. +services: + searxng: + image: ${SEARXNG_VALIDATION_IMAGE:?set the image being validated} + ports: !reset [] + volumes: !override + - searxng-validation-data:/etc/searxng + - ${SEARXNG_VALIDATION_CHECKS:?}/settings.yml:/tmp/searxng-settings.yml.template:ro + - ${SEARXNG_VALIDATION_CHECKS:?}/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro + - ${SEARXNG_VALIDATION_CHECKS:?}:/opt/odysseus-checks:ro + networks: !override + - default + restart: "no" + +networks: + default: {} + +volumes: + searxng-validation-data: + external: true + name: ${SEARXNG_VALIDATION_VOLUME:?} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51e12d341..229a4d599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,8 @@ jobs: python-version: "3.11" # Byte-compile sources — catches syntax errors without installing deps. - run: python -m compileall -q app.py core routes src services scripts tests + - name: Check committed frontend deployment modules + run: python scripts/build_frontend.py --check node-syntax: name: JS syntax (node --check) @@ -91,14 +93,20 @@ jobs: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "20" + node-version: "22.23.4" + - run: npm ci --ignore-scripts --no-audit --no-fund + - name: Check frontend architecture + run: node scripts/check_frontend.mjs # Syntax-check our own JS (skip vendored libs in static/lib). - name: node --check run: | shopt -s globstar nullglob - for f in static/app.js static/js/**/*.js; do + for f in static/app.js static/js/**/*.js frontend/src/**/*.js; do node --check "$f" done + node --check scripts/check_frontend.mjs + - name: Test frontend architecture gates and stateless proof + run: node --test tests/frontendArchitecture.test.mjs python-tests: name: Python tests (pytest ${{ matrix.shard }}) @@ -147,20 +155,35 @@ jobs: if: steps.docs-check.outputs.docs_only != 'true' with: python-version: "3.11" + # Establish system trust before dependency installers or their hooks run. + # Keep one apt pass for FFmpeg and bubblewrap; containment is checked later. + - name: Install FFmpeg and bubblewrap and validate system CA trust + if: steps.docs-check.outputs.docs_only != 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates + python scripts/check_ca_trust.py --system + command -v ffmpeg + ffmpeg -version | head -n 1 + # uv resolves the same versions as pip but installs ~5x faster, and its # cache keeps each shard from re-downloading the whole requirement set. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 if: steps.docs-check.outputs.docs_only != 'true' with: + version: "0.13.5" enable-cache: true cache-dependency-glob: requirements.txt prune-cache: false - - run: uv pip install --system -r requirements.txt + - run: | + uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt + python scripts/check_ca_trust.py --python + uv pip install --system -r requirements.txt if: steps.docs-check.outputs.docs_only != 'true' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: steps.docs-check.outputs.docs_only != 'true' with: - node-version: "20" + node-version: "22.23.4" cache: npm - run: npm ci if: steps.docs-check.outputs.docs_only != 'true' @@ -176,15 +199,9 @@ jobs: if: steps.docs-check.outputs.docs_only != 'true' - run: mkdir -p data # sqlite DB lives at ./data/app.db if: steps.docs-check.outputs.docs_only != 'true' - # One apt pass for FFmpeg (media integration tests) and bubblewrap - # (containment, verified in the next step). - - name: Install FFmpeg and bubblewrap + - name: Validate Python and system CA trust if: steps.docs-check.outputs.docs_only != 'true' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap - command -v ffmpeg - ffmpeg -version | head -n 1 + run: python scripts/check_ca_trust.py --system --python - name: Establish functional bubblewrap containment if: steps.docs-check.outputs.docs_only != 'true' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0e4f9a1f1..e78bba158 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -70,5 +70,9 @@ jobs: - name: Run pip-audit on requirements run: | set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends ca-certificates + python scripts/check_ca_trust.py --system + python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt pip install pip-audit==2.10.0 pip-audit -r requirements.txt -r requirements-optional.txt --strict diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index da41c7140..6865d5120 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -29,7 +29,6 @@ permissions: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: build: @@ -52,6 +51,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Normalize image name + shell: bash + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Set up Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to GHCR @@ -65,6 +67,7 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . + pull: true platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.arch }} @@ -93,6 +96,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Normalize image name + shell: bash + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Read APP_VERSION + short sha id: ver run: | diff --git a/.github/workflows/searxng-image.yml b/.github/workflows/searxng-image.yml new file mode 100644 index 000000000..844617b10 --- /dev/null +++ b/.github/workflows/searxng-image.yml @@ -0,0 +1,79 @@ +name: ci / SearXNG trust + +on: + pull_request: + paths: + - 'docker/searxng/**' + - 'scripts/check_ca_trust.py' + - 'tests/fixtures/ca-trust/**' + - 'tests/test_searxng*' + - 'docker-compose*.yml' + - 'config/searxng/**' + - 'scripts/migrate_searxng_settings.py' + - '.github/scripts/*searxng*' + - '.github/searxng-validation.compose.yml' + - '.github/workflows/searxng-image.yml' + push: + branches: [dev, main] + paths: + - 'docker/searxng/**' + - 'scripts/check_ca_trust.py' + - 'tests/fixtures/ca-trust/**' + - 'docker-compose*.yml' + - 'config/searxng/**' + - 'scripts/migrate_searxng_settings.py' + - '.github/scripts/*searxng*' + - '.github/searxng-validation.compose.yml' + - '.github/workflows/searxng-image.yml' + workflow_dispatch: + +concurrency: + group: searxng-image-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate: + name: SearXNG validate (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Select the immutable upstream image from Compose + id: image + run: | + docker compose --env-file /dev/null -f docker-compose.yml config --format json | + python3 -c 'import json, re, sys; image = json.load(sys.stdin)["services"]["searxng"]["image"]; re.fullmatch(r"docker\.io/searxng/searxng:[0-9]{4}\.[^@\s]+@sha256:[0-9a-f]{64}", image) or sys.exit("Invalid SearXNG pin"); print("image=" + image)' >> "$GITHUB_OUTPUT" + - name: Pull the upstream image without registry credentials + env: + SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }} + run: docker pull "$SEARXNG_TEST_IMAGE" + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - name: Check unmodified upstream trust and actual entrypoint offline + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: docker/searxng/Dockerfile + target: validation + build-args: SEARXNG_IMAGE=${{ steps.image.outputs.image }} + network: none + platforms: ${{ matrix.platform }} + no-cache-filters: validation + - name: Validate the selected upstream runtime through Compose and live HTTPS + env: + SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }} + run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE" diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index b00cd03a4..18a197eb0 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -76,5 +76,9 @@ jobs: - name: Run zizmor run: | set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends ca-certificates + python scripts/check_ca_trust.py --system + python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt pip install zizmor==1.25.2 zizmor --offline --min-severity=low .github/workflows/ diff --git a/.gitignore b/.gitignore index b6fe4d420..1ae2878b5 100644 --- a/.gitignore +++ b/.gitignore @@ -53,8 +53,6 @@ services/data/ # Test capture artifacts (browser session dumps may contain personal content) .playwright-mcp/ -# Local editor bench/probe artifacts (sessions.json holds session tokens) -bench-out/ # OS .DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml index 3a51ae4c1..0eec3f1ae 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -21,3 +21,17 @@ regexes = [ '''^inherits_2\.0\.1$''', '''^inherits_2\.0\.3$''', ] + + +[[allowlists]] +description = "Reviewed Skills OpenAPI baseline SHA-256 digest" +targetRules = ["generic-api-key"] +condition = "AND" +paths = [ + '''(?:^|/)tests/fixtures/skills_api_baseline\.json$''', + '''(?:^|/)docs/skills-audit-architecture\.md$''', +] +regexTarget = "secret" +regexes = [ + '''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''', +] diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 9602c57b0..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "vendor/photocraft"] - path = vendor/photocraft - url = https://github.com/storytold/photocraft diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 3058687c7..294289b7f 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -48,8 +48,8 @@ just composed. | Service | Image | Purpose | License | |---|---|---|---| | [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:2026.9.25-12f8b6515` (pinned tag; see compose) | Default metasearch backend | AGPL-3.0 | -| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:latest` | Vector store for memory / RAG | Apache-2.0 | -| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy` | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 | +| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:1.5.9` (pinned digest; see compose) | Vector store for memory / RAG | Apache-2.0 | +| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy:v2.32.0` (pinned digest; see compose) | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 | ## Bundled front-end libraries @@ -57,14 +57,10 @@ Vendored in `static/lib/` and served directly: | Library | Purpose | License | |---|---|---| -| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause | -| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 | -| [docx](https://github.com/dolanmiu/docx) (`docx.umd.min.js`) | Generate `.docx` documents | MIT | -| [mammoth.js](https://github.com/mwilliamson/mammoth.js) | Convert `.docx` → HTML | BSD-2-Clause | -| [html2pdf.js](https://github.com/eKoopmans/html2pdf.js) | HTML → PDF export (bundles jsPDF + html2canvas) | MIT | -| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT | -| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT | -| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT | +| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause ([full notice](licenses/highlightjs-BSD-3-Clause.txt)) | +| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) v0.20.3 (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 ([full notice](licenses/SheetJS-Apache-2.0.txt)) | +| [docx](https://github.com/dolanmiu/docx) v8.5.0 (`docx.umd.min.js`) | Generate `.docx` documents | MIT and bundled permissive notices ([full notices](licenses/docx-8.5.0-NOTICES.txt)) | +| [mammoth.js](https://github.com/mwilliamson/mammoth.js) v1.8.0 | Convert `.docx` → HTML | BSD-2-Clause and bundled permissive notices ([full notices](licenses/mammoth-1.8.0-NOTICES.txt)) | | [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) | | [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) | @@ -76,6 +72,13 @@ browser that supports `woff2`. The bundles are the published npm artifacts, unmodified — `.gitattributes` turns the whitespace check off for `static/lib/` so they can stay byte-identical to upstream. +Exact artifact hashes, upstream archive members, local filename mappings and +notice sources are recorded in [THIRD_PARTY_PROVENANCE.json](THIRD_PARTY_PROVENANCE.json). +SheetJS copyright and attribution are preserved in its full distribution license; +highlight.js attribution is Copyright 2006 Ivan Sagalaev. +Browser printing supplies the client Print / save PDF flow. 2FA QR images are +generated by the Python qrcode dependency listed below. + ## Front-end libraries loaded at runtime (CDN) Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored: @@ -91,9 +94,8 @@ Bundled in `static/fonts/`: | Font | License | Author | |---|---|---| -| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors | -| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson | -| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois | +| [Fira Code](https://github.com/tonsky/FiraCode) 6.2 | SIL Open Font License 1.1 ([full notice](licenses/FiraCode-OFL-1.1.txt)) | Nikita Prokopov & contributors | +| [Inter](https://github.com/rsms/inter) 4.1 (hinted WOFF2) | SIL Open Font License 1.1 ([full notice](licenses/Inter-OFL-1.1.txt)) | Rasmus Andersson | | [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez | ## Python dependencies @@ -170,12 +172,4 @@ concerns from earlier are resolved: ## Thanks to -Most of Odysseus's code was written *with* AI models, not just by a human. -The project would not exist without them — credit where credit is due: - -- **gpt-oss-120b** — the legend that kicked this project off. -- **Qwen3-235B** -- **DeepSeek V3.1 · DeepSeek V4 Pro · DeepSeek V4 Flash** -- **Claude** (Anthropic) -- **Codex** (OpenAI) - Friends, for helping me debug. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38586845f..8a7160245 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thanks for helping. The project is moving quickly, so the best contributions are Odysseus has two branches: - **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely. -- **`main`** — what users run. Curated and tested by the maintainer. Fast-forwarded to a stable `dev` commit at each release. +- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release. **Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed. @@ -62,6 +62,10 @@ docker compose logs --tail=120 odysseus Mention what you ran in the pull request description. If you could not run a check, say so. +## Maintainer governance and review guidance + +For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template. + ## Pull Requests Good pull requests usually include: diff --git a/Dockerfile b/Dockerfile index d6e7ca48f..936f35b90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,18 @@ # which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so # the final image / Cookbook never has to compile the broken sdists. See # docker/build-realesrgan-wheels.sh for the full rationale. -FROM python:3.14-slim AS realesrgan-wheels -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +FROM python:3.14.9-slim AS realesrgan-wheels +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* +COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py +RUN python /usr/local/bin/check-ca-trust.py --system +COPY requirements-bootstrap.txt /tmp/requirements-bootstrap.txt +RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --no-deps -r /tmp/requirements-bootstrap.txt \ + && python /usr/local/bin/check-ca-trust.py --python COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels -FROM python:3.14-slim +FROM python:3.14.9-slim # System deps. tmux is required by Cookbook for background downloads/serves. # openssh-client is required for Cookbook remote server tests, setup, probes, @@ -28,6 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ cmake \ curl \ + ca-certificates \ git \ nodejs \ npm \ @@ -48,6 +54,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libmagic1 \ && rm -rf /var/lib/apt/lists/* +# Refresh both Python trust bundles before installing application dependencies. +# System roots also serve web fetches, webhooks, curl, and Node's OpenSSL mode. +COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py +RUN python /usr/local/bin/check-ca-trust.py --system +COPY requirements-bootstrap.txt /tmp/requirements-bootstrap.txt +RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --no-deps -r /tmp/requirements-bootstrap.txt \ + && python /usr/local/bin/check-ca-trust.py --python +ENV NODE_OPTIONS=--use-openssl-ca \ + GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt + +# Chromium has a separate root store. Distrust the compromised intermediate +# public keys explicitly, including cross-signs, without waiting for CRL updates. +COPY docker/chromium-emudhra.json /etc/chromium/policies/managed/odysseus-emudhra.json + # Private browser automation wrapper used by the native `private_browser` tool. # Chromium is installed above, so agent-browser can drive the existing browser # binary without paying `npx` startup/install overhead on each tool call. @@ -72,7 +92,8 @@ RUN npm install -g agent-browser@0.35.0 --omit=dev --loglevel=error # /var/run/docker.sock mount). The Debian `docker.io` package ships # dockerd but not the client binary on slim, so grab the static client # tarball from download.docker.com instead. -ARG DOCKER_CLI_VERSION=29.6.2 +# 29.9.2 includes Go 1.26.10's eMudhra intermediate-key distrust. +ARG DOCKER_CLI_VERSION=29.9.2 RUN ARCH="$(dpkg --print-architecture)" \ && case "$ARCH" in \ amd64) DARCH=x86_64 ;; \ @@ -108,6 +129,11 @@ COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ && rm -rf /tmp/odysseus-wheels +# Check gRPC's effective defaults after all Python dependencies are installed. +# The application minimum fixes its bundled roots; the checked override also +# keeps the effective Docker selection consistent with system trust. +RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc + # Copy app code COPY . . # Require the redistribution notices in the image build context. diff --git a/HARNESS_VERSION b/HARNESS_VERSION index b6b54654b..e7797346a 100644 --- a/HARNESS_VERSION +++ b/HARNESS_VERSION @@ -1 +1 @@ -0.20.84 +0.20.19 diff --git a/Odysseus.spec b/Odysseus.spec index 547460c69..8aab775e8 100644 --- a/Odysseus.spec +++ b/Odysseus.spec @@ -5,7 +5,7 @@ a = Analysis( ['launcher.py'], pathex=[], binaries=[], - datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')], + datas=[('licenses', 'licenses'), ('THIRD_PARTY_PROVENANCE.json', '.'), ('ACKNOWLEDGMENTS.md', '.'), ('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/README.md b/README.md index 4ec557b11..06d4b5f1e 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,33 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled Deployment details are in the [setup guide](website/setup.md#security-notes). -## Star History +## Community & Growth - - - - - Star History Chart - - +

+ + odysseus-dev%2Fodysseus | Trendshift + +

+ +

+ + + + + Odysseus GitHub star history + + +

## License diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index d63d1335b..ba4999157 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -60,13 +60,11 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se **Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug. -### Post-external-context tool approval gate — off by default +### Post-external-context tool approval gate — on by default `src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately. -**It is disabled unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set** (`1`/`true`/`yes`/`on`). The default is off because the gate is conservative enough to interrupt ordinary agent work. That is a deliberate usability trade, and it means a default deployment relies on the wrapper above — not on the gate — to contain injected instructions. - -Operators who run the agent against untrusted web or email content with side-effecting tools enabled should turn it on. With the gate off, a successful injection can reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation; with it on, each of those is refused until approved. +It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation. Two exemptions apply even when the gate is on, both deliberate: @@ -85,7 +83,7 @@ Two exemptions apply even when the gate is on, both deliberate: These are open, acknowledged, and contributor help is welcome: -1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control, and it is off by default — so on a default deployment this gap is unmitigated beyond the untrusted-context wrapper. +1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control and is on by default; turning it off leaves this gap unmitigated beyond the untrusted-context wrapper. 2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. diff --git a/app.py b/app.py index 5215966fb..ae859a579 100644 --- a/app.py +++ b/app.py @@ -178,6 +178,14 @@ app.add_middleware(SecurityHeadersMiddleware) import asyncio as _asyncio from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware from starlette.responses import JSONResponse as _JSONResponse +import anyio as _anyio +from starlette.requests import ClientDisconnect as _ClientDisconnect + +# SSE endpoints (e.g. /api/editor/bridge/events) end/abort mid-stream when the +# browser disconnects; Starlette's BaseHTTPMiddleware surfaces that as +# anyio.EndOfStream (and sometimes ClientDisconnect). Those are client-side +# events, not server errors, so we map them to a 499 instead of a 500 traceback. +_STREAM_CLIENT_EXC = (_anyio.EndOfStream, _ClientDisconnect) REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45")) _TIMEOUT_EXEMPT_PREFIXES = ( @@ -190,6 +198,7 @@ _TIMEOUT_EXEMPT_PREFIXES = ( "/api/cookbook/setup", # remote pacman/apt installs "/api/upload", # large files "/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout + "/api/editor", # editor agent/bridge: generative steps can take 30-110s (SAM + local diffusion) "/api/memory/audit", # retains own 120s LLM inactivity timeout ) @@ -225,15 +234,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware): return await call_next(request) +# Heavy image ops that can run 30-150 s; a deploy restart must not cut these off. +_HEAVY_IMAGE_OP_PREFIXES = ( + "/api/image/inpaint", + "/api/image/replace-background", + "/api/image/outpaint", + "/api/image/extend", + "/api/image/remove-bg", + "/api/image/mask", + "/api/image/upscale-local", + "/api/image/denoise", + "/api/image/enhance-face", + "/api/gallery/ai-upscale", + "/api/gallery/style-transfer", + "/api/editor/agent", +) + + class _SlowRequestLogMiddleware(_BaseHTTPMiddleware): async def dispatch(self, request, call_next): + from src import activity + path = request.url.path or "" + heavy = any(path.startswith(p) for p in _HEAVY_IMAGE_OP_PREFIXES) + if heavy: + activity.begin_image_op(path) start = time.perf_counter() status = 500 try: response = await call_next(request) status = getattr(response, "status_code", 0) or 0 return response + except _STREAM_CLIENT_EXC: + # Client disconnected / SSE stream ended — not a server error. + status = 499 + return _JSONResponse({"detail": "client disconnected"}, status_code=499) finally: + if heavy: + activity.end_image_op(path) elapsed = time.perf_counter() - start try: threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75") @@ -516,28 +553,69 @@ app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static") @app.get("/api/generated-image/{filename}") async def serve_generated_image(filename: str, request: Request): """Serve generated images from the data directory.""" - img_path = resolve_generated_image_path(filename) - # SECURITY: filename is the only key, so anyone who knows / guesses a - # 12-hex content hash could pull another user's image bytes. Require - # auth and verify ownership via the gallery row (when one exists). - try: - from src.auth_helpers import get_current_user - from core.database import SessionLocal as _SL, GalleryImage as _GI - _user = get_current_user(request) - if _user: - _db = _SL() + # 1. Validate filename syntax upfront, rejecting malformed requests (400) + # without probing the filesystem or database. + from src.generated_images import GENERATED_IMAGE_RE + if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + + # 2. Ownership verification: + # If auth is enabled, verify caller identity and ownership BEFORE probing disk, + # preventing existence oracles during outages or unauthorized requests. + from src.auth_helpers import auth_disabled, effective_user + if not auth_disabled(): + _user = effective_user(request) + if not _user: + raise HTTPException(status_code=401, detail="Not authenticated") + + from src.generated_images import get_registered_image_owner + _reg_owner = get_registered_image_owner(filename) + if _reg_owner is not None: + if _reg_owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + else: try: - _row = _db.query(_GI).filter(_GI.filename == filename).first() - # Generated-but-not-yet-imported images have no row → allow. - # Row exists with a different owner → 404 (don't confirm existence). - if _row is not None and _row.owner and _row.owner != _user: - raise HTTPException(status_code=404, detail="Image not found") - finally: - _db.close() - except HTTPException: - raise - except Exception as _e: - logger.warning("Image ownership verification failed for %r", filename, exc_info=_e) + from core.database import ( + SessionLocal as _SL, + GalleryImage as _GI, + Session as _DbSession, + ChatMessage as _CM, + ) + _db = _SL() + try: + _row = _db.query(_GI).filter(_GI.filename == filename).first() + if _row is not None: + if _row.owner: + if _row.owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + elif _row.session_id: + _sess = _db.query(_DbSession).filter(_DbSession.id == _row.session_id).first() + if not _sess or not _sess.owner or _sess.owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + else: + # Gallery record has no owner or session link -> fail closed + raise HTTPException(status_code=404, detail="Image not found") + else: + # Rowless image: check if referenced in a chat session owned by this user + _chat_ref = ( + _db.query(_CM.id) + .join(_DbSession, _CM.session_id == _DbSession.id) + .filter(_DbSession.owner == _user, _CM.meta_data.like(f"%{filename}%")) + .first() + ) + if not _chat_ref: + raise HTTPException(status_code=404, detail="Image not found") + finally: + _db.close() + except HTTPException: + raise + except Exception as _e: + logger.warning("Image ownership verification failed for %r", filename, exc_info=_e) + raise HTTPException(status_code=503, detail="Ownership verification unavailable") + + # 3. Resolve confined path and verify existence on disk + img_path = resolve_generated_image_path(filename) + ext = filename.rsplit('.', 1)[-1].lower() mime = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", @@ -545,10 +623,6 @@ async def serve_generated_image(filename: str, request: Request): "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "mkv": "video/x-matroska", "m4v": "video/mp4", }.get(ext, "application/octet-stream") - # Generated-image filenames are content hashes → the bytes for a given - # filename never change. Cache them hard so the gallery doesn't - # re-download every full-size image each time it's opened. `immutable` - # tells the browser it never needs to revalidate within the max-age. return FileResponse( str(img_path), media_type=mime, @@ -718,7 +792,8 @@ app.include_router(setup_research_routes(research_handler, session_manager=sessi # History from routes.history.history_routes import setup_history_routes -app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler)) +history_router = setup_history_routes(session_manager, upload_handler=upload_handler) +app.include_router(history_router) # Search from routes.search.search_routes import setup_search_routes @@ -784,6 +859,10 @@ app.include_router(setup_gallery_routes()) from routes.editor_draft_routes import setup_editor_draft_routes app.include_router(setup_editor_draft_routes()) +# Natural-language editor planning (planner wired to the configured chat model) +from routes.editor_agent_routes import setup_editor_agent_routes +app.include_router(setup_editor_agent_routes()) + # Scheduled tasks + event bus from src.task_scheduler import TaskScheduler task_scheduler = TaskScheduler(session_manager) @@ -878,6 +957,7 @@ app.include_router(setup_codex_routes( memory_router=memory_router, calendar_router=calendar_router, document_router=document_router, + history_router=history_router, )) app.include_router(setup_claude_routes()) @@ -961,6 +1041,16 @@ async def get_version(): "source_commit": APP_SOURCE_COMMIT, } + +@app.get("/api/busy") +async def get_busy(): + """Report in-flight heavy work so a deploy can wait instead of dropping it.""" + from src import activity + return { + "busy_image_ops": activity.heavy_image_ops_active(), + **activity.activity(), + } + @app.get("/api/health") async def health_check() -> Dict[str, str]: return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()} @@ -1399,4 +1489,35 @@ if __name__ == "__main__": bind_host = os.getenv("APP_BIND", "127.0.0.1") bind_port = int(os.getenv("APP_PORT", "7011")) - uvicorn.run(app, host=bind_host, port=bind_port, log_level="info") + # Give in-flight requests time to finish on SIGTERM instead of being cut + # off mid-flight. Diffusion edits run 60-120 s; outpaint/extend can run up + # to ~330 s (provider cap 900 s), so default the graceful window to 900 s. + # systemd's TimeoutStopSec must exceed this (see the graceful-stop drop-in). + # + # We hook uvicorn's signal handler so a SIGTERM also flips src.shutdown: + # SSE / streaming generators check it and end promptly, so an open browser + # stream cannot stall a restart for the whole window. The long window stays + # a safety net for genuine in-flight image ops. + from src import shutdown as _shutdown + + config = uvicorn.Config( + app, + host=bind_host, + port=bind_port, + log_level="info", + timeout_graceful_shutdown=int(os.getenv("ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900") or 900), + ) + server = uvicorn.Server(config) + + # uvicorn's capture_signals() wires SIGINT/SIGTERM to self.handle_exit, so + # wrapping that method lets us set the shutdown flag before the normal exit + # path runs (this uvicorn version has no install_signal_handlers kwarg). + _uvicorn_handle_exit = server.handle_exit + + def _handle_shutdown_signal(signum, frame): + _shutdown.request_shutdown() + _uvicorn_handle_exit(signum, frame) + + server.handle_exit = _handle_shutdown_signal + + server.run() diff --git a/build-windows-portable.ps1 b/build-windows-portable.ps1 index 7da912f73..634ae8068 100644 --- a/build-windows-portable.ps1 +++ b/build-windows-portable.ps1 @@ -47,7 +47,8 @@ if (-not $pyExe) { Write-Host ("Using Python: " + $pyExe) Write-Step "Installing build dependencies" -& $pyExe -m pip install --upgrade pip --quiet +& $pyExe -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt +if ($LASTEXITCODE -ne 0) { Fail "TLS build dependency bootstrap failed." } & $pyExe -m pip install -r requirements.txt pyinstaller pystray Pillow if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed." } diff --git a/core/models.py b/core/models.py index a03e391fc..f1e1d89d5 100644 --- a/core/models.py +++ b/core/models.py @@ -11,6 +11,8 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING from src.tool_approval_scopes import ( CHAT_SESSION_APPROVAL_CONTEXT_MARKER, CHAT_SESSION_APPROVAL_DECISION, + CHAT_SESSION_APPROVAL_SIGNATURE_FIELD, + verify_chat_session_grant, ) if TYPE_CHECKING: @@ -60,6 +62,14 @@ def _history_grants_chat_session_approval( ask_user.get("kind") == "tool_approval" and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION and str(ask_user.get("session_id") or "") == expected_session + # Shape proves nothing here: routes that accept a + # caller-supplied metadata blob write into this same history. + and verify_chat_session_grant( + ask_user.get(CHAT_SESSION_APPROVAL_SIGNATURE_FIELD), + expected_session, + ask_user.get("approval_id"), + CHAT_SESSION_APPROVAL_DECISION, + ) ): return True return False diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 8e6702b43..5c85014d6 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -127,11 +127,15 @@ services: - ${RENDER_GID:-render} chromadb: - image: docker.io/chromadb/chroma:latest + image: docker.io/chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 ports: - "${CHROMADB_BIND:-127.0.0.1}:8100:8000" volumes: - - chromadb-data:/chroma/chroma + # The image runs `chroma run /config.yaml`, whose persist_path is "/data". + # It declares no VOLUME, so anything written outside a mount lives in the + # container's writable layer and dies with it. /chroma/chroma is where + # pre-1.0 images kept the store; mounting there now catches nothing. + - chromadb-data:/data environment: - ANONYMIZED_TELEMETRY=FALSE restart: unless-stopped @@ -141,8 +145,8 @@ services: # (depends_on: condition: service_healthy), so a broken upstream `latest` # tag blocks the whole app from starting. 2026.6.2 crashes on boot with # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414). - # Bump this deliberately after verifying a newer tag boots clean. - image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 + # Update all default/GPU pins together after native startup and trust checks. + image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8 entrypoint: - /bin/sh - -c @@ -192,7 +196,7 @@ services: restart: unless-stopped ntfy: - image: docker.io/binwiederhier/ntfy + image: docker.io/binwiederhier/ntfy:v2.32.0@sha256:59250e1d8be2ee2186c4560322f625faef22ace380331f05b63f8842dbd62602 command: serve ports: - "${NTFY_BIND:-127.0.0.1}:8091:80" diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index eb3c9d7e3..5c628fcfb 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -130,11 +130,15 @@ services: capabilities: [gpu] chromadb: - image: docker.io/chromadb/chroma:latest + image: docker.io/chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 ports: - "${CHROMADB_BIND:-127.0.0.1}:8100:8000" volumes: - - chromadb-data:/chroma/chroma + # The image runs `chroma run /config.yaml`, whose persist_path is "/data". + # It declares no VOLUME, so anything written outside a mount lives in the + # container's writable layer and dies with it. /chroma/chroma is where + # pre-1.0 images kept the store; mounting there now catches nothing. + - chromadb-data:/data environment: - ANONYMIZED_TELEMETRY=FALSE restart: unless-stopped @@ -144,8 +148,8 @@ services: # (depends_on: condition: service_healthy), so a broken upstream `latest` # tag blocks the whole app from starting. 2026.6.2 crashes on boot with # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414). - # Bump this deliberately after verifying a newer tag boots clean. - image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 + # Update all default/GPU pins together after native startup and trust checks. + image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8 entrypoint: - /bin/sh - -c @@ -195,7 +199,7 @@ services: restart: unless-stopped ntfy: - image: docker.io/binwiederhier/ntfy + image: docker.io/binwiederhier/ntfy:v2.32.0@sha256:59250e1d8be2ee2186c4560322f625faef22ace380331f05b63f8842dbd62602 command: serve ports: - "${NTFY_BIND:-127.0.0.1}:8091:80" diff --git a/docker-compose.yml b/docker-compose.yml index 4fb49a45b..2b90227a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,11 +108,15 @@ services: restart: unless-stopped chromadb: - image: docker.io/chromadb/chroma:latest + image: docker.io/chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 ports: - "${CHROMADB_BIND:-127.0.0.1}:8100:8000" volumes: - - chromadb-data:/chroma/chroma + # The image runs `chroma run /config.yaml`, whose persist_path is "/data". + # It declares no VOLUME, so anything written outside a mount lives in the + # container's writable layer and dies with it. /chroma/chroma is where + # pre-1.0 images kept the store; mounting there now catches nothing. + - chromadb-data:/data environment: - ANONYMIZED_TELEMETRY=FALSE restart: unless-stopped @@ -122,8 +126,8 @@ services: # (depends_on: condition: service_healthy), so a broken upstream `latest` # tag blocks the whole app from starting. 2026.6.2 crashes on boot with # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414). - # Bump this deliberately after verifying a newer tag boots clean. - image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 + # Update all default/GPU pins together after native startup and trust checks. + image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8 entrypoint: - /bin/sh - -c @@ -173,7 +177,7 @@ services: restart: unless-stopped ntfy: - image: docker.io/binwiederhier/ntfy + image: docker.io/binwiederhier/ntfy:v2.32.0@sha256:59250e1d8be2ee2186c4560322f625faef22ace380331f05b63f8842dbd62602 command: serve ports: - "${NTFY_BIND:-127.0.0.1}:8091:80" diff --git a/docker/chromium-ca-policy.md b/docker/chromium-ca-policy.md new file mode 100644 index 000000000..b4852ac3a --- /dev/null +++ b/docker/chromium-ca-policy.md @@ -0,0 +1,20 @@ +# Chromium incident distrust policy + +`chromium-emudhra.json` is installed in the Docker image at `/etc/chromium/policies/managed/odysseus-emudhra.json`. Chromium 132 and later support `CADistrustedCertificates`: a list of base64-encoded DER X.509 certificates whose public keys must not be accepted for TLS server authentication. Matching is by SubjectPublicKeyInfo (SPKI), so one certificate per compromised key also blocks its cross-signs. This policy adds no trust, disables no certificate checks, and does not require a revocation-list download. Native browsers and other browser distributions require separately managed installation of the appropriate policy. + +Policy semantics: [Chromium's version-155 definition](https://github.com/chromium/chromium/blob/155.0.8059.79/components/policy/resources/templates/policy_definitions/CertificateManagement/CADistrustedCertificates.yaml). Linux installation paths: [Chromium policy documentation](https://www.chromium.org/administrators/configuring-policy-for-extensions/#linux). + +Public certificate source, retrieved 2026-10-21: [incident intermediate inventory](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). Its downloaded JSON has SHA-256 `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`. Each selected certificate's DER fingerprint and public-key fingerprint were checked against that inventory before inclusion. The eight unique keys below are covered, including the code-signing intermediate keys listed in the incident. The policy contains only public certificates; no private key is included. + +| Intermediate | SHA-256 of DER SPKI | +| --- | --- | +| EM OV TLS CA - G3B | `43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b` | +| EM DV TLS CA - G3C | `49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f` | +| EM EV TLS CA - G3C | `5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0` | +| EM OV TLS CA - G3C | `63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8` | +| EM OV CS CA - G3C | `92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30` | +| EM EV TLS CA - G3B | `e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41` | +| EM DV TLS CA - G3B | `ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105` | +| EM OV CS CA - G3B | `ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6` | + +`tests/test_chromium_ca_policy.py` verifies that the policy distrusts exactly these eight keys using valid intermediate CA certificates and adds no trust settings. Changes to the encoded material need a fresh source/fingerprint check and behavioral validation: all twelve published incident HTTPS chains must be rejected by the actual Chromium binary while ordinary HTTPS still succeeds. Confirm loading and any policy errors at `chrome://policy`. Do not treat a passing PEM-store check as a browser-policy check. diff --git a/docker/chromium-emudhra.json b/docker/chromium-emudhra.json new file mode 100644 index 000000000..104f82f60 --- /dev/null +++ b/docker/chromium-emudhra.json @@ -0,0 +1,12 @@ +{ + "CADistrustedCertificates": [ + "MIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAwMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEEAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe8Dz3bQjauvy9uw==", + "MIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1MDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxgyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNdAEKwaJ0mVzScy2+6", + "MIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUzOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11ZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49AgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+bSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3VmxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9i1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAqMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysGAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/fAal1GNxsOutLnr0Y", + "MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44cNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR794rSb9vGqxZzelQth", + "MIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0MFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEGBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9O8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3QbX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUNq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggrBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgGBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBgeuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=", + "MIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYwMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDAlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6MN/P+ByGeiRMghzB", + "MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/JhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPOIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8gt2TlfJbHii+KNvK", + "MIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgyN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2rzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsGAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYGZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03YEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9HTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=" + ] +} diff --git a/docker/searxng/Dockerfile b/docker/searxng/Dockerfile new file mode 100644 index 000000000..0b487e66c --- /dev/null +++ b/docker/searxng/Dockerfile @@ -0,0 +1,9 @@ +# Validation only. Deploy the unmodified upstream image selected by Compose. +ARG SEARXNG_IMAGE +FROM ${SEARXNG_IMAGE} AS validation +COPY scripts/check_ca_trust.py docker/searxng/validate_image.py /opt/odysseus-trust/ +COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json +RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py + +FROM validation AS live-validation +RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live diff --git a/docker/searxng/Dockerfile.dockerignore b/docker/searxng/Dockerfile.dockerignore new file mode 100644 index 000000000..3e7e75e3f --- /dev/null +++ b/docker/searxng/Dockerfile.dockerignore @@ -0,0 +1,10 @@ +** +!scripts/ +!scripts/check_ca_trust.py +!docker/ +!docker/searxng/ +!docker/searxng/** +!tests/ +!tests/fixtures/ +!tests/fixtures/ca-trust/ +!tests/fixtures/ca-trust/incident.json diff --git a/docker/searxng/validate_image.py b/docker/searxng/validate_image.py new file mode 100644 index 000000000..c768cf010 --- /dev/null +++ b/docker/searxng/validate_image.py @@ -0,0 +1,162 @@ +"""Verify incident distrust and startup in the unmodified upstream image.""" + +import hashlib +import asyncio +import json +import os +from pathlib import Path +import re +import signal +import ssl +import subprocess +import sys +import tempfile +import time +import urllib.request + +import certifi + +from check_ca_trust import check_bundle + + +def check_chains(): + incident = json.loads(Path(__file__).with_name("incident.json").read_text()) + system = Path(os.environ["SSL_CERT_FILE"]) + capath = os.environ["SSL_CERT_DIR"] + stores = [["-CAfile", str(system), "-no-CApath"], + ["-CAfile", certifi.where(), "-no-CApath"], + ["-no-CAfile", "-CApath", capath]] + check_bundle(system) + check_bundle(Path(certifi.where())) + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + for fingerprint, pem in incident["certificates"].items(): + if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint: + raise ValueError(f"incident fixture fingerprint mismatch: {fingerprint}") + (directory / fingerprint).write_text(pem) + positive = directory / "affected.pem" + positive.write_text("\n".join(incident["certificates"][fp] for fp in incident["roots"])) + for hostname, chain in incident["chains"].items(): + untrusted = directory / "untrusted.pem" + untrusted.write_text("\n".join(incident["certificates"][fp] for fp in chain[1:])) + base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]), + "-purpose", "sslserver", "-verify_hostname", hostname, + "-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)] + expectations = [(selection, False) for selection in stores] + for selection, expected in expectations + [ + (["-CAfile", str(positive), "-no-CApath"], True)]: + result = subprocess.run(base + selection + [str(directory / chain[0])], + capture_output=True, text=True) + if (result.returncode == 0) != expected: + raise RuntimeError(f"unexpected chain result: {hostname}: {selection}: {result.stderr}") + # Accept issuer lookup, self-signed and explicitly untrusted + # certificate errors, rather than tool or fixture failures. + if not expected and not re.search(r"error (?:2|18|19|20|21|27) at \d+ depth lookup", result.stderr): + raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}") + print(f"{len(incident['chains'])}/{len(incident['chains'])} incident chains rejected by system PEM, hashed directory and certifi; affected controls accepted") + + +def check_boot(live=False): + with tempfile.TemporaryDirectory() as directory: + settings = Path(directory) / "settings.yml" + settings.write_text("use_default_settings: true\nserver:\n secret_key: offline-build-validation-only\n limiter: false\nsearch:\n formats: [html, json]\n") + cache = Path(directory) / "cache" + cache.mkdir() + env = dict(os.environ, SEARXNG_SETTINGS_PATH=str(settings), + __SEARXNG_CONFIG_PATH=directory, __SEARXNG_DATA_PATH=str(cache), + GRANIAN_HOST="127.0.0.1", GRANIAN_PORT="18888") + # Do not expose a port or issue an outbound search during offline builds. + with tempfile.TemporaryFile() as log: + process = subprocess.Popen(["/usr/local/searxng/entrypoint.sh"], + cwd="/usr/local/searxng", env=env, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + healthy = False + for _ in range(60): + if process.poll() is not None: + break + try: + with opener.open("http://127.0.0.1:18888/healthz", timeout=1) as response: + healthy = response.status == 200 + except OSError: + pass + if healthy: + break + time.sleep(0.5) + if not healthy: + log.seek(0) + raise RuntimeError("SearXNG failed boot smoke: " + log.read().decode(errors="replace")[-6000:]) + print("SearXNG loopback healthcheck passed") + # Its root entrypoint regenerates system trust. Repeat the + # behavioral checks after that real startup path completes. + check_running(live=live, port=18888) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + + +def check_running(live=False, port=None): + """Verify an existing service without repairing it or starting another one.""" + port = int(os.environ.get("GRANIAN_PORT", "8080")) if port is None else port + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(f"http://127.0.0.1:{port}/healthz", timeout=5) as response: + if response.status != 200: + raise RuntimeError("SearXNG running-service healthcheck failed") + check_chains() + for path in Path(os.environ["SSL_CERT_DIR"]).iterdir(): + if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name): + check_bundle(path) + if live: + asyncio.run(check_https()) + with opener.open(f"http://127.0.0.1:{port}/search?q=python+documentation&format=json", timeout=90) as search: + payload = json.load(search) + results = payload.get("results", []) + if not results: + raise RuntimeError("live search returned no results") + print(f"SearXNG live JSON search passed: {len(results)} results") + + +async def check_https(): + # Exercise the actual pinned SearXNG client factory with its default CA + # selection, rather than a different HTTP library's trust configuration. + sys.path.insert(0, "/usr/local/searxng") + from curl_cffi.requests.exceptions import RequestException + from searx.network.client import new_client + + client = new_client(enable_http=False, verify=True, enable_http2=True, + enable_http3=False, max_connections=2, proxies={}, + local_address=None, max_redirects=3) + try: + response = await client.get("https://pypi.org/simple/certifi/", timeout=30) + if response.status_code != 200: + raise RuntimeError(f"ordinary HTTPS failed: {response.status_code}") + print("SearXNG actual curl client ordinary HTTPS passed with verification enabled") + incident = json.loads(Path(__file__).with_name("incident.json").read_text()) + for hostname in incident["chains"]: + try: + await client.get(f"https://{hostname}/", timeout=30, allow_redirects=False) + except RequestException as exc: + # libcurl code 60 is peer certificate verification failure. + # DNS failures, timeouts and handshake EOF do not prove distrust. + if exc.code != 60: + raise RuntimeError(f"inconclusive incident TLS failure: {hostname}: {exc}") from exc + else: + raise RuntimeError(f"incident HTTPS unexpectedly accepted: {hostname}") + print(f"SearXNG actual curl client rejects {len(incident['chains'])}/{len(incident['chains'])} incident hosts with certificate errors after startup") + finally: + await client.aclose() + + +if __name__ == "__main__": + live = "--live" in sys.argv + if "--running" in sys.argv: + check_running(live=live) + else: + check_chains() + check_boot(live=live) diff --git a/docs/EDITOR_AGENT_API.md b/docs/EDITOR_AGENT_API.md new file mode 100644 index 000000000..c873d28d9 --- /dev/null +++ b/docs/EDITOR_AGENT_API.md @@ -0,0 +1,201 @@ +# Editor Agent API + +How an agent (LLM or script) drives the PhotoCraft editor. There are three layers: + +1. **Control API** — `window.geEditorControl`, a stable in-page command surface. +2. **NL planner** — `window.geEditorAI`, turns a prompt into control-API steps (LLM or basic mode). +3. **Bridge** — server-mediated SSE channel so a *server-side* agent can drive an **open** editor. + +All three are owner-scoped by the app session; nothing is exposed to unauthenticated users. + +--- + +## 1. Control API (`window.geEditorControl`) + +Installed when the editor opens. Version `EDITOR_CONTROL_VERSION`. + +```js +await window.geEditorControl.invoke(op, params) // { ok, op, result } | { ok:false, op, error } +await window.geEditorControl.run([{op, params}]) // sequential, stops on first failure +window.geEditorControl.list() // [{op, description}] +``` + +`invoke` is async. Every op that mutates the document pushes **one** undo entry via +`_saveState`, so a single op is one undo step. + +### Op catalog + +| Op | Params | Notes | +| --- | --- | --- | +| `document.info` | – | size, layers, mode | +| `document.importImage` | `{dataUrl,name,raster}` | load a raster image as a new layer | +| `layers.list` | – | top-most first | +| `layers.select` | `{id\|index\|name}` | index is top-most-first | +| `layers.addRaster` | – | new empty pixel layer | +| `layers.addAdjustment` | `{type,params}` | adjustment layer | +| `layers.duplicate` / `layers.delete` | – | on the active/selected layer(s) | +| `layers.setOpacity` | `{id?,opacity 0-1}` | | +| `layers.setBlend` | `{id?,blendMode\|mode}` | | +| `layers.group` / `layers.ungroup` | – | | +| `selection.selectAll` / `deselect` / `invert` | – | | +| `selection.rectangle` / `selection.ellipse` | `{x,y,w,h}` | document space | +| `selection.subject` | `{label}` | SAM text grounding (falls back to a centred rect) | +| `selection.face` | – | SAM "face" (falls back to an upper-centre ellipse) | +| `selection.sky` | – | SAM "sky" (falls back to a top-band colour range) | +| `selection.background` | `{fuzziness}` | border colour union | +| `selection.samText` | `{text}` | SAM text grounding only (throws without SAM) | +| `selection.colorRange` | `{color,fuzziness}` | | +| `selection.hasMask` | – | `{active, space}` | +| `adjustments.apply` | `{type,params}` | adjustment layer; camelCase types accepted | +| `adjustments.applyMasked` | `{brightness,contrast,saturation,warmth,invert}` | pixel pass, masked if a selection exists else whole layer | +| `filters.apply` | `{id}` | opens the slider dialog and applies | +| `filters.applyMasked` | `{id,radius,amount,invert}` | `id`: blur-gaussian, sharpen, unsharp-mask | +| `transform.start` / `transform.apply` / `transform.cancel` | – | | +| `text.add` | `{text,x,y,color,size,font,align}` | | +| `history.undo` / `history.redo` | – | | +| `history.mark` | – | returns current undo depth (for grouping) | +| `history.collapse` | `{mark,label}` | collapses entries above `mark` into one | +| `export.png` | – | `{dataUrl}` of the flattened document | +| `export.gallery` | – | saves a copy back to the gallery | +| `screenshot` | – | `{dataUrl,width,height}` of the composited canvas | + +Param conventions the planner must respect: + +- `adjustments.applyMasked`: `brightness`/`contrast`/`warmth` are **-100..100** (0 neutral); + `saturation` is a **multiplier** (1 neutral; values >3 are treated as percentages). +- `adjustments.apply` `type` aliases are normalised (`blackAndWhite` -> `black-white`). +- `color-balance` params are `{shadows:{r,g,b}, midtones:{r,g,b}, highlights:{r,g,b}}`; + a bare number is coerced to `{r:n,g:n,b:n}`. +- A masked op with **no** active selection degrades to a whole-layer edit (does not throw). + +--- + +## 2. NL planner (`window.geEditorAI`) + +```js +const res = await window.geEditorAI.execute('blur the background behind the person', { onStep }); +// exec: window.geEditorAI.execute('blur the background behind the person', { onStep }); +// res = { ok, prompt, reply, planner, basicMode, steps, trace, failed } +``` + +Flow (`static/js/editor/nl-edit.js`): + +1. Read document state + a **before** screenshot. +2. Ask the **server planner** (`POST /api/editor/plan`) with the prompt, state and + screenshot. The client passes its strong guidance via `state.planner_guidance`. + If the route is unavailable it falls back to `localPlan` (regex — labelled + **"basic mode"** in the UI). +3. `sanitizeSteps()`: drop no-op/export steps and, if a masked op has no preceding + `selection.*`, insert the region implied by the prompt (face/sky/background/subject). +4. `history.mark`, then run each step, screenshotting after each one. +5. **Self-check** (server plans only, max 3 rounds): send the after screenshot back in + `mode:"check"` with the original intent. If not satisfied, **revert** the previous + attempt (undo its history entries) and apply the model's **full corrected** plan. +6. `history.collapse` — the whole request becomes a single undoable step. + +UI: the Quick Edit box routes multi-clause / "and ..." prompts to this executor and +streams `Step i/n: ` into its status line; a `(basic mode)` suffix marks the offline planner. + +### Server route + +`POST /api/editor/plan` (HARNESS, `routes/editor_agent_routes.py`) + +``` +{ prompt, state, mode?: "plan"|"check", screenshot?: "data:image/..." } +-> { reply, planner, steps: [{op, params}], done? } +``` + +Owner-scoped, authenticated. Uses the configured default/utility model with vision. +If the model call is unavailable it returns a deterministic `planner:"fallback"` plan. + +--- + +## 3. Bridge (server agent drives an open editor) + +`static/js/editor/editor-bridge.js` subscribes the open editor to the server queue. +Enabled automatically when the editor opens (`window.__geEditorBridge`). + +- Subscribe: `GET /api/editor/bridge/events?session=` — SSE, `data: {id,op,params}` (keepalive every 15s). +- The page runs the op via the control API and replies: + `POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`. +- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`. + With `wait:true` the call blocks and returns the editor's result. + +Round-trip example (authenticated; `` = `window.__geEditorBridge.session`): + +```bash +curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \ + -H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \ + -d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}' +# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."} +``` + +Session scoping is `owner:session`, so an agent can only drive editors owned by the +same user. The bridge is a **browser-only** flow: it requires an editor page to be open. + +--- + +## 4. SAM dependency + +`selection.subject` / `selection.face` / `selection.sky` / `selection.samText` use the +server SAM mask tool (`POST /api/image/mask`). When the SAM tools are **not installed** +the editor raises *"Object mask tools are not installed"* and the control op falls back +to a **coarse approximation** (centred rect / upper-centre ellipse / top-band colour range). +On a plain background the approximation is acceptable; on real photos it leaks, producing +visible rectangular/elliptical artifacts. Install the Cookbook SAM mask dependencies for +true subject/face/sky segmentation. + +`selection.colorRange` and `selection.background` never need SAM (pure colour matching), +but they are also coarse on busy scenes. + +--- + +## 5. Two ways for an agent to use this — pick one + +### Option A — built-in tool (in-process) + +Register an editor-control tool inside the app (same process as the model) that calls the +bridge/control API directly. Uses the app's normal login; nothing new is exposed. This is +the rejected stdio-MCP design's replacement: a server-side tool that talks to the bridge +needs either an internal token (auth-bypass) or to run in-process. **Decision pending (user).** + +### Option B — browser-only flow (works today) + +No new server tool. An external agent drives an open editor purely through the bridge +endpoints using the logged-in owner's session. Restricted to the editor the user has open. +This is the flow demonstrated above and requires **no** auth-bypass. + +A stdio `photo_editor` MCP server was prototyped and **rejected** because the built-in MCP +subprocess has no internal token; wiring it would require expanding an auth bypass. +The two viable options above are left for the user to choose. + +--- + +## 6. Known limitations / honest grading + +Live run against real 7011 with the server LLM planner (7 prompts): all 7 executed +(`planner:"llm"`). Graded by eye: + +- PASS: black & white portrait; warm cinematic; sharper + more vivid; text title. +- FAIL (region-masked, SAM-bound): "dramatic sky" (global over-saturation instead of a + dramatic, masked sky), "blur the background" (coarse mask leaks; visible rectangle), + "brighten the face" (coarse ellipse/rect artifact). + +Whole-image edits are reliable; subject/face/sky region edits need SAM. The self-check +loop works (revert-and-replan) but the region cases spend all 3 iterations. + +## 7. Theming (control kit) + +The editor's interactive controls (ranges, number fields, selects, checkboxes) share a +single themed kit: `static/js/editor/control-kit.js` adds `.ge-ctl-*` classes scoped to +the editor roots (`#gallery-editor-container`, `.ge-adj-popup`, `.ge-filter-overlay`, +`.ge-canvas-size-overlay`, `.ge-float-window`, `.ge-transform-popup`), and +`static/css/13-image-editor.css` (section "Editor control kit") styles them from the app +design tokens (`static/css/00-tokens.css`) to match the app sliders/selects. It keeps +range fills in sync and attaches a **scrub-on-label** affordance to numeric fields +(drag the field's label to change the value; arrow keys work, Shift = x10). + +**The PhotoCraft editor is dark-only.** `:root.light` flips the global tokens, but +`13-image-editor.css` has no `.light` handling and the editor relies on dark surfaces, +so light mode renders a garbled mix. Unlike the rest of Odysseus, the editor follows +Photoshop and is not offered in a light theme. diff --git a/docs/chromadb-volume-migration.md b/docs/chromadb-volume-migration.md new file mode 100644 index 000000000..4ec7853ac --- /dev/null +++ b/docs/chromadb-volume-migration.md @@ -0,0 +1,408 @@ +# ChromaDB Volume Migration — Upgrading from pre-#6166 installs + +> [!IMPORTANT] +> This guide is only for Docker-based installs that were deployed before PR #6166 +> was merged. Native host installs are unaffected (they persist to `data/chroma/` +> on the host filesystem, which was always stored correctly). + +--- + +## 1. What changed and why + +Before PR #6166, the `chromadb-data` named volume was mounted at `/chroma/chroma` inside +the ChromaDB container. However, current `chromadb/chroma` images (version 1.0+) write their +database to `/data` — configured by the container image's internal `/config.yaml` (`persist_path: "/data"`). +Because the upstream ChromaDB image declares no `VOLUME` directive: + +* **Any vector data written to `/data` accumulated exclusively in the container's writable layer.** +* The mounted `chromadb-data` named volume at `/chroma/chroma` remained completely empty. +* Any recreation or removal of the container (`docker compose down`, `docker compose up --force-recreate`, `docker rm`) + permanently destroys that writable layer and all vector embeddings within it. + +PR #6166 corrects the mount target across all Compose configurations to `chromadb-data:/data`. + +Fresh installations will persist data directly to the named volume automatically. However, for +**existing running installations**, the vector store currently resides in the old container's +writable layer. + +This guide provides a zero-data-loss, fail-closed operational procedure to quiesce, preserve, +back up, verify, seed, and validate that data before performing cutover. + +--- + +## 2. Architecture and Safety Principles + +1. **No Live `docker cp`:** ChromaDB uses SQLite (`chroma.sqlite3`) and binary HNSW segment index + files (`data_level0.bin`, `header.bin`, `length.bin`, `link_lists.bin`). Taking a file copy while + the database engine is running risks capturing torn SQLite pages or out-of-sync HNSW graph segments. + Writers must be quiesced and ChromaDB gracefully stopped before copying files. +2. **Do Not Trust `docker rename` Alone:** Docker Compose tracks containers by internal metadata labels + (`com.docker.compose.project`, `com.docker.compose.service`, `com.docker.compose.container-number`), + **not** merely by container name. Renaming a container does not strip its labels; Compose commands + can still discover, restart, or recreate it. To guarantee preservation of the writable layer, the + stopped container must be committed to an immutable Docker image (`docker commit`). +3. **Never Overwrite Existing Volumes:** Named volumes must be positively verified as empty prior to seeding. +4. **Staging Validation Before Production Cutover:** The seeded named volume must be independently validated + using a disposable ChromaDB instance on an isolated port before touching the production Compose deployment. +5. **No Rollback to `/chroma/chroma`:** Rolling back to pre-#6166 Compose files reintroduces the broken + mount path where ChromaDB never looks. Rollback procedures must restore data into the corrected `/data` path + or redeploy from the immutable backup image. + +--- + +## 3. Step-by-Step Migration Procedure + +### Step 0: Identify your project and volume names + +Docker Compose prefixes container and volume names with the project name (usually the root directory name). +Identify yours before proceeding: + +```bash +# Identify running ChromaDB container +docker ps --filter "ancestor=chromadb/chroma" --format "table {{.ID}}\t{{.Status}}\t{{.Names}}" + +# Identify the named volume +docker volume ls --filter "name=chromadb" +``` + +Throughout this guide: +* `` represents your Compose project (e.g. `odysseus` or `odysseus-maintainer-preview`). +* `` represents the running ChromaDB container (e.g. `-chromadb-1`). +* `` represents the named volume (e.g. `_chromadb-data`). + +Set shell variables for convenience: +```bash +PROJECT="" +CONTAINER="${PROJECT}-chromadb-1" +VOLUME="${PROJECT}_chromadb-data" +``` + +--- + +### Step 1: Pre-Migration Collection Inventory (Baseline) + +Establish a baseline of all existing collections and their embedding counts through the live ChromaDB API: + +```bash +# List all collections and record counts +curl -s http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections \ + | python3 -c ' +import json, sys, urllib.request +data = json.load(sys.stdin) +print(f"Total collections found: {len(data)}") +for col in data: + c_id = col["id"] + name = col["name"] + try: + with urllib.request.urlopen(f"http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp: + count = resp.read().decode().strip() + except Exception as e: + count = f"error: {e}" + print(f"Collection: {name} (ID: {c_id}) -> Count: {count}") +' | tee pre_migration_inventory.txt +``` + +Save `pre_migration_inventory.txt` securely. This baseline is used in Step 7 and Step 9 to verify +zero data loss. + +--- + +### Step 2: Quiesce Writers and Gracefully Stop ChromaDB + +To ensure transactional and referential consistency across SQLite and HNSW segment files: + +1. Stop application writers (Odysseus) to prevent any in-flight requests: + ```bash + docker compose stop odysseus + ``` +2. Gracefully stop the ChromaDB service. This permits SQLite to checkpoint the Write-Ahead Log (WAL) + and flushes all in-memory vector index segments to disk: + ```bash + docker compose stop chromadb + ``` +3. Confirm that the container status is `Exited`: + ```bash + docker ps -a --filter "name=${CONTAINER}" --format "table {{.ID}}\t{{.Status}}\t{{.Names}}" + ``` + *Do not proceed if the container is still running.* + +--- + +### Step 3: Preserve the Original Container as an Immutable Image + +Because Docker Compose discovers containers via service labels, running `docker compose up` later +could recreate and destroy the original container's writable layer. + +Preserve the complete writable layer into an immutable Docker image: +```bash +BACKUP_IMAGE="${PROJECT}-chromadb-backup:pre-migration-$(date +%Y%m%d-%H%M%S)" +docker commit "${CONTAINER}" "${BACKUP_IMAGE}" +echo "Committed container writable layer to: ${BACKUP_IMAGE}" +``` + +Verify the image was created: +```bash +docker images "${BACKUP_IMAGE}" +``` + +This image is your ultimate insurance: even if all containers and volumes are deleted, your +data remains safely archived in Docker's image store. + +--- + +### Step 4: Take a Cold File Backup + +With the container stopped, copy the `/data` directory out to a dedicated host backup directory: + +```bash +BACKUP_DIR="./chromadb-backup-$(date +%Y%m%d-%H%M%S)" +mkdir -p "${BACKUP_DIR}" + +docker cp "${CONTAINER}:/data/." "${BACKUP_DIR}/" +echo "Cold backup created at: ${BACKUP_DIR}" +``` + +--- + +### Step 5: Verify Backup Integrity + +Never proceed to destructive cutover without verifying both SQLite and vector index structures. + +1. **Verify SQLite Database File:** + ```bash + test -s "${BACKUP_DIR}/chroma.sqlite3" || { echo "ERROR: chroma.sqlite3 is missing or 0 bytes"; exit 1; } + ``` +2. **Execute SQLite PRAGMA Integrity Check:** + ```bash + INTEGRITY=$(sqlite3 "${BACKUP_DIR}/chroma.sqlite3" "PRAGMA integrity_check;") + if [ "${INTEGRITY}" != "ok" ]; then + echo "ERROR: SQLite integrity check failed: ${INTEGRITY}" + exit 1 + fi + echo "SQLite integrity check: OK" + ``` +3. **Verify Collections Table Structure:** + ```bash + sqlite3 "${BACKUP_DIR}/chroma.sqlite3" "SELECT id, name FROM collections;" + ``` +4. **Verify HNSW Index Binary Files:** + Vector representations are not stored inside SQLite; they are kept in segment directories under `/data`: + ```bash + find "${BACKUP_DIR}" -type f -name "*.bin" + ``` + Ensure `.bin` files (`data_level0.bin`, `header.bin`, etc.) exist for indexed collections. + +--- + +### Step 6: Verify Destination Volume is Empty and Seed Safely + +The named volume `${VOLUME}` must be empty before seeding. Under no circumstances should +migration files be merged into a populated volume. + +1. **Positive Empty Check:** + ```bash + VOL_FILES=$(docker run --rm -v "${VOLUME}:/vol:ro" alpine ls -A /vol) + if [ -n "${VOL_FILES}" ]; then + echo "ERROR: Named volume ${VOLUME} already contains files: ${VOL_FILES}" + echo "Aborting migration to prevent overwriting existing volume data." + exit 1 + fi + echo "Volume ${VOLUME} is empty and ready for seeding." + ``` +2. **Seed the Volume:** + Use a disposable Alpine container to populate the volume with exact ownership and permissions: + ```bash + docker run --rm \ + -v "${VOLUME}:/target" \ + -v "$(realpath "${BACKUP_DIR}"):/source:ro" \ + alpine sh -c "cp -a /source/. /target/ && ls -la /target/" + ``` +3. **Verify Seeded Contents:** + ```bash + docker run --rm -v "${VOLUME}:/vol:ro" alpine ls -la /vol + ``` + +--- + +### Step 7: Staging Validation in an Isolated Disposable Container + +Before modifying your production Compose stack, validate that ChromaDB boots successfully from the +seeded volume and serves all collections. + +1. **Start Disposable Staging Container:** + Run a temporary ChromaDB instance bound to an isolated loopback port (e.g. `127.0.0.1:18199`): + ```bash + docker run -d --name "${PROJECT}-chromadb-staging" \ + -p 127.0.0.1:18199:8000 \ + -v "${VOLUME}:/data" \ + chromadb/chroma:latest + + # Wait for staging ChromaDB to boot + sleep 4 + curl -s http://127.0.0.1:18199/api/v2/heartbeat || { echo "Staging container failed to respond"; exit 1; } + ``` +2. **Compare Collection Counts with Baseline:** + ```bash + curl -s http://127.0.0.1:18199/api/v2/tenants/default_tenant/databases/default_database/collections \ + | python3 -c ' + import json, sys, urllib.request + data = json.load(sys.stdin) + print(f"Staging collections verified: {len(data)}") + for col in data: + c_id = col["id"] + name = col["name"] + with urllib.request.urlopen(f"http://127.0.0.1:18199/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp: + count = resp.read().decode().strip() + print(f" {name}: {count} records") + ' + ``` + *Confirm that the output matches `pre_migration_inventory.txt` from Step 1.* + +3. **Tear Down Staging Container:** + ```bash + docker rm -f "${PROJECT}-chromadb-staging" + ``` + +--- + +### Step 8: Apply Updated Compose Files and Perform Production Cutover + +Once the seeded volume is proven healthy: + +1. Update your repository to the revision containing PR #6166: + ```bash + git pull + ``` +2. Verify the mount line in your Compose file: + ```bash + grep -E 'chromadb-data:/data' docker-compose.yml + ``` +3. Bring up the updated ChromaDB container: + ```bash + docker compose up -d chromadb + ``` + Docker Compose will detect the updated mount configuration, replace the stopped container, + and attach `${VOLUME}` directly to `/data`. +4. Bring up the application stack: + ```bash + docker compose up -d odysseus + ``` + +--- + +### Step 9: Post-Migration Production Verification + +1. **Confirm Mount Point:** + ```bash + docker inspect "${CONTAINER}" --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}' + ``` + Expected output: + ``` + _chromadb-data -> /data + ``` +2. **Confirm Production Collection Counts:** + ```bash + curl -s http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections \ + | python3 -c ' + import json, sys, urllib.request + data = json.load(sys.stdin) + print(f"Production collections: {len(data)}") + for col in data: + c_id = col["id"] + name = col["name"] + with urllib.request.urlopen(f"http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp: + count = resp.read().decode().strip() + print(f" {name}: {count} records") + ' + ``` +3. **Application Verification:** + Log in to Odysseus, navigate to **Settings -> Memory**, and confirm that memory items, + tool embeddings, and RAG document queries operate normally. + +--- + +## 4. Rollback Procedures + +### Scenario A: Failure During Staging (Before Compose Cutover) + +If staging verification in Step 7 fails or reveals discrepancies: +1. Remove the staging container: + ```bash + docker rm -f "${PROJECT}-chromadb-staging" + ``` +2. Clear the failed destination volume: + ```bash + docker run --rm -v "${VOLUME}:/target" alpine sh -c "rm -rf /target/*" + ``` +3. Restart the original production container. Because the original container was only stopped (never recreated), + its writable layer remains completely intact: + ```bash + docker compose start chromadb + docker compose start odysseus + ``` +4. Verify production status on port 8100. + +--- + +### Scenario B: Failure After Compose Cutover + +If an issue occurs after `docker compose up -d`: + +> [!CAUTION] +> **Do not `git checkout` the pre-#6166 Compose file!** +> In the pre-#6166 Compose file, `chromadb-data` was mounted to `/chroma/chroma`. +> ChromaDB 1.0+ persists to `/data`, so checking out the old Compose file will cause +> ChromaDB to ignore the restored volume and boot with an empty database. + +To restore service: +1. **Option 1 — Restore from Verified Host Backup into the `/data` Volume:** + If the container failed due to data corruption during startup: + ```bash + docker compose stop chromadb + docker run --rm -v "${VOLUME}:/target" alpine sh -c "rm -rf /target/*" + docker run --rm \ + -v "${VOLUME}:/target" \ + -v "$(realpath "${BACKUP_DIR}"):/source:ro" \ + alpine cp -a /source/. /target/ + docker compose up -d chromadb + ``` +2. **Option 2 — Fallback to Committed Backup Image:** + If Compose cannot start ChromaDB with the volume: + ```bash + docker compose stop chromadb + docker run -d --name "${PROJECT}-chromadb-fallback" \ + -p 127.0.0.1:8100:8000 \ + "${BACKUP_IMAGE}" + ``` + This immediately restores the exact pre-migration container state. + +--- + +## 5. Post-Migration Cleanup (Explicit Authorization) + +Retain the host backup directory (`${BACKUP_DIR}`) and the backup Docker image (`${BACKUP_IMAGE}`) +for at least 7 days until routine backups have succeeded. + +Once you are completely confident in system stability, manually clean up: +```bash +# Remove host backup +rm -rf ./chromadb-backup-* + +# Remove backup Docker image +docker rmi "${BACKUP_IMAGE}" +``` + +--- + +## 6. Re-indexing (Disaster Recovery Only) + +If an installation has already destroyed its pre-#6166 container writable layer (e.g. prior to following +this guide), the vector embeddings cannot be restored from disk. In this scenario: + +* **RAG Documents:** Re-upload files under **Settings -> Memory -> RAG**. Odysseus automatically re-chunks + and re-indexes documents upon upload. +* **Semantic Memories:** Text memory records reside authoritatively in `data/app.db` (SQLite). Vector representations + are re-indexed automatically when Odysseus connects to a healthy ChromaDB instance. +* **Tool Index:** Rebuilt automatically by Odysseus upon first query. + +Re-indexing is a disaster recovery fallback, not a substitute for preserving existing database state. diff --git a/docs/frontend-architecture/ADR-001-source-and-deployment.md b/docs/frontend-architecture/ADR-001-source-and-deployment.md new file mode 100644 index 000000000..cc1eca04e --- /dev/null +++ b/docs/frontend-architecture/ADR-001-source-and-deployment.md @@ -0,0 +1,135 @@ +# ADR 001: Feature-first native ESM with controlled deployment output + +Date: 2026-10-13. Status: implemented F1 proof, proposed for maintainer adoption. +Baseline: official `upstream/dev` at `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +Scope: source ownership and distribution foundation. Notes implementation is deferred. + +## Decision + +Maintain migrated JavaScript in `frontend/src/`. Publish native ES modules to +explicitly registered paths in `static/js/`, and commit source and output together. +Use `python scripts/build_frontend.py` to copy bytes without rewriting imports, +bundling, minification, source maps, timestamps or generated headers. A checked-in +`frontend/modules.json` maps source paths to deployment paths. All other static +files remain authored legacy modules or deployment assets until a reviewed slice +transfers their ownership. Do not claim that the whole static tree is generated. + +The proof transfers maintenance of the stateless action-menu ordering helper to +`frontend/src/shared/actionMenuOrder.js`. Its output remains +`/static/js/actionMenuOrder.js`, with the same three exports and identical bytes. +All seven current consumers, HTML, service worker and runtime entry points retain +their existing URLs. There is no wrapper, extra request or second browser module +instance. The source copy is a development input, not a second runtime entry point. + +Use `frontend/src/app/` for composition, `features//index.js` for feature +public APIs, and responsibility directories only when implementation requires +them. The [handbook](handbook.md) defines dependency, ownership and lifecycle rules. +F1 creates only the shared proof, without empty application or feature scaffolding. + +## Evidence and alternatives + +Both options were executed against this helper, rather than assumed to work. +Measurements are local compatibility observations, not general performance claims. + +| Property | Controlled native ESM copy, selected | esbuild 0.25.12 controlled ESM build | +| --- | --- | --- | +| Proof output | 1,688 bytes, identical SHA-256 to existing runtime | 1,554 bytes with `--bundle --format=esm --target=es2022 --platform=browser` | +| Repeatability | Clean temporary publication and idempotent rerun tested | Two independent compiler invocations emitted identical bytes | +| Runtime contract | Existing exports, imports, cache entry and module identity unchanged | Export names, icon and ordering matched in an executed Node import | +| Tooling | Python standard library; no production dependency | Compiler and platform binary needed for artifact production | +| Local cost | Copy check: 0.03 s, 15,596 KB maximum RSS | Cached `npx` ESM transform: 0.58 s, 114,196 KB maximum RSS | +| Incremental migration | Explicit file ownership, preserved module boundaries | Feasible for the stateless leaf; stateful facade/chunk identity still needs a separate graph spike | +| Release implications | Commit output; install/startup paths consume existing static assets | Would also need committed or release-produced output plus compiler locking and facade/chunk manifest policy | + +The timing rows measure different tool paths, including `npx` overhead. The +compiler's single-leaf success does not establish stateful graph or lazy chunk +compatibility. The 134-byte bundle reduction does not justify that additional +contract in F1. A future compiler adoption requires a new ADR and graph-level +evidence. React, Vue, TypeScript, a global state library, hashed chunks and a +development server have no demonstrated necessity here. + +Acorn 8.19.0 is added only as an exact, lockfile-pinned development dependency for +architecture checks. A real JavaScript parser is needed to enforce boundaries +across multiline imports, re-exports and lazy thunks without treating comments +and string literals as executable imports. It is MIT licensed, about 568 KB +unpacked, and contributes no deployed JavaScript or runtime dependency. + +## Distribution contract + +1. Source and generated output are reviewed and committed together. CI checks + byte parity; it does not silently rebuild and accept drift. +2. Native Python startup continues to use `STATIC_DIR` and `/static`, without + invoking the publisher. Native installers already obtain tracked static files. +3. Docker's current `COPY . .` includes the output. `.dockerignore` excludes + `node_modules/`. The Docker runtime and entrypoint require no frontend build. +4. `Odysseus.spec` and `build-windows-portable.ps1` already include `static/`. + The macOS wrapper uses the Python checkout and its static assets. Neither + needs the source tree or frontend tooling to serve the proof. +5. A release producer runs the checks before packaging and distributes the + entire matching static graph. The publisher is not a transactional deployment + mechanism. Partial copying of a new graph into a live deployment is unsupported. +6. New output paths must declare LF checkout behavior in `.gitattributes`, as + this proof does, so Windows checkout conversion cannot create false byte drift. + Before checking parity or writing output, the publisher uses the manifest to + require effective `text: set` and `eol: lf` on every source/output path. It asks + Git in a temporary repository containing only the applicable repository-owned + `.gitattributes` files, including nested rules. Global/local Git overrides + cannot supply missing declarations. No blanket legacy-JS rule is introduced. + Git is a development/publication prerequisite, not a startup dependency. + +An isolated distribution probe executes the real FastAPI lifespan, shell and +static requests after omitting `frontend/` and `node_modules/` and removing Node +from the child process search path. It verifies bytes, MIME, CSP, revalidation, +source-tree 404 behavior and static path confinement. The same probe runs in an existing Docker image +with networking disabled. This is not a claim that a fresh full Docker image or +Windows/macOS executable was built; see [validation](validation.md). + +## Imports, identity and offline operation + +New maintained modules use one unversioned deployment URL. Existing legacy URLs, +including their version queries, stay unchanged. A copy mapping must preserve +the resolution of every literal relative import; the architecture checker rejects +a mapping that changes that resolution. Canonical absolute `/static/js/...` +imports are also supported. No bare packages or browser imports of `frontend/src` +are supported. Existing graph and identity guards continue to cover the runtime. + +Each registered output joins the exact `PRECACHE` or `PANEL_PRECACHE` array. The +new checker parses those arrays, rather than accepting a URL mentioned in a +comment. Installation fetches do not evaluate lazy modules. Import-time DOM work, +timers, state creation and network I/O must be characterized before changing an +entry point's load timing. New source dependency cycles are rejected; existing +legacy cycles are inventoried and remain outside the new rules. + +The proof already has its exact precache entry, so no SW cache-name or HTML edit +is needed. Its first evaluation in a fresh offline browser page passes, and +importing the panel registry does not evaluate the editor. The current worker's +`/static/` scope cannot control the SPA root. Open PR #6227 addresses that existing +limitation; F1 does not fix or mask it. Full root offline reload is not certified. + +## Security and compatibility + +Keep backend authorization and owner scope authoritative. No agent, database, +auth, CSP, static mount or API implementation changes belong to this decision. +The helper remains same-origin ESM without eval, compiler runtime or external +resources. No new source maps or vendored assets are emitted; existing SRI/CSP +policy and distribution notices remain unchanged. The publisher validates path +containment, symlinks, duplicate ownership and unmapped source before writing. +It never deletes unowned static assets. Deleting or retiring outputs is an +explicit reviewed migration operation, not automatic cleanup. + +## Rollout, rollback and limits + +Adopt F1 for controlled, reviewed slices after maintainer review. Leave Notes, +bootstrap, HTML and service worker unchanged here. Register new files explicitly; +update offline manifests only when new runtime dependencies are introduced. +F1 rollback can remove the source manifest, proof tooling, gates and documentation +without changing runtime bytes. Later rollback restores the complete prior +source/output/facade/precache set through an authorized follow-up change. + +Boundary gates cannot prove semantic purity, owner isolation or correct teardown. +Domain platform/clock checks are intentionally conservative syntax guards; +behavior tests still establish those contracts. Worker URLs, styles and vendored +libraries are outside the maintained-JS import gate and need explicit review if +a future lane changes them. Legacy exceptions require an exact specifier and a +module-scoped reason; there are none in F1. New source is not permission to +grandfather new dependency cycles or to replicate broadly shared mutable state. diff --git a/docs/frontend-architecture/assessment.md b/docs/frontend-architecture/assessment.md new file mode 100644 index 000000000..d5c746c7c --- /dev/null +++ b/docs/frontend-architecture/assessment.md @@ -0,0 +1,127 @@ +# F1 runtime and architecture assessment + +Assessment date: 2026-10-13. Repository: `odysseus-dev/odysseus`. +Worktree: `worktrees/architecture/frontend-foundation`. +Branch: `refactor/frontend-architecture-foundation`. +HEAD, local `upstream/dev`, and the live GitHub official `dev` commit were all +`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The initial worktree was clean. +That commit is the merged PR #6495, selected Notes/Todos agent context. +`origin` points to the maintainer fork; integration targets official `upstream/dev`. + +Inputs read completely: the 2026-10-13 frontend architecture RFC and coding brief +from Downloads, `specs/frontend.md`, repository instructions and existing tests. +The current user brief supersedes the coding brief's `origin/dev` wording and +defines the Notes state/workflow slice before reminders. The design is self-contained +in this directory and does not require access to Downloads. + +Input provenance: + +- RFC SHA-256: `11dedcbb9971f4d787f2721b9c27aa43299baba39ca91066d01507d2d69ac4f8`. +- Coding brief SHA-256: `e4882867617b9c732ce4f445e3bc2da0b4ec1315119f8226c1fd8c1acbacf29d`. + +## Module graph and bootstrap + +`node scripts/check_frontend.mjs --inventory` reproduces the parser-based inventory. +It excludes vendored `static/lib/` and committed editor build output. At this base +it scans 230 JS files, finds 707 static import/re-export edges and 108 literal +dynamic import edges, and finds no missing targets. No computed dynamic imports +were found in the original parser audit. The earlier existing-regex scan counted +814 edges; executable syntax parsing counts 815. Use parser evidence for design +and retain the existing regression guard unchanged. + +| Area | Current boundary and implication | +| --- | --- | +| HTML shell | `static/index.html`: nonce-bearing boot scripts, CSS/preloads, direct ESM script tags, then app/init/a11y. Multiple entry roots mean module identity matters. | +| App composition | `static/app.js`: feature imports, globals, route/sidebar composition, session hydration and auth-fetch wiring. It eagerly imports Notes, Calendar, Chat, Documents and Settings. | +| Startup | `static/js/startupShell.js`, `sessions.js`, `init.js`: first-paint scheduling, deferred hydration, user-switch storage cleanup and cosmetic permission gates. Preserve boot ordering. | +| Lazy loading | `static/js/panels.js`: only `editor` is registered. A memoized import thunk shares successful/in-flight promises and retries failed imports. Do not label all panels lazy. | +| Optional libraries | `markdown.js` lazily loads vendored KaTeX/Mermaid. KaTeX and fonts are precached; Mermaid becomes cached after first use. Pyodide has a CDN dependency. | +| Feature packages | Settings registry/navigation/finder/lifecycle, Calendar utilities/reminders, Email wrapper/package and editor tools already demonstrate partial decomposition. | + +Highest import fan-in: `ui.js` 77, `spinner.js` 55, `editor/state.js` 39, +`escMenuStack.js` 23, and storage/modal/z-order helpers 17 each. The measured +static strongly connected components are: + +- Theme and UI. +- Email Inbox, compatibility wrapper, package index, AI reply, attachments, + body rendering, menus, reader, settings page and unsubscribe. +- Cookbook, diagnosis, HW Fit, download, running and serve modules. + +These are existing compatibility constraints, not approved patterns for new +source. Dynamic edges connect more features but do not themselves establish +initialization cycles. No global legacy dependency rewrite is attempted in F1. + +## State, side effects and ownership + +| Owner | Evidence and architectural treatment | +| --- | --- | +| Shared configuration | `appConfig.js` owns invalidatable settings/tool request promises and consumes a sessionStorage prefetch once. Retain single identity and writer invalidation. | +| Browser persistence | `storage.js` centralizes keys and safe access; feature files also use direct localStorage. `init.js` cleans storage on user changes. Preferences are not an authorization store. | +| Feature state | Notes has module variables, selection sets, undo/drafts and reminder state. Email exports a broadly mutable package state object; editor state is imported by 39 modules. Keep ownership explicit; do not expand these into a global store. | +| Import-time effects | Notes reads storage/window and schedules reminders. Email state reads localStorage. Theme/UI initialize from DOM readiness; modal manager installs global listeners. Chip/mobile helpers bind at startup. Moving an import can change behavior before any exported function is called. | +| Compatibility globals | App exposes controllers on `window`; Notes exposes `window.notesModule`. They are compatibility bridges, not the new inter-feature API convention. | +| Async ownership | Session hydration, config invalidation, draft serialized saves and request-generation guards already prevent stale completion. New slices must preserve these rather than only move declarations. | + +Good patterns to retain: narrow zero-state leaves, explicit wrapper export lists, +public API tests, retryable memoized lazy imports, shared config invalidation, +request-generation checks and source/package-aware regression helpers. + +Patterns to avoid: blanket barrels exposing internals, feature imports from +generic helpers, cycles through coordinators, direct writes to another feature's +state or DOM, anonymous global listener ownership, import-triggered network/timer +initialization in new APIs, and helpers grouped by convenience instead of purpose. + +## Runtime, assets and distribution + +| Contract | Verified source | +| --- | --- | +| Static URL and revalidation | `app.py`: `register_static_mime_types`, `_RevalidatingStatic`, `/static` mount. JS MIME is `text/javascript`; JS/CSS/HTML have `Cache-Control: no-cache`. | +| App paths | `src/constants.py` and `src/runtime_paths.py`: checkout root in native mode, PyInstaller `_MEIPASS` in frozen mode; static assets follow app root, persisted data follows data-directory policy. | +| CSP | `core/middleware.py`: same-origin scripts, nonce and jsDelivr allowance; `src/app_helpers.py` injects HTML nonces. No CSP relaxation is needed for the proof. | +| Integrity | No `integrity=` attributes were found in the shell. Proof output is byte-identical and emits no source maps; no existing SRI or vendored artifacts change. | +| Runtime-generated assets | `/api/generated-image/...` uses a confined data path and owner handling; report/render endpoints, Blob manifests/icons and uploads are runtime concerns outside static source publication. | +| Docker | `Dockerfile` copies app code and existing static output; `.dockerignore` excludes node_modules. Node/npm in the image support Browser MCP, not frontend startup. | +| Native installers | `setup.py`, `start-macos.sh`, `launch-windows.ps1`, systemd service: Python dependencies/startup consume the checkout. No frontend build is invoked. | +| Windows desktop | `Odysseus.spec` and `build-windows-portable.ps1` include the whole static directory. Maintain output here rather than require a new bundle path or compiler. | +| macOS | `build-macos-app.sh` creates a launcher around the Python checkout. Static serving follows the existing checkout path. Installer execution was not performed on Linux. | + +## PWA limitations and test coverage + +`static/sw.js` matches exact URLs, including queries. Install fetches each +precache entry separately and tolerates failed fetches. It does not discover +transitive dependencies. HTML root and JS/CSS handlers are network-first; +other static assets are cache-first with background refresh. API and non-GET +requests bypass caching. Cache updates need explicit version/manifest review. +The older frontend spec's root stale-while-revalidate description is stale. + +Registration in `static/index.html` currently requests no wider scope, so the +worker controls `/static/`, not `/`. Root offline reload is therefore a known +pre-existing gap. PR #6227 proposes the scope/header correction. F1 tests actual +offline ESM loading under the current scope and leaves that separate fix alone. +Offline APIs, first-use Mermaid and optional CDN runtimes are not certified. + +Existing coverage includes runtime graph resolution and version parity; editor +offline graph coverage and lazy-loader retries; Email API/export/evaluation +coverage; Notes autosave, XSS, selection/listener and agent-context regressions; +MIME, nonce and runtime paths. Tests use pytest, Node's built-in runner and existing +Playwright Chromium. `tests/TESTING_STANDARD.md` requires behavior-first, isolated +tests. F1 adds real publication failures, architecture violations and an offline +browser proof. It does not replace existing source-sensitive tests. + +## Open PR overlap, checked live on 2026-10-13 + +The open-PR listing was fetched through all pages; selected PR file lists were +inspected. Refresh this inventory before any later slice. + +| PR | Overlap and disposition | +| --- | --- | +| [#6470](https://github.com/odysseus-dev/odysseus/pull/6470) | Theme/sidebar redesign touches HTML, SW, shared UI and CSS. F1 changes none of those assets. Future manifest/UI slices need collision review. | +| [#6695](https://github.com/odysseus-dev/odysseus/pull/6695) | OXS touches bootstrap, HTML, platform, middleware and other backend boundaries. F1 does not establish an extension API or modify that platform. | +| [#6227](https://github.com/odysseus-dev/odysseus/pull/6227) | Root SW scope/header fix. Independent follow-up dependency for broader offline certification. | +| [#6394](https://github.com/odysseus-dev/odysseus/pull/6394) | Older CSS extraction touches legacy style/SW assets; requires separate supersession review. | +| [#6627](https://github.com/odysseus-dev/odysseus/pull/6627) | npm development-dependency update overlaps package/lock files; reconcile the exact Acorn development entry on integration. | +| [#6585](https://github.com/odysseus-dev/odysseus/pull/6585), [#6647](https://github.com/odysseus-dev/odysseus/pull/6647), [#6652](https://github.com/odysseus-dev/odysseus/pull/6652) | Notes optional-field persistence and agent routing/dispatch. Future characterization must reflect their merged state; F1 changes no Notes or backend behavior. | +| [#6594](https://github.com/odysseus-dev/odysseus/pull/6594), [#6152](https://github.com/odysseus-dev/odysseus/pull/6152) | Desktop launcher/build and frozen-runtime packaging work. Existing paths stay stable; packaging still needs platform-owner validation. | + +PR titles alone do not establish compatibility. This report records the checked +file overlap and relevant dependencies, not approval of those implementations. diff --git a/docs/frontend-architecture/evidence/notes/implementation.sha256 b/docs/frontend-architecture/evidence/notes/implementation.sha256 new file mode 100644 index 000000000..023b5ca30 --- /dev/null +++ b/docs/frontend-architecture/evidence/notes/implementation.sha256 @@ -0,0 +1,112 @@ +53dfddd71f02a9a60df42e3fb045bd1a80919d50870c0b77afe18a853f485bea .gitattributes +867c6a4b025fefecb6ac4a91a38563a36107d9653599f64685e16a5be4fe7727 frontend/modules.json +76de35ac93d72edb5dcd295e85c704af9b3f08c2fe9fdc56e0bcbebd4b322fd8 frontend/src/features/notes/application/agentActions.js +37021f9643f6a3299d36b6021783a559e60ad84848ac2b12f0980b5034b6b9c4 frontend/src/features/notes/application/cardOperations.js +fe3033db6ef316c6b15c6b01c0f202ee76a61ea2c5b6952b20144660ff17eb54 frontend/src/features/notes/application/checklistOperations.js +85fc592724524bb5ff37d7e5e58a68fda9d7e645810b6c87a2d4dd3cb41f9dd5 frontend/src/features/notes/application/deletion.js +ce38e7976ee315b198baebffe45f9a1f4e63d87affd9898ed5cf4bde37b903e7 frontend/src/features/notes/application/drafts.js +6210915184c30f73911f144fe0d12a9935d6f2ce42cb2aaf6aeaa75614ca51a2 frontend/src/features/notes/application/queries.js +0da923790f4036d5fe354df4e529feb4b96e178495cb951f269093c0a8347e4e frontend/src/features/notes/application/reminders.js +8f95d086fc2e60beae32b9e9debba41b35448e1c5b4ac78e4989652ef2d44e35 frontend/src/features/notes/application/selection.js +4ceb424b3b6e30a5f507804f42051be02390d4ce1f03ef284094e48825cde5c3 frontend/src/features/notes/application/state.js +1c5d5af833aab91f71a34c11535885eb7bf9f751de5aa89f79defe0f696f20fb frontend/src/features/notes/application/submission.js +1d58ea2d495c8d301c550eb9ba7f627c515a8784bcd105558d941522965e77b0 frontend/src/features/notes/application/workflows.js +9ddbfb20e0a5564dd83365ad498286d68ef03fd60d632619ad4d9656e2f73c95 frontend/src/features/notes/domain/noteRules.js +946ef7a98b0a2dbe329e8259250af11a11702c74fd997e17fb3aff007fbe40c9 frontend/src/features/notes/domain/recurrence.js +25cf3a1cef5dbac0840aef513ffa010a9b08d206de96ce2527e6159a5454b555 frontend/src/features/notes/index.js +8b186bbfbb003b8d738c80713e1cbb5c6924ee14119929c5ea65707b46076b0c frontend/src/features/notes/infrastructure/http.js +c7552d1f4600ae721437892fc0a1e5a0332abe30082bd943cd13a379c9514fec frontend/src/features/notes/infrastructure/notifications.js +ff91a55c55fbdd876c69d9ec10e4e992104c95b6d12d752d9745193fccbc646a frontend/src/features/notes/infrastructure/storage.js +4ff6d69d04547a0325041f774c3bf0707d8b67982d59a221127e07dc9b01383c frontend/src/features/notes/ui/cardChecklist.js +dd87f1929ef8a5d57fdf1ddb12a0a6be53d95be07946be54f946c6fe31ef80bc frontend/src/features/notes/ui/cardInteractions.js +4ff661a65d88e810f346260a354070dba325d6beaa57d1c4e82e8bf09b894538 frontend/src/features/notes/ui/cardRendering.js +113d4457f25291d15afb7fb6775ea063f24c9dac51bb0d262787c9e40705235b frontend/src/features/notes/ui/cardReorder.js +eecafbd92076209fa41826820125fbe9ffa757d6d1bff018e9acc2ede2a18e72 frontend/src/features/notes/ui/checklist.js +155ea3d70e653f38a62ff50fc5be6aee019d42e86d28753da9edb258ac7c93e1 frontend/src/features/notes/ui/checklistReorder.js +5462fea63a2aa9d1fc07dca91b462f6ff24f352b05d99ea6feb437469c0e1f3e frontend/src/features/notes/ui/clipboard.js +2619e5f8b15eb3057330ef220abe6bc354a496effc0df3d89385cdfef9242c86 frontend/src/features/notes/ui/colors.js +11cf24e7e8e8954d764cb58d786fa6f6d71f84f1c6c5ba644d761e28cfdbaedf frontend/src/features/notes/ui/draftEditor.js +f55d4eff7f550e183ecf240a45002936063dbb4eae153e11834da4cb6bd13d35 frontend/src/features/notes/ui/drawing.js +f85c86e70b3a380449fe3c7ecc7a4e68946de320523f5edc8e33f8eab3f0a02a frontend/src/features/notes/ui/editor.js +3946b8324ad8f0212621e526479668308e48b799bf0dbb608e5cd4626f6c1f03 frontend/src/features/notes/ui/filters.js +a893d09e96fb98d13db8ae5c3fb8c5e7a040c62031dd961396a18b7ae2a82a38 frontend/src/features/notes/ui/layout.js +8d8c62a87d4df134d3b9dd97eaf59d02c52f1a38f6bc2e109e8426081d453a8d frontend/src/features/notes/ui/lifecycle.js +62ad9408cb139143168f2c5834ec56b83264c4daed3e84fa9b4b0a281712144c frontend/src/features/notes/ui/mobileEditor.js +2a3ccbad103dcc880e4baf2b3aa3ece4df7b73752bd30078185f03227585148f frontend/src/features/notes/ui/mobileReorder.js +856fe945cf697278f795b8753704119839fa12bc5e0e69e9dab57c4b4dd6d24e frontend/src/features/notes/ui/panel.js +8aa081cb7341d9a67ef46c74da49aebd729e9b53a73d618b81c89566c874b507 frontend/src/features/notes/ui/quickAdd.js +8a4f23a013f06876894e6544b49540b288007d0d7e4cddca955978588f47a64a frontend/src/features/notes/ui/reminderDisplay.js +cb043be801ab40cf8141af6ff1ad788e69fef57a87960ca992071ad9ad29ad90 frontend/src/features/notes/ui/reminderPicker.js +38f12ba7771589aa427f054f48919569dcee9abe0246e02b9767202adb854cdc frontend/src/features/notes/ui/selection.js +2634f252b2899e2e70fc9fec37144b0cc9b0ed00724da81bfef74b1acf7c05b6 frontend/src/features/notes/ui/text.js +d2655ef163825ce6c40d8ef46bc9dfc63a2aa19fb1a7e10b28002f79cb0db409 frontend/src/features/notes/ui/toolbar.js +a0aa203e87814d0eebebb6b049776c3efd576f4a5c6e17e486c8a2893c9c21df frontend/src/features/notes/ui/windowLayout.js +68f0cbe07c7b61fcaf92920cd47042c576ab27fd9553c971cff45c595c25d80a frontend/src/shared/ui/confetti.js +c16fcde607dce2f10a1afd1f8845403a7d0b97a9d5a67ec7aa329c367ba585e8 scripts/verify_frontend_runtime.py +c2e5de2b8ce168220bad4fcefed710e60eeb0dd3e992e76cd980f2c0ca52815a scripts/verify_notes_browser_runtime.py +fa31d673c40f973490e9fac52130b4a915b944a7abdd217a9bd8d57e66d57ca6 static/js/compare/vote.js +76de35ac93d72edb5dcd295e85c704af9b3f08c2fe9fdc56e0bcbebd4b322fd8 static/js/features/notes/application/agentActions.js +37021f9643f6a3299d36b6021783a559e60ad84848ac2b12f0980b5034b6b9c4 static/js/features/notes/application/cardOperations.js +fe3033db6ef316c6b15c6b01c0f202ee76a61ea2c5b6952b20144660ff17eb54 static/js/features/notes/application/checklistOperations.js +85fc592724524bb5ff37d7e5e58a68fda9d7e645810b6c87a2d4dd3cb41f9dd5 static/js/features/notes/application/deletion.js +ce38e7976ee315b198baebffe45f9a1f4e63d87affd9898ed5cf4bde37b903e7 static/js/features/notes/application/drafts.js +6210915184c30f73911f144fe0d12a9935d6f2ce42cb2aaf6aeaa75614ca51a2 static/js/features/notes/application/queries.js +0da923790f4036d5fe354df4e529feb4b96e178495cb951f269093c0a8347e4e static/js/features/notes/application/reminders.js +8f95d086fc2e60beae32b9e9debba41b35448e1c5b4ac78e4989652ef2d44e35 static/js/features/notes/application/selection.js +4ceb424b3b6e30a5f507804f42051be02390d4ce1f03ef284094e48825cde5c3 static/js/features/notes/application/state.js +1c5d5af833aab91f71a34c11535885eb7bf9f751de5aa89f79defe0f696f20fb static/js/features/notes/application/submission.js +1d58ea2d495c8d301c550eb9ba7f627c515a8784bcd105558d941522965e77b0 static/js/features/notes/application/workflows.js +9ddbfb20e0a5564dd83365ad498286d68ef03fd60d632619ad4d9656e2f73c95 static/js/features/notes/domain/noteRules.js +946ef7a98b0a2dbe329e8259250af11a11702c74fd997e17fb3aff007fbe40c9 static/js/features/notes/domain/recurrence.js +25cf3a1cef5dbac0840aef513ffa010a9b08d206de96ce2527e6159a5454b555 static/js/features/notes/index.js +8b186bbfbb003b8d738c80713e1cbb5c6924ee14119929c5ea65707b46076b0c static/js/features/notes/infrastructure/http.js +c7552d1f4600ae721437892fc0a1e5a0332abe30082bd943cd13a379c9514fec static/js/features/notes/infrastructure/notifications.js +ff91a55c55fbdd876c69d9ec10e4e992104c95b6d12d752d9745193fccbc646a static/js/features/notes/infrastructure/storage.js +4ff6d69d04547a0325041f774c3bf0707d8b67982d59a221127e07dc9b01383c static/js/features/notes/ui/cardChecklist.js +dd87f1929ef8a5d57fdf1ddb12a0a6be53d95be07946be54f946c6fe31ef80bc static/js/features/notes/ui/cardInteractions.js +4ff661a65d88e810f346260a354070dba325d6beaa57d1c4e82e8bf09b894538 static/js/features/notes/ui/cardRendering.js +113d4457f25291d15afb7fb6775ea063f24c9dac51bb0d262787c9e40705235b static/js/features/notes/ui/cardReorder.js +eecafbd92076209fa41826820125fbe9ffa757d6d1bff018e9acc2ede2a18e72 static/js/features/notes/ui/checklist.js +155ea3d70e653f38a62ff50fc5be6aee019d42e86d28753da9edb258ac7c93e1 static/js/features/notes/ui/checklistReorder.js +5462fea63a2aa9d1fc07dca91b462f6ff24f352b05d99ea6feb437469c0e1f3e static/js/features/notes/ui/clipboard.js +2619e5f8b15eb3057330ef220abe6bc354a496effc0df3d89385cdfef9242c86 static/js/features/notes/ui/colors.js +11cf24e7e8e8954d764cb58d786fa6f6d71f84f1c6c5ba644d761e28cfdbaedf static/js/features/notes/ui/draftEditor.js +f55d4eff7f550e183ecf240a45002936063dbb4eae153e11834da4cb6bd13d35 static/js/features/notes/ui/drawing.js +f85c86e70b3a380449fe3c7ecc7a4e68946de320523f5edc8e33f8eab3f0a02a static/js/features/notes/ui/editor.js +3946b8324ad8f0212621e526479668308e48b799bf0dbb608e5cd4626f6c1f03 static/js/features/notes/ui/filters.js +a893d09e96fb98d13db8ae5c3fb8c5e7a040c62031dd961396a18b7ae2a82a38 static/js/features/notes/ui/layout.js +8d8c62a87d4df134d3b9dd97eaf59d02c52f1a38f6bc2e109e8426081d453a8d static/js/features/notes/ui/lifecycle.js +62ad9408cb139143168f2c5834ec56b83264c4daed3e84fa9b4b0a281712144c static/js/features/notes/ui/mobileEditor.js +2a3ccbad103dcc880e4baf2b3aa3ece4df7b73752bd30078185f03227585148f static/js/features/notes/ui/mobileReorder.js +856fe945cf697278f795b8753704119839fa12bc5e0e69e9dab57c4b4dd6d24e static/js/features/notes/ui/panel.js +8aa081cb7341d9a67ef46c74da49aebd729e9b53a73d618b81c89566c874b507 static/js/features/notes/ui/quickAdd.js +8a4f23a013f06876894e6544b49540b288007d0d7e4cddca955978588f47a64a static/js/features/notes/ui/reminderDisplay.js +cb043be801ab40cf8141af6ff1ad788e69fef57a87960ca992071ad9ad29ad90 static/js/features/notes/ui/reminderPicker.js +38f12ba7771589aa427f054f48919569dcee9abe0246e02b9767202adb854cdc static/js/features/notes/ui/selection.js +2634f252b2899e2e70fc9fec37144b0cc9b0ed00724da81bfef74b1acf7c05b6 static/js/features/notes/ui/text.js +d2655ef163825ce6c40d8ef46bc9dfc63a2aa19fb1a7e10b28002f79cb0db409 static/js/features/notes/ui/toolbar.js +a0aa203e87814d0eebebb6b049776c3efd576f4a5c6e17e486c8a2893c9c21df static/js/features/notes/ui/windowLayout.js +7f1a7961d586767298672fad71a0d627389e185f0fb28792a04d0b987e1d5c5c static/js/notes.js +68f0cbe07c7b61fcaf92920cd47042c576ab27fd9553c971cff45c595c25d80a static/js/shared/ui/confetti.js +97fd43c17f3cb1928947e24159a3b59dc81ef7dca95862e8a47154e9cb732f88 static/sw.js +910cd5777564e9f6338b8a864a772beffea197dc476464171c180e7be96e572d tests/browser/notesNative.mjs +ef4bc40a710919c81c582d3b9fe80376d0d5ae66fb9be3c293871a83ee360f97 tests/browser/notesOffline.mjs +437ec2e9865b3e066bbec8d7595a0d947f1e503b96e7b4d819ec1bed86c9bb1e tests/browser/notesReference.mjs +9af973a5d2fb08a5eed550be0198bef501af56802c3844b5d0bb8e718cc685cf tests/fixtures/notesRecurrenceBaseline.mjs +859a5f442dc717f59993891c338cd6ae9d6e5e009392a2ee69ab0e3b063bb572 tests/helpers/notes_source.py +739ffc3f20de8e2bdf1d513b17bf485f85821e9906109cdf8ffea04b81b0f1dd tests/notesApplication.test.mjs +1946369be43177b19a44cd9ae4f90d64da6558d1a470f8e347b490a2188782be tests/notesCharacterization.test.mjs +99f0cd92a30cf01c0ee6814f6684edeb80ebdb9a5ab96583244092865bfb6212 tests/notesDraftAutosave.test.mjs +0417205809de16c7c7338ea80ba19ca844603b3e06655987226f5f83d962d18b tests/notesInfrastructure.test.mjs +dcf7c2d33de9bb457f96af070354ecc3ab69669002bdc9c7c943bef1b3d2e9bc tests/notesRecurrence.test.mjs +dd9a703465dde8f92625a3226ab52d20a02aa5d3415fbcc70609f69a372ccc12 tests/notesReminders.test.mjs +5a9112569f226525faaf6603a9867620b47902534f9cd882c08f71f2f96614b2 tests/notesWorkflows.test.mjs +35fb2bd1f02757b73188342ec00f1e97253b8ba8dfb577861675872a517bd949 tests/selectedNotesContext.test.mjs +c6c64c2b695b84d00f8a80a980be00766175c482548c86484f991ef671ecfbe9 tests/test_notes_document_mutual_minimize.py +02c2aeb856798d982e5debcb9c605172933b0216a039cf0d96cdd415b1e2ef83 tests/test_notes_dom_xss_helpers.py +5a511c7a2ef5d28463df34f3462db9ce59575d624cf8181577bab2cc371b4f9f tests/test_notes_mobile_checklist_toggle_static.py +1db10a0d344818459d64279da807ddefe9b866dfe560ac4b154b486dd1a5efc0 tests/test_notes_reference_browser.py +49f466b1a717098aa25439b6ff9ef3ae3d027d9dd976212b165b0b99a582b952 tests/test_notes_reference_contracts_js.py +c2d877a11eafcb99b24a1c89111e5df4453a62bc8cf7d26ae1118b25c4a5ace4 tests/test_notes_search_reset_on_reopen_js.py +6d95f073421427b5e149ef1e85c8b5749095d00337f05960134552985f73e372 tests/test_notes_select_esc_listener_js.py +b3118a4b40fcfcbb26ad9ccbcb332fc6ced873d8cad1160be38a286ad1e664a4 tests/test_notes_sidebar_new_note.py diff --git a/docs/frontend-architecture/evidence/notes/native-desktop.png b/docs/frontend-architecture/evidence/notes/native-desktop.png new file mode 100644 index 000000000..fd30ced2a Binary files /dev/null and b/docs/frontend-architecture/evidence/notes/native-desktop.png differ diff --git a/docs/frontend-architecture/evidence/notes/native-mobile.png b/docs/frontend-architecture/evidence/notes/native-mobile.png new file mode 100644 index 000000000..fdede2e84 Binary files /dev/null and b/docs/frontend-architecture/evidence/notes/native-mobile.png differ diff --git a/docs/frontend-architecture/handbook.md b/docs/frontend-architecture/handbook.md new file mode 100644 index 000000000..ade3bec14 --- /dev/null +++ b/docs/frontend-architecture/handbook.md @@ -0,0 +1,231 @@ +# Odysseus frontend architecture handbook + +Status: F1 merged in PR #6701. The complete Notes reference is implemented as an +unpublished review diff on `refactor/notes-reference-decomposition`. Read its +[case study](notes-case-study.md), [module inventory](notes-module-inventory.md) +and [validation receipts](notes-validation.md). Publication and the recipient's +post-integration reproduction remain pending. + +Read the [ADR](ADR-001-source-and-deployment.md), [assessment](assessment.md), +[validation report](validation.md) and [Notes roadmap](notes-roadmap.md). +The frontend program ends with the Notes reference decomposition and handoff. +Calendar, Documents, Email, Chat, Skills, Settings and other later decompositions +belong to the designated frontend maintainer; do not start them automatically. + +## Source, ownership and public APIs + +`frontend/src/app/` composes public feature APIs and platform ports. Each +`features//index.js` is an intentional API, with explicit named exports +and a compatibility default only when existing consumers require one. Internals +live under `domain/`, `application/`, `infrastructure/` or `ui/` as needed. Do not +create empty directories, boilerplate controllers or a framework by convention. +Feature names `app` and `shared` are reserved. + +Keep pure business rules in domain; transitions and workflows in application; +HTTP/storage/browser services in infrastructure; DOM rendering and lifecycle in +UI. The index composes adapters, state and views. Application receives adapters +as explicit arguments or constructor ports, rather than importing concrete +infrastructure. Views call application operations; infrastructure does not own +business decisions. A public API returns data/operations, not mutable stores or +private DOM nodes. Importing an API should define capabilities; explicit lifecycle +methods activate them. Legacy side effects remain characterized until a reviewed +slice safely changes their placement. + +Shared code has a named, proven cross-feature responsibility. `shared/domain/` +holds pure primitives, `shared/platform/` holds narrowly scoped platform adapters, +and `shared/ui/` holds common DOM capabilities. A named stateless root leaf such +as `shared/actionMenuOrder.js` is also allowed and receives domain restrictions. +Do not create a central `utils.js` collection or move feature business rules into +shared simply to satisfy an import rule. + +## Mechanically checked dependency contract + +| Importer | Permitted maintained-source targets | +| --- | --- | +| App composition | Other app code, shared code, feature `index.js` APIs | +| Feature index | Its own responsibility modules and shared code; other feature public indexes | +| Feature domain | Its own domain and shared domain/stateless leaves | +| Feature application | Its own application/domain, shared domain, other feature public indexes | +| Feature infrastructure | Its own infrastructure/domain, shared domain/platform | +| Feature UI | Its own UI/application/domain, shared domain/platform/UI | +| Shared domain leaf | Shared domain leaves only | +| Shared platform | Shared platform/domain only | +| Shared UI | Shared UI/platform/domain only | + +No feature imports app composition. No shared code imports a feature. No external +feature imports private responsibility modules. UI and infrastructure communicate +with another feature through a supplied application operation/port. Maintained +source dependency cycles, including literal lazy imports, fail the architecture +gate. If features need bidirectional coordination, app composition injects a +callback or adapter instead of creating mutual imports. Prefer this even when a +one-way public index import would pass the gate. + +Cross-feature events must document event name, payload, acknowledgement, owner, +subscription disposal and delivery timing. Keep them local and specific; F1 adds +no global event bus. Preserve `odysseus:notes-context-selected` and its synchronous +`accepted` acknowledgement in future Notes work. The server remains authoritative +for owner filtering and untrusted selected IDs. Frontend APIs cannot grant access. + +The checker parses executable ESM, forbids computed imports in new source, +requires exact precache entries, rejects unresolved/remapped relative imports, +and permits only canonical static JS URLs with one unversioned identity for +maintained modules. Domain code cannot mention known browser/HTTP/timer globals +or an ambient clock; use `now`/clock inputs. This is a conservative vocabulary +check, including shadowed names, not a semantic purity proof. Randomness, hidden +side effects, authorization and lifecycle behavior require behavioral tests. + +Legacy imports from new source default to forbidden. If an incremental bridge is +necessary, declare `legacyImports` on that source's manifest entry as a list of +`{ "specifier": "/static/js/example.js?v=existing", "reason": "contract, owner and removal slice" }`. +Only feature composition, infrastructure or UI boundaries may use it. Exact +specifier/reason and target existence are required; unused exceptions fail. +Keep the existing query identity. New domain/application modules receive no +legacy exception. F1 has zero exceptions. Do not use exceptions to invent new +shared platform dependencies on feature internals. + +## State and lifecycle + +Each feature has one authoritative mutable application-state owner. A transition +operation owns writes; UI receives a view/snapshot or subscription. Preserve +existing objects and initialization timing when extracting a stateful module. +Do not instantiate a new store in both the legacy facade and the feature API. +Compatibility facades only forward the same live implementation. + +The Notes reference uses internal read-only live views to preserve captured note +identity across existing asynchronous completions. Its state owner supplies all +mutations; the public index exposes operations without the store. Notes deliberately +retains last-completion fetch ordering. Do not infer request-generation guards from +its decomposition. Its page-owned reminders survive panel closure, and panel/editor +listeners and observers have separate disposal scopes. + +Document lifecycle scopes: page, authenticated user, session, mounted panel and +editor/draft. Name owners of subscriptions, DOM listeners, timers, observers, +pending requests and caches. Mounting must be idempotent; disposal releases the +resources it owns. Background reminders may outlive an open panel. Closing the +panel must not silently stop those reminders during extraction. + +Keep request-generation and selected-session checks around asynchronous +completions. Serialize writes where the current contract requires it. Dispose +subscriptions explicitly and prevent late updates to a replaced view. Feature +state changes on user/session switch must follow current cleanup policy. Browser +storage is for preferences/drafts/cache state, never a substitute for backend +authorization, credential storage or owner filtering. Define every storage key's +owner, retention/reset behavior and compatibility format before moving it. + +## Creating a module or introducing a feature + +1. Identify responsibility, owner, public consumers, side effects and behavioral + evidence before editing. Start with a pure leaf when introducing a feature. +2. Place only the required source file(s), for example + `features/example/domain/calculate.js`. Add an index only when there is a + real supported feature API to compose. +3. Register each source in `frontend/modules.json`. A typical new mapping is + `features/example/domain/calculate.js` to + `js/features/example/domain/calculate.js`. Preserve the source/deployment + relative graph. The proof's legacy URL mapping is an explicit special case. +4. For an extracted leaf, the legacy feature coordinator may import its output + as a temporary composition bridge. New maintained features use public indexes; + the legacy tree is not globally forced into the new boundary rules in F1. +5. Add the exact output URL to the appropriate explicit SW array, retain lazy + evaluation timing and bump the cache name when the graph changes. Declare + `text eol=lf` for both mapped paths in repository-owned `.gitattributes` + files; source-tree or scoped output patterns are allowed. The publisher checks + effective Git rules, including nested overrides, and rejects a missing LF rule + even if output bytes already match. Do not broaden unrelated legacy checkout + rules or automatically rewrite all existing query identities. +6. Run the publisher, inspect the source/output diff, then run architecture, + identity, graph, offline and focused behavior checks. Include both sides of + the mapping in review. Retiring output requires a separate reviewed deletion; + the publisher does not clean the static tree. + +```sh +python scripts/build_frontend.py +python scripts/build_frontend.py --check +npm ci --ignore-scripts --no-audit --no-fund +node scripts/check_frontend.mjs +node scripts/check_frontend.mjs --inventory +node --test tests/frontendArchitecture.test.mjs +./venv/bin/python -m pytest -q -rs tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py tests/test_panel_loader_js.py +./venv/bin/python scripts/verify_frontend_runtime.py +``` + +Use your actual repository virtualenv path; the F1 environment is recorded in +validation. Node 20+ follows current CI. Existing Playwright/browser prerequisites +are documented in `tests/README.md` and `tests/KNOWN_FAILURES.md`. Missing Node can +skip Python browser wrappers, which is a coverage gap, not a pass. The standalone +Node gates require locked development dependencies and fail when missing. + +The publisher validates all inputs before writing and only overwrites owned +outputs whose bytes differ. It is idempotent and working-directory independent. +Git must be available for publication and `--check`; neither runs at application +startup. Attribute evaluation uses a private temporary Git repository and ignores +global/local overrides, so checkout consistency is reproducible in CI. It adds +no runtime configuration or backend imports. CI checks LF attributes, parity and source +syntax in existing jobs; the Node job also runs architecture self-tests. Existing +runtime identity tests remain authoritative across legacy imports, HTML and SW. +The isolated runtime probe is included in the new pytest publication tests. + +## Review, release and rollback + +Review the source/output mapping and exact exported API, then the owner/dependency +contract and behavior tests. Check manifests, URL identities, MIME/CSP, load timing, +storage compatibility and disposal. Reject unexplained feature behavior changes. +Do not repair unrelated defects under an extraction title. + +Release from a validated revision with committed output. Native startup and +packagers consume static files without a compiler. Run parity before producing a +Docker or desktop artifact. Test the actual packaged application on its platform; +Linux contract probes cannot certify macOS/Windows executables. Deploy matching +shell, facades, graph and manifests as a unit. Keep an authorized path to the last +complete artifact. Rollback restores the whole matching set, not just an entry +file or cache version. Do not delete a facade until its public consumers have +migrated and compatibility tests make that decision reviewable. + +## Proposal and PR templates + +For a decomposition proposal, record: baseline; responsibility map; current public +API and consumers; state owner; I/O and lifecycle boundaries; planned slice; +allowed dependencies/temporary exceptions; preserved behavior; deterministic +characterization tests; deployment/offline changes; overlapping PRs; rollback; +explicit non-goals and remaining uncertainties. + +For each migration PR, lead with the responsibility moved and resulting boundary. +Record source/output paths, API compatibility, state/lifecycle ownership, +characterization results, exact validation commands, platform gaps, exception +removal plan, upstream dependencies and rollback. Attach a concrete before/after +example when needed to establish behavioral equivalence. Preserve the repository +PR template and never claim unexecuted checks. + +F1 is an entirely separate foundation-only PR. Deliver the complete Notes +decomposition, regression tests, compatibility validation and final reference +case study through one draft PR targeting official `dev`, using one Notes branch +and worktree after F1 integration. N0 through N6 and the N5 substages are internal +engineering checkpoints, not independent PRs or merges. Each checkpoint records +focused tests, review evidence, its validated revision or reproducible snapshot, +and rollback to the prior matching source/output/facade/precache set. Keep the +draft open while those checkpoints progress; mark it ready only after the entire +decomposition passes the [roadmap's acceptance gates](notes-roadmap.md). + +Structure the Notes PR description around the final integrated design and link +each checkpoint's evidence. The receiving maintainer may use smaller independent +PRs for future frontend features at their discretion; the one-PR Notes decision +does not impose that delivery size on later work. + +## Program backlog and final handoff + +Priority order: review/integrate F1; resolve root offline certification with the +owner of PR #6227; complete the Notes checkpoints and final reference case study +within one draft PR following the [roadmap](notes-roadmap.md); integrate the +complete Notes PR after its acceptance gates; finalize the recipient handoff. +Only then does the receiving maintainer prioritize Calendar, Documents, Email, +Chat, Skills and Settings against current behavior and risk. Existing partial +packages and high fan-in helpers need assessment, not automatic replacement. + +Final H acceptance requires a designated maintainer, the integrated Notes PR +revision and its internal checkpoint receipts, the actual public API/state/lifecycle +map and final reference case study included in that PR, validated deployment/offline +commands, known exceptions/debt, +outstanding PR dependencies, rollback evidence and a reproducible independent +setup by the recipient. F1 provides the handbook structure and contracts; it does +not claim Notes or the completed handoff have occurred. The initiating architect +then returns to separately authorized non-frontend architecture work. diff --git a/docs/frontend-architecture/notes-case-study.md b/docs/frontend-architecture/notes-case-study.md new file mode 100644 index 000000000..cc1a28d7e --- /dev/null +++ b/docs/frontend-architecture/notes-case-study.md @@ -0,0 +1,260 @@ +# Notes reference decomposition + +Implementation started: 2026-10-13. Final validation: 2026-10-14. +Foundation: [PR #6701](https://github.com/odysseus-dev/odysseus/pull/6701), +`1cc56cb9d78f3e2399d1d61155b71d771a17b85b`. Branch was safely updated via +non-destructive fast-forward to latest official `upstream/dev` at +`86b886c611338282af6d4a717246ea4e256f58df`, incorporating PRs #6704 and #6693. +Neither changed a file in the Notes diff. The independent AGY audit returned +CONDITIONAL PASS; all pre-publication corrective actions (issue template conformance, +PR template conformance, visual evidence paths, and em-dash removal) have been completed. + +The entire implementation belongs to one Notes PR. N0 through N6 are internal +checkpoints, not separate branches, commits or PRs. The [validation receipts](notes-validation.md), +[module inventory](notes-module-inventory.md) and [changed-file inventory](notes-changed-files.md) +accompany this case study. + +## Responsibility map and size + +The original `static/js/notes.js` contained 5,471 lines. It mixed mutable Notes +data, view preferences, selection, undo, CRUD, date calculations, reminders, +browser persistence, editor autosave, rendering, canvas tools and window management. +Its import evaluated storage preferences and scheduled reminder initialization. + +| Original responsibility | Maintained owner | +| --- | --- | +| Completion, tags, recurrence, date labels and serialization | `domain/recurrence.js`, `domain/noteRules.js` | +| Notes data and selection/filter/editing state | `application/state.js`, instantiated once in `index.js` | +| CRUD, undo and fetch completion ordering | `application/workflows.js`, `cardOperations.js`, `deletion.js` | +| Checklist mutations and rollback | `application/checklistOperations.js` | +| Editor optimistic submission and recovery | `application/submission.js` | +| Serialized autosave and synchronous backup | `application/drafts.js`; form collection in `ui/draftEditor.js` | +| Filtering, searching and ordering | `application/queries.js`; controls in `ui/filters.js` and `toolbar.js` | +| Selected-note attachment and bulk requests | `application/selection.js`; controls in `ui/selection.js` | +| Existing agent-solve workflow | `application/agentActions.js`, through supplied HTTP/session ports | +| HTTP, persistence and browser notifications | `infrastructure/http.js`, `storage.js`, `notifications.js` | +| Background scheduler and delivery decisions | `application/reminders.js` | +| Panel mounting, closing, keyboard handling and navigation | `ui/panel.js` | +| Docking, fullscreen bounds and stacking | `ui/windowLayout.js`, using existing shared capabilities | +| Cards, card gestures, quick add and masonry | `ui/cardRendering.js`, `cardInteractions.js`, `cardChecklist.js`, `cardReorder.js`, `quickAdd.js`, `layout.js` | +| Editor types, colors, reminders and checklists | `ui/editor.js`, `colors.js`, `reminderPicker.js`, `checklist.js` | +| Drawing, local drawing undo and PNG upload | `ui/drawing.js` | +| Mobile reader/editor and reorder gestures | `ui/mobileEditor.js`, `mobileReorder.js`, `checklistReorder.js` | +| Clipboard, safe rendering and reminder display | `ui/clipboard.js`, `text.js`, `reminderDisplay.js` | +| Owned document/window listener disposal | `ui/lifecycle.js`, with panel and observer disposal callbacks | +| Composition and supported exports | `index.js`; five-line legacy facade at `static/js/notes.js` | + +The feature has 41 maintained modules: two domain, eleven application, three +infrastructure, twenty-four UI and one index. Source is counted once, excluding +byte-identical deployment copies. Domain has 356 lines, application 826, +infrastructure 112, UI 4,628, and composition 518: 6,440 maintained feature lines. +The module inventory records final per-file counts. The legacy entry shrank +from 5,471 to five lines. Interfaces and explicit composition increase the total +source size; reducing total lines was not the architectural objective. + +One 28-line stateless confetti renderer also moved to `shared/ui/confetti.js`. +Notes and Compare both use it. Compare retains its existing `spawnConfetti` export, +which is the same function as the shared export. This removes Notes' import of +Compare's private voting implementation without migrating Compare itself. + +## Dependency graph and composition + +```mermaid +flowchart TD + Legacy[Legacy Notes URL and window bridge] --> Index[Notes index: one composition] + Index --> UI[Notes UI factories] + Index --> App[Application workflows and state owner] + Index --> Ports[HTTP, persistence and notification adapters] + UI --> Rules[Pure domain rules] + App --> Rules + Index --> Shared[Existing shared UI capabilities and maintained confetti] +``` + +Application modules receive adapters and feedback operations as arguments. They +do not import browser infrastructure or DOM views. Pure domain modules receive +the clock at the original read sites. UI consumes a read-only state view and +application operations. Composition supplies callbacks between views and workflows; +modules do not import the coordinator back. F1 rejects maintained-source cycles. + +There are nine exact legacy-import exceptions, all on the Notes index. They bridge +the existing UI, modal, color-picker, drag, tile, dock, stacking, Escape and tour-hint +capabilities at their existing URLs. Their removal condition is migration of the +corresponding shared responsibility to maintained source with its identity and +lifecycle contract verified. No new feature imports another feature's private internals. + +## State and asynchronous ownership + +`createNotesState` is called once. It owns the Notes collection, selection IDs, +editing ID, search, filters, archive/view flags, loading and dismissed-reminder time. +Neither the facade nor reminders create a second collection. The public feature API +does not expose this store. + +Internal views are read-only live views rather than detached snapshots. This choice +preserves captured note identity: an existing asynchronous rollback still acts on +the same captured note. Array/object writes and selection mutations must use the +state owner's operations. Workflow-local undo queues and serialized draft promises, +and UI-local canvas undo/gesture state, are not competing Notes stores. + +Fetches retain the original last-completion behavior. An earlier request that +finishes later can overwrite a later request's result. Tests pin this behavior; +generation guards would be a separate behavioral change. Submission also retains +the original first-temporary-note replacement rule. Multiple simultaneous creations +need a separately reviewed correlation fix, not a silent change during extraction. + +Archive, unarchive, pin, item edit/remove/add, delete and undo preserve their original +optimistic ordering and rollback. Animation callbacks remain view-owned. Drawing +save keeps the existing button guard around its asynchronous PNG upload. Draft writes +are serialized per editor; a completed older save cannot clear a newer local draft. + +Reminder scans still use the originally loaded Notes collection, not a second +all-active cache. An archived-only collection or a failure-cleared list can defer +active reminders until an active refresh/reopen. The timer remains alive when the +panel closes, but extraction does not redesign this inherited data-availability +limitation. Review such a change separately from scheduler/state ownership. + +## Public compatibility + +The import URL remains `/static/js/notes.js?v=20260911notesselectioncancel1`. +App, Chat stream updates, Chat note links and Documents continue using their existing +imports. Calendar and Documents retain their window bridge calls. HTML and app +bootstrap code are unchanged. The service worker retains the exact legacy entry. + +The legacy namespace still exports `openPanel`, `closePanel`, `togglePanel`, +`isPanelOpen`, `openNote`, `newNote`, `refreshDueBadge`, `dismissFiredReminderDot`, +`openNotes`, `closeNotes`, `isNotesOpen`, and `default`. +The default object's ten methods are unchanged; `dismissFiredReminderDot` remains +named-only. Aliases refer to the same functions, including those on the default +object. `openNote` and `refreshDueBadge` remain async; function arities are pinned. + +`window.notesModule` is exactly the default controller. The canonical unversioned +feature index and both tested legacy entry URLs share the same controller and +operations. The default object retains its existing mutability. A different legacy +URL has its own ESM namespace, as native ESM requires, but creates no second store +or scheduler because it forwards to the same canonical index. + +The canonical index additionally exports `startNotesBackground` for explicit +activation. The compatibility facade invokes it once through an idempotent start +operation. Existing consumers need no change. Remove the facade only after every +consumer, global bridge, bootstrap and offline reference has migrated together. + +## Protected selected Notes/Todos integration + +The sender dispatches `odysseus:notes-context-selected` on `window` with +`{ noteIds, accepted: false }`. Dispatch and acknowledgement remain synchronous. +Chat owns acceptance, its session/pending selection, the context indicator and +the `selected_note_ids` send payload. Notes exits selection only after acceptance. +Rejected selections stay selected and display the existing feedback. + +No Chat internals were imported or changed. Existing session lifecycle events still +transfer a pending selection on materialization, restore selections on chat switches, +and reset them for a new chat. Backend ID validation and owner filtering remain +authoritative. Unit/backend tests cover these contracts; the real authenticated +browser probe verifies rejection, pending-chat acceptance, the actual indicator and +new-chat reset. Live model execution is not part of that probe. + +## Persistence and HTTP contracts + +| Key | Preserved format | +| --- | --- | +| `odysseus-notes-view` | `list` or `grid` | +| `odysseus-notes-labels-collapsed` | `1` or `0` | +| `odysseus-notes-first-open-hint-v1` | Existing first-open marker | +| `odysseus-notes-reminder-fired` | JSON array of IDs | +| `odysseus-notes-reminder-glowed` | JSON array of IDs | +| `odysseus-notes-reminder-pending-highlight` | JSON array of IDs | +| `odysseus-notes-reminder-active-highlight` | JSON array of IDs | +| `odysseus-notes-reminder-dismissed-at` | Decimal millisecond timestamp | +| `odysseus-note-draft-` and `odysseus-note-draft-__new__` | Existing JSON draft fields and `_ts` | + +CRUD, reorder, upload, reminder firing, default-chat lookup, session creation and +chat-stream requests keep their URLs, methods, credentials, payloads and error +interpretation. Uploads retain `files` and optional `gallery_id` multipart fields. +The agent-solve stream still disables `manage_notes` and drains without switching +the user's chat. No backend implementation changed. + +Storage backup failures still warn once; failed network saves keep recoverable +drafts, including intentionally empty text. Draft JSON does not serialize a canvas +bitmap. A drawing remains ephemeral until its explicit PNG upload, as before. + +## Lifecycle scopes and explicit corrections + +| Scope | Owner and disposal | +| --- | --- | +| Browser page | One reminder startup timeout and interval; page termination ends them. The reminder factory also has explicit disposal. | +| Open panel | Capture/bubble keyboard handlers, reorder listeners and masonry observer; both close paths dispose them. | +| Editor/canvas | Local DOM handlers and draft workflow; drawing's window listener is released when its canvas detaches or the panel closes. | +| Mobile editor/gesture | Overlay and document touch handlers; owner replacement and panel disposal remove handlers and reset registration flags. | +| Menus/hints | Notes portals close with the panel; shared dismiss functions unregister their Escape/tour lifetimes. | +| File picker | Existing completion or 30-second cleanup owns the temporary input. | + +Two implementation corrections follow the user's explicit lifecycle requirements. +The original close paths canceled the supposedly background reminder interval. +The new page-owned interval survives closing/minimizing Notes, while retaining the +3-second startup delay, 30-second period and immediate first check. Closing a panel +also releases owned observers/listeners, pending hold timers, active Notes gestures +and portals; late panel fetch callbacks do +not recreate UI on a detached panel. Network completion/state ordering is preserved. + +Shared drag/window utilities continue owning their own active-gesture handlers. +They were not moved into Notes. Idle open/close and editor replacement are verified; +every possible interleaving with an active shared window drag was not tested. + +## Source and deployment + +Every maintained source is registered in `frontend/modules.json` and copied without +rewriting imports. Notes sources map to `static/js/features/notes/` at the same +relative paths. Shared confetti maps to `static/js/shared/ui/confetti.js`. +Repository-owned LF attributes and byte parity cover all 43 maintained modules, +including the unchanged F1 proof. The legacy facade is an authored compatibility +entry, not a second implementation. + +All new outputs join exact service-worker precache arrays. A real offline test also +found eight previously missing exact legacy URLs in the Notes graph; those entries +are now included. The eager Notes graph has 57 exact runtime URLs. Cache naming +follows the repository's `odysseus-v659-notes-reference` convention. Root offline +reload remains outside certification because the existing worker scope is `/static/`. + +Python startup and Docker serve the committed static graph without maintained +source or frontend tooling. The publisher is a development check/copy operation, +not a runtime compiler or an atomic deployment system. + +## Checkpoints, rollback and lessons + +[Checkpoint receipts](notes-validation.md#internal-checkpoints) record the actual +checks and local snapshot hashes. These intermediate snapshots are engineering +evidence, not standalone release artifacts. N5 includes bounded editor, cards, +checklist, drawing, mobile, toolbar and window reviews on the same branch. + +The N3 mechanical state rewrite initially confused canvas-local and feature undo +bindings. The N5 drawing test found it; the canvas binding was restored and the +same drawing/undo interaction was replayed against the original implementation. +Use scope-aware transformations and test each stateful UI responsibility before +accepting a broad rewrite. Source parsing alone does not prove semantic equivalence. + +For rollback, reconstruct the prior complete artifact from its revision/snapshot +in a fresh temporary directory, run parity/graph/behavior checks there, then publish +the matching static graph and service worker as a unit through the authorized +release process. Restore the whole facade/source/output/manifest set, not just +`notes.js`. No schema or storage-key migration needs reversal. Do not reset a dirty +worktree or delete unrelated outputs. The validation report gives a non-destructive +snapshot reproduction recipe. + +For the next maintainer: + +1. Start with the module inventory and the state/workflow contracts, then reproduce + the focused tests, offline test and isolated native browser probe. +2. Review the explicit timer/lifecycle corrections separately from unchanged + business rules. Inspect both maintained source and byte-identical deployment. +3. Resolve or track the reproduced upstream CI failures and the open PR overlaps + before authorizing publication. Create/link the dedicated issue, then publish + exactly one Notes PR targeting official `dev`. +4. Record the integrated revision and independent recipient reproduction to complete + H. The designated maintainer can then apply this method to future features. + +For Calendar, Documents, Email and later monoliths, characterize actual exports, +URL identities, state writers, clock reads, I/O and lifecycle first. Extract pure +rules, then adapters, then a single state owner/workflows. Use explicit callback +ports to break coordinator cycles. Split UI by interactions and resource lifetime, +not by arbitrary line ranges. Transfer source/output ownership and offline URLs +with each boundary, and preserve source-sensitive assertions at their new owner. +Those feature migrations are not part of this implementation. diff --git a/docs/frontend-architecture/notes-changed-files.md b/docs/frontend-architecture/notes-changed-files.md new file mode 100644 index 000000000..02f916e46 --- /dev/null +++ b/docs/frontend-architecture/notes-changed-files.md @@ -0,0 +1,165 @@ +# Notes complete changed-file inventory + +Changed files in PR #6713 (`refactor/notes-reference-decomposition`) against +`dev`. 121 files: 16 modified and 105 added. No files are deleted. Deployment copies +are byte-identical to their registered maintained sources. Image files are review +evidence, not production dependencies. + +The unchanged foundation worktree, backend, app bootstrap, HTML, CSS, dependency +lockfiles, Dockerfile and CI workflow are outside this diff. The only Compare +change delegates its existing stateless confetti export to maintained shared UI. + +## Maintained source (42) + +| Status | File | +| --- | --- | +| A | [frontend/src/features/notes/application/agentActions.js](../../frontend/src/features/notes/application/agentActions.js) | +| A | [frontend/src/features/notes/application/cardOperations.js](../../frontend/src/features/notes/application/cardOperations.js) | +| A | [frontend/src/features/notes/application/checklistOperations.js](../../frontend/src/features/notes/application/checklistOperations.js) | +| A | [frontend/src/features/notes/application/deletion.js](../../frontend/src/features/notes/application/deletion.js) | +| A | [frontend/src/features/notes/application/drafts.js](../../frontend/src/features/notes/application/drafts.js) | +| A | [frontend/src/features/notes/application/queries.js](../../frontend/src/features/notes/application/queries.js) | +| A | [frontend/src/features/notes/application/reminders.js](../../frontend/src/features/notes/application/reminders.js) | +| A | [frontend/src/features/notes/application/selection.js](../../frontend/src/features/notes/application/selection.js) | +| A | [frontend/src/features/notes/application/state.js](../../frontend/src/features/notes/application/state.js) | +| A | [frontend/src/features/notes/application/submission.js](../../frontend/src/features/notes/application/submission.js) | +| A | [frontend/src/features/notes/application/workflows.js](../../frontend/src/features/notes/application/workflows.js) | +| A | [frontend/src/features/notes/domain/noteRules.js](../../frontend/src/features/notes/domain/noteRules.js) | +| A | [frontend/src/features/notes/domain/recurrence.js](../../frontend/src/features/notes/domain/recurrence.js) | +| A | [frontend/src/features/notes/index.js](../../frontend/src/features/notes/index.js) | +| A | [frontend/src/features/notes/infrastructure/http.js](../../frontend/src/features/notes/infrastructure/http.js) | +| A | [frontend/src/features/notes/infrastructure/notifications.js](../../frontend/src/features/notes/infrastructure/notifications.js) | +| A | [frontend/src/features/notes/infrastructure/storage.js](../../frontend/src/features/notes/infrastructure/storage.js) | +| A | [frontend/src/features/notes/ui/cardChecklist.js](../../frontend/src/features/notes/ui/cardChecklist.js) | +| A | [frontend/src/features/notes/ui/cardInteractions.js](../../frontend/src/features/notes/ui/cardInteractions.js) | +| A | [frontend/src/features/notes/ui/cardRendering.js](../../frontend/src/features/notes/ui/cardRendering.js) | +| A | [frontend/src/features/notes/ui/cardReorder.js](../../frontend/src/features/notes/ui/cardReorder.js) | +| A | [frontend/src/features/notes/ui/checklist.js](../../frontend/src/features/notes/ui/checklist.js) | +| A | [frontend/src/features/notes/ui/checklistReorder.js](../../frontend/src/features/notes/ui/checklistReorder.js) | +| A | [frontend/src/features/notes/ui/clipboard.js](../../frontend/src/features/notes/ui/clipboard.js) | +| A | [frontend/src/features/notes/ui/colors.js](../../frontend/src/features/notes/ui/colors.js) | +| A | [frontend/src/features/notes/ui/draftEditor.js](../../frontend/src/features/notes/ui/draftEditor.js) | +| A | [frontend/src/features/notes/ui/drawing.js](../../frontend/src/features/notes/ui/drawing.js) | +| A | [frontend/src/features/notes/ui/editor.js](../../frontend/src/features/notes/ui/editor.js) | +| A | [frontend/src/features/notes/ui/filters.js](../../frontend/src/features/notes/ui/filters.js) | +| A | [frontend/src/features/notes/ui/layout.js](../../frontend/src/features/notes/ui/layout.js) | +| A | [frontend/src/features/notes/ui/lifecycle.js](../../frontend/src/features/notes/ui/lifecycle.js) | +| A | [frontend/src/features/notes/ui/mobileEditor.js](../../frontend/src/features/notes/ui/mobileEditor.js) | +| A | [frontend/src/features/notes/ui/mobileReorder.js](../../frontend/src/features/notes/ui/mobileReorder.js) | +| A | [frontend/src/features/notes/ui/panel.js](../../frontend/src/features/notes/ui/panel.js) | +| A | [frontend/src/features/notes/ui/quickAdd.js](../../frontend/src/features/notes/ui/quickAdd.js) | +| A | [frontend/src/features/notes/ui/reminderDisplay.js](../../frontend/src/features/notes/ui/reminderDisplay.js) | +| A | [frontend/src/features/notes/ui/reminderPicker.js](../../frontend/src/features/notes/ui/reminderPicker.js) | +| A | [frontend/src/features/notes/ui/selection.js](../../frontend/src/features/notes/ui/selection.js) | +| A | [frontend/src/features/notes/ui/text.js](../../frontend/src/features/notes/ui/text.js) | +| A | [frontend/src/features/notes/ui/toolbar.js](../../frontend/src/features/notes/ui/toolbar.js) | +| A | [frontend/src/features/notes/ui/windowLayout.js](../../frontend/src/features/notes/ui/windowLayout.js) | +| A | [frontend/src/shared/ui/confetti.js](../../frontend/src/shared/ui/confetti.js) | + +## Deployment copies (42) + +| Status | File | +| --- | --- | +| A | [static/js/features/notes/application/agentActions.js](../../static/js/features/notes/application/agentActions.js) | +| A | [static/js/features/notes/application/cardOperations.js](../../static/js/features/notes/application/cardOperations.js) | +| A | [static/js/features/notes/application/checklistOperations.js](../../static/js/features/notes/application/checklistOperations.js) | +| A | [static/js/features/notes/application/deletion.js](../../static/js/features/notes/application/deletion.js) | +| A | [static/js/features/notes/application/drafts.js](../../static/js/features/notes/application/drafts.js) | +| A | [static/js/features/notes/application/queries.js](../../static/js/features/notes/application/queries.js) | +| A | [static/js/features/notes/application/reminders.js](../../static/js/features/notes/application/reminders.js) | +| A | [static/js/features/notes/application/selection.js](../../static/js/features/notes/application/selection.js) | +| A | [static/js/features/notes/application/state.js](../../static/js/features/notes/application/state.js) | +| A | [static/js/features/notes/application/submission.js](../../static/js/features/notes/application/submission.js) | +| A | [static/js/features/notes/application/workflows.js](../../static/js/features/notes/application/workflows.js) | +| A | [static/js/features/notes/domain/noteRules.js](../../static/js/features/notes/domain/noteRules.js) | +| A | [static/js/features/notes/domain/recurrence.js](../../static/js/features/notes/domain/recurrence.js) | +| A | [static/js/features/notes/index.js](../../static/js/features/notes/index.js) | +| A | [static/js/features/notes/infrastructure/http.js](../../static/js/features/notes/infrastructure/http.js) | +| A | [static/js/features/notes/infrastructure/notifications.js](../../static/js/features/notes/infrastructure/notifications.js) | +| A | [static/js/features/notes/infrastructure/storage.js](../../static/js/features/notes/infrastructure/storage.js) | +| A | [static/js/features/notes/ui/cardChecklist.js](../../static/js/features/notes/ui/cardChecklist.js) | +| A | [static/js/features/notes/ui/cardInteractions.js](../../static/js/features/notes/ui/cardInteractions.js) | +| A | [static/js/features/notes/ui/cardRendering.js](../../static/js/features/notes/ui/cardRendering.js) | +| A | [static/js/features/notes/ui/cardReorder.js](../../static/js/features/notes/ui/cardReorder.js) | +| A | [static/js/features/notes/ui/checklist.js](../../static/js/features/notes/ui/checklist.js) | +| A | [static/js/features/notes/ui/checklistReorder.js](../../static/js/features/notes/ui/checklistReorder.js) | +| A | [static/js/features/notes/ui/clipboard.js](../../static/js/features/notes/ui/clipboard.js) | +| A | [static/js/features/notes/ui/colors.js](../../static/js/features/notes/ui/colors.js) | +| A | [static/js/features/notes/ui/draftEditor.js](../../static/js/features/notes/ui/draftEditor.js) | +| A | [static/js/features/notes/ui/drawing.js](../../static/js/features/notes/ui/drawing.js) | +| A | [static/js/features/notes/ui/editor.js](../../static/js/features/notes/ui/editor.js) | +| A | [static/js/features/notes/ui/filters.js](../../static/js/features/notes/ui/filters.js) | +| A | [static/js/features/notes/ui/layout.js](../../static/js/features/notes/ui/layout.js) | +| A | [static/js/features/notes/ui/lifecycle.js](../../static/js/features/notes/ui/lifecycle.js) | +| A | [static/js/features/notes/ui/mobileEditor.js](../../static/js/features/notes/ui/mobileEditor.js) | +| A | [static/js/features/notes/ui/mobileReorder.js](../../static/js/features/notes/ui/mobileReorder.js) | +| A | [static/js/features/notes/ui/panel.js](../../static/js/features/notes/ui/panel.js) | +| A | [static/js/features/notes/ui/quickAdd.js](../../static/js/features/notes/ui/quickAdd.js) | +| A | [static/js/features/notes/ui/reminderDisplay.js](../../static/js/features/notes/ui/reminderDisplay.js) | +| A | [static/js/features/notes/ui/reminderPicker.js](../../static/js/features/notes/ui/reminderPicker.js) | +| A | [static/js/features/notes/ui/selection.js](../../static/js/features/notes/ui/selection.js) | +| A | [static/js/features/notes/ui/text.js](../../static/js/features/notes/ui/text.js) | +| A | [static/js/features/notes/ui/toolbar.js](../../static/js/features/notes/ui/toolbar.js) | +| A | [static/js/features/notes/ui/windowLayout.js](../../static/js/features/notes/ui/windowLayout.js) | +| A | [static/js/shared/ui/confetti.js](../../static/js/shared/ui/confetti.js) | + +## Compatibility and deployment configuration (5) + +| Status | File | +| --- | --- | +| M | [.gitattributes](../../.gitattributes) | +| M | [frontend/modules.json](../../frontend/modules.json) | +| M | [static/js/compare/vote.js](../../static/js/compare/vote.js) | +| M | [static/js/notes.js](../../static/js/notes.js) | +| M | [static/sw.js](../../static/sw.js) | + +## Tests (21) + +| Status | File | +| --- | --- | +| A | [tests/browser/notesNative.mjs](../../tests/browser/notesNative.mjs) | +| A | [tests/browser/notesOffline.mjs](../../tests/browser/notesOffline.mjs) | +| A | [tests/browser/notesReference.mjs](../../tests/browser/notesReference.mjs) | +| A | [tests/fixtures/notesRecurrenceBaseline.mjs](../../tests/fixtures/notesRecurrenceBaseline.mjs) | +| A | [tests/helpers/notes_source.py](../../tests/helpers/notes_source.py) | +| A | [tests/notesApplication.test.mjs](../../tests/notesApplication.test.mjs) | +| A | [tests/notesCharacterization.test.mjs](../../tests/notesCharacterization.test.mjs) | +| M | [tests/notesDraftAutosave.test.mjs](../../tests/notesDraftAutosave.test.mjs) | +| A | [tests/notesInfrastructure.test.mjs](../../tests/notesInfrastructure.test.mjs) | +| A | [tests/notesRecurrence.test.mjs](../../tests/notesRecurrence.test.mjs) | +| A | [tests/notesReminders.test.mjs](../../tests/notesReminders.test.mjs) | +| A | [tests/notesWorkflows.test.mjs](../../tests/notesWorkflows.test.mjs) | +| M | [tests/selectedNotesContext.test.mjs](../../tests/selectedNotesContext.test.mjs) | +| M | [tests/test_notes_document_mutual_minimize.py](../../tests/test_notes_document_mutual_minimize.py) | +| M | [tests/test_notes_dom_xss_helpers.py](../../tests/test_notes_dom_xss_helpers.py) | +| M | [tests/test_notes_mobile_checklist_toggle_static.py](../../tests/test_notes_mobile_checklist_toggle_static.py) | +| A | [tests/test_notes_reference_browser.py](../../tests/test_notes_reference_browser.py) | +| A | [tests/test_notes_reference_contracts_js.py](../../tests/test_notes_reference_contracts_js.py) | +| M | [tests/test_notes_search_reset_on_reopen_js.py](../../tests/test_notes_search_reset_on_reopen_js.py) | +| M | [tests/test_notes_select_esc_listener_js.py](../../tests/test_notes_select_esc_listener_js.py) | +| M | [tests/test_notes_sidebar_new_note.py](../../tests/test_notes_sidebar_new_note.py) | + +## Runtime validation (2) + +| Status | File | +| --- | --- | +| M | [scripts/verify_frontend_runtime.py](../../scripts/verify_frontend_runtime.py) | +| A | [scripts/verify_notes_browser_runtime.py](../../scripts/verify_notes_browser_runtime.py) | + +## Documentation and execution receipts (9) + +| Status | File | +| --- | --- | +| A | [docs/frontend-architecture/evidence/notes/implementation.sha256](../../docs/frontend-architecture/evidence/notes/implementation.sha256) | +| A | [docs/frontend-architecture/evidence/notes/native-desktop.png](../../docs/frontend-architecture/evidence/notes/native-desktop.png) | +| A | [docs/frontend-architecture/evidence/notes/native-mobile.png](../../docs/frontend-architecture/evidence/notes/native-mobile.png) | +| M | [docs/frontend-architecture/handbook.md](../../docs/frontend-architecture/handbook.md) | +| A | [docs/frontend-architecture/notes-case-study.md](../../docs/frontend-architecture/notes-case-study.md) | +| A | [docs/frontend-architecture/notes-changed-files.md](../../docs/frontend-architecture/notes-changed-files.md) | +| A | [docs/frontend-architecture/notes-module-inventory.md](../../docs/frontend-architecture/notes-module-inventory.md) | +| M | [docs/frontend-architecture/notes-roadmap.md](../../docs/frontend-architecture/notes-roadmap.md) | +| A | [docs/frontend-architecture/notes-validation.md](../../docs/frontend-architecture/notes-validation.md) | + +See [the case study](notes-case-study.md) for ownership and handoff, +[the module inventory](notes-module-inventory.md) for line counts, and +[the validation report](notes-validation.md) for exact executed checks. diff --git a/docs/frontend-architecture/notes-module-inventory.md b/docs/frontend-architecture/notes-module-inventory.md new file mode 100644 index 000000000..5d8184858 --- /dev/null +++ b/docs/frontend-architecture/notes-module-inventory.md @@ -0,0 +1,56 @@ +# Notes source and deployment inventory + +The feature has 41 maintained modules. The unchanged F1 action-menu proof and +shared confetti bring the full manifest to 43. Outputs are copied byte-for-byte; +count maintained source once. The legacy entry is a five-line compatibility facade. + +| Source under frontend/src | Deployment under static | Lines | Layer | +| --- | --- | ---: | --- | +| [features/notes/application/agentActions.js](../../frontend/src/features/notes/application/agentActions.js) | [js/features/notes/application/agentActions.js](../../static/js/features/notes/application/agentActions.js) | 67 | application | +| [features/notes/application/cardOperations.js](../../frontend/src/features/notes/application/cardOperations.js) | [js/features/notes/application/cardOperations.js](../../static/js/features/notes/application/cardOperations.js) | 79 | application | +| [features/notes/application/checklistOperations.js](../../frontend/src/features/notes/application/checklistOperations.js) | [js/features/notes/application/checklistOperations.js](../../static/js/features/notes/application/checklistOperations.js) | 68 | application | +| [features/notes/application/deletion.js](../../frontend/src/features/notes/application/deletion.js) | [js/features/notes/application/deletion.js](../../static/js/features/notes/application/deletion.js) | 51 | application | +| [features/notes/application/drafts.js](../../frontend/src/features/notes/application/drafts.js) | [js/features/notes/application/drafts.js](../../static/js/features/notes/application/drafts.js) | 55 | application | +| [features/notes/application/queries.js](../../frontend/src/features/notes/application/queries.js) | [js/features/notes/application/queries.js](../../static/js/features/notes/application/queries.js) | 66 | application | +| [features/notes/application/reminders.js](../../frontend/src/features/notes/application/reminders.js) | [js/features/notes/application/reminders.js](../../static/js/features/notes/application/reminders.js) | 182 | application | +| [features/notes/application/selection.js](../../frontend/src/features/notes/application/selection.js) | [js/features/notes/application/selection.js](../../static/js/features/notes/application/selection.js) | 39 | application | +| [features/notes/application/state.js](../../frontend/src/features/notes/application/state.js) | [js/features/notes/application/state.js](../../static/js/features/notes/application/state.js) | 72 | application | +| [features/notes/application/submission.js](../../frontend/src/features/notes/application/submission.js) | [js/features/notes/application/submission.js](../../static/js/features/notes/application/submission.js) | 69 | application | +| [features/notes/application/workflows.js](../../frontend/src/features/notes/application/workflows.js) | [js/features/notes/application/workflows.js](../../static/js/features/notes/application/workflows.js) | 78 | application | +| [features/notes/domain/noteRules.js](../../frontend/src/features/notes/domain/noteRules.js) | [js/features/notes/domain/noteRules.js](../../static/js/features/notes/domain/noteRules.js) | 87 | domain | +| [features/notes/domain/recurrence.js](../../frontend/src/features/notes/domain/recurrence.js) | [js/features/notes/domain/recurrence.js](../../static/js/features/notes/domain/recurrence.js) | 269 | domain | +| [features/notes/index.js](../../frontend/src/features/notes/index.js) | [js/features/notes/index.js](../../static/js/features/notes/index.js) | 518 | composition | +| [features/notes/infrastructure/http.js](../../frontend/src/features/notes/infrastructure/http.js) | [js/features/notes/infrastructure/http.js](../../static/js/features/notes/infrastructure/http.js) | 29 | infrastructure | +| [features/notes/infrastructure/notifications.js](../../frontend/src/features/notes/infrastructure/notifications.js) | [js/features/notes/infrastructure/notifications.js](../../static/js/features/notes/infrastructure/notifications.js) | 20 | infrastructure | +| [features/notes/infrastructure/storage.js](../../frontend/src/features/notes/infrastructure/storage.js) | [js/features/notes/infrastructure/storage.js](../../static/js/features/notes/infrastructure/storage.js) | 63 | infrastructure | +| [features/notes/ui/cardChecklist.js](../../frontend/src/features/notes/ui/cardChecklist.js) | [js/features/notes/ui/cardChecklist.js](../../static/js/features/notes/ui/cardChecklist.js) | 148 | ui | +| [features/notes/ui/cardInteractions.js](../../frontend/src/features/notes/ui/cardInteractions.js) | [js/features/notes/ui/cardInteractions.js](../../static/js/features/notes/ui/cardInteractions.js) | 337 | ui | +| [features/notes/ui/cardRendering.js](../../frontend/src/features/notes/ui/cardRendering.js) | [js/features/notes/ui/cardRendering.js](../../static/js/features/notes/ui/cardRendering.js) | 244 | ui | +| [features/notes/ui/cardReorder.js](../../frontend/src/features/notes/ui/cardReorder.js) | [js/features/notes/ui/cardReorder.js](../../static/js/features/notes/ui/cardReorder.js) | 181 | ui | +| [features/notes/ui/checklist.js](../../frontend/src/features/notes/ui/checklist.js) | [js/features/notes/ui/checklist.js](../../static/js/features/notes/ui/checklist.js) | 190 | ui | +| [features/notes/ui/checklistReorder.js](../../frontend/src/features/notes/ui/checklistReorder.js) | [js/features/notes/ui/checklistReorder.js](../../static/js/features/notes/ui/checklistReorder.js) | 111 | ui | +| [features/notes/ui/clipboard.js](../../frontend/src/features/notes/ui/clipboard.js) | [js/features/notes/ui/clipboard.js](../../static/js/features/notes/ui/clipboard.js) | 82 | ui | +| [features/notes/ui/colors.js](../../frontend/src/features/notes/ui/colors.js) | [js/features/notes/ui/colors.js](../../static/js/features/notes/ui/colors.js) | 111 | ui | +| [features/notes/ui/draftEditor.js](../../frontend/src/features/notes/ui/draftEditor.js) | [js/features/notes/ui/draftEditor.js](../../static/js/features/notes/ui/draftEditor.js) | 67 | ui | +| [features/notes/ui/drawing.js](../../frontend/src/features/notes/ui/drawing.js) | [js/features/notes/ui/drawing.js](../../static/js/features/notes/ui/drawing.js) | 442 | ui | +| [features/notes/ui/editor.js](../../frontend/src/features/notes/ui/editor.js) | [js/features/notes/ui/editor.js](../../static/js/features/notes/ui/editor.js) | 518 | ui | +| [features/notes/ui/filters.js](../../frontend/src/features/notes/ui/filters.js) | [js/features/notes/ui/filters.js](../../static/js/features/notes/ui/filters.js) | 139 | ui | +| [features/notes/ui/layout.js](../../frontend/src/features/notes/ui/layout.js) | [js/features/notes/ui/layout.js](../../static/js/features/notes/ui/layout.js) | 120 | ui | +| [features/notes/ui/lifecycle.js](../../frontend/src/features/notes/ui/lifecycle.js) | [js/features/notes/ui/lifecycle.js](../../static/js/features/notes/ui/lifecycle.js) | 36 | ui | +| [features/notes/ui/mobileEditor.js](../../frontend/src/features/notes/ui/mobileEditor.js) | [js/features/notes/ui/mobileEditor.js](../../static/js/features/notes/ui/mobileEditor.js) | 257 | ui | +| [features/notes/ui/mobileReorder.js](../../frontend/src/features/notes/ui/mobileReorder.js) | [js/features/notes/ui/mobileReorder.js](../../static/js/features/notes/ui/mobileReorder.js) | 214 | ui | +| [features/notes/ui/panel.js](../../frontend/src/features/notes/ui/panel.js) | [js/features/notes/ui/panel.js](../../static/js/features/notes/ui/panel.js) | 506 | ui | +| [features/notes/ui/quickAdd.js](../../frontend/src/features/notes/ui/quickAdd.js) | [js/features/notes/ui/quickAdd.js](../../static/js/features/notes/ui/quickAdd.js) | 170 | ui | +| [features/notes/ui/reminderDisplay.js](../../frontend/src/features/notes/ui/reminderDisplay.js) | [js/features/notes/ui/reminderDisplay.js](../../static/js/features/notes/ui/reminderDisplay.js) | 129 | ui | +| [features/notes/ui/reminderPicker.js](../../frontend/src/features/notes/ui/reminderPicker.js) | [js/features/notes/ui/reminderPicker.js](../../static/js/features/notes/ui/reminderPicker.js) | 334 | ui | +| [features/notes/ui/selection.js](../../frontend/src/features/notes/ui/selection.js) | [js/features/notes/ui/selection.js](../../static/js/features/notes/ui/selection.js) | 61 | ui | +| [features/notes/ui/text.js](../../frontend/src/features/notes/ui/text.js) | [js/features/notes/ui/text.js](../../static/js/features/notes/ui/text.js) | 39 | ui | +| [features/notes/ui/toolbar.js](../../frontend/src/features/notes/ui/toolbar.js) | [js/features/notes/ui/toolbar.js](../../static/js/features/notes/ui/toolbar.js) | 78 | ui | +| [features/notes/ui/windowLayout.js](../../frontend/src/features/notes/ui/windowLayout.js) | [js/features/notes/ui/windowLayout.js](../../static/js/features/notes/ui/windowLayout.js) | 114 | ui | +| [shared/ui/confetti.js](../../frontend/src/shared/ui/confetti.js) | [js/shared/ui/confetti.js](../../static/js/shared/ui/confetti.js) | 28 | shared UI | + +Maintained Notes feature total: 6440 lines. Original entry: 5,471 lines. +Legacy facade after decomposition: five lines. Shared confetti: 28 lines. + +See [the case study](notes-case-study.md) for responsibilities and public/state/lifecycle +contracts, and [the validation report](notes-validation.md) for executed evidence. diff --git a/docs/frontend-architecture/notes-roadmap.md b/docs/frontend-architecture/notes-roadmap.md new file mode 100644 index 000000000..522923dc2 --- /dev/null +++ b/docs/frontend-architecture/notes-roadmap.md @@ -0,0 +1,178 @@ +# Notes reference decomposition roadmap + +Status: N0 through N6 implemented as an unpublished Notes review diff after F1 +merged in PR #6701. Actual decisions, interfaces, module ownership, checkpoint +receipts and execution limits are in the [case study](notes-case-study.md) and +[validation report](notes-validation.md). The proposal below retains the original +responsibility map for comparison. Notes integration and the recipient's independent +H reproduction remain pending. +F1 remains an entirely separate foundation-only PR. The complete Notes +decomposition, regression tests, compatibility validation and final reference +case study belong to one comprehensive draft PR targeting official `dev`. + +## Verified current responsibility and compatibility map + +Historical assessment baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +The implementation starts from merged F1 at +`1cc56cb9d78f3e2399d1d61155b71d771a17b85b`. +The module is `static/js/notes.js`, about 5,470 lines, with browser identity +`/static/js/notes.js?v=20260911notesselectioncancel1`. Preserve that identity across +the app import, HTML, SW and lazy consumers until a separately reviewed migration +changes the contract everywhere. + +| Responsibility | Current evidence | Intended owner after its reviewed slice | +| --- | --- | --- | +| State and CRUD | `_notes`, selection/filter/editing/loading variables, `_undoStack`, `_fetchNotes`, `_saveNote`, `_patchNote`, `_archiveNoteById` | One application state owner and workflow operations; no second reminder/UI copy | +| Dates and recurrence | `_normalizeRepeat`, `_advanceRecurring`, `_nthWeekdayOfMonth`, `_lastWeekdayOfMonth`, `_toLocalDatetimeStr`, repeat formatting helpers | Pure domain calculations with explicit clock inputs | +| Network and persistence | Notes CRUD/reorder, upload, reminder firing, draft and reminder localStorage keys | Infrastructure adapters preserving request payloads, credentials and error contracts | +| Reminders | `_checkReminders`, `_startReminderLoop`, `_initReminders`, notification permission/display and due/fired badge calculations | Application reminder workflow plus injected clock/storage/notification/API ports | +| Draft autosave | `_draftKey`, `_collectFormDraft`, `_wireDraftAutosave`, `_applyDraftToNote`, `_commitOpenInPlaceEditor` | Draft workflow with editor view/persistence ports and serialized saves | +| UI | `openPanel`, `closePanel`, `_renderNotes`, forms/cards/checklists, masonry, color/photo/drawing, mobile swipe/reorder | Scoped UI modules with explicit mount/dispose and application operations | +| Agent context | `#notes-send-agent` handler, selected IDs, `odysseus:notes-context-selected` | Preserve event payload and synchronous acknowledgement; Chat owns attachment/session state | +| Agent solve action | `_agentSolveNote`: default-chat lookup, session creation and chat stream | Separate existing workflow; do not conflate with selected-note attachment | +| Compatibility coordinator | Default controller, named aliases, `window.notesModule`, delayed reminder startup | Stable legacy facade/composition bridge until consumers safely migrate | + +The default API currently includes `openPanel`, `closePanel`, `togglePanel`, +`isPanelOpen`, `openNote`, `newNote`, `openNotes`, `closeNotes`, `isNotesOpen` and +`refreshDueBadge`. Named exports also include `dismissFiredReminderDot` and the +panel/open aliases. Characterization must pin the complete actual namespace and +default object separately, including identity of aliases and the window bridge. + +Consumers include `static/app.js` sidebar/routes/badge polling, +`chatStream.js` note tool updates, `chatRenderer.js` note links and +`document.js` coordination. `static/index.html` and `static/sw.js` are entry/cache +contracts. The shared UI, confetti, modal, color picker, drag, tiling, docking, +z-order and Escape helpers are direct dependencies. Those legacy dependencies +remain in the coordinator until their ownership is reviewed, rather than moving +all UI coupling into domain modules. + +Import-time behavior includes window origin/storage reads and +`setTimeout(_initReminders, 3000)`. The successful bootstrap loads Notes and starts +a 30-second reminder interval. App composition separately refreshes the badge +every five minutes. Panel/editing listeners, draft timers, highlight animations, +ResizeObserver and drawing/touch handlers have shorter scopes. Record them before +extraction; do not stop background reminders merely because a panel closes. +Listener cleanup concerns require characterization, not an unrelated fix hidden +inside an extraction. + +Persistence includes `odysseus-notes-view`, label collapse and hint preferences, +fired/glowed/pending/active reminder markers, dismissed timestamp, and +`odysseus-note-draft-` including `__new__`. Current direct APIs include Notes +CRUD/reorder/fire-reminder and upload, plus default-chat/session/chat-stream for +the existing solve action. Backend owner isolation and permission handling stay +authoritative and are outside this frontend decomposition's implementation scope. + +## PR #6495 contract that every relevant slice preserves + +Notes sends `odysseus:notes-context-selected` with `{ noteIds, accepted: false }`. +Chat's synchronous listener accepts only a valid active or pending chat, updates +session/pending selection, sets `accepted`, and updates the context indicator. +Notes clears select mode only after acceptance. Session lifecycle events +`odysseus:session-changed` carry session/materialization information. Chat appends +selected IDs as `selected_note_ids` when sending; server parsing validates IDs, +filters by owner and treats the resulting prompt context as untrusted. + +Keep selection transfer to a materialized chat, reset on a new chat, rejection +when no chat/model can accept it, aliases and indicator behavior. Do not broaden +frontend selections into permissions or bypass the backend. See +`tests/selectedNotesContext.test.mjs`, `tests/test_selected_notes_context.py`, +`static/js/chat.js` selected-context listeners and send payload, and +`static/js/sessions.js` lifecycle event producers. + +## One Notes branch, worktree and draft PR + +After F1 integration and authorization to begin Notes, create one worktree and +branch, suggested name `refactor/notes-reference-decomposition`, from the latest +official `upstream/dev`. N0 through N6 are sequential internal engineering +checkpoints on that same branch. No independent GitHub PR is required for a +checkpoint or a UI substage. Maintain one draft PR targeting official `dev`, with +checkpoint evidence linked from its description. Keep it a draft until the +complete decomposition passes all acceptance gates below. + +| Checkpoint | Responsibility | Deliverable and exit evidence | +| --- | --- | --- | +| N0 | Characterization | Public API/consumer, responsibility, state, lifecycle and network map; deterministic behavior tests; PR-overlap refresh; no extraction | +| N1 | Recurrence domain | Only pure date/recurrence rules to `features/notes/domain/recurrence.js`; preserve old wrapper call contracts, time-read position and returned strings; new offline dependency registered | +| N2 | Infrastructure | HTTP and storage adapters with exact request/error/key contracts; retain state ownership and backend authorization | +| N3 | Application | Single state owner and CRUD/optimistic/undo workflows; UI/reminders call operations; no competing arrays or stores | +| N4 | Reminders | Timer/notification/reminder orchestration through explicit ports; characterize startup, catch-up, deduplication, badge and background behavior | +| N5 | UI substages | Bounded card/panel, editor/draft, checklist, drawing and mobile reviews within the same PR; explicit listener/observer disposal and the same application operations | +| N6 | Compatibility and reference case study | Reduce coordinator; full API/consumer/lifecycle/offline comparison; retain legacy facade unless removal is proven safe; complete the final reference case study in the same PR | + +Do not create the Notes worktree, branch or PR during F1. N5 remains several +bounded internal reviews rather than a second monolith move. Every checkpoint +and UI substage must have its own focused tests, review evidence, lessons, +exceptions and a rollback plan. Record the validated revision or reproducible +patch/snapshot, prior known-good checkpoint, exact test commands/results and +how to restore its matching source/output/facade/precache set while preserving +unrelated work. Checkpoint commits require the applicable maintainer authority; +no checkpoint or commit is created by this planning document. + +Advance only after the current checkpoint's required checks and review findings +are resolved. A failed checkpoint returns to its prior validated state or receives +a bounded correction on the same branch; it does not require a separate PR. +If upstream changes require updating the baseline, update the same Notes branch, +record that revision and rerun affected checkpoint and compatibility gates. + +## Complete Notes PR acceptance gates + +Before marking the single draft PR ready for review: + +- Complete N0 through N6, including all N5 substages, with their tests, review + receipts and reproducible checkpoint/rollback evidence. +- Pass the required full Notes regression and compatibility checks on the final + combined revision, including PR #6495 selection/context behavior, public APIs, + module identities, single state ownership, storage formats, timers and teardown. +- Pass source/output parity, LF attribute, architecture, import graph and exact + offline manifest gates; verify current bootstrap/lazy behavior and relevant + native/packaging contracts. Record platform limits and distinguish preserved + offline module behavior from separately certified root offline reload. +- Resolve required gate failures and review findings; document unrelated existing + failures or coverage gaps explicitly rather than bypassing required checks. +- Include the final reference case study in this PR: actual before/after APIs, + dependency/state/lifecycle maps, stage decisions and lessons, validation recipes, + temporary exceptions, known debt and tested rollback/compatibility procedures. + +These are readiness gates for the complete PR, not separate stage merges. +The receiving maintainer may choose smaller independent PRs for future frontend +features; this one-PR delivery decision applies to the Notes reference program. + +## N0 and N1 characterization requirements + +Pin recurrence normalization for legacy bare repeat values and explicit values; +none/unknown/malformed input; daily/weekly/monthly/yearly advancement; month ends, +leap years, nth/last weekdays including absent fifth occurrences; overdue catch-up +and the 5,000-step guard; future anchors; local serialization and preserved hours; +DST gaps/overlaps under explicit time zones. `_advanceRecurring` currently reads +`Date.now()` after its first step. Inject a clock without changing that ordering. +Record the existing behavior of invalid dates rather than inventing improvements. + +Characterize request error handling, optimistic rollback/undo, archive/delete, +selection/filter/reset, ordering, uploads and notification permission errors. +For drafts, preserve synchronous local backup before the 500 ms persistence +timer, intentional empty text, checklist type fallback, serialized writes, +failure recovery and refusal to clear a newer draft when an older save finishes. + +Use stub clocks/ports and temporary storage for unit behavior; browser tests for +public namespace evaluation, DOM lifecycle, selected context and actual offline +loading. Run real modules through their output paths. Do not rewrite source-range +tests wholesale; migrate each only with its responsible slice and equivalent +behavioral assertions. Existing source-sensitive Notes autosave tests currently +read `notes.js`, so extraction has an explicit test-maintenance dependency. + +Before a slice, refresh #6585/#6647/#6652 backend behavior and #6470/#6695 shared +UI/platform overlaps from the [assessment](assessment.md). Existing fixes may +change characterization inputs, but this program does not merge or silently +reimplement those PRs. The independent root-SW fix #6227 is required before a +claim of full SPA offline reload readiness. + +## H completion and stopping rule + +The final reference case study is completed inside the Notes PR before it becomes +ready for review. After that PR is integrated, finalize H with the integrated +revision and checkpoint-to-integration receipts, outstanding dependencies and +the recipient's independent reproduction. Have the receiving maintainer +reproduce setup and validation independently and record that receipt. Transfer +future feature ownership explicitly. Do not automatically begin Calendar, +Documents, Email, Chat, Skills or Settings; the initiating architect continues +with separately approved non-frontend monolith work. diff --git a/docs/frontend-architecture/notes-validation.md b/docs/frontend-architecture/notes-validation.md new file mode 100644 index 000000000..e4e701c6c --- /dev/null +++ b/docs/frontend-architecture/notes-validation.md @@ -0,0 +1,261 @@ +# Notes implementation validation and checkpoint receipts + +Implementation baseline: `1cc56cb9d78f3e2399d1d61155b71d771a17b85b`, updated via +non-destructive fast-forward to latest official `upstream/dev` at +`86b886c611338282af6d4a717246ea4e256f58df` (incorporating PRs #6704 and #6693). +Independent AGY audit returned CONDITIONAL PASS. Pre-publication corrective +pass verified issue template, PR template, visual evidence paths, and em-dash removal. +The official integration branch is `dev`. Tracking issue #6712; PR #6713. + +## Final executed evidence + +| Check | Result | +| --- | --- | +| Notes Python tests, discovered by filename, plus F1 publication/graph/offline/loader tests | 172 passed, one release-smoke skip without `APP_PORT` | +| Node architecture, Notes oracle, domain, infrastructure, application, reminders, drafts, selection and workflow tests | 46 passed | +| Native Python lifespan/static distribution probe | Passed for every mapped output, with source/Node absent | +| Existing Docker image distribution probe | Passed with networking disabled and candidate files mounted read-only | +| Real authenticated native app, desktop and touch-mobile | Passed CRUD persistence, owner identity, actual Chat selected-context indicator/reset and the original release smoke scenario | +| Real Chromium first evaluation of Notes offline | Passed; every maintained output cached, facade/canonical API identity shared | +| Source/output parity and F1 boundaries | Passed, 43 maintained modules and nine composition-only legacy exceptions | +| Foundation-based full pytest execution | 13 failed, 13,909 passed, 60 skipped, six subtests passed, 192 warnings, 517.04 seconds | +| Targeted unchanged F1 baseline reproduction | Same 13 failures, 77 passed, six warnings | +| Targeted untouched latest `dev` reproduction | Three inherited failures, 87 passed, six warnings | +| Final indexed latest `dev` plus Notes overlay, full pytest | Three same-environment baseline failures, 13,923 passed, 60 skipped, six subtests passed, 192 warnings, 512.09 seconds | +| Untouched latest `dev` readiness checks, without the prewarm override | Five passed | + +The foundation-based full repository result is not green. Its failures reproduce against +the unchanged foundation in a disposable archive, with the same interpreter and +closed Chroma port. None was disabled, relabeled or fixed in this Notes change. +The focused Notes suite and required source/deployment gates are green. The final +code/test overlay is fingerprinted in [implementation.sha256](evidence/notes/implementation.sha256). +The only post-full-suite test-source edit removes a terminal +blank line from the browser wrapper; its content was compared ignoring terminal +whitespace and all three focused contract/browser checks passed again. Raw command +outputs are kept out of the repository; the counts above are the record. + +The first full run reported 19 failures. Six were introduced validation issues: +the SW cache name did not follow the existing numeric convention; a temporary +probe environment input was not registered by the configuration-reference checker; +and a Notes source assertion still expected the original default object spelling. +The cache name now follows the convention, the probe passes its output list as a +command argument, and the assertion verifies the new public binding. All 58 tests +in those affected files passed after correction. The foundation-based full run has only the +13 reproduced baseline failures below. + +### Reproduced baseline failures + +- `tests/test_aux_llm_owner_scope.py::test_auto_compaction_utility_endpoint_keeps_chat_owner` +- `tests/test_chatgpt_reasoning_and_grouping.py::test_history_and_chat_routes_support_reasoning_effort` +- `tests/test_containment_enforcement.py::test_namespace_handshake_closes_model_stdin` +- `tests/test_containment_enforcement.py::test_execution_environment_cannot_replace_probed_bwrap[False]` +- `tests/test_context_compactor.py::TestMaybeCompactFourthMessage::test_does_not_crash_on_none_content_turn` +- `tests/test_context_compactor.py::TestMaybeCompactFourthMessage::test_handles_multimodal_list_content` +- `tests/test_css_computed_style_snapshot.py::test_computed_styles_match_the_committed_baseline` +- `tests/test_history_compact_tool_calls.py::test_manual_compact_tolerates_chatmessage_with_none_content` +- `tests/test_history_compact_tool_calls.py::test_manual_compact_tolerates_dict_message_with_none_content` +- `tests/test_history_compact_tool_calls.py::test_registered_manual_compact_route_tolerates_none_content` +- `tests/test_history_compact_tool_calls.py::test_registered_manual_compact_route_uses_session_owner` +- `tests/test_model_helper_owner_scope.py::test_history_compact_resolves_with_owner_scope` +- `tests/test_readiness.py::test_tool_index_is_reported_but_not_critical_by_default` + +These cover existing compaction fixtures/source assertions, namespace containment, +settings-row CSS baseline drift and readiness expectations. The baseline reproduction used +`git archive` into `/tmp/odysseus-notes-baseline-OitxsU`, not another branch/worktree. + +## Reproducible commands + +### Latest integration target + +Final upstream inspection found `86b886c611338282af6d4a717246ea4e256f58df`. +PR #6704 repairs ten of the foundation's reproduced failures; PR #6693 adds +remote-only skill gating. Neither changes a file owned by the Notes diff. +The branch remains on the requested foundation, with no merge or rebase. +An archive of that latest target plus the exact final Notes code/test overlay is +validated in `/tmp/odysseus-notes-latest-VmZrgC` using the same full-suite command. +The fixture has a local Git index, initialized from the latest target and updated +with the review files, but no commit. This enables the tracked-publication checks. +The first archive-only attempt had one Git-metadata failure and seven checkout-only +skips. Both affected files subsequently passed all 19 tests with metadata present; +the complete suite was then rerun in that indexed fixture. +An untouched archive at `/tmp/odysseus-notes-latest-baseline-mfE4ss` reproduced +the remaining two containment failures and the readiness failure: three failed, +87 passed. + +These are reproduced local-environment failures, not a claim that official CI is +currently failing. The readiness assertion expects prewarming enabled, whereas +the full-run safety command explicitly sets `ODYSSEUS_TOOL_INDEX_PREWARM=0`. +With that override absent, all five readiness tests pass on untouched latest `dev`. +The two namespace tests receive extra `Failed to create stream fd` stderr in this +host's contained process output. That same output occurs without the Notes overlay; +no backend containment code was changed or a failing assertion weakened. +Reproduce those two checks on the supported CI runner before claiming a green +full-suite certification. + +In the final indexed full-suite run the only failures are `test_namespace_handshake_closes_model_stdin`, +`test_execution_environment_cannot_replace_probed_bwrap[False]`, and +`test_tool_index_is_reported_but_not_critical_by_default`. The missing-Git-metadata +publication failure and the seven checkout-only skips are absent from this run. +No Notes regression was reported. Feature code matched N6 at publication. Later +commits on the PR changed `ui/reminderPicker.js` (date input value assigned through +the DOM property) and, after review, `index.js`, `ui/layout.js` and +`application/state.js`; `implementation.sha256` covers the current files. + +To reproduce the integration rehearsal, extract the latest target with `git archive` +into a fresh fixture and copy only files in the changed-file inventory on top. +Do not overlay foundation files that are unchanged in this PR. Install the locked +development dependencies in the fixture, then run the commands below there. + +While this review diff is uncommitted, this prepares an equivalent Linux fixture +without changing either task worktree: + +```sh +notes_fixture=$(mktemp -d /tmp/ody-notes-integration-XXXXXX) +git archive 86b886c611338282af6d4a717246ea4e256f58df | tar -x -C "$notes_fixture" +{ git diff --name-only -z; git ls-files --others --exclude-standard -z; } | + tar --null -T - -cf - | tar -xf - -C "$notes_fixture" +git -C "$notes_fixture" init -q +git -C "$notes_fixture" fetch --no-write-fetch-head "$PWD" 86b886c611338282af6d4a717246ea4e256f58df +git -C "$notes_fixture" read-tree 86b886c611338282af6d4a717246ea4e256f58df +(cd "$notes_fixture" && npm ci --ignore-scripts --no-audit --no-fund && git add --all) +# Run the validation commands below from notes_fixture, using an isolated Python environment. +``` + +Use a real dependency directory for this recipe; a symlink named `node_modules` +can bypass the repository's directory-only ignore pattern. No checkpoint or +fixture commit is needed. After integration, validate the actual integrated checkout +directly, rather than relying on the uncommitted-diff recipe. + +### Commands + +Use the actual tested interpreter, or install the repository requirements in your +own isolated virtualenv. Node development dependencies were installed with the +existing lockfile and `npm ci --ignore-scripts --no-audit --no-fund`. + +```sh +python3 scripts/build_frontend.py +python3 scripts/build_frontend.py --check +node scripts/check_frontend.mjs +node scripts/check_frontend.mjs --inventory + +ENDPOINT_URL=http://127.0.0.1:1 node --test \ + tests/frontendArchitecture.test.mjs tests/noteTestOracle.test.mjs \ + tests/notesDraftAutosave.test.mjs tests/selectedNotesContext.test.mjs \ + tests/notesCharacterization.test.mjs tests/notesRecurrence.test.mjs \ + tests/notesInfrastructure.test.mjs tests/notesApplication.test.mjs \ + tests/notesReminders.test.mjs tests/notesWorkflows.test.mjs + +CHROMADB_HOST=127.0.0.1 CHROMADB_PORT=1 \ + python -m pytest -q -rs \ + $(rg --files tests -g 'test_*note*.py') \ + tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \ + tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \ + tests/test_panel_loader_js.py + +python scripts/verify_frontend_runtime.py +python scripts/verify_notes_browser_runtime.py + +docker run --rm --network none \ + --mount type=bind,src="$PWD",dst=/candidate,readonly \ + --workdir /candidate --entrypoint python \ + scripts/verify_frontend_runtime.py + +CHROMADB_HOST=127.0.0.1 CHROMADB_PORT=1 \ + ODYSSEUS_STARTUP_WARMUPS=0 ODYSSEUS_TOOL_INDEX_PREWARM=0 \ + python -m pytest -q -rfs + +git diff --check +``` + +`ENDPOINT_URL` satisfies the existing oracle's import configuration; its endpoint- +driving main does not run in these unit tests. Port 1 is the closed loopback target +used for pytest; the two runtime scripts reserve their own non-listening Chroma port. +The native browser script creates a private distribution/database and a temporary +authenticated user, then terminates the server and removes that fixture. It uses +the real application and Notes API rather than mocked browser routes. + +The deterministic browser harness uses stub API responses and the real Notes ESM, +DOM and stylesheet graph. It verifies namespace/default methods and arities, +aliases, remount/search reset, rejected/accepted attachments, draft persistence, +drawing/undo and listener/portal cleanup. It also checks larger-touch reorder after +re-render, active-drag close cleanup and cancellation of a mobile selection hold. +It includes the mobile viewport meta tag, +tap-to-read/edit behavior and the existing selection jiggle. The original monolith +can be replayed with `NOTES_BASELINE=1`; it is read from the frozen F1 revision. + +## Browser and packaging evidence + +Actual app screenshots: [desktop](evidence/notes/native-desktop.png) and +[mobile](evidence/notes/native-mobile.png). These are Chromium screenshots of the +disposable authenticated app at 1440 x 1000 and 390 x 844, not rendered mockups. +The deterministic before/after fixture had an identical mobile PNG and only 14 +changed desktop pixels in the header region; no layout/style change was observed. +CSS, app bootstrap and HTML have no implementation diff. + +The native and Docker distribution probe serves all manifest outputs without +`frontend/`, `node_modules/` or Node on the server path. It checks response bytes, +JS MIME, revalidation, CSP, shell startup, static confinement and source-tree 404. +Docker used existing image +`sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`. +This is not a fresh Dockerfile build or deployment. + +Tested locally: Linux, Python 3.14.4, Node 22.23.1 and Playwright/Chromium 1.62.1. +CI's exact Python 3.11/Node 20 pair, Firefox/WebKit, macOS/Windows executable builds, +live model/email notification delivery and full root offline reload were not run. +The Docker runtime uses its own existing dependency versions. Shared active-window +gesture interleavings are not exhaustively covered. No broader platform certification +is claimed. + +The full-run safety overrides differ from official CI's plain pytest command. +For the readiness default-contract check, use +`env -u ODYSSEUS_TOOL_INDEX_PREWARM python -m pytest -q tests/test_readiness.py`. +Keep explicit warmup/storage isolation on the disposable live-app probes. + +## Internal checkpoints + +Checkpoint snapshots are kept with the maintainer handoff, not in the repository. +The hashes below identify them. They contain scoped source/output/facade/manifest/test +overlays, not secrets or user data. Each starts from the same frozen baseline; they +are not separate PRs or release candidates. + +| Stage | Boundary and executed exit evidence | Snapshot SHA-256 | +| --- | --- | --- | +| N0 | Public/consumer/state/lifecycle map; 95 baseline Python and 11 Node passes; four new characterization tests; desktop/touch harness; 33 F1 publication/graph checks | `db0a2e6e68fec3442ce350ffb0acf47bcdf2a5266f7c64c2bf910a1c41c4fb62` | +| N1 | Pure recurrence/date rules; eight Node and nine focused Python passes; differential oracle in four time zones; parity/boundaries | `fd0638470744706fbbd5787cb47bd2078a93c9b854139f89f8fdc169c1f353c6` | +| N2 | HTTP/storage adapters; fourteen Node and seven focused Python passes; exact requests, keys and fallback contracts | `30826fb7972d1b9e3a790f97ee81f66dcb3dc58eb7d23e24d4f46e6d841b7c73` | +| N3 | Single state owner and CRUD/undo boundary; ten Node and seven focused Python passes; read-only state and archive rollback | `4bc5293519b8438d0126a61887bea64195dcdb7d59bcfaaba8ed4ae8c38be439` | +| N4 | Page-owned reminder workflow; four reminder contract tests and seven focused Python passes; singleton interval, catch-up, markers and fallback | `febb835c4958c8758e4b7c23ce89f3ec8d103c2cc28f89817d258b3791a9fcdf` | +| N5 | Complete UI/application integration; 44 Node passes, targeted source guards and actual browser checks; drawing binding correction, read-only state and disposal | `e147f645bdb2161a4b1dc6cf10c36ba6d0c214d8e12fccb478835043eaaf3e94` | +| N6 | Final facade, identity, exact offline graph, native/Docker checks, final editor-deletion/reorder ownership review, touch cleanup regression checks and maintainer review package | `c450e91f9fbfe5f0cd805e596b92a76383f22a74e9515ca3e3f6154aebf5f61f` | + +The N6 overlay contained the final code, tests, manifest and attributes at +publication, not the evidence directory itself. From the repository root, run +`sha256sum -c docs/frontend-architecture/evidence/notes/implementation.sha256` +to verify the current implementation files. + +No checkpoint commits were made. To reproduce an overlay, create a fresh temporary +directory, extract `git archive 1cc56cb9...` there, then extract the desired +snapshot from the handoff on top. Install the locked test dependencies +in that fixture and run its relevant commands. Begin from the baseline rather than +overlaying snapshots onto a later dirty tree. For live rollback, release a complete +matching artifact through the authorized release workflow. + +Temporary mechanical extraction scripts are not part of the supported deliverable. +They were archived locally, then removed. The first generated patch accidentally targeted +the separate foundation worktree; only those edits were reversed, absolute paths +were adopted, and that worktree's clean state was verified afterward. + +## Open PR overlaps and handoff limits + +The following remained open at the read-only GitHub refresh: + +- [#6585](https://github.com/odysseus-dev/odysseus/pull/6585): optional Notes field removal in the backend. Existing null-field behavior is retained here. +- [#6647](https://github.com/odysseus-dev/odysseus/pull/6647): read-only Notes intent routing. +- [#6652](https://github.com/odysseus-dev/odysseus/pull/6652): `manage_notes` registry dispatch. +- [#6470](https://github.com/odysseus-dev/odysseus/pull/6470): shared theme, typography, sidebar and modal changes. +- [#6695](https://github.com/odysseus-dev/odysseus/pull/6695): extension system/platform integration. + +Their changes were not merged or reimplemented. Shared UI exceptions, root-SW scope, +the original last-completion fetch race, temporary-note correlation and canvas-draft +limits are documented in the case study. Backend owner checks and PR #6495 contracts +remain authoritative. diff --git a/docs/frontend-architecture/validation.md b/docs/frontend-architecture/validation.md new file mode 100644 index 000000000..1e85db081 --- /dev/null +++ b/docs/frontend-architecture/validation.md @@ -0,0 +1,453 @@ +# F1 implementation and review report + +Date: 2026-10-13. Recommendation: ready for controlled incremental adoption after +maintainer review, with the platform/offline limits below. The foundation proof +is implemented and tested; Notes decomposition and the completed H handoff are +planned only. No commit, push, merge, PR or issue operation was performed. + +## Notes delivery documentation correction + +The maintainer's final delivery decision is one comprehensive Notes draft PR +targeting official `dev`, after the separate foundation-only F1 PR is integrated. +N0 through N6 are internal engineering checkpoints in one Notes worktree and +branch. The complete decomposition, regression tests, compatibility validation +and final reference case study belong to that single PR; it stays a draft until +the complete acceptance gates pass. Each stage retains its own test results, +review receipts and reproducible rollback/checkpoint strategy, without requiring +an independent GitHub PR. The receiving maintainer may choose smaller independent +PRs for future frontend features. + +This correction updates only `notes-roadmap.md`, `handbook.md` and this report. +Implementation and test files retain their pre-correction bytes. All five +foundation documents passed link, fence, whitespace, no-em-dash and delivery +wording checks. `git diff --check` and the preserved-runtime diff check passed. +A before/after snapshot confirmed exactly these three changed documents and +15 unchanged existing F1 files. The implementation test results below remain +the previous executed evidence; implementation tests were not rerun in this +documentation-only pass. + +## Independent review correction + +The maintainer reports that the independent F1 review found no critical +architectural defects. This pass preserves the reviewed native ESM architecture +and scope. Its correction is to turn the documented LF checkout convention into +a manifest-driven publisher/CI requirement. The full independent review text +was not supplied; this report records the supplied finding and executed evidence. + +The correction changes only `scripts/build_frontend.py`, +`tests/test_frontend_build.py`, the ADR, the handbook and this report. The +`.gitattributes` rules, architecture checker, CI workflow, manifest, dependencies +and stateless source remain as reviewed. The full F1 inventory below still has +18 files, including `.gitattributes`. + +The publisher now evaluates both paths of every manifest entry through Git's +actual attribute engine before parity evaluation or any deployment write. It +requires `text: set` and `eol: lf` from applicable repository-owned attribute files, +including nested overrides. A private temporary repository and isolated Git +configuration prevent global attributes, local `.git/info/attributes`, templates +or alternate Git directory settings from masking an omitted declaration. Paths +are sent through NUL-delimited stdin, preserving spaces and using no shell/path +quoting assumptions. The working repository's attributes, index and output are +not changed by this check. Git is a publication/development prerequisite; the +application does not invoke it to build or check frontend assets at startup. + +The production integration diff is the new `check_lf_attributes(root, paths)` +helper, four standard-library imports, this call, and updated CLI help: + +```diff +@@ synchronize + if unlisted: + raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}") ++ check_lf_attributes(root, sources | outputs) + stale = [] +``` + +The tests add fixture LF declarations and 14 cases. Ten parameterized cases +reject missing LF, CRLF, auto-text and binary-text rules for source/output paths +in both publish and check modes, even when bytes already match. The other four +cover a future manifest pair failing before any output is published and passing +after its LF declaration; nested rules and a spaced path; global/local settings +failing to mask an omitted repository rule; and clear failure without development +Git. Existing publication, ownership, confinement, parity and runtime tests remain. + +## Exact attributes diff and checkout evidence + +`git diff -- .gitattributes` is unchanged from the reviewed F1 diff: + +```diff +diff --git a/.gitattributes b/.gitattributes +index 8681aee1..dd49c4a0 100644 +--- a/.gitattributes ++++ b/.gitattributes +@@ -10,6 +10,10 @@ + entrypoint.sh text eol=lf + docker/entrypoint.sh text eol=lf + ++# Maintained frontend modules and this lane's byte-identical deployment output. ++frontend/src/** text eol=lf ++static/js/actionMenuOrder.js text eol=lf ++ + # Windows-native scripts stay CRLF. + *.ps1 text eol=crlf + *.cmd text eol=crlf +``` + +Exact command and results: + +```text +$ git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +frontend/src/shared/actionMenuOrder.js: text: set +frontend/src/shared/actionMenuOrder.js: eol: lf +static/js/actionMenuOrder.js: text: set +static/js/actionMenuOrder.js: eol: lf +``` + +The sampled legacy contracts were also inspected: + +| Path | text | eol | +| --- | --- | --- | +| `static/js/notes.js` | auto | unspecified | +| `static/index.html` | auto | unspecified | +| `launch-windows.ps1` | set | crlf | +| `start-macos.sh` | set | lf | + +Beyond samples, all 2,479 unrelated tracked paths were compared against HEAD for +both attributes and matched. The current Git 2.53.0 supports the diagnostic +`--source=HEAD` option; the publisher uses only the longstanding plain +`check-attr --stdin` interface, so it does not depend on that newer option. +The read-only whole-tree comparison command was: + +```sh +python3 - <<'PY' +import subprocess +paths = subprocess.check_output(['git', 'ls-files', '-z']) +def attributes(source): + result = subprocess.run( + ['git', 'check-attr', *source, '-z', '--stdin', 'text', 'eol'], + input=paths, capture_output=True, check=True, + ) + fields = result.stdout.decode().split('\0')[:-1] + return {(fields[i], fields[i + 1]): fields[i + 2] + for i in range(0, len(fields), 3)} +before, after = attributes(['--source=HEAD']), attributes([]) +owned = lambda p: p.startswith('frontend/src/') or p == 'static/js/actionMenuOrder.js' +changes = [(p, a, before[(p, a)], value) for (p, a), value in after.items() + if not owned(p) and before[(p, a)] != value] +assert not changes, changes +print(len({p for p, a in after if not owned(p)}), 'unrelated tracked paths unchanged') +PY +``` + +## Ownership enforcement after correction + +| Contract | Enforcement and CI receipt | +| --- | --- | +| Canonical, contained source/output paths and unique ownership | Publisher validates the whole manifest before writing; `python-syntax` runs its `--check` | +| Every maintained JS source is registered | Publisher rejects unlisted source; duplicate sources/outputs also fail | +| Reproducible LF checkout of both mapped paths | Publisher queries isolated repository-owned attributes, even for already matching bytes; the same CI `--check` now enforces it | +| Committed output equals maintained source | Publisher checks byte parity without rebuilding in CI; normal publication writes only stale owned outputs | +| Feature API/layer direction, no new cycles, stable relative resolution and maintained identities | Existing architecture checker and Node self-tests in `node-syntax`; no checker changes needed | +| Explicit exact-URL offline coverage | Architecture checker parses the existing SW arrays; real offline browser proof remains green | +| Legacy runtime graph/URL identity and behavior | Existing focused pytest guards and the full CI pytest jobs remain in place | + +These gates enforce the documented maintained-JS contract together. They do not +retroactively impose source ownership on all legacy static files, clean retired +outputs or prove semantic state/lifecycle correctness. Those existing limits and +the independent packaging/offline limitations below remain unchanged. + +## Final correction validation + +The full focused pytest command printed below was rerun with the same interpreter +and all listed files: **193 passed, 1 skipped, 1 existing SQLAlchemy warning in +12.33 s**. It includes all 27 publisher tests and the isolated native startup, +static traversal and no-Node distribution probe. The new 14 tests account for +the increase from the initial F1 run's 179 passes. No test failed in this pass. + +| Correction check | Actual result | +| --- | --- | +| Combined focused pytest command below | 193 passed, 1 unavailable-image skip | +| `node --test tests/frontendArchitecture.test.mjs` | 10 passed | +| Notes oracle/autosave/selected-context command below | 11 passed | +| `python3 scripts/build_frontend.py` | Passed, zero output changes, attributes enforced | +| `python3 scripts/build_frontend.py --check` | Passed, attributes and byte parity enforced | +| `node scripts/check_frontend.mjs` | Passed, one maintained module | +| Helper SHA-256 and `git check-attr` | Identical source/output hash; both text=set, eol=lf | +| All unrelated tracked attributes versus HEAD | 2,479 paths unchanged | +| `git diff --check` and preserved-runtime command below | Passed, no runtime/backend/packaging diff | +| `python -m compileall -q scripts/build_frontend.py tests/test_frontend_build.py` | Passed using the recorded virtualenv interpreter | +| Isolated Docker runtime command below | Passed, networking disabled; no source tree, node_modules or Node on PATH | +| Complete inventory, whitespace and documentation links | All 18 changed files matched the inventory; checks passed | + +The one skip remains the existing test requiring `odysseus-odysseus:latest`. +No new skip or waiver was introduced. Windows/macOS executable builds, a fresh +full Dockerfile build, the full pytest suite, Firefox/WebKit and CI's exact +Python 3.11/Node 20 versions remain unexecuted locally. Native Git semantics, +nested overrides, paths with spaces and ignored `core.autocrlf`/global attribute +settings are covered by executed Linux tests; actual Windows execution remains a +platform-owner check. The existing `/static/` worker scope still prevents a claim +of full root offline reload readiness. No service-worker scope change is included. + +The earlier local ChromaDB probe incident remains recorded under failures below. +All runtime checks in this correction pass used the isolated distribution probe +with reserved non-listening Chroma port and disabled warmups; Docker additionally +had networking disabled. This pass performed no new unisolated ChromaDB probe and +no attempt to restore or alter the existing local service's tool index. + +## Reviewer-ready PR summary + +Suggested title: `Establish frontend source ownership and native ESM deployment contracts`. + +Maintain a stateless helper under `frontend/src/` while publishing identical bytes +at its existing static URL. Add manifest-owned publication, repository-owned LF +attribute and byte-parity checks, parsed dependency/API/identity/offline gates, +and a self-contained architecture handbook with the Notes decomposition roadmap. +Startup and existing feature behavior retain their current runtime contracts. + +Validation: 193 focused Python tests passed with one existing Docker-image skip; +10 architecture and 11 Notes compatibility tests passed; source/output parity, +LF gates, preserved-runtime diff and isolated native/Docker runtime proofs passed. +Desktop executable builds and full root offline reload remain explicit limits. +All 18 files are inventoried below. The working diff is ready for review and is +uncommitted; commit, push and PR creation require separate authorization. + +## Verified repository state + +Repository: `odysseus-dev/odysseus`. Branch: +`refactor/frontend-architecture-foundation`. Worktree: +`/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation`. +Initial worktree was clean. HEAD and local official `upstream/dev` are +`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The live official GitHub `dev` SHA +matched at both initial and final verification. This is PR #6495's merge commit. +The `origin` remote is the maintainer fork, not the integration target. + +Final changes are uncommitted F1 files only. Runtime JavaScript, Notes, HTML, +SW, backend, Dockerfile and desktop startup/packaging implementations have no +diff. The stateless source and existing deployed helper are both 1,688 bytes, +SHA-256 `e17baec5d6ac381ce6234ab6f028a8208487280dd07d6950a3469f8488809684`. +The final publisher run reported zero changed output modules. + +## Decisions and scope + +The [ADR](ADR-001-source-and-deployment.md) compares two executed approaches: +controlled native ESM copying and esbuild 0.25.12 ESM compilation/bundling. +Native byte copying wins on existing URL/module/cache compatibility and absence +of runtime tooling. Commit explicit output beside maintained source, publish +only manifest-owned modules and enforce parity in CI. Python startup never builds. + +The [handbook](handbook.md) defines feature public indexes, responsibility layers, +state/lifecycle ownership, cross-feature ports/events, shared-code limits, legacy +coexistence, module creation, validation, rollback and handoff contracts. +The [assessment](assessment.md) records graph/bootstrap/state/storage/security, +packaging assumptions and current open-PR overlaps. The +[Notes roadmap](notes-roadmap.md) defines N0 through N6 as internal checkpoints +for one comprehensive Notes draft PR, with the final reference case study in +that PR and H recipient handoff after integration. None is implemented in F1. +There is one maintained stateless leaf and zero legacy-import +exceptions, with no empty feature scaffolding. + +Acorn 8.19.0 is the sole new, exact-pinned development dependency, used to parse +architecture edges accurately. It is not loaded or distributed as runtime JS. +esbuild was a pinned one-off spike and is not a project dependency. Existing +frameworks, application entry points and backend contracts are unchanged. + +## Exact changed files + +| File | Change | +| --- | --- | +| `.gitattributes` | LF checkout for maintained source and the registered deployed proof | +| `.github/workflows/ci.yml` | Parity check; locked parser install; source syntax and architecture self-tests | +| `package.json` | Exact Acorn development dependency | +| `package-lock.json` | Acorn lock/integrity entry | +| `frontend/modules.json` | Explicit source/deployment ownership mapping | +| `frontend/src/package.json` | Native ESM interpretation for source tests, no dependencies | +| `frontend/src/shared/actionMenuOrder.js` | Authoritative stateless source, copied verbatim from the existing helper | +| `scripts/build_frontend.py` | Validated source publisher with manifest-driven, repository-owned LF enforcement and non-mutating parity check | +| `scripts/check_frontend.mjs` | Parsed import/layer/API/cycle/identity/precache guards and graph inventory | +| `scripts/verify_frontend_runtime.py` | Isolated current-code distribution/runtime compatibility probe | +| `tests/frontendArchitecture.test.mjs` | Behavioral API equivalence and positive/negative architecture gates | +| `tests/test_frontend_build.py` | Publication/ownership/drift/runtime coverage plus positive and negative Git attribute enforcement tests | +| `tests/test_frontend_foundation_offline_js.py` | Real SW precache, offline first evaluation, canonical namespace identity and lazy registry proof | +| `docs/frontend-architecture/ADR-001-source-and-deployment.md` | Decision, alternatives and distribution/rollback contracts | +| `docs/frontend-architecture/assessment.md` | Evidence-backed architecture and PR-overlap inventory | +| `docs/frontend-architecture/handbook.md` | Permanent foundation and maintainer continuation methodology | +| `docs/frontend-architecture/notes-roadmap.md` | Reference decomposition and final handoff plan | +| `docs/frontend-architecture/validation.md` | This implementation/review/validation report | + +## Initial F1 validation before the review correction + +Environment: Linux; native Python 3.14.4; pytest 9.1.1; FastAPI 0.143.0; +Starlette 1.8.0; Node 22.23.1; Playwright 1.62.1 with installed Chromium. +CI remains Python 3.11 and Node 20; that exact interpreter pair was not executed +locally. Use the repository virtualenv, not the system Python missing dependencies. +The interpreter used here was +`/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python`. + +The historical counts below are coordinator-executed checks in this worktree, excluding +delegated claims and earlier repeated subsets. Delegation was discontinued on +user instruction; no delegated implementation was integrated. + +| Check | Actual result | +| --- | --- | +| Read-only baseline: graph, identity, helper, loader, MIME, nonce, runtime paths | 54 passed, one existing SQLAlchemy deprecation warning | +| Final combined focused pytest command below | 179 passed, 1 skipped, one existing SQLAlchemy warning, 12.62 s | +| Final `pytest -q -rs tests/test_frontend_build.py` review rerun | 13 passed, including native startup and encoded static traversal rejection | +| `node --test tests/frontendArchitecture.test.mjs` | 10 passed, no skips or failures; rerun after final gate review | +| Notes oracle/autosave/selected-context Node command below | 11 passed, no skips or failures | +| `python3 scripts/build_frontend.py` and `--check` | Zero output changes; byte parity passed | +| `node scripts/check_frontend.mjs` | Passed, one maintained module | +| `node scripts/check_frontend.mjs --inventory` | 230 files, 707 static edges, 108 lazy literal edges, no missing targets, three existing static cyclic components | +| `npm ci --ignore-scripts --no-audit --no-fund` | Locked development dependency installation succeeded | +| Native `scripts/verify_frontend_runtime.py` | Real FastAPI lifespan and shell/static assertions passed without maintained source, node_modules or Node on PATH | +| Docker runtime probe below | Same current-code assertions passed with networking disabled and source mount read-only | +| esbuild spike | Two identical 1,554-byte bundle outputs; executed exports/icon/ordering equivalence passed | +| JS syntax checks and Python compileall below | Passed | +| `git diff --check`, preserved-scope diff check, hash/LF checks | Passed; no runtime/backend/packaging source diff | +| Foundation documentation links, trailing whitespace and em-dash checks | All five files passed | + +The skipped test is +`tests/test_docker_devops_hardening.py:187`, which requires the unavailable +`odysseus-odysseus:latest` image. It was not relabeled or bypassed. F1's separate +Docker runtime probe used the available `odysseus-maintainer-preview-odysseus:latest`, +image ID `sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`, +with Python 3.14.7, FastAPI 0.141.1 and Starlette 1.6.0. This proves compatibility +of current candidate code/assets with that image's runtime dependencies, not a +fresh build of the full repository Dockerfile or a deployment. + +Reproduce the final focused command with the actual tested interpreter: + +```sh +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m pytest -q -rs \ + tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \ + tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \ + tests/test_action_menu_order.py tests/test_panel_loader_js.py \ + tests/test_app_static_mime.py tests/test_serve_html_with_nonce.py \ + tests/test_runtime_paths.py tests/test_ci_authoritative_validation.py \ + tests/test_selected_notes_context.py tests/test_email_library_module_graph_js.py \ + tests/test_settings_module_import_paths.py tests/test_app_config_shared_fetch_js.py \ + tests/test_markdown_lazy_lib_loading_js.py tests/test_startup_shell_js.py \ + tests/test_static_stylesheet_manifest.py tests/test_notes_dom_xss_helpers.py \ + tests/test_notes_reminder_temporal_redos.py tests/test_notes_sidebar_new_note.py \ + tests/test_notes_mobile_checklist_toggle_static.py \ + tests/test_notes_document_mutual_minimize.py tests/test_docker_devops_hardening.py + +node --test tests/frontendArchitecture.test.mjs +ENDPOINT_URL=http://127.0.0.1:1 node --test \ + tests/noteTestOracle.test.mjs tests/notesDraftAutosave.test.mjs \ + tests/selectedNotesContext.test.mjs + +python3 scripts/build_frontend.py --check +node scripts/check_frontend.mjs +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python scripts/verify_frontend_runtime.py + +docker run --rm --network none \ + --mount type=bind,src=/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation,dst=/candidate,readonly \ + --workdir /candidate --entrypoint python \ + odysseus-maintainer-preview-odysseus:latest scripts/verify_frontend_runtime.py +``` + +`ENDPOINT_URL` satisfies the existing oracle's import-time configuration requirement. +Its unit-test imports exercise pure exported functions; its network-driving main +is guarded by the executable-module check and does not run. The closed loopback +address is not a live model endpoint. No oracle implementation was changed. + +Syntax commands executed: + +```sh +bash -c 'shopt -s globstar nullglob; for f in static/app.js static/js/**/*.js frontend/src/**/*.js scripts/check_frontend.mjs tests/frontendArchitecture.test.mjs; do node --check "$f" || exit; done' +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m compileall -q app.py core routes src services scripts tests +git diff --check +git diff --exit-code -- static app.py Dockerfile .dockerignore Odysseus.spec build-macos-app.sh build-windows-portable.ps1 launcher.py core routes src services +git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +sha256sum frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +``` + +Spike compiler invocation, executed twice with equality and Node import assertions: + +```sh +npx --yes esbuild@0.25.12 frontend/src/shared/actionMenuOrder.js \ + --bundle --format=esm --target=es2022 --platform=browser --log-level=error +``` + +The clean publication proof uses temporary roots in `test_frontend_build.py`, +including an initially absent output tree. Real-checkout publication is idempotent. +No emitted JS bytes, URLs, boot scripts or cache manifests changed in this lane. + +## Failures encountered and their resolution + +- An early architecture self-test expected an identity diagnostic but got an + unresolved source path for a query-bearing relative import. The checker now + rejects alternate identity before resolving that source path. All final tests + pass; no test requirement was weakened. +- The initial Notes oracle command failed during import because `ENDPOINT_URL` + was missing. Inspection established that the unit-test path never runs its + endpoint-driving main. The explicit closed-address configuration above passed + all 11 tests. This prerequisite is recorded for N0 rather than hidden. +- The first isolated distribution fixture omitted the existing `companion/` + package, so both probes failed with `ModuleNotFoundError`. The fixture now + includes that required runtime directory; neither application nor packaging + implementation was modified to make the tests pass. +- The first exploratory native startup check connected to the existing local + ChromaDB and refreshed the built-in tool index. That was unintended. Subsequent + probes use a private distribution/data directory, a reserved non-listening + Chroma port, disabled existing warmup options and no Node on PATH. Docker also + disables networking. No attempt was made to overwrite or restore that service's + data; the earlier unisolated probe is not the reproducible validation recipe. + +## Remaining risks and limits + +Root offline reload is not certified: existing SW scope is `/static/`, and open +[#6227](https://github.com/odysseus-dev/odysseus/pull/6227) proposes the independent +scope/header correction. F1 preserves and tests actual offline module behavior +at the current scope. No first-use offline guarantee is claimed for optional +CDN libraries, Mermaid or server APIs. + +Windows/macOS packaged executables, a fresh full Dockerfile build, the full pytest +suite, Firefox/WebKit offline behavior and CI's exact Python 3.11/Node 20 versions +were not executed here. Existing packaging paths are inspected and unchanged; +platform owners should run release validation before broader certification. +The unavailable-image test remains a coverage gap. Legacy source still has +cross-feature coupling, global side effects and static cycles. New boundaries +are enforced on maintained source, not retrospectively on the entire legacy tree. + +The publisher intentionally does not clean retired outputs or provide atomic +multi-file deployment. Review full source/output/facade/manifest changes and +ship a coherent artifact. Domain syntax checks cannot prove all semantic purity +or disposal; behavioral characterization remains required. Reconcile the package +lock with #6627 and refresh all PR overlaps before integration. + +## Reviewer checklist + +- Verify the base contains #6495 and the official integration target is dev. +- Confirm the deployed helper hash and runtime-scope diff are unchanged. +- Review the layer matrix, public API rules, single-state ownership and zero + current legacy exceptions; verify negative tests actually reject violations. +- Review the manifest mapping, byte-parity/LF policy, exact offline precache and + source/output relative-resolution guard; verify missing or overridden LF + declarations fail publication and CI even when output bytes already match. +- Reproduce the focused tests and inspect the skipped-image prerequisite. +- Confirm no frontend compiler/runtime dependency is required at startup and + inspect the isolated Python/Docker probe's limits. +- Review overlap with #6470/#6695/#6227/#6627 and the Notes backend follow-ups. +- Treat the ADR as awaiting adoption review, and the Notes/H documents as plans. +- Authorize any commit/push/PR action separately. Do not begin Notes on this branch. + +## Required next reviews and continuation + +1. Review this F1 working diff. On explicit authorization, commit and submit only + the foundation-only F1 PR to official dev using the normal maintainer workflow. +2. Coordinate the existing #6227 offline fix and platform release validation as + separate work. Do not bundle that behavioral change into F1. +3. After F1 integration and Notes implementation authorization, create one Notes + worktree and branch from the latest official dev, preserving any subsequently + merged Notes changes. Use one comprehensive draft PR targeting official dev. +4. Complete N0 through N6 from the [Notes roadmap](notes-roadmap.md) on that same + branch, recording tests, review and rollback evidence at each checkpoint. Include + the final reference case study in the same PR and keep it a draft until the full + decomposition and regression/compatibility acceptance gates pass. Recurrence + extraction follows characterization as an internal stage, not another PR. +5. Complete H only after Notes integration and recipient reproduction. Transfer + future frontend work to the designated maintainer and stop this program. + +Another maintainer can start at the handbook, run the documented source/parity, +architecture, runtime and behavior checks, inspect this report and select the +next authorized slice without relying on unwritten architecture decisions. diff --git a/docs/maintainers/README.md b/docs/maintainers/README.md new file mode 100644 index 000000000..bd5e93f24 --- /dev/null +++ b/docs/maintainers/README.md @@ -0,0 +1,38 @@ +# Odysseus maintainer handbook + +Odysseus maintainers are trusted to act, not to wait for permission. The default is individual responsibility; additional review is required when the **risk** warrants it. + +This handbook records the project's maintainer governance and gives reviewers a reusable process. It complements [CONTRIBUTING.md](../../CONTRIBUTING.md) and [SECURITY.md](../../SECURITY.md); it does not replace the PR template, repository protections, or product decisions. + +## At a glance + +| Area | Working rule | +|---|---| +| Product direction | Pewds is the Product Owner | +| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks | +| External contribution | Maintainer review required | +| High-risk change | At least one **independent, qualified** maintainer review | +| Access | Need-to-have, least privilege, separately granted per system | +| Release | A designated Release Maintainer owns preparation and publish | +| Inactivity | Access review after 2 months without activity, notice, or response | +| Documentation | Repository Markdown is canonical | + +These are governance rules, not guarantees that a GitHub permission or branch-protection setting permits a particular action. Respect the stronger applicable automated gate. + +## Start here + +- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity. +- [Product and feature proposals](product-and-features.md): product approval versus technical ownership. +- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections. +- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence. +- [Triage](triage.md): turning incoming reports into actionable work. +- [Testing and releases](testing-and-releases.md): evidence, platform validation, promotion to main. +- [Documentation](documentation.md): canonical sources, updates, and traceability. + +## Accountability + +Maintainers own the changes they take on, including validation, regression prevention, follow-up, and rollback when needed. Roles describe responsibilities, not a hierarchy of technical approval. + +Important product-direction decisions go through the Product Owner. Maintainers retain responsibility for feasibility, implementation, maintainability, and security. + +AI coding or review agents can help collect evidence, test hypotheses, and prepare changes, but they are not the qualified human reviewer required for high-risk changes. A maintainer must inspect and accept the evidence. diff --git a/docs/maintainers/changes-and-review.md b/docs/maintainers/changes-and-review.md new file mode 100644 index 000000000..664b2596a --- /dev/null +++ b/docs/maintainers/changes-and-review.md @@ -0,0 +1,62 @@ +# Changes and review + +Review is **risk-based**. Trusted maintainers should be able to ship ordinary work without creating a queue of ceremonial approvals. + +## Required review + +| Change | Minimum review rule | +|---|---| +| Ordinary, reversible maintainer change | Independent approval optional; self-merge permitted after appropriate validation | +| External contribution | Maintainer review required | +| High-risk change | At least one independent, qualified maintainer review required | + +Repository branch protection and required checks also apply. Never treat this document as permission to bypass a live protection rule. + +## Ordinary maintainer changes + +1. Open a PR against `dev`, scoped to one coherent change. +2. Explain intent, expected behavior, validation, and rollback if relevant. +3. Run suitable tests and inspect the actual result. +4. Ensure applicable required checks pass, address feedback, and merge when ready. + +Self-merge is allowed for ordinary reversible work. The author remains responsible for follow-up, correction, and rollback. + +## External contributions and small corrections + +External contributions receive maintainer review. + +A maintainer may directly fix a **small, obvious** problem in a contributor PR when the intended behavior is clear and the repair is faster than a separate round trip. Document what changed, preserve contributor attribution and history, avoid force-pushing, and re-run affected checks. + +Substantive or ambiguous corrections require discussion with the contributor or a separately scoped correction. A maintainer must not silently turn a contributor's PR into unrelated work. Any corrective commit changes the SHA being reviewed; update validation and audit evidence accordingly. + +## High-risk changes + +High-risk work requires **at least one independent qualified review**. The author cannot be the only approver. Examples: + +- Authentication, authorization, tenancy, sandboxing, agent tool permissions, or secrets. +- Outbound network and SSRF defenses, sensitive integrations, and deployment access. +- Destructive operations, deletion, filesystem ownership, and data migrations. +- Persistence and concurrency invariants with possible data loss. +- Major architectural contracts, release infrastructure, or changes hard to roll back. + +A high-risk review should verify the threat or failure model, exact final commit, reproducer or regression evidence, failure and rollback behavior, and the relevant CI/security findings. Use the [review playbook](review-playbook.md). + +## Cross-area changes and CODEOWNERS + +Touching multiple areas does **not** automatically require multiple approvals. Consult domain expertise when needed. `.github/CODEOWNERS` currently contains no ownership rules; the per-area map is under discussion in [issue #593](https://github.com/odysseus-dev/odysseus/issues/593). Once populated, it helps identify reviewers and accountable contacts; it is not a blanket approval requirement by itself. + +## Validation and UI evidence + +Automated tests count as runtime evidence when they truly exercise the changed behavior. Use manual validation when coverage is insufficient. + +For any change to visible UI behavior, follow the current [contributor UI checklist](../../CONTRIBUTING.md#style-and-visual-changes) and the repository PR template. Screenshots or clips are expected when they materially demonstrate the rendered result; screenshots alone do not replace behavioral tests. + +## Disagreement and escalation + +The maintainer doing the work owns ordinary implementation decisions within approved product direction and existing technical contracts. For substantive disagreements: + +1. Involve a qualified maintainer with relevant expertise. +2. Escalate unresolved cross-cutting technical issues to trusted project-wide maintainers. +3. Refer product-direction disagreements to the Product Owner. + +Security findings should be handled via [SECURITY.md](../../SECURITY.md), without disclosing exploit details in public issues. Repository protection rules should support these risk-based policies and should not accidentally force independent approval for every ordinary maintainer PR. diff --git a/docs/maintainers/documentation.md b/docs/maintainers/documentation.md new file mode 100644 index 000000000..36c88d81d --- /dev/null +++ b/docs/maintainers/documentation.md @@ -0,0 +1,32 @@ +# Documentation + +Repository Markdown is the canonical source for project documentation. A public docs site may be generated from it; avoid maintaining an independent wiki copy unless there is a deliberate future decision to do so. + +## Where information belongs + +| Location | Purpose | +|---|---| +| `/design` | Product intent, proposals, and accepted decisions (when those paths are used) | +| `/specs` | Technical contracts and invariants | +| `/website` | User, setup, configuration, and deployment guidance (published as the docs site) | +| `/docs` | Developer, architecture, security, and maintainer guidance | +| Root Markdown | Discoverable entry points and policies | + +Keep root documents easy to find: + +- `README.md` +- `CONTRIBUTING.md` +- `SECURITY.md` +- `THREAT_MODEL.md` + +Not every named directory exists today; do not invent a parallel documentation store just to match this map. + +## Updating documentation + +When behavior, architecture, a workflow, or its verification requirements change, update the canonical documentation in the same PR when appropriate. + +Separate **policy** from **examples**: examples may evolve with tooling; changes to role authority, review requirements, or product ownership require an explicit governance decision. Do not introduce a new mandatory approval rule through a checklist alone. + +Documentation PRs should link the relevant issue, record what was reviewed (links, commands, or affected features), and avoid publishing private channels, account names, local paths, secrets, or confidential incident details. + +For practical examples of review evidence, see the [review playbook](review-playbook.md). For contributor setup and PR requirements, use [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/docs/maintainers/product-and-features.md b/docs/maintainers/product-and-features.md new file mode 100644 index 000000000..d41055a7f --- /dev/null +++ b/docs/maintainers/product-and-features.md @@ -0,0 +1,48 @@ +# Product and feature proposals + +Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety. + +## Which changes require product approval? + +**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse. + +**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction. + +Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery. + +### Urgent security and reliability fixes + +Urgent fixes or containment may proceed without prior product approval, even if behavior temporarily changes. Keep the change to what stops the immediate problem, notify Pewds promptly, and record the reason and rollback or recovery plan. Agree on any lasting product-direction change with Pewds afterwards. Normal high-risk review and repository checks still apply. + +## Product Owner initiated + +```text +Product Owner proposal or implementation + -> technical feasibility + -> plan and implementation + -> risk-based review and validation +``` + +A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review. + +## Community or maintainer initiated + +Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas). + +```text +Feature proposal + -> Product Owner accepts / rejects, where approval is required + -> technical feasibility + -> plan and implementation + -> risk-based review and validation +``` + +Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision. + +## Decision boundaries + +The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how approved work is built safely and maintainably**. + +A rejected or out-of-scope feature can be maintained independently in a contributor's fork. + +If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above. diff --git a/docs/maintainers/review-playbook.md b/docs/maintainers/review-playbook.md new file mode 100644 index 000000000..e9f0e6f99 --- /dev/null +++ b/docs/maintainers/review-playbook.md @@ -0,0 +1,129 @@ +# Practical PR review playbook + +Use this as a **repeatable review method**, not as an extra organizational approval ladder. The binding minimums remain in [changes and review](changes-and-review.md): ordinary maintainer changes may be self-merged, external PRs require maintainer review, and high-risk work requires at least one independent qualified maintainer review. + +No single checklist replaces engineering judgment. Increase depth with risk. + +## 1. Understand the PR and establish its review gates + +- Establish the problem, what the PR fundamentally proposes, and why the approach makes sense. +- Check product direction, architecture fit, and scope before detailed review. +- Write down the questions the review must answer, the evidence needed, and what would block further review or approval. +- Confirm repository, source fork, target branch (`dev`), exact **HEAD SHA**, and current base. +- Review the full effective diff, commit history, issue, prior feedback, and related upstream changes. +- Confirm the branch is mergeable against the **current** `dev`, not an older local snapshot. +- Identify the user-facing contract, affected data and users, security boundaries, and rollback plan. +- Separate pre-existing upstream problems from regressions introduced or made reachable by the PR. + +Example read-only commands: + +```bash +REPO="odysseus-dev/odysseus" +PR=1234 +gh pr view "$PR" --repo "$REPO" --json title,state,isDraft,headRefOid,baseRefName,mergeable +gh pr diff "$PR" --repo "$REPO" +gh pr checks "$PR" --repo "$REPO" +``` + +A green check on an old commit does not verify the revised branch. + +## 2. Review the failure and threat model + +For the relevant change, ask what happens with malformed input, missing auth state, wrong owner, concurrent requests, stale caches, partial failure, retries, cancelled jobs, unavailable dependencies, and restart/recovery. + +Prioritize boundaries that can lose data or authority: identity, delegated API tokens, filesystem confinement, email deletion, persistence versioning, network/SSRF destinations, secrets, and agent tool execution. + +For security claims, demonstrate the source-to-sink path and the *actual* guard. Tests must represent production middleware or transport where a unit mock could conceal a bypass. + +## 3. Correct without obscuring contributor work + +For small, unambiguous issues, maintainers may add focused corrections directly to a contributor PR under [the contribution review policy](changes-and-review.md#external-contributions-and-small-corrections). Preserve provenance, explain the change, prefer normal commits and fast-forward pushes, and avoid unrelated rewrites. + +For substantive or ambiguous work, ask the contributor or prepare a clearly separated patch. Any code change means validating **the new SHA**, not approving based solely on the previous audit. + +## 4. Build an evidence record + +Run contributor code under the [runtime isolation rules](testing-and-releases.md#runtime-isolation). + +Useful evidence includes: + +- Pre-fix reproduction, expected outcome, and actual outcome. +- Focused regression tests for the fix and broad suites relevant to cross-cutting effects. +- Actual runtime checks (browser/UI, backend, Docker, supported OS) where necessary. +- Exact executed commands, result counts, skipped cases, and environmental limitations. +- GitHub required checks and conflict-free state on the exact final SHA. +- Relevant operational consequences (data migration, rollback, compatibility). + +Keep audits reproducible. A screenshot alone is not a functional test, and a passing mocked test is not proof that a production integration path is safe. + +## 5. Independent review for high-risk changes + +A separate **qualified maintainer** must inspect high-risk changes; the implementing author cannot be the sole reviewer. Independent coding agents may be useful for adversarial tests and cross-checks, but **an AI agent is not itself a GitHub approver** and does not replace human accountability. + +To avoid confirmation bias, give the independent reviewer the current SHA, changed files, acceptance criteria, known reproduction, and explicit permission to challenge the proposed solution. Preserve unresolved findings and require a new audit when meaningful fixes change the diff. + +## CodeQL and security scanning + +A successful CodeQL workflow means analysis ran, not that the security dashboard is empty. Likewise, resolving a PR discussion does not necessarily resolve its code-scanning alert. + +For each relevant alert: + +1. Record the alert number, rule, severity, file/line, state, and source-to-sink trace if applicable. +2. Compare with the current `dev` baseline; decide whether the PR introduced, exposed, or did not affect it. +3. Reproduce or reason through the actual production behavior, including validation and authorization. +4. **Fix** credible vulnerabilities, preferably with a regression test. Do not hide them by broad suppressions. +5. **Recommend dismissal** only for a demonstrable false positive or a stale/invalid finding, with a precise technical explanation; only authorized maintainers should dismiss alerts. +6. Document genuine but intentionally accepted risk separately. A true positive is not a false positive merely because it is deliberate. +7. Rerun or inspect updated analysis on the final commit and confirm no blocking issues remain. + +Never publish exploit payloads, secrets, or credentials in public review comments. See [SECURITY.md](../../SECURITY.md). + +## 6. Decide and integrate + +Before approval or self-merge, verify: + +- Current branch and reviewed SHA are still the ones to be merged. +- All applicable required checks are complete and successful. +- High-risk independent review is present, where required. +- No unresolved, credible P0/P1/P2 security or data-integrity defects remain in the change. Resolve or explicitly document P3 observations with a tracked owner and priority. +- Review discussions, compatibility risks, and operational handoffs have been addressed. +- The correct target is `dev`; `main` is curated for releases. + +This P0-P3 classification is [triage guidance](triage.md#practical-priority-language), not a replacement for branch protection or an excuse to self-approve high-risk work. Maintainers may apply a stricter bar where warranted. + +For a final manual merge, GitHub CLI supports an exact-head guard: + +```bash +gh pr merge 1234 --repo odysseus-dev/odysseus --squash \ + --match-head-commit +``` + +Use `--delete-branch` only when removing the source branch is appropriate and does not disrupt another workstream. Do not force a merge past review or security requirements. + +## 7. Post-merge follow-up + +Verify the PR actually merged and reference its integration commit. Record remaining operational work, such as schema migrations, backup/restore verification, service restarts, or compatibility documentation. A code change to a container mount does **not** by itself migrate existing runtime data. + +Do not silently mark a deferred data migration complete; track its owner, evidence, rollback, and execution separately. + +## Reusable reviewer handoff + +```text +PR / target: +Reviewed source HEAD SHA: +Current target branch SHA: +Scope and changed files: +Impact and risk classification: +Security / correctness invariants challenged: +Pre-fix reproducer and post-fix evidence: +Tests run, skipped, and environment: +CodeQL / scanning alert dispositions: +Required GitHub checks (exact SHA): +Mergeability and remaining conflicts: +Unresolved P0 / P1 / P2 / P3 findings: +Rollback / operational follow-ups: +Independent qualified review required? Completed? +Verdict: APPROVED / CHANGES REQUIRED / BLOCKED +``` + +State what was actually verified. Do not write "all checks green" if only a subset of workflows was inspected. diff --git a/docs/maintainers/roles-and-access.md b/docs/maintainers/roles-and-access.md new file mode 100644 index 000000000..a98fd3258 --- /dev/null +++ b/docs/maintainers/roles-and-access.md @@ -0,0 +1,40 @@ +# Roles and access + +Roles clarify who is accountable. They do not create a management chain or automatically grant privileges. + +## Responsibility and typical access + +| Responsibility | Typical GitHub access | Purpose | +|---|---|---| +| Product Owner | No automatic repository access implied | Product direction | +| Organization Owner | Organization Owner | Organization administration and continuity | +| Repository Admin | Admin | Repository settings, permissions, and protection rules | +| Maintainer | Write by default | Implement, review, merge, and maintain | +| Triage | Triage | Organize intake, de-duplicate, and prepare work | +| Contributor / Platform Tester | Public / Read as appropriate | Contribute code or provide validation | + +Maintain access may be granted when repository-management duties require it. + +## Maintainer focus + +- **Core Maintainer:** project-wide judgment, difficult technical decisions, governance support. +- **Technical Maintainer:** implementation, architecture, validation, technical health. +- **Project Maintainer:** review, contributor coordination, documentation, releases, organization. + +These are responsibility labels. No label automatically changes GitHub permissions or overrides the [review rules](changes-and-review.md). + +## Functional responsibilities + +Maintainers may coordinate Release, Security, Infrastructure and CI, Community, or Test and Validation. These are duties, not ranks. For each release, designate one Release Maintainer and record the handoff if ownership changes. + +## Least privilege and continuity + +Grant access because it is necessary for current work, not because of a title. GitHub, Matrix, Plane, infrastructure, SSH, deployment systems, secrets, and external services are separate access surfaces. + +For elevated or sensitive access, document a responsible owner and recovery route in an appropriate **private** administrative system. Do not publish tokens, SSH details, personal addresses, or credential locations in this handbook. Remove access promptly when it is no longer needed. + +## Inactivity + +Tell an admin when you expect to be unavailable; a communicated absence is fine. + +If there is no meaningful maintainer or triage activity for **two months**, no prior notice, and no response to reasonable follow-up, elevated access may be reviewed and removed. This is access hygiene, not punishment. Returning members may regain access appropriate to their active responsibilities. diff --git a/docs/maintainers/testing-and-releases.md b/docs/maintainers/testing-and-releases.md new file mode 100644 index 000000000..23ab33853 --- /dev/null +++ b/docs/maintainers/testing-and-releases.md @@ -0,0 +1,52 @@ +# Testing and releases + +Validation must match the actual change, and evidence must state what was **run**, **observed**, and **not tested**. + +`dev` is the integration and stabilization branch. `main` is the curated release destination. Changes land in `dev`; a release promotes a verified `dev` revision to `main`. + +## Runtime isolation + +Run contributor-controlled tests, builds, installs, and repros in an isolated, secretless environment. Use disposable containers or microVMs where appropriate, with no credentials, sensitive host files, production data, or host/container-management sockets exposed. If safe isolation is unavailable, record the check as not run. + +Agents may use a skill for the maintainer's preferred environment and handling; the same isolation requirements apply. + +## Change validation + +- Run focused tests that cover the modified behavior and failure cases. +- Run broader regressions where risk crosses module boundaries. +- For authentication, data loss, or similar high-risk work, include adversarial and rollback tests. +- Reproduce the issue on the pre-fix version when safe and practical, then prove the correction. +- For visual changes, run the application and attach the screenshots or clips required by [CONTRIBUTING.md](../../CONTRIBUTING.md). +- Record exact commands, environment, result, skipped tests, and blockers. Never claim execution from a command you did not run. + +GitHub Actions success means a workflow finished successfully; it does **not** mean CodeQL found no alerts. Use [review playbook](review-playbook.md#codeql-and-security-scanning) to assess findings separately. A review must tie required CI and the final diff to the **same commit SHA**. + +## Supported-environment evidence + +Platform Testers may validate without maintainer permissions. Useful coverage includes: + +- Windows native and WSL. +- macOS Apple Silicon. +- macOS Intel while supported. +- Linux native on the reference distribution. +- Docker on supported hosts. + +Record the environment and configuration for each result. Do not imply that a platform was tested when it was not. + +## Release ownership + +A designated **Release Maintainer** owns preparation and the publish decision once the release criteria are met. Other maintainers and testers supply the evidence needed for the change set. If the Release Maintainer becomes unavailable, another maintainer explicitly takes over. + +## Release checklist + +Before promoting `dev` to `main`: + +1. Identify and freeze the release candidate SHA; check the actual content that will ship. +2. Verify required checks and relevant supported-environment validation. +3. Confirm high-risk and data-migration findings are resolved or addressed through an explicit approved plan. +4. Document skipped coverage, known issues, compatibility and upgrade implications. +5. Prepare the version, tag, artifacts, and release notes. +6. Confirm rollback/recovery, including any manual persistence or data migration steps. +7. Publish and record what was promoted, the verification evidence, and operational follow-ups. + +Publishing does not require ceremonial Core or domain sign-off when the criteria are met. Exceptional or disputed releases follow the normal high-risk escalation path. diff --git a/docs/maintainers/triage.md b/docs/maintainers/triage.md new file mode 100644 index 000000000..d2f47f8ab --- /dev/null +++ b/docs/maintainers/triage.md @@ -0,0 +1,48 @@ +# Triage + +Triage turns reports into evidence-backed work that a maintainer can pick up without guessing. + +## Intake flow + +```text +Report + -> reproduce / clarify + -> identify duplicates and affected versions + -> classify impact, urgency, and risk + -> prepare actionable work item + -> maintainer takes ownership + -> PR, verification, and follow-up +``` + +## A useful work item + +Record what is known and distinguish it from what remains unverified: + +- Original report and reproducible steps; installation method, OS, version/commit, and relevant environment. +- Expected versus actual behavior, including user-visible impact. +- Evidence: sanitized logs, screenshots, failing tests, or controlled reproduction. +- Related issues, PRs, duplicates, or a known prior regression. +- Scope, acceptance criteria, and non-goals. +- Priority, ownership, and relevant domain expertise. +- Remaining unknowns, risks, and safe recovery or workaround when available. + +Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md). + +## Practical priority language + +Use P0-P3 as **triage shorthand**, not as a substitute for verified exploitability or the project's actual GitHub labels: + +| Priority | Typical impact | Expected response | +|---|---|---| +| P0 | Active exploitation, broad irreversible loss, catastrophic outage | Immediate containment and escalation | +| P1 | Credible severe security or data-integrity vulnerability | Urgent owner assignment and validated fix | +| P2 | Significant correctness, security, or operational regression | Prioritize and resolve before approving affected high-risk changes | +| P3 | Limited impact, maintainability, or low-risk polish | Fix or explicitly track with rationale | + +Do not call a concern harmless merely because an automated check is green. Escalate privately when reproduction would disclose a vulnerability. + +## Where decisions live + +Matrix is for coordination; durable decisions belong in GitHub, Plane, a specification, or the relevant PR. Keep confidential security and access details in appropriate private channels. Do not leave important project decisions only in chat history. + +See the [review playbook](review-playbook.md) for converting triage into review evidence and handling contested or uncertain findings. diff --git a/docs/searxng-trust-image.md b/docs/searxng-trust-image.md new file mode 100644 index 000000000..e0f406e82 --- /dev/null +++ b/docs/searxng-trust-image.md @@ -0,0 +1,39 @@ +# Upstream SearXNG trust validation + +SearXNG owns independent system and Python certificate stores. Updating Odysseus's Python environment does not update this sidecar. The base and both standalone GPU Compose files now select the official `docker.io/searxng/searxng:2026.10.24-ef0ec03da` image at immutable multiarch digest `sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8`. Its system and certifi stores remove the affected eMudhra trust. The [upstream certifi update](https://github.com/searxng/searxng/pull/6881) is included in this release. + +Deploy the upstream image directly. The validation Dockerfile adds public fixtures and diagnostics only; no repair layer, custom runtime image or project registry publisher is required. `scripts/check_ca_trust.py` owns the known-certificate inventory for both the application and SearXNG checks. + +## Validate a pin update + +Run from the repository root on a disposable Docker host. The helper uses a fresh configuration volume, omits published ports and does not load the operator's `.env`. It retains the base Compose settings wrapper, healthcheck and capabilities. Outbound network access and working search engines are required for the live checks. + +```sh +image=$(docker compose --env-file /dev/null -f docker-compose.yml config --format json | + python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["searxng"]["image"])') + +# Offline build-step checks, including actual entrypoint and loopback health. +# Pulling the selected base still requires registry access. +docker build --network=none --target validation \ + --build-arg "SEARXNG_IMAGE=$image" -f docker/searxng/Dockerfile . + +# Exercise the exact unmodified deployment image after an anonymous pull. +docker pull "$image" +bash .github/scripts/validate-searxng-image.sh "$image" +``` + +The offline checks require all fourteen incident chains to fail certificate verification through the system PEM bundle, hashed CA directory and certifi. The affected positive-control roots must accept those same chains. Each fixture's DER fingerprint is verified first, and a fixed verification timestamp prevents expiry from hiding a broken mitigation. Checks run again after the real upstream root entrypoint regenerates trust. + +The live checks use SearXNG's actual curl client factory. Ordinary verified HTTPS must succeed, all fourteen incident hosts must fail with curl certificate error 60, and a JSON search must return results. DNS failures, timeouts and generic handshake failures are inconclusive and fail validation. The checks inspect the already-running service without changing its trust stores. + +The `ci / SearXNG trust` workflow runs these offline and Compose/live checks on native amd64 and arm64 runners. It selects the same immutable image as base Compose, pulls without registry credentials, and has read-only repository permissions. It neither publishes a replacement image nor requires package-write credentials. Update all three deployment pins together; the alignment regression guards the standalone GPU files. + +## Rollout and scope + +Changing a Compose pin does not replace running containers. After updating the checkout, run `docker compose pull searxng` and `docker compose up -d --no-deps searxng`, then inspect health, retained settings and actual Odysseus search integration. Use the appropriate standalone GPU Compose file when that is the deployment being managed. + +Operator-mounted trust, custom engines, persisted packages and independently managed clients need separate effective-client checks. An amd64 result does not establish arm64 behavior. The fixture-based check detects known certificate fingerprints; it does not model every native revocation mechanism or arbitrary reissued certificates sharing a compromised key. See [outbound TLS trust](tls-trust.md) for the application, bootstrap, Chromium and other runtime boundaries. + +## Public fixture provenance + +The public CA inventory comes from the incident's [roots](https://emudhra.we-have-a-problem.com/roots.json) and [intermediates](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). The additional cross-signed issuer and fourteen chain fixtures come from [chains.zip](https://emudhra.we-have-a-problem.com/chains.zip), SHA-256 `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`. Fixtures contain public certificates only. See [the fixture notes](../tests/fixtures/ca-trust/README.md). diff --git a/docs/skills-audit-architecture.md b/docs/skills-audit-architecture.md new file mode 100644 index 000000000..78227a763 --- /dev/null +++ b/docs/skills-audit-architecture.md @@ -0,0 +1,379 @@ +# Skills evaluation and audit architecture + +The Skills backend separates HTTP transport, model evaluation, agent execution, +audit orchestration and publication policy. `services/skills/` coordinates those +responsibilities; `services/memory/skills.py` remains the canonical persistence +and lifecycle owner. The extraction preserves the existing HTTP and Python +contracts. [PR #6711](https://github.com/odysseus-dev/odysseus/pull/6711) tracks +this boundary change under [issue #6710](https://github.com/odysseus-dev/odysseus/issues/6710). + +## Responsibilities and dependency boundaries + +| Module | Responsibility | +| --- | --- | +| `routes/skills_routes.py` | FastAPI request models, endpoint declarations, authentication, ownership, HTTP errors, exact approval validation and Python compatibility bindings | +| `services/skills/job_coordinator.py` | Transient job creation, continuation after approval validation, candidate selection, public polling projections and cancellation requests | +| `services/skills/agent_runner.py` | Manual execution, audit arms, agent loop integration, transcripts, authority propagation, approval pauses and same-task stream closure | +| `services/skills/audit_workflow.py` | Per-skill audit sequencing, self-edit retries, teacher escalation, sequential batches and scheduled execution | +| `services/skills/evaluation.py` | Model grading, verdict parsing, necessity and retrieval checks, efficiency fields and markdown improvement generation | +| `services/skills/publication.py` | Confidence and publishing policy, duplicate rules, batch finalization and markdown updates through SkillsManager | +| `services/skills/model_selection.py` | Owner-scoped worker/teacher resolution, utility fallback, endpoint selection and credential lookup | +| `services/skills/prompts.py` | Shared task construction and explicitly untrusted skill context | +| `services/skills/dependencies.py` | Frozen `AuditDependencies` containing callable bindings and transient registry references | +| `services/memory/skills.py` | Canonical skill identity, owner isolation, durable storage, indexing, retrieval and lifecycle state | + +The package initializer identifies the domain without adding a service facade. +Evaluation does not schedule jobs or persist skills. Publication computes policy +and applies updates through the supplied SkillsManager. Services do not import +`routes.skills_routes`. The existing preferences adapter in `publication.py` +still imports `routes.prefs_routes`; moving that adapter is a separate change. + +```mermaid +flowchart TD + HTTP[Authenticated HTTP requests] --> Routes[skills_routes] + Builtins[builtin_actions callers] --> Routes + Routes --> Jobs[job_coordinator] + Routes --> Models[model_selection] + Routes --> Evaluation[evaluation] + Routes --> Runner[agent_runner] + Routes --> Workflow[audit_workflow] + Jobs -->|injected calls| Runner + Jobs -->|injected calls| Workflow + Workflow -->|injected calls| Runner + Workflow -->|injected calls| Evaluation + Workflow -->|injected calls| Publication[publication] + Runner -->|injected calls| Evaluation + Runner --> Runtime[Agent runtime and authority enforcement] + Runner --> Prompts[prompts and untrusted context] + Jobs --> Prompts + Workflow --> Prompts + Routes --> Manager[Canonical SkillsManager] + Runner --> Manager + Workflow --> Manager + Publication --> Manager + Publication --> Preferences[Existing preferences adapter] + Models --> Resolver[Endpoint resolver and owner-scoped credentials] +``` + +Arrows include injected calls, rather than only Python imports. The workflow +imports `SkillAuditUnavailable` from the runner. Route wrappers construct +`AuditDependencies` on entry from the current route bindings, so existing +callers and monkeypatches reach the same collaborators without rebinding shared +service globals. + +The manual test registry belongs to `agent_runner.py`; the audit registry belongs +to `audit_workflow.py`. Routes expose aliases to those dictionaries. Registries +hold process-local job state and are not durable skill stores. All durable +metadata, confidence, markdown and publication changes use SkillsManager. +Published-only retrieval and local exclusion of `remote-only` skills stay in +the manager/index boundary. Service loggers use their module names; the route +module retains its own logger. Root handlers supply the existing log output. + +## Execution and publication lifecycle + +1. The HTTP handler authenticates the caller, verifies ownership and validates + the request. Manual model choices retain registered-endpoint validation and + owner-scoped credential lookup. +2. Job coordination creates the owner-keyed transient record and launches the + execution coroutine. Replacing a manual test retires its pending approval. + Polling exposes only the existing public fields, excluding credentials, + private transcripts, continuation state and asyncio tasks. +3. The runner uses the originating request authority or the consumed sealed + approval authority. It captures the transcript and closes the async stream + with `aclosing` inside the task that opened it, including early approval + pauses, cancellation and exceptions. +4. Manual approval pauses return to the HTTP validation boundary. Continuation + reconstructs context from the original untrusted skill and accumulated + untrusted transcript. Manual completion records audit metadata and adjusts + confidence (pass .95, needs_work .6, fail .4) without publishing. +5. An automated audit checks deterministic duplicates, advisory necessity and, + when indicated, retrieval precision. It runs skill and no-skill arms on the + same task, grades the result, then may self-edit and retry. An optional + distinct teacher rewrites the skill; the worker performs the retry. +6. Publication applies owner preferences, confidence thresholds, necessity, + utility and duplicate rules through SkillsManager. A normal sequential batch + finalizes its processed results. Cancellation or infrastructure unavailability + skips batch finalization and clears task/current state. + +A clean audit pass sets confidence to .95, a self-edit pass to .85 and a teacher +rewrite pass to .8. Continued failure becomes draft at .35. Unknown and +inconclusive verdicts preserve publication state. Automatic publishing requires +an enabled owner policy, a passing verdict at or above the configured threshold, +and no necessity, worse-baseline or duplicate blocker. The default threshold is +.85; owner values are clamped to [0, 1]. Markdown edits pin the existing skill name +so usage and audit sidecars remain attached to the same identity. + +Per-skill duplicate blocking and batch duplicate finalization have distinct +selection rules and must not be consolidated without a behavior proposal. +Batch finalization excludes builtins and binned records, changes only processed +skills, protects approval-required results, and bins duplicate losers. Duplicate +similarity uses the existing .38 threshold and name-suffix grouping. + +Batches run sequentially per owner, retain current/done/results and timestamps, +and cap progress logs at 1,000 entries. Ordinary exceptions record inconclusive +skill audits. Infrastructure failures stop the batch without inventing a skill +verdict. Scheduled execution skips an active owner job, selects existing lifecycle +candidates by oldest audit, caps the batch (default eight), and marks model phases +as background work. These services retain the existing scheduling and +cancellation semantics. + +## Security and approval invariants + +HTTP authentication and owner verification remain at the transport boundary. +Manager reads and writes receive the owner. Ownerless builtins retain their +existing visibility; an authenticated user cannot access an ownerless ordinary +skill. Existing ownerless deployment behavior is preserved. + +The `/test-approval` handler validates the pending job, approval ID, decision, +owner and empty session binding, then destructively consumes the sealed approval +with `allow_continuation=False` before calling the coordinator. Stale, mismatched +or reused approvals fail with 409. The consumed grant authorizes only its exact +action. Services do not manufacture approval authority or relax process identity, +workspace confinement or request-authority enforcement in the agent runtime. + +Skill markdown and resumed transcripts remain explicitly untrusted context, +never trusted system instructions. Audit arms suppress saved skill injection. +Unattended runs deny encountered approval records instead of leaving reusable +grants. An initial approval-required audit result preserves the skill's publication +and confidence and directs the user to manual approval. Retry handling has an +inherited gap: an approval pause after a rewrite can still lead to demotion. +Same-task stream closure preserves the originating ContextVar cleanup, but manual +cancellation can retain private continuation state. These are separate hardening +items, not guarantees supplied by the extraction. + +## Model selection and evaluation + +Audit resolution uses an explicit endpoint/model pair when supplied, looking up +credentials among enabled owner-visible endpoints. A model-only choice uses the +existing owner-scoped model resolver. Otherwise it resolves the utility model, +including the resolver's default behavior, then takes the first configured +utility fallback if the primary result is incomplete. A missing worker raises +`ValueError`. Served-model normalization keeps an exact match, then a basename +match, then the first served ID; discovery exceptions keep the resolved choice. + +An enabled teacher resolves separately with the same owner. Its credentials are +not borrowed from the worker. Teacher resolution failure logs the existing warning +and leaves the teacher absent. Manual tests retain their HTTP endpoint validation +and registered fallback credentials. + +Owner reasoning effort is resolved for each evaluation or rewrite call. Workload +flags, credentials, retries, prompts and permissive parsing remain unchanged: + +| Phase | Token budget | Timeout in seconds | +| --- | --- | --- | +| Run review, up to two attempts | 32768 | 180 | +| Necessity | 8192 | 120 | +| Retrieval precision | 4096 | 90 | +| Markdown improvement | 16384 | 180 | + +Review parsing retains think-block stripping, trailing-comma recovery, last +usable verdict selection, prose fallback, bounded fields and unknown outcomes. +These model outputs are advisory and do not grant execution authority. + +## Public HTTP contract + +All paths below have prefix `/api/skills`. Successful status defaults to 200. +Request models and endpoint declaration order remain fixed. A frozen +OpenAPI and declaration-order fixture records +the baseline contract independently of the extracted implementation. + +| Method and suffix | Successful response | Authorization and errors | +| --- | --- | --- | +| GET empty | `skills,count` | Owner-filtered load | +| GET `/index` | `index,count` | Owner-scoped eligible index | +| GET `/slash-catalog` | `skills,count` | Owner-scoped index; sorted slash entries | +| GET `/builtin` | `builtin,count` and optional `error` | Existing shared capability text | +| GET `/builtin/{name}` | `name,text,default,is_overridden` | 404 missing tool; 500 import failure | +| PUT `/builtin/{name}` | `ok,name,is_overridden` | Admin; 404 unknown, 400 missing text | +| DELETE `/builtin/{name}` | `ok,name,is_overridden` | Admin | +| POST `/import-from-url` | `ok,skill,files` | Admin and current owner; 400 import, 502 HTTP, 500 other failure | +| POST `/add` | `ok,deduped,skill` | Current owner passed to manager; unchanged old/new request fields | +| POST `/{skill_id}/invoke` | `ok,type,name,command,message` | Eligible owner index only; 404 unavailable/source missing | +| GET `/{skill_id}` | Full skill dictionary | Owner load; 404 missing | +| GET `/{skill_id}/markdown` | `name,markdown` | Owner verification; 404 missing/source missing | +| POST `/{skill_id}/test` | `ok,status,skill,model` | Owner verification, HTTP request authority, registered fallback credentials; 400 missing/invalid model | +| POST `/{skill_id}/test-approval` | `ok,status,decision` | User approval request, owner verification, pending ID/owner/empty session match; 400 invalid body/decision, 409 stale/mismatch/consumed | +| GET `/{skill_id}/test-status` | `status` or `status,task,model,log,verdict,approval` | Owner-keyed lookup; server continuation/credentials omitted | +| POST `/audit-all` | `ok,status,total` plus running `done,model` or empty `results,log` | Owner-keyed singleton; 400 model resolution | +| GET `/audit-all/status` | `status` or job progress/result/log/timestamps | Owner-keyed lookup; task and credentials omitted | +| POST `/audit-all/cancel` | `ok,status` | Owner-keyed lookup and cancellation | +| POST `/{skill_id}/markdown` | `ok,name` | Owner verification; 400 missing/parse, 404 missing, 500 update | +| PUT `/{skill_id}` | `ok` | Owner verification and scoped manager update; 404 missing | +| DELETE `/{skill_id}` | `ok` | Owner verification and scoped manager deletion; 404 missing | +| POST `/search` | `skills,query,count` | Owner-filtered input; 400 empty query | + +Strict owner verification allows an ownerless builtin but denies an ownerless +ordinary skill for an authenticated user. The existing ownerless deployment +behavior remains. Global auth middleware is unchanged. + +## Python compatibility and frozen contracts + +`app.py` imports `setup_skills_routes`. `src/builtin_actions.py` uses the existing +route helpers and audit registry. Route aliases and wrappers preserve the original +28 inspectable interfaces, `SkillAuditUnavailable`, registry identity and patchable +collaborators. Lazy model/agent imports and route-level effort injection keep +existing offline test seams effective. + +The task and message callbacks are bound through `AuditDependencies` at service +entry. Patches to route-level test tasks and test/baseline messages reach manual +execution, approval continuation, both audit arms, worker retries and scheduled +audits. The `_skill_baseline_task` compatibility alias delegates to the current +route test-task builder. Execution continues to reuse one task for both arms, +as in the original implementation; it does not independently derive a baseline +task through that alias. + +`tests/fixtures/skills_api_baseline.json` records declaration order and structural +signatures captured from pre-extraction revision +`86b886c611338282af6d4a717246ea4e256f58df`. Signature records retain parameter +names, order, kinds, defaults, default factories and annotations, including +Pydantic constraint metadata. They compare typing origins and arguments instead +of Python-version-dependent annotation representations. The expanded records also +pin field validation aliases, alias priority, discriminators, `validate_default`, +rebuilt field annotations and model configuration. Field order remains visible +through the constructor parameter sequence. Only the known builtin factories +`list`, `tuple`, `dict`, `set` and `frozenset` are supported. Custom factories, +including closures and factories taking validated data, fail closed without being +executed. Unsupported metadata/configuration values also raise rather than using +unstable representations. This is a structural contract for supported interfaces, +not proof of behavioral equivalence for arbitrary validators or Python code. + +The expanded fixture was derived again from the original revision above in a +detached scratch worktree, with the updated comparator copied in. Python 3.11 +and 3.12 produced byte-identical snapshots. The previous parameter/return records, +all 28 interface names, all 22 endpoint declarations and the original OpenAPI +digest were checked unchanged before accepting the new field records. + +`tests/fixtures/skills_openapi_baseline.json` is the same baseline's complete +Skills-router OpenAPI document, generated with a standalone FastAPI application. +Sorted keys, ASCII escaping, two-space indentation and a final newline make the +fixture deterministic. Tests require exact canonical equality and print a unified +diff on mismatch. The redundant digest field and assertions have been removed; +they provided no independent compatibility guarantee. The fixture pins model +schemas and constraints. CI reads committed fixtures and needs no Git history +for these tests. Baseline updates require an intentional compatibility decision, rather +than regenerating fixtures from a failing implementation. + +The narrow `.gitleaks.toml` exception remains necessary for the historical public +digest in earlier fixture and documentation revisions. It applies to the exact digest, +the `generic-api-key` rule and the two named paths. Removing a current copy cannot +remove findings from published history. Keep the exception unless the pinned +full-history scan proves it unnecessary; do not rewrite history to remove it. +The checksum-verified 8.30.1 scan without this exception still reports both +historical copies in commit `a547c4d8`; retaining the exception passes the full +PR/dev history scan. +Reconsider it after squash merge only after verifying the resulting reachable +history with the pinned full-history scan. + +The ReDoS source guard scans `services/skills/evaluation.py`, the canonical +evaluation parser module, as well as the retained route and memory-extractor +locations. Verdict regex runtime tests import the canonical module directly. +The source guard must reject reintroducing the lazy think-block expression; +the ordinary-input and adversarial-flood runtime tests remain in place. An isolated +source mutation reproduced the old guard's false green for the evaluation module. +The corrected guard rejected the vulnerable expression in each of the three +locations and passed again after restoring the temporary copies. The mutated +regex was never executed, and production parser behavior was not changed. + +## Reproducible validation + +From the repository root, use a virtual environment with `requirements-dev.txt` +installed and the repository's frontend dependencies available. The examples use +`.venv/bin/python`; repeat the contract tests with a Python 3.12 environment as +well as Python 3.11. Record interpreter/dependency versions with results. + +```sh +.venv/bin/python -m pytest -q \ + tests/test_skills_audit_characterization.py tests/test_skills_audit_services.py +.venv/bin/python -m compileall -q app.py core routes src services scripts tests +.venv/bin/python -c 'import routes.skills_routes; import services.skills.agent_runner; import services.skills.audit_workflow' +.venv/bin/python scripts/build_frontend.py --check +node scripts/check_frontend.mjs +node --test tests/skillsApproval.test.mjs tests/frontendArchitecture.test.mjs +git diff --check +``` + +The focused Skills, authority and approval selection is reproducible with: + +```sh +.venv/bin/python - <<'PYTHON' +from pathlib import Path +import subprocess +import sys + +files = set() +for path in Path("tests").rglob("test_*.py"): + text = path.read_text() + if ("skill" in path.name or "approval" in path.name + or "utility_reasoning" in path.name or "skills_routes" in text + or "review_endpoint_credentials" in path.name): + files.add(str(path)) +files.update([ + "tests/test_request_authority.py", + "tests/test_endpoint_registered_authority.py", + "tests/test_redos_verdict_continuation.py", + "tests/test_redos_residual_python.py", +]) +subprocess.run([sys.executable, "-m", "pytest", "-q", "-rs", + "--durations=15", *sorted(files)], check=True) +PYTHON +``` + +The original selection contained 729 cases. Additional characterization cases +increase that count without removing the original coverage. For the full suite, +remove a development `APP_PORT` and put the environment's tools on PATH: + +```sh +env -u APP_PORT PATH="$PWD/.venv/bin:$PATH" \ + PLAYWRIGHT_BROWSERS_PATH="$PWD/.cache/ms-playwright" \ + .venv/bin/python -m pytest -q -rs --durations=25 +``` + +Run the checksum-verified Gitleaks binary pinned in +`.github/workflows/secret-scan.yml` on full reachable history, including the PR +head and fetched official `dev`. The current workflow pins 8.30.1. Its CI command +is `gitleaks git --no-banner --redact --verbose .` with a full-history checkout. +A local checkout can restrict refs to the relevant histories with +`--log-opts='HEAD upstream/dev'`; do not restrict the commit range to just the +latest patch. Scan uncommitted fixtures too before committing. + +## Historical evidence and validation limits + +The initial extraction validation at `a547c4d8` reported 42 characterization and +service cases, 729 focused cases, 13 relevant Node cases and 17 isolated uvicorn +HTTP checks passing. The historical repository-wide run reported 13,959 passed, +60 skipped and three failures. These counts describe the extraction validation, +not the current revision. The HTTP checks used isolated state and authentication +and included second-owner denial, but exercised neither a configured live model +nor an external tool action. + +The two containment failures reproduced on unchanged baseline `86b886c6` with +extra `Failed to create stream fd: No such file or directory` stderr output: + +- `test_namespace_handshake_closes_model_stdin` +- `test_execution_environment_cannot_replace_probed_bwrap[False]` + +`test_legacy_cleanup_against_a_private_real_tmux_server` failed under the original +full-suite load and passed isolated reruns. Its cause was not fully diagnosed. +These historical failures must be distinguished from new regressions; successful +CI and maintainer review remain integration gates. Earlier AST comparisons and +the [maintainer review](https://github.com/odysseus-dev/odysseus/pull/6711#issuecomment-6170115749) +provide historical extraction evidence, not an independent review of later fixes. + +## Known limitations and follow-up boundaries + +[Issue #6635](https://github.com/odysseus-dev/odysseus/issues/6635) remains open: +manual tests without an appropriate workspace can still fail to execute their +proposed action. Stream cleanup and exact approvals are preserved, but do not +supply a missing workspace. + +Jobs remain process-local and do not survive restarts. Durable jobs, moving the +preferences adapter below transport, consolidating storage under the domain +package, or changing duplicate/publication policies need separate designs. +The canonical manager, skill format/lifecycle modules, provider implementations, +agent authority enforcement and frontend contracts remain their existing owners. + +The [second-review correction report](skills-audit-second-review.md) records the +restored prompt seams, independently expanded constructor baseline, actual +validation results and a separate prioritized plan for the six inherited P2 +failure paths. Model-controlled lifecycle fields and preference-load failures +require maintainer action before automatic rewrite/publication deployment. diff --git a/docs/skills-audit-second-review.md b/docs/skills-audit-second-review.md new file mode 100644 index 000000000..91fdaae43 --- /dev/null +++ b/docs/skills-audit-second-review.md @@ -0,0 +1,200 @@ +# Skills PR #6711 second-review corrections + +The correction scope is route prompt compatibility and structural constructor +contracts. PR #6711 remains draft. Durable skill ownership stays with +`SkillsManager`. No publication policy, authority mechanism, provider runtime, +frontend behavior or protected manager/index implementation was changed. + +The reviewed head was `a6a33674ed4facf11f53e7cdd41f882ad0fa8482`. The independent +original baseline is `86b886c611338282af6d4a717246ea4e256f58df`. Official `dev` +integration was tested against `c3819e3cbf56c4f899eacc66b21115a77d9c570d`. + +## Compatibility corrections + +Service-local imports captured prompt builders outside the route namespace. +Patching the public route symbols therefore stopped affecting execution after +extraction. `AuditDependencies` now binds the current test-task, test-message and +baseline-message builders. Manual execution, exact-approval continuation, initial +audits, baseline arms, self/teacher retries and scheduled batches use those +bindings. The route `_skill_baseline_task` wrapper again delegates to the current +route `_skill_test_task`. Both execution arms still use one shared task, matching +the original behavior. No service imports Skills routes. + +The signature checker treated a factory's module/qualified name as its identity +and omitted Pydantic settings outside the generated Python signature. Closures +with different captured values and default-validation changes were false +negatives. New tests additionally demonstrated hidden validation-alias path and +model-configuration changes. The checker now records public field validation +settings, rebuilt annotations, defaults and inherited model configuration. See +the [Pydantic FieldInfo reference](https://docs.pydantic.dev/latest/api/fields/) +for the distinction between factories, validation aliases and `validate_default`. + +Only known builtin factories are supported by identity. Custom factories, +including factories requiring validated field data, raise `TypeError` without +being invoked during comparison. Unsupported metadata/configuration values also +fail closed. No bytecode, arbitrary object representations or factory output is +serialized. The comparator does not promise equivalence of arbitrary Python +validators or factory implementations. + +The expanded fixture was generated from the original baseline in a new detached +worktree, with only the revised test comparator copied in. Python 3.11 and 3.12 +produced byte-identical files. Every previous parameter/return record remains +equal. All 28 interfaces and 22 endpoint declarations remain present. The +canonical OpenAPI fixture is unchanged, and its original digest is unchanged. +Independent native `inspect.signature` snapshots also match original versus +corrected implementations on each interpreter. + +## Regression evidence and validation + +The new tests were run before production/comparator edits: 12 failed and four +passed, demonstrating the six signature cases and six broken prompt cases. After +correction, all 16 added cases pass. The prompt probes capture actual +`stream_agent_loop` ingress rather than replacing the runners or coordination. +They cover patched and normal execution, guard escaping and taint metadata, +owner authority, exact-approval continuation, and worker retries after both +self and teacher rewrites. The same ten prompt cases pass on the original +implementation. The six signature cases reject custom factories without calling +them and detect default validation, alias paths and model validation settings. + +Environments: Python 3.11.17 and 3.12.13, FastAPI 0.143.1, Pydantic 2.14.1, +pytest 9.1.1. Python test commands set `FASTEMBED_MODEL=review-disabled` and +`HF_HUB_OFFLINE=1` to exercise unavailable optional embeddings without model +downloads. They do not validate live providers or embedding quality. + +| Executed scope | Result | +| --- | --- | +| 53-file Skills, approval, authority and lifecycle selection, Python 3.11 | 757 passed | +| Same selection, Python 3.12 | 757 passed | +| Both characterization/service files, included above | 70 passed | +| Original baseline, available focused selection plus revised characterization | 724 passed | +| Original baseline, revised characterization plus prompt probes, each interpreter | 47 passed | +| Current-dev combined tree, focused selection plus eight intervening dev test files, each interpreter | 900 passed | +| Repository-wide suite, Python 3.11 | 13,991 passed, 60 skipped, two inherited environment failures, six subtests passed | +| Frontend publisher and architecture checks | Passed, zero changed modules | +| Relevant Node tests | 13 passed | +| Compilation, both Python versions | Passed | +| Import graph and fresh service-first/route-first imports, both versions | Passed; acyclic; no reverse Skills route import | +| Original normalized moved-body comparison | 26 of 26 matched after explicit dependency substitutions | +| Pinned/checksum-verified Gitleaks 8.30.1, reachable HEAD and official dev histories | No leaks | +| Same Gitleaks, tracked working files | No leaks | +| Diff/whitespace and protected-source comparisons | Passed | + +The two repository-wide failures are +`tests/test_containment_enforcement.py::test_namespace_handshake_closes_model_stdin` +and `test_execution_environment_cannot_replace_probed_bwrap[False]`. Isolated +reruns on both original and corrected implementations reproduce the same extra +`Failed to create stream fd: No such file or directory` stderr. The independent +review traced it to login-profile `im-config` calls to `systemd-cat` inside +Bubblewrap without the journal stream socket. These environment failures remain; +they were not hidden by excluding tests. A full Python 3.12 suite was not run. + +The combined test tree used the official merge revision `5419ed37`, whose tree +exactly matches `git merge-tree --write-tree upstream/dev a6a33674`, with the +correction diff applied in a detached scratch worktree. Published history was +not rebased, amended, reset or rewritten. + +Local commands, independent fixture captures, baseline reproductions and full +logs are retained under `/tmp/skills-6711-corrections/`. The independent review +and adversarial probe source remain under `/tmp/skills-6711-second/`. These +temporary evidence paths are not portable repository fixtures. CI should read +the committed golden fixtures, not regenerate them from the implementation. + +## Separate inherited P2 hardening plan + +All six defects demonstrably exist on the official original baseline. The six +safety assertions in `/tmp/skills-6711-second/test_adversarial.py` fail on both +the original and corrected trees with the same outcomes. Logs are +`inherited-original.log` and `inherited-head.log` in the correction evidence +directory. The original/source body comparison also matches these paths after +dependency substitutions. There is no demonstrated material worsening from the +extraction. That finding does not make their security impact acceptable. + +Priorities below concern a separate hardening change. Items 1 and 2 are release +blockers requiring maintainer resolution before deploying automatic skill +rewrite/publication workflows. This recommendation is independent of whether +the architecture PR is integrated. No tool-authority bypass or cross-owner +credential disclosure was demonstrated, and no critical extraction regression +was found that requires expanding this compatibility correction. + +1. **Model-controlled lifecycle/provenance fields.** Current source: + [publication.py:328](../services/skills/publication.py#L328), especially line + 342; the bypassed retrieval condition is + [skills.py:703](../services/memory/skills.py#L703). Original source: + [routes/skills_routes.py:1039](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L1039). + `test_rewrite_cannot_promote_model_text_to_builtin` uses the real manager and + saves model markdown with `source: builtin`, `status: published` and + `confidence: 1`. Identity/owner remain pinned, but the lifecycle fields persist + and the skill becomes retrieval-eligible without a passing audit. Corrective + change: allowlist procedure and approved descriptive/retrieval fields during + model rewrites; preserve existing owner, provenance, publication, confidence, + audit and teacher lifecycle state. Only verified policy may update those + fields. Metadata-only fixes must preserve the procedure or trigger a retest. + Add real-manager tests for self, teacher and metadata-only rewrites. + +2. **Preference loading fails open for automatic publication.** Current source: + [publication.py:13](../services/skills/publication.py#L13) and + [skills.py:59](../services/memory/skills.py#L59). Original source: + [routes/skills_routes.py:710](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L710). + `test_preferences_read_failure_does_not_enable_auto_publication` makes the + loader raise `PermissionError`; automatic publication is enabled rather than + preserving the owner's possible opt-out. Corrective change: distinguish a + successfully loaded empty preference store from an exception, log the failure + and disable automatic publication on exceptions. Align the manager retrieval + preference adapter in the separate fix. Test unreadable/malformed existing + preference files and the ordinary empty-store default independently. + +3. **Manual cancellation retains private credentials and stale running state.** + Current source: + [agent_runner.py:125](../services/skills/agent_runner.py#L125) and + [job_coordinator.py:49](../services/skills/job_coordinator.py#L49). Original: + [routes/skills_routes.py:635](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L635). + `test_manual_cancellation_cleans_sensitive_continuation` suspends and cancels + the stream. The stream closes in its originating task, but job status remains + running and `_run.headers.Authorization` remains resident. Polling still hides + these fields; this is retention, not demonstrated disclosure. Corrective + change: handle `CancelledError`, mark cancelled, clear private continuation + data, retire only the attached exact approval and re-raise. Preserve state on + a normal awaiting-approval return. Retain task handles for coordinated + replacement/cancellation and test owner/approval isolation. + +4. **Approval pauses during retries lead to demotion.** Current source: + [audit_workflow.py:244](../services/skills/audit_workflow.py#L244), teacher + retry at line 280 and demotion at line 303. Original: + [routes/skills_routes.py:1448](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L1448) + and lines 1484/1507. `test_retry_approval_preserves_publication_and_confidence` + begins published at .9, fails the first grade, then pauses the rewritten test + for approval. The result is flagged/draft/.35. The lower-level approval is + correctly denied; state handling afterwards is wrong. Corrective change: + immediately recognize `approval_required` after every retry, stop escalation + and demotion, and preserve the intended pre-pause lifecycle state. Coordinate + with item 1 so model rewrites cannot replace that saved state. Test both + self/teacher retries, repeated pauses and batch protection. + +5. **Duplicate losers are binned after keeper publication fails.** Current: + [publication.py:217](../services/skills/publication.py#L217), including loser + writes at line 228. Original: + [routes/skills_routes.py:914](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L914). + `test_duplicate_losers_retained_if_keeper_write_fails` raises disk-full during + keeper publication and still observes a loser binned. Corrective change: + require confirmed keeper persistence before binning losers when automatic + publication is enabled. Treat both exceptions and False returns as failure, + retain candidates and report recovery information. Preserve existing keeper + selection and duplicate scoring rules; add failure/retry tests. + +6. **Reported publication status can disagree with canonical storage.** Current: + [publication.py:320](../services/skills/publication.py#L320). Original: + [routes/skills_routes.py:1017](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L1017). + `test_publication_does_not_report_unpersisted_success` returns False from the + manager update; the helper reports published while the stored skill remains + draft. Exceptions are also swallowed by the inspected code. Corrective change: + check persistence success, log failures and return reloaded canonical state or + an explicit persistence error. Test False/exception outcomes and the job/UI + projection. Coordinate this change with item 5 without changing publication + thresholds or scoring. + +Maestrum's read-only triage delegation produced no usable result within its +usefulness window and was cancelled. Cleanup completed and the session records +an interrupted result, not a successful review. The coordinator reclaimed the +remaining report work and verified the source references and reproduction logs +above. No worker claim was used as proof. No GitHub issue, new PR, maintainer +comment or PR-description edit was made. diff --git a/docs/tls-trust.md b/docs/tls-trust.md new file mode 100644 index 000000000..71f0fe76e --- /dev/null +++ b/docs/tls-trust.md @@ -0,0 +1,75 @@ +# Outbound HTTPS trust + +Odysseus uses both Python package CA bundles and system trust. HTTPX uses certifi by default; the guarded web/PDF fetcher and webhook delivery explicitly use Python's system SSL context. A configured `LLM_CA_BUNDLE` adds certificates to system trust for model calls. Upgrading certifi alone cannot repair every outbound HTTPS path. + +For the October 2026 eMudhra incident, use certifi 2026.10.23 or newer, pip 26.2.2 or newer, and uv 0.13.5 or newer wherever uv is used. Pip has its own vendored certifi and also loads system roots. Update the operating system's certificate package separately. See the [Python security announcement](https://discuss.python.org/t/109576) and [incident details](https://emudhra.we-have-a-problem.com/). + +Before other pip installs, bootstrap the selected environment with the integrity-pinned wheels: + +```sh +python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt +python -m pip install -r requirements.txt +``` + +The pinned bootstrap protects package integrity even when the old installer's certificate bundle is affected. It is not an operating-system trust-store update. Native Windows/macOS trust and command-line OpenSSL PEM stores may differ; follow the platform vendor's remediation for every store in use. Restart Odysseus and its subprocesses after updating trust. + +On Linux/OpenSSL deployments, inspect both the effective CA file and hashed CA directory, plus the two Python bundles: + +```sh +python scripts/check_ca_trust.py --system --python --overrides --verbose +``` + +Explicit PEM bundles can also be passed as positional arguments. The check rejects the four published root fingerprints, all sixteen published intermediate certificates (including their cross-signs), the cross-signed G3 issuer in the published test chains, and malformed/empty bundles. Checking intermediates matters because adding one as a custom trust anchor can restore an affected chain even after its roots have been removed. + +`--overrides` checks every nonempty `LLM_CA_BUNDLE`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`, `NODE_EXTRA_CA_CERTS` and `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` setting in the checker's process environment, including configured fallback settings. Requests/pip hashed-directory overrides are inspected too; `--system` covers `SSL_CERT_DIR`. Run with the application's environment and filesystem access. The script does not load `.env` or application configuration files; pass any CA paths configured only there explicitly, for example `python scripts/check_ca_trust.py --system --python /path/to/private-ca.pem`. Missing or invalid configured files fail the check. Selecting only `--overrides` with no configured paths also fails instead of reporting that stores were validated. + +`--verbose` reports the Python executable/prefix, user home, effective UID/GID where available, and resolved CA paths, including bundles selected from user-installed packages. Compare these with the running application's identity and interpreter. It does not dump environment variables or credentials. + +This is an offline inventory check of known certificate fingerprints. It does not modify stores, reject requests at runtime, model native OS revocation, or detect arbitrary reissued certificates sharing the compromised keys. Chromium's policy separately matches public keys. Check mounted or replaced CA files again after deployment, remove affected trust through the owning platform/client configuration, and restart clients that cache SSL contexts. A passing build cannot validate custom files introduced later. + +Both Docker build stages use Python 3.14.9, refresh system CA certificates and bootstrap pip/certifi before dependency installation. The build fails if known affected CA certificates remain. Node uses the corrected system store through `--use-openssl-ca`. Python gRPC uses that same PEM file through `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`; updating certifi alone does not replace gRPC's independent wheel bundle. After dependency installation, the image checks gRPC's selected file as well. An explicit gRPC override must be readable and free of known affected CA certificates. [grpcio 1.84.1](https://github.com/grpc/grpc/releases/tag/v1.84.1) includes the upstream root-bundle correction and is the application minimum. Retain the checked override as a consistent image trust selection; older persisted Python environments and explicit client roots still need independent checks. + +The image also bundles Docker CLI [29.9.2](https://github.com/moby/moby/releases/tag/docker-v29.9.2), built with Go 1.26.10's distrust of the compromised intermediate keys. Cookbook uses this client for optional Docker operations. Default Compose does not mount the host Docker socket; local daemon access requires the explicit [host-Docker overlay](../docker/host-docker.yml), whose usual Unix-socket connection does not use TLS. The client update is maintenance of a shipped tool, not evidence that these local calls were exposed. It does not update the host daemon or other containers' clients and trust stores; assess those separately. + +Chromium uses a separate trust store. The Docker image installs a managed `CADistrustedCertificates` policy for the eight compromised intermediate public keys, covering their cross-signs without depending on background revocation updates. It adds no trusted certificate and does not disable certificate verification. The policy and public-certificate provenance are documented in [the browser policy note](../docker/chromium-ca-policy.md). Native browser installations and independently managed profiles require their own platform policy or vendor update; this image policy does not configure host browsers. + +To replace only Odysseus in an already running Compose stack, build from the corrected checkout with a distinct local image tag. Use the same Compose files and project name as the existing deployment for every command below: + +```sh +export ODYSSEUS_IMAGE="odysseus-local:ca-$(git rev-parse --short=12 HEAD)" +docker compose build --pull --no-cache odysseus +docker compose up -d --no-deps --pull never --no-build odysseus +docker image inspect --format '{{.Id}}' "$ODYSSEUS_IMAGE" +docker inspect --format '{{.Image}}' "$(docker compose ps -q odysseus)" +``` + +The two image IDs must match. Keep the selected `ODYSSEUS_IMAGE` in the deployment configuration for subsequent recreations. Compose can prefer the registry image when both `image` and `build` are present; the explicit pull/build flags keep this replacement on the local build. `--no-deps` leaves the existing sidecars running and is intended for an existing stack. For registry deployments, select a verified corrected immutable tag or digest. A merge to `dev` publishes `:dev`; it does not update `:latest`, which is published from `main`. + +Check the standard image as its configured app account, using the image's Python interpreter and the same user-bin PATH prefix as the entrypoint: + +```sh +docker compose exec -T --user 0 odysseus sh -ec ' + gosu_bin=$(command -v gosu) + app_user=$(getent passwd "${PUID:-1000}" | cut -d: -f1) + test -n "$app_user" + exec "$gosu_bin" "$app_user" env PATH="/app/.local/bin:$PATH" \ + /usr/local/bin/python /app/scripts/check_ca_trust.py \ + --system --python --grpc --overrides --verbose +' +``` + +`gosu` selects the account's HOME and drops privileges before Python runs, so the check includes its persisted user packages. The entrypoint itself is not rerun. Plain `docker compose exec ... python` uses the image's default root user and can miss `/app/.local` packages. A custom service user, entrypoint, virtual environment or launch command requires matching that deployment's actual user, HOME and Python interpreter instead of copying the standard command unchanged. Docker exec inherits container-creation environment, not variables exported later by the app or entrypoint: reproduce any additional Python/TLS settings with exec's `--env` options, and pass CA paths configured only in application files explicitly. Confirm the verbose identity and selected paths before accepting the result. + +For containers started non-root or without the SETUID/SETGID capabilities needed by `gosu`, use `docker compose exec --user UID:GID --env HOME=/actual/app/home` with the deployment's actual numeric IDs, home, PATH and interpreter instead. Do not add capabilities merely to run this check. + +SearXNG is a separate HTTPS client and image; updating the Odysseus image does not patch it. Inspect its effective CA path and update or rebuild that image independently. Also inspect persisted `/app/.local` packages, remote Cookbook environments, and externally configured MCP runtimes. Image replacement does not necessarily replace those environments. + +For behavioral validation, all fourteen [published test chains](https://emudhra.we-have-a-problem.com/chains.zip), including the AAA cross-signs and two chains without Certificate Transparency entries, must fail certificate verification through the effective stores. Confirm that an ordinary known-good HTTPS request still succeeds. In Chromium, confirm the managed policy is loaded at `chrome://policy` and test the incident endpoints through the actual browser path with certificate verification enabled. The PEM checker cannot inspect Chromium's policy or its active verifier. Package versions and a successful image build alone do not prove that a running deployment selects the corrected store. + +## Follow-up releases + +The maintained bootstrap selects certifi 2026.10.23 with its verified wheel hash; pip remains 26.2.2. The certifi update removes the C1/C3 roots in addition to the earlier incident changes. CI uses uv 0.13.5, which adds verifier-level rejection of the compromised intermediate keys and updated managed CPython builds. Existing virtual environments do not update automatically. See the [certifi change](https://github.com/certifi/python-certifi/commit/b60466c9f2882d9cf088943c650b0faac06c080c), [uv release](https://github.com/astral-sh/uv/releases/tag/0.13.5), and [Python security releases](https://blog.python.org/2026/10/python-3151-3149-31317/). The pinned bootstrap still replaces the selected versions exactly; preserving newer installed versions and private-mirror support remain separate maintenance. + +SearXNG remains an independent trust owner. Use the [upstream-image validation and rollout instructions](searxng-trust-image.md); updating these application packages does not repair an existing sidecar. Host daemons, native browser policy, optional compiled clients and independently managed integrations require their own effective-client checks. + +CPython's [intermediate-key verifier block](https://github.com/python/cpython/pull/160042) and its [3.14 backport](https://github.com/python/cpython/pull/160569) merged on October 24. They are distinct from Python 3.14.9's bundled-pip update. No released 3.14 interpreter containing that verifier change was identified when this image pin was selected; retain the corrected effective trust stores and track the subsequent security release. hf-xet's [trust fix](https://github.com/huggingface/xet-core/pull/1038) is also merged but not included in the latest published 1.7.0 release at that check. Agent-browser 0.39.2 and primp 2.0.1 are not established incident fixes for their independent native trust paths. A version bump alone does not establish that the active client uses corrected trust. diff --git a/frontend/modules.json b/frontend/modules.json new file mode 100644 index 000000000..0f882adbd --- /dev/null +++ b/frontend/modules.json @@ -0,0 +1,215 @@ +{ + "version": 1, + "modules": [ + { + "source": "shared/actionMenuOrder.js", + "deployment": "js/actionMenuOrder.js" + }, + { + "source": "features/notes/domain/recurrence.js", + "deployment": "js/features/notes/domain/recurrence.js" + }, + { + "source": "features/notes/infrastructure/http.js", + "deployment": "js/features/notes/infrastructure/http.js" + }, + { + "source": "features/notes/infrastructure/storage.js", + "deployment": "js/features/notes/infrastructure/storage.js" + }, + { + "source": "features/notes/application/state.js", + "deployment": "js/features/notes/application/state.js" + }, + { + "source": "features/notes/application/workflows.js", + "deployment": "js/features/notes/application/workflows.js" + }, + { + "source": "features/notes/application/reminders.js", + "deployment": "js/features/notes/application/reminders.js" + }, + { + "source": "features/notes/infrastructure/notifications.js", + "deployment": "js/features/notes/infrastructure/notifications.js" + }, + { + "source": "features/notes/ui/text.js", + "deployment": "js/features/notes/ui/text.js" + }, + { + "source": "features/notes/ui/colors.js", + "deployment": "js/features/notes/ui/colors.js" + }, + { + "source": "features/notes/ui/panel.js", + "deployment": "js/features/notes/ui/panel.js" + }, + { + "source": "features/notes/ui/reminderDisplay.js", + "deployment": "js/features/notes/ui/reminderDisplay.js" + }, + { + "source": "features/notes/ui/selection.js", + "deployment": "js/features/notes/ui/selection.js" + }, + { + "source": "features/notes/ui/filters.js", + "deployment": "js/features/notes/ui/filters.js" + }, + { + "source": "features/notes/ui/cardRendering.js", + "deployment": "js/features/notes/ui/cardRendering.js" + }, + { + "source": "features/notes/ui/layout.js", + "deployment": "js/features/notes/ui/layout.js" + }, + { + "source": "features/notes/ui/quickAdd.js", + "deployment": "js/features/notes/ui/quickAdd.js" + }, + { + "source": "features/notes/ui/cardInteractions.js", + "deployment": "js/features/notes/ui/cardInteractions.js" + }, + { + "source": "features/notes/ui/cardChecklist.js", + "deployment": "js/features/notes/ui/cardChecklist.js" + }, + { + "source": "features/notes/ui/cardReorder.js", + "deployment": "js/features/notes/ui/cardReorder.js" + }, + { + "source": "features/notes/ui/draftEditor.js", + "deployment": "js/features/notes/ui/draftEditor.js" + }, + { + "source": "features/notes/ui/editor.js", + "deployment": "js/features/notes/ui/editor.js" + }, + { + "source": "features/notes/ui/reminderPicker.js", + "deployment": "js/features/notes/ui/reminderPicker.js" + }, + { + "source": "features/notes/ui/checklist.js", + "deployment": "js/features/notes/ui/checklist.js" + }, + { + "source": "features/notes/ui/drawing.js", + "deployment": "js/features/notes/ui/drawing.js" + }, + { + "source": "features/notes/ui/clipboard.js", + "deployment": "js/features/notes/ui/clipboard.js" + }, + { + "source": "features/notes/ui/mobileEditor.js", + "deployment": "js/features/notes/ui/mobileEditor.js" + }, + { + "source": "features/notes/ui/mobileReorder.js", + "deployment": "js/features/notes/ui/mobileReorder.js" + }, + { + "source": "features/notes/ui/checklistReorder.js", + "deployment": "js/features/notes/ui/checklistReorder.js" + }, + { + "source": "features/notes/application/queries.js", + "deployment": "js/features/notes/application/queries.js" + }, + { + "source": "features/notes/application/agentActions.js", + "deployment": "js/features/notes/application/agentActions.js" + }, + { + "source": "features/notes/application/deletion.js", + "deployment": "js/features/notes/application/deletion.js" + }, + { + "source": "features/notes/domain/noteRules.js", + "deployment": "js/features/notes/domain/noteRules.js" + }, + { + "source": "features/notes/index.js", + "deployment": "js/features/notes/index.js", + "legacyImports": [ + { + "specifier": "/static/js/ui.js?v=20260916largetoolscroll1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/modalManager.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/colorPicker.js?v=20260910eyedropper1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/windowDrag.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/tileManager.js?v=20260910responsivebounds1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/modalSnap.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/toolWindowZOrder.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/escMenuStack.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/tourHintDismiss.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + } + ] + }, + { + "source": "features/notes/application/submission.js", + "deployment": "js/features/notes/application/submission.js" + }, + { + "source": "features/notes/application/drafts.js", + "deployment": "js/features/notes/application/drafts.js" + }, + { + "source": "features/notes/ui/lifecycle.js", + "deployment": "js/features/notes/ui/lifecycle.js" + }, + { + "source": "features/notes/application/cardOperations.js", + "deployment": "js/features/notes/application/cardOperations.js" + }, + { + "source": "features/notes/application/checklistOperations.js", + "deployment": "js/features/notes/application/checklistOperations.js" + }, + { + "source": "features/notes/application/selection.js", + "deployment": "js/features/notes/application/selection.js" + }, + { + "source": "features/notes/ui/toolbar.js", + "deployment": "js/features/notes/ui/toolbar.js" + }, + { + "source": "features/notes/ui/windowLayout.js", + "deployment": "js/features/notes/ui/windowLayout.js" + }, + { + "source": "shared/ui/confetti.js", + "deployment": "js/shared/ui/confetti.js" + } + ] +} diff --git a/frontend/src/features/notes/application/agentActions.js b/frontend/src/features/notes/application/agentActions.js new file mode 100644 index 000000000..d96828211 --- /dev/null +++ b/frontend/src/features/notes/application/agentActions.js @@ -0,0 +1,67 @@ +/** Create an agent session, link it to a note and drain its background stream. */ +import { _noteToAgentPrompt } from '../domain/noteRules.js'; +export function createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData, + state, + streamComplete, + uiModule +}) { + async function _agentSolveNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prompt = _noteToAgentPrompt(note); + if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; } + try { + const dc = await (await api.defaultChat()).json(); + if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } + + // 1. Create the session server-side (no UI switch). skip_validation + // avoids re-probing : the default-chat endpoint is already known good. + const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40); + const csFd = createFormData(); + csFd.append('name', 'Agent: ' + label); + csFd.append('endpoint_url', dc.endpoint_url); + csFd.append('model', dc.model); + if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); + csFd.append('skip_validation', 'true'); + const csRes = await api.createSession(csFd); + if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } + const sess = await csRes.json(); + const sid = sess.id; + + // 2. Link the session to the note right away so the tag appears. + const n = state.notes.find(x => x.id === id); + if (n) app.mergeNote(n, { agent_session_id: sid }); + _renderNotes(); + _patchNote(id, { agent_session_id: sid }).catch(() => {}); + + // 3. Kick off the agent run in the background. POST to chat_stream in + // agent mode and drain the SSE so the server runs the loop to + // completion + saves : without rendering anything in the chat UI. + const fd = createFormData(); + fd.append('message', prompt); + fd.append('session', sid); + fd.append('mode', 'agent'); + fd.append('disabled_tools', JSON.stringify(['manage_notes'])); + api.startChat(fd) + .then(async (res) => { + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + // Drain to completion (server finishes + persists the run). + while (true) { const { done } = await reader.read(); if (done) break; } + streamComplete(sid); + }) + .catch(() => {}); + + uiModule.showToast('Agent working in background — tap the Agent tag when ready'); + } catch (e) { + uiModule.showError('Agent failed: ' + (e.message || e)); + } + } + + return { _agentSolveNote }; +} diff --git a/frontend/src/features/notes/application/cardOperations.js b/frontend/src/features/notes/application/cardOperations.js new file mode 100644 index 000000000..1131d2ec0 --- /dev/null +++ b/frontend/src/features/notes/application/cardOperations.js @@ -0,0 +1,79 @@ +/** Optimistic card mutations and rollback. Animation completion is a UI-supplied boundary. */ +export function createCardOperations({ + app, + api, + effects, + pushUndo, + undoArchive +}) { + const state = app.state; + function togglePin(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prevPinned = note.pinned, prevSortOrder = note.sort_order; + app.mergeNote(note, { pinned: !prevPinned }); + const patch = { pinned: note.pinned }; + if (note.pinned) { + const minPinned = state.notes.filter(n => n.pinned && n.id !== id).reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + app.mergeNote(note, { sort_order: minPinned - 1 }); + patch.sort_order = note.sort_order; + } + effects.render(); + api.patchNote(id, patch).catch(() => { + app.mergeNote(note, { pinned: prevPinned, sort_order: prevSortOrder }); + effects.render(); effects.showError('Failed to pin'); + }); + } + async function updateColor(id, color) { + try { + await api.patchNote(id, { color: color || null }); + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, { color }); + } catch { effects.showError('Failed to update color'); } + } + function stageArchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return null; + const removed = app.removeNoteAt(index); + const undo = () => undoArchive(removed, index); + pushUndo({ label: 'archive', run: undo }); + return () => { + effects.render(); + api.patchNote(id, { archived: true }).then(() => effects.archived(undo)).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to archive'); + }); + }; + } + function unarchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + api.patchNote(id, { archived: false }).then(() => effects.showToast('Unarchived')).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to unarchive'); + }); + } + function deleteOptimistically(id, toast = false) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + const deletion = api.deleteNote(id); + const completion = toast ? deletion.then(() => effects.showToast('Deleted')) : deletion; + completion.catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to delete'); + }); + } + // Desktop deliberately persists without updating local sort_order, as before. + function persistOrder(ids) { return api.reorder(ids); } + async function commitMobileOrder(ids) { + try { + await api.reorder(ids); + ids.forEach((id, index) => { + const note = state.notes.find(note => note.id === id); + if (note) app.mergeNote(note, { sort_order: index }); + }); + } catch (error) { console.warn('reorder failed', error); } + } + return { togglePin, updateColor, stageArchive, unarchive, deleteOptimistically, persistOrder, commitMobileOrder }; +} diff --git a/frontend/src/features/notes/application/checklistOperations.js b/frontend/src/features/notes/application/checklistOperations.js new file mode 100644 index 000000000..b4a596b04 --- /dev/null +++ b/frontend/src/features/notes/application/checklistOperations.js @@ -0,0 +1,68 @@ +/** Checklist item workflows. Supplied feedback callbacks update the invoking view. */ +export function createChecklistOperations({ + app, + api, + effects, + uid +}) { + const state = app.state; + function itemNote(id, index) { + const note = state.notes.find(n => n.id === id); + return note && Array.isArray(note.items) && note.items[index] ? note : null; + } + function removeItem(id, index) { + const note = itemNote(id, index); + if (!note) return; + const removed = note.items[index]; + app.mergeNote(note, { items: note.items.filter((_, i) => i !== index) }); + effects.render(); + api.patchNote(id, { items: note.items }).catch(() => { + app.spliceItems(note, index, 0, removed); effects.render(); effects.showError('Failed to remove item'); + }); + } + function editItem(note, index, text) { + const oldText = (note.items[index].text || '').trim(); + if (text === oldText) { effects.render(); return; } + const oldItem = note.items[index]; + if (!text) app.spliceItems(note, index, 1); + else app.updateItem(note, index, { text }); + api.patchNote(note.id, { items: note.items }).catch(() => { + if (!text) app.spliceItems(note, index, 0, oldItem); + else app.updateItem(note, index, { text: oldText }); + effects.render(); effects.showError('Failed to update item'); + }); + effects.render(); + } + function addItem(id, text, added) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const items = Array.isArray(note.items) ? [...note.items] : []; + items.push({ id: uid(), text, done: false }); + app.mergeNote(note, { items }); + added(); effects.render(); + return () => api.patchNote(id, { items }).catch(() => { + app.mergeNote(note, { items: items.slice(0, -1) }); effects.render(); effects.showError('Failed to add item'); + }); + } + function toggleItem(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + const wasDone = note.items.length > 0 && note.items.every(it => it.done); + app.updateItem(note, index, { done: !note.items[index].done }); + changed(note.items[index].done); + if (!wasDone && note.items.length > 0 && note.items.every(it => it.done)) completed(); + api.patchNote(id, { items: note.items }).catch(() => { + app.updateItem(note, index, { done: !note.items[index].done }); changed(note.items[index].done); + }); + } + async function toggleTodayStep(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + app.updateItem(note, index, { done: !note.items[index].done }); changed(); + try { + await api.patchNote(id, { items: note.items }); effects.render(); + if (note.items.every(it => it.done)) completed(); + } catch { app.updateItem(note, index, { done: !note.items[index].done }); } + } + return { removeItem, editItem, addItem, toggleItem, toggleTodayStep }; +} diff --git a/frontend/src/features/notes/application/deletion.js b/frontend/src/features/notes/application/deletion.js new file mode 100644 index 000000000..e31e9b422 --- /dev/null +++ b/frontend/src/features/notes/application/deletion.js @@ -0,0 +1,51 @@ +/** Confirmed deletion and past-reminder clearing workflows. */ +export function createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects, + state +}) { + async function _deleteNote(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); effects.showToast('Deleted'); } + catch (err) { effects.showError(err.message); } + } + + async function _clearPastReminders() { + const targets = state.notes.filter(n => !n.archived && _isPastReminder(n)); + if (!targets.length) { + effects.showToast?.('No past reminders to clear'); + return; + } + const ok = effects.styledConfirm + ? await effects.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }) + : effects.confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`); + if (!ok) return; + await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {}))); + await _fetchNotes(); + _renderNotes(); + effects.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`); + } + + async function deleteFromEditor(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + const index = state.notes.findIndex(note => note.id === id); + if (index >= 0) app.removeNoteAt(index); + app.setEditingId(null); + _renderNotes(); + _deleteNoteApi(id).then(() => effects.showToast('Deleted')).catch(() => { + effects.showError('Failed to delete'); + _fetchNotes().then(() => _renderNotes()); + }); + } + return { _deleteNote, _clearPastReminders, deleteFromEditor }; +} diff --git a/frontend/src/features/notes/application/drafts.js b/frontend/src/features/notes/application/drafts.js new file mode 100644 index 000000000..4f7a4715d --- /dev/null +++ b/frontend/src/features/notes/application/drafts.js @@ -0,0 +1,55 @@ +/** Serialized autosave and synchronous local draft backup. DOM collection is a supplied port. */ +export function createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects +}) { + const state = app.state; + const { _draftKey, _clearDraft } = persistence; + function bindDraft({ collect, id, view }) { + let t = null; + let pending = Promise.resolve(); + let stopped = false; + let warned = false; + const save = () => { + const d = collect(); + // An empty edit can be intentional: don't restore stale server text over it. + try { persistence.setItem(_draftKey(id), JSON.stringify(d)); } + catch { + if (!warned) effects.showError('Draft backup unavailable. Keep this note open until saved.'); + warned = true; + } + return d; + }; + const persist = (d) => { + if (!id || id === '__new__' || d.note_type === 'draw') return pending; + const { _ts, ...payload } = d; + payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null; + pending = pending.then(async () => { + try { + await api.patchNote(id, payload); + view.failed = false; + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, payload); + if (persistence.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id); + } catch { + if (!view.failed) effects.showError('Note not synced. Your edits are kept on this device; retry Save.'); + view.failed = true; + } + }); + return pending; + }; + const flush = () => { scheduler.clearTimeout(t); return save(); }; + const stop = () => { stopped = true; scheduler.clearTimeout(t); return pending; }; + const sched = () => { + const d = save(); + if (stopped) return; + scheduler.clearTimeout(t); + t = scheduler.setTimeout(() => persist(d), 500); + }; + return { flush, stop, schedule: sched }; + } + return { bindDraft }; +} diff --git a/frontend/src/features/notes/application/queries.js b/frontend/src/features/notes/application/queries.js new file mode 100644 index 000000000..6a92250c9 --- /dev/null +++ b/frontend/src/features/notes/application/queries.js @@ -0,0 +1,66 @@ +/** Application-owned filtering, searching and note ordering. */ +import { _noteTags, _visibleNoteTags, _nextGoalStep, _isCalendarReminderNote } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createQueries({ + _hasActiveReminder, + state +}) { + function _sortedVisibleNotes() { + const displayNotes = _userVisibleNotes(); + let filtered = state.activeLabel ? displayNotes.filter(n => _noteTags(n).includes(state.activeLabel)) : displayNotes; + if (state.activeFilter === 'reminders') { + filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date)); + } else if (state.activeFilter === 'no-reminders') { + filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date))); + } else if (state.activeFilter === 'default') { + filtered = filtered.filter(n => _visibleNoteTags(n).length === 0); + } else if (state.activeFilter === 'goals') { + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived); + } else if (state.activeFilter === 'today') { + // Today view: only goals that still have an unchecked step. + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)); + } + if (state.searchQuery) { + filtered = filtered.filter(n => { + const q = state.searchQuery; + if ((n.title || '').toLowerCase().includes(q)) return true; + if ((n.content || '').toLowerCase().includes(q)) return true; + if ((n.label || '').toLowerCase().includes(q)) return true; + if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true; + return false; + }); + } + const sorted = [...filtered].sort((a, b) => { + // In reminders view: sort by due date ascending (soonest first) + if (state.activeFilter === 'reminders') { + const da = new Date(a.due_date || 0).getTime(); + const db = new Date(b.due_date || 0).getTime(); + return da - db; + } + // Archived view: newest archived first (ignore manual sort_order). + if (state.showingArchived) { + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + } + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + // Active reminders (due date in the past, not done/archived) rank + // immediately under the pinned block. + const aActive = _hasActiveReminder(a); + const bActive = _hasActiveReminder(b); + if (aActive && !bActive) return -1; + if (!aActive && bActive) return 1; + const so = (a.sort_order || 0) - (b.sort_order || 0); + if (so !== 0) return so; + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + }); + + + return sorted; + } + + function _userVisibleNotes() { + return state.notes.filter(n => !_isCalendarReminderNote(n)); + } + + return { _sortedVisibleNotes, _userVisibleNotes }; +} diff --git a/frontend/src/features/notes/application/reminders.js b/frontend/src/features/notes/application/reminders.js new file mode 100644 index 000000000..5e2475993 --- /dev/null +++ b/frontend/src/features/notes/application/reminders.js @@ -0,0 +1,182 @@ +/** Page-owned reminder scheduler and notification delivery workflow. */ +import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js'; +export function createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + effects, + fetchNotes, + isNoteFullyDone, + hasItems, + dismissedKey +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders } = persistence; + const _patchNote = api.patchNote; + const _fetchNotes = fetchNotes; + const _isNoteFullyDone = isNoteFullyDone; + const _hasItems = hasItems; + const REMINDER_DISMISSED_AT_KEY = dismissedKey; + const _updateRailBadge = effects.badge; + let _reminderTimer = null; + let startupTimer = null; + let started = false; + let disposed = false; + function _checkReminders() { + if (!state.notes.length) return; + const now = clock.now(); + const fired = _loadFiredReminders(); + let changed = false; + for (const note of state.notes) { + if (!note.due_date || note.archived) continue; + if (!_hasTimeComponent(note.due_date)) continue; + if (fired.has(note.id)) continue; + const due = new Date(note.due_date).getTime(); + if (isNaN(due)) continue; + if (due <= now && due > now - 60000) { + _fireReminder(note); + // Recurring? advance the due_date instead of marking as fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + // Don't add to fired : new due_date is in the future + continue; + } + } + fired.add(note.id); + changed = true; + } else if (due <= now - 60000) { + // Past, never seen : silently advance recurring or mark fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + continue; + } + } + fired.add(note.id); + changed = true; + } + } + if (changed) _saveFiredReminders(fired); + // Always refresh badge : fired state may have changed visually without note mutation + _updateRailBadge(); + } + + function _fireReminder(note) { + const title = note.title || 'Note reminder'; + // Include the verbatim note content so the email/notification actually + // shows what to do, not just a count. Cap the per-item lines (8 max) and + // total length so the body stays inbox-friendly. + let rawBody; + if (_hasItems(note)) { + const pending = (note.items || []) + .filter(i => !i.done && !i.checked) + .map(i => (i.text || '').trim()) + .filter(Boolean); + if (pending.length) { + const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n'); + const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : ''; + rawBody = `Pending (${pending.length}):\n${shown}${extra}`; + } else { + rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`; + } + } else { + rawBody = (note.content || '').slice(0, 400); + } + + // Ask the server to dispatch according to user settings. The server may + // return an LLM-written synthesis line and/or send an email. We still show + // a local browser notification so the user gets immediate feedback even if + // the server path is disabled or slow. + const showLocal = body => notifications.show(title, body, note.id); + + // Fire-and-forget server dispatch. If synthesis comes back quickly enough, + // use it as the notification body; otherwise the local notification has + // already shown with the raw body. + let shown = false; + const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500); + + api.fireReminder({ note_id: note.id, title, body: rawBody }) + .then(r => r.ok ? r.json() : null) + .then(data => { + scheduler.clearTimeout(timer); + if (shown) return; + shown = true; + const body = (data && data.synthesis) ? data.synthesis : rawBody; + showLocal(body); + }) + .catch(() => { + scheduler.clearTimeout(timer); + if (!shown) { shown = true; showLocal(rawBody); } + }); + + // Pulse the card if visible; otherwise queue it so the next time the user + // opens the notes panel the card gets a brief glow. + effects.highlight(note.id); + } + + function _startReminderLoop() { + if (disposed || _reminderTimer) return; + _reminderTimer = scheduler.setInterval(_checkReminders, 30000); + _checkReminders(); // run once immediately + } + + function _countDueReminders() { + return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length; + } + + function _countFiredReminders() { + // Reminders whose time has actually passed (not just date-today), + // and which fired after the last user dismissal. + const now = clock.now(); + return state.notes.filter(n => { + if (n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date || !_hasTimeComponent(n.due_date)) return false; + const t = new Date(n.due_date).getTime(); + if (isNaN(t) || t > now) return false; + return t > state.firedDotDismissedAt; + }).length; + } + + function dismissFiredReminderDot() { + app.setFiredDotDismissedAt(clock.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + _updateRailBadge(); + } + + async function refreshDueBadge(opts = {}) { + // Usually lightweight, but callers that just created a note reminder can + // force a refresh so the background reminder loop sees it immediately. + if (opts.force || state.notes.length === 0) { + try { + const wasArchived = state.showingArchived; + app.setShowingArchived(false); + await _fetchNotes(); + app.setShowingArchived(wasArchived); + } catch {} + } + _updateRailBadge(); + } + + async function _initReminders() { + try { + const res = await api.listNotes(false); + if (res.ok && !disposed) { + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + _startReminderLoop(); + } + } catch {} + } + + function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); } + function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; } + return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders }; +} diff --git a/frontend/src/features/notes/application/selection.js b/frontend/src/features/notes/application/selection.js new file mode 100644 index 000000000..6017025e2 --- /dev/null +++ b/frontend/src/features/notes/application/selection.js @@ -0,0 +1,39 @@ +/** Selected-note attachment retains synchronous acceptance; bulk requests retain partial-failure behavior. */ +export function createSelectionOperations({ + app, + api, + effects, + sendContext, + exitSelection, + fetchNotes +}) { + const state = app.state; + function attachSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + const detail = { noteIds: ids, accepted: false }; + sendContext(detail); + if (!detail.accepted) { + effects.showToast('Choose a chat or model before attaching notes'); + return; + } + exitSelection(); + } + async function archiveSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + await Promise.all(ids.map(id => api.patchNote(id, { archived: true }).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Archived ${ids.length}`); + } + async function deleteSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + if (effects.styledConfirm) { + const ok = await effects.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + } + await Promise.all(ids.map(id => api.deleteNote(id).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Deleted ${ids.length}`); + } + return { attachSelected, archiveSelected, deleteSelected }; +} diff --git a/frontend/src/features/notes/application/state.js b/frontend/src/features/notes/application/state.js new file mode 100644 index 000000000..b87ef11c4 --- /dev/null +++ b/frontend/src/features/notes/application/state.js @@ -0,0 +1,83 @@ +/** The sole Notes state owner. Views have live read-only access; writes use operations. */ +export function createNotesState(initial = {}) { + const values = { + open: false, notes: [], editingId: null, selectedIds: new Set(), + activeLabel: null, activeFilter: null, reminderChipNext: 'reminders', searchQuery: '', + viewMode: initial.viewMode || 'list', showingArchived: false, selectMode: false, + suppressNextNoteTapId: null, labelsCollapsed: !!initial.labelsCollapsed, + loading: false, firedDotDismissedAt: initial.firedDotDismissedAt || 0, + }; + // Preserve captured note identity across asynchronous completions without + // giving a view a writable reference to the application store. + const views = new WeakMap(), targets = new WeakMap(); + const unwrap = value => targets.get(value) || value; + // Copies built from views (spread, filter, map) still hold nested views; + // store only raw data so the owner never keeps its own read-only views. + function unwrapDeep(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return targets.get(value); + if (seen.has(value)) return value; + seen.add(value); + if (Array.isArray(value)) value.forEach((item, index) => { value[index] = unwrapDeep(item, seen); }); + else if (Object.getPrototypeOf(value) === Object.prototype) for (const key of Object.keys(value)) value[key] = unwrapDeep(value[key], seen); + return value; + } + function readonly(value) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return value; + if (views.has(value)) return views.get(value); + const view = new Proxy(value, { + get(target, key) { + if (target instanceof Set) { + if (['add', 'delete', 'clear'].includes(key)) return () => { throw new TypeError('Use Notes selection operations'); }; + if (key === 'valueOf') return () => views.get(target); + if (key === 'forEach') return (callback, thisArg) => target.forEach(value => callback.call(thisArg, value, value, views.get(target))); + const member = Reflect.get(target, key, target); + return typeof member === 'function' ? member.bind(target) : member; + } + return readonly(Reflect.get(target, key)); + }, + set() { throw new TypeError('Use Notes state operations'); }, + deleteProperty() { throw new TypeError('Use Notes state operations'); }, + defineProperty() { throw new TypeError('Use Notes state operations'); }, + setPrototypeOf() { throw new TypeError('Use Notes state operations'); }, + preventExtensions() { throw new TypeError('Use Notes state operations'); }, + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (descriptor && 'value' in descriptor) return { ...descriptor, value: readonly(descriptor.value) }; + return descriptor; + }, + }); + views.set(value, view); targets.set(view, value); + return view; + } + const state = Object.freeze(Object.fromEntries(Object.keys(values).map(key => [key, () => readonly(values[key])]))); + const view = Object.freeze(Object.defineProperties({}, Object.fromEntries(Object.entries(state).map(([key, get]) => [key, { get, enumerable: true }])))); + return { + state: view, + setOpen: value => { values.open = value; }, + setEditingId: value => { values.editingId = value; }, + setActiveLabel: value => { values.activeLabel = value; }, + setActiveFilter: value => { values.activeFilter = value; }, + setReminderChipNext: value => { values.reminderChipNext = value; }, + setSearchQuery: value => { values.searchQuery = value; }, + setViewMode: value => { values.viewMode = value; }, + setShowingArchived: value => { values.showingArchived = value; }, + setSelectMode: value => { values.selectMode = value; }, + setSuppressNextNoteTapId: value => { values.suppressNextNoteTapId = value; }, + setLabelsCollapsed: value => { values.labelsCollapsed = value; }, + setLoading: value => { values.loading = value; }, + setFiredDotDismissedAt: value => { values.firedDotDismissedAt = value; }, + replaceNotes: notes => { values.notes = notes; }, + removeNoteAt: index => readonly(values.notes.splice(index, 1)[0]), + insertNoteAt: (index, note) => { values.notes.splice(index, 0, unwrapDeep(note)); }, + prependNote: note => { values.notes.unshift(unwrapDeep(note)); }, + replaceNoteAt: (index, note) => { values.notes[index] = unwrapDeep(note); }, + mergeNote: (note, patch) => { for (const [key, value] of Object.entries(patch)) unwrap(note)[key] = unwrapDeep(value); }, + updateItem: (note, index, patch) => { Object.assign(unwrap(unwrap(note).items[index]), unwrapDeep({ ...patch })); }, + spliceItems: (note, ...args) => unwrap(unwrap(note).items).splice(...args.map(arg => unwrapDeep(arg))), + selectId: id => { values.selectedIds.add(id); }, + deselectId: id => values.selectedIds.delete(id), + clearSelection: () => values.selectedIds.clear(), + }; +} diff --git a/frontend/src/features/notes/application/submission.js b/frontend/src/features/notes/application/submission.js new file mode 100644 index 000000000..4703eb7c1 --- /dev/null +++ b/frontend/src/features/notes/application/submission.js @@ -0,0 +1,69 @@ +/** Editor submission ordering, optimistic state, reminder rearming and save recovery. */ +export function createNotesSubmission({ + app, + api, + persistence, + clock, + uid, + effects, + fetchNotes +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders, _loadGlowedReminders, _saveGlowedReminders, _loadDraft, _clearDraft } = persistence; + function submitNote(payload, note, isEdit) { + // Reset fired reminder if due_date changed (so re-arm works), and also + // clear the entry-glow seen flag so the new firing glows again on the + // next time the user opens the panel. + if (isEdit && note.due_date !== payload.due_date) { + const fired = _loadFiredReminders(); + fired.delete(note.id); + _saveFiredReminders(fired); + const glowed = _loadGlowedReminders(); + glowed.delete(note.id); + _saveGlowedReminders(glowed); + effects.clearGlow(note.id); + } + // Edited notes move to the top of their section (under pinned). Compute + // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above + // siblings; the pin block keeps its own ordering above this. + if (!payload.pinned) { + // Both edits AND newly-created notes anchor above the rest of the + // unpinned section. Without this, freshly created notes sit at the + // bottom because manually-reordered siblings already carry negative + // sort_order values. + const minUnpinned = state.notes + .filter(n => !n.pinned && (!isEdit || n.id !== note.id)) + .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + payload.sort_order = minUnpinned - 1; + } + // Optimistic update : update local state first, render, then save in background + app.setEditingId(null); + const draftId = isEdit ? note.id : '__new__'; + const submittedDraft = JSON.stringify(_loadDraft(draftId)); + if (isEdit) { + const idx = state.notes.findIndex(n => n.id === note.id); + if (idx >= 0) app.replaceNoteAt(idx, { ...state.notes[idx], ...payload }); + } else { + app.prependNote({ ...payload, id: 'tmp_' + uid(), created_at: new Date(clock.now()).toISOString(), updated_at: new Date(clock.now()).toISOString() }); + } + effects.render(); + // Background save + api.saveNote(payload).then(saved => { + if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId); + if (!isEdit && saved && saved.id) { + // Replace temp ID with real one from server. AND re-render : the + // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign + // bumps the in-memory id, so all subsequent clicks (edit, done, copy, + // archive, delete) silently fail to find the note in `_notes`. + const tmp = state.notes.find(n => n.id.startsWith('tmp_')); + if (tmp) app.mergeNote(tmp, saved); + effects.render(); + } + }).catch(err => { + effects.showError('Save failed: ' + err.message); + fetchNotes().then(() => effects.render()); + }); + + } + return { submitNote }; +} diff --git a/frontend/src/features/notes/application/workflows.js b/frontend/src/features/notes/application/workflows.js new file mode 100644 index 000000000..018cbf8aa --- /dev/null +++ b/frontend/src/features/notes/application/workflows.js @@ -0,0 +1,78 @@ +/** Notes CRUD, optimistic archive and in-memory undo workflows. */ +export function createNotesWorkflows({ + app, + api, + effects +}) { + const state = app.state; + const _undoStack = []; + const _renderNotes = effects.render; + const _patchNote = api.patchNote; + const uiModule = effects; + function _pushUndo(entry) { + _undoStack.push(entry); + if (_undoStack.length > 20) _undoStack.shift(); + } + + function _popAndRunUndo() { + const entry = _undoStack.pop(); + if (entry) entry.run(); + return !!entry; + } + + function _undoArchive(note, prevIdx) { + // Re-insert at original position and clear archived flag on the server. + const safeIdx = Math.min(Math.max(prevIdx, 0), state.notes.length); + app.insertNoteAt(safeIdx, { ...note, archived: false }); + _renderNotes(); + _patchNote(note.id, { archived: false }).catch(() => { + // Roll back local insertion if the server refuses + const i = state.notes.findIndex(n => n.id === note.id); + if (i >= 0) app.removeNoteAt(i); + _renderNotes(); + uiModule.showError('Undo failed'); + }); + } + + function _archiveNoteById(id, { card = null, celebrate = false } = {}) { + if (!id) return false; + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return false; + const note = state.notes[idx]; + if (celebrate && card && note) effects.celebrateArchive(note, card); + const removed = app.removeNoteAt(idx); + app.setEditingId(null); + _renderNotes(); + const undo = () => _undoArchive(removed, idx); + _pushUndo({ label: 'archive', run: undo }); + _patchNote(id, { archived: true }).then(() => { + effects.archived(undo); + }).catch(() => { + app.insertNoteAt(idx, removed); + _renderNotes(); + uiModule.showError('Failed to archive'); + }); + return true; + } + + async function _fetchNotes() { + app.setLoading(true); + try { + const res = await api.listNotes(state.showingArchived); + if (!res.ok) { app.replaceNotes([]); return; } + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + } catch (e) { + console.error('Failed to fetch notes:', e); + app.replaceNotes([]); + } finally { + app.setLoading(false); + } + } + + function _saveNote(note) { return api.saveNote(note); } + + function _deleteNoteApi(id) { return api.deleteNote(id); } + + return { _pushUndo, _popAndRunUndo, _undoArchive, _archiveNoteById, _fetchNotes, _saveNote, _deleteNoteApi, _patchNote, undoCount: () => _undoStack.length }; +} diff --git a/frontend/src/features/notes/domain/noteRules.js b/frontend/src/features/notes/domain/noteRules.js new file mode 100644 index 000000000..ba3f8ae2f --- /dev/null +++ b/frontend/src/features/notes/domain/noteRules.js @@ -0,0 +1,87 @@ +/** Pure Notes completion, tags, reminder and serialization rules. */ +import { _hasTimeComponent } from './recurrence.js'; +export function _isCalendarReminderNote(n) { + return n && n.source === 'calendar' && n.label === 'calendar'; +} + +export function _hasActiveReminder(n, clock) { + if (!n || n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date) return false; + const t = new Date(n.due_date).getTime(); + return !isNaN(t) && t <= clock.now(); +} + +export function _isNoteFullyDone(note) { + if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) { + return note.items.every(it => it.done); + } + return false; +} + +export function _hasItems(note) { + return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist'); +} + +export function _goalProgress(note) { + if (!Array.isArray(note?.items) || note.items.length === 0) return ''; + const done = note.items.filter(it => it.done).length; + return ` ${done}/${note.items.length}`; +} + +export function _nextGoalStep(note) { + if (!Array.isArray(note?.items)) return null; + for (let i = 0; i < note.items.length; i++) { + if (!note.items[i].done) return { idx: i, item: note.items[i] }; + } + return null; +} + +export function _noteTags(n) { + const tags = []; + if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean)); + if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder'); + return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; +} + +export function _visibleNoteTags(n) { + return _noteTags(n).filter(t => t !== 'reminder'); +} + +export function _isPastReminder(n, clock) { + if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false; + const due = new Date(n.due_date).getTime(); + return !isNaN(due) && due <= clock.now(); +} + +export function _serializeNoteForCopy(note) { + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (Array.isArray(note.items) && note.items.length) { + if (lines.length) lines.push(''); + for (const it of note.items) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + } + return lines.join('\n').trim(); +} + +export function _noteToAgentPrompt(note) { + const parts = []; + if ((note.title || '').trim()) parts.push(note.title.trim()); + if ((note.content || '').trim()) parts.push(note.content.trim()); + if (Array.isArray(note.items)) { + note.items.filter(it => !it.done && (it.text || '').trim()) + .forEach(it => parts.push('- ' + it.text.trim())); + } + const body = parts.join('\n'); + return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : ''; +} + +export function _standardVisualVariant(note) { + const source = String(note?.id || note?.title || note?.created_at || 'standard-note'); + let hash = 0; + for (let i = 0; i < source.length; i++) hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; + return Math.abs(hash) % 5; +} diff --git a/frontend/src/features/notes/domain/recurrence.js b/frontend/src/features/notes/domain/recurrence.js new file mode 100644 index 000000000..f72498fbb --- /dev/null +++ b/frontend/src/features/notes/domain/recurrence.js @@ -0,0 +1,269 @@ +// Local-time date and recurrence rules. Callers supply the clock at the original read sites. +export const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; +export const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +export function _hasTimeComponent(dateStr) { + return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); +} + +export function _formatDueDate(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const hasTime = _hasTimeComponent(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const diffDays = Math.round((due - today) / 86400000); + const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; + if (hasTime && d < now) return 'overdue'; + if (!hasTime && diffDays < 0) return 'overdue'; + if (diffDays === 0) return hasTime ? timeStr : 'today'; + if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; +} + +export function _isDueOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + if (isNaN(d)) return false; + if (_hasTimeComponent(dateStr)) return d < new Date(clock.now()); + return d < new Date(new Date(clock.now()).toDateString()); +} + +export function _isDueTodayOrOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + const now = new Date(clock.now()); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return due <= today; +} + +export function _laterTodayDate(clock) { + const now = new Date(clock.now()); + const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today + // If less than 1 hour before 6pm, push to "in 3 hours" instead + if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); + return eight; +} + +export function _tomorrowDate(clock) { + const t = new Date(clock.now()); + t.setDate(t.getDate() + 1); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _nextWeekDate(clock) { + const t = new Date(clock.now()); + const daysUntilMon = (8 - t.getDay()) % 7 || 7; + t.setDate(t.getDate() + daysUntilMon); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _toLocalDatetimeStr(d) { + // Format as YYYY-MM-DDTHH:MM (local, no TZ) + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function _formatReminderTag(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const sameDay = d.toDateString() === now.toDateString(); + const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = d.toDateString() === tomorrow.toDateString(); + const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + if (sameDay) return `Today, ${time}`; + if (isTomorrow) return `Tomorrow, ${time}`; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return `${dateLabel}, ${time}`; +} + +export function _nthWeekdayLabel(d) { + const n = Math.ceil(d.getDate() / 7); // 1..5 + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; +} + +export function _isLastWeekdayOfMonth(d) { + const test = new Date(d); + test.setDate(d.getDate() + 7); + return test.getMonth() !== d.getMonth(); +} + +export function _nthWeekdayOfMonth(year, month, weekday, n) { + const first = new Date(year, month, 1); + const offset = (weekday - first.getDay() + 7) % 7; + let day = 1 + offset + (n - 1) * 7; + // Last day of month + const lastDay = new Date(year, month + 1, 0).getDate(); + if (day > lastDay) day -= 7; + return new Date(year, month, day, 0, 0, 0); +} + +export function _lastWeekdayOfMonth(year, month, weekday) { + const lastDay = new Date(year, month + 1, 0); + const back = (lastDay.getDay() - weekday + 7) % 7; + return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); +} + +export function _snapToRepeat(currentDate, normRepeat, clock) { + const hh = currentDate.getHours(); + const mm = currentDate.getMinutes(); + const now = clock.now(); + const anchor = currentDate.getTime() > now ? currentDate : new Date(clock.now()); + const parts = normRepeat.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + const targetWd = parseInt(parts[1], 10); + if (isNaN(targetWd)) return null; + const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); + const delta = (targetWd - d.getDay() + 7) % 7; + d.setDate(d.getDate() + delta); + if (d.getTime() <= now) d.setDate(d.getDate() + 7); + return d; + } + if (kind === 'monthly') { + const sub = parts[1]; + let y = anchor.getFullYear(); + let m = anchor.getMonth(); + // Walk forward up to 14 months to find the next matching slot. + for (let tries = 0; tries < 14; tries++) { + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + if (isNaN(wantDay)) return null; + const lastDay = new Date(y, m + 1, 0).getDate(); + target = new Date(y, m, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + if (isNaN(n) || isNaN(wd)) return null; + target = _nthWeekdayOfMonth(y, m, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + if (isNaN(wd)) return null; + target = _lastWeekdayOfMonth(y, m, wd); + } else { + return null; + } + target.setHours(hh, mm, 0, 0); + if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; + m++; + if (m > 11) { m = 0; y++; } + } + return null; + } + return null; +} + +export function _formatRepeatLabel(repeat, originalDate) { + if (!repeat || repeat === 'none') return ''; + const norm = _normalizeRepeat(repeat, originalDate); + if (norm === 'daily') return 'Daily'; + if (norm === 'yearly') return 'Yearly'; + const parts = norm.split(':'); + if (parts[0] === 'weekly') { + const wd = parseInt(parts[1], 10); + if (isNaN(wd)) return 'Weekly'; + return `Weekly on ${_DAYS[wd]}s`; + } + if (parts[0] === 'monthly') { + if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Monthly on last ${_DAYS[wd]}`; + } + } + return norm; +} + +export function _normalizeRepeat(repeat, originalDate) { + if (!repeat || repeat === 'none') return 'none'; + if (repeat === 'daily' || repeat === 'yearly') return repeat; + if (/^(weekly|monthly):/.test(repeat)) return repeat; + // Legacy bare values: derive params from the original date + const wd = originalDate.getDay(); + const n = Math.ceil(originalDate.getDate() / 7); + if (repeat === 'weekly') return `weekly:${wd}`; + if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; + if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; + if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; + return repeat; +} + +export function _advanceRecurring(dateStr, repeat, clock) { + const orig = new Date(dateStr); + const hh = orig.getHours(); + const mm = orig.getMinutes(); + let d = new Date(orig); + const norm = _normalizeRepeat(repeat, orig); + if (norm === 'none') return null; + + function step() { + if (norm === 'daily') { + d.setDate(d.getDate() + 1); + return; + } + if (norm === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + return; + } + const parts = norm.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + // Snap to the requested weekday in the next 1-7 days + const targetWd = parseInt(parts[1], 10); + let delta = (targetWd - d.getDay() + 7) % 7; + if (delta === 0) delta = 7; + d.setDate(d.getDate() + delta); + d.setHours(hh, mm, 0, 0); + return; + } + if (kind === 'monthly') { + const sub = parts[1]; + const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); + const nm = (d.getMonth() + 1) % 12; + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + const lastDay = new Date(ny, nm + 1, 0).getDate(); + target = new Date(ny, nm, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + target = _nthWeekdayOfMonth(ny, nm, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + target = _lastWeekdayOfMonth(ny, nm, wd); + } else { + d = null; return; + } + target.setHours(hh, mm, 0, 0); + d = target; + return; + } + d = null; + } + + step(); + if (d === null) return null; + const now = clock.now(); + // Cap catch-up to avoid runaway on a malformed/very-old date. + let guard = 5000; + while (d.getTime() <= now) { + if (--guard <= 0) return null; + step(); + if (d === null) return null; + } + return _toLocalDatetimeStr(d); +} diff --git a/frontend/src/features/notes/index.js b/frontend/src/features/notes/index.js new file mode 100644 index 000000000..146844b8a --- /dev/null +++ b/frontend/src/features/notes/index.js @@ -0,0 +1,503 @@ +/** Notes composition and intentional public API. The legacy entry activates compatibility startup. */ +import { createSelectionOperations } from './application/selection.js'; +import { createToolbar } from './ui/toolbar.js'; +import { createWindowLayout } from './ui/windowLayout.js'; +import { createCardOperations } from './application/cardOperations.js'; +import { createChecklistOperations } from './application/checklistOperations.js'; +import { createNotesSubmission } from './application/submission.js'; +import { createNotesDrafts } from './application/drafts.js'; +import { createNotesLifecycle } from './ui/lifecycle.js'; +import * as noteRules from './domain/noteRules.js'; +import { createText } from './ui/text.js'; +import { createColors } from './ui/colors.js'; +import { createPanel } from './ui/panel.js'; +import { createReminderDisplay } from './ui/reminderDisplay.js'; +import { createSelection } from './ui/selection.js'; +import { createFilters } from './ui/filters.js'; +import { createCardRendering } from './ui/cardRendering.js'; +import { createLayout } from './ui/layout.js'; +import { createQuickAdd } from './ui/quickAdd.js'; +import { createCardInteractions } from './ui/cardInteractions.js'; +import { createCardChecklist } from './ui/cardChecklist.js'; +import { createCardReorder } from './ui/cardReorder.js'; +import { createDraftEditor } from './ui/draftEditor.js'; +import { createEditor } from './ui/editor.js'; +import { createReminderPicker } from './ui/reminderPicker.js'; +import { createChecklist } from './ui/checklist.js'; +import { createDrawing } from './ui/drawing.js'; +import { createClipboard } from './ui/clipboard.js'; +import { createMobileEditor } from './ui/mobileEditor.js'; +import { createMobileReorder } from './ui/mobileReorder.js'; +import { createChecklistReorder } from './ui/checklistReorder.js'; +import { createQueries } from './application/queries.js'; +import { createAgentActions } from './application/agentActions.js'; +import { createDeletion } from './application/deletion.js'; +import { createNotesReminders } from './application/reminders.js'; +import { createNotesNotifications } from './infrastructure/notifications.js'; +import { createNotesState } from './application/state.js'; +import { createNotesWorkflows } from './application/workflows.js'; +import { createNotesApi } from './infrastructure/http.js'; +import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY, NOTES_FIRST_OPEN_HINT_KEY, NOTES_LABELS_COLLAPSED_KEY } from './infrastructure/storage.js'; +import * as dates from './domain/recurrence.js'; +import uiModule from '/static/js/ui.js?v=20260916largetoolscroll1'; +import { spawnConfetti } from '/static/js/shared/ui/confetti.js'; +import * as Modals from '/static/js/modalManager.js'; +import { attachColorPicker } from '/static/js/colorPicker.js?v=20260910eyedropper1'; +import { makeWindowDraggable } from '/static/js/windowDrag.js'; +import { snapModalToZone } from '/static/js/tileManager.js?v=20260910responsivebounds1'; +import { applyEdgeDock, clearDockSide } from '/static/js/modalSnap.js'; +import { topToolWindowZ, topPortalZ } from '/static/js/toolWindowZOrder.js'; +import { bindMenuDismiss, dismissOrRemove } from '/static/js/escMenuStack.js'; +import { bindTourHintDismiss } from '/static/js/tourHintDismiss.js'; + +const api = createNotesApi({ origin: window.location.origin, request: (...args) => fetch(...args) }); +const persistence = createNotesPersistence({ storage: typeof localStorage === 'undefined' ? undefined : localStorage }); +const { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft } = persistence; +const clock = { now: () => Date.now() }; +const app = createNotesState({ + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list', + labelsCollapsed: typeof localStorage !== 'undefined' && localStorage.getItem(NOTES_LABELS_COLLAPSED_KEY) === '1', + firedDotDismissedAt: (() => { try { const v = parseInt(persistence.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10); return Number.isFinite(v) && v > 0 ? v : 0; } catch { return 0; } })(), +}); +const state = app.state; +const lifecycle = createNotesLifecycle(); +const scheduler = { setTimeout: (...args) => setTimeout(...args), clearTimeout: id => clearTimeout(id), setInterval: (...args) => setInterval(...args), clearInterval: id => clearInterval(id) }; +const notifications = createNotesNotifications({ browser: window, notify: window.Notification, openPanel: () => openPanel(), toast: title => uiModule?.showToast?.(title) }); +const reminders = createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + fetchNotes: () => _fetchNotes(), + isNoteFullyDone: note => _isNoteFullyDone(note), + hasItems: note => _hasItems(note), + dismissedKey: REMINDER_DISMISSED_AT_KEY, + effects: { badge: () => _updateRailBadge(), highlight: id => { + _setReminderCardGlow(id, true); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (card) { card.classList.add('note-card-reminder-fired'); setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000); } + else _queuePendingHighlight(id); +} } +}); +const workflows = createNotesWorkflows({ + app, + api, + effects: { + render: () => _renderNotes(), + showError: (...args) => uiModule.showError(...args), + archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }), + celebrateArchive: (note, card) => { if (_hasItems(note) && !(note.items || []).some(i => !i.done)) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); } }, +} +}); +const _NOTE_UNDO_ICON = ''; +function _pushUndo(entry) { return workflows._pushUndo(entry); } +function _popAndRunUndo() { return workflows._popAndRunUndo(); } +function _undoArchive(note, prevIdx) { return workflows._undoArchive(note, prevIdx); } +function _archiveNoteById(id, options) { return workflows._archiveNoteById(id, options); } +function _fetchNotes() { return workflows._fetchNotes(); } + +function _deleteNoteApi(id) { return workflows._deleteNoteApi(id); } +function _patchNote(id, patch) { return workflows._patchNote(id, patch); } +function _uid() { return Math.random().toString(36).slice(2, 10); } +function _formatDueDate(dateStr) { return dates._formatDueDate(dateStr, clock); } +function _isDueOverdue(dateStr) { return dates._isDueOverdue(dateStr, clock); } + +function _laterTodayDate() { return dates._laterTodayDate(clock); } +function _tomorrowDate() { return dates._tomorrowDate(clock); } +function _nextWeekDate() { return dates._nextWeekDate(clock); } +function _formatReminderTag(dateStr) { return dates._formatReminderTag(dateStr, clock); } +function _snapToRepeat(currentDate, normRepeat) { return dates._snapToRepeat(currentDate, normRepeat, clock); } +function _ensureNotificationPermission() { return notifications.ensurePermission(); } + +function _startReminderLoop() { return reminders._startReminderLoop(); } + +function _countFiredReminders() { return reminders._countFiredReminders(); } + +function _hasActiveReminder(...args) { return noteRules._hasActiveReminder(...args, clock); } +const _isNoteFullyDone = noteRules._isNoteFullyDone; +const _hasItems = noteRules._hasItems; +function _isPastReminder(...args) { return noteRules._isPastReminder(...args, clock); } +function _esc(...args) { return text._esc(...args); } + +function _safeImgSrc(...args) { return text._safeImgSrc(...args); } +function _linkify(...args) { return text._linkify(...args); } +function _isBgImage(...args) { return colors._isBgImage(...args); } + +function _dotBg(...args) { return colors._dotBg(...args); } +function _dotIsActive(...args) { return colors._dotIsActive(...args); } +function _customColorStyle(...args) { return colors._customColorStyle(...args); } +function _pickCustomBgImage(...args) { return colors._pickCustomBgImage(...args); } +function _wireFormColors(...args) { return colors._wireFormColors(...args); } + +function openPanel(...args) { return panel.openPanel(...args); } + +function _smoothScrollToNoteCard(...args) { return panel._smoothScrollToNoteCard(...args); } +function _clearViewedReminderGlows(...args) { return reminderDisplay._clearViewedReminderGlows(...args); } +function _setReminderCardGlow(...args) { return reminderDisplay._setReminderCardGlow(...args); } +function _queuePendingHighlight(...args) { return reminderDisplay._queuePendingHighlight(...args); } +function _flushPendingHighlights(...args) { return reminderDisplay._flushPendingHighlights(...args); } +function _updateRailBadge(...args) { return reminderDisplay._updateRailBadge(...args); } +function _enterSelectMode(...args) { return selection._enterSelectMode(...args); } +function _exitSelectMode(...args) { return selection._exitSelectMode(...args); } +function _updateBulkBar(...args) { return selection._updateBulkBar(...args); } + +function _renderLabelsInto(...args) { return filters._renderLabelsInto(...args); } +function _renderLoadingSkeleton(...args) { return cardRendering._renderLoadingSkeleton(...args); } +function _renderNotes(...args) { return cardRendering._renderNotes(...args); } +function _captureCardPositions(...args) { return layout._captureCardPositions(...args); } +function _animateReflow(...args) { return layout._animateReflow(...args); } +function _applyMasonry(...args) { return layout._applyMasonry(...args); } +function _renderQuickAdd(...args) { return quickAdd._renderQuickAdd(...args); } + +function _editNote(...args) { return quickAdd._editNote(...args); } +function _bindCardEvents(...args) { return cardInteractions._bindCardEvents(...args); } +function _wireCardChecklist(...args) { return cardChecklist._wireCardChecklist(...args); } +function _wireTodayView(...args) { return cardChecklist._wireTodayView(...args); } +function _wireCardReorder(...args) { return cardReorder._wireCardReorder(...args); } + +function _wireDraftAutosave(...args) { return draftEditor._wireDraftAutosave(...args); } +function _commitOpenInPlaceEditor(...args) { return draftEditor._commitOpenInPlaceEditor(...args); } +function _applyDraftToNote(...args) { return draftEditor._applyDraftToNote(...args); } +function _buildForm(...args) { return editor._buildForm(...args); } +function _wireReminderPicker(...args) { return reminderPicker._wireReminderPicker(...args); } +function _buildGoalHtml(...args) { return checklist._buildGoalHtml(...args); } +function _wireGoalForm(...args) { return checklist._wireGoalForm(...args); } +function _buildChecklistHtml(...args) { return checklist._buildChecklistHtml(...args); } +function _wireRow(...args) { return checklist._wireRow(...args); } +function _wireChecklist(...args) { return checklist._wireChecklist(...args); } +function _collectItems(...args) { return checklist._collectItems(...args); } +function _buildDrawHtml(...args) { return drawing._buildDrawHtml(...args); } +function _wireCanvas(...args) { return drawing._wireCanvas(...args); } +function _uploadCanvasAsPng(...args) { return drawing._uploadCanvasAsPng(...args); } +function _openNoteCornerMenu(...args) { return clipboard._openNoteCornerMenu(...args); } +function _copyNote(...args) { return clipboard._copyNote(...args); } +function _isNotesMobileMode(...args) { return mobileEditor._isNotesMobileMode(...args); } +function _openMobileFullscreenEdit(...args) { return mobileEditor._openMobileFullscreenEdit(...args); } +function _closeMobileFullscreenEdit(...args) { return mobileEditor._closeMobileFullscreenEdit(...args); } + +function _bindLongPressDrag(...args) { return mobileReorder._bindLongPressDrag(...args); } + +function _wireChecklistTouchReorder(...args) { return checklistReorder._wireChecklistTouchReorder(...args); } + +function _sortedVisibleNotes(...args) { return queries._sortedVisibleNotes(...args); } +function _userVisibleNotes(...args) { return queries._userVisibleNotes(...args); } +function _agentSolveNote(...args) { return agentActions._agentSolveNote(...args); } + +function _clearPastReminders(...args) { return deletion._clearPastReminders(...args); } +const text = createText({ + uiModule +}); +const colors = createColors({ + api +}); +const { COLORS, COLOR_HEX } = colors; +const windowLayout = createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}); +const toolbar = createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}); +const panel = createPanel({ + wireToolbar: toolbar.wireToolbar, + wireSelectionActions: () => selection.wireSelectionActions(), + _wireNotesWindow: windowLayout._wireNotesWindow, + _restoreNotesSidebarDock: windowLayout._restoreNotesSidebarDock, + _bringNotesToFront: windowLayout._bringNotesToFront, + disposeUI: () => { layout.dispose(); mobileReorder.dispose(); checklistReorder.dispose(); lifecycle.dispose(); }, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}); +const reminderDisplay = createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}); +const selectionOperations = createSelectionOperations({ + app, + api, + fetchNotes: _fetchNotes, + exitSelection: _exitSelectMode, + sendContext: detail => window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail })), + effects: { render: _renderNotes, styledConfirm: uiModule.styledConfirm, showToast: (...args) => uiModule.showToast(...args) } +}); +const selection = createSelection({ + operations: selectionOperations, + _renderNotes, + app, + state +}); +const filters = createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}); +const cardRendering = createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}); +const layout = createLayout({ + isOpen: () => state.open +}); +const quickAdd = createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}); +const cardOperations = createCardOperations({ + app, + api, + pushUndo: _pushUndo, + undoArchive: _undoArchive, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), showToast: (...args) => uiModule.showToast(...args), archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }) } +}); +const checklistOperations = createChecklistOperations({ + app, + api, + uid: _uid, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args) } +}); +const cardInteractions = createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}); +const cardChecklist = createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}); +const cardReorder = createCardReorder({ + persistOrder: cardOperations.persistOrder, + lifecycle, + _isNotesMobileMode, + state +}); +const draftEditor = createDraftEditor({ + bindDraft: (...args) => drafts.bindDraft(...args), + _collectItems, + _loadDraft +}); +const editor = createEditor({ + deleteFromEditor: id => deletion.deleteFromEditor(id), + submitNote: (...args) => submission.submitNote(...args), + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}); +const reminderPicker = createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}); +const checklist = createChecklist({ + _esc, + _uid, + spawnConfetti +}); +const drawing = createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}); +const clipboard = createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}); +const mobileEditor = createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}); +const mobileReorder = createMobileReorder({ + commitMobileOrder: cardOperations.commitMobileOrder, + lifecycle, + _applyMasonry, + _enterSelectMode, + app +}); +const checklistReorder = createChecklistReorder({ + lifecycle +}); +const queries = createQueries({ + _hasActiveReminder, + state +}); +const agentActions = createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData: () => new FormData(), + state, + streamComplete: sid => { try { window.sessionModule?.markStreamComplete?.(sid); } catch {} }, + uiModule +}); +const deletion = createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects: { styledConfirm: uiModule.styledConfirm, confirm: message => confirm(message), showToast: (...args) => uiModule.showToast(...args), showError: (...args) => uiModule.showError(...args) }, + state +}); +const notesModule = { openPanel: panel.openPanel, closePanel: panel.closePanel, togglePanel: panel.togglePanel, isPanelOpen: panel.isPanelOpen, openNote: panel.openNote, newNote: quickAdd.newNote, openNotes: panel.openPanel, closeNotes: panel.closePanel, isNotesOpen: panel.isPanelOpen, refreshDueBadge: reminders.refreshDueBadge }; +const publicOpenPanel = panel.openPanel, publicClosePanel = panel.closePanel, publicTogglePanel = panel.togglePanel, publicIsPanelOpen = panel.isPanelOpen, publicOpenNote = panel.openNote, publicNewNote = quickAdd.newNote, publicRefreshDueBadge = reminders.refreshDueBadge, publicDismissFiredReminderDot = reminders.dismissFiredReminderDot; +export { publicOpenPanel as openPanel, publicClosePanel as closePanel, publicTogglePanel as togglePanel, publicIsPanelOpen as isPanelOpen, publicOpenNote as openNote, publicNewNote as newNote, publicRefreshDueBadge as refreshDueBadge, publicDismissFiredReminderDot as dismissFiredReminderDot, publicOpenPanel as openNotes, publicClosePanel as closeNotes, publicIsPanelOpen as isNotesOpen }; +export function startNotesBackground() { reminders.start(); } +export default notesModule; +const submission = createNotesSubmission({ + app, + api, + persistence, + clock, + uid: _uid, + fetchNotes: _fetchNotes, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), clearGlow: id => _setReminderCardGlow(id, false) } +}); +const drafts = createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects: { showError: (...args) => uiModule.showError(...args) } +}); diff --git a/frontend/src/features/notes/infrastructure/http.js b/frontend/src/features/notes/infrastructure/http.js new file mode 100644 index 000000000..eeafd1b0f --- /dev/null +++ b/frontend/src/features/notes/infrastructure/http.js @@ -0,0 +1,29 @@ +/** Notes HTTP port. Response interpretation stays with the owning workflow. */ +export function createNotesApi({ origin, request }) { + const json = (method, body) => ({ method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + async function saveNote(note) { + const response = await request(note.id ? `${origin}/api/notes/${note.id}` : `${origin}/api/notes`, json(note.id ? 'PUT' : 'POST', note)); + if (!response.ok) throw new Error('Failed to save note'); + return await response.json(); + } + async function patchNote(id, patch) { + const response = await request(`${origin}/api/notes/${id}`, json('PUT', patch)); + if (!response.ok) throw new Error('Failed to update note'); + return await response.json(); + } + async function deleteNote(id) { + const response = await request(`${origin}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' }); + if (!response.ok) throw new Error('HTTP ' + response.status); + } + return { + saveNote, patchNote, deleteNote, + listNotes: archived => request(`${origin}/api/notes${archived ? '?archived=true' : ''}`, { credentials: 'same-origin' }), + reorder: ids => request(`${origin}/api/notes/reorder`, json('POST', { ids })), + upload: body => request(`${origin}/api/upload`, { method: 'POST', body, credentials: 'same-origin' }), + uploadUrl: id => `${origin}/api/upload/${id}`, + fireReminder: body => request('/api/notes/fire-reminder', json('POST', body)), + defaultChat: () => request(`${origin}/api/default-chat`, { credentials: 'same-origin' }), + createSession: body => request(`${origin}/api/session`, { method: 'POST', body, credentials: 'same-origin' }), + startChat: body => request(`${origin}/api/chat_stream`, { method: 'POST', body, credentials: 'same-origin' }), + }; +} diff --git a/frontend/src/features/notes/infrastructure/notifications.js b/frontend/src/features/notes/infrastructure/notifications.js new file mode 100644 index 000000000..cd98eb702 --- /dev/null +++ b/frontend/src/features/notes/infrastructure/notifications.js @@ -0,0 +1,20 @@ +/** Browser notification permission/display port, independent of Notes rendering. */ +export function createNotesNotifications({ browser, notify, openPanel, toast }) { + async function ensurePermission() { + if (!('Notification' in browser)) return false; + if (notify.permission === 'granted') return true; + if (notify.permission === 'denied') return false; + try { return await notify.requestPermission() === 'granted'; } + catch { return false; } + } + function show(title, body, noteId) { + if ('Notification' in browser && notify.permission === 'granted') { + try { + const notification = new notify(title, { body, tag: 'note-' + noteId, icon: '/static/favicon.ico' }); + notification.onclick = () => { browser.focus(); openPanel(); notification.close(); }; + } catch {} + } + toast(title); + } + return { ensurePermission, show }; +} diff --git a/frontend/src/features/notes/infrastructure/storage.js b/frontend/src/features/notes/infrastructure/storage.js new file mode 100644 index 000000000..06aaa01ab --- /dev/null +++ b/frontend/src/features/notes/infrastructure/storage.js @@ -0,0 +1,63 @@ +/** Browser persistence and unchanged Notes key/serialization contracts. */ +export const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired'; +export const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed'; +export const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight'; +export const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'; +export const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at'; +export const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1'; +export const NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'; +export const _DRAFT_PREFIX = 'odysseus-note-draft-'; + +export function createNotesPersistence({ storage: localStorage }) { +function _loadPendingHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _loadGlowedReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveGlowedReminders(set) { + try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {} +} + +function _loadActiveHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveActiveHighlights(set) { + try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {} +} + +function _savePendingHighlights(set) { + try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); } + catch {} +} + +function _loadFiredReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveFiredReminders(set) { + try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); } + catch {} +} + +function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); } + +function _loadDraft(id) { + try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; } +} + +function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} } + +return { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft, + getItem: key => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: key => localStorage.removeItem(key), +}; +} diff --git a/frontend/src/features/notes/ui/cardChecklist.js b/frontend/src/features/notes/ui/cardChecklist.js new file mode 100644 index 000000000..6bbf39326 --- /dev/null +++ b/frontend/src/features/notes/ui/cardChecklist.js @@ -0,0 +1,148 @@ +/** Card checklist editing and Today-step interactions. */ +export function createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}) { + function _wireCardChecklist(body) { + // Remove a single checklist item (hover X) + body.querySelectorAll('.note-checkbox-rm').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + checklistOperations.removeItem(noteId, idx); + }); + }); + + function _startChecklistItemEdit(noteId, idx, span) { + if (span.isContentEditable) return; + const note = state.notes.find(n => n.id === noteId); + if (!note || !Array.isArray(note.items) || !note.items[idx]) return; + + span.textContent = note.items[idx].text || ''; + span.contentEditable = "true"; + span.spellcheck = false; + span.focus(); + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(span); + selection.removeAllRanges(); + selection.addRange(range); + + const save = () => { + if (!span.isContentEditable) return; + span.contentEditable = "false"; + const newText = span.textContent.trim(); + checklistOperations.editItem(note, idx, newText); + }; + + const onKeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + save(); + } else if (e.key === 'Escape') { + e.preventDefault(); + span.contentEditable = "false"; + _renderNotes(); + } + }; + + span.addEventListener('blur', save, { once: true }); + span.addEventListener('keydown', onKeydown); + } + + // Edit a single checklist item (hover Edit button) + body.querySelectorAll('.note-checkbox-edit').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + const span = btn.parentElement.querySelector('.note-check-text'); + if (span) _startChecklistItemEdit(noteId, idx, span); + }); + }); + + // Prevent clicks from toggling the row while actively editing inline + body.querySelectorAll('.note-check-text').forEach(span => { + span.addEventListener('click', (e) => { + if (span.isContentEditable) { + e.stopPropagation(); + } + }); + }); + + // Quick-add new checklist item (hover input at bottom of todo cards) + body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('keydown', async (e) => { + e.stopPropagation(); + if (e.key !== 'Enter') return; + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + const noteId = input.dataset.noteId; + const persist = checklistOperations.addItem(noteId, text, () => { input.value = ''; }); + if (!persist) return; + // Refocus the input on the same card + setTimeout(() => { + const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`); + if (next) next.focus(); + }, 0); + persist(); + }); + }); + + // Checkboxes (dot toggle, optimistic) : disabled in select mode + body.querySelectorAll('.note-checkbox').forEach(el => { + el.addEventListener('click', (e) => { + if (state.selectMode) return; // let card-level handler take over + e.preventDefault(); + e.stopPropagation(); + const noteId = el.dataset.noteId; + const idx = parseInt(el.dataset.idx); + checklistOperations.toggleItem(noteId, idx, done => el.classList.toggle('done', done), () => { + const card = el.closest('.note-card'); + if (card) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); } + }); + }); + }); + + + } + + function _wireTodayView(body) { + body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => { + dot.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = dot.dataset.noteId; + const idx = parseInt(dot.dataset.idx); + const row = dot.closest('.notes-today-row'); + await checklistOperations.toggleTodayStep(id, idx, () => row?.classList.add('done'), () => { + const r = (row || dot).getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); + }); + }); + }); + body.querySelectorAll('.notes-today-title').forEach(el => { + el.addEventListener('click', () => { + const id = el.dataset.noteId; + if (!id) return; + // Drop the Today filter first so the regular card list is rendered; + // _editNote needs to find a .note-card in the DOM to replace with + // the editor form. + app.setActiveFilter(null); + _renderNotes(); + _editNote(id); + }); + }); + } + + return { _wireCardChecklist, _wireTodayView }; +} diff --git a/frontend/src/features/notes/ui/cardInteractions.js b/frontend/src/features/notes/ui/cardInteractions.js new file mode 100644 index 000000000..09e083c41 --- /dev/null +++ b/frontend/src/features/notes/ui/cardInteractions.js @@ -0,0 +1,337 @@ +/** Card taps, pin/color controls, menus and archive animation. */ +export function createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}) { + function _bindCardEvents(body) { + // Skip completed leading rows so the first unfinished checklist item is + // visible when a note preview has its own scroll area. + requestAnimationFrame(() => { + body.querySelectorAll('.note-checklist-preview').forEach(preview => { + const firstPending = preview.querySelector('.note-checkbox:not(.done)'); + if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4); + }); + }); + const tapToEditOrSelect = (cardEl) => { + const id = cardEl.dataset.noteId; + // A long-press enters select mode and re-renders the card. Ignore the + // synthetic click that mobile browsers emit when that same touch ends. + if (state.suppressNextNoteTapId === id) { + app.setSuppressNextNoteTapId(null); + return; + } + if (state.selectMode) { + const cb = cardEl.querySelector('.note-card-cb'); + if (cb) { + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + } + } else if (_isNotesMobileMode()) { + // Mobile: open the per-note fullscreen edit overlay instead of the + // in-place form. Tiles on mobile are read-only previews. + _openMobileFullscreenEdit(id, cardEl); + } else { + _editNote(id); + } + }; + // Mobile: long-press anywhere on a note card → enter Select mode and + // select that card. Cancelled by movement (so it doesn't interfere with + // vertical scrolling) or by lifting the finger before the timer fires. + if (_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card)); + } + body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => { + card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true); + }); + // Click title : edit, or toggle select in select mode + body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + _smoothScrollToNoteCard(el.closest('.note-card')); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click content : edit, or toggle select in select mode + body.querySelectorAll('.note-content-preview').forEach(el => { + el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); + }); + // A saved drawing is the note's primary content. Open its editor directly + // when the preview is clicked, matching title/content preview behavior. + body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click empty area of checklist preview (not on checkbox/X) : edit + body.querySelectorAll('.note-checklist-preview').forEach(el => { + el.addEventListener('wheel', (e) => { + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + const pane = el.closest('.notes-pane-body'); + if (pane) { + pane.scrollTop += e.deltaY; + e.preventDefault(); + } + } + }, { passive: false }); + el.addEventListener('click', (e) => { + if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Clicking todo item text now toggles its checkbox : let the click bubble + // up to the parent .note-checkbox row handler. To open the editor, the + // user clicks the pencil corner. + // (No-op block kept as a marker : removing the listener entirely means + // clicks naturally bubble to the row toggle below.) + // In select mode, clicking anywhere on the card toggles selection + if (state.selectMode) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Mobile, non-select: tapping anywhere on the card body (not on an + // interactive child : buttons, pin, checkbox, color dot, reminder pill, + // agent tag, links) opens the fullscreen editor. Previously only the + // title / content preview triggered edit, so padding + empty gutters were + // dead zones that felt broken on mobile. + if (_isNotesMobileMode() && !state.selectMode) { + const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest(_INTERACTIVE)) return; + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Multi-select checkbox (only in select mode) + body.querySelectorAll('.note-card-cb').forEach(cb => { + cb.addEventListener('click', (e) => e.stopPropagation()); + cb.addEventListener('change', () => { + const id = cb.dataset.noteId; + if (cb.checked) app.selectId(id); + else app.deselectId(id); + cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked); + _updateBulkBar(); + }); + }); + // Pin toggle (optimistic) + body.querySelectorAll('.note-card-pin').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.togglePin(id); + }); + }); + // Color picker + const _applyCardColor = async (card, id, newColor) => { + const isBg = _isBgImage(newColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); }); + if (newColor && !isBg) card.classList.add('note-color-' + newColor); + if (isBg) card.setAttribute('style', _customColorStyle(newColor)); + else card.removeAttribute('style'); + card.querySelectorAll('.note-card-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor)); + d.style.background = _dotBg(d.dataset.color, newColor); + }); + await cardOperations.updateColor(id, newColor); + }; + body.querySelectorAll('.note-card-color-dot').forEach(dot => { + dot.addEventListener('click', (e) => { + e.stopPropagation(); + const card = dot.closest('.note-card'); + const id = card.dataset.noteId; + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); }); + return; + } + _applyCardColor(card, id, dot.dataset.color); + }); + }); + // Plain pencil corner → open editor. The unarchive corner shares the + // .note-card-edit-corner class for styling, so :not() keeps the edit + // handler off it. + body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (id) _editNote(id); + }); + }); + // Copy corner : bottom-right, just left of the Done check. Shared with + // the Ctrl/Cmd+C shortcut wired further down so both code paths run the + // same serializer + feedback flash. + // ⋯ corner menu : Copy + Agent (solve-this-todo). + body.querySelectorAll('.note-card-corner-menu').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _openNoteCornerMenu(btn); + }); + }); + body.querySelectorAll('.note-card-label-chip').forEach(chip => { + chip.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const label = chip.dataset.noteLabelFilter; + if (!label) return; + if (state.activeLabel === label && state.activeFilter === null) { + app.setActiveLabel(null); + } else { + app.setActiveFilter(null); + app.setActiveLabel(label); + } + _renderNotes(); + }); + }); + // Done (✓) at bottom-right : only visible on hover for active notes. + body.querySelectorAll('.note-card-done').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + const card = btn.closest('.note-card'); + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return; + // Celebrate completion : same confetti shower the bulk-archive uses. + if (card) { + const r = card.getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); + } + const finish = cardOperations.stageArchive(id); + if (!finish) return; + if (card) { + card.classList.add('note-card-sliding-out'); + let done = false; + const once = () => { if (done) return; done = true; finish(); }; + card.addEventListener('transitionend', once, { once: true }); + setTimeout(once, 400); + } else { + finish(); + } + }); + }); + // Unarchive corner : only visible in archive view. + body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Trash corner : archive view only. Permanent delete, no confirmation. + body.querySelectorAll('.note-card-corner-trash').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id, true); + }); + }); + + body.querySelectorAll('.note-card-archive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (!id) return; + const card = btn.closest('.note-card'); + let done = false; + const finishRemove = () => { + if (done) return; + done = true; + _archiveNoteById(id, { card, celebrate: true }); + }; + if (card) { + card.classList.add('note-card-sliding-out'); + card.addEventListener('transitionend', finishRemove, { once: true }); + setTimeout(finishRemove, 400); + } else { + finishRemove(); + } + }); + }); + // Unarchive (optimistic) : only present in archive view + body.querySelectorAll('.note-card-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Delete (optimistic) + body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id); + }); + }); + // Copy entire checklist (title + items, markdown-style) + body.querySelectorAll('.note-card-copy').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (lines.length) lines.push(''); + for (const it of (note.items || [])) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + const text = lines.join('\n').trim(); + try { + await navigator.clipboard.writeText(text); + uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`); + } catch { + // Fallback for browsers blocking the async API + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); uiModule.showToast?.('Copied'); } + catch { uiModule.showError?.('Copy failed'); } + ta.remove(); + } + }); + }); + + _wireCardChecklist(body); + _wireCardReorder(body); + + } + + return { _bindCardEvents }; +} diff --git a/frontend/src/features/notes/ui/cardRendering.js b/frontend/src/features/notes/ui/cardRendering.js new file mode 100644 index 000000000..315e47218 --- /dev/null +++ b/frontend/src/features/notes/ui/cardRendering.js @@ -0,0 +1,244 @@ +/** Render note cards from application queries and read-only state. */ +import { _nextGoalStep, _goalProgress, _hasItems, _standardVisualVariant, _visibleNoteTags } from '../domain/noteRules.js'; +import { _hasTimeComponent, _formatRepeatLabel } from '../domain/recurrence.js'; +export function createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}) { + function _renderLoadingSkeleton() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + const skel = document.createElement('div'); + skel.className = 'notes-skeleton'; + skel.innerHTML = ` +
+
+
+
+ `; + body.appendChild(skel); + } + + function _renderNotes() { + _updateRailBadge(); + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + const prevPositions = _captureCardPositions(); + const activeReminderHighlights = _loadActiveHighlights(); + + const sorted = _sortedVisibleNotes(); + + let html = ''; + // Today view: render a compact card listing the next-unchecked step from + // each active goal. Tapping a step toggles it done (same idx-based wiring + // as regular checkboxes). Tapping the title opens the goal note for full + // editing. + if (state.activeFilter === 'today') { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', `
All caught up — no pending goal steps right now.
`); + } else { + let todayHtml = `
+
+ + Today · one step per goal +
+
`; + for (const note of sorted) { + const next = _nextGoalStep(note); + if (!next) continue; + const progress = _goalProgress(note).trim(); + todayHtml += `
+ +
+
${_esc(note.title || '(untitled goal)')}
+
${_linkify(next.item.text || '')}
+
+ ${_esc(progress)} +
`; + } + todayHtml += `
`; + body.insertAdjacentHTML('beforeend', todayHtml); + } + _wireTodayView(body); + return; + } + for (const note of sorted) { + if (state.editingId === note.id) continue; // skip : form is shown instead + const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)'; + const dueFmt = _formatDueDate(note.due_date); + const overdue = _isDueOverdue(note.due_date); + + let contentHtml = ''; + if (_hasItems(note) && Array.isArray(note.items)) { + // Goal notes can carry a free-form description above the step list : + // todos rarely do, but the same render works for both. + if (note.note_type === 'goal' && (note.content || '').trim()) { + const fullText = note.content || ''; + const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText; + contentHtml += `
${_esc(preview)}
`; + } + contentHtml += '
'; + // Show ALL items : the preview container is scrollable (CSS caps + // its max-height + overflow-y:auto), so there's no need to truncate. + for (let i = 0; i < note.items.length; i++) { + const item = note.items[i]; + const doneClass = item.done ? ' done' : ''; + const indent = Math.min(item.indent || 0, 3); + contentHtml += `
+ + ${_linkify(item.text)} + + +
`; + } + contentHtml += '
'; + } else { + const fullText = note.content || ''; + const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText; + // _linkify already calls _esc internally, so URLs become clickable + // anchors (used by e.g. the "remind me to reply" email deep-link). + contentHtml = preview ? `
${_linkify(preview)}
` : ''; + } + + const isBg = _isBgImage(note.color); + const cc = (note.color && !isBg) ? ' note-color-' + note.color : ''; + const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : ''; + const isStandard = !note.note_type || note.note_type === 'note'; + const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : ''; + const sel = state.selectedIds.has(note.id) ? ' note-card-selected' : ''; + const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date) + ? `
+ + ${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''} +
` + : ''; + const noteTags = _visibleNoteTags(note); + const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `${dueFmt}` : ''; + const colorDots = COLORS.map(c => ``).join(''); + const goalClass = note.note_type === 'goal' ? ' note-card-goal' : ''; + const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : ''; + const goalPill = note.note_type === 'goal' + ? ` + + Goal${_goalProgress(note)} + ` + : ''; + const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : ''; + html += `
+ ${state.selectMode ? `` : ''} + ${goalPill} + + ${state.showingArchived + ? ` + ` + : ` + ${_hasItems(note) ? `` : ''}`} +
+
${_esc(note.title || '')}
+ ${dueBadge} +
+ ${_safeImgSrc(note.image_url) ? `` : ''} + ${contentHtml} + ${_hasItems(note) ? `
` : ''} + ${reminderTagHtml} + ${noteTags.length ? `
${noteTags.map(t => ``).join(' ')}
` : ''} +
+
${colorDots}
+ + ${state.showingArchived ? ` + + ` : ` + ${_hasItems(note) ? ` + ` : ''} + + + `} +
+
`; + } + + // Always render quick-add at top (collapsed unless user is typing) + const existingForm = body.querySelector('.note-form'); + if (existingForm && state.editingId === '__new__') { + // Keep the expanded form, replace cards after it + const next = [...body.children].filter(c => c !== existingForm); + next.forEach(c => c.remove()); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + existingForm.insertAdjacentHTML('afterend', html); + } + } else { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes yet ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + body.insertAdjacentHTML('beforeend', html); + } + } + + _bindCardEvents(body); + _animateReflow(prevPositions); + _applyMasonry(body); + } + + return { _renderLoadingSkeleton, _renderNotes }; +} diff --git a/frontend/src/features/notes/ui/cardReorder.js b/frontend/src/features/notes/ui/cardReorder.js new file mode 100644 index 000000000..dd32d517c --- /dev/null +++ b/frontend/src/features/notes/ui/cardReorder.js @@ -0,0 +1,181 @@ +/** Desktop and large-touch-device card reorder gestures. */ +export function createCardReorder({ + lifecycle, + _isNotesMobileMode, + persistOrder, + state +}) { + function _wireCardReorder(body) { + lifecycle.release(body); + // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom + // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is + // unreliable on touch browsers and can compete with the long-press flow. + if (!_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('dragstart', (e) => { + if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) { + e.preventDefault(); + return; + } + card.classList.add('dragging'); + body.classList.add('drag-active'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {} + }); + card.addEventListener('dragend', async () => { + card.classList.remove('dragging'); + body.classList.remove('drag-active'); + body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + try { await persistOrder(ids); } + catch {} + }); + }); + } + // Track which card we last swapped with so a single hover-over triggers + // one swap, not a jitter as the pointer keeps moving inside the same card. + let _lastSwapId = null; + function _maybeSwap(dragging, clientX, clientY) { + const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card'); + if (!target || target === dragging || !body.contains(target)) return; + const id = target.dataset.noteId; + if (id === _lastSwapId) return; + // FLIP across ALL siblings. In list view only `target` moves visually, but + // in grid view (2-col) and when the pinned-section grid-column-start rule + // shifts, several cards reflow at once. Capture every card's pre-swap rect, + // do the DOM swap, then animate any that actually moved. The dragging card + // is excluded : it's already finger-tracked via translate3d. + const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging); + const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()])); + const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling; + body.insertBefore(dragging, target); + body.insertBefore(target, draggingNext); + for (const c of cards) { + const prev = prevRects.get(c); + const next = c.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue; + c.style.transition = 'none'; + c.style.transform = `translate(${dx}px, ${dy}px)`; + requestAnimationFrame(() => { + c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)'; + c.style.transform = ''; + c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true }); + }); + } + _lastSwapId = id; + } + lifecycle.listen(body, body, 'dragover', (e) => { + e.preventDefault(); + const dragging = body.querySelector('.note-card.dragging'); + if (!dragging) return; + _maybeSwap(dragging, e.clientX, e.clientY); + }); + lifecycle.listen(body, body, 'dragend', () => { _lastSwapId = null; }); + + // Legacy touch drag for larger touch devices only. Phone-sized Notes uses + // the placeholder sorter wired by `_bindLongPressDrag`; running both flows + // makes one press start two independent drag sessions. + if (!_isNotesMobileMode() && 'ontouchstart' in window) { + let dragCard = null; + let isDragging = false; + let longPressTimer = null; + let startX = 0, startY = 0; + const LONG_PRESS_MS = 350; + const MOVE_THRESHOLD_PX = 8; + const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a'; + + // Anchor for the finger-follow transform. Recomputed after every swap so + // the card stays under the finger across reorderings. + let anchorX = 0, anchorY = 0; + const _follow = (clientX, clientY) => { + if (!dragCard) return; + const dx = clientX - anchorX; + const dy = clientY - anchorY; + // Compose with the CSS .dragging transform (scale + rotate). + dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`; + }; + const _reanchor = (clientX, clientY) => { + if (!dragCard) return; + // Clear any prior translate so the card resettles in its natural slot, + // then anchor at the finger's current position. Subsequent _follow calls + // translate by (finger - anchor), keeping the finger at the same relative + // spot on the card it had when we re-anchored. + dragCard.style.transform = ''; + anchorX = clientX; + anchorY = clientY; + }; + + const _endDrag = (committed) => { + clearTimeout(longPressTimer); + longPressTimer = null; + if (dragCard) { + dragCard.classList.remove('dragging'); + dragCard.style.transform = ''; + } + body.classList.remove('drag-active'); + _lastSwapId = null; + if (isDragging) { + document.documentElement.style.touchAction = ''; + if (committed) { + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + persistOrder(ids).catch(() => {}); + } + } + dragCard = null; + isDragging = false; + }; + lifecycle.onDispose(body, () => _endDrag(false)); + + lifecycle.listen(body, body, 'touchstart', (e) => { + if (state.selectMode) return; + const card = e.target.closest('.note-card'); + if (!card) return; + if (e.target.closest(_selectorSkip)) return; + const t = e.touches[0]; + startX = t.clientX; startY = t.clientY; + dragCard = card; + longPressTimer = setTimeout(() => { + if (!dragCard) return; + isDragging = true; + dragCard.classList.add('dragging'); + body.classList.add('drag-active'); + document.documentElement.style.touchAction = 'none'; + _reanchor(startX, startY); + try { if (navigator.vibrate) navigator.vibrate(15); } catch {} + }, LONG_PRESS_MS); + }, { passive: true }); + + lifecycle.listen(body, body, 'touchmove', (e) => { + if (!dragCard) return; + const t = e.touches[0]; + if (!isDragging) { + // Movement before long-press fires = user is scrolling; cancel pickup. + if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) { + clearTimeout(longPressTimer); + longPressTimer = null; + dragCard = null; + } + return; + } + e.preventDefault(); + // Live-follow the finger first, then check for a swap. After a swap the + // card's natural slot moves, so we re-anchor and re-apply the offset. + _follow(t.clientX, t.clientY); + const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + _maybeSwap(dragCard, t.clientX, t.clientY); + const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + if (before !== after) { + _reanchor(t.clientX, t.clientY); + _follow(t.clientX, t.clientY); + } + }, { passive: false }); + + lifecycle.listen(body, body, 'touchend', () => _endDrag(true)); + lifecycle.listen(body, body, 'touchcancel', () => _endDrag(false)); + } + } + + return { _wireCardReorder }; +} diff --git a/frontend/src/features/notes/ui/checklist.js b/frontend/src/features/notes/ui/checklist.js new file mode 100644 index 000000000..59a01aaa4 --- /dev/null +++ b/frontend/src/features/notes/ui/checklist.js @@ -0,0 +1,190 @@ +/** Checklist rows, goal forms, keyboard input and item collection. */ +export function createChecklist({ + _esc, + _uid, + spawnConfetti +}) { + function _buildGoalHtml(note, items) { + const desc = (note?.content || '').toString(); + return ` +
+ + ${_buildChecklistHtml(items)} +
+ `; + } + + function _wireGoalForm(form, container) { + if (!container) return; + // _wireHashtag is a closure inside _buildForm : out of scope here. Inline + // the same behavior (type "#foo " in the description → tag added to the + // form's label input) so editing a goal note doesn't ReferenceError. + const desc = container.querySelector('.note-form-goal-desc'); + const labelInput = form?.querySelector('.note-form-label'); + if (desc && labelInput) { + const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + desc.addEventListener('input', () => { + const m = tagRe.exec(desc.value); + if (!m) return; + const tag = m[2]; + // Same dedup-after-stripping fix as the plain note hashtag handler. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = desc.value.length - m[0].length + m[1].length; + desc.value = desc.value.slice(0, cut); + }); + } + // Always wire the checklist. The previous gate on a `note-form-goal-fresh` + // class was dead code : nothing ever set that class, so the editor never + // hooked up add/drag/Tab. + _wireChecklist(container); + } + + function _buildChecklistHtml(items) { + let html = '
'; + for (const item of items) { + const indent = Math.min(item.indent || 0, 3); + html += `
+ ⋮⋮ + + + +
`; + } + // `type="button"` matters on mobile : without it some browsers treat + // bare
`; + return html; + } + + function _wireRow(row, container) { + row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove()); + row.querySelector('.note-cl-dot')?.addEventListener('click', () => { + const wasDone = row.classList.contains('done'); + row.classList.toggle('done'); + // The custom dot has no native input/change event. Notify the form so + // mobile edit mode switches its action from Archive back to Update. + container.dispatchEvent(new Event('input', { bubbles: true })); + const becameDone = !wasDone; // we just flipped it + const dot = row.querySelector('.note-cl-dot'); + const dRect = (dot || row).getBoundingClientRect(); + // Small confetti burst on each fresh check so the user gets a + // "well done" beat per item, not just the grand-finale on all-done. + if (becameDone) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16); + } + // Bigger burst when the whole list is now done. + const rows = [...container.querySelectorAll('.note-cl-row')]; + const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0); + if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60); + } + }); + const txt = row.querySelector('.note-cl-text'); + txt?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); } + else if (e.key === 'Tab') { + e.preventDefault(); + const cur = parseInt(row.dataset.indent || '0'); + const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1); + row.dataset.indent = String(next); + row.style.paddingLeft = (next * 16) + 'px'; + } else if (e.key === 'Backspace' && txt.value === '') { + e.preventDefault(); + const prev = row.previousElementSibling; + row.remove(); + if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus(); + } + }); + // Drag handlers + row.addEventListener('dragstart', (e) => { + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {} + }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + }); + } + + function _wireChecklist(container) { + if (!container) return; + // Delegate the + Add click off the container so re-renders + mobile + // touch quirks don't leave the button dead. The previous direct + // `addEventListener` on the button silently broke on mobile when + // _wireChecklist ran more than once (or before the button was in DOM). + if (!container._addDelegated) { + container._addDelegated = true; + container.addEventListener('click', (ev) => { + const addBtn = ev.target.closest('.note-cl-add'); + if (!addBtn || !container.contains(addBtn)) return; + ev.preventDefault(); + ev.stopPropagation(); + const inputs = container.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const row = document.createElement('div'); + row.className = 'note-cl-row'; + row.draggable = true; + row.dataset.itemId = _uid(); + row.dataset.indent = '0'; + row.innerHTML = `⋮⋮`; + inputs.insertBefore(row, addBtn); + row.querySelector('.note-cl-text').focus(); + _wireRow(row, container); + }); + } + container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container)); + + // Drag-over handler on the inputs container + const inputs = container.querySelector('.note-checklist-inputs'); + if (inputs) { + inputs.addEventListener('dragover', (e) => { + e.preventDefault(); + const dragging = inputs.querySelector('.note-cl-row.dragging'); + if (!dragging) return; + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')]; + const after = rows.find(r => { + const box = r.getBoundingClientRect(); + return e.clientY < box.top + box.height / 2; + }); + if (after) { + after.classList.add('drop-before'); + inputs.insertBefore(dragging, after); + } else if (rows.length) { + rows[rows.length - 1].classList.add('drop-after'); + inputs.insertBefore(dragging, container.querySelector('.note-cl-add')); + } + }); + inputs.addEventListener('dragleave', (e) => { + if (!inputs.contains(e.relatedTarget)) { + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + } + }); + } + } + + function _collectItems(form) { + const items = []; + form.querySelectorAll('.note-cl-row').forEach(row => { + const text = row.querySelector('.note-cl-text')?.value?.trim(); + if (text) items.push({ + id: row.dataset.itemId || _uid(), + text, + done: row.classList.contains('done'), + indent: parseInt(row.dataset.indent || '0'), + }); + }); + return items; + } + + return { _buildGoalHtml, _wireGoalForm, _buildChecklistHtml, _wireRow, _wireChecklist, _collectItems }; +} diff --git a/frontend/src/features/notes/ui/checklistReorder.js b/frontend/src/features/notes/ui/checklistReorder.js new file mode 100644 index 000000000..c5c7e1101 --- /dev/null +++ b/frontend/src/features/notes/ui/checklistReorder.js @@ -0,0 +1,111 @@ +/** Mobile checklist grip dragging and document listener ownership. */ +export function createChecklistReorder({ + lifecycle +}) { + let _clDrag = null; + + let _clDocBound = false; + let _clOwner = null; + + function _wireChecklistTouchReorder(form) { + const container = form.querySelector('.note-checklist-inputs'); + if (!container) return; + container.querySelectorAll('.note-cl-grip').forEach(grip => { + if (grip.dataset.touchBound === '1') return; + grip.dataset.touchBound = '1'; + grip.addEventListener('touchstart', (e) => { + if (e.touches.length !== 1) return; + e.preventDefault(); + const row = grip.closest('.note-cl-row'); + if (!row) return; + _beginChecklistGrab(row, container, e.touches[0]); + }, { passive: false }); + }); + if (!_clDocBound || _clOwner !== form) { + if (_clOwner) lifecycle.release(_clOwner); + _clOwner = form; + lifecycle.listen(form, document, 'touchmove', _onClTouchMove, { passive: false }); + lifecycle.listen(form, document, 'touchend', _onClTouchEnd, { passive: true }); + lifecycle.listen(form, document, 'touchcancel', _onClTouchEnd, { passive: true }); + _clDocBound = true; + } + } + + function _beginChecklistGrab(row, container, touch) { + if (_clDrag) return; + const rect = row.getBoundingClientRect(); + const placeholder = document.createElement('div'); + placeholder.className = 'note-cl-row-placeholder'; + placeholder.style.height = rect.height + 'px'; + container.insertBefore(placeholder, row); + + row.classList.add('note-cl-row-dragging'); + row.style.position = 'fixed'; + row.style.left = rect.left + 'px'; + row.style.top = rect.top + 'px'; + row.style.width = rect.width + 'px'; + row.style.zIndex = '10002'; + row.style.pointerEvents = 'none'; + + _clDrag = { + row, placeholder, container, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onClTouchMove(e) { + if (!_clDrag) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const t = e.touches[0]; + const { row, placeholder, container } = _clDrag; + row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px'; + row.style.top = (t.clientY - _clDrag.grabOffsetY) + 'px'; + + const under = document.elementFromPoint(t.clientX, t.clientY); + const target = under && under.closest + ? under.closest('.note-cl-row:not(.note-cl-row-dragging)') + : null; + if (!target || target === row) return; + if (target.parentNode !== container) return; + + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (t.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + container.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + container.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onClTouchEnd() { + if (!_clDrag) return; + const { row, placeholder } = _clDrag; + _clDrag = null; + const phRect = placeholder.getBoundingClientRect(); + row.style.transition = 'left 0.18s ease, top 0.18s ease'; + row.style.left = phRect.left + 'px'; + row.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(row, placeholder); + placeholder.remove(); + row.classList.remove('note-cl-row-dragging'); + row.style.cssText = ''; + // Order is persisted as part of the form's normal save (rows are + // re-serialized in DOM order via _collectItems). + }, 200); + } + + function dispose() { + if (_clDrag) _onClTouchEnd(); + _clDocBound = false; + _clOwner = null; + } + return { dispose, _wireChecklistTouchReorder, _beginChecklistGrab, _onClTouchMove, _onClTouchEnd }; +} diff --git a/frontend/src/features/notes/ui/clipboard.js b/frontend/src/features/notes/ui/clipboard.js new file mode 100644 index 000000000..1dab84258 --- /dev/null +++ b/frontend/src/features/notes/ui/clipboard.js @@ -0,0 +1,82 @@ +/** Note clipboard feedback and the Copy/Agent corner menu. */ +import { _serializeNoteForCopy } from '../domain/noteRules.js'; +export function createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}) { + function _openNoteCornerMenu(btn) { + document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const menu = document.createElement('div'); + menu.className = 'note-corner-menu-dropdown'; + menu.innerHTML = ` + + `; + document.body.appendChild(menu); + const r = btn.getBoundingClientRect(); + // Right-align to the ⋯ button, clamped to the viewport. + const mw = 168; + let left = Math.min(r.right - mw, window.innerWidth - mw - 8); + left = Math.max(8, left); + // Drop down by default; flip up if there isn't room below (the button + // sits at the card's bottom edge now). + const mh = menu.offsetHeight || 96; + const below = window.innerHeight - r.bottom; + const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); + menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`; + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); + menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); + } + + async function _copyNote(noteId, btnEl) { + const note = state.notes.find(n => n.id === noteId); + if (!note) return false; + const text = _serializeNoteForCopy(note); + if (!text) return false; + let ok = false; + try { + await navigator.clipboard.writeText(text); + ok = true; + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { ok = document.execCommand('copy'); } catch { ok = false; } + ta.remove(); + } + if (ok) { + if (btnEl && !btnEl._copyFlashing) { + const original = btnEl.innerHTML; + btnEl._copyFlashing = true; + btnEl.innerHTML = ''; + btnEl.classList.add('copied'); + setTimeout(() => { + btnEl.innerHTML = original; + btnEl.classList.remove('copied'); + btnEl._copyFlashing = false; + }, 1200); + } + uiModule.showToast?.('Copied'); + } else { + uiModule.showError?.('Copy failed'); + } + return ok; + } + + return { _openNoteCornerMenu, _copyNote }; +} diff --git a/frontend/src/features/notes/ui/colors.js b/frontend/src/features/notes/ui/colors.js new file mode 100644 index 000000000..0a9503cfb --- /dev/null +++ b/frontend/src/features/notes/ui/colors.js @@ -0,0 +1,111 @@ +/** Note colors, custom background selection and form color controls. */ +export function createColors({ + api +}) { + const COLORS = [ + { name: 'none', value: '' }, + { name: 'red', value: 'red' }, + { name: 'orange', value: 'orange' }, + { name: 'yellow', value: 'yellow' }, + { name: 'green', value: 'green' }, + { name: 'blue', value: 'blue' }, + { name: 'purple', value: 'purple' }, + { name: 'custom', value: 'custom' }, // sentinel : clicking opens native color picker + ]; + + const COLOR_HEX = { + '': 'var(--border)', + // Pale/pastel palette : matches the calendar event color picker. + red: '#f0b5ba', + orange: '#e8ccb2', + yellow: '#f2dfbd', + green: '#cce0bc', + blue: '#b0d7f7', + purple: '#e2bcee', + }; + + const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; + + function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); } + + function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; } + + function _dotBg(value, noteColor) { + if (value === 'custom') { + const url = _bgImageUrl(noteColor); + return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT; + } + return COLOR_HEX[value]; + } + + function _dotIsActive(value, noteColor) { + if (value === 'custom') return _isBgImage(noteColor); + return value === (noteColor || ''); + } + + function _customColorStyle(c) { + if (!_isBgImage(c)) return ''; + const url = _bgImageUrl(c); + return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`; + } + + function _pickCustomBgImage() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; + document.body.appendChild(input); + let done = false; + const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + finish(api.uploadUrl(fileId)); + } catch { finish(null); } + }); + // Best-effort cleanup if user dismisses the dialog. + setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); + input.click(); + }); + } + + function _wireFormColors(form, setColor) { + // Color dots : apply to entire form immediately + const _applyFormColor = (newColor) => { + const currentColor = newColor || ''; + setColor(currentColor); + form.dataset.noteColor = currentColor; + const isBg = _isBgImage(currentColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); }); + if (currentColor && !isBg) form.classList.add('note-color-' + currentColor); + if (isBg) form.setAttribute('style', _customColorStyle(currentColor)); + else form.removeAttribute('style'); + form.querySelectorAll('.note-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor)); + d.style.background = _dotBg(d.dataset.color, currentColor); + }); + form.dispatchEvent(new Event('change', { bubbles: true })); + }; + form.querySelectorAll('.note-color-dot').forEach(dot => { + dot.addEventListener('click', () => { + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); }); + return; + } + _applyFormColor(dot.dataset.color); + }); + }); + + + } + + return { COLORS, COLOR_HEX, _isBgImage, _bgImageUrl, _dotBg, _dotIsActive, _customColorStyle, _pickCustomBgImage, _wireFormColors }; +} diff --git a/frontend/src/features/notes/ui/draftEditor.js b/frontend/src/features/notes/ui/draftEditor.js new file mode 100644 index 000000000..09df33b79 --- /dev/null +++ b/frontend/src/features/notes/ui/draftEditor.js @@ -0,0 +1,67 @@ +/** Collect editor drafts, restore them and wire the autosave workflow. */ +export function createDraftEditor({ + bindDraft, + _collectItems, + _loadDraft +}) { + function _collectFormDraft(form) { + if (!form) return null; + const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || form.dataset.noteType || 'note'; + const d = { + _ts: Date.now(), + note_type: type, + color: form.dataset.noteColor || '', + title: form.querySelector('.note-form-title')?.value || '', + label: form.querySelector('.note-form-label')?.value || '', + due_date: form.querySelector('.note-form-due')?.value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + }; + if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || ''; + else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); } + else d.items = _collectItems(form); + return d; + } + + function _isDraftEmpty(d) { + if (!d) return true; + if ((d.title || '').trim()) return false; + if ((d.content || '').trim()) return false; + if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false; + return true; + } + + function _wireDraftAutosave(form, id) { + const view = { + get failed() { return form._autosaveFailed; }, + set failed(value) { form._autosaveFailed = value; }, + }; + const draft = bindDraft({ collect: () => _collectFormDraft(form), id, view }); + form._flushDraft = draft.flush; + form._stopDraftAutosave = draft.stop; + form.addEventListener('input', draft.schedule); + form.addEventListener('change', draft.schedule); + form.addEventListener('click', event => { + if (event.target.closest('.note-cl-dot, .note-cl-rm')) draft.schedule(); + }); + } + + function _commitOpenInPlaceEditor() { + const form = document.querySelector('#notes-pane .note-form'); + if (!form) return; + const d = _collectFormDraft(form); + if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; } + form.querySelector('.note-form-save')?.click(); + } + + function _applyDraftToNote(note, id) { + const d = _loadDraft(id); + if (!d) return { note, restored: false }; + const merged = { ...(note || {}) }; + ['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => { + if (d[k] !== undefined) merged[k] = d[k]; + }); + return { note: merged, restored: true }; + } + + return { _collectFormDraft, _isDraftEmpty, _wireDraftAutosave, _commitOpenInPlaceEditor, _applyDraftToNote }; +} diff --git a/frontend/src/features/notes/ui/drawing.js b/frontend/src/features/notes/ui/drawing.js new file mode 100644 index 000000000..da33c9236 --- /dev/null +++ b/frontend/src/features/notes/ui/drawing.js @@ -0,0 +1,442 @@ +/** Canvas drawing tools, local drawing undo and PNG uploads. */ +export function createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}) { + function _buildDrawHtml() { + return ` +
+ +
+ + +
+ + +
+ + + + +
+
+ `; + } + + function _wireCanvas(container, initialImageUrl) { + const canvas = container.querySelector('.note-form-canvas'); + if (!canvas) return; + const resolveThemeColor = (expression, fallback) => { + const probe = document.createElement('span'); + probe.style.color = expression; + probe.style.position = 'fixed'; + probe.style.visibility = 'hidden'; + document.body.appendChild(probe); + const resolved = getComputedStyle(probe).color || fallback; + probe.remove(); + return resolved; + }; + const colorToHex = (color, fallback) => { + try { + const probe = document.createElement('canvas'); + probe.width = probe.height = 1; + const probeCtx = probe.getContext('2d'); + probeCtx.fillStyle = color; + probeCtx.fillRect(0, 0, 1, 1); + const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`; + } catch { return fallback; } + }; + const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34'); + const surfaceColorHex = colorToHex(surfaceColor, '#282c34'); + const highlightColor = colorToHex( + resolveThemeColor('var(--accent, var(--red))', '#e06c75'), + '#e06c75', + ); + const colorInput = container.querySelector('.note-form-draw-color'); + if (colorInput) colorInput.value = highlightColor; + // Bump the backing-store resolution for retina displays so strokes stay + // crisp. Set only style.width : leaving style.height to auto so the canvas + // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions + // are pinned, max-width:100% shrinks the width only, leaving rasterized + // glyphs visibly stretched relative to their on-screen input. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const cssW = canvas.width; + const cssH = canvas.height; + // Fill the container up to the logical width (don't pin a hard 600px, + // which on a narrow phone forces the card wider than the viewport and + // pushes the drawing outside the note). _pos() scales pointer coords by + // the actual displayed width, so accuracy is preserved at any size. + canvas.style.width = '100%'; + canvas.style.maxWidth = cssW + 'px'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = cssW + ' / ' + cssH; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Load prior drawing as starting point so consecutive edits compose. + const safeInitialImageUrl = _safeImgSrc(initialImageUrl); + if (safeInitialImageUrl) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} }; + img.src = safeInitialImageUrl; + // Float an X over the canvas so the user can blank it out and go back to + // a clean draw surface. Removes itself once clicked. + const wrap = container.querySelector('.note-form-draw-wrap'); + if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) { + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'note-form-draw-bg-rm'; + rm.title = 'Clear photo (regular draw)'; + rm.innerHTML = '×'; + rm.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + rm.remove(); + }); + wrap.appendChild(rm); + } + } + + // Swap the native browser color dialog for the in-house HSV picker + // (same one used by Themes + the gallery editor). Existing `input` event + // listeners + .value reads keep working : see colorPicker.js. + if (colorInput) attachColorPicker(colorInput); + const sizeInput = container.querySelector('.note-form-draw-size'); + const beSeg = container.querySelector('.note-form-draw-be'); + const brushBtn = container.querySelector('.note-form-draw-brush'); + const eraserBtn = container.querySelector('.note-form-draw-eraser'); + const textBtn = container.querySelector('.note-form-draw-text'); + const lineBtn = container.querySelector('.note-form-draw-line'); + const circleBtn = container.querySelector('.note-form-draw-circle'); + const undoBtn = container.querySelector('.note-form-draw-undo'); + // Single source of truth for what clicks/drags do. Other booleans are + // derived from this so we never end up with conflicting "eraser AND text" + // states (the bug that made T appear broken after using the eraser). + // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle' + let mode = 'pen'; + let drawing = false; + let last = null; + // The text tool has three preset sizes (CSS px font-size). + const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 }; + // Line / circle stroke widths in logical pixels : three crisp options. + const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 }; + // Snapshot taken at the start of a shape drag so the preview can repaint + // cleanly on each move without accumulating intermediate strokes. + let _shapeSnapshot = null; + + // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each + // operation : stroke, text commit, or future operations : and pop+restore + // on Undo. Cap to 30 to keep memory bounded. + const _undoStack = []; + const UNDO_LIMIT = 30; + const _syncUndoButton = () => { + const available = _undoStack.length > 0; + undoBtn?.classList.toggle('has-undo', available); + undoBtn?.toggleAttribute('disabled', !available); + undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true'); + }; + const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true })); + const _snapshot = () => { + try { + const w = canvas.width, h = canvas.height; + _undoStack.push(ctx.getImageData(0, 0, w, h)); + if (_undoStack.length > UNDO_LIMIT) _undoStack.shift(); + _syncUndoButton(); + } catch {} + }; + const _undo = () => { + const prev = _undoStack.pop(); + if (!prev) { _syncUndoButton(); return; } + // Restore against the raw backing store: temporarily reset the active + // ctx scale, paint the snapshot 1:1, then reapply our standard transform. + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(prev, 0, 0); + ctx.restore(); + _markCanvasDirty(); + _syncUndoButton(); + }; + + const _pos = (e) => { + // CSS can shrink the canvas (max-width:100%) without changing its logical + // size, so compute the displayed-to-logical scale per axis. Pointer coords + // are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH). + const r = canvas.getBoundingClientRect(); + const sx = cssW / r.width; + const sy = cssH / r.height; + const t = e.touches ? e.touches[0] : e; + return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; + }; + const _begin = (e) => { + if (mode.startsWith('text-')) { + // Stop the event so the browser doesn't synthesize a follow-up click + // that would blur the input we're about to create. + e.preventDefault?.(); + e.stopPropagation?.(); + _openTextInput(e); + return; + } + _snapshot(); + last = _pos(e); + drawing = true; + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Capture the backing pixels so the preview can replay each move from + // the same starting state (otherwise live shapes accumulate). + try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {} + return; + } + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + }; + + // Drop an HTML input at the click position so the user can type, then + // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form + // annotations work in our doc editor. + let _activeTextInput = null; + const _openTextInput = (e) => { + // Commit any prior pending input before starting a new one : otherwise + // the first click leaves an orphaned input the user thinks "didn't work". + if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} } + const r = canvas.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + // Position is anchored to the wrap, not the canvas; since the canvas is + // the first child of the wrap and the wrap has no padding, they share an + // origin, so we offset from the canvas rect directly. + const px = t.clientX - r.left; + const py = t.clientY - r.top; + const logical = _pos(e); + // Size is decided by which T variant is active (S/M/L), not the stroke + // slider : those are independent dials. + const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm'; + const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m; + const wrap = container.querySelector('.note-form-draw-wrap'); + if (!wrap) return; + if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'note-form-draw-textinput'; + input.placeholder = 'type then Enter'; + const color = colorInput?.value || '#222'; + const maxW = Math.max(120, Math.floor(r.width - px - 4)); + input.style.cssText = [ + 'position:absolute', + `left:${px}px`, + `top:${Math.max(0, py - sizeCss * 0.7)}px`, + `font:${sizeCss}px Arial, sans-serif`, + `color:${color}`, + 'background:#ffffff', + 'border:2px solid var(--accent)', + 'border-radius:4px', + 'outline:none', + 'padding:2px 6px', + 'min-width:120px', + `max-width:${maxW}px`, + 'z-index:1000', + 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', + 'pointer-events:auto', + ].join(';'); + wrap.appendChild(input); + _activeTextInput = input; + // Focus synchronously so the call still counts as inside the user gesture + // on iOS / Android, then re-focus on the next frame in case a competing + // synthetic event (touch → click) moved focus away. + input.focus(); + requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); }); + let committed = false; + const commit = () => { + if (committed) return; + committed = true; + const text = input.value; + if (_activeTextInput === input) _activeTextInput = null; + input.remove(); + if (!text) return; + // Snapshot BEFORE rasterizing so Undo removes the text in one step. + _snapshot(); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = color; + // Canvas now scales uniformly (style.height: auto), so a single ratio + // suffices for picking the logical font size that matches what the + // user just saw in the HTML input. + const sx = cssW / r.width; + const logicalSize = sizeCss * sx; + ctx.font = `${logicalSize}px sans-serif`; + ctx.textBaseline = 'top'; + ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7); + ctx.restore(); + _markCanvasDirty(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } + else if (ev.key === 'Escape') { input.value = ''; input.blur(); } + }); + }; + const _move = (e) => { + if (!drawing) return; + e.preventDefault?.(); + const p = _pos(e); + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Restore the pre-shape bitmap, then redraw the preview shape from + // anchor → current pointer. + if (_shapeSnapshot) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(_shapeSnapshot, 0, 0); + ctx.restore(); + } + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = colorInput?.value || '#222'; + const sizeKey = mode.slice(-1); + ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m; + ctx.beginPath(); + if (mode.startsWith('line-')) { + ctx.moveTo(last.x, last.y); + ctx.lineTo(p.x, p.y); + } else { + const dx = p.x - last.x; + const dy = p.y - last.y; + const radius = Math.hypot(dx, dy); + ctx.arc(last.x, last.y, radius, 0, Math.PI * 2); + } + ctx.stroke(); + return; + } + const erasing = mode === 'eraser'; + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor); + ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + last = p; + }; + const _end = () => { + if (drawing) _markCanvasDirty(); + drawing = false; last = null; _shapeSnapshot = null; + }; + + canvas.addEventListener('mousedown', _begin); + canvas.addEventListener('mousemove', _move); + lifecycle.listen(canvas, window, 'mouseup', _end); + // Non-passive so text mode can preventDefault : otherwise the synthetic + // mousedown/click that follows a touch can blur the freshly-created text + // input on iOS Safari, making T look like a no-op. + canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false }); + canvas.addEventListener('touchmove', _move, { passive: false }); + canvas.addEventListener('touchend', _end); + canvas.addEventListener('touchcancel', _end); + + // Single mode setter : keeps the toolbar, swatch, and cursor in sync, and + // makes sure exiting the eraser restores the user's chosen color whether + // they exit by clicking another tool or by toggling eraser off. + let _preEraseColor = null; + const _setMode = (next) => { + const wasEraser = mode === 'eraser'; + mode = next; + const isEraser = next === 'eraser'; + const isPen = next === 'pen'; + const isText = next.startsWith('text-'); + // Swatch: theme surface while erasing, restored when leaving that mode. + if (isEraser && !wasEraser && colorInput) { + _preEraseColor = colorInput.value; + colorInput.value = surfaceColorHex; + } else if (!isEraser && wasEraser && colorInput && _preEraseColor) { + colorInput.value = _preEraseColor; + _preEraseColor = null; + } + // Brush/Eraser segmented pill : slides to the active side. When a non- + // pen/eraser tool (T / line / circle) is active, neither half is + // highlighted, but the pill still indicates which side the user was on + // last so they can return with one click. + const isLine = next.startsWith('line-'); + const isCircle = next.startsWith('circle-'); + beSeg?.classList.toggle('is-eraser', isEraser); + brushBtn?.classList.toggle('active', isPen); + eraserBtn?.classList.toggle('active', isEraser); + textBtn?.classList.toggle('active', isText); + lineBtn?.classList.toggle('active', isLine); + circleBtn?.classList.toggle('active', isCircle); + // Per-button size badges (S/M/L), driven off the mode suffix. + const tBadge = textBtn?.querySelector('.note-form-draw-text-badge'); + if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : ''; + const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge'); + if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : ''; + const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge'); + if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : ''; + // Reflect the chosen size in the icon itself : thicker line/circle stroke + // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`. + const _sz = next.slice(-1); + [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l')); + if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz); + if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz); + if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz); + canvas.style.cursor = isText ? 'text' : 'crosshair'; + }; + brushBtn?.addEventListener('click', () => _setMode('pen')); + eraserBtn?.addEventListener('click', () => _setMode('eraser')); + // T / Line / Circle: each cycles its own three sizes, then back to pen. + const _cycle = (prefix) => { + const seq = ['s', 'm', 'l']; + if (!mode.startsWith(prefix)) return prefix + 's'; + const cur = mode.slice(-1); + const next = seq[seq.indexOf(cur) + 1]; + return next ? prefix + next : 'pen'; + }; + textBtn?.addEventListener('click', () => _setMode(_cycle('text-'))); + lineBtn?.addEventListener('click', () => _setMode(_cycle('line-'))); + circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-'))); + undoBtn?.addEventListener('click', () => _undo()); + _syncUndoButton(); + + // Stash so the save handler can read it later without re-resolving DOM. + canvas._cssW = cssW; + canvas._cssH = cssH; + return canvas; + } + + async function _uploadCanvasAsPng(canvas, galleryId = null) { + if (!canvas) return null; + const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); + if (!blob) return null; + const fd = new FormData(); + fd.append('files', blob, 'drawing.png'); + if (galleryId) fd.append('gallery_id', galleryId); + try { + const res = await api.upload(fd); + const data = await res.json(); + const id = data.files?.[0]?.id; + return id ? { + url: api.uploadUrl(id), + galleryId: data.files?.[0]?.gallery_id || null, + } : null; + } catch { return null; } + } + + return { _buildDrawHtml, _wireCanvas, _uploadCanvasAsPng }; +} diff --git a/frontend/src/features/notes/ui/editor.js b/frontend/src/features/notes/ui/editor.js new file mode 100644 index 000000000..f5bdc7c9f --- /dev/null +++ b/frontend/src/features/notes/ui/editor.js @@ -0,0 +1,518 @@ +/** Build and wire note, checklist, goal and drawing editor forms. */ +export function createEditor({ + deleteFromEditor, + submitNote, + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}) { + function _buildForm(note = null) { + const isEdit = note && note.id; + const type = note?.note_type === 'checklist' ? 'todo' : (note?.note_type || 'note'); + const color = note?.color || ''; + const items = note?.items || [{ id: _uid(), text: '', done: false }]; + + const form = document.createElement('div'); + form.className = 'note-form'; + form.dataset.noteType = type; + form.dataset.noteColor = color || ''; + if (color && !_isBgImage(color)) form.classList.add('note-color-' + color); + if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color)); + let currentImageUrl = _safeImgSrc(note?.image_url || ''); + form.innerHTML = ` +
+ + + + +
+ ${currentImageUrl && type !== 'draw' ? `
` : ''} +
+ ${type === 'note' + ? `` + : type === 'draw' + ? _buildDrawHtml() + : type === 'goal' + ? _buildGoalHtml(note, items) + : _buildChecklistHtml(items)} +
+
+
+
+ + + +
+ + +
+ ${COLORS.map(c => ``).join('')} +
+ +
+ ${isEdit ? ` + + + ` : ''} + + + +
+
+ `; + + let currentType = type; + let currentColor = color; + // Stash original-form values so round-trips (Note→Todo→Note) restore the + // user's hand-formatted text instead of a join of generated items. Same the + // other way: if you started in todo, switch to note, switch back, items + // come back unchanged. + let _stashedNoteText = (type === 'note') ? (note?.content || '') : null; + let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null; + // Goal mode kept its own pair of stashes (description + steps) so a + // Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in + // the type picker was later removed, so the only entry point now is + // *editing* an existing goal-typed note : but the switch handler still + // accepts Goal→Todo/Note transitions (downgrading legacy goals), so + // these stashes still earn their keep. + let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null; + let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null; + + // Drawing also stashes the saved image URL so it survives Note↔Draw flips. + let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null; + const _refreshFormLayout = () => { + const body = form.closest('.notes-pane-body'); + if (!body) return; + _applyMasonry(body); + requestAnimationFrame(() => { + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + }); + }; + + // Type segmented control : Note | Todo | Draw + form.querySelectorAll('.note-form-type-pill').forEach(pill => { + pill.addEventListener('click', () => { + const newType = pill.dataset.type; + if (newType === currentType) return; + const bodyEl = form.querySelector('.note-form-body'); + // Stash whatever the user has in the current mode before swapping it + // out, so a subsequent flip back restores their work. + if (currentType === 'note') { + _stashedNoteText = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'todo') { + _stashedTodoItems = _collectItems(form); + } else if (currentType === 'goal') { + _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || ''; + _stashedGoalItems = _collectItems(form); + } else if (currentType === 'draw') { + const c = form.querySelector('.note-form-canvas'); + if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} } + } + // Render the new mode's body and re-wire its inputs. + if (newType === 'todo') { + let nextItems; + if (_stashedTodoItems && _stashedTodoItems.length) { + nextItems = _stashedTodoItems; + } else if (_stashedGoalItems && _stashedGoalItems.length) { + // Going Goal→Todo keeps the AI-generated steps as a plain checklist. + nextItems = _stashedGoalItems; + } else if (_stashedNoteText) { + const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean); + nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }]; + } else { + nextItems = [{ id: _uid(), text: '', done: false }]; + } + bodyEl.innerHTML = _buildChecklistHtml(nextItems); + _wireChecklist(bodyEl); + } else if (newType === 'draw') { + bodyEl.innerHTML = _buildDrawHtml(); + // If the user just attached a photo (via the photo button) and then + // toggled to Draw, paint that photo onto the canvas so they can draw + // on top of it. _stashedDrawUrl wins if they were drawing earlier in + // the same edit session. + _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null); + } else { + const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '') + ? _stashedNoteText + : (_stashedGoalDesc && _stashedGoalDesc) + || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n'); + bodyEl.innerHTML = ``; + _wireHashtag(bodyEl.querySelector('.note-form-content')); + } + const focusEl = newType === 'note' + ? bodyEl.querySelector('.note-form-content') + : newType === 'todo' + ? bodyEl.querySelector('.note-cl-text') + : null; + if (focusEl) { + requestAnimationFrame(() => { + focusEl.focus({ preventScroll: true }); + try { + const end = focusEl.value.length; + focusEl.setSelectionRange(end, end); + } catch {} + }); + } + currentType = newType; + const seg = form.querySelector('.note-form-type-seg'); + seg?.classList.toggle('is-todo', newType === 'todo'); + seg?.classList.toggle('is-draw', newType === 'draw'); + form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType)); + // The standalone image preview (form-image-wrap) and the canvas would + // otherwise both show the same image_url when editing a drawn note. + // Hide it in draw mode, restore it when leaving draw mode. + const imgWrap = form.querySelector('.note-form-image-wrap'); + if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : ''; + // The background-color dots set the note card's bg : they make no sense + // for a drawn note (the canvas image IS the card content), so hide them. + const bgPicker = form.querySelector('.note-color-picker'); + if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : ''; + if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64'; + } + _refreshFormLayout(); + }); + }); + + // Slide a finger across the Note/Todo/Draw control to switch modes (mobile). + // On touchmove we find the pill under the finger and click it : reusing the + // pill click handler above, so the body re-renders + content stashing all + // work. Only fires when crossing into a *different* pill. + const _typeSeg = form.querySelector('.note-form-type-seg'); + if (_typeSeg) { + let _sliding = false; + const _activateAt = (x, y) => { + const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill'); + if (pill && !pill.classList.contains('active')) pill.click(); + }; + _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true }); + _typeSeg.addEventListener('touchmove', (e) => { + if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: true }); + _typeSeg.addEventListener('touchend', () => { _sliding = false; }); + _typeSeg.addEventListener('touchcancel', () => { _sliding = false; }); + } + + _wireFormColors(form, value => { currentColor = value; }); + + if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body')); + if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body')); + if (currentType === 'draw') { + _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null); + // Same hides we apply on type-switch : keep them consistent on initial open. + const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none'; + const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none'; + } + + // Auto-grow the plain-note textarea so editing longer notes is + // comfortable : it expands with the content (up to a cap) instead of + // staying a cramped 4-row box. The user can still drag-resize too. + const _contentTa = form.querySelector('.note-form-content'); + if (_contentTa) { + const _grow = () => { + _contentTa.style.height = 'auto'; + // Inline form: cap at ~50vh so a huge note doesn't push the action + // buttons off-screen. Fullscreen mobile overlay: the body scrolls and + // there are no inline buttons crowding, so allow nearly the full height + // : capping at 50vh there clipped longer notes ("part disappears"). + const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay'); + const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5)); + _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px'; + }; + _contentTa.addEventListener('input', _grow); + // Grow on open so existing content is fully visible. Run again after the + // fullscreen overlay's open animation settles : measuring mid-animation + // (the overlay starts scaled/transitioning) can under-size the box. + setTimeout(_grow, 0); + setTimeout(_grow, 360); + } + + _wireReminderPicker(form); + + // Photo upload + const photoBtn = form.querySelector('.note-form-photo-btn'); + const photoInput = form.querySelector('.note-form-photo-input'); + if (photoBtn && photoInput) { + photoBtn.addEventListener('click', () => photoInput.click()); + photoInput.addEventListener('change', async () => { + const file = photoInput.files?.[0]; + if (!file) return; + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + currentImageUrl = api.uploadUrl(fileId); + // Only ever keep the latest attached photo : drop any existing wrap + // before inserting a fresh one. Picking a second photo replaces the + // first instead of stacking. + form.querySelector('.note-form-image-wrap')?.remove(); + const wrap = document.createElement('div'); + wrap.className = 'note-form-image-wrap'; + wrap.innerHTML = ``; + // Insert AFTER the whole header (a flex-row), not after the + // title input itself : otherwise the image lands as a sibling + // of the title inside the header and flex puts them side-by-side. + form.querySelector('.note-form-header').after(wrap); + wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; }); + wrap.querySelector('img').src = currentImageUrl; + } catch (err) { uiModule.showError('Image upload failed'); } + photoInput.value = ''; + }); + } + // Existing image remove + form.querySelector('.note-form-image-rm')?.addEventListener('click', () => { + form.querySelector('.note-form-image-wrap')?.remove(); + currentImageUrl = ''; + }); + + // Title Enter -> focus body (textarea or first checklist item) + form.querySelector('.note-form-title').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); return; } + const firstItem = form.querySelector('.note-cl-text'); + if (firstItem) firstItem.focus(); + } + }); + + // Hashtag → label: typing "#foo " in title/content appends "foo" to the + // space-separated tag list. Repeats are deduplicated, so #foo #foo only + // keeps one. Tags already present in the label field are left alone. + const labelInput = form.querySelector('.note-form-label'); + const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + function _wireHashtag(el) { + if (!el || !labelInput) return; + el.addEventListener('input', () => { + const m = _hashtagRe.exec(el.value); + if (!m) return; + const tag = m[2]; + // Dedup against the stripped form : labelInput may already hold `#tag` + // (after Enter normalised), so includes(tag) on the raw split would + // miss the duplicate and append a bare `tag` next to `#tag`. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = el.value.length - m[0].length + m[1].length; + el.value = el.value.slice(0, cut); + }); + } + _wireHashtag(form.querySelector('.note-form-title')); + _wireHashtag(form.querySelector('.note-form-content')); + // Pressing Enter in the tag field commits the current word as its own tag + // and parks the cursor after a trailing space, so the next word becomes a + // separate tag rather than overwriting the previous one. + labelInput?.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); + e.stopPropagation(); + // Strip any leading #s the user typed, dedupe, then re-prepend exactly + // one #. So typing "foo" or "#foo" both end up as "#foo " in the input; + // the save handler keeps stripping #s before storing so DB stays clean. + const tags = [...new Set(labelInput.value.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (!tags.length) return; + labelInput.value = tags.map(t => '#' + t).join(' ') + ' '; + labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + }); + + // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save + // Escape -> cancel edit + form.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + form.querySelector('.note-form-save')?.click(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + form.querySelector('.note-form-cancel')?.click(); + } + }); + + // Save. Prevent the button from stealing focus on press: on mobile, the + // first tap would otherwise just blur the focused textarea/input (closing + // the keyboard and shifting layout), so the tap never reached the button and + // you had to tap "Done" twice. mousedown preventDefault keeps focus put while + // still letting the click fire. + const _saveBtnEl0 = form.querySelector('.note-form-save'); + _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault()); + _saveBtnEl0.addEventListener('click', async () => { + // Guard against spam-clicks: the drawing save AWAITS a canvas upload before + // the optimistic re-render removes the form, so without this a slow upload + // let repeated clicks create duplicate notes. + const _saveBtn = form.querySelector('.note-form-save'); + if (_saveBtn._saving) return; + // Mobile: when an existing note is opened and closed without edits, the + // Update (✓) button morphs into Archive (set up below). Route the click + // directly through the archive flow. Using a DOM .click() proxy here was + // fragile on mobile because the real archive button can move from the + // footer to the fullscreen header. + if (_saveBtn.classList.contains('archive-mode')) { + _archiveNoteById(note?.id); + return; + } + _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; + try { + form._flushDraft?.(); + await form._stopDraftAutosave?.(); + form._flushDraft?.(); + const title = form.querySelector('.note-form-title').value.trim(); + // Normalize tag input: split on whitespace, strip leading #s, dedupe, + // re-join with single spaces. Empty → null. + const _rawLabel = form.querySelector('.note-form-label')?.value || ''; + const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder'); + const labelVal = _tags.length ? _tags.join(' ') : null; + const payload = { + title, + note_type: currentType, + color: currentColor, + label: labelVal, + due_date: form.querySelector('.note-form-due').value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + image_url: currentImageUrl || null, + }; + if (currentType === 'note') { + payload.content = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'draw') { + // Upload the canvas PNG before saving so image_url points to a + // persistent file. We block the save until upload completes : drawings + // can't be re-rendered later without the URL. + const canvas = form.querySelector('.note-form-canvas'); + const upload = await _uploadCanvasAsPng(canvas, note?.gallery_id || null); + if (!upload?.url) { uiModule.showError('Failed to save drawing'); return; } + payload.image_url = upload.url; + if (upload.galleryId) payload.gallery_id = upload.galleryId; + } else if (currentType === 'goal') { + // Legacy: existing goal-type notes still edit through this branch. + // No AI involvement : save as a normal note with description + items. + payload.content = form.querySelector('.note-form-goal-desc')?.value || ''; + payload.items = _collectItems(form); + } else { + payload.items = _collectItems(form); + } + if (isEdit) payload.id = note.id; + submitNote(payload, note, isEdit); + } finally { + // Re-enable on early returns / errors. On success the form is removed by + // the optimistic re-render, so re-enabling the detached button is a no-op. + _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = ''; + } + }); + + // Mobile-only: when editing an existing note, the Update (✓) button starts in + // archive-mode (visually + behaviorally) and flips to Update on the first + // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever + // touching a separate Archive button. + if (isEdit && window.innerWidth <= 768) { + const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); + const _isFinishedTodo = () => { + if (currentType !== 'todo') return false; + const rows = [...form.querySelectorAll('.note-cl-row')]; + const withText = rows.filter(row => (row.querySelector('.note-cl-text')?.value || '').trim()); + return withText.length > 0 && withText.every(row => row.classList.contains('done')); + }; + const _enterArchive = () => { + _saveBtnEl0.classList.add('archive-mode'); + const label = _isFinishedTodo() ? 'Finish' : 'Archive'; + if (_saveLabelEl) _saveLabelEl.textContent = label; + _saveBtnEl0.title = label; + }; + const _enterUpdate = () => { + if (!_saveBtnEl0.classList.contains('archive-mode')) return; + _saveBtnEl0.classList.remove('archive-mode'); + if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; + _saveBtnEl0.title = 'Update'; + }; + _enterArchive(); + form.addEventListener('input', _enterUpdate, true); + form.addEventListener('change', _enterUpdate, true); + // Checklist dots use a custom control and dispatch their update before + // this click bubbles. Re-evaluate afterward so a fully completed todo + // offers Finish (which archives/removes it from the active list). + form.addEventListener('click', (event) => { + if (!event.target.closest('.note-cl-dot')) return; + setTimeout(() => { + if (_isFinishedTodo()) _enterArchive(); + else _enterUpdate(); + }, 0); + }, true); + } + + // Cancel + form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); app.setEditingId(null); _renderNotes(); }); + + // Archive / Delete : edit-mode-only buttons, mirror the (now-hidden) card actions. + form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => { + if (!isEdit) return; + _archiveNoteById(note.id); + }); + form.querySelector('.note-form-delete-btn')?.addEventListener('click', () => { + if (!isEdit) return; + return deleteFromEditor(note.id); + }); + + // Autosave a draft to persistence on every change so unsaved edits + // survive connection loss / reload / accidental close. + _wireDraftAutosave(form, isEdit ? note.id : '__new__'); + + return form; + } + + return { _buildForm }; +} diff --git a/frontend/src/features/notes/ui/filters.js b/frontend/src/features/notes/ui/filters.js new file mode 100644 index 000000000..49142a260 --- /dev/null +++ b/frontend/src/features/notes/ui/filters.js @@ -0,0 +1,139 @@ +/** Label chips, reminder/goal filters and collapsed tag-strip rendering. */ +import { _visibleNoteTags, _nextGoalStep } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}) { + function _renderLabels(root = document) { + const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar'); + if (!bar) return; + if (state.labelsCollapsed) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + const visibleNotes = _userVisibleNotes(); + const labels = new Set(); + for (const n of visibleNotes) for (const t of _visibleNoteTags(n)) labels.add(t); + const sortedLabels = [...labels].sort(); + // Count active reminders (not archived, has datetime due_date) + const reminderCount = visibleNotes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length; + const pastReminderCount = visibleNotes.filter(n => !n.archived && _isPastReminder(n)).length; + const defaultCount = visibleNotes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length; + // Active goals = non-archived goal notes. Today view lists pending steps + // from each, so we surface the count next to the chip. + const goalCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived).length; + const todayCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length; + const hasUsefulFilters = sortedLabels.length > 0 || reminderCount > 0 || goalCount > 0; + const hasActiveFilter = state.activeLabel !== null || state.activeFilter !== null; + if (!hasUsefulFilters && !hasActiveFilter) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + bar.style.display = ''; + const allActive = state.activeLabel === null && state.activeFilter === null; + let html = ``; + html += ``; + if (todayCount > 0) { + const isOn = state.activeFilter === 'today'; + const icon = ''; + html += ``; + } + if (goalCount > 0) { + const isOn = state.activeFilter === 'goals'; + const icon = ''; + html += ``; + } + const isReminderOn = state.activeFilter === 'reminders'; + const isReminderOff = state.activeFilter === 'no-reminders'; + const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`; + const reminderIcon = isReminderOff + // bell-off icon + ? '' + : ''; + html += ``; + const showingReminders = state.activeFilter === 'reminders'; + if (showingReminders && pastReminderCount > 0) { + html += ``; + } + for (const lbl of sortedLabels) { + html += ``; + } + bar.innerHTML = html; + bar.querySelectorAll('.notes-label-chip').forEach(chip => { + chip.addEventListener('click', () => { + if (chip.dataset.action === 'all') { + app.setActiveLabel(null); + app.setActiveFilter(null); + } else if (chip.dataset.action === 'today') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'today') ? null : 'today'); + } else if (chip.dataset.action === 'goals') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'goals') ? null : 'goals'); + } else if (chip.dataset.action === 'default') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'default') ? null : 'default'); + } else if (chip.dataset.action === 'reminders') { + app.setActiveLabel(null); + // Cycle: null → reminders → null → no-reminders → null → reminders → ... + if (state.activeFilter === null) { + app.setActiveFilter(state.reminderChipNext); + app.setReminderChipNext((state.reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders'); + } else { + app.setActiveFilter(null); + } + } else if (chip.dataset.action === 'clear-past-reminders') { + _clearPastReminders(); + return; + } else { + app.setActiveFilter(null); + app.setActiveLabel(chip.dataset.label || null); + } + _renderNotes(); + }); + }); + } + + function _renderLabelsInto(_body) { + if (!_body) return; + const pane = _body.closest('.notes-pane'); + const toggleHost = pane?.querySelector('.notes-search-bar') || _body; + let toggle = toggleHost.querySelector(':scope > .notes-label-toggle'); + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'notes-label-toggle cal-filter-toggle'; + toggle.addEventListener('click', () => { + app.setLabelsCollapsed(!state.labelsCollapsed); + try { persistence.setItem(NOTES_LABELS_COLLAPSED_KEY, state.labelsCollapsed ? '1' : '0'); } catch {} + _renderNotes(); + }); + const selectButton = toggleHost.querySelector('#notes-select-btn'); + toggleHost.insertBefore(toggle, selectButton || null); + } + toggle.textContent = state.labelsCollapsed ? '+ Tags' : '− Tags'; + toggle.title = state.labelsCollapsed ? 'Show tags' : 'Hide tags'; + toggle.setAttribute('aria-expanded', state.labelsCollapsed ? 'false' : 'true'); + + // The shared chip scroller wraps this row once it is mounted. + let bar = _body.querySelector('.notes-labels-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'notes-labels-bar'; + _body.appendChild(bar); + } + _renderLabels(_body); + } + + return { _renderLabels, _renderLabelsInto }; +} diff --git a/frontend/src/features/notes/ui/layout.js b/frontend/src/features/notes/ui/layout.js new file mode 100644 index 000000000..8935feff5 --- /dev/null +++ b/frontend/src/features/notes/ui/layout.js @@ -0,0 +1,123 @@ +/** Card reflow animation and the owned masonry resize observer. */ +export function createLayout({ isOpen = () => true } = {}) { + function _captureCardPositions() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return null; + const positions = new Map(); + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + if (id) positions.set(id, card.getBoundingClientRect()); + }); + return positions; + } + + function _animateReflow(prevPositions) { + if (!prevPositions || !prevPositions.size) return; + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || !body.isConnected) return; + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const prev = prevPositions.get(id); + if (!prev) return; + const next = card.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; + // Invert: jump back to old position + card.style.transition = 'none'; + card.style.transform = `translate(${dx}px, ${dy}px)`; + // Play: animate to 0 + requestAnimationFrame(() => { + card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)'; + card.style.transform = ''; + card.addEventListener('transitionend', () => { + card.style.transition = ''; + }, { once: true }); + }); + }); + } + + function _applyMasonry(body) { + if (!body || !body.isConnected) return; + const pane = body.closest('.notes-pane'); + const isGrid = pane?.classList.contains('notes-view-grid'); + const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches; + // Tear down any prior observer (defensive : _renderNotes wipes body.innerHTML). + if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; } + // A save that resolves after close re-renders the leaving pane; don't + // re-create the observer that panel teardown already disposed. + if (!isOpen()) return; + if (!isGrid) { + // Clear any leftover inline spans so list view lays out normally. + body.querySelectorAll('.note-card, .notes-label-toggle, .notes-labels-bar, .notes-quick-add, .note-form, .doclib-chip-scroll-frame:has(> .notes-labels-bar)').forEach(c => { c.style.gridRowEnd = ''; }); + return; + } + const ROW_PX = 4; + const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX)); + const recomputeFullRows = () => { + const quickAdd = body.querySelector('.notes-quick-add'); + const labelsToggle = body.querySelector('.notes-label-toggle'); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsToggle && getComputedStyle(labelsToggle).display !== 'none') { + labelsToggle.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsToggle.scrollHeight))}`; + } + if (labelsLayout && getComputedStyle(labelsLayout).display !== 'none') { + const shave = isMobileGrid ? 4 : 0; + labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`; + } + if (quickAdd) { + const shave = isMobileGrid ? 4 : 0; + quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`; + } + body.querySelectorAll('.note-form').forEach(form => { + form.style.gridColumn = '1 / -1'; + const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw'); + const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1; + const renderedHeight = form.getBoundingClientRect?.().height || 0; + const drawReserve = isDrawForm && isMobileGrid ? 12 : 12; + const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve; + form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`; + }); + }; + const recompute = (card) => { + // scrollHeight returns the natural content height : card.getBoundingClientRect() + // would return the grid cell height (collapsed to 4px until the span is set, + // which is the value we're trying to compute). + const h = card.scrollHeight + (isMobileGrid ? 6 : 8); + if (h <= 0) return; + card.style.gridRowEnd = `span ${spanForHeight(h)}`; + }; + recomputeFullRows(); + body.querySelectorAll('.note-card').forEach(recompute); + // Watch masonry participants : content can grow (image load, todo edits, + // quick-add/form expansion), and stale spans are what cause visual merging. + if ('ResizeObserver' in window) { + _masonryObserver = new ResizeObserver(entries => { + let fullRowsChanged = false; + for (const e of entries) { + if (e.target.classList.contains('note-card')) recompute(e.target); + else fullRowsChanged = true; + } + if (fullRowsChanged) recomputeFullRows(); + }); + body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c)); + body.querySelectorAll('.notes-label-toggle, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c)); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsLayout) _masonryObserver.observe(labelsLayout); + } + } + + let _masonryObserver = null; + + function dispose() { + _masonryObserver?.disconnect(); + _masonryObserver = null; + } + return { dispose, _captureCardPositions, _animateReflow, _applyMasonry }; +} diff --git a/frontend/src/features/notes/ui/lifecycle.js b/frontend/src/features/notes/ui/lifecycle.js new file mode 100644 index 000000000..67a8da199 --- /dev/null +++ b/frontend/src/features/notes/ui/lifecycle.js @@ -0,0 +1,36 @@ +/** Notes-owned document/window listeners. Detached editor/menu owners release their resources. */ +export function createNotesLifecycle() { + const owners = new Map(); + let observer = null; + function release(owner) { + const entry = owners.get(owner); + if (!entry) return; + owners.delete(owner); + for (const remove of entry.removers) remove(); + if (!owners.size) { observer?.disconnect(); observer = null; } + } + function onDispose(owner, remove) { + if (!owner) throw new Error('Notes listener needs a lifecycle owner'); + let entry = owners.get(owner); + if (!entry) { + entry = { connected: owner.isConnected, removers: [] }; + owners.set(owner, entry); + } + entry.removers.push(remove); + if (!observer) { + observer = new MutationObserver(() => { + for (const [node, scope] of owners) { + if (node.isConnected) scope.connected = true; + else if (scope.connected) release(node); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + } + function listen(owner, target, event, listener, options) { + onDispose(owner, () => target.removeEventListener(event, listener, options)); + target.addEventListener(event, listener, options); + } + function dispose() { for (const owner of [...owners.keys()]) release(owner); } + return { listen, onDispose, release, dispose }; +} diff --git a/frontend/src/features/notes/ui/mobileEditor.js b/frontend/src/features/notes/ui/mobileEditor.js new file mode 100644 index 000000000..f751ba7aa --- /dev/null +++ b/frontend/src/features/notes/ui/mobileEditor.js @@ -0,0 +1,257 @@ +/** Mobile fullscreen editing, link reader mode and return to the panel. */ +export function createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}) { + function _isNotesMobileMode() { + return ('ontouchstart' in window) && window.innerWidth <= 768; + } + + let _mobileFsOverlay = null; + + let _mobileFsNoteId = null; + + function _openMobileFullscreenEdit(id, fromCard) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + // Tear down any previous overlay (defensive). + _closeMobileFullscreenEdit({ save: false }); + _mobileFsNoteId = id; + app.setEditingId(id); + + const overlay = document.createElement('div'); + overlay.className = 'note-fullscreen-overlay'; + overlay.innerHTML = ` +
+ +
+
+
+ `; + const body = overlay.querySelector('.note-fullscreen-body'); + // Reuse the same edit form the in-place flow builds. Save buttons, + // checklist toggles, etc. all work as-is. Restore any unsaved draft. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + body.appendChild(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + document.body.appendChild(overlay); + _mobileFsOverlay = overlay; + + // Animate up from the tapped tile's position so the transition reads + // as a zoom rather than a hard cut. + if (fromCard) { + const r = fromCard.getBoundingClientRect(); + const vw = window.innerWidth, vh = window.innerHeight; + overlay.style.transformOrigin = + `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`; + } + overlay.classList.add('opening'); + requestAnimationFrame(() => overlay.classList.add('open')); + + // Wire the back button : saves whatever the form has and closes. + // mousedown preventDefault so it doesn't blur the input on first tap (which + // would eat the tap and require a second one). + const _backBtn = overlay.querySelector('.note-fullscreen-back'); + _backBtn.addEventListener('mousedown', (e) => e.preventDefault()); + _backBtn.addEventListener('click', () => { + _closeMobileFullscreenEdit({ save: true }); + }); + + // The form's built-in Cancel only resets the in-place edit state; in + // the overlay context it does nothing visible. Replace its handler so + // Cancel actually dismisses the overlay without saving. + const cancelBtn = form.querySelector('.note-form-cancel'); + if (cancelBtn) { + const fresh = cancelBtn.cloneNode(true); + cancelBtn.parentNode.replaceChild(fresh, cancelBtn); + fresh.addEventListener('mousedown', (e) => e.preventDefault()); + fresh.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _closeMobileFullscreenEdit({ save: false }); + }); + } + // The built-in Save handler does the API call + refresh, but doesn't + // dismiss our overlay. Augment it (do NOT replace : the original is + // async and we'd lose the API call) to schedule a close once the + // save+render has had time to complete. + const saveBtn = form.querySelector('.note-form-save'); + if (saveBtn) { + saveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350); + }); + } + // Make the checklist row drag handle (⋮⋮) actually work on touch. + // The form's default uses HTML5 native draggable which never fires + // on iOS/Android. Wire touch-based reorder for any row inside the + // overlay's checklist. + _wireChecklistTouchReorder(form); + + // For each todo row whose text contains a URL, swap the bare + // for a linkified so URLs are tappable. Tapping anywhere that + // ISN'T a link flips back to the input for editing. + form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode); + + // Move only Delete from the form's footer actions row into the header. + // Archive intentionally stays out of the header: the footer Update button + // already morphs into the mobile Archive action for an untouched note. + const headerActions = overlay.querySelector('.note-fullscreen-actions'); + const archiveBtn = form.querySelector('.note-form-archive-btn'); + const deleteBtn = form.querySelector('.note-form-delete-btn'); + if (headerActions && deleteBtn) { + deleteBtn.classList.remove('note-form-collapsible'); + headerActions.appendChild(deleteBtn); + } + // The built-in archive/delete handlers re-render the notes grid but + // leave THIS overlay sitting in front of it : looks like nothing + // happened. Add follow-up listeners that close the overlay so the + // user sees the action take effect. + if (archiveBtn) { + archiveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200); + }); + } + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + // Delete shows a styled confirm : give it room to resolve before + // we try to dismiss the overlay. + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500); + }); + } + + // Tuck the tags input into the bottom actions row (Cancel / Update), + // pinned to the LEFT. Frees the meta row of an extra wrapping line + // and groups all the "exit" controls together. + const actionsGroup = form.querySelector('.note-form-actions-group'); + const tagsInput = form.querySelector('.note-form-label'); + if (actionsGroup && tagsInput) { + actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild); + } + + // For checklist-type notes, move the photo (attach image) button into + // the same row as the + Add button (right side) : keeps the meta row + // tidy and puts the camera within thumb-reach of the active edit. + const addBtn = form.querySelector('.note-cl-add'); + const photoBtn = form.querySelector('.note-form-photo-btn'); + if (addBtn && photoBtn) { + const addRow = document.createElement('div'); + addRow.className = 'note-cl-add-row'; + addBtn.parentNode.insertBefore(addRow, addBtn); + addRow.appendChild(addBtn); + addRow.appendChild(photoBtn); + // Tapping anywhere on the row (the empty gap, the dashed border, + // the "+ Add" label) triggers add. The photo button keeps its own + // click target so attach-image isn't ambushed. + addRow.addEventListener('click', (e) => { + if (e.target.closest('.note-form-photo-btn')) return; + if (e.target === addBtn || addBtn.contains(e.target)) return; + addBtn.click(); + }); + // The form's delegated "+ Add" handler does + // inputs.insertBefore(newRow, addBtn) + // : but addBtn is no longer a direct child of `.note-checklist-inputs` + // now that we wrapped it. Bind a fresh handler that does the same + // thing but inserts before the WRAPPING row, and stop propagation + // so the broken delegate never runs. + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const inputs = form.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const newRow = document.createElement('div'); + newRow.className = 'note-cl-row'; + newRow.draggable = true; + newRow.dataset.itemId = _uid(); + newRow.dataset.indent = '0'; + newRow.innerHTML = '⋮⋮'; + inputs.insertBefore(newRow, addRow); + _wireRow(newRow, inputs); + // Touch reorder on the freshly-added row's grip. + _wireChecklistTouchReorder(form); + newRow.querySelector('.note-cl-text')?.focus(); + }, { capture: true }); + } + + // Read-mode overlay for plain notes: render the content as a div with + // clickable hyperlinks, layered above the textarea. Tapping anywhere + // in the overlay that ISN'T a link hides the overlay and focuses the + // textarea so the user can start editing. Tapping a link opens it. + const ta = form.querySelector('.note-form-content'); + if (ta && (note.content || '').trim()) { + const reader = document.createElement('div'); + reader.className = 'note-form-content-reader'; + reader.innerHTML = _linkify(note.content || ''); + ta.style.display = 'none'; + ta.insertAdjacentElement('beforebegin', reader); + reader.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let links open normally + reader.remove(); + ta.style.display = ''; + // Let the browser place the cursor naturally : forcing + // setSelectionRange right after focus() raced with the underlying + // tap event and produced inconsistent cursor positions on mobile. + ta.focus({ preventScroll: true }); + }); + } + + // Opening an EXISTING note → read mode, no keyboard pop. Only a + // brand-new note (created via the + button) should auto-focus an + // input field. The user can tap the content to switch to edit. + // (New-note creation flows through _createNote, not this function.) + } + + function _closeMobileFullscreenEdit(opts = {}) { + if (!_mobileFsOverlay) return; + const overlay = _mobileFsOverlay; + overlay.querySelector('.note-form')?._flushDraft?.(); + _mobileFsOverlay = null; + // If the form has a Save button, click it on close so edits aren't lost + // when the user uses the back arrow instead of an explicit Save. + if (opts.save) { + const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]'); + if (saveBtn) try { saveBtn.click(); } catch {} + } + overlay.classList.remove('open'); + overlay.classList.add('closing'); + setTimeout(() => { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + _mobileFsNoteId = null; + app.setEditingId(null); + // Refresh the grid so any save the user made is reflected. + if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); } + }, 220); + } + + function _addRowReadMode(row) { + const txt = row.querySelector('.note-cl-text'); + if (!txt) return; + const val = txt.value || ''; + if (!/(https?:\/\/|www\.)/i.test(val)) return; + if (row.querySelector('.note-cl-text-reader')) return; // already wired + const span = document.createElement('span'); + span.className = 'note-cl-text-reader'; + span.innerHTML = _linkify(val); + txt.style.display = 'none'; + txt.insertAdjacentElement('beforebegin', span); + span.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let the link open + span.remove(); + txt.style.display = ''; + txt.focus({ preventScroll: true }); + }); + } + + return { _isNotesMobileMode, _openMobileFullscreenEdit, _closeMobileFullscreenEdit, _addRowReadMode }; +} diff --git a/frontend/src/features/notes/ui/mobileReorder.js b/frontend/src/features/notes/ui/mobileReorder.js new file mode 100644 index 000000000..b5f6404d3 --- /dev/null +++ b/frontend/src/features/notes/ui/mobileReorder.js @@ -0,0 +1,214 @@ +/** Mobile card long press, placeholder drag and reorder persistence. */ +export function createMobileReorder({ + lifecycle, + _applyMasonry, + _enterSelectMode, + commitMobileOrder, + app, +}) { + function _bindLongPressDrag(card) { + let pressTimer = null; + let startX = 0, startY = 0; + let armed = false; + const CANCEL_PX = 8; + const HOLD_MS = 450; + + card.addEventListener('touchstart', (e) => { + // Don't fight scroll on touchpoints over real interactive children + // (in mobile-mode they're CSS-hidden anyway, but be defensive). + if (e.target.closest('button, input, a, .note-form')) return; + if (e.touches.length !== 1) return; + armed = true; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + pressTimer = setTimeout(() => { + if (!armed) return; + try { navigator.vibrate?.(15); } catch {} + app.setSuppressNextNoteTapId(card.dataset.noteId); + _enterSelectMode(card.dataset.noteId); + }, HOLD_MS); + }, { passive: true }); + card.addEventListener('touchmove', (e) => { + if (!armed) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + } + }, { passive: true }); + const cancel = () => { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + }; + lifecycle.onDispose(card, cancel); + card.addEventListener('touchend', cancel, { passive: true }); + card.addEventListener('touchcancel', cancel, { passive: true }); + } + + let _dragState = null; + + let _docDragHandlersBound = false; + + function _enterDragMode(initialCard, initialTouch) { + document.body.classList.add('notes-drag-mode'); + document.querySelectorAll('.note-card').forEach(_setupDragForCard); + if (!_docDragHandlersBound) { + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchmove', _onDocTouchMove, { passive: false }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchend', _onDocTouchEnd, { passive: true }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchcancel', _onDocTouchEnd, { passive: true }); + _docDragHandlersBound = true; + } + // Auto-grab the card the user long-pressed : they're already holding + // their finger on it, so begin the drag straight away. Releasing + // (touchend) commits the reorder AND exits drag mode in one motion. + if (initialCard && initialTouch) { + _beginGrab(initialCard, initialTouch); + } + } + + function _exitDragMode() { + document.body.classList.remove('notes-drag-mode'); + if (_dragState) { + // Defensive: if exit fires while a drag is in flight, snap the card back. + _onDocTouchEnd(); + } + // Leave _docDragHandlersBound true so re-entering drag mode reuses them. + } + + function _setupDragForCard(card) { + if (card.dataset.dragBound === '1') return; + card.dataset.dragBound = '1'; + card.addEventListener('touchstart', (e) => { + if (!document.body.classList.contains('notes-drag-mode')) return; + if (e.touches.length !== 1) return; + if (_dragState) return; + e.preventDefault(); + e.stopPropagation(); + _beginGrab(card, e.touches[0]); + }, { passive: false }); + } + + function _beginGrab(card, touch) { + const rect = card.getBoundingClientRect(); + const prevStyle = card.getAttribute('style') || ''; + // Placeholder fills the card's old slot so the grid layout doesn't reflow. + const placeholder = document.createElement('div'); + placeholder.className = 'note-card-placeholder'; + placeholder.style.width = rect.width + 'px'; + placeholder.style.height = rect.height + 'px'; + placeholder.style.margin = getComputedStyle(card).margin; + if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd; + const grid = card.parentNode; + grid.insertBefore(placeholder, card); + + // Detach the card visually : fixed-position, anchored to the finger. + card.classList.add('note-card-dragging'); + card.style.position = 'fixed'; + card.style.left = rect.left + 'px'; + card.style.top = rect.top + 'px'; + card.style.width = rect.width + 'px'; + card.style.height = rect.height + 'px'; + card.style.margin = '0'; + card.style.zIndex = '10001'; + // pointer-events:none so elementFromPoint sees the card BENEATH the finger + card.style.pointerEvents = 'none'; + + _dragState = { + card, placeholder, grid, prevStyle, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onDocTouchMove(e) { + if (!_dragState) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const { card, placeholder, grid } = _dragState; + card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px'; + const quickAdd = grid.querySelector('.notes-quick-add'); + const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top; + const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8); + const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY)); + card.style.top = nextTop + 'px'; + + const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY)); + const under = document.elementFromPoint(touch.clientX, hitY); + const target = under && under.closest + ? under.closest('.note-card:not(.note-card-dragging)') + : null; + if (!target || target === card) return; + if (target.parentNode !== grid) return; + + // Move the PLACEHOLDER (not the card) above or below the target depending + // on which half of the target the finger is in. This is the hysteresis + // that stops the oscillation : once the placeholder moves past a card, + // the cursor has to cross THAT card's midpoint in the other direction + // to swap back. + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (touch.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + grid.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + grid.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onDocTouchEnd() { + if (!_dragState) return; + const { card, placeholder, grid, prevStyle } = _dragState; + _dragState = null; + // Animate the card from its current fixed position to where the + // placeholder sits, then re-parent and clear inline styles. Drag + // mode auto-exits once the snap finishes : release = done. + const phRect = placeholder.getBoundingClientRect(); + card.style.transition = 'left 0.2s ease, top 0.2s ease'; + card.style.left = phRect.left + 'px'; + card.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + // Restore the card's pre-drag inline styles. Mobile masonry stores + // grid-row-end inline, and custom backgrounds use inline style too; wiping + // cssText made dropped cards collapse into neighboring notes in grid view. + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + _applyMasonry(grid); + _commitNoteReorder(); + // One drag, one exit : release ends the rearrange session entirely. + if (document.body.classList.contains('notes-drag-mode')) { + document.body.classList.remove('notes-drag-mode'); + } + }, 210); + } + + async function _commitNoteReorder() { + const grid = document.querySelector('#notes-pane .notes-pane-body'); + if (!grid) return; + const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean); + if (!ids.length) return; + await commitMobileOrder(ids); + } + + function dispose() { + if (_dragState) { + const { card, placeholder, prevStyle } = _dragState; + _dragState = null; + placeholder.parentNode?.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + } + document.body.classList.remove('notes-drag-mode'); + _docDragHandlersBound = false; + } + return { dispose, _bindLongPressDrag, _enterDragMode, _exitDragMode, _setupDragForCard, _beginGrab, _onDocTouchMove, _onDocTouchEnd, _commitNoteReorder }; +} diff --git a/frontend/src/features/notes/ui/panel.js b/frontend/src/features/notes/ui/panel.js new file mode 100644 index 000000000..2265897c0 --- /dev/null +++ b/frontend/src/features/notes/ui/panel.js @@ -0,0 +1,506 @@ +/** Panel mounting, keyboard handlers, minimization and note navigation. */ +export function createPanel({ + wireToolbar, + wireSelectionActions, + _wireNotesWindow, + _restoreNotesSidebarDock, + _bringNotesToFront, + disposeUI, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}) { + function _forceCloseNotesPanel() { + app.setOpen(false); + app.setEditingId(null); + try { _commitOpenInPlaceEditor(); } catch {} + try { _closeMobileFullscreenEdit({ save: true }); } catch {} + try { _clearViewedReminderGlows(); } catch {} + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); + document.getElementById('tool-notes-btn')?.classList.remove('active'); + try { Modals.unregister('notes-panel'); } catch {} + try { document.getElementById('notes-pane')?.remove(); } catch {} + try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} + try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} + } + + function _showNotesFirstOpenHint(pane) { + if (!pane || typeof persistence === 'undefined') return; + try { + if (persistence.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; + persistence.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); + } catch { + return; + } + + dismissOrRemove(document.getElementById('notes-first-open-hint')); + const hint = document.createElement('div'); + hint.id = 'notes-first-open-hint'; + hint.className = 'tour-hint'; + hint.innerHTML = ` +
Notes is your basic todo list, and also where reminders are managed.
+ + `; + document.body.appendChild(hint); + + const place = () => { + const r = pane.getBoundingClientRect(); + const hw = hint.offsetWidth || 260; + hint.style.top = Math.max(12, r.top + 58) + 'px'; + hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; + }; + requestAnimationFrame(() => { + place(); + hint.classList.add('tour-hint-in'); + }); + lifecycle.listen(hint, window, 'resize', place); + bindTourHintDismiss(hint, { + timeout: 6500, + fade: 180, + onDismiss: () => window.removeEventListener('resize', place), + }); + } + + function _ensureNotesChipRegistered() { + if (Modals.isRegistered('notes-panel')) return; + Modals.register('notes-panel', { + railBtnId: 'rail-notes', + sidebarBtnId: 'tool-notes-btn', + restoreFn: () => { openPanel(); }, + closeFn: () => { _forceCloseNotesPanel(); }, + }); + } + + function openPanel() { + if (state.open) { + _bringNotesToFront(); + return; + } + // Notes and the document editor occupy the same primary workspace. Preserve + // an open document as a dock chip instead of allowing the two panes to stack + // into a broken layout (or closing the user's document outright). + try { + if (window.documentModule?.isPanelOpen?.()) { + window.documentModule.closePanel('down'); + } + } catch (_) {} + app.setOpen(true); + app.setEditingId(null); + app.setSearchQuery(''); + _clearViewedReminderGlows(); + app.setFiredDotDismissedAt(Date.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + + const container = document.getElementById('chat-container'); + if (!container) return; + + document.body.classList.add('notes-view'); + + // On mobile the notes panel takes the whole screen : auto-close the + // sidebar so the panel isn't cropped underneath it. + if (window.innerWidth <= 768) { + const sb = document.getElementById('sidebar'); + if (sb) sb.classList.add('hidden'); + document.body.classList.add('sidebar-collapsed'); + } + // Mobile mode: tiles become read-only previews (no inline checkbox / + // edit / archive / etc.), tap opens a fullscreen edit overlay, + // long-press enters drag-to-reorder mode. See _bindCardEvents + + // .notes-mobile-mode CSS rules. + if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); + + // Toggle button state + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.add('active'); + + // Create panel + const pane = document.createElement('div'); + pane.id = 'notes-pane'; + pane.className = 'notes-pane'; + pane.innerHTML = ` + +
+

Notes

+ + + + +
+ + +
+ `; + + // On mobile open as a full-screen bottom sheet (slide up), not the desktop + // side panel. Set inline so it wins over the base .notes-pane rule regardless + // of cascade specifics (the CSS @media override wasn't reliably applying, + // which left it as a side panel squeezing the chat). + if (window.innerWidth <= 768) { + pane.style.position = 'fixed'; + pane.style.inset = 'var(--sheet-top) 0 0'; + pane.style.width = '100%'; + pane.style.maxWidth = '100%'; + pane.style.zIndex = '170'; + pane.style.borderRadius = '14px 14px 0 0'; + pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; + pane.style.transformOrigin = 'bottom center'; + } + + // Mount on body so Notes can behave like the other draggable windows. On + // desktop it is immediately docked to the right by _restoreNotesSidebarDock. + const backdrop = document.createElement('div'); + backdrop.className = 'notes-pane-backdrop'; + backdrop.id = 'notes-pane-backdrop'; + backdrop.addEventListener('click', (ev) => { + if (ev.target === backdrop) closePanel('down'); + }); + backdrop.appendChild(pane); + document.body.appendChild(backdrop); + _wireNotesWindow(pane); + _restoreNotesSidebarDock(pane); + _bringNotesToFront(pane); + + // Events + // (Close chevron removed : swipe down on mobile, tool-rail toggle on desktop.) + + // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). + // Mirrors the document sheet gesture : finger-following, velocity-based + // dismiss, rubber-band on up-drag, spring snap-back. + _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); + _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); + + pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); + pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); + + const minBtn = document.getElementById('notes-minimize-btn'); + if (minBtn) minBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closePanel('down'); + }); + wireToolbar(pane); + // Select mode + document.getElementById('notes-select-btn').addEventListener('click', () => { + if (state.selectMode) _exitSelectMode(); else _enterSelectMode(); + }); + // Esc cancels select mode. Notes uses a toggle "Select" button rather + // than a *-bulk-cancel button, so the global Esc-cancel handler in + // keyboard-shortcuts.js can't reach it : handle it here. Capture phase + // + stopPropagation so Esc cancels select instead of closing the panel. + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + } + _notesSelectEscHandler = (e) => { + if (e.key === 'Escape' && state.selectMode) { + e.preventDefault(); + e.stopPropagation(); + _exitSelectMode(); + } + }; + document.addEventListener('keydown', _notesSelectEscHandler, true); + wireSelectionActions(); + // Escape: exit select mode first (if active), otherwise close the panel. + // Skip when the user is editing a form field : those have their own + // ESC-to-cancel handlers and we don't want to nuke the whole panel + // mid-edit. + // Idempotent: remove any previous handler from a prior openPanel so + // re-opening doesn't stack multiple handlers. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + _notesKeydownHandler = (e) => { + if (!state.open) return; + const t = e.target; + const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); + // Ctrl/Cmd+Z anywhere in the panel : undo the last note action. Skip when + // typing in a field so the browser's normal text-undo still works. + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { + if (inField) return; + if (workflows.undoCount() === 0) return; + e.preventDefault(); + _popAndRunUndo(); + return; + } + // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the + // user is editing or has an active text selection (let the browser handle + // a real text copy in that case). + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { + if (inField) return; + const sel = window.getSelection?.(); + if (sel && sel.toString && sel.toString().length > 0) return; + const hovered = document.querySelector('.note-card:hover'); + if (!hovered) return; + const id = hovered.dataset.noteId; + if (!id) return; + e.preventDefault(); + // Flash the ⋯ menu button (copy now lives in that menu). + const btn = hovered.querySelector('.note-card-corner-menu'); + _copyNote(id, btn); + return; + } + if (e.key !== 'Escape') return; + if (inField) return; + if (state.selectMode) { _exitSelectMode(); return; } + if (state.showingArchived) { + // Mirror the archive toggle button: flip back to active notes. + document.getElementById('notes-archive-toggle')?.click(); + return; + } + _forceCloseNotesPanel(); + }; + document.addEventListener('keydown', _notesKeydownHandler); + + // Load : show skeleton immediately, then fetch + _renderLoadingSkeleton(); + // Defer the highlight flush to the next frame so it runs *after* the cards + // are committed to the DOM (and any FLIP animations have settled), giving + // the querySelector lookups inside something to find. + _fetchNotes().then(() => { + if (!pane.isConnected || !state.open) { _startReminderLoop(); return; } + _renderNotes(); + requestAnimationFrame(() => _flushPendingHighlights()); + _startReminderLoop(); + _showNotesFirstOpenHint(pane); + }); + } + + function closePanel(direction) { + if (!state.open) return; + app.setOpen(false); + app.setEditingId(null); + _clearViewedReminderGlows(); + const _minimize = direction === 'down'; + if (_minimize) { + _ensureNotesChipRegistered(); + } else if (Modals.isRegistered('notes-panel')) { + Modals.unregister('notes-panel'); + } + + // Drop panel-owned keydown listeners. The reminder interval is page-owned. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view'); + document.body.classList.remove('notes-mobile-mode'); + document.body.classList.remove('notes-drag-mode'); + // Closing the panel should PRESERVE in-progress edits, not drop them. + // Commit any open in-place editor, and close the mobile fullscreen + // overlay with save=true so the note is persisted. + try { _commitOpenInPlaceEditor(); } catch {} + _closeMobileFullscreenEdit({ save: true }); + // /notes route may have collapsed the wide sidebar to a rail; restore. + try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} + + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.remove('active'); + + const pane = document.getElementById('notes-pane'); + const backdrop = document.getElementById('notes-pane-backdrop'); + if (pane) { + // Scale-out + fade. Match the enter animation duration so close feels + // like the same gesture played backwards. + pane.classList.add('notes-pane-leaving'); + const _cleanup = () => { + try { pane.remove(); } catch {} + try { backdrop?.remove(); } catch {} + }; + pane.addEventListener('animationend', _cleanup, { once: true }); + // Belt-and-braces: if animation is skipped (reduced motion / detached + // tab) the listener won't fire; remove after the expected duration. + setTimeout(_cleanup, 220); + } else if (backdrop) { + backdrop.remove(); + } + // Show the dock chip for a swipe-down minimize (tap it to reopen). + if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } + } + + function togglePanel() { + if (state.open) closePanel(); + else openPanel(); + } + + function isPanelOpen() { return state.open; } + + async function openNote(noteId) { + const wasOpen = !!(isPanelOpen && isPanelOpen()); + app.setShowingArchived(false); + app.setActiveLabel(null); + app.setActiveFilter(null); + app.setSearchQuery(''); + if (!wasOpen) { + openPanel(); + } else { + _bringNotesToFront(); + const searchEl = document.getElementById('notes-search'); + if (searchEl) searchEl.value = ''; + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.remove('notes-pane-archive'); + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) archiveBtn.classList.remove('active'); + await _fetchNotes(); + _renderNotes(); + } + // openPanel() kicks off _fetchNotes() asynchronously, so the cards for + // newly-created notes may not be in the DOM yet. Poll until the card exists. + if (!noteId) return; + let tries = 0; + const findAndFlash = () => { + const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) + || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); + if (card) { + _smoothScrollToNoteCard(card); + card.classList.add('note-card-flash'); + setTimeout(() => card.classList.remove('note-card-flash'), 1600); + return true; + } + return false; + }; + const tryNext = () => { + if (findAndFlash()) return; + if (++tries < 20) setTimeout(tryNext, 200); + }; + setTimeout(tryNext, 120); + } + + let _notesKeydownHandler = null; + + let _notesSelectEscHandler = null; + + function _wireNotesSwipeDismiss(el, pane) { + if (!el || !pane) return; + const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; + let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; + let dragging = false, cancelled = false; + + el.addEventListener('touchstart', (e) => { + if (window.innerWidth > 768 || e.touches.length !== 1) return; + if (e.target.closest('button, input, select, label, textarea')) return; + const t = e.touches[0]; + startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; + velocity = 0; dragging = false; cancelled = false; + }, { passive: true }); + + el.addEventListener('touchmove', (e) => { + if (cancelled || window.innerWidth > 768) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - startX); + const dy = t.clientY - startY; + if (!dragging) { + if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } + if (Math.abs(dy) > 8) { + dragging = true; + pane.style.animation = 'none'; + pane.style.transition = 'none'; + pane.style.willChange = 'transform'; + } else return; + } + const dt = e.timeStamp - lastT; + if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; + lastY = t.clientY; lastT = e.timeStamp; + e.preventDefault(); + pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; + }, { passive: false }); + + const endSwipe = () => { + if (!dragging) return; + dragging = false; + pane.style.willChange = ''; + const dy = lastY - startY; + if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { + // Slide fully off-screen, then minimise. Keep it translated down (don't + // reset) so it doesn't flash back before closePanel removes it. + pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; + pane.style.transform = 'translateY(100%)'; + setTimeout(() => closePanel('down'), 200); + } else { + pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; + pane.style.transform = ''; + setTimeout(() => { pane.style.transition = ''; }, 260); + } + }; + el.addEventListener('touchend', endSwipe, { passive: true }); + el.addEventListener('touchcancel', endSwipe, { passive: true }); + } + + function _smoothScrollToNoteCard(card) { + if (!card) return; + const scroller = card.closest('.notes-pane-body'); + if (!scroller) { + try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2; + scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' }); + } + + function disposeOwnedPortals() { + document.querySelectorAll('.note-reminder-menu, .note-corner-menu-dropdown, #notes-first-open-hint').forEach(dismissOrRemove); + } + + return { _forceCloseNotesPanel, openPanel, closePanel, togglePanel, isPanelOpen, openNote, _smoothScrollToNoteCard }; +} diff --git a/frontend/src/features/notes/ui/quickAdd.js b/frontend/src/features/notes/ui/quickAdd.js new file mode 100644 index 000000000..3a723fcdc --- /dev/null +++ b/frontend/src/features/notes/ui/quickAdd.js @@ -0,0 +1,170 @@ +/** Quick-add expansion, draft restoration and opening inline editors. */ +export function createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}) { + function _renderQuickAdd(body) { + const wrap = document.createElement('div'); + wrap.className = 'notes-quick-add'; + // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw : + // drawing happens in the expanded form). The pill that's active steers + // both the placeholder and the type the form opens in. + wrap.innerHTML = ` +
+ + +
+ + + `; + body.appendChild(wrap); + + const input = wrap.querySelector('.notes-quick-input'); + const seg = wrap.querySelector('.notes-quick-type-seg'); + let currentType = 'todo'; + const setType = (t) => { + if (t !== 'note' && t !== 'todo') return; + currentType = t; + seg.classList.toggle('is-todo', t === 'todo'); + seg.classList.toggle('is-note', t === 'note'); + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + const on = p.dataset.type === t; + p.classList.toggle('active', on); + p.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…'; + }; + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + p.addEventListener('click', (e) => { + e.stopPropagation(); + setType(p.dataset.type); + }); + }); + // Click input or type → expand to full form + const expandToForm = (initialType = 'note', initialText = '') => { + app.setEditingId('__new__'); + const form = _buildForm({ note_type: initialType }); + form.classList.add('note-form-new'); + if (initialText) { + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.value = initialText; + } + const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid') + && window.matchMedia('(max-width: 768px)').matches; + if (mobileGrid) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = 'span 64'; + } + wrap.replaceWith(form); + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) { + titleEl.focus(); + // Move caret to end + titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length); + } + }; + // Expand only on real intent: a click directly on the input, or actual + // typing. Focus alone : including focus stolen from a missed nearby + // click : no longer creates an empty form. + input.addEventListener('click', () => expandToForm(currentType, input.value)); + input.addEventListener('input', () => expandToForm(currentType, input.value)); + wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => { + e.stopPropagation(); + expandToForm(currentType); + // Trigger photo input on the new form + setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50); + }); + } + + function _createNote(type = 'todo') { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || state.editingId === '__new__') return; + app.setEditingId('__new__'); + // Restore an unsaved new-note draft if one survived a prior close/loss. + const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__'); + const form = _buildForm(_n); + form.classList.add('note-form-new'); + body.prepend(form); + const initialField = type === 'note' + ? form.querySelector('.note-form-content') + : form.querySelector('.note-cl-text, .note-form-title'); + initialField?.focus(); + if (restored) uiModule.showToast('Restored unsaved note'); + } + + function newNote() { + if (!state.open) openPanel(); + requestAnimationFrame(() => _createNote('note')); + } + + function _editNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + app.setEditingId(id); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) return; + // Restore an unsaved draft (from a prior connection loss / close) over + // the saved note so the user picks up where they left off. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + card.replaceWith(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + // Pinned notes live in the first masonry column : the edit form has + // column-span:all, which can leave the form rendered above the fold or + // visually buried under neighboring pinned cards. Bring it into view + // (and onto a higher stacking context) so editing a pinned note always + // pops to the top. + form.style.position = 'relative'; + form.style.zIndex = '5'; + // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px) + // after replaceWith, which is much shorter than the actual form. Recompute + // masonry so the form gets the correct row span and cards below stop + // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as + // the user types / adds checklist items. + const _body = form.closest('.notes-pane-body'); + if (_body) { + _applyMasonry(_body); + requestAnimationFrame(() => _applyMasonry(_body)); + } + requestAnimationFrame(() => { + try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); } + catch { form.scrollIntoView(); } + }); + // Pick the most useful field to focus. On phones especially, the user + // taps Edit to type : landing in the title when there's already a title + // (and likely a body to extend) loses momentum. Prefer the body textarea + // for plain notes, the first checklist item for todos, fall back to title. + const _focusBest = () => { + if (note.note_type === 'note' || !note.note_type) { + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; } + } + if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') { + // Last non-empty checklist row, or the first row if all empty. + const rows = form.querySelectorAll('.note-cl-row .note-cl-text'); + let target = null; + for (const inp of rows) { if ((inp.value || '').trim()) target = inp; } + target = target || rows[0]; + if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; } + } + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.focus(); + }; + _focusBest(); + } + + return { _renderQuickAdd, _createNote, newNote, _editNote }; +} diff --git a/frontend/src/features/notes/ui/reminderDisplay.js b/frontend/src/features/notes/ui/reminderDisplay.js new file mode 100644 index 000000000..5f6507d46 --- /dev/null +++ b/frontend/src/features/notes/ui/reminderDisplay.js @@ -0,0 +1,129 @@ +/** Reminder badges, sticky card highlights and entry scrolling. */ +import { _isNoteFullyDone } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}) { + function _clearViewedReminderGlows() { + const active = _loadActiveHighlights(); + if (!active.size) return; + _saveActiveHighlights(new Set()); + document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => { + card.classList.remove('note-card-reminder-fired-sticky'); + }); + } + + function _setReminderCardGlow(noteId, on = true) { + if (!noteId) return; + const active = _loadActiveHighlights(); + if (on) active.add(noteId); + else active.delete(noteId); + _saveActiveHighlights(active); + document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => { + card.classList.toggle('note-card-reminder-fired-sticky', on); + }); + } + + function _queuePendingHighlight(noteId) { + const set = _loadPendingHighlights(); + set.add(noteId); + _savePendingHighlights(set); + } + + function _flushPendingHighlights() { + // Fresh firings (queued by the background loop while the panel was closed) + // glow unconditionally : a notification just told the user something + // happened, so we always point at the note even if it was glowed before. + const queued = _loadPendingHighlights(); + const glowed = _loadGlowedReminders(); + const toGlow = new Set(queued); + // For notes that are merely overdue at open time (no fresh firing event), + // only glow the ones we haven't already shown : otherwise reopening the + // panel keeps lighting up old reminders forever. + for (const n of state.notes) { + if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue; + if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id); + } + // Always consume the queue. + _savePendingHighlights(new Set()); + if (!toGlow.size) return; + let firstCard = null; + for (const id of toGlow) { + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) continue; + _setReminderCardGlow(id, true); + if (!firstCard) firstCard = card; + glowed.add(id); + } + _saveGlowedReminders(glowed); + // Bring the first one into view so it can't get buried below the fold. + if (firstCard) { + requestAnimationFrame(() => { + _smoothScrollToNoteCard(firstCard); + }); + } + } + + function _updateRailBadge() { + const fired = _countFiredReminders(); + // Rail (mini sidebar) : only show the count when reminders have ACTUALLY + // fired since the last dismissal (i.e. you haven't opened notes yet). + // Showing every overdue note forever made the badge feel permanent. + const railBtn = document.getElementById('rail-notes'); + if (railBtn) { + let badge = railBtn.querySelector('.rail-notes-badge'); + if (fired > 0) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'rail-notes-badge'; + railBtn.appendChild(badge); + } + badge.textContent = fired > 99 ? '99+' : String(fired); + badge.classList.add('fired'); + } else if (badge) { + badge.remove(); + } + } + // Main sidebar button + const sidebarBtn = document.getElementById('tool-notes-btn'); + if (sidebarBtn) { + let dot = sidebarBtn.querySelector('.tool-notes-dot'); + if (fired > 0) { + if (!dot) { + dot = document.createElement('span'); + dot.className = 'tool-notes-dot'; + const newNoteBtn = sidebarBtn.querySelector('#notes-new-note-btn'); + sidebarBtn.insertBefore(dot, newNoteBtn || null); + } + } else if (dot) { + dot.remove(); + } + } + // Individual note cards : pulse ones with fired reminders + document.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note || note.archived || _isNoteFullyDone(note)) { + card.classList.remove('note-card-reminder-due'); + return; + } + if (note.due_date && _hasTimeComponent(note.due_date)) { + const t = new Date(note.due_date).getTime(); + card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now()); + } else { + card.classList.remove('note-card-reminder-due'); + } + }); + } + + return { _clearViewedReminderGlows, _setReminderCardGlow, _queuePendingHighlight, _flushPendingHighlights, _updateRailBadge }; +} diff --git a/frontend/src/features/notes/ui/reminderPicker.js b/frontend/src/features/notes/ui/reminderPicker.js new file mode 100644 index 000000000..da9d00398 --- /dev/null +++ b/frontend/src/features/notes/ui/reminderPicker.js @@ -0,0 +1,335 @@ +/** Reminder date presets and recurrence picker interactions. */ +import { _formatRepeatLabel, _toLocalDatetimeStr, _normalizeRepeat, _DAYS, _ORDINALS } from '../domain/recurrence.js'; +export function createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}) { + function _wireReminderPicker(form) { + // Reminder bell : opens dropdown menu + const remindBtn = form.querySelector('.note-form-remind-btn'); + const dueInput = form.querySelector('.note-form-due'); + const repeatInput = form.querySelector('.note-form-repeat'); + const tagsEl = form.querySelector('.note-form-reminder-tags'); + + function _renderReminderTag() { + if (!tagsEl) return; + const v = dueInput.value; + const rep = repeatInput.value || 'none'; + if (!v) { tagsEl.innerHTML = ''; return; } + const label = _formatReminderTag(v); + const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : ''; + tagsEl.innerHTML = ``; + tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => { + if (e.target.classList.contains('note-reminder-tag-x')) { + dueInput.value = ''; + repeatInput.value = 'none'; + _renderReminderTag(); + return; + } + _openReminderMenu(remindBtn || tagsEl, true); + }); + } + + function _openReminderMenu(anchor, isEdit = false) { + // Close any existing menu + document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + document.body.appendChild(menu); + + const presetItems = [ + { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) }, + { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) }, + { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) }, + { label: 'Select date and time', sub: '', action: () => _pickCustomDate() }, + ]; + + // Sub-page state for the repeat picker. null = top page. + // 'weekly' | 'monthly' | 'monthly_nth' + let subMode = null; + // Temporary state for monthly_nth so user can click N then weekday (or vice versa) + // before committing. + let nthDraft = { n: 0, w: -1 }; + + const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + function getNorm() { + if (!dueInput.value) return 'none'; + return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value)); + } + + function commit(val) { + repeatInput.value = val; + _renderReminderTag(); + menu.remove(); + } + + // Like commit, but first snaps dueInput.value forward to the next matching + // slot for the chosen recurrence. Use for weekly/monthly variants where the + // current due date may not match the chosen pattern (e.g. user picks + // "weekly on Mondays" while the date is a Wednesday). + function snapAndCommit(val) { + if (dueInput.value) { + const cur = new Date(dueInput.value); + const norm = _normalizeRepeat(val, cur); + const snapped = _snapToRepeat(cur, norm); + if (snapped) { + dueInput.value = _toLocalDatetimeStr(snapped); + if (remindBtn) remindBtn.classList.add('has-date'); + } + } + commit(val); + } + + function reposition() { + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 220; + const mh = menu.offsetHeight || 280; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + } + + function render() { + let html = ''; + + if (subMode === null) { + html += '
Remind me later
'; + for (let i = 0; i < presetItems.length; i++) { + const it = presetItems[i]; + html += ``; + } + if (isEdit && dueInput.value) { + const norm = getNorm(); + html += '
'; + html += '
Repeat
'; + // None + html += ``; + // Daily + html += ``; + // Weekly → + { + const isW = norm.startsWith('weekly:'); + const wd = isW ? parseInt(norm.split(':')[1], 10) : null; + const sub = isW && !isNaN(wd) ? `${_DAYS[wd]}` : ''; + html += ``; + } + // Monthly → + { + const isM = norm.startsWith('monthly:'); + const sub = isM ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + // Yearly + html += ``; + } + } else if (subMode === 'weekly') { + const norm = getNorm(); + const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1; + html += ``; + html += '
Weekly on…
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + } else if (subMode === 'monthly') { + const norm = getNorm(); + const dueDate = new Date(dueInput.value); + const dayN = dueDate.getDate(); + html += ``; + html += '
Monthly on…
'; + // Day N : uses the chosen date's day. Always offered. + const dayVal = `monthly:day:${dayN}`; + html += ``; + // Nth weekday → + { + const isNth = norm.startsWith('monthly:nth:'); + const sub = isNth ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + } else if (subMode === 'monthly_nth') { + // Pick ordinal (1..4) and weekday (0..6); commit when both chosen. + html += ``; + html += '
Nth weekday of month
'; + html += '
Which one
'; + html += '
'; + for (let i = 1; i <= 4; i++) { + html += ``; + } + html += '
'; + html += '
Weekday
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + html += '
'; + const ready = nthDraft.n > 0 && nthDraft.w >= 0; + const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday'; + html += ``; + } + + menu.innerHTML = html; + reposition(); + wire(); + } + + function wire() { + menu.querySelectorAll('[data-action]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + const a = el.dataset.action; + if (a === 'preset') { + const it = presetItems[parseInt(el.dataset.i, 10)]; + it.action(); + menu.remove(); + } else if (a === 'set') { + snapAndCommit(el.dataset.val); + } else if (a === 'sub') { + subMode = el.dataset.sub; + // Seed nth draft from saved value only on first entry : preserve + // in-progress picks across back-trips (Nth → back → Nth again). + if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) { + const norm = getNorm(); + const m = norm.match(/^monthly:nth:(\d):(\d)$/); + if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) }; + } + render(); + } else if (a === 'back') { + subMode = null; + render(); + } else if (a === 'back-monthly') { + subMode = 'monthly'; + render(); + } else if (a === 'weekly-pick') { + snapAndCommit(`weekly:${el.dataset.wd}`); + } else if (a === 'nth-n') { + nthDraft.n = parseInt(el.dataset.n, 10); + render(); + } else if (a === 'nth-w') { + nthDraft.w = parseInt(el.dataset.wd, 10); + render(); + } else if (a === 'nth-save') { + if (nthDraft.n > 0 && nthDraft.w >= 0) { + snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`); + } + } + }); + }); + } + + render(); + // Click outside to close (single global handler attached after first paint) + setTimeout(() => { + const close = (e) => { + if (!menu.isConnected) { document.removeEventListener('click', close); return; } + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } + }; + if (menu.isConnected) lifecycle.listen(menu, document, 'click', close); + }, 0); + } + + function _monthlyShortDescriptor(norm) { + const parts = norm.split(':'); + if (parts[1] === 'day') return `Day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Last ${_DAYS[wd].slice(0, 3)}`; + } + return ''; + } + + function _setReminder(datetimeLocalStr) { + dueInput.value = datetimeLocalStr; + if (remindBtn) { + remindBtn.classList.add('has-date'); + // Jingle the bell. CSS handles the animation; remove + reflow + re-add + // so it replays every time the user sets/changes a reminder. + const _bell = remindBtn.querySelector('svg'); + if (_bell) { + _bell.classList.remove('jingling'); + void _bell.offsetWidth; + _bell.classList.add('jingling'); + setTimeout(() => _bell.classList.remove('jingling'), 700); + } + } + _renderReminderTag(); + _ensureNotificationPermission(); + } + + function _pickCustomDate() { + // Replace the dropdown menu with a small inline picker + document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); + menu.innerHTML = ` +
Pick date and time
+
+ +
+
+ + `; + document.body.appendChild(menu); + // Position next to the bell button + const anchor = remindBtn || form.querySelector('.note-form-reminder-tags'); + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 240; + const mh = menu.offsetHeight || 200; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + const dInput = menu.querySelector('.note-reminder-date-input'); + dInput.value = initial; + dInput.focus(); + if (typeof dInput.showPicker === 'function') { + try { dInput.showPicker(); } catch {} + } + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { + if (dInput.value) _setReminder(dInput.value); + close(); + }); + } + + if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); + _renderReminderTag(); + + + } + + return { _wireReminderPicker }; +} diff --git a/frontend/src/features/notes/ui/selection.js b/frontend/src/features/notes/ui/selection.js new file mode 100644 index 000000000..e64549dff --- /dev/null +++ b/frontend/src/features/notes/ui/selection.js @@ -0,0 +1,61 @@ +/** Selection controls and view feedback for accepted attachments. */ +export function createSelection({ + operations, + _renderNotes, + app, + state +}) { + function _enterSelectMode(initialId = null) { + app.setSelectMode(true); + app.clearSelection(); + if (initialId) app.selectId(initialId); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + if (bar) bar.classList.remove('hidden'); + if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } + _renderNotes(); + _updateBulkBar(); + } + + function _exitSelectMode() { + app.setSelectMode(false); + app.clearSelection(); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + const all = document.getElementById('notes-select-all'); + if (bar) bar.classList.add('hidden'); + if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } + if (all) all.checked = false; + _renderNotes(); + } + + function _updateBulkBar() { + const count = state.selectedIds.size; + const countEl = document.getElementById('notes-selected-count'); + const sendAgentBtn = document.getElementById('notes-send-agent'); + const archiveBtn = document.getElementById('notes-bulk-archive'); + const deleteBtn = document.getElementById('notes-bulk-delete'); + const allEl = document.getElementById('notes-select-all'); + if (countEl) countEl.textContent = `${count} Selected`; + if (sendAgentBtn) sendAgentBtn.disabled = count === 0; + if (archiveBtn) archiveBtn.disabled = count === 0; + if (deleteBtn) deleteBtn.disabled = count === 0; + if (allEl) allEl.checked = state.notes.length > 0 && state.notes.every(n => state.selectedIds.has(n.id)); + // Toggle select-mode class so todo dots don't react to hover + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.toggle('notes-select-mode', count > 0); + } + + function wireSelectionActions() { + document.getElementById('notes-select-all').addEventListener('change', e => { + if (e.target.checked) state.notes.forEach(n => app.selectId(n.id)); + else app.clearSelection(); + _renderNotes(); _updateBulkBar(); + }); + document.getElementById('notes-send-agent').addEventListener('click', operations.attachSelected); + document.getElementById('notes-bulk-archive').addEventListener('click', operations.archiveSelected); + document.getElementById('notes-bulk-delete').addEventListener('click', operations.deleteSelected); + document.getElementById('notes-bulk-cancel').addEventListener('click', _exitSelectMode); + } + return { wireSelectionActions, _enterSelectMode, _exitSelectMode, _updateBulkBar }; +} diff --git a/frontend/src/features/notes/ui/text.js b/frontend/src/features/notes/ui/text.js new file mode 100644 index 000000000..75ecbaf78 --- /dev/null +++ b/frontend/src/features/notes/ui/text.js @@ -0,0 +1,39 @@ +/** Escaping, safe image sources and link rendering. */ +export function createText({ + uiModule +}) { + function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(//g, '>'); } + + function _attrEsc(s) { + return String(s || '') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + .replace(/`/g, '`'); + } + + function _safeImgSrc(s) { + const v = (s || '').trim(); + if (!v) return ''; + if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v; + if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v; + return ''; + } + + function _linkify(s) { + const escaped = _esc(s); + const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g; + return escaped.replace(urlRe, (m) => { + let url = m; + // Trim a trailing ')' that doesn't have a matching '(' inside the URL + if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { + url = url.slice(0, -1); + } + const href = url.startsWith('www.') ? `https://${url}` : url; + return `${url}` + (url !== m ? m.slice(url.length) : ''); + }); + } + + return { _esc, _attrEsc, _safeImgSrc, _linkify }; +} diff --git a/frontend/src/features/notes/ui/toolbar.js b/frontend/src/features/notes/ui/toolbar.js new file mode 100644 index 000000000..3c3c9fe58 --- /dev/null +++ b/frontend/src/features/notes/ui/toolbar.js @@ -0,0 +1,78 @@ +/** Panel search, archive and list/grid controls. */ +export function createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}) { + function wireToolbar(pane) { + // Search + const searchEl = document.getElementById('notes-search'); + if (searchEl) { + searchEl.addEventListener('input', () => { + app.setSearchQuery(searchEl.value.trim().toLowerCase()); + _renderNotes(); + }); + } + + // View toggle + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) { + const ARCHIVE_ICON = 'Archive'; + const CLOSE_ICON = 'Archive'; + const syncArchiveBtn = () => { + archiveBtn.classList.toggle('active', state.showingArchived); + archiveBtn.title = state.showingArchived ? 'Exit archive' : 'View archive'; + archiveBtn.style.opacity = state.showingArchived ? '1' : '0.8'; + // Swap to an X while in archive view so it doubles as a close-back- + // to-active-notes toggle. + archiveBtn.innerHTML = state.showingArchived ? CLOSE_ICON : ARCHIVE_ICON; + // Tint the whole pane so it's obvious you're not in the active list. + pane.classList.toggle('notes-pane-archive', state.showingArchived); + }; + syncArchiveBtn(); + archiveBtn.addEventListener('click', async () => { + app.setShowingArchived(!state.showingArchived); + app.clearSelection(); + syncArchiveBtn(); + // Brief fade so the body content swap doesn't snap : the bg-tint + // change is already eased by CSS transitions on .notes-pane*. + const _bodyEl = document.querySelector('#notes-pane .notes-pane-body'); + if (_bodyEl) { + _bodyEl.style.transition = 'opacity 0.18s ease'; + _bodyEl.style.opacity = '0.25'; + } + await _fetchNotes(); + _renderNotes(); + if (_bodyEl) { + requestAnimationFrame(() => { + _bodyEl.style.opacity = ''; + _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true }); + }); + } + }); + } + const viewBtn = document.getElementById('notes-view-toggle'); + if (viewBtn) { + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + // Label shows what you'll switch TO : "Grid" while in list, "List" while in grid. + const _setViewLabel = () => { + const lbl = viewBtn.querySelector('.notes-header-btn-label'); + if (lbl) lbl.textContent = state.viewMode === 'grid' ? 'List' : 'Grid'; + }; + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + viewBtn.addEventListener('click', () => { + app.setViewMode(state.viewMode === 'grid' ? 'list' : 'grid'); + try { persistence.setItem('odysseus-notes-view', state.viewMode); } catch {} + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + }); + } + + } + return { wireToolbar }; +} diff --git a/frontend/src/features/notes/ui/windowLayout.js b/frontend/src/features/notes/ui/windowLayout.js new file mode 100644 index 000000000..9e9173a47 --- /dev/null +++ b/frontend/src/features/notes/ui/windowLayout.js @@ -0,0 +1,114 @@ +/** Shared-window integration: safe fullscreen bounds, docking and stacking. */ +export function createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}) { + function _notesFullscreenSafeRect() { + const vw = window.innerWidth || document.documentElement.clientWidth || 0; + const vh = window.innerHeight || document.documentElement.clientHeight || 0; + let left = 0; + let right = vw; + + const sidebar = document.getElementById('sidebar'); + const rail = document.getElementById('icon-rail'); + const hamburgerRight = document.body.classList.contains('hamburger-right') + || sidebar?.classList.contains('right-side') + || rail?.classList.contains('right-side'); + + const reserve = (el) => { + if (!el || getComputedStyle(el).display === 'none') return; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + if (hamburgerRight) right = Math.min(right, rect.left); + else left = Math.max(left, rect.right); + }; + + if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar); + reserve(rail); + + // The fixed hamburger can remain visible even when the rail/sidebar is + // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it. + const hamburger = document.getElementById('hamburger-btn'); + if (hamburger && getComputedStyle(hamburger).display !== 'none') { + const rect = hamburger.getBoundingClientRect(); + const pad = 8; + if (hamburgerRight) right = Math.min(right, rect.left - pad); + else left = Math.max(left, rect.right + pad); + } + + left = Math.max(0, Math.min(left, vw - 80)); + right = Math.max(left + 80, Math.min(right, vw)); + return { left, top: 0, width: right - left, height: vh }; + } + + function _wireNotesWindow(pane) { + if (!pane || pane.dataset.windowDragWired === '1') return; + const header = pane.querySelector('.notes-pane-header'); + if (!header) return; + pane.dataset.windowDragWired = '1'; + makeWindowDraggable(pane, { + content: pane, + header, + fsClass: 'notes-window-fullscreen', + skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber', + enableDock: true, + enableLeftDock: true, + onEnterFullscreen: () => { + pane.classList.add('notes-window-fullscreen'); + snapModalToZone(pane, { + name: 'fullscreen', + rect: _notesFullscreenSafeRect(), + }); + }, + onExitFullscreen: () => { + _restoreNotesSidebarDock(pane); + }, + }); + } + + function _clearNotesSnapStyles(pane) { + if (!pane) return; + const hadLeft = pane.classList.contains('modal-left-docked'); + const hadRight = pane.classList.contains('modal-right-docked'); + pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked'); + if (hadLeft) clearDockSide('left', pane); + if (hadRight) clearDockSide('right', pane); + ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height', + 'max-height', 'margin', 'transform', 'border-radius'] + .forEach((prop) => pane.style.removeProperty(prop)); + delete pane.dataset._tilePreSnap; + delete pane.dataset._tileZone; + delete pane._preDockSnapshot; + delete pane._dockSide; + delete pane._dockSuspended; + } + + function _restoreNotesSidebarDock(pane) { + if (!pane || window.innerWidth <= 768) return; + _clearNotesSnapStyles(pane); + if (!pane.isConnected) return; + applyEdgeDock(pane, 'right'); + } + + function _topToolWindowZ(exclude = null) { + return topToolWindowZ({ exclude }); + } + + function _bringNotesToFront(pane = document.getElementById('notes-pane')) { + if (!pane) return; + const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement; + const z = _topToolWindowZ(backdrop) + 1; + if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important'); + try { + window.dispatchEvent(new CustomEvent('odysseus:modal-opened', { + detail: { id: 'notes-panel', modal: pane }, + })); + } catch (_) {} + } + + + return { _wireNotesWindow, _restoreNotesSidebarDock, _bringNotesToFront }; +} diff --git a/frontend/src/package.json b/frontend/src/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/frontend/src/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/frontend/src/shared/actionMenuOrder.js b/frontend/src/shared/actionMenuOrder.js new file mode 100644 index 000000000..f5cd80b9d --- /dev/null +++ b/frontend/src/shared/actionMenuOrder.js @@ -0,0 +1,29 @@ +/** Stable semantic ordering for item-level action menus. */ +const COMMON_ACTION_ORDER = [ + { rank: 200, pattern: /^(rename|change name)\b/i }, + { rank: 650, pattern: /^select\b/i }, + { rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i }, + { rank: 500, pattern: /^(copy|clone|duplicate)\b/i }, + { rank: 550, pattern: /^(export|download)\b/i }, + { rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i }, + { rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i }, + { rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i }, + { rank: 1000, pattern: /^cancel\b/i }, +]; + +// Canonical Select glyph for item-level menus. Toolbar toggles may use the +// smaller version, but dropdown rows should all use this exact SVG. +export const SELECT_MENU_ICON = ''; + +export function actionMenuRank(item) { + if (Number.isFinite(item?.menuOrder)) return item.menuOrder; + const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim(); + return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100; +} + +export function orderActionMenuItems(items) { + return items + .map((item, index) => ({ item, index, rank: actionMenuRank(item) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(entry => entry.item); +} diff --git a/frontend/src/shared/ui/confetti.js b/frontend/src/shared/ui/confetti.js new file mode 100644 index 000000000..205f9acdc --- /dev/null +++ b/frontend/src/shared/ui/confetti.js @@ -0,0 +1,28 @@ +/** Stateless celebration renderer shared by Notes and Compare. */ +export function spawnConfetti(cx, cy, count) { + const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff']; + for (let i = 0; i < count; i++) { + const el = document.createElement('div'); + el.className = 'confetti-piece'; + const color = colors[Math.floor(Math.random() * colors.length)]; + const size = 5 + Math.random() * 8; + const isCircle = Math.random() > 0.5; + el.style.width = size + 'px'; + el.style.height = (isCircle ? size : size * 0.6) + 'px'; + el.style.background = color; + el.style.borderRadius = isCircle ? '50%' : '2px'; + el.style.left = cx + 'px'; + el.style.top = cy + 'px'; + const angle = Math.random() * Math.PI * 2; + const speed = 60 + Math.random() * 160; + const dx = Math.cos(angle) * speed; + const dy = Math.sin(angle) * speed - 100; + const duration = 1.0 + Math.random() * 1.0; + el.animate([ + { transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 }, + { transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 } + ], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' }); + document.body.appendChild(el); + setTimeout(() => el.remove(), duration * 1000 + 50); + } +} diff --git a/integrations/claude/skills/odysseus/SKILL.md b/integrations/claude/skills/odysseus/SKILL.md index 31b40ee01..c8bd94db3 100644 --- a/integrations/claude/skills/odysseus/SKILL.md +++ b/integrations/claude/skills/odysseus/SKILL.md @@ -100,6 +100,14 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory - `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. - `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. +## Chat history + +- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope. + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID +``` + ## Email draft + send - Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. diff --git a/integrations/claude/skills/odysseus/scripts/odysseus_api.py b/integrations/claude/skills/odysseus/scripts/odysseus_api.py index 8a22eb494..54df59fa1 100755 --- a/integrations/claude/skills/odysseus/scripts/odysseus_api.py +++ b/integrations/claude/skills/odysseus/scripts/odysseus_api.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Small Odysseus scoped API helper for Codex terminal sessions.""" +"""Small Odysseus scoped API helper for Claude Code terminal sessions.""" from __future__ import annotations @@ -44,7 +44,7 @@ def _config() -> tuple[str, str] | None: if not token: missing.append("ODYSSEUS_API_TOKEN") if missing: - print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr) + print(f"missing {', '.join(missing)}; create a Claude Agent token in Odysseus Settings", file=sys.stderr) return None return base_url, token diff --git a/integrations/codex/skills/odysseus/SKILL.md b/integrations/codex/skills/odysseus/SKILL.md index d4cbdf726..a939cac33 100644 --- a/integrations/codex/skills/odysseus/SKILL.md +++ b/integrations/codex/skills/odysseus/SKILL.md @@ -100,6 +100,14 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex - `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. - `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. +## Chat history + +- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope. + +```bash +python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID +``` + ## Email draft + send - Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. diff --git a/launch-windows.ps1 b/launch-windows.ps1 index ab0e3542b..31c45a5fb 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -131,7 +131,8 @@ if (-not (Test-Path $venvPy)) { # 3. Install / update dependencies Write-Step "Installing dependencies (first run can take a few minutes)" -& $venvPy -m pip install --upgrade pip --quiet +& $venvPy -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt +if ($LASTEXITCODE -ne 0) { Fail "TLS dependency bootstrap failed." } & $venvPy -m pip install -r requirements.txt if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed. Scroll up for the pip error." } diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index e7c2ceb95..b43eeca87 100644 --- a/mcp_servers/email_server.py +++ b/mcp_servers/email_server.py @@ -1201,7 +1201,7 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict: "folder": folder, "is_read": bool(row.get("read")), "is_done": bool(row.get("done") or row.get("answered")), - "is_favorite": bool(row.get("favorite", row.get("flagged") or row.get("starred"))), + "is_favorite": bool(row.get("favorite") or row.get("flagged") or row.get("starred")), "spam_label": str(row.get("spam_label") or ""), "spam_score": int(row.get("spam_score") or 0), "list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""), @@ -1525,17 +1525,15 @@ def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None) return None if not _fixture_owner_has_rows(_current_owner()): return None - if not uid and not message_id: - return {"error": "Email UID or Message-ID is required"} - matches = [item for item in _fixture_email_rows(_current_owner()) - if _fixture_row_matches_account(item, account) - and _fixture_folder_matches(item.get("folder"), folder) - and (not uid or str(item.get("uid")) == str(uid)) - and (not message_id or str(item.get("message_id")) == str(message_id))] - if len(matches) > 1: - return {"error": "Email identity is ambiguous; specify its account and folder"} - if matches: - return matches[0] + for item in _fixture_email_rows(_current_owner()): + if not _fixture_row_matches_account(item, account): + continue + if not _fixture_folder_matches(item.get("folder"), folder): + continue + if uid and str(item.get("uid")) == str(uid): + return item + if message_id and str(item.get("message_id")) == str(message_id): + return item return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"} @@ -3519,6 +3517,21 @@ def _block_sender(sender=None, uids=None, folder="INBOX", account=None, reason=" } +def _attachment_dir(folder, uid, account_id) -> Path: + """Owner/account-scoped extraction directory (see src.mail_attachment_paths). + + `folder` comes from tool arguments and the IMAP server's own mailbox names + (`/` hierarchies, absolute or `..` segments), so it must not become a path; + the old `MAIL_ATTACHMENTS_DIR/{folder}_{uid}` wrote outside the root and + shared one directory between every owner's "INBOX 42". + """ + from src.mail_attachment_paths import attachment_scope_dir + + return attachment_scope_dir( + MAIL_ATTACHMENTS_DIR, folder, uid, owner=_current_owner(), account_id=account_id, + ) + + def _download_attachment(uid, index, folder="INBOX", account=None): """Extract a specific attachment to disk and return its local path.""" fixture = _fixture_attachment_source(uid, index, folder=folder, account=account) @@ -3527,7 +3540,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None): filename = str(att.get("filename") or f"attachment-{index}.txt") safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt" content = str(att.get("content") or "") - target_dir = Path(MAIL_ATTACHMENTS_DIR) / re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}") + target_dir = _attachment_dir(folder, uid, _row.get("account_id") or account) path = target_dir / safe_name size = len(content.encode("utf-8")) try: @@ -3560,7 +3573,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None): raw = msg_data[0][1] msg = email.message_from_bytes(raw) - target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}" + target_dir = _attachment_dir(folder, uid, _load_config(account).get("account_id") or account) filepath = _extract_attachment_to_disk(msg, index, target_dir) if not filepath: return {"error": f"Attachment index {index} not found"} diff --git a/mcp_servers/image_gen_server.py b/mcp_servers/image_gen_server.py index 6b68a27f8..697522e3d 100644 --- a/mcp_servers/image_gen_server.py +++ b/mcp_servers/image_gen_server.py @@ -139,6 +139,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: # Save to gallery try: from src.database import SessionLocal, GalleryImage + owner = str(arguments.get("_odysseus_owner") or "").strip() or None db = SessionLocal() db.add(GalleryImage( id=str(uuid.uuid4()), @@ -147,9 +148,16 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: model=model_id, size=size, quality=payload.get("quality", "medium"), + owner=owner, )) db.commit() db.close() + if owner: + try: + from src.generated_images import register_generated_image + register_generated_image(filename, owner) + except Exception: + pass except Exception: pass diff --git a/package-lock.json b/package-lock.json index 4d0b4b8d6..97fb6b572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "name": "odysseus", "devDependencies": { "@antithesishq/bombadil": "^0.7.0", - "@playwright/test": "^1.62.1" + "@playwright/test": "^1.62.1", + "acorn": "8.19.0" } }, "node_modules/@antithesishq/bombadil": { @@ -36,6 +37,19 @@ "node": ">=20" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/package.json b/package.json index 6f72684c8..aa454e017 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@antithesishq/bombadil": "^0.7.0", - "@playwright/test": "^1.62.1" + "@playwright/test": "^1.62.1", + "acorn": "8.19.0" } } diff --git a/requirements-bootstrap.txt b/requirements-bootstrap.txt new file mode 100644 index 000000000..da1d62220 --- /dev/null +++ b/requirements-bootstrap.txt @@ -0,0 +1,8 @@ +# Integrity-pinned bootstrap for the October 2026 eMudhra CA incident. +# Install with --require-hashes --only-binary=:all: --no-deps before other pip work. +# pip vendors its own certifi; updating the application bundle cannot update it. +# Direct universal wheels avoid index resolution through the old trust store. +pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl \ + --hash=sha256:fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70 +certifi @ https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 diff --git a/requirements.txt b/requirements.txt index 7fabaae93..415b4b503 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,13 +3,16 @@ uvicorn python-multipart python-dotenv httpx +# HTTPS clients using certifi must distrust the compromised eMudhra CA paths. +certifi>=2026.10.23 httpcore>=1.0,<2.0 pydantic>=2.13.4 pydantic-settings>=2.14.1 SQLAlchemy pypdf pypdfium2 -Pillow +# Pillow 13 fixes known image-processing advisories; keep local embeddings compatible. +Pillow>=13.0.0 faster-whisper pdfplumber beautifulsoup4 @@ -21,7 +24,9 @@ numpy # chromadb-client is the lightweight HTTP client (talks to a standalone # ChromaDB service); fastembed runs local ONNX embeddings. chromadb-client -fastembed +# gRPC vendors independent roots; 1.84.1 removes the incident trust anchors. +grpcio>=1.84.1 +fastembed>=0.9.1 youtube-transcript-api yt-dlp # Markdown rendering for research reports (src/visual_report.py). diff --git a/routes/assistant_routes.py b/routes/assistant_routes.py index f16f016e9..d31376dc3 100644 --- a/routes/assistant_routes.py +++ b/routes/assistant_routes.py @@ -16,6 +16,7 @@ from pydantic import BaseModel from core.database import SessionLocal, CrewMember, ScheduledTask from src.auth_helpers import get_current_user +from src.endpoint_resolver import resolve_owner_registered_endpoint_url from src.owner_identity import REQUEST_SENTINEL_OWNERS from src.task_scheduler import compute_next_run @@ -178,7 +179,13 @@ def setup_assistant_routes(task_scheduler) -> APIRouter: if payload.model is not None: crew_db.model = payload.model or None if payload.endpoint_url is not None: - crew_db.endpoint_url = payload.endpoint_url or None + try: + crew_db.endpoint_url = ( + resolve_owner_registered_endpoint_url(db, payload.endpoint_url, owner) + if payload.endpoint_url else None + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc if payload.timezone is not None: crew_db.timezone = payload.timezone or None diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 69ca3be56..1042437ac 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -15,6 +15,7 @@ from pathlib import Path from core.atomic_io import atomic_write_json, atomic_write_text from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR +from src.auth_helpers import _auth_disabled from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings from src.settings import ( @@ -129,6 +130,21 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: token = request.cookies.get(SESSION_COOKIE) return auth_manager.get_username_for_token(token) + def _require_admin_user(request: Request) -> Optional[str]: + """Resolve the current user, enforcing admin access. + + Honors AUTH_ENABLED=false the same way core.middleware.require_admin + does: when the operator has explicitly disabled auth (e.g. behind a + reverse proxy that handles login), there is no session user, and + admin-gated app configuration must stay reachable. + """ + user = _get_current_user(request) + if _auth_disabled(): + return user + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + return user + @router.post("/setup") async def first_run_setup(body: SetupRequest, request: Request): """Create initial admin account. Only works if no accounts exist.""" @@ -718,9 +734,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.post("/features") async def set_features(request: Request): """Admin only: update feature toggles.""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() current = _load_features() for key in current: @@ -738,16 +752,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: for keybinds + TTS prefs, so it stays callable without admin.""" user = _get_current_user(request) settings = without_retired_settings(_load_settings()) - if user and auth_manager.is_admin(user): + if _auth_disabled() or (user and auth_manager.is_admin(user)): return settings return scrub_settings(settings) @router.post("/settings") async def set_settings(request: Request): """Admin only: update app settings.""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() current = _load_settings() # Per-key validation for numeric settings: coerce to int and clamp to a @@ -780,9 +792,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: file: UploadFile = File(...), ): """Infer the general prose style from one user-supplied document.""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + user = _require_admin_user(request) filename = Path(file.filename or "sample.txt").name suffix = Path(filename).suffix.lower() allowed = { @@ -861,9 +871,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.get("/integrations") async def list_integrations_route(request: Request): """List all integrations (admin only, keys masked).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) items = load_integrations() # Mask API keys for frontend display safe = [mask_integration_secret(item) for item in items] @@ -877,9 +885,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.post("/integrations") async def create_integration(request: Request): """Create a new integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() item = add_integration(body) return {"ok": True, "integration": mask_integration_secret(item)} @@ -887,9 +893,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.put("/integrations/{integration_id}") async def update_integration_route(integration_id: str, request: Request): """Update an existing integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() item = update_integration(integration_id, body) if not item: @@ -899,9 +903,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.delete("/integrations/{integration_id}") async def delete_integration_route(integration_id: str, request: Request): """Delete an integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) ok = delete_integration(integration_id) if not ok: raise HTTPException(404, "Integration not found") @@ -910,9 +912,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.post("/integrations/{integration_id}/test") async def test_integration_route(integration_id: str, request: Request): """Test connectivity to an integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) integ = get_integration(integration_id) if not integ: raise HTTPException(404, "Integration not found") diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index ac7107ccb..aa84768fd 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str: lower = s.lower().strip() def _parse_time(t): - t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) + t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() @@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str: return base.replace(hour=t[0], minute=t[1]).isoformat() # Time-first: "3pm today", "11pm today", "9am tomorrow" - m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) + m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) if m: time_part, word = m.group(1).strip(), m.group(2) base = today @@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime: def _parse_time(t: str): """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" - t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) + t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None @@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime: # time-first: "3pm today", "9am tomorrow", "11pm tonight" # (parity with parse_due_for_user, which handles these via the same form) - m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) + m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) if m: time_part, word = m.group(1).strip(), m.group(2) base = today diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index eb5fcf489..8635e2c76 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -98,7 +98,9 @@ def clean_repeated_assistant_content(text: object) -> str: "", value, ).strip() - value = re.sub(r"(?is)\s*\s*$", "", value).strip() + # No leading `\s*`: .strip() removes that whitespace anyway, and scanning + # it from every offset of a long whitespace run was quadratic (ReDoS). + value = re.sub(r"(?is)\s*$", "", value).strip() return value _CASUAL_OPENING_RE = re.compile( @@ -549,6 +551,7 @@ async def auto_name_session(session_manager, sess): try: from src.llm_core import llm_call_async from src.task_endpoint import resolve_task_endpoint + from src.utility_effort import effort_for_call # Find first user message first_msg = "" @@ -568,6 +571,50 @@ async def auto_name_session(session_manager, sess): endpoint_url = str(getattr(sess, "endpoint_url", "") or "") model_name = str(getattr(sess, "model", "") or "") + + # The spec ("Ajax") model names its own chats from the user's first + # prompt. This function only runs after the first response stream has + # settled (auto_name_session_after_stream), so titling does not + # contend with the visible answer. Thinking is disabled and the + # budget kept small so the model returns a bare title. + try: + from src.model_profiles import uses_odysseus_progressive_thinking + _is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name) + except Exception: + _is_spec_model = False + if _is_spec_model and endpoint_url: + _headers = getattr(sess, "headers", None) + if isinstance(_headers, str): + try: + _headers = json.loads(_headers) + except Exception: + _headers = None + try: + title = await llm_call_async( + endpoint_url, + model_name, + [ + {"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."}, + {"role": "user", "content": first_msg}, + ], + temperature=0.3, + max_tokens=32, + headers=_headers or None, + timeout=20, + thinking_mode="off", + ) + except Exception as e: + logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e) + title = "" + title = str(title or "").strip().strip('"\'').strip() + from src.text_helpers import strip_think + title = strip_think(title, prose=False, prompt_echo=False) + if not title or len(title) >= 80 or "\n" in title: + title = fallback_session_title(first_msg) + session_manager.update_session_name(sess.id, title) + logger.info(f"Auto-named spec session {sess.id}: {title}") + return + if ( "ttft" in model_name.lower() or re.search(r":18\d{3}\b", endpoint_url) @@ -601,6 +648,7 @@ async def auto_name_session(session_manager, sess): max_tokens=64, headers=t_headers, timeout=15, + reasoning_effort=effort_for_call(t_url, t_model, owner, sess), ) title = title.strip().strip('"\'').strip() @@ -1008,6 +1056,7 @@ async def build_chat_context( chat_processor, message: str, session_id: str, + selected_notes_context: str | None = None, preset_id=None, att_ids: list = None, use_web=None, @@ -1180,6 +1229,23 @@ async def build_chat_context( if search_context and allow_tool_preprocessing and not casual_low_signal: preface.append(untrusted_context_message("prefetched search context", search_context)) + # Inject selected notes as context + if selected_notes_context: + selected_notes_context = ( + "The following selected notes and todos are reference context explicitly chosen " + "by the user. Use them directly when relevant to the user's request. Do not " + "ask for permission to use this context, and do not call note-management " + "tools just to retrieve the same notes; use those tools only for a requested " + "change or when this context is insufficient.\n\n" + + selected_notes_context + ) + preface.append( + untrusted_context_message( + "Selected notes and todos", + selected_notes_context, + ) + ) + # YouTube transcripts for transcript in preprocessed.youtube_transcripts: preface.append(untrusted_context_message("youtube transcript", transcript)) @@ -1324,13 +1390,17 @@ def _normalize_thinking(text: str) -> str: # Handle garbled tags: reasoning text followed by as separator # e.g. "The user said...I should respond.\nHey! What's up?" + # Linear form of `^([\s\S]+?)\n*\s*([\s\S]*?)(?:)?\s*$`: + # the lookbehind stops the lazy prefix re-scanning a newline run from every + # offset, and the optional trailing closer is dropped after the match + # instead of being retried at every body offset (both were quadratic). garbled = re.match( - r'^([\s\S]+?)\n*\s*([\s\S]*?)(?:)?\s*$', + r'^([\s\S]+?)(?\s*([\s\S]*)$', text, re.IGNORECASE ) if garbled: before = garbled.group(1).strip() - after = garbled.group(2).strip() + after = re.sub(r'$', '', garbled.group(2).rstrip(), flags=re.IGNORECASE).strip() # Only treat as garbled if the part before looks like reasoning reasoning_starts = ( 'The user ', 'I need ', 'I should ', 'I will ', diff --git a/routes/chat_routes.py b/routes/chat_routes.py index d3257882d..065fdb6c4 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -11,7 +11,7 @@ from urllib.parse import urlparse from datetime import datetime from typing import Dict, Any, AsyncGenerator, List, Optional -from fastapi import APIRouter, Request, HTTPException, Form, Query +from fastapi import APIRouter, Request, HTTPException, Form, Query, Depends from fastapi.responses import StreamingResponse from pydantic import ValidationError @@ -27,6 +27,7 @@ from src.llm_core import ( from src.agent_loop import ( stream_agent_loop, _configured_model_tool_surface, + _configured_model_tool_surface_raw, _local_media_needs_browser_render, _looks_like_workspace_coding_request, ) @@ -49,11 +50,17 @@ from src.foreground_model_routing import ( from src.session_search import search_session_messages from src.prompt_security import untrusted_context_message from core.exceptions import SessionNotFoundError -from src.auth_helpers import effective_user, get_current_user +from src.auth_helpers import ( + effective_user, + get_current_user, + is_delegated_credential, + require_api_token_scope, + require_chat_api_token_scope, +) from routes.session_routes import _verify_session_owner from routes.document_helpers import _owner_session_filter from core.database import SessionLocal, get_session_mode, set_session_mode -from core.database import Session as DBSession, ChatMessage as DBChatMessage +from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note from core.database import Document as DBDocument, ModelEndpoint from core.log_safety import redact_url from routes.research_routes import _resolve_research_endpoint @@ -80,6 +87,8 @@ from src.tool_policy import ( web_search_enabled_for_turn, ) from src.tool_approvals import tool_approval_store +from src.tool_approval_scopes import stamp_chat_session_grant +from src.tool_security import delegated_credential_blocked_tools from src.workspace_paths import backend_workspace_path from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES from src.model_profiles import ( @@ -320,13 +329,31 @@ def _is_external_discovery_request(text: str) -> bool: )) +_URL_SCHEME_RE = re.compile(r"https?://", re.I) +_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I) + + +def _mentions_pdf_url(value: str) -> bool: + """Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``. + + Checked per whitespace-free token from its first scheme only (a later + scheme's tail is a suffix of the first's), so a token packed with + `http://` repeats is scanned once instead of once per repeat (ReDoS). + """ + for token in value.split(): + scheme = _URL_SCHEME_RE.search(token) + if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1): + return True + return False + + def _prefers_structured_document_tools(text: str) -> bool: """Identify external paper/PDF extraction where shell is a bad source route.""" value = str(text or "") if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I): return False return bool( - re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I) + _mentions_pdf_url(value) or re.search( r"\b(?:paper|report|study)\b[\s\S]{0,1200}?" r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b", @@ -1265,6 +1292,23 @@ def _stream_failure_status(chunk: str) -> Optional[int]: return None +def _reject_delegated_tool_approval(request: Request) -> None: + """Refuse an approval answered by a bearer API token. + + A tool approval records that a HUMAN authorized one dangerous action. A + token is a delegated credential handed to an integration, so when it + answers the prompt it triggered, nobody is asked and the gate collapses + into an extra round trip. Owner and session already match here: the token + is answering on behalf of the account that minted it. + """ + if is_delegated_credential(request): + raise HTTPException( + 403, + "Tool approvals require an interactive session. " + "API tokens cannot authorize a gated action.", + ) + + def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool: """Persist a consumed approval decision on its existing tool event.""" @@ -1289,6 +1333,11 @@ def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool: if str(ask_user.get("approval_id") or "") != approval_key: continue ask_user["resolved"] = normalized_decision + stamp_chat_session_grant( + ask_user, + getattr(sess, "id", ""), + normalized_decision, + ) message_id = metadata.get("_db_id") resolved_metadata = { key: value for key, value in metadata.items() if key != "_db_id" @@ -2106,6 +2155,68 @@ def _set_user_time_from_request(request: Request) -> None: except Exception: pass +def _parse_selected_note_ids(raw) -> list[str]: + """Validate user-selected Note IDs before passing them to SQLAlchemy.""" + if raw is None or raw == "": + return [] + if not isinstance(raw, str) or len(raw) > 8192: + raise HTTPException(400, "Invalid selected_note_ids payload") + try: + parsed = json.loads(raw) + except (TypeError, ValueError) as exc: + raise HTTPException(400, "Invalid selected_note_ids JSON") from exc + if ( + not isinstance(parsed, list) + or len(parsed) > 50 + or any( + not isinstance(note_id, str) + or not note_id.strip() + or len(note_id) > 128 + for note_id in parsed + ) + ): + raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs") + return list(dict.fromkeys(parsed)) + + +def _format_selected_notes_context(notes) -> str: + """Format selected Notes/Todos as reference context for the model.""" + sections = [] + + for index, note in enumerate(notes, start=1): + lines = [ + f"--- Selected Note {index} ---", + f"Title: {note.title or '(untitled)'}", + f"Type: {note.note_type or 'note'}", + ] + + if note.content: + lines.extend([ + "Content:", + str(note.content), + ]) + + if note.items: + try: + items = json.loads(note.items) + except (TypeError, json.JSONDecodeError): + items = [] + + if isinstance(items, list): + lines.append("Items:") + for item in items: + if not isinstance(item, dict): + continue + + status = "done" if item.get("done") else "pending" + text = str(item.get("text") or "").strip() + + if text: + lines.append(f"- [{status}] {text}") + + sections.append("\n".join(lines)) + + return "\n\n".join(sections) def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager): """Use an explicit request override, then fall back to the active preset.""" @@ -2131,13 +2242,17 @@ def setup_chat_routes( webhook_manager=None, skills_manager=None, ) -> APIRouter: - router = APIRouter(tags=["chat"]) + router = APIRouter( + tags=["chat"], + dependencies=[Depends(require_chat_api_token_scope)], + ) # ------------------------------------------------------------------ # # POST /api/chat (non-streaming) # ------------------------------------------------------------------ # @router.post("/api/chat", response_model=Dict[str, Any]) async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]: + require_api_token_scope(request, "chat") _set_user_time_from_request(request) message = chat_request.message @@ -2354,6 +2469,7 @@ def setup_chat_routes( # ------------------------------------------------------------------ # @router.post("/api/chat_stream") async def chat_stream(request: Request) -> StreamingResponse: + require_api_token_scope(request, "chat") body = None try: if request.headers.get("content-type", "").startswith("application/json"): @@ -2371,6 +2487,8 @@ def setup_chat_routes( form_data = await request.form() message = form_data.get("message") session = form_data.get("session") + selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids")) + attachments = form_data.get("attachments") use_web = form_data.get("use_web") use_research = form_data.get("use_research") @@ -2473,7 +2591,7 @@ def setup_chat_routes( _explicit_web_intent = ( _explicit_url_target or bool(re.search( - r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b", + r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b", _msg_l, )) or requires_external_web_verification(message) @@ -2580,8 +2698,6 @@ def setup_chat_routes( or form_data.get("editor_session_id") or "" ).strip() - editor_state = form_data.get("editor_state") or "" - editor_screenshot = form_data.get("editor_screenshot") or "" photo_editor_requested = ( str(form_data.get("photo_editor") or "").strip().lower() in {"1", "true", "yes", "on"} @@ -2693,7 +2809,24 @@ def setup_chat_routes( current_rejected=workspace_rejected, ) owner = effective_user(request) + selected_notes = [] + if selected_note_ids: + notes_db = SessionLocal() + try: + selected_notes = ( + notes_db.query(Note) + .filter( + Note.id.in_(selected_note_ids), + Note.owner == owner, + ) + .all() + ) + finally: + notes_db.close() + selected_notes_context = _format_selected_notes_context(selected_notes) + if tool_approval_id: + _reject_delegated_tool_approval(request) from src.agent_runtime.authority import require_user_approval_request require_user_approval_request(request) pending_tool_approval = tool_approval_store.peek(tool_approval_id) @@ -2963,6 +3096,7 @@ def setup_chat_routes( sess, request, chat_handler, chat_processor, message=message, session_id=session, + selected_notes_context=selected_notes_context, preset_id=preset_id, att_ids=att_ids, use_web=use_web, @@ -3163,10 +3297,6 @@ def setup_chat_routes( ) ) if _photo_editor_active: - # The editor tool family drives the browser-side image editor; it - # replaces the generic edit_image instruction. Without an open - # editor session the tools cannot reach the canvas, so tell the - # user to open the image instead of emitting failing tool calls. if editor_session: editor_context = {'role': 'system', 'content': 'The image editor is open (session ' + editor_session + '). Use run_editor_command, ' @@ -3236,6 +3366,12 @@ def setup_chat_routes( if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities: _explicit_web_intent = False disabled_tools = set() + # Minting is admin-only, so every owner-keyed check below answers + # "admin" for a token. Cap it at the non-admin policy instead. + # stream_agent_loop repeats this from delegated_credential. + _delegated_credential = is_delegated_credential(request) + if _delegated_credential: + disabled_tools.update(delegated_credential_blocked_tools()) # Only disable bash when the caller *explicitly* set it to a falsy # value. When unset (None), defer to per-user privilege checks below. # Web search is per-turn opt-in: either the chat pre-search setting @@ -3478,7 +3614,7 @@ def setup_chat_routes( # The strict router (spec) surface picks its tools in src/spec_router.py # AFTER authority is built, so its admission is keyed off the surface. try: - _spec_surface = _configured_model_tool_surface( + _spec_surface = _configured_model_tool_surface_raw( getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user ) == "spec" except Exception: @@ -3490,15 +3626,8 @@ def setup_chat_routes( active_document=bool(active_doc), image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])), - spec_surface=_spec_surface, capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if ( - _explicit_browser_intent - or _external_discovery_intent - # Grant search/browser tools whenever the turn actually offers - # them (Web toggle on / not denied), not only for explicit - # browser wording. Otherwise the model sees web_search in its - # schema but every call is rejected by request authority. - or "web_search" not in disabled_tools + _explicit_browser_intent or _external_discovery_intent ) else set())), ) if exact_tool_approval is not None: @@ -3525,6 +3654,8 @@ def setup_chat_routes( if s["function"]["name"] != "host_shell"] _contract_mgr = get_mcp_manager() _owner_blocked = blocked_tools_for_owner(_user) + if _delegated_credential: + _owner_blocked.update(delegated_credential_blocked_tools()) if ( _workspace_tools_disabled_for_owner(_user) and not _native_workspace_contract @@ -3723,17 +3854,10 @@ def setup_chat_routes( ) # Every recovery path receives the same scope denial. The central # dispatcher also checks the immutable contract after rewrites. - # The strict router (spec) surface picks its own tools AFTER this - # contract is resolved, so scoping disabled_tools to the regex - # contract would empty its menu (e.g. all email/notes tools denied - # for a plain "search for the appointment email" turn). On spec - # turns keep only the pre-contract denials (user/privilege/global/ - # plan/compare) and let src/spec_router.py + the spec surface decide. - if not _spec_surface: - disabled_tools.update( - s["function"]["name"] for s in _contract_schemas - if not _turn_contract.permits(s["function"]["name"]) - ) + disabled_tools.update( + s["function"]["name"] for s in _contract_schemas + if not _turn_contract.permits(s["function"]["name"]) + ) # Contract resolution is the final policy-and-routing authority. # Some legacy/API-model paths arrive with a stale disabled snapshot # assembled before routing. The scope-denial pass above may retain @@ -4046,10 +4170,9 @@ def setup_chat_routes( owner=_user, size="auto", progress_callback=_image_progress_callback, - incognito=incognito, )) else: - _img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user, incognito=incognito)) + _img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user)) _img_started = time.time() _img_tick = 0 while not _img_task.done(): @@ -4161,8 +4284,6 @@ def setup_chat_routes( # indicator, but don't fold them into the saved # reply (mirrors the rewrite path below). if data.get("thinking"): - if thinking_mode == "off": - continue thinking_response += data["delta"] else: full_response += data["delta"] @@ -4588,6 +4709,7 @@ def setup_chat_routes( uploaded_files=ctx.uploaded_files, defer_context_shaping=_foreground_policy.enabled, external_untrusted_context_seen=external_untrusted_context_seen, + delegated_credential=_delegated_credential, exact_approval=exact_tool_approval, client_runtime_context=client_runtime_context, thinking_mode=thinking_mode, @@ -4605,8 +4727,6 @@ def setup_chat_routes( # Forward them for the live indicator, but keep # them out of the saved reply (same as chat mode). if data.get("thinking"): - if thinking_mode == "off": - continue thinking_response += data["delta"] else: full_response = _render_state.content @@ -4790,6 +4910,25 @@ def setup_chat_routes( _terminal_saved = True if _saved_id: yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n' + run_post_response_tasks( + sess, session_manager, session, message, _saveable_no_tool_response, + _metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager, + incognito=incognito, compare_mode=compare_mode, + character_name=ctx.preset.character_name, + agent_rounds=_agent_rounds, + agent_tool_calls=_agent_tool_calls, + skills_manager=skills_manager, + owner=_user, + extract_skills=( + user_requested_agent + and not tool_approval_continuation + ), + allow_background_extraction=_post_response_extraction_allowed( + tools_blocked=tool_policy.block_all_tool_calls, + tool_approval_continuation=tool_approval_continuation, + client_runtime_context=client_runtime_context, + ), + ) yield f'data: {json.dumps(_metrics_event)}\n\n' except json.JSONDecodeError: yield chunk @@ -4898,20 +5037,13 @@ def setup_chat_routes( async def _safe_stream() -> AsyncGenerator[str, None]: """Wrapper that guarantees _active_streams cleanup even if stream_with_save raises before reaching a mode-specific finally block.""" - _gen = stream_with_save() try: - while not _shutdown.is_shutting_down(): - try: - chunk = await _gen.__anext__() - except StopAsyncIteration: + async for chunk in stream_with_save(): + if _shutdown.is_shutting_down(): break yield chunk finally: _active_streams.pop(session, None) - try: - await _gen.aclose() - except Exception: - pass # Compare panes and explicitly unattended native clients are # short-lived, single-shot generations with nobody to resume them. diff --git a/routes/codex_routes.py b/routes/codex_routes.py index f42c6b632..542e44d49 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -35,6 +35,7 @@ CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"} CALENDAR_WRITE_SCOPES = {"calendar:write"} DOCS_READ_SCOPES = {"documents:read", "documents:write"} DOCS_WRITE_SCOPES = {"documents:write"} +CHAT_READ_SCOPES = {"chat"} WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"} @@ -161,6 +162,7 @@ def setup_codex_routes( memory_router: APIRouter | None = None, calendar_router: APIRouter | None = None, document_router: APIRouter | None = None, + history_router: APIRouter | None = None, ) -> APIRouter: router = APIRouter(prefix="/api/codex", tags=["codex"]) email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list") @@ -174,6 +176,7 @@ def setup_codex_routes( documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library") documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}") documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document") + chat_history_endpoint = _find_endpoint(history_router, "GET", "/api/history/{session_id}") @router.get("/capabilities") def capabilities(request: Request): @@ -219,6 +222,11 @@ def setup_codex_routes( "launch": scoped(COOKBOOK_LAUNCH_SCOPES), "actions": ["tasks", "servers", "output", "serve", "stop"], }, + "chat": { + "read": scoped(CHAT_READ_SCOPES), + "actions": ["read"], + "available": chat_history_endpoint is not None, + }, }, "safety": { "email_send_requires_confirmation": True, @@ -226,6 +234,33 @@ def setup_codex_routes( }, } + @router.get("/chat/{session_id}") + async def read_chat( + request: Request, + session_id: str, + offset: int = 0, + limit: int = 100, + ): + _scope_owner(request, CHAT_READ_SCOPES) + if chat_history_endpoint is None: + raise HTTPException(503, "chat history unavailable") + offset, limit = _clamp_pagination( + offset, + limit, + default_limit=100, + max_limit=100, + ) + result = await chat_history_endpoint(request, session_id, limit, offset) + return { + "session_id": session_id, + "name": result["name"], + "model": result["model"], + "messages": result["history"], + "total": result["total"], + "offset": result["offset"], + "limit": result["limit"], + } + @router.get("/plugin.zip") def plugin_zip(request: Request): require_authenticated_request(request) diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index e09bbbe37..02b419894 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -701,13 +701,17 @@ def setup_cookbook_routes() -> APIRouter: return cmd repo_id = "cyankiwi/MiniMax-M3-AWQ-INT4" - snapshot = ( - "/home/pewds/.cache/huggingface/hub/" - "models--cyankiwi--MiniMax-M3-AWQ-INT4/" - "snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b" - ) - if body[serve_i + 1] == repo_id: - body[serve_i + 1] = snapshot + hf_home = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface"))) + hf_cache = Path(os.environ.get("HUGGINGFACE_HUB_CACHE", str(hf_home / "hub"))) + snapshot_root = hf_cache / "models--cyankiwi--MiniMax-M3-AWQ-INT4" / "snapshots" + if body[serve_i + 1] == repo_id and snapshot_root.is_dir(): + installed_snapshots = sorted( + (p for p in snapshot_root.iterdir() if p.is_dir()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if installed_snapshots: + body[serve_i + 1] = str(installed_snapshots[0]) def add_env(key: str, value: str) -> None: if not any(p.startswith(f"{key}=") for p in env_parts): diff --git a/routes/document/document_routes.py b/routes/document/document_routes.py index 9a7743e8a..2b987e140 100644 --- a/routes/document/document_routes.py +++ b/routes/document/document_routes.py @@ -1185,7 +1185,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: async def ai_tidy_documents(request: Request) -> Dict[str, Any]: """Use AI to judge if documents are junk/test/accidental, then delete them. Caches verdicts so previously-reviewed docs are skipped.""" - from src.task_endpoint import resolve_task_endpoint + from src.task_endpoint import resolve_task_endpoint, task_llm_call_async from src.endpoint_resolver import resolve_endpoint from src.llm_core import llm_call_async @@ -1228,13 +1228,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: + "\n".join(doc_list) ) - response = await llm_call_async( - url, model, + response = await task_llm_call_async( [{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."}, {"role": "user", "content": prompt}], + fallback_url=url, fallback_model=model, fallback_headers=headers, + owner=user or None, temperature=0.1, max_tokens=200, - headers=headers, timeout=30, ) diff --git a/routes/editor_agent_routes.py b/routes/editor_agent_routes.py new file mode 100644 index 000000000..410bab501 --- /dev/null +++ b/routes/editor_agent_routes.py @@ -0,0 +1,1818 @@ +"""Editor agent routes: natural-language planning for the image editor. + +Provides: + POST /api/editor/plan -> {reply, planner, steps:[{op,params}], done?} + POST /api/editor/bridge/command -> enqueue validated ops for the open editor + GET /api/editor/bridge/events -> SSE stream of queued commands (browser subscriber) + POST /api/editor/bridge/result -> browser posts results/screenshots back + POST /api/editor/bridge/await -> wait for a queued command's result + +The browser editor (static/js/editor/nl-edit.js) posts {prompt, state, mode, screenshot} +and consumes {reply, planner, steps} or {done:true}. When mode == "check" and the model +reports completion, the route returns {"done": true} to stop the client's self-check loop. + +The planner is wired to the app's configured chat model (vision-capable when a screenshot +is supplied) via set_editor_planner(); a deterministic keyword planner is the fallback so +the editor always gets a usable plan. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +import re +import uuid +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from src import editor_style_guide as style_guide +from src import editor_agent_model as editor_agent_model +from src import shutdown as _shutdown + +logger = logging.getLogger(__name__) + +# ── Op catalog (kept in sync with static/js/editor/editor-control.js) ── +OP_CATALOG = [ + "document.info", "document.importImage", + "document.crop", "document.resizeCanvas", "document.savePsd", + "layers.list", "layers.select", "layers.addRaster", "layers.placeImage", "layers.addAdjustment", + "layers.duplicate", "layers.delete", "layers.setOpacity", "layers.setBlend", + "layers.group", "layers.ungroup", "layers.addStyle", + "ai.generativeExpand", + "selection.selectAll", "selection.deselect", "selection.invert", + "selection.rectangle", "selection.ellipse", + "selection.subject", "selection.sky", "selection.background", "selection.samText", + "selection.colorRange", "selection.maskBounds", "selection.exportMask", + "adjustments.apply", "adjustments.applyMasked", + "filters.apply", "filters.applyMasked", + "transform.start", "transform.apply", "transform.cancel", + "text.add", + "history.mark", "history.collapse", "history.undo", "history.redo", + "export.png", "export.gallery", "screenshot", +] +# Retouch/selection ops are defined once in the shared style guide so the +# server planner and the browser fallback cannot drift; a plan naming one of +# these would otherwise be filtered out of the catalog. +for _extra_op in style_guide.NEW_OP_NAMES: + if _extra_op not in OP_CATALOG: + OP_CATALOG.append(_extra_op) +del _extra_op +_OP_SET = frozenset(OP_CATALOG) + +# Canonical argument shapes advertised to the model so it stops guessing. The +# browser client tolerates loose forms as a safety net, but the model should +# emit exactly these. +_ADJUSTMENT_TYPES = ( + "brightness-contrast {brightness,contrast} (multipliers, 1=neutral), " + "exposure {exposure,offset,gamma}, white-balance {temperature,tint}, " + "hue-saturation {hue,saturation,lightness}, vibrance {vibrance}, black-white, " + "shadows-highlights {shadows,highlights}, levels, curves, color-balance, " + "photo-filter {color,density,preserve_luminosity}, selective-color, gradient-map, " + "invert, posterize, threshold, channel-mixer, color-lookup" +) +_FILTER_IDS = ( + "blur-gaussian, blur-motion, blur-zoom, sharpen, sharpen-edges, unsharp-mask, " + "noise-add, noise-despeckle, pixelate-mosaic, pixelate-crystallize, stylize-emboss, " + "stylize-find-edges, stylize-solarize, distort-twirl, distort-pinch, distort-spherize, " + "distort-ripple, render-clouds, clarity {amount,radius}, vignette {amount,feather}" +) +_CANONICAL_GUIDANCE = ( + "Canonical op shapes (use exactly these, never invent keys or names): " + "adjustments.apply / adjustments.applyMasked params = " + "{\"type\": , \"params\": {...}}; " + "filters.apply / filters.applyMasked params = {\"id\": }. " + "Never nest a numeric bag under {\"adjustments\": {...}} with no type, and never invent " + "adjustment types or filter ids. \"clarity\" (params amount,radius) and \"vignette\" " + "(params amount,feather) are real filters — use them for local-contrast and framing. " + "Canvas: document.crop {x,y,width,height}, document.resizeCanvas {width,height,anchor}, " + "ai.generativeExpand {width,height,anchor,prompt,mode:'instruction',feather}. Text/styles: " + "text.add {text,x,y,color,size}, layers.addStyle {type,params}. Export: document.savePsd. " + "ai_edit_image takes a mode: 'instruction' (default; whole image, no mask, prompt names " + "what stays unchanged) for background/scene/season/style changes, adding an object to an " + "existing photo, and removing diffuse distractions; 'masked' (tight selection first) ONLY " + "for explicit regional removal of a clearly identified object. generate_image is ONLY for " + "creating a brand-new image." +) +# The shared style guide is the single source of truth for intent routing, +# calibrated strengths and the low-key/temperature/portrait rules. Both the +# server planner and the browser fallback read it (the client fetches +# GET /api/editor/style-guide). +_STYLE_GUIDANCE = style_guide.guidance() + +# EDITOR_PLANNER: async (messages: list[dict], owner: Optional[str]) -> str | dict. Set by app.py. +_EDITOR_PLANNER: Optional[Callable[[List[dict], Optional[str]], Any]] = None +_MAX_STEPS = 32 +_QUEUE_MAX = 64 +# Generative editor steps (SAM mask + local diffusion) take 30-110s each, so +# bridge waits and the agent's per-step wait must outlast the provider. +_BRIDGE_DEFAULT_TIMEOUT = 330.0 + +# ── Strict JSON ops schema (structured outputs / grammar) ── +# The planner (the local Qwen3.5-9B at :8097 and every fallback) is constrained +# to this schema so it can only emit valid editor ops. Allowed ops: +# * the deterministic editor ops (OP_CATALOG), +# * generate_edit -> image model (instruction, optional mask) [=ai_edit_image] +# * select_object -> SAM mask by description (seneca /v1/images/mask) +# [=selection.samText] +# * ask_user -> ask 2-3 concrete choices (never a silent tone default). +# The model-facing aliases are translated to the internal execution ops below, +# so the browser client and the executor keep their existing op vocabulary. +_MODEL_PLAN_OPS = tuple(dict.fromkeys( + list(OP_CATALOG) + ["generate_edit", "select_object", "ai_edit_image", + "generate_image", "ask_user"] +)) +_ALLOWED_PLAN_OPS = frozenset(OP_CATALOG) | {"ai_edit_image", "generate_image"} +_PLAN_OP_ALIASES = { + "generate_edit": "ai_edit_image", # image model: instruction + optional mask + "select_object": "selection.samText", # SAM mask by description +} +# Inner op values the ai_edit_image tool accepts (src/agent_tools/editor_tools.py +# `_AI_OPS`). A model-authored ai_edit_image step frequently omits the inner op or +# invents one (e.g. "restyle"), which the tool then rejects. Normalise it so +# instruction/restyle edits become op "generative" and masked region edits become +# "inpaint". +_AI_IMAGE_INNER_OPS = frozenset( + {"remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"} +) + + +def _normalize_ai_step(params: Any) -> Dict[str, Any]: + """Repair an ai_edit_image step's inner op so it always runs (restyle -> + generative, masked -> inpaint).""" + params = dict(params) if isinstance(params, dict) else {} + mode = str(params.get("mode") or "").strip().lower() + inner = str(params.get("op") or "").strip().lower() + if inner not in _AI_IMAGE_INNER_OPS: + inner = "inpaint" if mode == "masked" else "generative" + params["op"] = inner + if inner in ("inpaint", "generative") and not mode: + params["mode"] = "masked" if inner == "inpaint" else "instruction" + return params +PLAN_SCHEMA: Dict[str, Any] = { + "type": "object", + "properties": { + "reply": {"type": "string"}, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": list(_MODEL_PLAN_OPS), + "description": ( + "generate_edit = AI image model edit (params {prompt, " + "mode:'instruction'|'masked'}; do NOT add an inner 'op' — " + "instruction/restyle edits run as the image model's " + "'generative' op, masked region edits as 'inpaint'). " + "select_object = SAM mask by text (params {text}). " + "ask_user = ask with 2-3 choices (params {question, options})." + ), + }, + "params": {"type": "object"}, + }, + "required": ["op"], + }, + }, + "ask_user": { + "type": "object", + "properties": { + "question": {"type": "string"}, + "options": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["question", "options"], + }, + "done": {"type": "boolean"}, + }, + "required": ["reply", "steps"], +} +PLAN_RESPONSE_FORMAT: Dict[str, Any] = { + "type": "json_schema", + "json_schema": {"name": "editor_plan", "schema": PLAN_SCHEMA}, +} + + +def _normalize_ask_user(payload: Any) -> Optional[Dict[str, Any]]: + """Validate an ask_user payload: one question and 2-3 concrete choices.""" + if not isinstance(payload, dict): + return None + question = str(payload.get("question") or "").strip() + raw = payload.get("options") + labels: List[str] = [] + for opt in (raw or []): + if isinstance(opt, str): + lbl = opt.strip() + elif isinstance(opt, dict): + lbl = str(opt.get("label") or "").strip() + else: + lbl = "" + if lbl: + labels.append(lbl) + labels = labels[:3] + if not question or len(labels) < 2: + return None + # Match the shape the ask_user tool + the editor client expect: option + # objects with a label (and an optional description). + return { + "question": question, + "options": [{"label": lbl, "description": ""} for lbl in labels], + "multi": False, + } + + +def _validate_plan(data: Any) -> Optional[Dict[str, Any]]: + """Validate a raw planner plan against the strict ops schema. + + Returns a normalized plan (aliases translated, ops checked against the + allowed set, ask_user normalized to 2-3 choices) or None when the plan is + invalid or empty. Callers turn None into an ask_user plan — never a silent + tone default. + """ + if not isinstance(data, dict): + return None + reply = str(data.get("reply") or "") + ask_user = _normalize_ask_user(data.get("ask_user")) + raw_steps = data.get("steps") + if raw_steps is None: + raw_steps = [] + if not isinstance(raw_steps, list): + return None + steps: List[Dict[str, Any]] = [] + for s in raw_steps[:_MAX_STEPS]: + if not isinstance(s, dict): + continue # drop a malformed step rather than rejecting the whole plan + op = str(s.get("op") or "") + if op == "ask_user": + au = _normalize_ask_user(s.get("params")) + if au is not None and ask_user is None: + ask_user = au + continue + op = _PLAN_OP_ALIASES.get(op, op) + if op not in _ALLOWED_PLAN_OPS: + continue # drop a single unknown/hallucinated op; keep the valid steps + params = s.get("params") if isinstance(s.get("params"), dict) else {} + if op == "ai_edit_image": + params = _normalize_ai_step(params) + steps.append({"op": op, "params": params}) + # Guardrails apply to model plans too (no stacked lifts, no brighten after + # retouch.skin, no global adjustment after a masked recolour). + steps = style_guide.enforce_guards(steps) + done = bool(data.get("done")) + if not steps and not done and ask_user is None: + return None + out: Dict[str, Any] = {"reply": reply, "planner": "llm", "steps": steps} + if ask_user is not None: + out["ask_user"] = ask_user + if done: + out["done"] = True + return out + +# Generative edits (remove/add/replace content) require an image provider. When +# none is available the agent must stop and say so plainly rather than apply a +# deterministic approximation that silently does the wrong thing. +try: + from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE as _PROVIDER_REQUIRED_MESSAGE +except Exception: # pragma: no cover - defensive + _PROVIDER_REQUIRED_MESSAGE = ( + "Removing/adding objects needs an image generation provider; enable one in " + "Settings > Models." + ) + +_AI_CONTENT_OPS = frozenset({"ai_edit_image", "generate_image"}) + +# Model ids that are editing/inpainting models rather than text-to-image ones. +_EDIT_MODEL_RE = re.compile(r"edit|inpaint|fill|kontext|controlnet|removal|qwen-image-edit", re.I) + +# Content nouns that signal a generative object edit rather than a tone/filter +# tweak. Kept narrow so "make it black and white" / "add a vignette" stay +# deterministic. +_GEN_OBJECTS = ( + r"(?:persons?|people|men|man|women|woman|guys?|girls?|boys?|someone|somebody|" + r"objects?|car|cars|sign|signs|logos?|watermarks?|trees?|buildings?|" + r"reflections?|animals?|dogs?|cats?|birds?|hands?|fingers?|moons?|suns?|clouds?|" + r"stars?|fire|smoke|snow|rain|flowers?|mountains?|planets?|balloons?|rockets?|hats?|" + r"glasses|sunglasses|shades|beard|beards|stickers?|frames?|borders?|" + r"rainbows?|wildflowers?|sunflowers?|tulips?|daisies?|leaves|pumpkins?|lanterns?|" + r"butterflies?|bees?|boats?|sailboats?|kites?|umbrellas?)" +) +_REMOVE_RE = re.compile( + r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out|photoshop\s+out|paint\s+out)\b" + r"[^.!?\n]{0,60}?\b" + _GEN_OBJECTS + r"\b", + re.I, +) +_ADD_RE = re.compile( + r"\b(?:add|insert|put|place|draw|paint|create|generate)\b" + r"[^.!?\n]{0,40}?\b" + _GEN_OBJECTS + r"\b", + re.I, +) +_REPLACE_RE = re.compile( + r"\b(?:replace|swap|substitute)\b|\b(?:change|turn)\b[^.!?\n]{0,40}?\b(?:to|into)\b", + re.I, +) +# Removing diffuse clutter ("remove the distractions") is generative too, but +# the specific-noun _REMOVE_RE above does not catch it. +_REMOVE_CLEANUP_RE = re.compile( + r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out|clean\s+up|declutter|clear)\b" + r"[^.!?\n]{0,40}?\b(?:distractions?|clutter|junk|mess|noise|" + r"unwanted\s+(?:objects?|items?|things?)|stuff|things|blemishes?|spots?)\b", + re.I, +) + +# Changing the whole background/scene (or placing the subject somewhere new) +# is a generative replacement, not the local remove-bg op. "make the +# background nicer/better" is included; plain "blur the background" is not. +_BG_RE = re.compile( + r"\b(?:replace|change|swap|put|place|set|make|improve|enhance|beautify|upgrade)\b" + r"[^.!?\n]{0,40}?\b(?:background|backdrop|scene)\b" + r"|\b(?:put|place)\b[^.!?\n]{0,30}?\b(?:on|in|at)\s+(?:a|an|the)\s+" + r"(?:beach|street|mountains?|forest|city|park|ocean|sea|desert|room|stage|field|snow|" + r"night|moon|mars|space|island|jungle)\b", + re.I, +) +# Season/weather changes and an explicit "generative fill" change scene +# content (not just tone), so they must be a generative edit, never a +# colour adjustment/filter step. +_SEASON_WEATHER_RE = re.compile( + r"\b(?:snow(?:y|ing|fall)?|winter|autumn(?:al)?|fall\s+(?:leaves|foliage)|spring|" + r"rain(?:y|ing)?|fog(?:gy)?|mist(?:y)?|storm(?:y)?|overcast)\b", + re.I, +) +_GENERATIVE_FILL_RE = re.compile(r"\bgenerative(?:\s+fill)?\b", re.I) +# Whole-image artistic RESTYLE requests (turn the photo into a painting, anime, +# pixel art, ...). These are a model instruction edit over the whole frame, not a +# local adjustment. The client has a short fast-path regex; the server must +# generalise so unknown restyle phrasings still reach the model. +_RESTYLE_RE = re.compile( + r"\b(?:re[\s-]?style|painting|painterly|water[\s-]?colou?r|oil\s+paint(?:ing)?|" + r"acrylic|gouache|pastel|sketch|pencil|charcoal|ink\s+drawing|line\s+art|comic|" + r"cartoon|anime|manga|illustration|3d\s+render|cgi|claymation|clay|pixel\s+art|" + r"voxel|low[\s-]?poly|mosaic|stained\s+glass|pop\s+art|art\s+nouveau|ukiyo[\s-]?e|" + r"surreal|fantasy\s+art|steampunk|cyberpunk|vaporwave|synthwave|graffiti|woodcut|" + r"linocut|etching|lithograph|impressionist|cubist|abstract|doodle|vector\s+art|" + r"neon\s+sign|psychedelic|gothic|comic[\s-]?book|storybook)\b", + re.I, +) + +# Text/title/PSD/format requests are deterministic edits, never generative. +_TEXT_RE = re.compile( + r"\b(?:title|caption|subtitle|label|heading|watermark|text|word(?:ing)?)\b", re.I, +) +_TEXT_ADD_RE = re.compile(r"\b(?:add|insert|put|place|write|overlay|create|set)\b", re.I) +_STYLE_RE = re.compile( + r"\b(drop[\s-]?shadow|outer[\s-]?glow|glow|stroke|outline|emboss|bevel)\b", re.I, +) +_PSD_RE = re.compile(r"\b(?:psd|photoshop file|layered file)\b", re.I) + +# Aspect -> exact output size. Instagram/format intents crop to the aspect and +# then resize to this size (then optionally add a title). +_ASPECT_TARGETS = ( + (re.compile(r"\b(?:1080\s*[x×]\s*1350|4\s*:\s*5|four[\s-]?fifths?|instagram)\b", re.I), (1080, 1350)), + (re.compile(r"\b(?:1080\s*[x×]\s*1080|1\s*:\s*1|square)\b", re.I), (1080, 1080)), + (re.compile(r"\b(?:1080\s*[x×]\s*1920|9\s*:\s*16|story|reel|tiktok|vertical)\b", re.I), (1080, 1920)), + (re.compile(r"\b(?:1920\s*[x×]\s*1080|16\s*:\s*9|widescreen|landscape video)\b", re.I), (1920, 1080)), +) +_DIM_RE = re.compile(r"\b(\d{3,5})\s*[x×]\s*(\d{3,5})\b") + + +def _is_text_request(text: str) -> bool: + return bool(_TEXT_RE.search(text) and _TEXT_ADD_RE.search(text)) + + +def _format_target(text: str) -> Optional[Tuple[int, int]]: + for pattern, dims in _ASPECT_TARGETS: + if pattern.search(text): + return dims + m = _DIM_RE.search(text) + if m: + try: + return (int(m.group(1)), int(m.group(2))) + except Exception: + return None + return None + + +def _extend_requested(text: str) -> bool: + if re.search(r"\b(?:extend|expand|wider|taller|letterbox)\b", text, re.I): + return True + return bool(re.search( + r"\badd\b[^.!?\n]{0,25}?\b(?:canvas|border|space|margin)\b", text, re.I, + )) + + +def _generative_kind(prompt: str) -> Optional[str]: + """Classify a prompt as a generative content edit: 'remove'/'add'/'replace'. + + Returns None for tone/colour/filter/text/selection edits that deterministic + editor ops handle correctly. 'remove the background' is deliberately not + matched (that is the local remove-bg op). + """ + text = str(prompt or "") + if not text.strip(): + return None + # Text/title, PSD and format requests are deterministic — never generative. + if _is_text_request(text) or _PSD_RE.search(text) or _format_target(text): + return None + # An explicit brand-new image ("create/generate/make a new image/picture/logo + # ...") is a text-to-image generation, never an edit of the current photo. + if _is_new_image_request(text): + return "add" + # Replacing the background/scene (or putting the subject somewhere new) is + # a generative replacement even though it is phrased like a background op. + if _BG_RE.search(text): + return "replace" + if re.search( + r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of)\s+(?:the\s+)?background\b", + text, + re.I, + ): + return None + # Season/weather changes and explicit "generative fill" change content, not + # tone. Only treat them as generative when the user is not asking to REMOVE + # that element ("remove the rain" stays a removal). + if _GENERATIVE_FILL_RE.search(text) or _SEASON_WEATHER_RE.search(text): + if not re.search( + r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\b", text, re.I + ): + return "replace" + if _RESTYLE_RE.search(text) and not _is_new_image_request(text): + # A whole-image artistic restyle is a model instruction edit (keep the + # subject/composition), never a local tone/colour adjustment. + return "restyle" + if _REPLACE_RE.search(text): + return "replace" + if _ADD_RE.search(text): + return "add" + if _REMOVE_RE.search(text) or _REMOVE_CLEANUP_RE.search(text): + return "remove" + return None + + +_NEW_IMAGE_RE = re.compile( + r"\b(?:create|generate|make|produce|draw|design)\b[^.!?\n]{0,30}?\b" + r"(?:new\s+)?(?:image|picture|photo|illustration|artwork|graphic|logo|icon|wallpaper|poster)\b", + re.I, +) +_EDIT_CONTEXT_RE = re.compile( + r"\b(?:this|that|these|it|the\s+(?:photo|image|picture)|attached|from\s+this|" + r"of\s+(?:this|the\s+photo)|edit|in\s+(?:this|the)\s+photo)\b", + re.I, +) +_KEEP_CLAUSE_RE = re.compile(r"\bkeep\b|\bunchanged\b|\bpreserve\b|\bsame\b|\bidentical\b", re.I) + + +def _is_new_image_request(text: str) -> bool: + """True only for a from-scratch image generation (not an edit of an existing photo).""" + if not _NEW_IMAGE_RE.search(text): + return False + return not _EDIT_CONTEXT_RE.search(text) + + +def _instruction_prompt(text: str) -> str: + """Append a keep-unchanged clause so instruction edits preserve the subject.""" + t = str(text or "").strip() + if not t: + return t + if _KEEP_CLAUSE_RE.search(t): + return t + return ( + t.rstrip(".") + + ". Keep the main subject and everything else in the image exactly the same." + ) + + +def _restyle_prompt(text: str) -> str: + """Whole-image artistic restyle instruction (subject/composition preserved).""" + t = str(text or "").strip() + if t: + base = ( + "Restyle the entire image as requested, applying the artistic style " + "across the whole frame: " + t + ) + else: + base = "Restyle the entire image in a new artistic style." + return _instruction_prompt(base) + + +def _generative_mode(kind: str, prompt: str) -> str: + """'masked' only for an explicit regional removal of a clearly identified object. + + Everything else generative (background/season/style replacement, adding an + object to the scene, removing diffuse distractions) uses 'instruction'. + """ + if kind == "remove": + text = str(prompt or "") + if _REMOVE_RE.search(text) and not _REMOVE_CLEANUP_RE.search(text): + return "masked" + return "instruction" + + +def _removal_prompt(text: str) -> str: + """A more explicit diffuse-removal instruction that names common clutter.""" + t = str(text or "").strip() or "Remove the distracting clutter from the photo" + return ( + t.rstrip(". ") + + ". Remove any distracting clutter such as roads, guardrails, utility poles, " + "signs, wires and debris, and fill the cleared area with the surrounding " + "landscape so it looks natural." + ) + + +# Rich, scene-specific descriptions so a background replacement produces a +# convincing scene (Qwen-Image-Edit is an instruction model and a bare "a beach" +# tends to yield a flat blue backdrop). Keyed by keywords in the request. +_SCENE_HINTS = ( + (r"beach|shore|seaside|coast|tropical", + "a sunny tropical beach", + "golden sand, turquoise sea with gentle waves, a clear blue sky with soft " + "clouds, and natural daylight"), + (r"mountain|alps|peak|valley", + "a scenic mountain landscape", + "distant peaks, natural daylight and soft haze"), + (r"forest|woods|jungle", + "a lush green forest", + "dappled natural daylight and depth"), + (r"city|street|urban|downtown", + "a realistic city street", + "recognisable city buildings and depth of field with natural perspective"), + (r"field|meadow|grass", + "an open grassy field", + "natural daylight and a distant horizon"), + (r"snow|winter", + "a snow-covered landscape", + "cold, soft daylight and a pale sky"), + (r"desert|dunes", + "a warm desert", + "sand dunes and a clear sky"), + (r"studio|plain|neutral|solid", + "a clean neutral studio backdrop", + "soft, even lighting"), +) +_SCENE_DEFAULT = ( + "a realistic natural background", + "matching the subject's lighting, perspective and shadows", +) +# Season/weather scene descriptions ("make it snowy/autumn"). +_SEASON_HINTS = ( + (r"snow|winter|snowy", "a snowy winter scene", + "fresh snow covering the ground and surfaces, soft cold daylight, a pale overcast sky"), + (r"autumn|autumnal|fall", "an autumn scene", + "vivid orange and golden foliage, warm low sunlight and fallen leaves"), + (r"spring", "a bright spring scene", + "fresh green foliage and blossoms under bright natural daylight"), + (r"rain|storm|overcast", "a rainy overcast scene", + "wet reflective surfaces, raindrops and a dramatic overcast sky"), + (r"fog|mist", "a foggy scene", + "soft drifting mist and diffuse light"), + (r"sky|clouds", "a new sky", + "realistic clouds and natural lighting matching the scene"), +) + + +def _scene_prompt(text: str) -> str: + """Deterministic, templated instruction for a background/scene change. + + Qwen-Image-Edit is an instruction model and a bare scene noun tends to yield + a flat backdrop, so the scene is expanded into concrete detail. The result is + passed to the provider unmodified. + """ + t = str(text or "").strip() + if not t: + return t + if _BG_RE.search(t): + scene, details = _SCENE_DEFAULT + for pattern, scene_name, description in _SCENE_HINTS: + if re.search(pattern, t, re.I): + scene, details = scene_name, description + break + return ( + f"Replace the entire background behind the person with {scene}: {details}. " + "Keep the person exactly the same." + ) + if _SEASON_WEATHER_RE.search(t) or _GENERATIVE_FILL_RE.search(t): + scene, details = _SCENE_DEFAULT + for pattern, scene_name, description in _SEASON_HINTS: + if re.search(pattern, t, re.I): + scene, details = scene_name, description + break + return ( + f"Change the scene to {scene}: {details}. " + "Keep the main subject and everything else in the image exactly the same." + ) + return t + + +def _generative_step(kind: str, prompt: str) -> Dict[str, Any]: + """The AI step a generative intent must use instead of deterministic ops.""" + instruction = str(prompt or "").strip() + if kind == "restyle": + return { + "op": "ai_edit_image", + "params": {"op": "generative", "mode": "instruction", + "prompt": _restyle_prompt(instruction), "steps": 8}, + } + if kind == "add": + # A brand-new image is the only case for generate_image; adding an object + # to an existing photo is an instruction edit of the whole image. + if _is_new_image_request(prompt): + return {"op": "generate_image", "params": {"prompt": instruction}} + return { + "op": "ai_edit_image", + "params": { + "op": "generative", + "mode": "instruction", + "prompt": _instruction_prompt(instruction), + "steps": 8, + }, + } + mode = _generative_mode(kind, prompt) + op = "inpaint" if kind == "remove" else "generative" + if mode == "instruction": + if kind == "remove": + base = _removal_prompt(instruction) + else: + base = _scene_prompt(instruction) + params: Dict[str, Any] = {"op": op, "mode": "instruction", "prompt": _instruction_prompt(base)} + else: + params = {"op": op, "mode": "masked", "prompt": instruction} + if kind in ("remove", "replace"): + # Removal and background-replacement edits need more sampler steps than + # the server's default of 4. + params["steps"] = 8 + return {"op": "ai_edit_image", "params": params} + + +_SKY_ADD_RE = re.compile( + r"\b(?:sky|moons?|suns?|sunset|sunrise|clouds?|stars?|night|aurora|stars?)\b", + re.I, +) +_REMOVE_NOUN_RE = re.compile( + r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out|clean\s+up|declutter|clear)\b" + r"[^.!?\n]{0,60}?\b(" + + _GEN_OBJECTS + + r"|distractions?|clutter|junk|mess|noise|stuff|things)\b", + re.I, +) + + +def _selection_step_for(kind: str, prompt: str) -> Optional[Dict[str, Any]]: + """A deterministic selection step that yields the mask a MASKED ai_edit_image needs. + + Instruction-mode edits operate on the whole image and need no mask, so this + returns None for them. Masked mode is only used for an explicit regional + object removal; the selection targets that object so ``selection.exportMask`` + has a tight region to export. + """ + text = str(prompt or "") + if kind == "remove" and _generative_mode(kind, text) == "masked": + m = _REMOVE_NOUN_RE.search(text) + label = (m.group(1).lower() if m else "person") + if label.endswith("s"): + label = label[:-1] + if label in ("people",): + label = "person" + return {"op": "selection.subject", "params": {"label": label}} + return None + + +def _generative_steps(kind: str, prompt: str) -> List[Dict[str, Any]]: + """The selection step (when needed) followed by the AI content step.""" + steps: List[Dict[str, Any]] = [] + selection = _selection_step_for(kind, prompt) + if selection: + steps.append(selection) + steps.append(_generative_step(kind, prompt)) + return steps + + +def _plan_uses_ai_content(steps: Any) -> bool: + return any(isinstance(s, dict) and s.get("op") in _AI_CONTENT_OPS for s in (steps or [])) + + +def _ensure_generative(prompt: str, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Force a generative intent onto an AI content op, never a substitute. + + If the prompt is a generative content edit (remove/add/replace) but the + plan has no ai_edit_image/generate_image step, replace the plan with the + correct AI step so the agent cannot silently do an unrelated deterministic + edit. Non-generative prompts are returned unchanged. + """ + kind = _generative_kind(prompt) + if not kind: + return steps + # Background/scene replacement is deterministic: always use the templated + # scene prompt so a model rewrite cannot drop the scene details (a model + # ai_edit_image step would otherwise pass with its own vague prompt). + if kind == "replace" and ( + _BG_RE.search(str(prompt or "")) + or _SEASON_WEATHER_RE.search(str(prompt or "")) + or _GENERATIVE_FILL_RE.search(str(prompt or "")) + ): + return _generative_steps(kind, prompt) + # For a restyle (and any other generative intent) the planner is expected to + # choose generate_edit itself; the _RESTYLE_RE keyword list is only a fast + # path used when the model did not already pick an AI content op. + if not _plan_uses_ai_content(steps): + return _generative_steps(kind, prompt) + return steps + + +def _image_provider_available(owner: Optional[str]) -> bool: + try: + from src.ai_interaction import image_editing_provider_available + + return bool(image_editing_provider_available(owner)) + except Exception: + return True # unknown: let the tool attempt and report its own error + + +def _state_dims(state: Any) -> Optional[Tuple[int, int]]: + """Current canvas size from the client-sent state (flat or nested).""" + if not isinstance(state, dict): + return None + candidates = [state] + for key in ("document", "document_info", "info"): + if isinstance(state.get(key), dict): + candidates.append(state[key]) + for src in candidates: + w = src.get("width") or src.get("imgWidth") + h = src.get("height") or src.get("imgHeight") + try: + if w and h: + return (int(round(float(w))), int(round(float(h)))) + except Exception: + continue + return None + + +def _title_text(prompt: str) -> Optional[str]: + quoted = re.search(r"[\"'\u201c\u201d\u2018\u2019]([^\"'\u201c\u201d\u2018\u2019]{1,40})[\"'\u201c\u201d\u2018\u2019]", prompt) + if quoted and quoted.group(1).strip(): + return quoted.group(1).strip() + caps = re.search(r"\b([A-Z][A-Z0-9]{1,20})\b", prompt) + if caps and caps.group(1).lower() not in {"add", "title", "text", "psd"}: + return caps.group(1) + return None + + +def _crop_rect_for_aspect(cur: Tuple[int, int], target: Tuple[int, int]) -> Optional[Dict[str, int]]: + cw, ch = cur + tw, th = target + if cw <= 0 or ch <= 0 or tw <= 0 or th <= 0: + return None + target_ratio = tw / th + cur_ratio = cw / ch + if abs(cur_ratio - target_ratio) < 0.02: + return None # already the right aspect + if cur_ratio > target_ratio: + # too wide -> trim the sides + new_w = int(round(ch * target_ratio)) + new_w = min(cw, max(1, new_w)) + return {"x": (cw - new_w) // 2, "y": 0, "width": new_w, "height": ch} + # too tall -> trim top/bottom + new_h = int(round(cw / target_ratio)) + new_h = min(ch, max(1, new_h)) + return {"x": 0, "y": (ch - new_h) // 2, "width": cw, "height": new_h} + + +def _extend_dims(cur: Tuple[int, int], target: Tuple[int, int]) -> Tuple[int, int]: + """Target canvas that grows (never crops) toward the target aspect.""" + cw, ch = cur + target_ratio = target[0] / target[1] + cur_ratio = cw / ch + if abs(cur_ratio - target_ratio) < 0.02: + return (cw, ch) + if cur_ratio < target_ratio: + return (int(round(ch * target_ratio)), ch) # add width + return (cw, int(round(cw / target_ratio))) # add height + + +def _plan_text_steps(prompt: str) -> List[Dict[str, Any]]: + title = _title_text(prompt) or "Title" + steps: List[Dict[str, Any]] = [{ + "op": "text.add", + "params": {"text": title, "x": 40, "y": 40, "color": "#ffffff", "size": 64}, + }] + if _STYLE_RE.search(prompt): + steps.append({"op": "layers.addStyle", "params": {"type": "drop-shadow", "params": {}}}) + return steps + + +def _plan_format_steps(prompt: str, state: Any, provider_available: bool) -> Optional[List[Dict[str, Any]]]: + target = _format_target(prompt) + if not target: + return None + cur = _state_dims(state) + if not cur: + # No canvas size known: ask the client to crop to the aspect itself. + return None + steps: List[Dict[str, Any]] = [] + if _extend_requested(prompt): + new_w, new_h = _extend_dims(cur, target) + if (new_w, new_h) == cur: + return None + if provider_available: + steps.append({"op": "ai.generativeExpand", "params": { + "width": new_w, "height": new_h, + "anchor": "center", "mode": "instruction", "feather": True, + "steps": 8, + "prompt": "extend the surrounding scene and sky naturally into the new border, " + "keep the original photo unchanged"}}) + else: + steps.append({"op": "document.resizeCanvas", "params": { + "width": new_w, "height": new_h, "anchor": "center"}}) + else: + rect = _crop_rect_for_aspect(cur, target) + if rect: + steps.append({"op": "document.crop", "params": rect}) + # Only resize when an exact output size was requested. + if abs(target[0] / target[1] - cur[0] / cur[1]) >= 0.02 or target in ((1080, 1350), (1080, 1080), (1080, 1920), (1920, 1080)): + steps.append({"op": "document.resizeCanvas", "params": { + "width": target[0], "height": target[1], "anchor": "center"}}) + if not steps: + return None + # Optional caption/title that came with the format request. + if _is_text_request(prompt): + steps.extend(_plan_text_steps(prompt)) + return steps + + +def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]: + if _PSD_RE.search(prompt): + return [{"op": "document.savePsd", "params": {}}] + return None + + +def _plan_special( + prompt: str, state: Any, provider_available: bool, +) -> Optional[Tuple[List[Dict[str, Any]], str, str]]: + """Deterministic overrides for intents the model reliably botches. + + Canvas format/crop/extend, text + layer styles and PSD export must not be + left to the model. Returns (steps, reply, note) or None. These never need an + image provider except generative border fill, used only when one exists. + """ + # Targeted recolour is fully deterministic (object mask + masked hue shift) + # and must never be left to the model, which sometimes substitutes a + # generative placeImage. Highest priority so it can never vary. + recolor = style_guide.recolor_plan(prompt) + if recolor: + return list(recolor["steps"]), str(recolor["reply"]), "" + if _format_target(prompt): + steps = _plan_format_steps(prompt, state, provider_available) + if not steps: + return None + target = _format_target(prompt) + reply = f"Setting the canvas to {target[0]}\u00d7{target[1]}." + note = "" + if _extend_requested(prompt) and not provider_available: + note = ("No image generation provider is configured, so the new border " + "was extended with edge/transparent pixels.") + return steps, reply, note + if _PSD_RE.search(prompt): + steps = _plan_psd_steps(prompt) + return (steps, "Saving a layered PSD.", "") if steps else None + if _is_text_request(prompt): + steps = _plan_text_steps(prompt) + if steps: + reply = ("Adding the title with a drop shadow." + if _STYLE_RE.search(prompt) else "Adding the title text.") + return steps, reply, "" + # Structural ops (fill, text edit, align, rotate/flip, copy/paste, flatten, + # merge-down, colour overlay) — the guide's deterministic routing so the + # model never substitutes a generic adjustment/transform for them. + structural = style_guide.structural_plan(prompt) + if structural: + return list(structural["steps"]), str(structural["reply"]), "" + return None + + +_DIMS_CLAIM_RE = re.compile(r"(\d{2,5})\s*[x\u00d7]\s*(\d{2,5})") +_CANVAS_CHANGE_OPS = frozenset({ + "document.crop", "document.resizeCanvas", "ai.generativeExpand", "document.importImage", +}) +_TEXT_OPS = frozenset({"text.add", "layers.addStyle", "document.savePsd"}) + + +def _step_op_signature(step: Any) -> str: + """Identify an executed step by op plus its adjustment type / filter id.""" + if not isinstance(step, dict): + return "" + op = str(step.get("op") or "") + params = step.get("params") or {} + if op in ("adjustments.apply", "adjustments.applyMasked"): + return f"{op}:{params.get('type') or ''}" + if op in ("filters.apply", "filters.applyMasked"): + return f"{op}:{params.get('id') or ''}" + return op + + +# Phrases that name a specific edit. If the reply claims one of these but the +# executed steps do not contain it, the reply is rewritten from the real steps. +_OP_CLAIM_KEYWORDS: List[Tuple[str, Tuple[str, ...]]] = [ + ("photo.autoTone", ("auto tone", "auto-tone", "autotone", "auto-tune")), + ("adjustments.apply:shadows-highlights", ("shadow", "highlight")), + ("adjustments.apply:white-balance", ("white balance", "white-balance", + "temperature", "colour cast", "color cast")), + ("adjustments.apply:vibrance", ("vibrance",)), + ("adjustments.apply:brightness-contrast", ("brightness", "contrast")), + ("adjustments.apply:black-white", ("black and white", "black & white", + "grayscale", "greyscale", "monochrome")), + ("filters.apply:clarity", ("clarity", "local contrast")), + ("filters.apply:vignette", ("vignette",)), + ("filters.apply:sharpen", ("sharpen", "crisper")), + ("retouch.skin", ("skin", "complexion")), + ("retouch.teeth", ("teeth", "smile")), +] + +_OP_LABELS: Dict[str, str] = { + "photo.autoTone": "auto tone", + "adjustments.apply:shadows-highlights": "a shadow lift (highlights protected)", + "adjustments.apply:white-balance": "white balance", + "adjustments.apply:vibrance": "vibrance", + "adjustments.apply:brightness-contrast": "brightness/contrast", + "adjustments.apply:black-white": "black and white", + "filters.apply:clarity": "local contrast (clarity)", + "filters.apply:vignette": "a light vignette", + "filters.apply:sharpen": "sharpening", + "selection.face": "a face selection", + "selection.background": "a background selection", + "retouch.skin": "gentle skin retouching", + "retouch.teeth": "teeth whitening", +} + + +def _reply_from_ops(steps: List[Dict[str, Any]]) -> str: + """Describe what was actually executed, never what was planned.""" + labels: List[str] = [] + for step in steps or []: + sig = _step_op_signature(step) + label = _OP_LABELS.get(sig) + if label is None: + label = sig.split(":")[0] or "an edit" + if label not in labels: + labels.append(label) + if not labels: + return "Done." + return "Done \u2014 I applied " + ", ".join(labels) + "." + + +def _reply_matches_ops(planned_reply: str, steps: List[Dict[str, Any]]) -> bool: + """True when the reply does not claim an edit that was not executed.""" + text = (planned_reply or "").lower() + if not text: + return True + # "drop shadow" is a layer style, not a shadows/highlights adjustment. + text = text.replace("drop shadow", "drop-shadow-style") + executed = {_step_op_signature(step) for step in (steps or [])} + for sig, keywords in _OP_CLAIM_KEYWORDS: + if sig in executed: + continue + if any(keyword in text for keyword in keywords): + return False + return True + + + +def _verified_reply( + prompt: str, + steps: List[Dict[str, Any]], + results: List[Dict[str, Any]], + final_state: Any, + planned_reply: str, +) -> str: + """Build the final reply from ACTUAL executed results + final document state. + + Never let a plan's optimistic prose claim an edit that did not happen. + """ + failed = [ + r for r in (results or []) + if isinstance(r, dict) and r.get("ok") is False + ] + if failed: + op = str(failed[0].get("op") or "step") + err = failed[0].get("error") or "it did not complete" + return (f"I couldn't finish the {op} step ({err}). " + f"Nothing else was left changed — the image is back as it was.") + dims = _state_dims(final_state) + step_ops = [str(s.get("op") or "") for s in (steps or []) if isinstance(s, dict)] + claim = _DIMS_CLAIM_RE.search(planned_reply or "") + if claim and dims: + try: + claimed = (int(claim.group(1)), int(claim.group(2))) + except Exception: + claimed = None + if claimed and (abs(dims[0] - claimed[0]) > 2 or abs(dims[1] - claimed[1]) > 2): + if not any(op in _CANVAS_CHANGE_OPS for op in step_ops): + return (f"I planned to make it {claimed[0]}\u00d7{claimed[1]}, but the canvas is " + f"still {dims[0]}\u00d7{dims[1]} — the resize didn't apply. " + f"Please try again in the editor.") + if not steps and not results: + return planned_reply or "I didn't have a concrete edit to apply." + if not _is_text_request(prompt) and not _reply_matches_ops(planned_reply, steps): + return _reply_from_ops(steps) + if _is_text_request(prompt) and any(op in _TEXT_OPS for op in step_ops) and not failed: + return planned_reply or "Added the text." + if dims and any(op in _CANVAS_CHANGE_OPS for op in step_ops): + return (planned_reply or f"Done — the canvas is now {dims[0]}\u00d7{dims[1]}.") + return planned_reply + + +def set_editor_planner( + fn: Optional[Callable[[List[dict], Optional[str]], Any]], +) -> None: + global _EDITOR_PLANNER + _EDITOR_PLANNER = fn + + +_ROUTING_GUIDANCE = ( + "Routing rules (follow strictly): removing objects/people, adding new content " + "(e.g. a moon, an animal, a sign), or replacing/changing what is in the picture " + "are GENERATIVE edits -> use op \"ai_edit_image\" (op \"inpaint\" to remove, " + "\"generative\" to replace, with a descriptive prompt) or op \"generate_image\" " + "({prompt}) for added content. NEVER approximate these with selection.rectangle, " + "adjustments.apply, filters.apply, transform.* or any other deterministic op. " + "Use deterministic ops only for tone/colour/filter/crop/resize/text/selection " + "edits that do not invent or delete picture content. \"remove the background\" " + "is op \"ai_edit_image\" op \"remove-bg\". If a generative edit is requested and " + "no image provider is configured, do NOT substitute a deterministic edit: return " + "{\"reply\": \"" + _PROVIDER_REQUIRED_MESSAGE + "\", \"steps\": []}." +) + +# Strict ops vocabulary for the model (the schema is also sent as a structured +# output constraint, so the model can only emit these ops). +_SCHEMA_NOTE = ( + "Reply with ONLY a JSON object matching the ops schema. Valid step ops are the " + "deterministic editor ops above plus: \"generate_edit\" (a model image edit; " + "params {\"prompt\": str, \"mode\": \"instruction\"|\"masked\", \"mask\": optional " + "object description}) for any edit that invents, removes, replaces, recolours or " + "restyles image content; \"select_object\" (params {\"text\": object description}) " + "to select an object by description; and \"ask_user\" (params {\"question\": str, " + "\"options\": [2-3 short choices]}) when the request is ambiguous. For " + "\"generate_edit\" set params {\"prompt\": str, \"mode\": \"instruction\"|\"masked\"} " + "and do NOT set an inner \"op\" field: the server maps instruction/restyle edits to " + "the image model's \"generative\" op and masked region edits to \"inpaint\". Prefer " + "generate_edit over deterministic approximations for content changes, and ask_user " + "with 2-3 concrete choices rather than guessing a tone tweak." +) + +_SYSTEM_PROMPT = ( + "You are a photo-editor planning agent. Convert the user's request into a JSON plan of " + "editor operations. Return ONLY JSON: {\"reply\": str, \"steps\": [{\"op\": str, \"params\": {}}]}. " + "Available ops: " + ", ".join(OP_CATALOG) + ", plus ai_edit_image and generate_image. " + + _ROUTING_GUIDANCE + " " + + _CANONICAL_GUIDANCE + " " + _STYLE_GUIDANCE + " " + "Use selection.* before targeted edits when possible. Keep to <=6 steps. " + "If a screenshot is attached, use it to judge the current result. " + + _SCHEMA_NOTE +) + +_CHECK_SYSTEM_PROMPT = ( + "You are a photo-editor quality checker. A screenshot of the editor's CURRENT result is " + "attached along with the user's request and the iteration number. Decide whether the edit " + "satisfies the request. Return ONLY JSON: if it is complete, {\"done\": true, \"reply\": str}; " + "if more work is needed, {\"done\": false, \"reply\": str, \"steps\": [{\"op\": str, \"params\": {}}]}. " + "Available ops: " + ", ".join(OP_CATALOG) + ", plus ai_edit_image and generate_image. " + + _ROUTING_GUIDANCE + " " + + _CANONICAL_GUIDANCE + " " + _STYLE_GUIDANCE + " " + "Never repeat steps that already ran; <=4 steps. " + + _SCHEMA_NOTE +) + + +_GENERIC_TONE_OPS = frozenset({"photo.autoTone", "adjustments.apply", "filters.apply"}) + + +def _plan_is_generic_tone_only(steps: Any) -> bool: + """True when a plan is nothing but whole-image tone/colour/filter steps.""" + items = [s for s in (steps or []) if isinstance(s, dict)] + return bool(items) and all(s.get("op") in _GENERIC_TONE_OPS for s in items) + + +def _unsure_plan(prompt: str, plan: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Demote a bare whole-image tone plan to an ask when the prompt is unsure. + + When the planner produced ONLY generic tone ops (photo.autoTone / + adjustments.apply / filters.apply) for a prompt that matches no concrete + intent and names no concrete defect, the editor is guessing ("it just adds + vibrance and brightness"). Ask the user with 2-3 concrete choices instead. + """ + if not isinstance(plan, dict): + return plan + steps = plan.get("steps") or [] + if not _plan_is_generic_tone_only(steps): + return plan + if _generative_kind(prompt): + return plan + if style_guide.has_concrete_intent(prompt): + return plan + return style_guide.ask_user_plan() + + +def _fallback_plan(prompt: str) -> Dict[str, Any]: + kind = _generative_kind(prompt) + if kind: + label = {"remove": "object removal", "add": "content addition", + "replace": "content replacement", "restyle": "artistic restyle"}[kind] + return { + "reply": f"Planned an AI {label}.", + "planner": "fallback", + "steps": _generative_steps(kind, prompt), + } + guided = style_guide.fallback_plan(prompt) + if guided is not None: + guided["steps"] = style_guide.enforce_guards(guided.get("steps") or []) + return guided + # Nothing mapped to a concrete intent: ASK instead of silently applying a + # default tone tweak. The old fallback emitted a generic + # photo.autoTone/adjustments/filters stack here, which is the "it just adds + # vibrance and brightness" bug for unrecognized prompts. + return style_guide.ask_user_plan() + + +def _parse_plan(raw: Any) -> Optional[Dict[str, Any]]: + if isinstance(raw, dict): + data = raw + else: + text = raw if isinstance(raw, str) else "" + if not text: + return None + try: + data = json.loads(text) + except Exception: + start, end = text.find("{"), text.rfind("}") + if start < 0 or end <= start: + return None + try: + data = json.loads(text[start:end + 1]) + except Exception: + return None + # Every plan is validated against the strict ops schema; an invalid or empty + # plan returns None so the caller falls back to ask_user (never a tone + # default). See _validate_plan. + return _validate_plan(data) + + +def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]: + if isinstance(body.get("steps"), list): + raw = body["steps"] + elif body.get("op"): + raw = [{"op": body["op"], "params": body.get("params") or {}}] + else: + raw = [] + out: List[Dict[str, Any]] = [] + for step in raw[:_MAX_STEPS]: + if not isinstance(step, dict): + continue + op = str(step.get("op") or "") + if op not in _OP_SET: + continue + out.append({"op": op, "params": step.get("params") or {}}) + return out + + +def _user(request: Request) -> Optional[str]: + try: + from src.auth_helpers import get_current_user + return get_current_user(request) + except Exception: + return None + + +async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]: + """Plan with the local editor-agent model, falling back through the chain. + + Order: per-request ``agent_model`` override -> local Qwen3.5-9B (vision, + screenshot attached) -> the same model text-only with a VL caption of the + screenshot -> the hosted default/utility chat model. The model actually + used is recorded so the route can report it. + """ + result = await editor_agent_model.plan_with_editor_agent( + messages, owner, override=editor_agent_model.get_override(), + response_format=PLAN_RESPONSE_FORMAT, + ) + if result is None: + return None + raw, model_used, source = result + editor_agent_model.set_last_model(model_used, source) + return raw + + +def setup_editor_agent_routes() -> APIRouter: + router = APIRouter(tags=["editor-agent"]) + + if _EDITOR_PLANNER is None: + set_editor_planner(_default_editor_planner) + + # Probe the local editor-agent model server (non-blocking) so the planner + # can use it for vision planning when reachable. + editor_agent_model.startup_probe() + + # scoped session -> list of that session's live subscriber queues; id -> result future. + # A command is BROADCAST to every live subscriber (per-connection queue), never to a + # single shared queue: EventSource reconnects overlap, and with one shared queue a + # command could be consumed by a stale subscriber while the active page got nothing. + subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {} + backlog: Dict[str, List[dict]] = {} + pending: Dict[str, asyncio.Future] = {} + + def _subscribe(session: str) -> "asyncio.Queue[dict]": + q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX) + subscribers.setdefault(session, []).append(q) + # Replay anything enqueued while this session had no live subscriber. + for cmd in backlog.pop(session, []) or []: + try: + q.put_nowait(cmd) + except asyncio.QueueFull: + break + return q + + def _unsubscribe(session: str, q: "asyncio.Queue[dict]") -> None: + subs = subscribers.get(session) + if subs and q in subs: + subs.remove(q) + if not subs: + subscribers.pop(session, None) + + def _broadcast(session: str, cmd: dict) -> int: + subs = subscribers.get(session) or [] + if subs: + for q in list(subs): + try: + q.put_nowait(cmd) + except asyncio.QueueFull: + pass + return len(subs) + # No live subscriber yet: hold it so a subscriber that connects shortly + # after still receives the command (bounded ring buffer). + bl = backlog.setdefault(session, []) + bl.append(cmd) + if len(bl) > _QUEUE_MAX: + del bl[0: len(bl) - _QUEUE_MAX] + return 0 + + def _scoped(session: Optional[str], owner: Optional[str]) -> str: + return f"{owner or 'anon'}:{session or 'default'}" + + @router.post("/api/editor/plan") + async def plan(request: Request) -> JSONResponse: + owner = _user(request) + try: + body = await request.json() + except Exception: + body = {} + prompt = str(body.get("prompt") or "").strip() + state = body.get("state") or {} + mode = str(body.get("mode") or "plan").strip().lower() + screenshot = body.get("screenshot") + agent_model = str(body.get("agent_model") or "").strip() + if not prompt and mode != "check": + return JSONResponse({"reply": "No prompt provided.", "planner": "none", "steps": [], + "agent_model": agent_model or editor_agent_model.local_model()}) + + system = _CHECK_SYSTEM_PROMPT if mode == "check" else _SYSTEM_PROMPT + text = json.dumps({"prompt": prompt, "state": state}, ensure_ascii=False) + content: Any = text + if isinstance(screenshot, str) and screenshot.startswith("data:image/"): + content = [ + {"type": "text", "text": text}, + {"type": "image_url", "image_url": {"url": screenshot}}, + ] + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": content}, + ] + + raw: Any = None + planner = _EDITOR_PLANNER + editor_agent_model.set_override(agent_model) + editor_agent_model.set_last_model("", "") + if planner is not None: + try: + raw = planner(messages, owner) + if inspect.isawaitable(raw): + raw = await raw + except Exception: + raw = None + _used = editor_agent_model.get_last_model() + model_used = (_used[0] if _used and _used[0] + else (agent_model or editor_agent_model.local_model())) + parsed = _parse_plan(raw) + if isinstance(parsed, dict): + parsed["agent_model"] = model_used + if mode != "check": + _special = _plan_special(prompt, state, _image_provider_available(owner)) + if _special: + _special_steps, _special_reply, _ = _special + return JSONResponse({ + "reply": _special_reply, + "planner": "deterministic", + "steps": style_guide.enforce_guards(_special_steps) or _special_steps, + "agent_model": model_used, + }) + if parsed and mode != "check": + _strengthened = style_guide.strengthen(prompt, list(parsed.get("steps") or [])) + if _strengthened != parsed.get("steps"): + parsed["steps"] = _strengthened + parsed["planner"] = str(parsed.get("planner") or "llm") + "+guide" + # Keep /plan and /agent consistent: a generative intent must never be + # left on a deterministic substitute (e.g. a model returning only + # selection/adjustment steps for 'remove the person on the left'). + _generative = _ensure_generative(prompt, list(parsed.get("steps") or [])) + if _generative != parsed.get("steps"): + parsed["steps"] = _generative + parsed["planner"] = str(parsed.get("planner") or "llm") + "+generative" + # Same safety guards the /agent path applies, so /plan never returns + # a stacked-lift or a global adjustment after a masked recolour. + parsed["steps"] = style_guide.enforce_guards(list(parsed.get("steps") or [])) + # A bare whole-image tone plan for an unspecified prompt is a guess: + # ask the user instead of silently tweaking. + _maybe = _unsure_plan(prompt, parsed) + if _maybe is not parsed and isinstance(_maybe, dict): + _maybe["agent_model"] = model_used + parsed = _maybe + + if mode == "check": + # Never let a self-check re-plan substitute a generative placeImage + # for a deterministic recolour; re-issue the masked-hue plan instead. + _recolor = style_guide.recolor_plan(prompt) + if _recolor and _plan_uses_ai_content(list((parsed or {}).get("steps") or [])): + return JSONResponse({ + "reply": str(_recolor["reply"]), + "planner": "deterministic", + "steps": style_guide.enforce_guards(list(_recolor["steps"])), + "agent_model": model_used, + }) + if parsed and (parsed.get("done") or parsed.get("steps")): + return JSONResponse(parsed) + return JSONResponse({ + "done": True, + "reply": (parsed or {}).get("reply") or "The edit looks complete.", + "planner": (parsed or {}).get("planner") or "check", + "steps": [], + "agent_model": model_used, + }) + if parsed: + return JSONResponse(parsed) + _fallback = _fallback_plan(prompt) + _fallback["agent_model"] = model_used + return JSONResponse(_fallback) + + @router.post("/api/editor/bridge/command") + async def bridge_command(request: Request) -> JSONResponse: + owner = _user(request) + try: + body = await request.json() + except Exception: + body = {} + session = _scoped(body.get("session"), owner) + steps = _normalize_steps(body) + wait = bool(body.get("wait")) + timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT) + ids: List[str] = [] + wait_ids: List[str] = [] + for step in steps: + cid = uuid.uuid4().hex + ids.append(cid) + if wait: + pending[cid] = asyncio.get_event_loop().create_future() + wait_ids.append(cid) + _broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]}) + if wait and len(wait_ids) == 1: + try: + result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout) + return JSONResponse(result) + except asyncio.TimeoutError: + pending.pop(wait_ids[0], None) + return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]}) + return JSONResponse({"queued": ids, "session": session}) + + @router.get("/api/editor/bridge/events") + async def bridge_events(request: Request, session: str = "default") -> StreamingResponse: + owner = _user(request) + scoped = _scoped(session, owner) + + async def gen(): + q = _subscribe(scoped) + try: + yield ": connected\n\n" + while not _shutdown.is_shutting_down(): + try: + cmd = await asyncio.wait_for(q.get(), timeout=15) + yield f"data: {json.dumps(cmd)}\n\n" + except asyncio.TimeoutError: + yield ": keepalive\n\n" + finally: + _unsubscribe(scoped, q) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + @router.post("/api/editor/bridge/result") + async def bridge_result(request: Request) -> JSONResponse: + try: + body = await request.json() + except Exception: + body = {} + cid = str(body.get("id") or "") + fut = pending.pop(cid, None) + if fut is not None and not fut.done(): + fut.set_result({ + "id": cid, "ok": bool(body.get("ok")), + "result": body.get("result"), "error": body.get("error"), + "screenshot": body.get("screenshot"), + }) + return JSONResponse({"ok": True}) + + @router.post("/api/editor/bridge/await") + async def bridge_await(request: Request) -> JSONResponse: + try: + body = await request.json() + except Exception: + body = {} + cid = str(body.get("id") or "") + timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT) + fut = pending.get(cid) + if fut is None: + fut = asyncio.get_event_loop().create_future() + pending[cid] = fut + try: + result = await asyncio.wait_for(fut, timeout=timeout) + return JSONResponse(result) + except asyncio.TimeoutError: + pending.pop(cid, None) + return JSONResponse({"ok": False, "error": "timeout", "id": cid}) + + _AGENT_SYSTEM_PROMPT = ( + "You are the photo editor agent. You drive the open image editor and " + "may also run AI image operations. Available editor ops: " + + ", ".join(OP_CATALOG) + + ", plus ai_edit_image and generate_image. " + "Respond with ONLY a JSON object: {\"reply\": , " + "\"steps\": [{\"op\": , \"params\": {...}}]}. " + + _ROUTING_GUIDANCE + " " + + _CANONICAL_GUIDANCE + " " + _STYLE_GUIDANCE + " " + "Prefer AI content ops for generative requests: \"remove the person on the left\" " + "-> op \"ai_edit_image\" op \"inpaint\" with a prompt; \"add a full moon in the sky\" " + "-> op \"generate_image\" with a prompt. " + "Use deterministic editor ops (adjustments.apply, filters.apply, selection.*, " + "layers.placeImage, text.add, ...) only for non-generative, precise edits. " + "Keep steps minimal and ordered. " + + _SCHEMA_NOTE + ) + + def _scoped_editor_tools() -> List[str]: + """The exact tool list offered to the model for a photo_editor turn.""" + fallback = [ + "plan_edit", "run_editor_command", "get_editor_screenshot", + "ai_edit_image", "generate_image", "ask_user", + ] + try: + from src.tool_policy import ToolPolicy + from src.tool_schemas import FUNCTION_TOOL_SCHEMAS + from src.turn_contract import resolve_turn_contract + + contract = resolve_turn_contract( + capabilities={"photo_editor"}, + schemas=FUNCTION_TOOL_SCHEMAS, + policy=ToolPolicy(), + required_capabilities={"photo_editor"}, + ) + return sorted(contract.offered) or fallback + except Exception: + return fallback + + @router.post("/api/editor/agent") + async def editor_agent(request: Request) -> JSONResponse: + """Scoped photo_editor agent turn (Quick Edit). + + Offers ONLY the editor tool family plus ask_user, plans edits, and + drives the open editor through the owner-scoped bridge. AI edit and + generation actions land as new layers; the original is never + overwritten. + """ + owner = _user(request) + try: + body = await request.json() + except Exception: + body = {} + prompt = str(body.get("prompt") or "").strip() + session = str(body.get("editor_session") or body.get("session") or "default") + state = body.get("state") if isinstance(body.get("state"), dict) else {} + screenshot = body.get("screenshot") + # Optional user-picked image model (from the editor model selector). + model_choice = str(body.get("model") or body.get("image_model") or "").strip() + # Optional user-picked PLANNER model (the editor agent/planner model). + agent_model = str(body.get("agent_model") or "").strip() + tools_offered = _scoped_editor_tools() + scoped = _scoped(session, owner) + trace: List[Dict[str, Any]] = [ + {"type": "session", "session": scoped, "tools_offered": tools_offered}, + ] + if not prompt: + return JSONResponse({ + "ok": False, "reply": "No prompt provided.", + "tools_offered": tools_offered, "steps": [], "trace": trace, + "agent_model": agent_model or editor_agent_model.local_model(), + }) + text = json.dumps({"prompt": prompt, "state": state}, ensure_ascii=False) + content: Any = text + if isinstance(screenshot, str) and screenshot.startswith("data:image/"): + content = [ + {"type": "text", "text": text}, + {"type": "image_url", "image_url": {"url": screenshot}}, + ] + messages = [ + {"role": "system", "content": _AGENT_SYSTEM_PROMPT}, + {"role": "user", "content": content}, + ] + raw = None + planner = _EDITOR_PLANNER + editor_agent_model.set_override(agent_model) + editor_agent_model.set_last_model("", "") + if planner is not None: + try: + raw = planner(messages, owner) + if hasattr(raw, "__await__"): + raw = await raw + except Exception: + raw = None + _used = editor_agent_model.get_last_model() + model_used = (_used[0] if _used and _used[0] + else (agent_model or editor_agent_model.local_model())) + plan = _parse_plan(raw) or _fallback_plan(prompt) + _strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or [])) + if _strengthened != plan.get("steps"): + plan = {**plan, "steps": _strengthened, + "planner": f"{plan.get('planner') or 'llm'}+guide"} + kind = _generative_kind(prompt) + provider_available = _image_provider_available(owner) + # Deterministic overrides for intents the model reliably botches: canvas + # format/crop/extend, text + layer styles, and PSD export. + _special = _plan_special(prompt, state, provider_available) + special_note = "" + if _special: + special_steps, special_reply, special_note = _special + steps = style_guide.enforce_guards(special_steps) or list(special_steps) + reply = special_reply + kind = None + plan = {**plan, "steps": steps, + "planner": f"{plan.get('planner') or 'llm'}+deterministic"} + else: + steps = _ensure_generative(prompt, list(plan.get("steps") or [])) + steps = style_guide.enforce_guards(steps) + if steps != (plan.get("steps") or []): + plan = {**plan, "steps": steps, + "planner": f"{plan.get('planner') or 'llm'}+generative"} + reply = str(plan.get("reply") or "") + # A bare whole-image tone plan for an unspecified prompt is a guess: + # ask the user instead of silently tweaking. + # A bare whole-image tone plan for an unspecified prompt is a guess: + # ask the user instead of silently tweaking. Only replace when the + # guard actually changed the plan (identity), so a model-supplied + # ask_user never wipes out a concrete generative edit. + _base_plan = {**plan, "steps": steps} + _maybe = _unsure_plan(prompt, _base_plan) + if _maybe is not _base_plan and isinstance(_maybe, dict) and _maybe.get("ask_user"): + plan = _maybe + steps = [] + reply = str(_maybe.get("reply") or "") + trace.append({ + "type": "plan", "tool": "plan_edit", + "planner": plan.get("planner"), "steps": steps, + "image_provider": provider_available, "agent_model": model_used, + }) + # A vague prompt with no concrete defect asks the user instead of + # guessing a token edit (2-3 concrete options). + ask_user_payload = plan.get("ask_user") + if isinstance(ask_user_payload, dict) and not steps and not _special: + trace.append({"type": "ask_user", + "question": ask_user_payload.get("question")}) + return JSONResponse({ + "ok": True, + "ask_user": ask_user_payload, + "ask_user_pending": True, + "reply": reply or str(ask_user_payload.get("question") or ""), + "tools_offered": tools_offered, + "steps": [], + "trace": trace, + "results": [], + "agent_model": model_used, + }) + # No image provider: stop before any edit runs and tell the user plainly + # (zero canvas changes; nothing to roll back). + if (kind or _plan_uses_ai_content(steps)) and not provider_available: + trace.append({"type": "blocked", "reason": "image_provider_unavailable"}) + return JSONResponse({ + "ok": False, + "blocked": True, + "needs_provider": True, + "reply": _PROVIDER_REQUIRED_MESSAGE, + "message": _PROVIDER_REQUIRED_MESSAGE, + "image_provider": False, + "tools_offered": tools_offered, + "steps": steps, + "trace": trace, + "results": [], + "agent_model": model_used, + }) + results: List[Dict[str, Any]] = [] + for step in steps: + op = step.get("op") + params = step.get("params") or {} + if op in ("ai_edit_image", "generate_image"): + try: + from src.agent_tools.editor_tools import AiEditImageTool, GenerateImageLayerTool + except Exception: + results.append({"op": op, "ok": False, "error": "editor tools unavailable"}) + break + tool_payload = dict(params) + tool_payload.setdefault("session", session) + # Route to the user-picked model when one is selected. + if model_choice: + tool_payload.setdefault("model", model_choice) + # Hand the AI step the canvas image the client already captured + # for this request. Without it the tool falls back to a separate + # bridge screenshot, which is not reliably answered right after a + # selection step (the masked path was losing the canvas image). + if isinstance(screenshot, str) and screenshot.startswith("data:image/"): + tool_payload.setdefault("image", screenshot) + tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool + try: + _desc, res = await tool_cls().execute( + json.dumps(tool_payload), + {"owner": owner, "session_id": scoped}, + ) + except Exception as exc: + res = {"error": str(exc), "exit_code": 1} + res = res if isinstance(res, dict) else {} + ok = res.get("exit_code", 0) == 0 + step_result = { + k: res.get(k) for k in ("image_id", "image_url", "placed", "output", "model") + if k in res + } + results.append({ + "op": op, "ok": ok, "result": step_result, + # Surface the real provider/tool failure so the reply can + # say what went wrong instead of a bare ok:true/ok:false. + "error": None if ok else (res.get("error") or res.get("output") or "it did not complete"), + }) + trace.append({"type": "step", "op": op, "ok": ok, "result": step_result}) + if not ok: + break + continue + cid = uuid.uuid4().hex + pending[cid] = asyncio.get_event_loop().create_future() + _broadcast(scoped, {"id": cid, "op": op, "params": params}) + try: + res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT) + except asyncio.TimeoutError: + pending.pop(cid, None) + results.append({"op": op, "ok": False, "error": "timeout"}) + trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"}) + break + results.append(res) + trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")}) + if res.get("ok") is False: + break + # Post-run verification: read the ACTUAL canvas back and base the reply + # on executed results + final document.info, never the plan. + final_state: Dict[str, Any] = {} + if steps and not any(r.get("ok") is False for r in results): + try: + _info_id = uuid.uuid4().hex + pending[_info_id] = asyncio.get_event_loop().create_future() + _broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}}) + _info = await asyncio.wait_for(pending[_info_id], timeout=60) + if isinstance(_info, dict) and isinstance(_info.get("result"), dict): + final_state = _info["result"] + except Exception: + final_state = {} + planned_reply = reply + reply = _verified_reply(prompt, steps, results, final_state, planned_reply) + if special_note and special_note.strip() not in reply: + reply = (reply + " " + special_note.strip()).strip() + return JSONResponse({ + "ok": not any(r.get("ok") is False for r in results), + "reply": reply or "Done.", + "planned_reply": planned_reply, + "image_provider": provider_available, + "final_state": final_state, + "tools_offered": tools_offered, + "tools": ["plan_edit"], + "steps": steps, + "trace": trace, + "results": results, + "agent_model": model_used, + }) + + @router.get("/api/editor/style-guide") + async def style_guide_route() -> JSONResponse: + """The shared intent -> ops guide (single source of truth). + + The browser fallback planner fetches this so the client and server + routing rules cannot drift. + """ + return JSONResponse(style_guide.as_json()) + + @router.get("/api/editor/image-models") + async def editor_image_models(request: Request) -> JSONResponse: + """List the image models available from configured, owner-visible providers. + + Each entry is ``{id, label, kind, provider}`` where ``id`` is the model id + to pass as ``model`` to ai_edit_image / generate_image, and ``kind`` is + ``edit`` (editing/inpainting models) or ``generate`` (text-to-image). + """ + owner = _user(request) + models: List[Dict[str, Any]] = [] + seen: set = set() + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter + from src.endpoint_resolver import _endpoint_enabled_models + from src.image_model_ids import looks_like_image_generation_model + + db = SessionLocal() + try: + query = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), # noqa: E712 + ModelEndpoint, + owner, + ) + for ep in query.all(): + # Image-only endpoints (the local Qwen server) list their + # models verbatim. Gateway/chat endpoints (OpenRouter, …) + # mix chat and image models, so expose only ids that are + # recognisably image-capable — the same test the request + # router uses. + image_endpoint = getattr(ep, "model_type", None) == "image" + provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image" + for name in _endpoint_enabled_models(ep): + if not image_endpoint and not looks_like_image_generation_model(name): + continue + key = (provider, name) + if key in seen: + continue + seen.add(key) + models.append({ + "id": name, + "label": name, + "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", + "provider": provider, + "endpoint_id": getattr(ep, "id", None), + "base_url": getattr(ep, "base_url", "") or "", + }) + finally: + db.close() + except Exception: + logger.warning("editor image-models lookup failed", exc_info=True) + return JSONResponse({"models": models}) + + @router.get("/api/editor/agent-models") + async def editor_agent_models(request: Request) -> JSONResponse: + """List the models the editor agent/planner can run on. + + Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is + passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and + ``source`` is ``local`` (the local Qwen3.5-9B vision server) or + ``hosted`` (the configured default/utility chat model that the planner + falls back to). + """ + owner = _user(request) + try: + models = await editor_agent_model.list_editor_agent_models(owner) + except Exception: + logger.warning("editor agent-models lookup failed", exc_info=True) + models = [] + return JSONResponse({ + "models": models, + "probe": editor_agent_model.probe_status(), + }) + + @router.post("/api/editor/self-check") + async def editor_self_check(request: Request) -> JSONResponse: + """Same-origin VL self-check: compare BEFORE/AFTER against the intent. + + The local VL server (seneca :8097) sends no CORS headers, so the browser + cannot call it directly and the self-check silently "passed". This route + proxies the two images + intent to the agent VLM and returns + ``{done, issues, retry_hint, text}`` (``done=True`` with + ``reason="unavailable"`` on any failure so the client never loops). + """ + owner = _user(request) + try: + body = await request.json() + except Exception: + body = {} + result = await editor_agent_model.self_check( + body.get("before"), + body.get("after"), + str(body.get("prompt") or body.get("intent") or ""), + model=str(body.get("model") or "").strip() or None, + owner=owner, + ) + return JSONResponse(result) + + return router diff --git a/routes/email/email_helpers.py b/routes/email/email_helpers.py index 163132b85..d697b2fe7 100644 --- a/routes/email/email_helpers.py +++ b/routes/email/email_helpers.py @@ -443,9 +443,11 @@ async def _generate_email_summary( headers: dict | None = None, max_tokens: int = 8192, timeout: int = 180, + owner: str | None = None, ) -> str: """Generate an interactive email summary through the shared LLM adapter.""" from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call raw = await llm_call_async( url=url, @@ -456,6 +458,7 @@ async def _generate_email_summary( headers=headers, timeout=timeout, workload="foreground", + reasoning_effort=effort_for_call(url, model, owner), ) return _normalize_email_summary(raw) diff --git a/routes/email/email_pollers.py b/routes/email/email_pollers.py index 9229c0796..ec9d6a8fa 100644 --- a/routes/email/email_pollers.py +++ b/routes/email/email_pollers.py @@ -1031,6 +1031,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None fallback_url=url, fallback_model=model, fallback_headers=headers, owner=account_owner or None, temperature=0.7, max_tokens=1024, timeout=90, + reasoning_effort="", ) reply = _apply_email_style_mechanics(_extract_reply(reply or "")) if reply: diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 0b71116d6..294585e79 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -25,13 +25,16 @@ import json import re import html import io +import tempfile import zipfile -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse from html.parser import HTMLParser as _HTMLParser import logging import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path +from email.message import EmailMessage +from email import policy as email_policy from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -40,6 +43,7 @@ from starlette.concurrency import run_in_threadpool from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request from fastapi.responses import FileResponse, StreamingResponse from src.constants import DATA_DIR +from src import shutdown as _shutdown from src.path_confinement import confine from src.llm_core import llm_call_async @@ -81,6 +85,18 @@ _SERVER_OWNED_OAUTH_FIELDS = { "oauth_refresh_token", "oauth_token_expiry", } +_CACHE_HOOKS: dict = {"invalidate_list": None} + + +def _check_mail_host(host, owner: str, label: str) -> str | None: + """Reject unsafe or unresolved mail hosts before saving or testing them.""" + from src.url_safety import check_outbound_host + ok, reason = check_outbound_host(host, block_private=_mail_private_blocked(owner)) + if ok: + return None + logger.warning("Rejected %s mail host for owner=%r: %s", label, owner, reason) + # Resolver and exception details belong in server logs, never the response. + return f"Rejected {label} host: server address is not allowed or does not resolve" def _normalized_mail_host(value) -> str: @@ -572,6 +588,23 @@ def _uid_exists(conn, uid: str, *, strict: bool = False) -> bool: return False +def _uid_has_deleted_flag(conn, uid: str) -> bool: + r"""Return True if message already carries \Deleted flag.""" + try: + status, data = conn.uid("FETCH", _uid_bytes(uid), "(FLAGS)") + if status == "OK" and data: + for part in data: + if not part: + continue + meta = part[0] if isinstance(part, tuple) else part + meta_b = meta if isinstance(meta, bytes) else str(meta).encode() + if rb"\Deleted" in meta_b: + return True + except Exception: + pass + return False + + def _resolve_current_email_uid(conn, uid: str, message_id: str | None = None) -> str: """Resolve a stale cached UID by the message's stable RFC Message-ID.""" uid = str(uid or "").strip() @@ -618,10 +651,15 @@ def _message_id_chain(*values: str) -> list[str]: def _uid_from_fetch_meta(meta_b: bytes) -> str: - m = re.search(rb"\bUID\s+(\d+)\b", meta_b) + m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE) return m.group(1).decode() if m else "" +def _size_from_fetch_meta(meta_b: bytes) -> int | None: + m = re.search(rb"\bRFC822\.SIZE\s+(\d+)\b", meta_b, re.IGNORECASE) + return int(m.group(1)) if m else None + + def _parse_list_unsubscribe_header(value: str | None) -> list[dict]: """Parse RFC List-Unsubscribe entries into safe reviewable actions. @@ -1797,13 +1835,55 @@ def setup_email_routes(): return None return v[1] + _LIST_STALE = {} + + def _list_stale_get(key): + return _LIST_STALE.get(key) + + def _list_stale_put(key, value): + _LIST_STALE[key] = value + if len(_LIST_STALE) > 96: + for k in list(_LIST_STALE.keys())[:-48]: + _LIST_STALE.pop(k, None) + + _LIST_REFRESHING = set() + def _list_cache_put(key, value): _LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value) + # Also snapshot the last-good response for stale-while-revalidate. + _list_stale_put(key, value) # Cap size if len(_LIST_CACHE) > 64: for k in list(_LIST_CACHE.keys())[:-32]: _LIST_CACHE.pop(k, None) + def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner): + """Refresh a list snapshot off the event loop, at most once per key. + + Runs the blocking IMAP fetch in a daemon thread, then repopulates both + the TTL cache and the stale snapshot so the next list view is warm. + """ + if cache_key in _LIST_REFRESHING: + return + _LIST_REFRESHING.add(cache_key) + + def _run(): + try: + refreshed = _list_emails_sync(*sync_args) + if refreshed and not refreshed.get("error"): + _list_cache_put(cache_key, refreshed) + try: + _schedule_recent_email_warm( + refreshed.get("emails") or [], folder, account_id, owner) + except Exception: + pass + except Exception as exc: # pragma: no cover - background best effort + logger.debug("email list background refresh failed: %s", exc) + finally: + _LIST_REFRESHING.discard(cache_key) + + _threading.Thread(target=_run, daemon=True, name="email-list-refresh").start() + def _folder_cache_get(account_id, owner): key = (account_id or "", owner or "") v = _FOLDER_CACHE.get(key) @@ -1831,6 +1911,12 @@ def setup_email_routes(): delete, move) so the UI doesn't show stale read/unread counts for up to the 8s TTL after a manual flag change. With no args, clears everything. """ + hook = _CACHE_HOOKS.get("invalidate_list") + if hook: + try: + hook(account_id, folder) + except Exception: + pass if account_id is None and folder is None: _LIST_CACHE.clear() return @@ -2824,6 +2910,46 @@ def setup_email_routes(): owner, account_id or "", folder, filter, limit, offset, elapsed_ms, ) return cached + # Stale-while-revalidate: never block a plain list view on IMAP while + # we already hold a recent snapshot or a durable index. Serve it now + # with a `stale` marker and refresh in the background so the next view + # is warm. Blocking IMAP only remains for a genuine cold start with no + # snapshot and no indexed rows (e.g. a brand-new account/folder). + if not from_addr and not has_attachments and not (date_from or date_to): + snapshot = _list_stale_get(ck) + if snapshot is None: + try: + indexed_emails, indexed_total, indexed_at = _email_index_list( + owner, account_id, folder, filter, limit, offset, False, + ) + except Exception: + indexed_emails, indexed_total, indexed_at = [], 0, None + if indexed_total: + snapshot = { + "emails": indexed_emails, + "total": indexed_total, + "folder": folder, + "offset": offset, + "sync": { + "source": "index", + "indexed": indexed_total, + "updated_at": indexed_at, + }, + } + if snapshot is not None: + payload = dict(snapshot) + payload["emails"] = list(payload.get("emails") or []) + _hide_unlinked_calendar_tags(payload["emails"]) + sync_meta = dict(payload.get("sync") or {}) + sync_meta["source"] = "cache_stale" if _list_stale_get(ck) is not None else "index" + sync_meta["stale"] = True + payload["sync"] = sync_meta + _schedule_list_refresh( + (folder, limit, offset, filter, account_id, from_addr, + bool(has_attachments), owner, True, date_from or "", date_to or ""), + ck, folder, account_id, owner, + ) + return payload result = await _asyncio.to_thread( _list_emails_sync, folder, limit, offset, filter, account_id, from_addr, bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "", @@ -4161,6 +4287,414 @@ def setup_email_routes(): headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, ) + @router.post("/messages-download-bulk") + async def download_bulk_messages(request: Request, owner: str = Depends(require_owner)): + """Export selected original email messages as .eml files in a ZIP.""" + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + messages = payload.get("messages") if isinstance(payload, dict) else None + if not isinstance(messages, list) or not messages: + raise HTTPException(status_code=400, detail="No emails selected") + if len(messages) > 250: + raise HTTPException(status_code=400, detail="Too many emails selected") + + category = str(payload.get("category") or "selected").strip().lower() + category = re.sub(r"[^a-z0-9_-]+", "-", category).strip("-") or "selected" + selected_dates = sorted({ + str(row.get("date") or "").strip() + for row in messages if isinstance(row, dict) + and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(row.get("date") or "").strip()) + }) + date_span = "" + if selected_dates: + date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}" + export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "") + + # 512 MiB safety cap prevents unbounded memory growth when exporting + # many large messages. + max_total_bytes = 512 * 1024 * 1024 + max_message_bytes = 64 * 1024 * 1024 + spool_threshold_bytes = 32 * 1024 * 1024 + used_names: set[str] = set() + exported = 0 + skipped = 0 + total_bytes = 0 + validated_accounts: set[str] = set() + + def _email_filename(row: dict, uid: str) -> str: + date = str(row.get("date") or "").strip() + date = date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else "" + subject = str(row.get("subject") or "email").strip() + subject = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]+', "_", subject) + subject = re.sub(r"\s+", " ", subject).strip(" .")[:100] or "email" + safe_uid = re.sub(r"[^A-Za-z0-9_-]+", "_", uid).strip("_") or "message" + prefix = f"{date} - " if date else "" + return _safe_attachment_zip_name(f"{prefix}{subject} - {safe_uid}.eml", "email.eml") + + def _fixture_message_bytes(fixture: dict, uid: str, folder: str) -> bytes: + msg = EmailMessage() + sender_name = str(fixture.get("from_name") or "").strip() + sender_address = str(fixture.get("from_address") or "").strip() + sender = email.utils.formataddr((sender_name, sender_address)) if sender_address else sender_name + for key, value in ( + ("From", sender), + ("To", fixture.get("to") or owner), + ("Subject", fixture.get("subject") or "(no subject)"), + ("Message-ID", fixture.get("message_id") or f""), + ): + clean_value = re.sub(r"[\r\n]+", " ", str(value or "")).strip() + if clean_value: + msg[key] = clean_value + raw_date = str(fixture.get("date") or "").strip() + if raw_date: + try: + parsed_date = datetime.fromisoformat(raw_date.replace("Z", "+00:00")) + if parsed_date.tzinfo is None: + parsed_date = parsed_date.replace(tzinfo=timezone.utc) + except Exception: + try: + parsed_date = email.utils.parsedate_to_datetime(raw_date) + except Exception: + parsed_date = None + if parsed_date is not None: + msg["Date"] = email.utils.format_datetime(parsed_date) + msg.set_content(str(fixture.get("body") or "")) + for attachment in fixture.get("attachments") or []: + try: + index = int(attachment.get("index")) + source = _fixture_attachment_source(uid, index, owner, folder) + if not source: + continue + _row, raw_attachment = source + content_type = str(raw_attachment.get("content_type") or "application/octet-stream") + maintype, _, subtype = content_type.partition("/") + if not subtype: + maintype, subtype = "application", "octet-stream" + msg.add_attachment( + str(raw_attachment.get("content") or "").encode("utf-8"), + maintype=maintype, + subtype=subtype, + filename=str(raw_attachment.get("filename") or attachment.get("filename") or f"attachment-{index}"), + ) + except Exception: + logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True) + return msg.as_bytes(policy=email_policy.SMTP) + + # Optimization: single message exports return raw .eml directly without ZIP compression. + if len(messages) == 1: + row = messages[0] + if not isinstance(row, dict): + raise HTTPException(status_code=404, detail="No selected emails could be exported") + uid = str(row.get("uid") or "").strip() + if not uid: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id: + _assert_owns_account(account_id, owner) + + filename = _email_filename(row, uid) + message_bytes: bytes | None = None + + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + message_bytes = _fixture_message_bytes(fixture, uid, folder) + elif re.fullmatch(r"\d+", uid): + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + uid_b = uid.encode() + st_sz, sz_data = None, None + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)") + except Exception as sz_err: + logger.warning("Single message size preflight query error for uid=%s: %s", uid, sz_err) + + if st_sz != "OK": + logger.warning("Single message size preflight status=%s for uid=%s", st_sz, uid) + raise HTTPException(status_code=502, detail="Unable to verify email size safety") + + if not sz_data: + # Message does not exist in mailbox (missing mail) + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + sz: int | None = None + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + if u == uid: + sz = _size_from_fetch_meta(meta) + break + + if sz is None or sz < 0: + logger.warning("Single message size preflight returned invalid/malformed size for uid=%s", uid) + raise HTTPException(status_code=502, detail="Invalid email size metadata from server") + + if sz > max_message_bytes or sz > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + # Preflight size safety established; perform RFC822 fetch. + st, msg_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822)") + if st == "OK" and msg_data: + for meta, raw in _group_uid_fetch_records(msg_data): + if _uid_from_fetch_meta(meta) == uid and raw: + message_bytes = bytes(raw) + break + except HTTPException: + raise + except Exception as exc: + logger.warning("Single email export failed for uid=%s folder=%s: %s", uid, folder, exc) + + if not message_bytes: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + if len(message_bytes) > max_message_bytes or len(message_bytes) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + return StreamingResponse( + io.BytesIO(message_bytes), + media_type="message/rfc822", + headers={ + "Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}", + "X-Exported-Emails": "1", + "X-Skipped-Emails": "0", + }, + ) + + seen_items: set[tuple[str | None, str, str]] = set() + remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {} + spooled_file = tempfile.SpooledTemporaryFile(max_size=spool_threshold_bytes, mode="w+b") + response_started = False + try: + with zipfile.ZipFile(spooled_file, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for row in messages: + if not isinstance(row, dict): + skipped += 1 + continue + uid = str(row.get("uid") or "").strip() + if not uid: + skipped += 1 + continue + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id and account_id not in validated_accounts: + _assert_owns_account(account_id, owner) + validated_accounts.add(account_id) + + item_key = (account_id, folder, uid) + if item_key in seen_items: + skipped += 1 + continue + seen_items.add(item_key) + + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + filename = _email_filename(row, uid) + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = _fixture_message_bytes(fixture, uid, folder) + if len(message_bytes) > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + total_bytes += len(message_bytes) + if total_bytes > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + try: + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + except Exception as zip_err: + logger.error("ZIP entry write failed for fixture: %s", zip_err) + raise HTTPException(status_code=500, detail="Failed to create export archive") + exported += 1 + continue + if not re.fullmatch(r"\d+", uid): + skipped += 1 + continue + group = remote_groups.setdefault((account_id, folder), {}) + group.setdefault(uid, (_email_filename(row, uid), row)) + + preflight_error: HTTPException | None = None + + # Fetch original RFC822 data in batches and store it byte-for-byte. + # Note on IMAP memory-safety: Python's standard imaplib buffers socket responses into + # memory before returning literals to application code. Preflight verification ensures + # we do not intentionally fetch messages exceeding limits, but an untrusted or non-compliant + # IMAP server sending unexpectedly large literals cannot be bounded at the transport level + # without custom socket streaming. Post-fetch checks guard application-level storage. + for (account_id, folder), rows_by_uid in remote_groups.items(): + rows = list(rows_by_uid.items()) + remaining_rows = len(rows) + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + + # Size preflight query to catch oversized exports early and bound batch sizes. + uid_set_all = ",".join(uid for uid, _ in rows).encode() + st_sz, sz_data = None, None + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)") + except Exception as sz_err: + logger.warning("IMAP size preflight query failed for folder=%s: %s", folder, sz_err) + + if st_sz != "OK": + logger.warning("IMAP size preflight status=%s for folder=%s account=%s", st_sz, folder, account_id) + preflight_error = HTTPException(status_code=502, detail="Unable to verify email size safety") + skipped += remaining_rows + remaining_rows = 0 + continue + + if not sz_data: + # All requested UIDs in this mailbox are missing on server + skipped += remaining_rows + remaining_rows = 0 + continue + + reported_sizes: dict[str, int | None] = {} + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + if u: + reported_sizes[u] = _size_from_fetch_meta(meta) + + # Categorize requested UIDs + malformed_uids = [ + uid for uid, _ in rows + if uid in reported_sizes and (reported_sizes[uid] is None or reported_sizes[uid] < 0) + ] + if malformed_uids: + logger.warning("IMAP size preflight returned invalid/malformed size for uids=%s in folder=%s", malformed_uids, folder) + preflight_error = HTTPException(status_code=502, detail="Invalid email size metadata from server") + skipped += remaining_rows + remaining_rows = 0 + continue + + # Identify missing mail (requested UIDs not returned in preflight) + missing_uids = {uid for uid, _ in rows if uid not in reported_sizes} + if missing_uids: + skipped += len(missing_uids) + remaining_rows -= len(missing_uids) + rows = [item for item in rows if item[0] not in missing_uids] + + if not rows: + continue + + size_by_uid: dict[str, int] = { + uid: reported_sizes[uid] for uid, _ in rows + } + + for u, s in size_by_uid.items(): + if s > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + if total_bytes + sum(size_by_uid.values()) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + # Form batches with at most 20 messages and capped at 10 MiB payload. + batches: list[list[tuple[str, tuple[str, dict]]]] = [] + cur_batch: list[tuple[str, tuple[str, dict]]] = [] + cur_batch_bytes = 0 + max_batch_bytes = 10 * 1024 * 1024 + for item in rows: + uid_val = item[0] + sz = size_by_uid[uid_val] + if cur_batch and ( + len(cur_batch) >= 20 or + (cur_batch_bytes + sz > max_batch_bytes) + ): + batches.append(cur_batch) + cur_batch = [] + cur_batch_bytes = 0 + cur_batch.append(item) + cur_batch_bytes += sz + if cur_batch: + batches.append(cur_batch) + + for batch in batches: + wanted = {uid: filename for uid, (filename, _row) in batch} + uid_set = ",".join(wanted).encode() + status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)") + if status != "OK" or not msg_data: + skipped += len(batch) + remaining_rows -= len(batch) + continue + found: set[str] = set() + for meta, raw_message in _group_uid_fetch_records(msg_data): + uid = _uid_from_fetch_meta(meta) + if not uid or uid not in wanted or not raw_message: + continue + if uid in found: + # P2: Reject duplicate IMAP response records for an already-processed UID + logger.debug("Ignoring duplicate IMAP FETCH record for uid=%s in folder=%s", uid, folder) + continue + filename = wanted[uid] + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = bytes(raw_message) + if len(message_bytes) > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + total_bytes += len(message_bytes) + if total_bytes > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + try: + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + except Exception as zip_err: + logger.error("ZIP entry write failed for remote message: %s", zip_err) + raise HTTPException(status_code=500, detail="Failed to create export archive") + found.add(uid) + exported += 1 + remaining_rows -= 1 + missing = len(batch) - len(found) + skipped += len(batch) - len(found) + remaining_rows -= missing + except HTTPException: + raise + except Exception as exc: + skipped += remaining_rows + logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc) + + if preflight_error is not None: + raise preflight_error + + if exported <= 0: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + spooled_file.seek(0) + zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip") + logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped) + + def _iter_spooled(f, chunk_size=64 * 1024): + try: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk + finally: + f.close() + + response_started = True + return StreamingResponse( + _iter_spooled(spooled_file), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_name}"', + "X-Exported-Emails": str(exported), + "X-Skipped-Emails": str(skipped), + }, + ) + finally: + if not response_started: + spooled_file.close() + @router.get("/inline-image/{uid}") async def inline_image( uid: str, @@ -4764,6 +5298,7 @@ def setup_email_routes(): return {"success": False, "error": "No email UIDs provided", "deleted_uids": [], "failed_uids": []} deleted_uids = [] failed_uids = [] + trash_folder = None try: with _imap(account_id, owner=owner) as conn: select_status, _ = conn.select(_q(folder), readonly=False) @@ -4779,7 +5314,8 @@ def setup_email_routes(): trash_folder = "Trash" except Exception: pass - pending_expunge = False + pending_expunge = [] + preexisting_deleted = set() for uid in uids: try: if not _uid_exists(conn, uid): @@ -4789,32 +5325,75 @@ def setup_email_routes(): if status != "OK": copy_status, _ = conn.uid("COPY", _uid_bytes(uid), _q(trash_folder)) if copy_status != "OK": - # Keep deletion reliable even when this IMAP - # server has no writable Trash folder. - store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") - if store_status != "OK": - failed_uids.append(uid) - continue - pending_expunge = True - else: - store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") - if store_status != "OK": - failed_uids.append(uid) - continue - pending_expunge = True + # Bulk Delete means move to Trash. Never remove + # the original without a recoverable Trash copy. + failed_uids.append(uid) + continue + if _uid_has_deleted_flag(conn, uid): + preexisting_deleted.add(uid) + store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") + if store_status != "OK": + failed_uids.append(uid) + continue + # Keep the local index until the server confirms + # that the copied originals have been removed. + pending_expunge.append(uid) + continue deleted_uids.append(uid) _email_index_delete(owner, account_id, folder, uid) except Exception: failed_uids.append(uid) logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True) if pending_expunge: - conn.expunge() - if deleted_uids: - _invalidate_list_cache(account_id, folder) - return {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids} + expunge_ok = False + # Attempt selective RFC 4315 UID EXPUNGE so unrelated messages + # carrying \Deleted in the selected mailbox are never expunged. + # Mailbox-wide EXPUNGE is strictly avoided to eliminate TOCTOU races + # where concurrent clients mark unrelated messages \Deleted. + try: + uid_set_bytes = _uid_bytes(",".join(pending_expunge)) + uid_exp_status, _ = conn.uid("EXPUNGE", uid_set_bytes) + if uid_exp_status == "OK": + expunge_ok = True + except Exception: + logger.debug("Selective UID EXPUNGE failed", exc_info=True) + expunge_ok = False + + if expunge_ok: + for uid in pending_expunge: + deleted_uids.append(uid) + _email_index_delete(owner, account_id, folder, uid) + else: + # Prioritize preservation over cleanup: unflag \Deleted from our + # copied messages so future EXPUNGE operations do not destroy them. + # Do not remove a pre-existing \Deleted flag present before this operation. + for uid in pending_expunge: + if uid not in preexisting_deleted: + try: + conn.uid("STORE", _uid_bytes(uid), "-FLAGS", "\\Deleted") + except Exception: + pass + failed_uids.extend(pending_expunge) + if not deleted_uids: + return {"success": False, "error": "Failed to delete requested emails", "deleted_uids": [], "failed_uids": failed_uids} + res = {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids} + if failed_uids: + res["error"] = "Mail operation partially failed" + return res except Exception as e: logger.error(f"Failed to bulk delete emails: {e}") - return {"success": False, "error": "Mail operation failed", "deleted_uids": deleted_uids, "failed_uids": [u for u in uids if u not in deleted_uids]} + failed_all = [u for u in uids if u not in deleted_uids] + return { + "success": bool(deleted_uids), + "error": "Mail operation failed" if not deleted_uids else "Mail operation partially failed", + "deleted_uids": deleted_uids, + "failed_uids": failed_all, + } + finally: + if deleted_uids: + _invalidate_list_cache(account_id, folder) + if trash_folder: + _invalidate_list_cache(account_id, trash_folder) @router.delete("/odysseus/reminders") async def delete_odysseus_reminder_emails( @@ -5920,7 +6499,12 @@ def setup_email_routes(): }, ] - style = await llm_call_async(url, model, messages, headers=headers, max_tokens=2048) + from src.utility_effort import effort_for_call + + style = await llm_call_async( + url, model, messages, headers=headers, max_tokens=2048, + reasoning_effort=effort_for_call(url, model, owner), + ) style = _strip_think(style or "") if not style: return {"success": False, "error": "LLM failed to generate style description"} @@ -6004,6 +6588,7 @@ def setup_email_routes(): headers=req_headers, max_tokens=8192, timeout=180, + owner=owner, ) except Exception as e: logger.warning( @@ -6125,6 +6710,8 @@ def setup_email_routes(): if not candidates: return {"success": False, "error": "No LLM endpoint configured"} + from src.utility_effort import candidate_effort_factory + content = await llm_call_async_with_fallback( candidates, messages=[ @@ -6152,6 +6739,7 @@ def setup_email_routes(): temperature=0.2, max_tokens=8192, timeout=180, + candidate_request_factory=candidate_effort_factory(owner), ) model = candidates[0][1] if candidates else "" content = (content or "").strip() @@ -6212,7 +6800,7 @@ def setup_email_routes(): await queue.put({'type': 'result', **result}) task = asyncio.create_task(run()) try: - while True: + while not _shutdown.is_shutting_down(): item = await queue.get() yield 'data: ' + json.dumps(item) + '\n\n' if item.get('type') == 'result': @@ -6645,6 +7233,12 @@ def setup_email_routes(): if account_id: _assert_owns_account(account_id, owner) + for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")): + if data.get(field): + host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label) + if host_error: + return {"ok": False, "error": host_error} + # Non-reply automation flags stay global. Away/auto-reply settings are # account-scoped when an account_id is supplied by the Email Settings UI. settings = _load_settings() @@ -6785,6 +7379,11 @@ def setup_email_routes(): smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465) if port_err: return {"ok": False, "error": port_err} + for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")): + if data.get(field): + host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label) + if host_error: + return {"ok": False, "error": host_error} db = SessionLocal() try: _lock_email_account_owner_mutation(db, owner) @@ -6830,6 +7429,11 @@ def setup_email_routes(): """Update an email account. Passwords only overwrite if non-empty.""" # Path param account_id — dep validated via Query, re-check the path-param value. _assert_owns_account(account_id, owner) + for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")): + if data.get(field): + host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label) + if host_error: + return {"ok": False, "error": host_error} from core.database import SessionLocal, EmailAccount db = SessionLocal() try: diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index 276ac90a5..c856223c3 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -1,14 +1,17 @@ """Gallery routes — browsable library for photos and AI-generated images.""" import os +import asyncio import base64 +import gc import hashlib import io import logging import re +import time import uuid from pathlib import Path -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Tuple from fastapi import APIRouter, HTTPException, Query, Request @@ -34,6 +37,241 @@ _SAM_STATE: Dict[str, Any] = {} _GROUNDING_STATE: Dict[str, Any] = {} +def _rss_mb() -> float: + """Current process RSS in MB (from /proc; -1.0 when unavailable).""" + try: + with open("/proc/self/status", "r") as fh: + for line in fh: + if line.startswith("VmRSS:"): + return round(int(line.split()[1]) / 1024.0, 1) + except Exception: + pass + return -1.0 + + +def _image_op_log_rss(op: str, phase: str) -> None: + logger.info("image-op %s %s rss_mb=%.1f", op, phase, _rss_mb()) + + +def _local_image_models_enabled() -> bool: + """Whether GB-scale local image models may be loaded IN-PROCESS. + + Default OFF: loading rembg/SAM/OWL-ViT/Real-ESRGAN in the web process + routinely OOM-kills memory-constrained hosts. Enable with + ``ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS=1`` or point ``ODYSSEUS_IMAGE_OPS_BASE`` + at a remote service that implements the same ``/api/image/*`` endpoints. + """ + return os.getenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "").strip().lower() in ( + "1", "true", "yes", "on", + ) + + +def _remote_image_ops_base() -> str: + return (os.getenv("ODYSSEUS_IMAGE_OPS_BASE", "") or "").strip().rstrip("/") + + +def _outpaint_timeout() -> float: + """Provider timeout for the outpaint/extend proxy. + + Tall extensions can take up to ~330 s (the provider's own timeout is + 900 s), so the proxy waits 900 s by default — otherwise a long extend is + cut off at the proxy. Override with ``ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT``. + """ + try: + return float(os.getenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "900") or 900) + except (TypeError, ValueError): + return 900.0 + + +def _unload_image_models() -> None: + """Drop lazily-loaded torch models so their RAM returns to the OS.""" + if not _SAM_STATE and not _GROUNDING_STATE: + return + _SAM_STATE.clear() + _GROUNDING_STATE.clear() + try: + import torch # type: ignore + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + pass + gc.collect() + _image_op_log_rss("models", "unloaded") + + +async def _route_heavy_image_op(path: str, body: dict, *, label: str): + """Proxy a heavy image op to the remote ops service when configured, else + run it in-process only when explicitly enabled. + + Returns the remote JSON on success, or ``None`` to signal the caller should + run it in-process; raises HTTPException(503) with a clear message otherwise + so the app never loads a GB-scale model on a memory-constrained host. + """ + base = _remote_image_ops_base() + if base: + _image_op_log_rss(label, f"proxy->{base}") + try: + import httpx + + async with httpx.AsyncClient(timeout=300) as client: + resp = await client.post(base + path, json=body) + if resp.status_code == 200: + return resp.json() + logger.warning("image-op %s remote %s -> %s", label, path, resp.status_code) + raise HTTPException( + 503, + f"{label} is temporarily unavailable (remote service returned {resp.status_code}).", + ) + except HTTPException: + raise + except Exception as exc: + logger.warning("image-op %s remote %s failed: %s", label, path, exc) + raise HTTPException( + 503, + f"{label} is temporarily unavailable (remote service unreachable).", + ) from exc + if _local_image_models_enabled(): + return None + raise HTTPException(503, f"{label} is temporarily unavailable on this server.") + + +def _xywh_to_xyxy(bbox): + """Convert a remote image-ops [x, y, w, h] bbox to this app's [x1,y1,x2,y2].""" + try: + x, y, w, h = (float(v) for v in bbox) + return [int(x), int(y), int(x + w), int(y + h)] + except Exception: + return bbox + + +async def _remote_remove_background(body: dict): + """Proxy background removal to the remote ops service (seneca contract). + + Endpoint: POST {base}/v1/images/remove-background (multipart: image, + optional hint_mask, model, return_mask). Returns {"image": } + to match this route's local response shape, or None when the caller should + run in-process (local models explicitly enabled); raises 503 otherwise. + """ + base = _remote_image_ops_base() + if not base: + if _local_image_models_enabled(): + return None + raise HTTPException(503, "Background removal is temporarily unavailable on this server.") + img_bytes = _b64_bytes(body.get("image")) + hint_bytes = None + if body.get("hint_mask"): + hint_bytes = _b64_bytes(body.get("hint_mask"), label="hint_mask") + _image_op_log_rss("remove-bg", f"proxy->{base}") + try: + import httpx + + files = {"image": ("image.png", img_bytes, "image/png")} + if hint_bytes: + files["hint_mask"] = ("hint.png", hint_bytes, "image/png") + data = {"model": str(body.get("model") or ""), "return_mask": "true"} + async with httpx.AsyncClient(timeout=300) as client: + resp = await client.post(base + "/v1/images/remove-background", files=files, data=data) + if resp.status_code == 200: + payload = resp.json() + items = payload.get("data") or [] + if not items or not items[0].get("b64_json"): + raise HTTPException(503, "Background removal is temporarily unavailable (remote returned no image).") + out = {"image": items[0]["b64_json"]} + if items[0].get("mask_b64"): + out["mask"] = items[0]["mask_b64"] + if items[0].get("bbox"): + out["bbox"] = items[0]["bbox"] + return out + raise HTTPException( + 503, f"Background removal is temporarily unavailable (remote service returned {resp.status_code})." + ) + except HTTPException: + raise + except Exception as exc: + logger.warning("image-op remove-bg remote failed: %s", exc) + raise HTTPException(503, "Background removal is temporarily unavailable (remote service unreachable).") from exc + + +async def _remote_smart_mask(body: dict): + """Proxy SAM/grounding masking to the remote ops service (seneca contract). + + Endpoint: POST {base}/v1/images/mask (multipart: image + one of prompt/box/ + points). Returns {"mask": , "bbox": [...], "model", "device"} to + match this route's local response shape, or None when the caller should run + in-process; raises 503 otherwise. + """ + base = _remote_image_ops_base() + if not base: + if _local_image_models_enabled(): + return None + raise HTTPException(503, "Smart mask is temporarily unavailable on this server.") + img_bytes = _b64_bytes(body.get("image")) + points = body.get("points") or [] + box = body.get("box") + text = (body.get("text") or body.get("query") or "").strip() + if not points and not box and not text: + raise HTTPException(400, "Provide at least one point, box, or object text") + _image_op_log_rss("mask", f"proxy->{base}") + try: + import httpx + + data: Dict[str, Any] = {} + if text: + data["prompt"] = text + if box: + try: + x1, y1, x2, y2 = (float(v) for v in box) + data["box"] = ",".join(str(int(v)) for v in (x1, y1, x2 - x1, y2 - y1)) + except Exception as exc: + raise HTTPException(400, "Box must be [x1, y1, x2, y2]") from exc + if points: + import json as _json + + data["points"] = _json.dumps( + [[float(p["x"]), float(p["y"]), int(p.get("label", 1))] for p in points] + ) + if body.get("multimask") is not None: + data["multimask"] = str(bool(body.get("multimask"))).lower() + if body.get("dilate_px") is not None: + data["dilate_px"] = str(int(body.get("dilate_px"))) + files = {"image": ("image.png", img_bytes, "image/png")} + async with httpx.AsyncClient(timeout=300) as client: + resp = await client.post(base + "/v1/images/mask", files=files, data=data) + if resp.status_code == 200: + payload = resp.json() + masks = payload.get("masks") or [] + candidates = [m for m in masks if m.get("b64_json")] + if not candidates: + # Empty selection is a normal 200 outcome (never a 400): the + # editor shows a "nothing selected" message and changes nothing. + return { + "mask": None, + "bbox": None, + "masks": [], + "model": payload.get("model") or "sam+grounding", + "device": payload.get("device", ""), + "error": "Nothing selected for that selection.", + } + best = max(candidates, key=lambda m: float(m.get("score") or 0.0)) + return { + "mask": best["b64_json"], + "bbox": _xywh_to_xyxy(best.get("bbox")), + "label": best.get("label"), + "masks": masks, + "model": payload.get("model") or "sam+grounding", + "device": payload.get("device", ""), + } + raise HTTPException( + 503, f"Smart mask is temporarily unavailable (remote service returned {resp.status_code})." + ) + except HTTPException: + raise + except Exception as exc: + logger.warning("image-op mask remote failed: %s", exc) + raise HTTPException(503, "Smart mask is temporarily unavailable (remote service unreachable).") from exc + + def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"): if not image_b64: raise HTTPException(400, "Missing image") @@ -50,12 +288,79 @@ def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"): raise HTTPException(400, "Invalid image") from exc +def _b64_bytes(value: str, *, label: str = "image") -> bytes: + """Decode raw base64 OR a ``data:image/...;base64,`` URL to bytes. + + The image-edit tools post data URLs, while the browser posts raw base64, + so every decode site must accept both. + """ + if not value: + raise HTTPException(400, f"Missing {label}") + text = str(value) + if "," in text and text.split(",", 1)[0].startswith("data:"): + text = text.split(",", 1)[1] + try: + return base64.b64decode(text) + except Exception as exc: + raise HTTPException(400, f"Invalid {label}") from exc + + def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str: buf = io.BytesIO() img.save(buf, format=fmt) return base64.b64encode(buf.getvalue()).decode("ascii") +def _prepare_local_edit(src_b64, mask_b64, max_side): + """Decode/normalize/cap an edit's inputs to PNG bytes at a model-friendly size. + + CPU-bound (a 4000x3000 decode+resize is ~0.2 s); run it via a thread off the + event loop. Returns (source_png_bytes, mask_png_bytes, model_w, model_h, orig_size). + """ + from PIL import Image + + img_bytes = _b64_bytes(src_b64) + source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA") + if mask_b64: + mask_bytes = _b64_bytes(mask_b64, label="mask") + mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L") + else: + mask_png = Image.new("L", source_png.size, 255) + orig_size = source_png.size + if max(orig_size) > max_side: + scale = float(max_side) / max(orig_size) + model_w = max(16, int(round(orig_size[0] * scale / 16.0)) * 16) + model_h = max(16, int(round(orig_size[1] * scale / 16.0)) * 16) + else: + model_w, model_h = orig_size + if (model_w, model_h) != orig_size: + source_work = source_png.resize((model_w, model_h), Image.LANCZOS) + mask_work = mask_png.resize((model_w, model_h), Image.LANCZOS) + else: + source_work, mask_work = source_png, mask_png + src_buf = io.BytesIO() + source_work.save(src_buf, format="PNG") + mask_buf = io.BytesIO() + mask_work.save(mask_buf, format="PNG") + return src_buf.getvalue(), mask_buf.getvalue(), model_w, model_h, orig_size + + +def _restore_local_b64(raw_b64, orig_size, model_size): + """Resize a provider result b64 back to the original dimensions (CPU-bound).""" + if tuple(model_size) == tuple(orig_size): + return raw_b64 + from PIL import Image + + try: + im = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA") + im = im.resize(tuple(orig_size), Image.LANCZOS) + ob = io.BytesIO() + im.save(ob, format="PNG") + return base64.b64encode(ob.getvalue()).decode("ascii") + except Exception: + return raw_b64 + + def _load_sam_backend(): model_id = os.getenv("ODYSSEUS_SAM_MODEL", "facebook/sam-vit-base") cached = _SAM_STATE.get(model_id) @@ -301,7 +606,61 @@ def _first_visible_image_endpoint(db, owner: str | None): for ep in endpoints: if getattr(ep, "owner", None) == owner: return ep - return endpoints[0] if endpoints else None + if endpoints: + return endpoints[0] + # Fallback: the image model may live on a *mixed* endpoint (model_type != + # "image"), e.g. qwen-image-edit / gpt-image-1 on a shared OpenAI-compatible + # gateway or a diffusion server registered as "chat". Chat image generation + # already resolves its model by name across every enabled endpoint, so the + # editor must find the same model here instead of reporting "no endpoint". + return _first_mixed_image_endpoint(db, owner) + + +def _first_mixed_image_endpoint(db, owner: str | None): + """Best-effort image endpoint when no image-only endpoint is registered. + + Returns an enabled, owner-visible endpoint whose configured model list + includes an image-generation model, preferring the admin's configured + ``image_model`` when it is one of them. + """ + import json + from src.auth_helpers import owner_filter + from src.image_model_ids import looks_like_image_generation_model + + preferred = "" + try: + from src.settings import load_settings + preferred = str((load_settings() or {}).get("image_model") or "").strip() + except Exception: + preferred = "" + + query = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), # noqa: E712 + ModelEndpoint, + owner, + ) + fallback = None + for ep in query.all(): + if getattr(ep, "model_type", None) == "image": + continue + models = set() + for field in ("cached_models", "pinned_models"): + try: + values = getattr(ep, field, None) or [] + values = json.loads(values) if isinstance(values, str) else values + models.update(v for v in values if isinstance(v, str)) + except (TypeError, ValueError): + pass + matches = {m for m in models if looks_like_image_generation_model(m)} + if not matches: + continue + if preferred and preferred in matches: + return ep + if owner and getattr(ep, "owner", None) == owner: + return ep + if fallback is None: + fallback = ep + return fallback def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: str = ""): @@ -360,6 +719,92 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]: return None +def _resolve_image_op_endpoint(db, owner, requested_base: str, chosen_model: str = "") -> Tuple[str, Optional[str]]: + """Resolve the provider base (with /v1) + api_key for a self-hosted image op. + + Mirrors inpaint_proxy: a client-supplied base must map to a registered, + owner-visible image endpoint (no direct use of request-body URLs). + """ + if requested_base: + ep = _visible_image_endpoint_for_base(db, requested_base, owner, chosen_model) + if not ep: + raise HTTPException(403, "Choose a registered image endpoint") + else: + ep = _first_visible_image_endpoint(db, owner) + if not ep: + raise HTTPException(400, "No image generation endpoint configured. Serve a diffusion model via Cookbook first.") + base = ep.base_url.rstrip("/") + if not base.endswith("/v1"): + base += "/v1" + return base, ep.api_key + + +async def _forward_seneca_image_op( + base: str, + api_key: Optional[str], + provider_path: str, + image_bytes: bytes, + fields: Dict[str, str], + *, + label: str, + timeout: float = 300.0, +) -> Dict[str, Any]: + """POST a single-image multipart op to the provider and return ``{image}``. + + The provider returns OpenAI-style ``{data:[{b64_json|url}]}`` or a raw + ``{image}``. ``request_id`` in ``fields`` keys the provider's progress route. + """ + import httpx + + url = base.rstrip("/") + provider_path + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + files = {"image": ("source.png", image_bytes, "image/png")} + _t0 = time.perf_counter() + try: + async with httpx.AsyncClient(timeout=timeout) as client: + r = await client.post(url, data=fields, files=files, headers=headers) + _provider_ms = (time.perf_counter() - _t0) * 1000.0 + except httpx.TimeoutException: + raise HTTPException(504, f"{label} timed out ({int(timeout)}s)") + except Exception as exc: + logger.warning("%s provider call failed: %s", label, exc) + raise HTTPException(503, f"{label} is temporarily unavailable (remote service unreachable).") + if r.status_code != 200: + logger.warning("%s provider status %s", label, r.status_code) + detail = f"{label} failed" + try: + j = r.json() + if isinstance(j, dict) and (j.get("error") or j.get("detail")): + detail = str(j.get("error") or j.get("detail"))[:200] + except Exception: + pass + raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail) + result = r.json() + raw_b64: Optional[str] = None + used_model = "" + if isinstance(result, dict): + used_model = str(result.get("model") or "") + data = result.get("data") + if isinstance(data, list) and data: + item = data[0] or {} + raw_b64 = item.get("b64_json") or None + if not raw_b64 and item.get("url"): + raw_b64 = await _fetch_result_image_b64(item["url"]) + if not raw_b64 and result.get("image"): + raw_b64 = result["image"] + if not raw_b64: + raise HTTPException(502, "Image endpoint returned no image") + logger.info( + "%s timing model_ms=%.1f total_ms=%.1f request_id=%s", + label, _provider_ms, + (time.perf_counter() - _t0) * 1000.0, fields.get("request_id") or "-", + ) + payload: Dict[str, Any] = {"image": raw_b64} + if used_model: + payload["model"] = used_model + return payload + + def setup_gallery_routes() -> APIRouter: router = APIRouter(tags=["gallery"]) @@ -602,7 +1047,7 @@ def setup_gallery_routes() -> APIRouter: # Use img2img endpoint if available, otherwise upscale via canvas on client try: - async with httpx.AsyncClient(timeout=120) as client: + async with httpx.AsyncClient(timeout=300) as client: resp = await client.post(f"{base_url}/images/upscale", json={ "image": b64, "scale": scale, }) @@ -645,7 +1090,7 @@ def setup_gallery_routes() -> APIRouter: base_url += "/v1" try: - async with httpx.AsyncClient(timeout=180) as client: + async with httpx.AsyncClient(timeout=300) as client: resp = await client.post(f"{base_url}/images/generations", json={ "prompt": prompt, "image": b64, @@ -1291,6 +1736,10 @@ def setup_gallery_routes() -> APIRouter: if not ok: raise HTTPException(400, f"Rejected endpoint URL: {reason}") chosen_model = (body.pop("_model", "") or "").strip() + # Optional client-supplied id so GET /api/image/edit-progress can poll the + # provider's step progress for this op. Ignored by providers that do not + # implement the progress contract. + progress_request_id = (body.get("request_id") or "").strip() api_key = None if not requested_base: db = SessionLocal() @@ -1338,8 +1787,10 @@ def setup_gallery_routes() -> APIRouter: raise HTTPException(500, "Pillow not installed on server") try: - img_bytes = base64.b64decode(body["image"]) - mask_bytes = base64.b64decode(body["mask"]) + img_bytes = _b64_bytes(body.get("image")) + if not body.get("mask"): + raise HTTPException(400, "A mask is required for OpenAI-compatible image edits") + mask_bytes = _b64_bytes(body.get("mask"), label="mask") source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA") mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L") # luminance # Build OpenAI mask: RGBA where alpha=255 means keep, 0 means regenerate. @@ -1390,7 +1841,11 @@ def setup_gallery_routes() -> APIRouter: } headers = {"Authorization": f"Bearer {api_key}"} try: - async with httpx.AsyncClient(timeout=120) as client: + logger.debug( + "inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s", + body.get("prompt", ""), oa_model, size, base, + ) + async with httpx.AsyncClient(timeout=300) as client: if is_openrouter: # Reference-based editing, then composite locally so # pixels outside the user's mask remain unchanged. @@ -1410,7 +1865,16 @@ def setup_gallery_routes() -> APIRouter: r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files) if r.status_code != 200: logger.error("inpaint_proxy OpenAI edit: status %s", r.status_code) - raise HTTPException(r.status_code, "OpenAI edit failed") + _detail = "OpenAI edit failed" + try: + _j = r.json() + if isinstance(_j, dict): + _detail = _j.get("error") or _j.get("detail") or _detail + if isinstance(_detail, dict): + _detail = _detail.get("message") or _detail + except Exception: + pass + raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})") result = r.json() raw_b64 = None if result.get("data"): @@ -1441,13 +1905,16 @@ def setup_gallery_routes() -> APIRouter: blended = Image.composite(generated, source_png, mask_png) out_buf = io.BytesIO() blended.save(out_buf, format="PNG") - return {"image": base64.b64encode(out_buf.getvalue()).decode()} + return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model} except Exception as comp_err: # Never return a full-image edit when masking fails. logger.warning(f"Inpaint compose failed: {comp_err}") raise HTTPException(502, "Could not apply the edit within the selected region") except httpx.TimeoutException: - raise HTTPException(504, "OpenAI inpaint timed out (120s)") + raise HTTPException(504, "OpenAI inpaint timed out (300s)") + except httpx.TransportError as exc: + logger.warning("inpaint_proxy: OpenAI endpoint unreachable: %s", exc) + raise HTTPException(502, f"Image server unreachable: {type(exc).__name__}") # Self-hosted diffusion server path. Newer Odysseus image # wrappers expose the OpenAI-compatible /v1/images/edits @@ -1459,45 +1926,96 @@ def setup_gallery_routes() -> APIRouter: # supports multiple models per process. Harmless if ignored. if chosen_model: body["model"] = chosen_model - async with httpx.AsyncClient(timeout=240) as client: + async with httpx.AsyncClient(timeout=300) as client: + _t0 = time.perf_counter() + _restore_ms = [0.0] try: import base64, io from PIL import Image - img_bytes = base64.b64decode(body["image"]) - mask_bytes = base64.b64decode(body["mask"]) - # Normalize both inputs to PNG bytes. Local MLX and - # Diffusers wrappers expect white mask pixels to mean - # "edit this region", which matches the editor's mask. - source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA") - mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L") - src_buf = io.BytesIO() - source_png.save(src_buf, format="PNG") - mask_buf = io.BytesIO() - mask_png.save(mask_buf, format="PNG") + # Decode/resize/encode off the event loop: a 4000x3000 input + # costs ~0.2 s of CPU that would otherwise block every other + # request (and stack under concurrent edits). + _max_side = int(os.getenv("ODYSSEUS_IMAGE_EDIT_MAX_SIDE", "768") or 768) + _src_png, _mask_png, _model_w, _model_h, _orig_size = await asyncio.to_thread( + _prepare_local_edit, body.get("image"), body.get("mask"), _max_side + ) files = { - "image": ("source.png", src_buf.getvalue(), "image/png"), - "mask": ("mask.png", mask_buf.getvalue(), "image/png"), + "image": ("source.png", _src_png, "image/png"), + "mask": ("mask.png", _mask_png, "image/png"), } data = { "model": chosen_model or body.get("model") or "", "prompt": body.get("prompt", ""), - "size": f"{int(body.get('width') or source_png.width)}x{int(body.get('height') or source_png.height)}", + "size": f"{_model_w}x{_model_h}", "n": "1", } + if progress_request_id: + data["request_id"] = progress_request_id + # Let the caller's step budget through up to a cap (quality + # edits such as removal/background-replacement/expand pass 8 + # on purpose); only fall back to a moderate default when no + # count is requested. The size cap above is what keeps the + # op responsive. + _max_steps = int(os.getenv("ODYSSEUS_IMAGE_EDIT_MAX_STEPS", "8") or 8) + if body.get("steps"): + try: + data["steps"] = str(min(max(1, int(body["steps"])), _max_steps)) + except (TypeError, ValueError): + data["steps"] = "4" + else: + data["steps"] = "4" + + async def _restore_b64(_raw: str) -> str: + """Resize a provider PNG b64 back to the original size (off-loop).""" + if (_model_w, _model_h) == _orig_size: + return _raw + _rs = time.perf_counter() + try: + return await asyncio.to_thread( + _restore_local_b64, _raw, _orig_size, (_model_w, _model_h) + ) + finally: + _restore_ms[0] += (time.perf_counter() - _rs) * 1000.0 + logger.debug( + "inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r " + "size=%r steps=%r endpoint=%s", + data.get("prompt"), data.get("model"), data.get("size"), + data.get("steps"), base, + ) + _prep_ms = (time.perf_counter() - _t0) * 1000.0 + + def _finish(_payload: Dict[str, Any]) -> Dict[str, Any]: + # model_ms is the provider round-trip (its own queue + + # diffusion time); prep_ms is our decode/resize; restore_ms + # is resizing the result back. total_ms is the whole op. + logger.info( + "inpaint-proxy timing prep_ms=%.1f model_ms=%.1f restore_ms=%.1f " + "total_ms=%.1f size=%s steps=%s model=%s request_id=%s", + _prep_ms, _provider_ms[0], _restore_ms[0], + (time.perf_counter() - _t0) * 1000.0, + data.get("size"), data.get("steps"), data.get("model"), + progress_request_id or "-", + ) + return _payload + + _t_post = time.perf_counter() r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), data=data, files=files) + _provider_ms = [(time.perf_counter() - _t_post) * 1000.0] if r.status_code == 200: result = r.json() + _used_model = result.get("model") if isinstance(result, dict) else None + _used_model = _used_model or data.get("model") or "" if isinstance(result, dict) and result.get("data"): item = result["data"][0] if item.get("b64_json"): - return {"image": item["b64_json"]} + return _finish({"image": await _restore_b64(item["b64_json"]), "model": _used_model}) if item.get("url"): raw_b64 = await _fetch_result_image_b64(item["url"]) if raw_b64: - return {"image": raw_b64} + return _finish({"image": await _restore_b64(raw_b64), "model": _used_model}) if isinstance(result, dict) and result.get("image"): - return {"image": result["image"]} + return _finish({"image": await _restore_b64(result["image"]), "model": _used_model}) raise HTTPException(502, "Image edit endpoint returned no image") if r.status_code not in (404, 405): logger.warning("inpaint_proxy self-hosted edits: status %s", r.status_code) @@ -1528,16 +2046,158 @@ def setup_gallery_routes() -> APIRouter: r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body) if r.status_code != 200: logger.error("inpaint_proxy diffusion: status %s", r.status_code) - raise HTTPException(r.status_code, "Inpaint request failed") + detail = "Inpaint request failed" + try: + j = r.json() + if isinstance(j, dict): + detail = j.get("detail") or j.get("error") or detail + except Exception: + pass + raise HTTPException(r.status_code, f"{detail} (provider HTTP {r.status_code})") return r.json() except httpx.TimeoutException: raise HTTPException(504, "Inpaint request timed out (240s)") + except httpx.TransportError as exc: + logger.warning("inpaint_proxy: image endpoint unreachable: %s", exc) + raise HTTPException(502, f"Image server unreachable: {type(exc).__name__}") except HTTPException: raise except Exception: logger.exception("inpaint_proxy: request failed") raise HTTPException(502, "Inpaint request failed") + async def _resolve_and_decode(body: Dict[str, Any], user): + """Shared prep for the seneca single-image ops: validate endpoint + decode. + + Returns (base, api_key, image_bytes, chosen_model). The base64 decode + runs off the event loop (large images). + """ + requested_base = (body.pop("_endpoint", "") or "").rstrip("/") + if requested_base: + from src.url_safety import check_outbound_url + ok, reason = check_outbound_url( + requested_base, + block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") + chosen_model = (body.pop("_model", "") or "").strip() + db = SessionLocal() + try: + base, api_key = _resolve_image_op_endpoint(db, user, requested_base, chosen_model) + finally: + db.close() + image_bytes = await asyncio.to_thread(_b64_bytes, body.get("image")) + return base, api_key, image_bytes, chosen_model + + @router.post("/api/image/replace-background") + async def replace_background(request: Request): + """Proxy seneca's subject-mask background replacement (~/v1/images/replace-background).""" + user = require_privilege(request, "can_generate_images") + body = await request.json() + base, api_key, image_bytes, chosen_model = await _resolve_and_decode(body, user) + fields: Dict[str, str] = { + "prompt": str(body.get("prompt") or ""), + "steps": str(body.get("steps") or 4), + } + if body.get("request_id"): + fields["request_id"] = str(body["request_id"]).strip() + if body.get("harmonise") is not None: + fields["harmonise"] = "true" if body.get("harmonise") else "false" + if body.get("shadow") is not None: + fields["shadow"] = "true" if body.get("shadow") else "false" + if chosen_model: + fields["model"] = chosen_model + return await _forward_seneca_image_op( + base, api_key, "/images/replace-background", image_bytes, fields, + label="replace-background", + ) + + @router.post("/api/image/outpaint") + async def outpaint(request: Request): + """Proxy seneca's canvas outpaint/extend (~/v1/images/outpaint, alias /extend).""" + user = require_privilege(request, "can_generate_images") + body = await request.json() + base, api_key, image_bytes, chosen_model = await _resolve_and_decode(body, user) + fields: Dict[str, str] = { + "prompt": str(body.get("prompt") or ""), + "steps": str(body.get("steps") or 4), + } + for key in ("ratio", "size", "context"): + if body.get(key) is not None and str(body.get(key)) != "": + fields[key] = str(body[key]) + if body.get("request_id"): + fields["request_id"] = str(body["request_id"]).strip() + if chosen_model: + fields["model"] = chosen_model + return await _forward_seneca_image_op( + base, api_key, "/images/outpaint", image_bytes, fields, + label="outpaint", timeout=_outpaint_timeout(), + ) + + @router.post("/api/image/extend") + async def outpaint_extend(request: Request): + """Alias of /api/image/outpaint (the provider exposes /outpaint as /extend too).""" + return await outpaint(request) + + @router.get("/api/image/edit-progress") + async def edit_progress(request: Request): + """Poll a running image edit's provider-side step progress. + + Contract v1 (image-op progress): the provider exposes + ``GET /v1/images/progress/{request_id}`` -> ``{stage, step, steps, + eta_s, progress}``. This proxy resolves the configured image endpoint + (owner-scoped) and returns that JSON, or ``{"available": false}`` when + the provider has no progress route yet, so the client can keep an + indeterminate bar without breaking. + """ + import httpx + user = require_privilege(request, "can_generate_images") + request_id = (request.query_params.get("request_id") or "").strip() + if not request_id: + raise HTTPException(400, "Missing request_id") + # request_id is spliced into the outbound URL, so it must be a safe token + # (no path separators/host chars). The base itself is a DB-validated row. + if not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", request_id): + raise HTTPException(400, "Invalid request_id") + requested_base = (request.query_params.get("base") or "").strip().rstrip("/") + endpoint_id = (request.query_params.get("endpoint_id") or "").strip() + db = SessionLocal() + try: + ep = None + if endpoint_id: + ep = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id), + ModelEndpoint, user, + ).first() + if ep is None and requested_base: + ep = _visible_image_endpoint_for_base(db, requested_base, user) + if ep is None: + ep = _first_visible_image_endpoint(db, user) + if ep is None: + return {"available": False, "reason": "no image endpoint configured"} + base = ep.base_url.rstrip("/") + finally: + db.close() + if not base.endswith("/v1"): + base += "/v1" + # Dynamic path segment (validated token above), so not routed through + # _join_checked_gallery_endpoint's exact-match whitelist. + url = f"{base}/images/progress/{request_id}" + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(url) + if r.status_code == 200: + data = r.json() + if isinstance(data, dict): + data.setdefault("available", True) + return data + return {"available": True, "progress": data} + return {"available": False, "status": r.status_code} + except Exception as exc: + logger.debug("edit-progress poll failed: %s", exc) + return {"available": False, "reason": "provider unreachable"} + # ---- POST /api/image/harmonize — proper img2img call ---- # Earlier version routed through inpaint with a full-white mask, but # most backends interpret "100% mask coverage" as "regenerate from @@ -1689,10 +2349,10 @@ def setup_gallery_routes() -> APIRouter: headers["Authorization"] = f"Bearer {api_key}" last_err = None - # Cold-start SDXL inpaint can take 60-90s on first request (loading - # weights to GPU). 240s gives headroom for both that and a full - # 1024×1024 inference pass on slower setups. - async with httpx.AsyncClient(timeout=240) as client: + # Cold-start image servers can take 90-110s for a full pass (and + # 60-90s more on first request while weights load to GPU). 300s gives + # headroom for both on slower setups. + async with httpx.AsyncClient(timeout=300) as client: for path, kind, payload in candidates: _effective_base = base_root if path.startswith("/sdapi") else base target = _join_checked_gallery_endpoint(_effective_base, path) @@ -1755,7 +2415,7 @@ def setup_gallery_routes() -> APIRouter: from PIL import Image, ImageFilter import base64, io - img_bytes = base64.b64decode(image_b64) + img_bytes = _b64_bytes(image_b64) img = Image.open(io.BytesIO(img_bytes)).convert("RGB") # Unsharp mask: radius=2, percent=amount*200, threshold=3 @@ -1776,6 +2436,9 @@ def setup_gallery_routes() -> APIRouter: image_b64 = body.get("image") if not image_b64: raise HTTPException(400, "No image provided") + _proxied = await _route_heavy_image_op("/api/image/denoise", body, label="AI denoise") + if _proxied is not None: + return _proxied try: strength = float(body.get("strength", 0.5)) except Exception: @@ -1788,7 +2451,7 @@ def setup_gallery_routes() -> APIRouter: except ImportError: raise HTTPException(500, "Server missing a required dependency") # Decode source image (RGB; Real-ESRGAN doesn't preserve alpha). - img_bytes = base64.b64decode(image_b64) + img_bytes = _b64_bytes(image_b64) src = Image.open(io.BytesIO(img_bytes)).convert("RGB") try: patch_realesrgan_torchvision_compat() @@ -1827,6 +2490,9 @@ def setup_gallery_routes() -> APIRouter: image_b64 = body.get("image") if not image_b64: raise HTTPException(400, "No image provided") + _proxied = await _route_heavy_image_op("/api/image/upscale-local", body, label="AI upscale") + if _proxied is not None: + return _proxied try: scale = int(body.get("scale", 2)) except Exception: @@ -1838,7 +2504,7 @@ def setup_gallery_routes() -> APIRouter: import numpy as np except ImportError: raise HTTPException(500, "Server missing a required dependency") - img_bytes = base64.b64decode(image_b64) + img_bytes = _b64_bytes(image_b64) src = Image.open(io.BytesIO(img_bytes)).convert("RGB") try: patch_realesrgan_torchvision_compat() @@ -1865,6 +2531,74 @@ def setup_gallery_routes() -> APIRouter: logger.warning("AI upscale failed", exc_info=True) return {"error": "AI upscale failed"} + # ---- POST /api/image/generate ---- + @router.post("/api/image/generate") + async def generate_image(request: Request): + """Text-to-image generation for the editor's Quick Edit 'Image' mode. + + Generates a brand-new image from a prompt using the admin-configured + image model, returned as base64 so the client can drop it in as a layer. + """ + import json as _json + user = require_privilege(request, "can_generate_images") + try: + body = await request.json() + except Exception: + body = {} + prompt = str((body or {}).get("prompt") or "").strip() + if not prompt: + raise HTTPException(400, "A prompt is required") + try: + width = int((body or {}).get("width") or 0) + height = int((body or {}).get("height") or 0) + except (TypeError, ValueError): + width = height = 0 + if height > width: + size = "1024x1536" + elif width > height: + size = "1536x1024" + else: + size = "1024x1024" + try: + from src.ai_interaction import do_generate_image + except Exception: + raise HTTPException(502, "Image generation is unavailable") + # The editor's model picker may choose a specific (cloud) image model; + # forward it so settings defaults only apply when none is selected. + model_choice = str((body or {}).get("model") or "").strip() + payload = {"prompt": prompt, "size": size} + if model_choice: + payload["model"] = model_choice + content = _json.dumps(payload) + result = await do_generate_image(content, owner=user, incognito=True) + if not isinstance(result, dict) or result.get("error"): + msg = (result.get("error") if isinstance(result, dict) else None) or "Image generation failed" + low = str(msg).lower() + # Plain-language status codes the editor maps to fixed messages: + # 503 -> generation is switched off on the server + # 502 -> the image server could not be reached (or failed) + if "disabled" in low or "switched off" in low or "not enabled" in low: + raise HTTPException(503, "Image generation is switched off on the server at the moment.") + if "timed out" in low or "timeout" in low: + raise HTTPException(504, f"Image server timed out: {msg}") + if "connection" in low or "connect" in low or "unreachable" in low: + raise HTTPException(502, f"Image server unreachable: {msg}") + # A configured image model that cannot be resolved means its endpoint + # did not answer, i.e. the image server is down, not a bad prompt. + if "no endpoint found with image model" in low: + raise HTTPException(502, f"Image server unreachable: {msg}") + raise HTTPException(502, msg) + image_url = result.get("image_url") or "" + b64 = image_url.split(",", 1)[1] if image_url.startswith("data:") else image_url + if not b64: + raise HTTPException(502, "Image generation returned no image") + return { + "image": b64, + "prompt": prompt, + "model": result.get("image_model"), + "size": result.get("image_size") or size, + } + # ---- POST /api/image/remove-bg ---- @router.post("/api/image/mask") async def smart_mask(request: Request): @@ -1876,6 +2610,9 @@ def setup_gallery_routes() -> APIRouter: """ require_privilege(request, "can_generate_images") body = await request.json() + _proxied = await _remote_smart_mask(body) + if _proxied is not None: + return _proxied image = _b64_to_pil_image(body.get("image") or "", mode="RGB") points = body.get("points") or [] box = body.get("box") @@ -1889,6 +2626,7 @@ def setup_gallery_routes() -> APIRouter: if not points and not box: raise HTTPException(400, "Provide at least one point, box, or object text") + _image_op_log_rss("mask", "start") backend = _load_sam_backend() torch = backend["torch"] processor = backend["processor"] @@ -1985,6 +2723,8 @@ def setup_gallery_routes() -> APIRouter: } if grounded: result["grounding"] = grounded + _image_op_log_rss("mask", "end") + _unload_image_models() return result except HTTPException: raise @@ -2010,11 +2750,14 @@ def setup_gallery_routes() -> APIRouter: body = await request.json() image_b64 = body.get("image") hint_b64 = body.get("hint_mask") + _proxied = await _remote_remove_background(body) + if _proxied is not None: + return _proxied from PIL import Image import base64, io - img_bytes = base64.b64decode(image_b64) + img_bytes = _b64_bytes(image_b64) img = Image.open(io.BytesIO(img_bytes)).convert("RGBA") W, H = img.size @@ -2022,7 +2765,7 @@ def setup_gallery_routes() -> APIRouter: bbox = None if hint_b64: try: - hint_bytes = base64.b64decode(hint_b64) + hint_bytes = _b64_bytes(hint_b64, label="hint_mask") hint = Image.open(io.BytesIO(hint_bytes)).convert("L") # Resize the hint to match if dimensions disagree if hint.size != img.size: @@ -2046,6 +2789,7 @@ def setup_gallery_routes() -> APIRouter: else: crop = img + _image_op_log_rss("remove-bg", "start") try: from rembg import remove from starlette.concurrency import run_in_threadpool @@ -2061,6 +2805,9 @@ def setup_gallery_routes() -> APIRouter: cut = tmp except Exception: return {"error": "No background removal model available. Install rembg: pip install rembg"} + finally: + _image_op_log_rss("remove-bg", "end") + _unload_image_models() # Compose the cropped result back into a full-size transparent canvas. if bbox: @@ -2100,11 +2847,13 @@ def setup_gallery_routes() -> APIRouter: from PIL import Image, ImageFilter, ImageEnhance import numpy as np - img_bytes = base64.b64decode(image_b64) + img_bytes = _b64_bytes(image_b64) img = Image.open(io.BytesIO(img_bytes)).convert("RGB") # Try GFPGAN first (AI face restoration) try: + if not _local_image_models_enabled(): + raise ImportError("local image models disabled") from gfpgan import GFPGANer import cv2 diff --git a/routes/history/history_routes.py b/routes/history/history_routes.py index 64ccb3dea..a14be5908 100644 --- a/routes/history/history_routes.py +++ b/routes/history/history_routes.py @@ -7,13 +7,14 @@ import logging import re from typing import Dict, Any, Optional -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, Request, HTTPException, Depends from core.models import ChatMessage from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession -from src.auth_helpers import effective_user +from src.auth_helpers import effective_user, require_chat_api_token_scope from src.topic_analyzer import analyze_topics from src.upload_handler import reserve_message_upload_references +from src.tool_approval_scopes import sanitize_client_message_metadata from routes.session_routes import ( _message_role, _message_text, @@ -236,8 +237,23 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int | return None +def _effort_mode(model: str, raw_effort: Any) -> str: + """Session ``thinking_mode`` for a picked effort: ``effort:`` or ``off``. + + Validated like the chat routes do, so llama-swap advertised efforts persist + as well as ChatGPT subscription levels. + """ + from src.chatgpt_subscription import validate_reasoning_effort + + effort = validate_reasoning_effort(model, raw_effort) + return f"effort:{effort}" if effort else "off" + + def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: - router = APIRouter(tags=["history"]) + router = APIRouter( + tags=["history"], + dependencies=[Depends(require_chat_api_token_scope)], + ) def _reserve_message_uploads( request: Request, @@ -387,26 +403,53 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: async def truncate_session(request: Request, session_id: str): _verify_session_owner(request, session_id) try: - body = await request.json() - keep_count = int(body.get("keep_count", 0)) - before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip() + try: + body = await request.json() + except json.JSONDecodeError: + raise HTTPException(400, "Request body must be valid JSON") + if not isinstance(body, dict): + raise HTTPException(400, "Request body must be a JSON object") + # Resolve before_msg_id / message_id alias with explicit presence checks. + # None (JSON null) and empty string are both treated as "not provided here" + # and fall through to the alias, preserving the original OR-chain semantics. + # Any other non-string value (False, 0, True, 123, list, …) is rejected + # immediately so falsy non-strings cannot bypass type validation. + raw_msg_id = body.get("before_msg_id") if "before_msg_id" in body else None + if raw_msg_id is None or raw_msg_id == "": + raw_msg_id = body.get("message_id") + if raw_msg_id is not None and not isinstance(raw_msg_id, str): + raise HTTPException(400, "Message ID must be a string") + before_msg_id = (raw_msg_id or "").strip() + if "keep_count" not in body and not before_msg_id: + raise HTTPException(400, "keep_count or before_msg_id required") + raw_count = body.get("keep_count", 0) + # Keep integer-string clients working, but do not silently convert + # booleans or fractional numbers into destructive message counts. + if type(raw_count) not in (int, str): + raise HTTPException(400, "keep_count must be a non-negative integer") + try: + keep_count = int(raw_count) + except ValueError: + raise HTTPException(400, "keep_count must be a non-negative integer") + if keep_count < 0: + raise HTTPException(400, "keep_count must be a non-negative integer") deleted_sft_pairs: list[dict[str, str]] = [] - if keep_count >= 0: - db = SessionLocal() - try: - all_db_messages = db.query(DbChatMessage).filter( - DbChatMessage.session_id == session_id - ).order_by(DbChatMessage.timestamp).all() - if before_msg_id: - resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id) - if resolved_keep_count is None: - raise HTTPException(404, "Message not found") - keep_count = resolved_keep_count - for pos, row in enumerate(all_db_messages): - row._will_delete_for_sft = pos >= keep_count - deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages) - finally: - db.close() + # keep_count is always >= 0 here; the block is unconditional. + db = SessionLocal() + try: + all_db_messages = db.query(DbChatMessage).filter( + DbChatMessage.session_id == session_id + ).order_by(DbChatMessage.timestamp).all() + if before_msg_id: + resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id) + if resolved_keep_count is None: + raise HTTPException(404, "Message not found") + keep_count = resolved_keep_count + for pos, row in enumerate(all_db_messages): + row._will_delete_for_sft = pos >= keep_count + deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages) + finally: + db.close() result = session_manager.truncate_messages(session_id, keep_count) _remove_deleted_sft_trace_rows( owner=effective_user(request), @@ -432,7 +475,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: content = body.get("content", "") if not content: raise HTTPException(400, "content is required") - metadata = body.get("metadata") + metadata = sanitize_client_message_metadata(body.get("metadata")) _reserve_message_uploads(request, content, metadata) msg = ChatMessage(role=role, content=content, metadata=metadata) session_manager.add_message(session_id, msg) @@ -1027,26 +1070,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: mode = getattr(session, "thinking_mode", "off") or "off" raw_effort = body.get("reasoning_effort") if raw_effort is not None: - clean_effort = str(raw_effort).strip().lower() - if clean_effort in {"", "default"}: - mode = "off" - else: - from src.chatgpt_subscription import get_chatgpt_model_metadata - meta = get_chatgpt_model_metadata(session.model) - if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: - mode = f"effort:{clean_effort}" - else: - mode = "off" + mode = _effort_mode(session.model, raw_effort) elif "thinking_mode" in body: raw_mode = str(body.get("thinking_mode") or "").strip().lower() if raw_mode.startswith("effort:"): - clean_effort = raw_mode[7:].strip() - from src.chatgpt_subscription import get_chatgpt_model_metadata - meta = get_chatgpt_model_metadata(session.model) - if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: - mode = f"effort:{clean_effort}" - else: - mode = "off" + mode = _effort_mode(session.model, raw_mode[7:]) elif raw_mode in {"", "on", "off"}: mode = raw_mode from src.model_profiles import supports_user_thinking_toggle @@ -1125,7 +1153,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: ) # Use utility model if available - util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None) + from src.utility_effort import effort_for_call + util_url, util_model, util_headers = resolve_endpoint( + "utility", + fallback_url=getattr(session, "endpoint_url", None), + fallback_model=getattr(session, "model", None), + fallback_headers=getattr(session, "headers", None), + owner=owner or None, + ) compact_url = util_url or session.endpoint_url compact_model = util_model or session.model compact_headers = util_headers if util_url else session.headers @@ -1141,6 +1176,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: ], temperature=0.2, max_tokens=1024, headers=compact_headers, timeout=30, + reasoning_effort=effort_for_call(compact_url, compact_model, owner, session), ) summary = normalize_compaction_summary(summary) diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index 0e2b30276..5d14ba8c3 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -114,6 +114,18 @@ def _mcp_oauth_redirect_uri() -> str: return REDIRECT_URI +def _is_legacy_google_flow(identifier: str) -> bool: + """Determine whether an identifier is the server_id of a configured Google OAuth MCP server.""" + if not identifier: + return False + db = SessionLocal() + try: + srv = db.query(McpServer).filter(McpServer.id == identifier).first() + return bool(srv and srv.oauth_config) + finally: + db.close() + + def setup_mcp_routes(mcp_manager: McpManager): """Setup MCP routes with the provided manager.""" @@ -487,10 +499,14 @@ def setup_mcp_routes(mcp_manager: McpManager): db.close() @router.get("/oauth/callback") - async def oauth_callback(code: str, state: str, request: Request): + async def oauth_callback(code: str = "", state: str = "", request: Request = None): """Handle OAuth callback. Generic MCP OAuth flows resolve via the pending-state registry; Google flows fall through to the legacy path.""" - require_admin(request) + if not state: + return HTMLResponse(_oauth_result_page("Error", "No OAuth state parameter found in the callback URL."), status_code=400) + if not code: + return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400) + from src.mcp_oauth import resolve_pending if resolve_pending(state, code): return HTMLResponse(_oauth_result_page( @@ -498,8 +514,16 @@ def setup_mcp_routes(mcp_manager: McpManager): "The MCP server is connecting. You can close this window and return to Odysseus.", success=True, )) - # Legacy Google path: state is the server_id - return await _exchange_and_connect(state, code, request) + + # Check if this callback belongs to a legacy Google flow vs expired generic flow + if _is_legacy_google_flow(state): + require_admin(request) + return await _exchange_and_connect(state, code, request) + + return HTMLResponse(_oauth_result_page( + "Authorization Expired", + "This authorization round has expired. Press Reconnect in Odysseus and try again.", + ), status_code=400) @router.post("/oauth/exchange/{server_id}") async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)): @@ -514,18 +538,28 @@ def setup_mcp_routes(mcp_manager: McpManager): except Exception: return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400) + state = params.get("state", [None])[0] + if not state: + return HTMLResponse(_oauth_result_page("Error", "No OAuth state parameter found in the callback URL."), status_code=400) + # Generic MCP OAuth: if the pasted URL carries a state we are waiting on, # resolve it directly (the background connect finishes the handshake). - state = params.get("state", [None])[0] from src.mcp_oauth import resolve_pending - if state and resolve_pending(state, code): + if resolve_pending(state, code): return HTMLResponse(_oauth_result_page( "Authorization Successful", "The MCP server is connecting. You can close this window and return to Odysseus.", success=True, )) - return await _exchange_and_connect(server_id, code, request) + # Check if this exchange belongs to a legacy Google flow vs expired generic flow + if _is_legacy_google_flow(server_id): + return await _exchange_and_connect(server_id, code, request) + + return HTMLResponse(_oauth_result_page( + "Authorization Expired", + "This authorization round has expired. Press Reconnect in Odysseus and try again.", + ), status_code=400) async def _exchange_and_connect(server_id: str, code: str, request: Request): """Exchange auth code for tokens and connect the MCP server.""" diff --git a/routes/memory/memory_routes.py b/routes/memory/memory_routes.py index c4232bec4..7e163433d 100644 --- a/routes/memory/memory_routes.py +++ b/routes/memory/memory_routes.py @@ -261,6 +261,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM sess.endpoint_url, sess.model, sess.headers, owner=_owner(request) ) + from src.utility_effort import effort_for_call + try: suggestion_text = await llm_call_async( t_url, @@ -269,6 +271,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM temperature=0.2, max_tokens=500, headers=t_headers, + reasoning_effort=effort_for_call(t_url, t_model, _owner(request), sess), ) try: suggestions = json.loads(suggestion_text) @@ -349,6 +352,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM endpoint_url = None model = None headers = {} + sess = None user = _owner(request) @@ -453,6 +457,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM "Return ONLY valid JSON, no markdown fences." ) + from src.utility_effort import effort_for_call + try: raw = await llm_call_async( endpoint_url, @@ -464,6 +470,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM temperature=0.2, max_tokens=2000, headers=headers, + reasoning_effort=effort_for_call(endpoint_url, model, user, sess), ) # Parse JSON diff --git a/routes/model_routes.py b/routes/model_routes.py index 3466d4b9b..9749e0bbd 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -47,6 +47,7 @@ from src.endpoint_resolver import ( build_headers, ) from src.auth_helpers import _auth_disabled, effective_user, owner_filter +from src.url_safety import check_outbound_url logger = logging.getLogger(__name__) @@ -472,7 +473,7 @@ def _truthy(value: str | None) -> bool: _ENDPOINT_KINDS = {"auto", "local", "api", "proxy"} _REFRESH_MODES = {"auto", "manual", "disabled"} -_MODEL_TOOL_MODES = {"none", "compact", "full"} +_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"} _MODEL_TOOL_MODE_ALIASES = { "regular": "full", "odysseus_compact": "compact", @@ -552,10 +553,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float: val = 0 if val > 0: return float(max(1, min(60, val))) - # llama.cpp and other local OpenAI-compatible servers can block briefly - # while warming/loading. A 2s local timeout makes working endpoints flicker - # offline before /v1/models is ready. - return 10.0 if category == "local" else 2.0 + # Keep the background refresh short so a single dead endpoint cannot stall + # the model picker. A timed-out refresh never clears a non-empty model + # cache, so a briefly-warming local server is simply retried next round. + return 2.0 def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float: @@ -1510,6 +1511,9 @@ def setup_model_routes(model_discovery): _refresh_inflight = {"v": False, "done": None} # coarse single-flight guard _REFRESH_FAILURE_BASE = 300.0 _REFRESH_FAILURE_MAX = 3600.0 + # Even a user-triggered refresh must not re-probe an endpoint that just + # failed/timed out on every click; keep a short cooldown. + _REFRESH_FAILURE_MIN = 60.0 def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str: # Session-backed endpoints carry no static key; include their auth id @@ -1557,14 +1561,20 @@ def setup_model_routes(model_discovery): if mode in ("manual", "disabled") and not force: return False, info fails = int(state.get("fail_count") or 0) - if fails and not force: + if fails: last_failure = float(state.get("last_failure") or 0.0) empty_local = ( not cached and category == "local" and str(getattr(ep, "id", "") or "").startswith("local-") ) - if now - last_failure < _failure_delay(fails, empty_local=empty_local): + delay = _failure_delay(fails, empty_local=empty_local) + if force: + # A user-triggered refresh gets a short cooldown instead of the + # long background backoff, but still skips a just-failed + # endpoint so repeated clicks cannot stall the picker. + delay = min(delay, _REFRESH_FAILURE_MIN) + if now - last_failure < delay: return False, info if cached and not force: interval = _endpoint_refresh_interval(ep, category) @@ -2163,6 +2173,19 @@ def setup_model_routes(model_discovery): # keep those container-local when the frontend marks them as such. base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local)) + # SSRF hardening: validate the user-supplied URL before any outbound + # request. Local-first means loopback/LAN endpoints are allowed by + # default; non-HTTP(S) schemes and the cloud metadata range are always + # rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown. + # Note: the outbound fetches rely on httpx's default + # follow_redirects=False — a redirect target is never dereferenced. + ok, reason = check_outbound_url( + base_url, + block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") + is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" # Auto-generate name from URL if not provided if not name.strip(): @@ -2399,6 +2422,16 @@ def setup_model_routes(model_discovery): from src.endpoint_resolver import resolve_url base_url = resolve_url(base_url) base_url = _rewrite_loopback_for_docker(base_url) + # SSRF hardening: validate the user-supplied URL before any outbound + # request. Local-first means loopback/LAN endpoints are allowed by + # default; non-HTTP(S) schemes and the cloud metadata range are always + # rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown. + ok, reason = check_outbound_url( + base_url, + block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") requested_kind = _normalize_endpoint_kind(endpoint_kind) is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" if is_featherless and requested_kind == "auto": @@ -2889,6 +2922,16 @@ def setup_model_routes(model_discovery): _new_base = _new_base[: -len(_suffix)].rstrip("/") _new_base = _normalize_base(_new_base) if _new_base: + # SSRF hardening: PATCH rewrites the stored base_url, so + # it needs the same guard as POST create/test — no + # bypass through the update path. Follow-up fetches + # rely on httpx's default follow_redirects=False. + ok, reason = check_outbound_url( + _new_base, + block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") ep.base_url = _new_base else: ep.is_enabled = not ep.is_enabled @@ -3055,4 +3098,10 @@ def setup_model_routes(model_discovery): router._should_refresh_endpoint = _should_refresh_endpoint router._search_endpoint_catalog = search_endpoint_catalog + # Exposed for tests: the in-memory backoff state, its key function, and the + # background refresh driver (probes endpoints in parallel with the short + # per-endpoint timeout). + router._refresh_state = _refresh_state + router._refresh_key = _refresh_key + router._refresh_caches_bg = _refresh_caches_bg return router diff --git a/routes/search/search_routes.py b/routes/search/search_routes.py index 5aafa03cb..a63607649 100644 --- a/routes/search/search_routes.py +++ b/routes/search/search_routes.py @@ -43,11 +43,22 @@ def setup_search_routes(config) -> APIRouter: router = APIRouter(tags=["search"]) @router.get("/search/web", response_class=HTMLResponse) - async def web_search_page(q: str = Query("", min_length=0)) -> HTMLResponse: + async def web_search_page(request: Request, q: str = Query("", min_length=0)) -> HTMLResponse: """Browser-facing search results page for clickable agent web_search rows.""" + # The site CSP allows inline script only with the per-request nonce + # (SecurityHeadersMiddleware); without it this page's script never ran. + nonce = html.escape(getattr(request.state, "csp_nonce", "") or "", quote=True) safe_q = str(q or "").strip() title = html.escape(safe_q or "Web search") - q_json = json.dumps(safe_q) + # json.dumps leaves `<`, `>` and `&` alone, so q=', + '' + ].join('\n'); + } else if (uid === 'long_msg') { + body_html = `

${'A'.repeat(5000)}

`; + } else { + body_html = `

Standard message body for ${uid}

`; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + uid: uid, + subject: `Subject ${uid}`, + from_name: 'Sender', + from_address: 'sender@example.com', + to: 'user@example.com', + date: '2026-10-20', + date_display: 'Oct 20, 2026', + body: `Plain text for ${uid}`, + body_html: body_html, + })); + return; + } + + let filePath = path.join(ROOT, url.pathname); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = path.extname(filePath); + const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain'; + res.writeHead(200, { 'Content-Type': mime }); + res.end(fs.readFileSync(filePath)); + return; + } + res.writeHead(404); + res.end('Not found'); + }); + + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + const origin = `http://127.0.0.1:${port}`; + + const launchOpts = { headless: true }; + const chromePath = findChromiumPath(); + if (chromePath) launchOpts.executablePath = chromePath; + + let browser; + try { + browser = await chromium.launch(launchOpts); + } catch (launchErr) { + throw new Error( + `Failed to launch Chromium browser: ${launchErr.message}\n` + + 'Missing or unexecutable Chromium binary.\n' + + 'To install Playwright Chromium, run:\n' + + ' npx playwright install chromium\n' + + 'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.' + ); + } + const context = await browser.newContext(); + const page = await context.newPage(); + + const results = {}; + + try { + await page.goto(`${origin}/static/index.html`); + await page.evaluate(() => { + if (!document.getElementById('email-lib-bulk-actions')) { + const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d); + } + if (!document.getElementById('email-lib-selected-count')) { + const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d); + } + }); + + // TEST 1: Full Print Image Sanitization and Replacement + const printImageResult = await page.evaluate(async (base) => { + window.API_BASE = base; + const mod = await import(`${base}/static/js/emailLibrary/index.js`); + const stateMod = await import(`${base}/static/js/emailLibrary/state.js`); + + stateMod.state._selectedUids = new Set(['img_test']); + stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }]; + + let iframePrintInvoked = false; + let printDocHtml = ''; + let afterPrintTriggered = false; + let hasPlaceholderElement = false; + + const observer = new MutationObserver(() => { + const frame = document.getElementById('email-browser-print-frame'); + if (frame && frame.contentWindow && !frame._patched) { + frame._patched = true; + frame.contentWindow.print = function() { + iframePrintInvoked = true; + printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : ''; + const body = frame.contentDocument ? frame.contentDocument.body : null; + hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder'); + frame.contentWindow.dispatchEvent(new Event('afterprint')); + afterPrintTriggered = true; + }; + } + }); + observer.observe(document.body, { childList: true }); + + await mod._exportSelectedEmailPdf(); + observer.disconnect(); + + const frameAfter = document.getElementById('email-browser-print-frame'); + + return { + iframePrintInvoked, + afterPrintTriggered, + frameRemovedAfterPrint: frameAfter === null, + hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'), + hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'), + hasNoAltNotice: printDocHtml.includes('[Image omitted]'), + hasPlaceholderElement, + hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'), + hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'), + hasInteractiveActionBtn: printDocHtml.includes('btn-action'), + hasXSS: printDocHtml.includes('